@guanlin4924/devflow-pi 0.1.39 → 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 +125 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +194 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +1 -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_runtime.rb +71 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +76 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +5 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +8 -2
package/package.json
CHANGED
|
@@ -4,6 +4,10 @@ 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
|
}
|
|
@@ -872,7 +898,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
872
898
|
const step = activeContract.step;
|
|
873
899
|
if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
|
|
874
900
|
const directory = path.join(cwd, ".planning", "evidence", feature);
|
|
875
|
-
const
|
|
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`);
|
|
876
905
|
await mkdir(directory, { recursive: true });
|
|
877
906
|
await appendFile(destination, `${JSON.stringify({
|
|
878
907
|
schema_version: 1,
|
|
@@ -881,6 +910,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
881
910
|
feature,
|
|
882
911
|
step,
|
|
883
912
|
event,
|
|
913
|
+
...(attempt === undefined ? {} : { attempt, branch_id: extra.branch_id }),
|
|
884
914
|
collector: "devflow-runtime/harness",
|
|
885
915
|
...extra,
|
|
886
916
|
})}\n`, "utf8");
|
|
@@ -1323,6 +1353,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1323
1353
|
}
|
|
1324
1354
|
|
|
1325
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 }>();
|
|
1326
1360
|
let pendingProjectInitialization: ProjectInitializationRequest | undefined;
|
|
1327
1361
|
let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
|
|
1328
1362
|
|
|
@@ -1351,11 +1385,83 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1351
1385
|
pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
|
|
1352
1386
|
}
|
|
1353
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
|
+
|
|
1354
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);
|
|
1355
1439
|
activeWorkflowTurn = true;
|
|
1356
|
-
await pi.sendUserMessage(workflowDirective(
|
|
1440
|
+
await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft));
|
|
1441
|
+
}
|
|
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" });
|
|
1357
1454
|
}
|
|
1358
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
|
+
|
|
1359
1465
|
pi.setLabel("Devflow Project Runtime");
|
|
1360
1466
|
|
|
1361
1467
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -1485,6 +1591,15 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1485
1591
|
pi.on("agent_end", async (event, ctx) => {
|
|
1486
1592
|
if (!activeWorkflowTurn || event.willContinue) return;
|
|
1487
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
|
+
}
|
|
1488
1603
|
if (controlState.phase === "executing") {
|
|
1489
1604
|
setControlState(ctx, waitingUserState("等待用户审查步骤产物并明确决定"));
|
|
1490
1605
|
ctx.ui.notify("当前步骤已结束。请审查输出:明确回复“批准执行”推进,或回复修改意见/“拒绝执行”保持当前步骤。", "info");
|
|
@@ -1645,6 +1760,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1645
1760
|
|
|
1646
1761
|
pi.on("tool_call", async (event, ctx) => {
|
|
1647
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
|
+
}
|
|
1648
1767
|
if (activeContract && controlState.phase === "executing" && !toolName.startsWith("devflow_")) {
|
|
1649
1768
|
if (!activeContract.allowedTools.includes(toolName)) {
|
|
1650
1769
|
return { block: true, reason: `Active contract ${activeContract.contractId} does not allow tool: ${toolName}` };
|
|
@@ -1806,10 +1925,13 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1806
1925
|
pi.on("tool_result", async (event, ctx) => {
|
|
1807
1926
|
if (event.isError) return;
|
|
1808
1927
|
if (activeWorkflowTurn && activeContract) {
|
|
1928
|
+
const taskContext = parallelTaskContexts.get(event.toolCallId);
|
|
1809
1929
|
await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
|
|
1810
1930
|
engine: event.toolName,
|
|
1811
1931
|
ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
|
|
1932
|
+
...(taskContext ?? {}),
|
|
1812
1933
|
});
|
|
1934
|
+
if (taskContext) parallelTaskContexts.delete(event.toolCallId);
|
|
1813
1935
|
}
|
|
1814
1936
|
await runHook(ctx.cwd, "post_tool.py", {
|
|
1815
1937
|
session_id: sessionId,
|
|
@@ -26,7 +26,12 @@ options = {
|
|
|
26
26
|
cancel_reason: nil,
|
|
27
27
|
expected_revision: nil,
|
|
28
28
|
json: false,
|
|
29
|
-
runtime_confirmed: 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: {}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
OptionParser.new do |opts|
|
|
@@ -43,6 +48,12 @@ OptionParser.new do |opts|
|
|
|
43
48
|
opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
|
|
44
49
|
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
45
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) }
|
|
46
57
|
end.parse!
|
|
47
58
|
|
|
48
59
|
root = Dir.pwd
|
|
@@ -409,6 +420,63 @@ def track_boundary?(state, pending_ids)
|
|
|
409
420
|
(pending_ids & track_allowed_steps(track)).empty? && pending_ids.any?
|
|
410
421
|
end
|
|
411
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
|
+
|
|
412
480
|
def fail_result(reason, details = {})
|
|
413
481
|
{ "ok" => false, "reason" => reason, "details" => details }
|
|
414
482
|
end
|
|
@@ -457,6 +525,40 @@ rescue JSON::ParserError => error
|
|
|
457
525
|
{ "ok" => false, "errors" => ["API test schema checker returned invalid JSON: #{error.message}"] }
|
|
458
526
|
end
|
|
459
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
|
+
|
|
460
562
|
def test_suite_freeze_attestation_path(root, feature)
|
|
461
563
|
DevflowControlIO.resolve_control_path(root, ".planning/features/#{feature}/test-suite-freeze.json", :artifact)
|
|
462
564
|
end
|
|
@@ -565,6 +667,7 @@ result =
|
|
|
565
667
|
next_intent_written = nil
|
|
566
668
|
actions = []
|
|
567
669
|
action_description = nil
|
|
670
|
+
parallel_run_to_write = nil
|
|
568
671
|
next_intent_requested = false
|
|
569
672
|
current_revision = Integer(state.fetch("revision", 0))
|
|
570
673
|
|
|
@@ -576,13 +679,40 @@ result =
|
|
|
576
679
|
)
|
|
577
680
|
end
|
|
578
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
|
+
|
|
579
706
|
# GateRuntime is a read-only boundary check. It runs while the project
|
|
580
707
|
# lock is held and before any candidate state mutation; the existing
|
|
581
708
|
# validator and advance_state CAS remain the final write guard.
|
|
582
709
|
if result.nil? && gate_contract_enabled?(workflow) &&
|
|
583
710
|
!%w[prepared claimed].include?(state.dig("rollover", "status")) &&
|
|
584
|
-
(options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch])
|
|
585
|
-
target_step_id = if options[:
|
|
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]
|
|
586
716
|
options[:complete_step]
|
|
587
717
|
elsif options[:confirm_checkpoint]
|
|
588
718
|
state["current_step"]
|
|
@@ -595,20 +725,72 @@ result =
|
|
|
595
725
|
elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
|
|
596
726
|
result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
|
|
597
727
|
else
|
|
598
|
-
transition_kind = if options[:
|
|
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]
|
|
599
731
|
"confirm_checkpoint"
|
|
600
732
|
elsif options[:enter_failure_branch]
|
|
601
733
|
"enter_failure_branch"
|
|
602
734
|
elsif options[:exit_failure_branch]
|
|
603
735
|
"exit_failure_branch"
|
|
604
736
|
end
|
|
605
|
-
gate_result =
|
|
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
|
+
)
|
|
606
758
|
if gate_result && gate_result["verdict"] != "allow"
|
|
607
759
|
result = fail_result("gate_boundary_rejected", "gate" => gate_result)
|
|
608
760
|
end
|
|
609
761
|
end
|
|
610
762
|
end
|
|
611
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
|
+
|
|
612
794
|
if result.nil? && options[:checkpoint_decision]
|
|
613
795
|
state["checkpoint_decision"] = options[:checkpoint_decision]
|
|
614
796
|
changed = true
|
|
@@ -675,6 +857,12 @@ result =
|
|
|
675
857
|
state["checkpoint"] = step["checkpoint"]
|
|
676
858
|
state["checkpoint_label"] = checkpoint_label(workflow, step["checkpoint"])
|
|
677
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."
|
|
678
866
|
elsif track_boundary?(state, pending)
|
|
679
867
|
state["current_step"] = step_id
|
|
680
868
|
state["status"] = "blocked"
|
|
@@ -917,6 +1105,7 @@ result =
|
|
|
917
1105
|
sync_artifact_registry(root, workflow, state, registry: candidate_registry)
|
|
918
1106
|
artifact_by_path = load_artifact_registry(root, workflow)
|
|
919
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
|
|
920
1109
|
# STATE.md is project-global. Once its top-level workflow is
|
|
921
1110
|
# terminal, retain the historical owner but release its active
|
|
922
1111
|
# reservation so the next feature can claim a distinct phase.
|
|
@@ -7,6 +7,7 @@ module CheckerRegistry
|
|
|
7
7
|
api-test-schema-check contract-coverage-check api-test-matrix-check
|
|
8
8
|
test-coverage-check test-obligation-check acceptance-test-results-check
|
|
9
9
|
change-impact-check tdd-evidence-check test-task-binding-check
|
|
10
|
+
cross-track-consistency-check
|
|
10
11
|
].freeze
|
|
11
12
|
EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
|
|
12
13
|
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
options = { feature: nil, json: false }
|
|
9
|
+
OptionParser.new do |opts|
|
|
10
|
+
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
11
|
+
opts.on("--json") { options[:json] = true }
|
|
12
|
+
end.parse!
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
15
|
+
feature = options[:feature].to_s
|
|
16
|
+
raise ArgumentError, "feature contains path separators" if feature.include?("/") || feature.include?("\\")
|
|
17
|
+
|
|
18
|
+
root = Dir.pwd
|
|
19
|
+
base = File.join(root, ".planning", "features", feature)
|
|
20
|
+
files = {
|
|
21
|
+
"api_contract" => File.join(base, "api-contract.md"),
|
|
22
|
+
"api_call_guide" => File.join(base, "api-call-guide.md"),
|
|
23
|
+
"frontend_spec" => File.join(base, "前端技术方案.md"),
|
|
24
|
+
"backend_spec" => File.join(base, "后端技术方案.md"),
|
|
25
|
+
"api_supplement" => File.join(base, "api-supplement-requests.md"),
|
|
26
|
+
}
|
|
27
|
+
errors = []
|
|
28
|
+
missing = files.select { |_, path| !File.file?(path) }.keys
|
|
29
|
+
errors << { "code" => "CROSS_TRACK_ARTIFACT_MISSING", "files" => missing } unless missing.empty?
|
|
30
|
+
|
|
31
|
+
read = lambda do |key|
|
|
32
|
+
path = files.fetch(key)
|
|
33
|
+
File.file?(path) ? File.read(path) : ""
|
|
34
|
+
end
|
|
35
|
+
contract = read.call("api_contract")
|
|
36
|
+
guide = read.call("api_call_guide")
|
|
37
|
+
frontend = read.call("frontend_spec")
|
|
38
|
+
|
|
39
|
+
# Endpoint references are deliberately extracted from the authoritative API
|
|
40
|
+
# contract and checked for textual closure in both consumers. This is a
|
|
41
|
+
# deterministic reference-closure check; an AI integration summary is not used.
|
|
42
|
+
endpoint_pattern = %r{\b(?:GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:-]+(?:\{[A-Za-z0-9_]+\})*)}
|
|
43
|
+
endpoints = contract.scan(endpoint_pattern).flatten.uniq
|
|
44
|
+
unreferenced = endpoints.reject { |endpoint| guide.include?(endpoint) && frontend.include?(endpoint) }
|
|
45
|
+
unless unreferenced.empty?
|
|
46
|
+
errors << { "code" => "CROSS_TRACK_ENDPOINT_REFERENCE_MISSING", "endpoints" => unreferenced }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
contract_set = endpoints.to_h { |endpoint| [endpoint, true] }
|
|
50
|
+
{
|
|
51
|
+
"api_call_guide" => guide.scan(endpoint_pattern).flatten.uniq,
|
|
52
|
+
"frontend_spec" => frontend.scan(endpoint_pattern).flatten.uniq,
|
|
53
|
+
}.each do |consumer, references|
|
|
54
|
+
ghosts = references.reject { |endpoint| contract_set[endpoint] }
|
|
55
|
+
errors << { "code" => "CROSS_TRACK_ENDPOINT_NOT_IN_CONTRACT", "consumer" => consumer, "endpoints" => ghosts } unless ghosts.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
supplement = read.call("api_supplement")
|
|
59
|
+
supplement_endpoints = supplement.scan(endpoint_pattern).flatten.uniq
|
|
60
|
+
supplement_ghosts = supplement_endpoints.reject { |endpoint| contract_set[endpoint] }
|
|
61
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_ENDPOINT_NOT_IN_CONTRACT", "endpoints" => supplement_ghosts } unless supplement_ghosts.empty?
|
|
62
|
+
supplement_unconsumed = supplement_endpoints.reject { |endpoint| guide.include?(endpoint) }
|
|
63
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_GUIDE_MISMATCH", "endpoints" => supplement_unconsumed } unless supplement_unconsumed.empty?
|
|
64
|
+
|
|
65
|
+
meta_path = File.join(base, "api-call-guide.meta.json")
|
|
66
|
+
if File.file?(meta_path)
|
|
67
|
+
begin
|
|
68
|
+
meta = JSON.parse(File.read(meta_path))
|
|
69
|
+
errors << { "code" => "CROSS_TRACK_API_GUIDE_NOT_CONSUMABLE" } unless meta["frontend_consumable"] == true && meta["mock_ready"] == true
|
|
70
|
+
pending = meta["pending_items"]
|
|
71
|
+
errors << { "code" => "CROSS_TRACK_API_GUIDE_PENDING_ITEMS", "pending_items" => pending } unless pending.is_a?(Array) && pending.empty?
|
|
72
|
+
rescue JSON::ParserError => error
|
|
73
|
+
errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
supplement_meta = File.join(base, "api-supplement-requests.meta.json")
|
|
78
|
+
if File.file?(supplement_meta)
|
|
79
|
+
begin
|
|
80
|
+
meta = JSON.parse(File.read(supplement_meta))
|
|
81
|
+
unresolved = meta["unresolved_supplement_count"]
|
|
82
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_UNRESOLVED", "count" => unresolved } unless unresolved == 0
|
|
83
|
+
rescue JSON::ParserError => error
|
|
84
|
+
errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
result = {
|
|
89
|
+
"ok" => errors.empty?,
|
|
90
|
+
"checker_id" => "cross-track-consistency-check",
|
|
91
|
+
"errors" => errors,
|
|
92
|
+
"evidence" => { "endpoint_count" => endpoints.length, "checked_files" => files.values.map { |path| path.sub("#{root}/", "") } },
|
|
93
|
+
}
|
|
94
|
+
puts(options[:json] ? JSON.generate(result) : result.to_yaml)
|
|
@@ -14,7 +14,7 @@ POLICY = {
|
|
|
14
14
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
15
15
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
16
16
|
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
|
|
17
|
-
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
17
|
+
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
18
18
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
19
19
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
20
20
|
"execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
|