Skip to content
This repository was archived by the owner on Apr 28, 2026. It is now read-only.

Commit 3a5c2bc

Browse files
AsiaOstrichclaude
andcommitted
feat(core): Add ActivationPredicate for stigmergic coordination (DEC-011). 新增 ActivationPredicate 動態激活條件支援 stigmergic 協調模式。
Add ActivationPredicate to Task interface enabling dynamic activation conditions beyond static depends_on. Supports three predicate types: threshold (metric comparison), state_flag (task status check), and custom (shell command). Plan validator enforces schema and semantic validation including dangerous command detection. 新增 ActivationPredicate 至 Task 介面,支援三種動態激活條件類型: - threshold:比較前置任務的 TaskResult.metrics 度量閾值 - state_flag:檢查指定任務的 TaskStatus 是否符合預期 - custom:執行 shell 指令,exit code 0 表示條件滿足 修改檔案: - types.ts:新增 ActivationPredicate、ComparisonOperator、Task.activationPredicate、TaskResult.metrics - plan-validator.ts:JSON Schema 擴充 + 語義驗證(threshold/state_flag/custom) - orchestrator.ts:executeOneTask 加入 evaluateActivationPredicate 評估邏輯 - plan-validator.test.ts:+15 個測試(AC-011-003~006, 010, 013) - orchestrator.test.ts:+12 個測試(AC-011-007~011, 014) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f096311 commit 3a5c2bc

5 files changed

Lines changed: 764 additions & 0 deletions

File tree

