@guanlin4924/devflow-pi 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +178 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +283 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +157 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +131 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +148 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +29 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +8 -2
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
|
|
9
|
+
type SubagentLifecyclePayload,
|
|
10
|
+
} from "@oh-my-pi/pi-coding-agent/task";
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
export type DevflowIntent = "idle" | "explain-state" | "project-init" | "requirement-draft" | "workflow-execute";
|
|
@@ -448,6 +452,7 @@ const REQUIRED_SCRIPTS = [
|
|
|
448
452
|
"workflow-orchestrator/scripts/tdd_evidence.py",
|
|
449
453
|
"workflow-orchestrator/scripts/acceptance_test_runner.py",
|
|
450
454
|
"workflow-orchestrator/scripts/machine_evidence.rb",
|
|
455
|
+
"workflow-orchestrator/scripts/cross_track_consistency_check.rb",
|
|
451
456
|
"workflow-orchestrator/scripts/execution_contract.rb",
|
|
452
457
|
"workflow-code-index/scripts/code_index.py",
|
|
453
458
|
"workflow-context-sync/scripts/context_sync_cursor.py",
|
|
@@ -760,12 +765,24 @@ function workflowDirective(snapshot: Record<string, unknown>, source: "start" |
|
|
|
760
765
|
recovery: snapshot.recovery,
|
|
761
766
|
requirement_draft: requirementDraft,
|
|
762
767
|
};
|
|
768
|
+
const parallelInstructions = snapshot.action === "run_parallel_window"
|
|
769
|
+
? [
|
|
770
|
+
"这是并行窗口:先按 parallel_run 中的 branch 记录逐个调用 task;一个 task 只能负责一个 workflow_step,不得把多个 step 合并到一个子 Agent。",
|
|
771
|
+
"本窗口的 branch step(backend-tech-spec、api-call-guide、ui-spec、feature-breakdown、frontend-tech-spec、api-supplement)统一使用白名单 agent implementation-worker;不要把 workflow step 名称当作 agent 名称。",
|
|
772
|
+
"只有执行验证类步骤才使用 verification-runner;project-scout、requirements-auditor、contract-auditor、devflow-code-reviewer 不属于本并行窗口的默认执行 agent。",
|
|
773
|
+
"每个 task 的提示必须原样包含 DEVFLOW_BRANCH_ID、DEVFLOW_WORKFLOW_STEP、DEVFLOW_TRACK、DEVFLOW_ATTEMPT 和 DEVFLOW_BRANCH_REVISION,并只写该分支的白名单产物。",
|
|
774
|
+
"子 Agent 完成后只返回产物和验证证据;不得调用 devflow_transition,不得自行声明 checkpoint 或 join。主 Agent 负责根据生命周期结果继续调度,Core 负责最终门禁。",
|
|
775
|
+
"如果生命周期 status=aborted,按 failed 处理:先调用 Core 的 branch fail,再根据恢复信息调用 retry;不得把 aborted 当作成功或忽略,否则 join 会永久等待。",
|
|
776
|
+
]
|
|
777
|
+
: [];
|
|
763
778
|
return [
|
|
764
779
|
"<devflow-runtime-directive>",
|
|
765
780
|
"Devflow 已通过启动预检。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。",
|
|
766
781
|
"先调用 devflow_state(action=\"inspect\") 重新确认 revision,再由 workflow-orchestrator 路由当前步骤。",
|
|
767
782
|
"只按 inspector 返回的当前 step 加载所需 Skill;不得调用 devflow_transition、不得推进下一步骤、不得替用户通过人工 checkpoint。",
|
|
768
|
-
|
|
783
|
+
...(parallelInstructions.length > 0 ? parallelInstructions : [
|
|
784
|
+
"完成本步骤的真实产物后,报告证据、未决项和验证结果并停止。请用户审查后明确回复“批准执行”才可推进;回复修改意见或“拒绝执行”则不得推进。",
|
|
785
|
+
]),
|
|
769
786
|
"当前步骤信封:",
|
|
770
787
|
JSON.stringify(envelope, null, 2),
|
|
771
788
|
"</devflow-runtime-directive>",
|
|
@@ -820,6 +837,15 @@ function inputPaths(input: unknown, cwd: string): string[] {
|
|
|
820
837
|
.filter((value): value is string => value !== null);
|
|
821
838
|
}
|
|
822
839
|
|
|
840
|
+
function parallelBranchContext(input: unknown): { branch_id: string; attempt: number; branch_revision: number } | null {
|
|
841
|
+
const text = typeof input === "string" ? input : JSON.stringify(input ?? {});
|
|
842
|
+
const branch = text.match(/DEVFLOW_BRANCH_ID\s*[:=]\s*([^\s,}"']+)/)?.[1];
|
|
843
|
+
const attempt = text.match(/DEVFLOW_ATTEMPT\s*[:=]\s*(\d+)/)?.[1];
|
|
844
|
+
const revision = text.match(/DEVFLOW_BRANCH_REVISION\s*[:=]\s*(\d+)/)?.[1];
|
|
845
|
+
if (!branch || attempt === undefined || revision === undefined) return null;
|
|
846
|
+
return { branch_id: branch, attempt: Number(attempt), branch_revision: Number(revision) };
|
|
847
|
+
}
|
|
848
|
+
|
|
823
849
|
function isControlPath(filePath: string): boolean {
|
|
824
850
|
return CONTROL_PATHS.includes(filePath) || CONTROL_PREFIXES.some(prefix => filePath.startsWith(prefix));
|
|
825
851
|
}
|
|
@@ -864,6 +890,31 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
864
890
|
let controlState: SessionControlState = initialControlState();
|
|
865
891
|
let activeContract: ActiveExecutionContract | undefined;
|
|
866
892
|
let contractAgentCalls = 0;
|
|
893
|
+
let projectRoot: string | undefined;
|
|
894
|
+
|
|
895
|
+
async function appendEngineEvidence(cwd: string | undefined, event: string, extra: Record<string, unknown> = {}) {
|
|
896
|
+
if (!cwd || !activeContract) return;
|
|
897
|
+
const feature = activeContract.feature;
|
|
898
|
+
const step = activeContract.step;
|
|
899
|
+
if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
|
|
900
|
+
const directory = path.join(cwd, ".planning", "evidence", feature);
|
|
901
|
+
const attempt = typeof extra.attempt === "number" && Number.isInteger(extra.attempt) && extra.attempt >= 0
|
|
902
|
+
? extra.attempt
|
|
903
|
+
: undefined;
|
|
904
|
+
const destination = path.join(directory, `${step}${attempt === undefined ? "" : `.attempt-${attempt}`}.engine-events.jsonl`);
|
|
905
|
+
await mkdir(directory, { recursive: true });
|
|
906
|
+
await appendFile(destination, `${JSON.stringify({
|
|
907
|
+
schema_version: 1,
|
|
908
|
+
timestamp: new Date().toISOString(),
|
|
909
|
+
session_id: sessionId,
|
|
910
|
+
feature,
|
|
911
|
+
step,
|
|
912
|
+
event,
|
|
913
|
+
...(attempt === undefined ? {} : { attempt, branch_id: extra.branch_id }),
|
|
914
|
+
collector: "devflow-runtime/harness",
|
|
915
|
+
...extra,
|
|
916
|
+
})}\n`, "utf8");
|
|
917
|
+
}
|
|
867
918
|
|
|
868
919
|
function controlGuide(state: SessionControlState): string[] {
|
|
869
920
|
const title = "Devflow 操作指引";
|
|
@@ -1302,6 +1353,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1302
1353
|
}
|
|
1303
1354
|
|
|
1304
1355
|
let activeWorkflowTurn = false;
|
|
1356
|
+
let parallelRunActive = false;
|
|
1357
|
+
let parallelContinuationQueued = false;
|
|
1358
|
+
const pendingParallelLifecycle = new Map<string, string>();
|
|
1359
|
+
const parallelTaskContexts = new Map<string, { branch_id: string; attempt: number; branch_revision: number }>();
|
|
1305
1360
|
let pendingProjectInitialization: ProjectInitializationRequest | undefined;
|
|
1306
1361
|
let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
|
|
1307
1362
|
|
|
@@ -1330,14 +1385,87 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1330
1385
|
pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
|
|
1331
1386
|
}
|
|
1332
1387
|
|
|
1388
|
+
function parallelBranches(snapshot: Record<string, unknown>): Array<{ workflow_step: string; track: string }> {
|
|
1389
|
+
const window = snapshot.parallel_window;
|
|
1390
|
+
if (!window || typeof window !== "object") return [];
|
|
1391
|
+
const branches = (window as Record<string, unknown>).branches;
|
|
1392
|
+
if (!branches || typeof branches !== "object") return [];
|
|
1393
|
+
return Object.entries(branches as Record<string, unknown>).flatMap(([track, steps]) => {
|
|
1394
|
+
if (!Array.isArray(steps)) return [];
|
|
1395
|
+
return steps.flatMap(step => {
|
|
1396
|
+
const workflowStep = typeof step === "string"
|
|
1397
|
+
? step
|
|
1398
|
+
: step && typeof step === "object" ? String((step as Record<string, unknown>).id ?? "") : "";
|
|
1399
|
+
return workflowStep ? [{ workflow_step: workflowStep, track }] : [];
|
|
1400
|
+
});
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
async function prepareParallelRun(cwd: string, snapshot: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
1405
|
+
if (snapshot.action !== "run_parallel_window") return snapshot;
|
|
1406
|
+
const feature = String(snapshot.current_feature ?? "");
|
|
1407
|
+
const window = snapshot.parallel_window as Record<string, unknown> | undefined;
|
|
1408
|
+
const workflowWindow = String(window?.id ?? "");
|
|
1409
|
+
const existing = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/parallel_run.rb", [
|
|
1410
|
+
"--action", "inspect", "--feature", feature, "--json",
|
|
1411
|
+
]));
|
|
1412
|
+
if (existing?.ok === true && existing.run) {
|
|
1413
|
+
parallelRunActive = true;
|
|
1414
|
+
return { ...snapshot, parallel_run: existing.run };
|
|
1415
|
+
}
|
|
1416
|
+
const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
|
|
1417
|
+
if (!feature || !workflowWindow || revision === undefined) throw new Error("parallel window snapshot is incomplete");
|
|
1418
|
+
const branches = parallelBranches(snapshot);
|
|
1419
|
+
if (branches.length === 0) throw new Error("parallel window contains no branch steps");
|
|
1420
|
+
const prepared = await runRuby(cwd, "workflow-orchestrator/scripts/parallel_run.rb", [
|
|
1421
|
+
"--action", "prepare", "--feature", feature, "--workflow-window", workflowWindow,
|
|
1422
|
+
"--state-revision", String(revision), "--json",
|
|
1423
|
+
...branches.flatMap(branch => ["--branch", `${branch.workflow_step}:${branch.track}`]),
|
|
1424
|
+
]);
|
|
1425
|
+
const payload = parseJsonResult(prepared);
|
|
1426
|
+
if (!controlPlaneSucceeded(prepared) || !payload?.run) {
|
|
1427
|
+
throw new Error(prepared.stderr || prepared.stdout || "parallel run prepare failed");
|
|
1428
|
+
}
|
|
1429
|
+
parallelRunActive = true;
|
|
1430
|
+
return { ...snapshot, parallel_run: payload.run };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1333
1433
|
async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
|
|
1434
|
+
if (snapshot.action !== "run_parallel_window") {
|
|
1435
|
+
parallelRunActive = false;
|
|
1436
|
+
parallelContinuationQueued = false;
|
|
1437
|
+
}
|
|
1438
|
+
const preparedSnapshot = await prepareParallelRun(projectRoot ?? process.cwd(), snapshot);
|
|
1334
1439
|
activeWorkflowTurn = true;
|
|
1335
|
-
await pi.sendUserMessage(workflowDirective(
|
|
1440
|
+
await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft));
|
|
1336
1441
|
}
|
|
1337
1442
|
|
|
1443
|
+
function scheduleParallelContinuation() {
|
|
1444
|
+
if (parallelContinuationQueued || pendingParallelLifecycle.size === 0) return;
|
|
1445
|
+
parallelContinuationQueued = true;
|
|
1446
|
+
const events = [...pendingParallelLifecycle.entries()].map(([id, status]) => `${id}:${status}`);
|
|
1447
|
+
pendingParallelLifecycle.clear();
|
|
1448
|
+
pi.sendMessage({
|
|
1449
|
+
customType: "devflow-parallel-lifecycle",
|
|
1450
|
+
content: `并行子 Agent 生命周期事件已到达(${events.join(", ")})。请重新 inspect 整个 parallel run,逐一处理所有已完成/失败分支,必要时调用 Core 的 branch complete/fail/retry 动作,然后继续派发仍满足前置依赖的分支;不得直接 join。`,
|
|
1451
|
+
display: false,
|
|
1452
|
+
details: { events },
|
|
1453
|
+
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
pi.events.on(TASK_SUBAGENT_LIFECYCLE_CHANNEL, data => {
|
|
1457
|
+
if (!parallelRunActive || !data || typeof data !== "object") return;
|
|
1458
|
+
const event = data as Partial<SubagentLifecyclePayload>;
|
|
1459
|
+
if (typeof event.id !== "string" || !["started", "completed", "failed", "aborted"].includes(String(event.status))) return;
|
|
1460
|
+
if (event.status === "started") return;
|
|
1461
|
+
pendingParallelLifecycle.set(event.id, String(event.status));
|
|
1462
|
+
scheduleParallelContinuation();
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1338
1465
|
pi.setLabel("Devflow Project Runtime");
|
|
1339
1466
|
|
|
1340
1467
|
pi.on("session_start", async (_event, ctx) => {
|
|
1468
|
+
projectRoot = ctx.cwd;
|
|
1341
1469
|
sessionId = randomUUID();
|
|
1342
1470
|
const result = await runHook(ctx.cwd, "start.py", {
|
|
1343
1471
|
session_id: sessionId,
|
|
@@ -1452,13 +1580,26 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1452
1580
|
await triggerWorkflow(next.snapshot, "approval");
|
|
1453
1581
|
});
|
|
1454
1582
|
|
|
1455
|
-
pi.on("before_agent_start", async event =>
|
|
1456
|
-
|
|
1457
|
-
|
|
1583
|
+
pi.on("before_agent_start", async event => {
|
|
1584
|
+
if (activeWorkflowTurn && activeContract) {
|
|
1585
|
+
await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
|
|
1586
|
+
await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
|
|
1587
|
+
}
|
|
1588
|
+
return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
|
|
1589
|
+
});
|
|
1458
1590
|
|
|
1459
1591
|
pi.on("agent_end", async (event, ctx) => {
|
|
1460
1592
|
if (!activeWorkflowTurn || event.willContinue) return;
|
|
1461
1593
|
activeWorkflowTurn = false;
|
|
1594
|
+
if (parallelRunActive) {
|
|
1595
|
+
parallelContinuationQueued = false;
|
|
1596
|
+
setControlState(ctx, executingState("并行窗口运行中,等待子 Agent 生命周期事件"));
|
|
1597
|
+
// A second detached task may finish while the continuation is running.
|
|
1598
|
+
// Its event is retained and gets a follow-up continuation instead of
|
|
1599
|
+
// being dropped behind the current queue marker.
|
|
1600
|
+
scheduleParallelContinuation();
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1462
1603
|
if (controlState.phase === "executing") {
|
|
1463
1604
|
setControlState(ctx, waitingUserState("等待用户审查步骤产物并明确决定"));
|
|
1464
1605
|
ctx.ui.notify("当前步骤已结束。请审查输出:明确回复“批准执行”推进,或回复修改意见/“拒绝执行”保持当前步骤。", "info");
|
|
@@ -1543,7 +1684,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1543
1684
|
"--json",
|
|
1544
1685
|
"--expected-revision",
|
|
1545
1686
|
String(revision),
|
|
1546
|
-
...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
|
|
1687
|
+
...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId!]),
|
|
1547
1688
|
]);
|
|
1548
1689
|
if (!result.ok) {
|
|
1549
1690
|
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
|
|
@@ -1619,6 +1760,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1619
1760
|
|
|
1620
1761
|
pi.on("tool_call", async (event, ctx) => {
|
|
1621
1762
|
const toolName = String(event.toolName).toLowerCase();
|
|
1763
|
+
if (parallelRunActive && toolName === "task") {
|
|
1764
|
+
const branchContext = parallelBranchContext(event.input);
|
|
1765
|
+
if (branchContext) parallelTaskContexts.set(event.toolCallId, branchContext);
|
|
1766
|
+
}
|
|
1622
1767
|
if (activeContract && controlState.phase === "executing" && !toolName.startsWith("devflow_")) {
|
|
1623
1768
|
if (!activeContract.allowedTools.includes(toolName)) {
|
|
1624
1769
|
return { block: true, reason: `Active contract ${activeContract.contractId} does not allow tool: ${toolName}` };
|
|
@@ -1691,6 +1836,18 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1691
1836
|
"Devflow 状态迁移只能由运行时在用户明确批准已展示的步骤或 checkpoint 后执行;LLM 不得自行推进。",
|
|
1692
1837
|
};
|
|
1693
1838
|
}
|
|
1839
|
+
if (isUserConfirmedStepTransition && activeContract) {
|
|
1840
|
+
const evidence = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/machine_evidence.rb", [
|
|
1841
|
+
"--feature", activeContract.feature, "--step", activeContract.step, "--write", "--json",
|
|
1842
|
+
]);
|
|
1843
|
+
const evidencePayload = parseJsonResult(evidence);
|
|
1844
|
+
if (!evidence.ok || !evidencePayload || evidencePayload.complete !== true) {
|
|
1845
|
+
return {
|
|
1846
|
+
block: true,
|
|
1847
|
+
reason: `Devflow machine evidence is incomplete; collect independent runtime evidence before completing ${activeContract.step}.`,
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1694
1851
|
if (
|
|
1695
1852
|
!toolName.startsWith("devflow_") &&
|
|
1696
1853
|
["write", "edit", "apply_patch", "notebook_edit", "bash", "shell", "exec_command"].includes(toolName) &&
|
|
@@ -1767,6 +1924,15 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1767
1924
|
|
|
1768
1925
|
pi.on("tool_result", async (event, ctx) => {
|
|
1769
1926
|
if (event.isError) return;
|
|
1927
|
+
if (activeWorkflowTurn && activeContract) {
|
|
1928
|
+
const taskContext = parallelTaskContexts.get(event.toolCallId);
|
|
1929
|
+
await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
|
|
1930
|
+
engine: event.toolName,
|
|
1931
|
+
ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
|
|
1932
|
+
...(taskContext ?? {}),
|
|
1933
|
+
});
|
|
1934
|
+
if (taskContext) parallelTaskContexts.delete(event.toolCallId);
|
|
1935
|
+
}
|
|
1770
1936
|
await runHook(ctx.cwd, "post_tool.py", {
|
|
1771
1937
|
session_id: sessionId,
|
|
1772
1938
|
cwd: ctx.cwd,
|
|
@@ -1867,7 +2033,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1867
2033
|
stderr: "TC candidates are accepted only during the active acceptance-test-design contract.",
|
|
1868
2034
|
});
|
|
1869
2035
|
}
|
|
1870
|
-
args.push("--
|
|
2036
|
+
args.push("--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
|
|
2037
|
+
// `write` is an explicit dry-run switch. Omit it only when the caller
|
|
2038
|
+
// explicitly asks for a preview; the normal approved proposal remains
|
|
2039
|
+
// a Core-owned write.
|
|
2040
|
+
if (params.write !== false) args.push("--write");
|
|
1871
2041
|
if (activeContract?.allowedGraphDelta?.length) args.push("--allowed-kinds", activeContract.allowedGraphDelta.join(","));
|
|
1872
2042
|
}
|
|
1873
2043
|
if (params.action === "confirm") {
|
|
@@ -12,6 +12,7 @@ require_relative "control_io"
|
|
|
12
12
|
require_relative "validate_state"
|
|
13
13
|
require_relative "phase_ownership"
|
|
14
14
|
require_relative "requirement_graph"
|
|
15
|
+
require_relative "gate_runtime"
|
|
15
16
|
|
|
16
17
|
options = {
|
|
17
18
|
state: nil,
|
|
@@ -24,7 +25,13 @@ options = {
|
|
|
24
25
|
cancel: false,
|
|
25
26
|
cancel_reason: nil,
|
|
26
27
|
expected_revision: nil,
|
|
27
|
-
json: false
|
|
28
|
+
json: false,
|
|
29
|
+
runtime_confirmed: false,
|
|
30
|
+
parallel_action: nil,
|
|
31
|
+
parallel_branch_id: nil,
|
|
32
|
+
parallel_attempt: nil,
|
|
33
|
+
parallel_branch_revision: nil,
|
|
34
|
+
parallel_evidence: {}
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
OptionParser.new do |opts|
|
|
@@ -40,6 +47,13 @@ OptionParser.new do |opts|
|
|
|
40
47
|
opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
|
|
41
48
|
opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
|
|
42
49
|
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
50
|
+
opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
|
|
51
|
+
opts.on("--parallel-action ACTION", %w[start complete fail retry join]) { |value| options[:parallel_action] = value }
|
|
52
|
+
opts.on("--parallel-branch-id ID", "Branch id for a parallel transition") { |value| options[:parallel_branch_id] = value }
|
|
53
|
+
opts.on("--parallel-task-id ID", "OMP task id to bind to a branch") { |value| options[:parallel_task_id] = value }
|
|
54
|
+
opts.on("--parallel-attempt N", Integer) { |value| options[:parallel_attempt] = value }
|
|
55
|
+
opts.on("--parallel-branch-revision N", Integer) { |value| options[:parallel_branch_revision] = value }
|
|
56
|
+
opts.on("--parallel-evidence JSON") { |value| options[:parallel_evidence] = JSON.parse(value) }
|
|
43
57
|
end.parse!
|
|
44
58
|
|
|
45
59
|
root = Dir.pwd
|
|
@@ -64,6 +78,63 @@ def load_workflow(root, state)
|
|
|
64
78
|
[relative, load_yaml_file(path)]
|
|
65
79
|
end
|
|
66
80
|
|
|
81
|
+
def gate_contract_enabled?(workflow)
|
|
82
|
+
workflow.dig("governance", "gate_contract_version").to_i >= 3
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Build the runtime view from the existing workflow declaration. This keeps
|
|
86
|
+
# governance.step_checkers as the single checker namespace and avoids creating
|
|
87
|
+
# a second hand-maintained per-step transition table.
|
|
88
|
+
def transition_gate_contract(workflow, step)
|
|
89
|
+
checkpoints = workflow["checkpoints"] || {}
|
|
90
|
+
checkpoint_id = step["checkpoint"]
|
|
91
|
+
checkpoint = checkpoint_id && checkpoints[checkpoint_id]
|
|
92
|
+
track_names = Array(workflow["parallel_windows"]).flat_map do |window|
|
|
93
|
+
next [] unless window.is_a?(Hash) && window["branches"].is_a?(Hash)
|
|
94
|
+
window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
|
|
95
|
+
end
|
|
96
|
+
allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
|
|
97
|
+
reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
|
|
98
|
+
next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
|
|
99
|
+
entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
|
|
100
|
+
end
|
|
101
|
+
{
|
|
102
|
+
"gate_id" => "#{step.fetch("id")}-gate-v1",
|
|
103
|
+
"step_id" => step.fetch("id"),
|
|
104
|
+
"topology" => {
|
|
105
|
+
"allowed_tracks" => allowed_tracks,
|
|
106
|
+
"allowed_predecessors" => Array(step["depends_on"]),
|
|
107
|
+
"allowed_reentry" => reentries,
|
|
108
|
+
"checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
|
|
109
|
+
},
|
|
110
|
+
"entry" => { "required_inputs" => Array(step["inputs"]) },
|
|
111
|
+
"outputs" => { "required" => Array(step["outputs"]).select { |output| output["required"] } },
|
|
112
|
+
"quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
|
|
113
|
+
"concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
|
|
114
|
+
"recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def transition_gate_preflight(workflow, state, step, current_revision, checkpoint_authorized = false, transition_kind = nil)
|
|
119
|
+
return nil unless gate_contract_enabled?(workflow)
|
|
120
|
+
|
|
121
|
+
contract = transition_gate_contract(workflow, step)
|
|
122
|
+
track = state["active_track"].to_s
|
|
123
|
+
track = "main" unless %w[A B].include?(track)
|
|
124
|
+
GateRuntime.evaluate(
|
|
125
|
+
contract: contract,
|
|
126
|
+
workflow_state: state,
|
|
127
|
+
current_revision: current_revision,
|
|
128
|
+
expected_revision: current_revision,
|
|
129
|
+
track: track,
|
|
130
|
+
source: "advance_state",
|
|
131
|
+
root: Dir.pwd,
|
|
132
|
+
check_outputs: step["id"].to_s == state["current_step"].to_s,
|
|
133
|
+
transition_kind: transition_kind,
|
|
134
|
+
checkpoint_authorized: checkpoint_authorized
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
67
138
|
def interpolate_path(path, state)
|
|
68
139
|
path
|
|
69
140
|
.gsub("{feature}", state.fetch("current_feature", "").to_s)
|
|
@@ -349,6 +420,63 @@ def track_boundary?(state, pending_ids)
|
|
|
349
420
|
(pending_ids & track_allowed_steps(track)).empty? && pending_ids.any?
|
|
350
421
|
end
|
|
351
422
|
|
|
423
|
+
def parallel_window_for(workflow, completed_step_id)
|
|
424
|
+
Array(workflow["parallel_windows"]).find do |window|
|
|
425
|
+
window.is_a?(Hash) && window["after_step"].to_s == completed_step_id.to_s
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def parallel_branch_for(root, state, branch_id)
|
|
430
|
+
record = ParallelRun.load(root, state.fetch("current_feature"))
|
|
431
|
+
branch = Array(record && record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
|
|
432
|
+
[record, branch]
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def advance_parallel_branch(root, state, options, lock = nil)
|
|
436
|
+
branch_id = options[:parallel_branch_id]
|
|
437
|
+
raise ArgumentError, "parallel branch id is required" if branch_id.to_s.empty?
|
|
438
|
+
record, branch = parallel_branch_for(root, state, branch_id)
|
|
439
|
+
raise ArgumentError, "parallel run not found" unless record
|
|
440
|
+
raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
|
|
441
|
+
expected = Integer(record.fetch("run_revision", 0))
|
|
442
|
+
case options[:parallel_action]
|
|
443
|
+
when "start"
|
|
444
|
+
ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "running", task_id: options[:parallel_task_id], lock: lock)
|
|
445
|
+
when "complete"
|
|
446
|
+
ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "succeeded", evidence_path: options[:parallel_evidence]["evidence_path"], output_fingerprint: options[:parallel_evidence]["output_fingerprint"], lock: lock)
|
|
447
|
+
when "fail"
|
|
448
|
+
# The error is diagnostic metadata only; it is never used as evidence or
|
|
449
|
+
# as an authorization for branch completion.
|
|
450
|
+
ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "failed", error: options[:parallel_evidence]["error"], lock: lock)
|
|
451
|
+
when "retry"
|
|
452
|
+
ParallelRun.retry_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, lock: lock)
|
|
453
|
+
else
|
|
454
|
+
raise ArgumentError, "unsupported parallel branch action"
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def join_parallel_window(root, state, workflow, current_revision)
|
|
459
|
+
record = ParallelRun.load(root, state.fetch("current_feature"))
|
|
460
|
+
raise ArgumentError, "parallel run not found" unless record
|
|
461
|
+
branches = Array(record["branches"])
|
|
462
|
+
incomplete = branches.reject { |branch| branch["status"].to_s == "succeeded" }
|
|
463
|
+
raise ArgumentError, "parallel join requires every branch to succeed" unless branches.any? && incomplete.empty?
|
|
464
|
+
|
|
465
|
+
branch_steps = branches.map { |branch| branch.fetch("workflow_step").to_s }
|
|
466
|
+
state["completed_steps"] = (Array(state["completed_steps"]) + branch_steps).uniq
|
|
467
|
+
state["pending_steps"] = Array(state["pending_steps"]) - branch_steps
|
|
468
|
+
state["last_completed_step"] = state["current_step"]
|
|
469
|
+
state.delete("parallel_run_ref")
|
|
470
|
+
record["status"] = "completed"
|
|
471
|
+
record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
|
|
472
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
473
|
+
state["current_step"] = workflow.fetch("parallel_windows").find { |window| window["id"].to_s == record["workflow_window"].to_s }.fetch("join_step")
|
|
474
|
+
state["status"] = "in_progress"
|
|
475
|
+
state["state_updated_at"] = Date.today.to_s
|
|
476
|
+
state["revision"] = current_revision + 1
|
|
477
|
+
[state, record, "Parallel window joined; branch steps merged and parallel_run_ref cleared."]
|
|
478
|
+
end
|
|
479
|
+
|
|
352
480
|
def fail_result(reason, details = {})
|
|
353
481
|
{ "ok" => false, "reason" => reason, "details" => details }
|
|
354
482
|
end
|
|
@@ -397,6 +525,40 @@ rescue JSON::ParserError => error
|
|
|
397
525
|
{ "ok" => false, "errors" => ["API test schema checker returned invalid JSON: #{error.message}"] }
|
|
398
526
|
end
|
|
399
527
|
|
|
528
|
+
def cross_track_checker_enabled?(workflow, step_id)
|
|
529
|
+
Array(workflow.dig("governance", "step_checkers", step_id)).map(&:to_s).include?("cross-track-consistency-check")
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def cross_track_consistency_check(root, feature)
|
|
533
|
+
script = File.join(__dir__, "cross_track_consistency_check.rb")
|
|
534
|
+
stdout, stderr, status = Open3.capture3("ruby", script, "--feature", feature, "--json", chdir: root)
|
|
535
|
+
payload = JSON.parse(stdout)
|
|
536
|
+
return payload if status.success? && payload.is_a?(Hash)
|
|
537
|
+
|
|
538
|
+
{ "ok" => false, "errors" => [stderr.strip.empty? ? "cross-track checker returned invalid output" : stderr.strip] }
|
|
539
|
+
rescue JSON::ParserError => error
|
|
540
|
+
{ "ok" => false, "errors" => ["cross-track checker returned invalid JSON: #{error.message}"] }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def branch_machine_evidence(root, state, branch)
|
|
544
|
+
script = File.join(__dir__, "machine_evidence.rb")
|
|
545
|
+
step = branch.fetch("workflow_step").to_s
|
|
546
|
+
attempt = Integer(branch.fetch("attempt", 0))
|
|
547
|
+
revision = Integer(branch.fetch("branch_revision", 0))
|
|
548
|
+
stdout, stderr, status = Open3.capture3(
|
|
549
|
+
"ruby", script, "--feature", state.fetch("current_feature"), "--step", step,
|
|
550
|
+
"--parallel-branch-id", branch.fetch("branch_id").to_s,
|
|
551
|
+
"--parallel-attempt", attempt.to_s, "--parallel-branch-revision", revision.to_s,
|
|
552
|
+
"--lock-held", "--write", "--json", chdir: root
|
|
553
|
+
)
|
|
554
|
+
payload = JSON.parse(stdout)
|
|
555
|
+
return payload if status.success? && payload.is_a?(Hash) && payload["ok"] == true
|
|
556
|
+
|
|
557
|
+
{ "ok" => false, "errors" => [stderr.strip.empty? ? "machine evidence returned invalid output" : stderr.strip] }
|
|
558
|
+
rescue JSON::ParserError => error
|
|
559
|
+
{ "ok" => false, "errors" => ["machine evidence returned invalid JSON: #{error.message}"] }
|
|
560
|
+
end
|
|
561
|
+
|
|
400
562
|
def test_suite_freeze_attestation_path(root, feature)
|
|
401
563
|
DevflowControlIO.resolve_control_path(root, ".planning/features/#{feature}/test-suite-freeze.json", :artifact)
|
|
402
564
|
end
|
|
@@ -505,6 +667,7 @@ result =
|
|
|
505
667
|
next_intent_written = nil
|
|
506
668
|
actions = []
|
|
507
669
|
action_description = nil
|
|
670
|
+
parallel_run_to_write = nil
|
|
508
671
|
next_intent_requested = false
|
|
509
672
|
current_revision = Integer(state.fetch("revision", 0))
|
|
510
673
|
|
|
@@ -516,6 +679,118 @@ result =
|
|
|
516
679
|
)
|
|
517
680
|
end
|
|
518
681
|
|
|
682
|
+
# Branch completion evidence is always generated by Core from the
|
|
683
|
+
# persisted branch record and current artifacts. Ignore any caller-built
|
|
684
|
+
# binding so an agent cannot self-attest a different attempt/revision.
|
|
685
|
+
if result.nil? && options[:parallel_action] == "complete" && options[:write]
|
|
686
|
+
_run, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
|
|
687
|
+
if branch.nil?
|
|
688
|
+
result = fail_result("parallel_branch_not_found")
|
|
689
|
+
else
|
|
690
|
+
generated = branch_machine_evidence(root, state, branch)
|
|
691
|
+
if generated["ok"] == true
|
|
692
|
+
binding = generated.fetch("parallel_binding")
|
|
693
|
+
options[:parallel_evidence] = {
|
|
694
|
+
"evidence_path" => generated.fetch("path"),
|
|
695
|
+
"branch_id" => binding.fetch("branch_id"),
|
|
696
|
+
"attempt" => binding.fetch("attempt"),
|
|
697
|
+
"branch_revision" => binding.fetch("branch_revision"),
|
|
698
|
+
"state_revision" => binding.fetch("state_revision")
|
|
699
|
+
}
|
|
700
|
+
else
|
|
701
|
+
result = fail_result("parallel_machine_evidence_failed", "evidence" => generated)
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# GateRuntime is a read-only boundary check. It runs while the project
|
|
707
|
+
# lock is held and before any candidate state mutation; the existing
|
|
708
|
+
# validator and advance_state CAS remain the final write guard.
|
|
709
|
+
if result.nil? && gate_contract_enabled?(workflow) &&
|
|
710
|
+
!%w[prepared claimed].include?(state.dig("rollover", "status")) &&
|
|
711
|
+
(options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch] || options[:parallel_action])
|
|
712
|
+
target_step_id = if options[:parallel_action] && options[:parallel_action] != "join"
|
|
713
|
+
_record, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
|
|
714
|
+
branch && branch["workflow_step"]
|
|
715
|
+
elsif options[:complete_step]
|
|
716
|
+
options[:complete_step]
|
|
717
|
+
elsif options[:confirm_checkpoint]
|
|
718
|
+
state["current_step"]
|
|
719
|
+
else
|
|
720
|
+
state["current_step"]
|
|
721
|
+
end
|
|
722
|
+
target_step = step_by_id[target_step_id]
|
|
723
|
+
if target_step.nil?
|
|
724
|
+
result = fail_result("gate_target_step_unknown", "step" => target_step_id)
|
|
725
|
+
elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
|
|
726
|
+
result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
|
|
727
|
+
else
|
|
728
|
+
transition_kind = if options[:parallel_action]
|
|
729
|
+
{ "start" => "parallel_branch_start", "complete" => "parallel_branch_complete", "fail" => "parallel_branch_fail", "retry" => "parallel_branch_retry", "join" => "parallel_join" }.fetch(options[:parallel_action])
|
|
730
|
+
elsif options[:confirm_checkpoint]
|
|
731
|
+
"confirm_checkpoint"
|
|
732
|
+
elsif options[:enter_failure_branch]
|
|
733
|
+
"enter_failure_branch"
|
|
734
|
+
elsif options[:exit_failure_branch]
|
|
735
|
+
"exit_failure_branch"
|
|
736
|
+
end
|
|
737
|
+
gate_result = GateRuntime.evaluate(
|
|
738
|
+
contract: transition_gate_contract(workflow, target_step),
|
|
739
|
+
workflow_state: state,
|
|
740
|
+
current_revision: current_revision,
|
|
741
|
+
expected_revision: current_revision,
|
|
742
|
+
track: if options[:parallel_action] && options[:parallel_action] != "join"
|
|
743
|
+
_record, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
|
|
744
|
+
branch ? branch["track"].to_s.sub(/\Atrack_/, "").upcase : (active_track(state) || "main")
|
|
745
|
+
else
|
|
746
|
+
active_track(state) || "main"
|
|
747
|
+
end,
|
|
748
|
+
branch_id: options[:parallel_branch_id],
|
|
749
|
+
attempt: options[:parallel_attempt],
|
|
750
|
+
branch_revision: options[:parallel_branch_revision],
|
|
751
|
+
evidence: options[:parallel_evidence],
|
|
752
|
+
source: "advance_state",
|
|
753
|
+
root: root,
|
|
754
|
+
check_outputs: target_step["id"].to_s == state["current_step"].to_s,
|
|
755
|
+
transition_kind: transition_kind,
|
|
756
|
+
checkpoint_authorized: options[:runtime_confirmed]
|
|
757
|
+
)
|
|
758
|
+
if gate_result && gate_result["verdict"] != "allow"
|
|
759
|
+
result = fail_result("gate_boundary_rejected", "gate" => gate_result)
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
if result.nil? && options[:parallel_action]
|
|
765
|
+
if !options[:write]
|
|
766
|
+
result = fail_result("parallel_transition_requires_write")
|
|
767
|
+
elsif options[:parallel_action] == "join"
|
|
768
|
+
begin
|
|
769
|
+
if cross_track_checker_enabled?(workflow, "integration-verify")
|
|
770
|
+
checker = cross_track_consistency_check(root, state.fetch("current_feature"))
|
|
771
|
+
unless checker["ok"] == true
|
|
772
|
+
result = fail_result("cross_track_consistency_failed", "checker" => checker)
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
if result.nil?
|
|
776
|
+
state, parallel_run_to_write, action_description = join_parallel_window(root, state, workflow, current_revision)
|
|
777
|
+
changed = true
|
|
778
|
+
actions << "parallel_window_joined"
|
|
779
|
+
end
|
|
780
|
+
rescue ArgumentError, KeyError, TypeError => error
|
|
781
|
+
result = fail_result("parallel_join_failed", "message" => error.message)
|
|
782
|
+
end
|
|
783
|
+
else
|
|
784
|
+
begin
|
|
785
|
+
advance_parallel_branch(root, state, options, project_lock)
|
|
786
|
+
actions << "parallel_branch_#{options[:parallel_action]}"
|
|
787
|
+
action_description = "Parallel branch #{options[:parallel_action]}: #{options[:parallel_branch_id]}"
|
|
788
|
+
rescue ArgumentError, KeyError, TypeError => error
|
|
789
|
+
result = fail_result("parallel_branch_transition_failed", "message" => error.message)
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
519
794
|
if result.nil? && options[:checkpoint_decision]
|
|
520
795
|
state["checkpoint_decision"] = options[:checkpoint_decision]
|
|
521
796
|
changed = true
|
|
@@ -582,6 +857,12 @@ result =
|
|
|
582
857
|
state["checkpoint"] = step["checkpoint"]
|
|
583
858
|
state["checkpoint_label"] = checkpoint_label(workflow, step["checkpoint"])
|
|
584
859
|
action_description = "Step completed: #{step["label"]} (#{step_id}). Now waiting at #{step["checkpoint"]}."
|
|
860
|
+
elsif (parallel_window = parallel_window_for(workflow, step_id))
|
|
861
|
+
state["current_step"] = parallel_window.fetch("join_step")
|
|
862
|
+
state["status"] = "in_progress"
|
|
863
|
+
state.delete("checkpoint")
|
|
864
|
+
state.delete("checkpoint_label")
|
|
865
|
+
action_description = "Step completed: #{step["label"]} (#{step_id}). Entered parallel window #{parallel_window["id"]}; awaiting Core prepare."
|
|
585
866
|
elsif track_boundary?(state, pending)
|
|
586
867
|
state["current_step"] = step_id
|
|
587
868
|
state["status"] = "blocked"
|
|
@@ -824,6 +1105,7 @@ result =
|
|
|
824
1105
|
sync_artifact_registry(root, workflow, state, registry: candidate_registry)
|
|
825
1106
|
artifact_by_path = load_artifact_registry(root, workflow)
|
|
826
1107
|
write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
|
|
1108
|
+
ParallelRun.write(root, state.fetch("current_feature"), parallel_run_to_write) if parallel_run_to_write
|
|
827
1109
|
# STATE.md is project-global. Once its top-level workflow is
|
|
828
1110
|
# terminal, retain the historical owner but release its active
|
|
829
1111
|
# reservation so the next feature can claim a distinct phase.
|