packages/core/src/orchestrator.test.ts

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,3 +761,335 @@ describe("orchestrate(Checkpoint)", () => {
761761
expect(report.summary.total_tasks).toBe(1);
762762
});
763763
});
764+
765+
// ============================================================
766+
// DEC-011: Stigmergic Coordination — ActivationPredicate 評估
767+
// [Source] specs/DEC-011-stigmergic-coordination.md
768+
// ============================================================
769+
770+
describe("DEC-011: ActivationPredicate 評估", () => {
771+
describe("[AC-011-007] threshold 類型評估", () => {
772+
it("[Source] 條件不滿足時 task 被 skip", async () => {
773+
const adapter = createMockAdapter({
774+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => ({
775+
task_id: task.id,
776+
status: "success",
777+
cost_usd: 0.1,
778+
// T-001 回傳 metrics,fail_rate = 0.1(不超過 0.3)
779+
metrics: { fail_rate: 0.1 },
780+
})),
781+
});
782+
783+
const plan: TaskPlan = {
784+
project: "test",
785+
tasks: [
786+
{ id: "T-001", title: "Run tests", spec: "run all tests" },
787+
{
788+
id: "T-002",
789+
title: "Refactor",
790+
spec: "refactor if fail rate high",
791+
depends_on: ["T-001"],
792+
activationPredicate: {
793+
type: "threshold",
794+
metric: "fail_rate",
795+
operator: ">",
796+
value: 0.3,
797+
description: "失敗率超過 30% 才觸發重構",
798+
},
799+
},
800+
],
801+
};
802+
803+
const report = await orchestrate(plan, adapter, defaultOptions);
804+
805+
expect(report.tasks[1].status).toBe("skipped");
806+
expect(report.tasks[1].error).toContain("activation predicate not met");
807+
expect(report.tasks[1].error).toContain("失敗率超過 30% 才觸發重構");
808+
// adapter 只被呼叫一次(T-001),T-002 被 skip 不執行
809+
expect(adapter.executeTask).toHaveBeenCalledTimes(1);
810+
});
811+
812+
it("[Source] 條件滿足時 task 正常執行", async () => {
813+
let callCount = 0;
814+
const adapter = createMockAdapter({
815+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => {
816+
callCount++;
817+
if (task.id === "T-001") {
818+
return {
819+
task_id: task.id,
820+
status: "success",
821+
cost_usd: 0.1,
822+
metrics: { fail_rate: 0.5 }, // 超過 0.3
823+
};
824+
}
825+
return { task_id: task.id, status: "success", cost_usd: 0.1 };
826+
}),
827+
});
828+
829+
const plan: TaskPlan = {
830+
project: "test",
831+
tasks: [
832+
{ id: "T-001", title: "Run tests", spec: "run all tests" },
833+
{
834+
id: "T-002",
835+
title: "Refactor",
836+
spec: "refactor",
837+
depends_on: ["T-001"],
838+
activationPredicate: {
839+
type: "threshold",
840+
metric: "fail_rate",
841+
operator: ">",
842+
value: 0.3,
843+
description: "失敗率超過 30%",
844+
},
845+
},
846+
],
847+
};
848+
849+
const report = await orchestrate(plan, adapter, defaultOptions);
850+
851+
expect(report.tasks[1].status).toBe("success");
852+
expect(adapter.executeTask).toHaveBeenCalledTimes(2);
853+
});
854+
855+
it("[Derived] 前置任務無 metrics 時條件不滿足 → skip", async () => {
856+
const adapter = createMockAdapter({
857+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => ({
858+
task_id: task.id,
859+
status: "success",
860+
cost_usd: 0.1,
861+
// 無 metrics 欄位
862+
})),
863+
});
864+
865+
const plan: TaskPlan = {
866+
project: "test",
867+
tasks: [
868+
{ id: "T-001", title: "A", spec: "X" },
869+
{
870+
id: "T-002",
871+
title: "B",
872+
spec: "Y",
873+
depends_on: ["T-001"],
874+
activationPredicate: {
875+
type: "threshold",
876+
metric: "fail_rate",
877+
operator: ">",
878+
value: 0.3,
879+
description: "需要 fail_rate 度量",
880+
},
881+
},
882+
],
883+
};
884+
885+
const report = await orchestrate(plan, adapter, defaultOptions);
886+
887+
expect(report.tasks[1].status).toBe("skipped");
888+
expect(report.tasks[1].error).toContain("activation predicate not met");
889+
});
890+
});
891+
892+
describe("[AC-011-008] state_flag 類型評估", () => {
893+
it("[Source] 條件不滿足時 task 被 skip", async () => {
894+
const adapter = createMockAdapter({
895+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => ({
896+
task_id: task.id,
897+
status: "success", // T-001 是 success,不是 failed
898+
cost_usd: 0.1,
899+
})),
900+
});
901+
902+
const plan: TaskPlan = {
903+
project: "test",
904+
tasks: [
905+
{ id: "T-001", title: "Run tests", spec: "run tests" },
906+
{
907+
id: "T-002",
908+
title: "Fix",
909+
spec: "fix if failed",
910+
depends_on: ["T-001"],
911+
activationPredicate: {
912+
type: "state_flag",
913+
taskId: "T-001",
914+
expectedStatus: "failed",
915+
description: "T-001 失敗時才執行修復",
916+
},
917+
},
918+
],
919+
};
920+
921+
const report = await orchestrate(plan, adapter, defaultOptions);
922+
923+
expect(report.tasks[1].status).toBe("skipped");
924+
});
925+
926+
it("[Source] 條件滿足時 task 正常執行", async () => {
927+
const adapter = createMockAdapter({
928+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => {
929+
if (task.id === "T-001") {
930+
return { task_id: task.id, status: "done_with_concerns", cost_usd: 0.1, concerns: ["perf"] };
931+
}
932+
return { task_id: task.id, status: "success", cost_usd: 0.1 };
933+
}),
934+
});
935+
936+
const plan: TaskPlan = {
937+
project: "test",
938+
tasks: [
939+
{ id: "T-001", title: "A", spec: "X" },
940+
{
941+
id: "T-002",
942+
title: "Review concerns",
943+
spec: "review",
944+
depends_on: ["T-001"],
945+
activationPredicate: {
946+
type: "state_flag",
947+
taskId: "T-001",
948+
expectedStatus: "done_with_concerns",
949+
description: "T-001 有疑慮時才審查",
950+
},
951+
},
952+
],
953+
};
954+
955+
const report = await orchestrate(plan, adapter, defaultOptions);
956+
957+
expect(report.tasks[1].status).toBe("success");
958+
expect(adapter.executeTask).toHaveBeenCalledTimes(2);
959+
});
960+
});
961+
962+
describe("[AC-011-009] custom 類型評估", () => {
963+
it("[Source] 指令回傳非零時 task 被 skip", async () => {
964+
const adapter = createMockAdapter();
965+
966+
const plan: TaskPlan = {
967+
project: "test",
968+
tasks: [
969+
{ id: "T-001", title: "A", spec: "X" },
970+
{
971+
id: "T-002",
972+
title: "Conditional",
973+
spec: "Y",
974+
depends_on: ["T-001"],
975+
activationPredicate: {
976+
type: "custom",
977+
command: "test -f nonexistent_file_that_does_not_exist",
978+
description: "檔案存在時才執行",
979+
},
980+
},
981+
],
982+
};
983+
984+
const report = await orchestrate(plan, adapter, defaultOptions);
985+
986+
expect(report.tasks[1].status).toBe("skipped");
987+
expect(report.tasks[1].error).toContain("activation predicate not met");
988+
});
989+
990+
it("[Source] 指令回傳零時 task 正常執行", async () => {
991+
const adapter = createMockAdapter();
992+
993+
const plan: TaskPlan = {
994+
project: "test",
995+
tasks: [
996+
{ id: "T-001", title: "A", spec: "X" },
997+
{
998+
id: "T-002",
999+
title: "Conditional",
1000+
spec: "Y",
1001+
depends_on: ["T-001"],
1002+
activationPredicate: {
1003+
type: "custom",
1004+
command: "true", // 永遠回傳 0
1005+
description: "永遠通過",
1006+
},
1007+
},
1008+
],
1009+
};
1010+
1011+
// 使用實際存在的目錄(custom command 需要 cwd 存在)
1012+
const report = await orchestrate(plan, adapter, { cwd: "/tmp" });
1013+
1014+
expect(report.tasks[1].status).toBe("success");
1015+
expect(adapter.executeTask).toHaveBeenCalledTimes(2);
1016+
});
1017+
});
1018+
1019+
describe("[AC-011-010] 向後相容", () => {
1020+
it("[Source] 無 activationPredicate 時行為不變", async () => {
1021+
const adapter = createMockAdapter();
1022+
const report = await orchestrate(simplePlan, adapter, defaultOptions);
1023+
1024+
expect(report.summary.total_tasks).toBe(3);
1025+
expect(report.summary.succeeded).toBe(3);
1026+
expect(report.summary.skipped).toBe(0);
1027+
});
1028+
1029+
it("[Derived] 並行模式無 activationPredicate 時行為不變", async () => {
1030+
const adapter = createMockAdapter();
1031+
const report = await orchestrate(simplePlan, adapter, {
1032+
...defaultOptions,
1033+
parallel: true,
1034+
});
1035+
1036+
expect(report.summary.total_tasks).toBe(3);
1037+
expect(report.summary.succeeded).toBe(3);
1038+
});
1039+
});
1040+
1041+
describe("[AC-011-011] TaskResult.metrics 欄位", () => {
1042+
it("[Source] adapter 回傳的 metrics 應保留在 TaskResult 中", async () => {
1043+
const adapter = createMockAdapter({
1044+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => ({
1045+
task_id: task.id,
1046+
status: "success",
1047+
cost_usd: 0.1,
1048+
metrics: { test_coverage: 0.85, fail_rate: 0.05 },
1049+
})),
1050+
});
1051+
1052+
const plan: TaskPlan = {
1053+
project: "test",
1054+
tasks: [{ id: "T-001", title: "A", spec: "X" }],
1055+
};
1056+
1057+
const report = await orchestrate(plan, adapter, defaultOptions);
1058+
1059+
expect(report.tasks[0].metrics).toBeDefined();
1060+
expect(report.tasks[0].metrics!.test_coverage).toBe(0.85);
1061+
expect(report.tasks[0].metrics!.fail_rate).toBe(0.05);
1062+
});
1063+
1064+
it("[Source] 無 metrics 時欄位為 undefined", async () => {
1065+
const adapter = createMockAdapter();
1066+
1067+
const plan: TaskPlan = {
1068+
project: "test",
1069+
tasks: [{ id: "T-001", title: "A", spec: "X" }],
1070+
};
1071+
1072+
const report = await orchestrate(plan, adapter, defaultOptions);
1073+
1074+
expect(report.tasks[0].metrics).toBeUndefined();
1075+
});
1076+
});
1077+
1078+
describe("[AC-011-014] 既有測試零回歸", () => {
1079+
it("[Derived] 依賴失敗仍然 skip(不受 predicate 影響)", async () => {
1080+
const adapter = createMockAdapter({
1081+
executeTask: vi.fn(async (task: Task): Promise<TaskResult> => {
1082+
if (task.id === "T-001") {
1083+
return { task_id: task.id, status: "failed", error: "compile error" };
1084+
}
1085+
return { task_id: task.id, status: "success" };
1086+
}),
1087+
});
1088+
1089+
const report = await orchestrate(simplePlan, adapter, defaultOptions);
1090+
1091+
expect(report.summary.failed).toBe(1);
1092+
expect(report.summary.skipped).toBe(2);
1093+
});
1094+
});
1095+
});

0 commit comments

Comments
 (0)