@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100
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/CHANGELOG.md +561 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +91 -15
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1506 -334
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +75 -21
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +676 -79
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +134 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +125 -80
- package/dist/services/eval-snapshots.js +838 -74
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +609 -310
- package/dist/services/lanes.js +62 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1128 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +425 -0
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +78 -27
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +27 -18
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +136 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +803 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +437 -81
- package/dist/services/runtime-budget.js +439 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +21 -19
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +61 -80
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +112 -108
- package/dist/services/stage-pause.js +117 -73
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +108 -132
- package/dist/services/vnext-code-review.js +245 -98
- package/dist/services/vnext-code.js +355 -257
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +166 -26
- package/dist/services/vnext-merge.js +413 -147
- package/dist/services/vnext-plan-review.js +182 -109
- package/dist/services/vnext-plan.js +199 -82
- package/dist/services/vnext-protocolize.js +133 -60
- package/dist/services/vnext-specify.js +94 -71
- package/dist/services/work-registry.js +813 -160
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +569 -16
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -1,31 +1,124 @@
|
|
|
1
|
+
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
2
|
+
import { bindStageWork, currentStageWorkIds, stageWorkIds } from "./stage-work-graph.js";
|
|
3
|
+
import { runtimeCommand } from "./runtime-command.js";
|
|
1
4
|
import crypto from "node:crypto";
|
|
2
5
|
import fs from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import {
|
|
8
|
+
import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
|
|
9
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
10
|
+
import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
6
11
|
import { validateSchema } from "./schema-validation.js";
|
|
7
|
-
import { resolveProjectRoot, resolveRunReferences
|
|
12
|
+
import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
|
|
8
13
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
9
|
-
import { readCodeCheckProfile, runCodeChecks } from "./code-checks.js";
|
|
14
|
+
import { ignoredWorkspacePaths, normalizeWorkspacePath, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
|
|
10
15
|
import { nextWorkId, nextWorkIds } from "./ids.js";
|
|
11
16
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
17
|
+
import { resolveStageTransition } from "./execution-policy.js";
|
|
18
|
+
import { appendAudit } from "./audit.js";
|
|
12
19
|
import { flowCommand } from "./stage-pause.js";
|
|
20
|
+
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
21
|
+
import { publicSessionIdentity } from "./session-identity.js";
|
|
22
|
+
import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
|
|
23
|
+
import { runControlDirective } from "./run-control.js";
|
|
24
|
+
import { prepareError, prepareJsonFile } from "../cli/input-preparation.js";
|
|
13
25
|
const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
|
|
14
26
|
export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
|
|
27
|
+
function ownsPhysicalWorkSession(context, projectId, owner, identity) {
|
|
28
|
+
if (identity.sessionId !== owner.session_id)
|
|
29
|
+
return false;
|
|
30
|
+
const binding = owner.hook_event_id ? context.db.get("SELECT daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, owner.hook_event_id]) : undefined;
|
|
31
|
+
// Legacy non-daemon sessions remain usable. Once a physical daemon was
|
|
32
|
+
// captured, another process cannot inherit authority merely by resuming
|
|
33
|
+
// the same provider Session ID.
|
|
34
|
+
return !binding?.daemon_id || binding.daemon_id === identity.daemonId;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A Stage terminal command is allowed only from the Session that owns its
|
|
38
|
+
* running coordinator Work. A hook receipt proves the caller; a WorkSession
|
|
39
|
+
* proves the owner. Either fact alone is insufficient.
|
|
40
|
+
*/
|
|
41
|
+
export function assertStageLifecycleOwner(context, input) {
|
|
42
|
+
ensureWorkRegistry(context);
|
|
43
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
44
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
45
|
+
if (!project)
|
|
46
|
+
throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
|
|
47
|
+
requireRun(context, project.id, input.runId);
|
|
48
|
+
const scopedWork = input.workId ? requireWork(context, input.workId, { projectId: project.id, runId: input.runId }) : undefined;
|
|
49
|
+
let owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
|
|
50
|
+
FROM work_sessions ws
|
|
51
|
+
JOIN works w ON w.work_id = ws.work_id
|
|
52
|
+
JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
|
|
53
|
+
WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
|
|
54
|
+
AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
|
|
55
|
+
${scopedWork ? "AND w.work_id = ?" : ""}
|
|
56
|
+
ORDER BY ws.created_at DESC LIMIT 1`, scopedWork ? [project.id, input.runId, input.stage, scopedWork.work_id] : [project.id, input.runId, input.stage]);
|
|
57
|
+
if (!owner && input.operation === "stage_finish" && ["code", "code-review"].includes(input.stage)) {
|
|
58
|
+
const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
59
|
+
const index = row ? JSON.parse(row.index_json) : null;
|
|
60
|
+
const completed = index?.stage_runs.find(entry => entry.stage === input.stage && entry.status === "done");
|
|
61
|
+
if (row && completed && (index?.current_stage ?? index?.stage_runs.at(-1)?.stage) === input.stage)
|
|
62
|
+
owner = context.db.get("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NULL AND w.status = 'completed' AND ws.status = 'completed' AND ws.result_path = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, input.runId, path.join(row.run_root, completed.dir, "stage-report.json")]);
|
|
63
|
+
}
|
|
64
|
+
if (!owner)
|
|
65
|
+
throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
|
|
66
|
+
const identity = claimStageLifecycleHookEvent(context, {
|
|
67
|
+
projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
|
|
68
|
+
runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
|
|
69
|
+
});
|
|
70
|
+
if (ownsPhysicalWorkSession(context, project.id, owner, identity))
|
|
71
|
+
return identity;
|
|
72
|
+
context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
|
|
73
|
+
const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
|
|
74
|
+
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
|
|
75
|
+
appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
|
|
76
|
+
throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
77
|
+
}
|
|
78
|
+
/** A Work terminal command belongs to the Session that started that Work.
|
|
79
|
+
* Controllers can still use their explicit non-agent operations; they cannot
|
|
80
|
+
* borrow a native worker's authority by naming its Work id. */
|
|
81
|
+
export function assertWorkLifecycleOwner(context, input) {
|
|
82
|
+
ensureWorkRegistry(context);
|
|
83
|
+
const work = requireWork(context, input.workId);
|
|
84
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
85
|
+
if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
86
|
+
throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
|
|
87
|
+
const owner = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
88
|
+
if (!owner)
|
|
89
|
+
throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
|
|
90
|
+
const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
|
|
91
|
+
if (ownsPhysicalWorkSession(context, work.project_id, owner, identity))
|
|
92
|
+
return identity;
|
|
93
|
+
context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
|
|
94
|
+
const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
|
|
95
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
|
|
96
|
+
appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
|
|
97
|
+
throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
98
|
+
}
|
|
15
99
|
export function createChildWork(context, input) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
100
|
+
return context.db.writeTransaction(() => {
|
|
101
|
+
const parent = requireWork(context, input.parentWorkId);
|
|
102
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
103
|
+
if (parent.status !== "running")
|
|
104
|
+
throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
|
|
105
|
+
const id = nextWorkId(context, parent.project_id, input.slug);
|
|
106
|
+
const now = context.now();
|
|
107
|
+
context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
|
|
108
|
+
if (input.stage)
|
|
109
|
+
bindStageWork(context, { projectId: parent.project_id, runId: parent.run_id, workId: id, stage: input.stage });
|
|
110
|
+
refreshRunWorkProjection(context, parent.project_id, parent.run_id);
|
|
111
|
+
return requireWork(context, id);
|
|
112
|
+
});
|
|
24
113
|
}
|
|
25
114
|
/** Validate a proposed batch before PLAN accepts it, without registering Work. */
|
|
26
115
|
export function validateWorkBatchFile(file) {
|
|
27
|
-
|
|
28
|
-
|
|
116
|
+
validateWorkBatch(prepareJsonFile(file, "work batch").value);
|
|
117
|
+
}
|
|
118
|
+
/** Validate an already prepared batch without reopening its source file. */
|
|
119
|
+
export function validateWorkBatch(value) {
|
|
120
|
+
const parsed = value;
|
|
121
|
+
if (!parsed || !Array.isArray(parsed.works) || parsed.works.length === 0)
|
|
29
122
|
throw new AppError("validation", "work batch requires non-empty works", 2);
|
|
30
123
|
const items = parsed.works.map((item) => validateItem(item, true));
|
|
31
124
|
const keys = new Set(items.map((item) => item.key));
|
|
@@ -41,18 +134,29 @@ export function validateWorkBatchFile(file) {
|
|
|
41
134
|
assertNoCycles(items.map((item) => ({ id: item.key, dependencies: item.depends_on ?? [] })));
|
|
42
135
|
assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map((item) => ({ id: item.key, parent: item.parent })));
|
|
43
136
|
}
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
throw new AppError("
|
|
49
|
-
const parsed =
|
|
50
|
-
if (!Array.isArray(parsed.works) || parsed.works.length === 0)
|
|
137
|
+
export function prepareWorkBatch(context, input) {
|
|
138
|
+
if (input.file !== undefined && input.value !== undefined)
|
|
139
|
+
throw new AppError("usage", "Work batch accepts either file or value, not both", 2);
|
|
140
|
+
if (input.file === undefined && input.value === undefined)
|
|
141
|
+
throw new AppError("usage", "Work batch input is required", 2);
|
|
142
|
+
const parsed = (input.file !== undefined ? prepareJsonFile(input.file, "work batch").value : input.value);
|
|
143
|
+
if (!parsed || !Array.isArray(parsed.works) || parsed.works.length === 0)
|
|
51
144
|
throw new AppError("validation", "work batch requires non-empty works", 2);
|
|
52
145
|
const items = parsed.works.map((item) => validateItem(item));
|
|
53
146
|
const keys = new Set(items.map((item) => item.key));
|
|
54
147
|
if (keys.size !== items.length)
|
|
55
148
|
throw new AppError("validation", "work batch keys must be unique", 2);
|
|
149
|
+
assertNoCycles(items.map(item => ({ id: item.key, dependencies: item.depends_on ?? [] })));
|
|
150
|
+
assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map(item => ({ id: item.key, parent: item.parent })));
|
|
151
|
+
prepareBatchParent(context, input.parentWorkId, items);
|
|
152
|
+
return items;
|
|
153
|
+
}
|
|
154
|
+
function prepareBatchParent(context, parentWorkId, items) {
|
|
155
|
+
const keys = new Set(items.map(item => item.key));
|
|
156
|
+
const parent = requireWork(context, parentWorkId);
|
|
157
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
158
|
+
if (parent.status !== "running")
|
|
159
|
+
throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
|
|
56
160
|
const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
|
|
57
161
|
const existingIds = new Set(existing.map((row) => row.work_id));
|
|
58
162
|
for (const item of items)
|
|
@@ -62,9 +166,15 @@ export function addWorkBatch(context, input) {
|
|
|
62
166
|
for (const item of items)
|
|
63
167
|
if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
|
|
64
168
|
throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
169
|
+
return parent;
|
|
170
|
+
}
|
|
171
|
+
export function addWorkBatch(context, input) {
|
|
172
|
+
const items = input.prepared ?? prepareWorkBatch(context, input);
|
|
173
|
+
return context.db.writeTransaction(() => {
|
|
174
|
+
// Only mutable authority/references are rechecked under the write lock.
|
|
175
|
+
// Caller bytes were read once, before admission, and are never reopened.
|
|
176
|
+
const parent = prepareBatchParent(context, input.parentWorkId, items);
|
|
177
|
+
const now = context.now();
|
|
68
178
|
const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
|
|
69
179
|
const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
|
|
70
180
|
const resolve = (value) => ids.get(value) ?? value;
|
|
@@ -74,16 +184,17 @@ export function addWorkBatch(context, input) {
|
|
|
74
184
|
for (const item of proposed)
|
|
75
185
|
context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
|
|
76
186
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'created', NULL, NULL, ?, ?, NULL)`, [item.id, parent.project_id, parent.run_id, item.parentId, item.task, item.launch_policy ?? "reuse_allowed", item.result_schema ?? null, item.payload ? JSON.stringify(item.payload) : null, JSON.stringify(item.dependencies), now, now]);
|
|
77
|
-
context.db.
|
|
78
|
-
|
|
187
|
+
const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [parent.project_id, parent.run_id]);
|
|
188
|
+
const stages = run ? (JSON.parse(run.index_json).stage_runs ?? []) : [];
|
|
189
|
+
const ownerStage = input.stage ?? [...stages].reverse().find(stage => stage.status === "running")?.stage;
|
|
190
|
+
for (const item of proposed) {
|
|
191
|
+
if (ownerStage)
|
|
192
|
+
bindStageWork(context, { projectId: parent.project_id, runId: parent.run_id, workId: item.id, stage: ownerStage, ...(input.cycle ? { cycle: input.cycle } : {}) });
|
|
79
193
|
appendFlowRunTimelineEvent(context, parent.project_id, parent.run_id, { type: "work_materialized", work_id: item.id, parent_work_id: item.parentId, depends_on: item.dependencies, launch_policy: item.launch_policy ?? "reuse_allowed" });
|
|
194
|
+
}
|
|
80
195
|
refreshRunWorkProjection(context, parent.project_id, parent.run_id);
|
|
81
196
|
return { ok: true, work_ids: Object.fromEntries(ids) };
|
|
82
|
-
}
|
|
83
|
-
catch (error) {
|
|
84
|
-
context.db.exec("ROLLBACK");
|
|
85
|
-
throw error;
|
|
86
|
-
}
|
|
197
|
+
});
|
|
87
198
|
}
|
|
88
199
|
export function listWorks(context, input) {
|
|
89
200
|
ensureWorkRegistry(context);
|
|
@@ -103,19 +214,146 @@ export function listWorks(context, input) {
|
|
|
103
214
|
where.push("status = ?");
|
|
104
215
|
params.push(input.status);
|
|
105
216
|
}
|
|
106
|
-
const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params);
|
|
107
|
-
const ready = (work) => isReady(context, work);
|
|
217
|
+
const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params).filter(work => !input.workIds || input.workIds.has(work.work_id));
|
|
218
|
+
const ready = (work) => (input.workIds || currentStageWorkIds(context, work.project_id, work.run_id))?.has(work.work_id) !== false && isReady(context, work);
|
|
108
219
|
const filtered = input.ready ? rows.filter(ready) : rows;
|
|
109
220
|
return { works: filtered.slice(0, input.limit ?? filtered.length).map((work) => {
|
|
110
221
|
const isReadyNow = ready(work);
|
|
111
222
|
return { ...work, payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), ready: isReadyNow, ...(isReadyNow ? { start_command: workStartCommand(context, work) } : {}), ...(input.includeResults ? {} : { result: undefined }) };
|
|
112
223
|
}) };
|
|
113
224
|
}
|
|
114
|
-
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? ORDER BY created_at", [work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions } }; }
|
|
225
|
+
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id, ws.status, ws.prompt_path, ws.result_path, ws.created_at, ws.completed_at, s.harness AS harness_id, COALESCE(s.provider_session_id, s.session_id) AS native_session_id FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at", [work.project_id, work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions: sessions.map(({ session_id, harness_id, native_session_id, ...session }) => ({ ...session, ...(harness_id && native_session_id ? { session: publicSessionIdentity({ harness: harness_id, provider_session_id: native_session_id, session_id }) } : {}) })) } }; }
|
|
226
|
+
function receiptIntentPath(resultPath, sessionId) {
|
|
227
|
+
return `${resultPath}.pending-${crypto.createHash("sha256").update(sessionId).digest("hex")}`;
|
|
228
|
+
}
|
|
229
|
+
function receiptFileHash(file) {
|
|
230
|
+
return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
|
|
231
|
+
}
|
|
232
|
+
function sha256(contents) { return crypto.createHash("sha256").update(contents).digest("hex"); }
|
|
233
|
+
function publishReceiptFile(file, contents) {
|
|
234
|
+
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFileSync(temporary, contents);
|
|
237
|
+
const descriptor = fs.openSync(temporary, "r");
|
|
238
|
+
try {
|
|
239
|
+
fs.fsyncSync(descriptor);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
fs.closeSync(descriptor);
|
|
243
|
+
}
|
|
244
|
+
fs.renameSync(temporary, file);
|
|
245
|
+
const directory = fs.openSync(path.dirname(file), "r");
|
|
246
|
+
try {
|
|
247
|
+
fs.fsyncSync(directory);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
fs.closeSync(directory);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
fs.rmSync(temporary, { force: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Rebuild prompt/context projections from a committed Work-start receipt. */
|
|
258
|
+
export function materializePendingWorkStarts(context, projectId, runId) {
|
|
259
|
+
const runHome = requireRunHome(requireRun(context, projectId, runId));
|
|
260
|
+
context.db.writeTransaction(() => {
|
|
261
|
+
const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' AND ws.start_receipt_json IS NOT NULL", [projectId, runId]);
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
// The same coordinator Session can move on to a different stage packet.
|
|
264
|
+
// A start receipt is not authority to overwrite that later packet.
|
|
265
|
+
let receipt;
|
|
266
|
+
try {
|
|
267
|
+
receipt = JSON.parse(row.start_receipt_json);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
|
|
271
|
+
}
|
|
272
|
+
const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
|
|
273
|
+
if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
|
|
274
|
+
continue;
|
|
275
|
+
if (receipt.version !== 1 || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
|
|
276
|
+
throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
|
|
277
|
+
publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
|
|
278
|
+
publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function publishStartReceiptFile(file, contents, expectedHash) {
|
|
283
|
+
const actual = receiptFileHash(file);
|
|
284
|
+
if (actual === expectedHash)
|
|
285
|
+
return;
|
|
286
|
+
if (actual !== null) {
|
|
287
|
+
throw new AppError("work_start_projection_conflict", "Work start packet differs from its committed receipt; preserve it for recovery", 1, { path: file, expected_sha256: expectedHash, actual_sha256: actual });
|
|
288
|
+
}
|
|
289
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
290
|
+
publishReceiptFile(file, contents);
|
|
291
|
+
}
|
|
292
|
+
/** Replay only a prepared publication whose exact receipt was committed in SQLite. */
|
|
293
|
+
export function materializePendingWorkResults(context, projectId, runId) {
|
|
294
|
+
context.db.beginWriteTransaction();
|
|
295
|
+
try {
|
|
296
|
+
const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL`, [projectId, runId]);
|
|
297
|
+
for (const row of rows) {
|
|
298
|
+
const intentPath = receiptIntentPath(row.result_path, row.id);
|
|
299
|
+
if (!fs.existsSync(intentPath))
|
|
300
|
+
continue;
|
|
301
|
+
const intent = JSON.parse(fs.readFileSync(intentPath, "utf8"));
|
|
302
|
+
const digest = crypto.createHash("sha256").update(row.result).digest("hex");
|
|
303
|
+
if (intent.work_id !== row.work_id || intent.work_session_id !== row.id || intent.status !== row.status || intent.result_sha256 !== digest)
|
|
304
|
+
throw new AppError("work_result_publication_conflict", "Pending result publication does not identify the committed receipt", 1, { work_id: row.work_id, intent_path: intentPath });
|
|
305
|
+
const actual = receiptFileHash(row.result_path);
|
|
306
|
+
if (actual !== digest && actual !== intent.previous_sha256)
|
|
307
|
+
throw new AppError("work_result_artifact_drift", "Result changed after its publication was prepared; preserve it for diagnosis", 1, { work_id: row.work_id, actual_sha256: actual, expected_sha256: digest });
|
|
308
|
+
if (fs.existsSync(row.result_path)) {
|
|
309
|
+
const previous = fs.readFileSync(row.result_path);
|
|
310
|
+
if (!previous.equals(Buffer.from(row.result))) {
|
|
311
|
+
const retained = `${row.result_path}.conflict-${crypto.createHash("sha256").update(previous).digest("hex")}`;
|
|
312
|
+
try {
|
|
313
|
+
fs.writeFileSync(retained, previous, { flag: "wx", flush: true });
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error.code !== "EEXIST" || !fs.readFileSync(retained).equals(previous))
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
publishReceiptFile(row.result_path, row.result);
|
|
322
|
+
fs.unlinkSync(intentPath);
|
|
323
|
+
}
|
|
324
|
+
context.db.exec("COMMIT");
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
context.db.exec("ROLLBACK");
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
115
331
|
export function mutateWorkDeps(context, input) {
|
|
332
|
+
if (input.action === "list") {
|
|
333
|
+
const { work, dependencies } = prepareWorkDeps(context, input);
|
|
334
|
+
return { work_id: work.work_id, depends_on: dependencies };
|
|
335
|
+
}
|
|
336
|
+
prepareWorkDeps(context, input);
|
|
337
|
+
return context.db.writeTransaction(() => {
|
|
338
|
+
const { work, dependencies } = prepareWorkDeps(context, input);
|
|
339
|
+
context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify(dependencies), context.now(), work.work_id]);
|
|
340
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
341
|
+
return { work_id: work.work_id, depends_on: dependencies };
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
export function prepareWorkDeps(context, input) {
|
|
345
|
+
if (!["list", "add", "remove", "clear"].includes(input.action) || (input.on !== undefined && (!Array.isArray(input.on) || input.on.some(id => typeof id !== "string" || !id.trim()))))
|
|
346
|
+
throw new AppError("usage", "Invalid Work dependency action or --on target", 2);
|
|
347
|
+
if (["add", "remove"].includes(input.action) && !input.on?.length)
|
|
348
|
+
throw new AppError("usage", "Dependency add/remove requires at least one --on target", 2);
|
|
349
|
+
if (["list", "clear"].includes(input.action) && input.on?.length)
|
|
350
|
+
throw new AppError("usage", "Dependency list/clear does not accept --on targets", 2);
|
|
351
|
+
if (input.action === "list") {
|
|
352
|
+
const work = requireWork(context, input.workId);
|
|
353
|
+
return { work, dependencies: parseDependencies(work) };
|
|
354
|
+
}
|
|
116
355
|
const work = requireWork(context, input.workId);
|
|
117
|
-
|
|
118
|
-
return { work_id: work.work_id, depends_on: parseDependencies(work) };
|
|
356
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
119
357
|
if (work.status !== "created")
|
|
120
358
|
throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
|
|
121
359
|
const dependencies = new Set(parseDependencies(work));
|
|
@@ -123,8 +361,8 @@ export function mutateWorkDeps(context, input) {
|
|
|
123
361
|
dependencies.clear();
|
|
124
362
|
for (const id of input.on ?? []) {
|
|
125
363
|
const dependency = requireWork(context, id);
|
|
126
|
-
if (dependency.run_id !== work.run_id)
|
|
127
|
-
throw new AppError("validation", "Dependencies must stay in one RUN", 2);
|
|
364
|
+
if (dependency.project_id !== work.project_id || dependency.run_id !== work.run_id)
|
|
365
|
+
throw new AppError("validation", "Dependencies must stay in one project and RUN", 2);
|
|
128
366
|
if (input.action === "add")
|
|
129
367
|
dependencies.add(dependency.work_id);
|
|
130
368
|
else
|
|
@@ -133,56 +371,76 @@ export function mutateWorkDeps(context, input) {
|
|
|
133
371
|
if (dependencies.has(work.work_id))
|
|
134
372
|
throw new AppError("validation", "Work cannot depend on itself", 2);
|
|
135
373
|
assertNoCycles(context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [work.project_id, work.run_id]).map((row) => ({ id: row.work_id, dependencies: row.work_id === work.work_id ? [...dependencies] : parseDependencies(row) })));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
374
|
+
return { work, dependencies: [...dependencies] };
|
|
375
|
+
}
|
|
376
|
+
export function prepareWorkDelete(context, id) {
|
|
377
|
+
const work = requireWork(context, id);
|
|
378
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
379
|
+
if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [work.work_id]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${work.work_id}%`]))
|
|
380
|
+
throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2);
|
|
381
|
+
return work;
|
|
139
382
|
}
|
|
140
|
-
export function deleteWork(context, id) {
|
|
141
|
-
|
|
142
|
-
|
|
383
|
+
export function deleteWork(context, id) {
|
|
384
|
+
prepareWorkDelete(context, id);
|
|
385
|
+
return context.db.writeTransaction(() => { const work = prepareWorkDelete(context, id); context.db.run("DELETE FROM works WHERE work_id = ?", [work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: work.work_id }; });
|
|
386
|
+
}
|
|
387
|
+
export function shortWorkId(id) { return shortEntityReference(id); }
|
|
143
388
|
/**
|
|
144
389
|
* Return the engine-bound command, never a PATH-dependent `dd-flow` token.
|
|
145
390
|
* Fan-out workers run in independently spawned harnesses where the adapter's
|
|
146
391
|
* PATH is not inherited; using the shared lifecycle command keeps them on the
|
|
147
392
|
* same captured runtime as their parent stage.
|
|
148
393
|
*/
|
|
149
|
-
export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id);
|
|
150
|
-
export function
|
|
394
|
+
export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, runtimeCommand(context, ["work", "start", shortWorkId(work.work_id), "--project-root", run.project_root, ...(recovery ? ["--recovery-id", recovery.recovery_id] : []), "--json"])); }
|
|
395
|
+
export function assertWorkLaunchReady(context, id) {
|
|
151
396
|
ensureWorkRegistry(context);
|
|
152
397
|
const work = requireWork(context, id);
|
|
398
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
153
399
|
if (work.status !== "created")
|
|
154
400
|
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
|
|
155
401
|
if (!isReady(context, work)) {
|
|
156
402
|
const blockers = readinessBlockers(context, work);
|
|
157
|
-
const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
|
|
403
|
+
const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => currentStageWorkIds(context, candidate.project_id, candidate.run_id)?.has(candidate.work_id) !== false && isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
|
|
158
404
|
throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
|
|
159
405
|
}
|
|
406
|
+
return work;
|
|
407
|
+
}
|
|
408
|
+
export function startWork(context, id, input) {
|
|
409
|
+
if (input.hookEventId && !context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
410
|
+
const retainedWork = requireWork(context, id);
|
|
411
|
+
const retainedRun = requireRun(context, retainedWork.project_id, retainedWork.run_id);
|
|
412
|
+
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(retainedRun.project_root))
|
|
413
|
+
throw new AppError("project_mismatch", "Work start belongs to another project", 1);
|
|
414
|
+
const reply = recoverCommittedWorkStart(context, null, input.hookEventId, recoveryGuard(context, retainedWork.project_id, retainedWork.run_id)?.generation ?? 0, retainedWork.work_id);
|
|
415
|
+
if (reply)
|
|
416
|
+
return reply;
|
|
417
|
+
}
|
|
418
|
+
const work = assertWorkLaunchReady(context, id);
|
|
160
419
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
161
420
|
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
162
421
|
throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root });
|
|
173
|
-
const started = startBoundWork(context, work, run, identity, hookEventId);
|
|
174
|
-
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
|
|
422
|
+
const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
|
|
423
|
+
if (input.recoveryId !== recoveryId)
|
|
424
|
+
throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
|
|
425
|
+
const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: entityReferenceVariants(work.work_id).map((reference) => workStartMatchKey(reference, run.project_root, recoveryId)), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
426
|
+
const eventKey = hookEventId;
|
|
427
|
+
const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
|
|
428
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
|
|
175
429
|
return started;
|
|
176
430
|
}
|
|
177
431
|
/** Binds the already-running coordinator of a stage to its trusted hook Session. */
|
|
178
432
|
export function bindRunningWorkSession(context, input) {
|
|
179
433
|
const work = requireWork(context, input.workId);
|
|
434
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
180
435
|
if (work.status !== "running")
|
|
181
436
|
throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
|
|
182
437
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
183
438
|
const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
|
|
439
|
+
assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
|
|
184
440
|
const active = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
185
441
|
if (active) {
|
|
442
|
+
if (active.session_id === identity.sessionId && !ownsPhysicalWorkSession(context, work.project_id, active, identity))
|
|
443
|
+
throw new AppError("handoff_session_mismatch", "The active Work belongs to another physical daemon", 1, { work_id: work.work_id });
|
|
186
444
|
if (active.session_id !== identity.sessionId) {
|
|
187
445
|
// A frozen `new_session` policy may transfer the same coordinator Work
|
|
188
446
|
// only to its next stage. Repeating the same stage from another Session
|
|
@@ -191,7 +449,7 @@ export function bindRunningWorkSession(context, input) {
|
|
|
191
449
|
throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
|
|
192
450
|
const now = context.now();
|
|
193
451
|
const id = `WS-${crypto.randomUUID()}`;
|
|
194
|
-
context.db.
|
|
452
|
+
context.db.beginWriteTransaction();
|
|
195
453
|
try {
|
|
196
454
|
context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
|
|
197
455
|
const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
|
|
@@ -213,7 +471,7 @@ export function bindRunningWorkSession(context, input) {
|
|
|
213
471
|
}
|
|
214
472
|
const now = context.now();
|
|
215
473
|
const id = `WS-${crypto.randomUUID()}`;
|
|
216
|
-
context.db.
|
|
474
|
+
context.db.beginWriteTransaction();
|
|
217
475
|
try {
|
|
218
476
|
bindSession(context, work, run, identity, now);
|
|
219
477
|
context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
|
|
@@ -229,10 +487,22 @@ export function bindRunningWorkSession(context, input) {
|
|
|
229
487
|
/** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
|
|
230
488
|
export function bindStageCoordinatorWork(context, input) {
|
|
231
489
|
const work = requireWork(context, input.workId);
|
|
490
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
232
491
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
233
|
-
claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
234
492
|
const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
|
|
235
|
-
const
|
|
493
|
+
const profile = snapshot.execution_profile;
|
|
494
|
+
const previous = context.db.get("SELECT ws.session_id, s.current_stage FROM work_sessions ws JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? AND ws.status = 'running' ORDER BY ws.created_at DESC LIMIT 1", [work.project_id, work.work_id]);
|
|
495
|
+
let sessionMode = profile?.settings.stage_session_mode ?? "same_session";
|
|
496
|
+
if (profile?.settings.execution) {
|
|
497
|
+
if (!profile.agent_profiles)
|
|
498
|
+
throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
|
|
499
|
+
sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
500
|
+
}
|
|
501
|
+
const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
|
|
502
|
+
if (previous?.current_stage && previous.current_stage !== input.stage && sessionMode === "new_session" && previous.session_id === identity.sessionId)
|
|
503
|
+
throw new AppError("stage_handoff_required", "This stage requires a fresh coordinator Session under its frozen execution policy", 1, { from_stage: previous.current_stage, stage: input.stage });
|
|
504
|
+
claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
505
|
+
const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: sessionMode === "new_session" });
|
|
236
506
|
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, binding.session_id]);
|
|
237
507
|
refreshRunSessionProjection(context, work.project_id, work.run_id);
|
|
238
508
|
return binding;
|
|
@@ -240,78 +510,294 @@ export function bindStageCoordinatorWork(context, input) {
|
|
|
240
510
|
/** Starts a created coordinator Work from its trusted `stage start` event. */
|
|
241
511
|
export function startStageCoordinatorWork(context, input) {
|
|
242
512
|
const work = requireWork(context, input.workId);
|
|
513
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
243
514
|
if (work.status !== "created")
|
|
244
515
|
throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
245
516
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
246
517
|
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
247
518
|
throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
|
|
248
|
-
const
|
|
249
|
-
const binding = startBoundWork(context, work, run, identity, input.hookEventId);
|
|
250
|
-
const sessionId = String(binding.session_binding.session_id ?? "");
|
|
251
|
-
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
|
|
519
|
+
const binding = startBoundWork(context, work, run, () => claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) }), input.hookEventId, input.stage);
|
|
252
520
|
refreshRunSessionProjection(context, work.project_id, work.run_id);
|
|
253
521
|
return binding;
|
|
254
522
|
}
|
|
255
|
-
/**
|
|
256
|
-
export function startBoundWork(context, work, run,
|
|
257
|
-
|
|
258
|
-
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
259
|
-
const now = context.now();
|
|
260
|
-
const linkId = `WS-${crypto.randomUUID()}`;
|
|
523
|
+
/** Claim, command authority and binding commit together; only packet projection follows. */
|
|
524
|
+
export function startBoundWork(context, work, run, claimIdentity, hookEventId, stage) {
|
|
525
|
+
const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
261
526
|
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
527
|
+
const linkId = `WS-${crypto.randomUUID()}`;
|
|
528
|
+
// Recovery can reopen the same Work without retiring its historical packet.
|
|
529
|
+
// Give each new-generation start its own immutable publication destination.
|
|
530
|
+
const packetDirectory = generation > 0 ? path.join(directory, "starts", linkId) : directory;
|
|
531
|
+
const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
|
|
532
|
+
// The first reviewer freezes shared inputs in the same transaction as its claim.
|
|
533
|
+
const readOnly = parsePayload(work)?.read_only === true;
|
|
534
|
+
let fingerprint;
|
|
535
|
+
let binding;
|
|
266
536
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
context
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
537
|
+
binding = context.db.writeTransaction(() => {
|
|
538
|
+
const current = requireWork(context, work.work_id);
|
|
539
|
+
assertRunMutationAllowed(context, current.project_id, current.run_id, generation);
|
|
540
|
+
if (current.status !== "created")
|
|
541
|
+
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id, status: current.status });
|
|
542
|
+
if (Object.keys(current).some(key => current[key] !== work[key]))
|
|
543
|
+
throw new AppError("work_not_ready", "Work inputs changed while preparing its start packet", 2, { work_id: current.work_id });
|
|
544
|
+
if (!isReady(context, current))
|
|
545
|
+
throw new AppError("work_not_ready", "Work dependencies are not ready", 2, { work_id: current.work_id, blockers: readinessBlockers(context, current) });
|
|
546
|
+
if (fs.existsSync(path.join(directory, ".retry.json")))
|
|
547
|
+
throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
|
|
548
|
+
const identity = claimIdentity();
|
|
549
|
+
assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
|
|
550
|
+
const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
551
|
+
const recovery = recoveryPromptForWork(context, current.work_id);
|
|
552
|
+
let prompt = renderWorkerPrompt(context, current, run, dependencyResults);
|
|
553
|
+
if (recovery)
|
|
554
|
+
prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
|
|
555
|
+
if (readOnly)
|
|
556
|
+
fingerprint = sharedReviewInput(work, run, true) ?? workspaceFingerprint(run.workspace_root);
|
|
557
|
+
const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint } : {}) };
|
|
558
|
+
const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
|
|
559
|
+
const response = { ok: true, work_id: current.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
560
|
+
const receipt = JSON.stringify({ version: 1, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
|
|
561
|
+
const now = context.now();
|
|
562
|
+
bindSession(context, current, run, identity, now);
|
|
563
|
+
if (current.parent_work_id && current.launch_policy === "reuse_allowed")
|
|
564
|
+
context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, current.work_id]);
|
|
565
|
+
if (context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, current.work_id]).changes !== 1)
|
|
566
|
+
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id });
|
|
567
|
+
context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
|
|
568
|
+
if (stage)
|
|
569
|
+
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [stage, now, current.project_id, identity.sessionId]);
|
|
570
|
+
if (recovery)
|
|
571
|
+
activateRecoveryWork(context, current.work_id);
|
|
572
|
+
return response;
|
|
573
|
+
});
|
|
275
574
|
}
|
|
276
575
|
catch (error) {
|
|
277
|
-
|
|
576
|
+
// This transaction publishes only SQL authority, never files or provider calls.
|
|
577
|
+
if (error instanceof AppError && error.code === "storage_write_failed" && error.details.transaction_rolled_back === true)
|
|
578
|
+
error.details.effect = "no_effect";
|
|
579
|
+
if (error instanceof AppError && error.details.effect === "no_effect" && !context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
580
|
+
let retry = false;
|
|
581
|
+
try {
|
|
582
|
+
retry = context.db.writeTransaction(() => {
|
|
583
|
+
if (context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.work_id') = ? AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.storage_retry') = 1", [work.project_id, work.work_id, generation]))
|
|
584
|
+
return false;
|
|
585
|
+
return context.db.run("UPDATE hook_events SET outcome_json = ? WHERE project_id = ? AND event_key = ? AND status = 'observed' AND outcome_json IS NULL", [JSON.stringify({ work_id: work.work_id, run_id: work.run_id, generation, effect: "no_effect", disposition: "retrying", storage_retry: 1, deadline: Date.now() + 60000 }), work.project_id, hookEventId]).changes === 1;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (persistence) {
|
|
589
|
+
error.details.retry_persistence_error = String(persistence);
|
|
590
|
+
}
|
|
591
|
+
if (retry)
|
|
592
|
+
return startBoundWork(context, work, run, claimIdentity, hookEventId, stage);
|
|
593
|
+
}
|
|
278
594
|
throw error;
|
|
279
595
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
596
|
+
try {
|
|
597
|
+
materializePendingWorkStarts(context, work.project_id, work.run_id);
|
|
598
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
if (error instanceof AppError) {
|
|
602
|
+
error.details.effect = "committed";
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
throw new AppError("work_start_publication_failed", error instanceof Error ? error.message : String(error), 1, { effect: "committed", work_id: work.work_id, hook_event_id: hookEventId, cause: { code: error?.code ?? null } });
|
|
606
|
+
}
|
|
607
|
+
return binding;
|
|
608
|
+
}
|
|
609
|
+
/** Read the exact committed reply, never bind another Session or regenerate a packet. */
|
|
610
|
+
export function recoverCommittedWorkStart(context, invocationId, eventKey, generation, workId) {
|
|
611
|
+
const row = context.db.get("SELECT ws.start_receipt_json, w.project_id, w.run_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE ws.start_receipt_json IS NOT NULL AND ws.hook_event_id = ? AND json_extract(ws.start_receipt_json, '$.invocation_id') IS ? AND (? IS NULL OR w.work_id = ?)", [eventKey, invocationId, workId ?? null, workId ?? null]);
|
|
612
|
+
if (!row)
|
|
613
|
+
return undefined;
|
|
614
|
+
const receipt = JSON.parse(row.start_receipt_json);
|
|
615
|
+
if (receipt.generation !== generation || !receipt.response || receipt.response_sha256 !== sha256(JSON.stringify(receipt.response)))
|
|
616
|
+
throw new AppError("work_start_receipt_invalid", "Committed reply identity or checksum differs", 1, { invocation_id: invocationId });
|
|
617
|
+
assertRunMutationAllowed(context, row.project_id, row.run_id, generation);
|
|
618
|
+
materializePendingWorkStarts(context, row.project_id, row.run_id);
|
|
619
|
+
return receipt.response;
|
|
620
|
+
}
|
|
621
|
+
/** Root coordinators have no worker checks: settle them inside the stage transaction. */
|
|
622
|
+
export function finishStageCoordinatorWork(context, id, result) {
|
|
623
|
+
const work = requireWork(context, id);
|
|
624
|
+
if (work.parent_work_id !== null || codePacket(work) || parsePayload(work)?.read_only)
|
|
625
|
+
throw new AppError("invalid_work_state", "Stage settlement requires a root coordinator", 2);
|
|
626
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
627
|
+
const link = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
|
|
628
|
+
if (!link)
|
|
629
|
+
throw new AppError("runtime_missing", "Coordinator has no open Work/Session link", 1);
|
|
630
|
+
validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link.result_path);
|
|
631
|
+
finishFanInWork(context, id, result);
|
|
632
|
+
context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ? AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')", [context.now(), work.project_id, link.session_id, link.session_id]);
|
|
633
|
+
settleRecoveryWork(context, id, "completed");
|
|
286
634
|
}
|
|
287
|
-
export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
|
|
635
|
+
export function finishWork(context, id, result, progress, retry) { return settle(context, id, "completed", result, progress, retry); }
|
|
288
636
|
/** Structured fan-in closes a parent after its Session was handed to a child. */
|
|
289
|
-
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
|
|
290
|
-
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running') LIMIT 1", [work.work_id]))
|
|
637
|
+
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
|
|
638
|
+
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
|
|
291
639
|
throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
|
|
292
640
|
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
293
641
|
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
294
|
-
export function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (fs.existsSync(source))
|
|
298
|
-
fs.renameSync(source, path.join(archive, file));
|
|
299
|
-
} context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
|
|
300
|
-
async function settle(context, id, status, result, progress) {
|
|
642
|
+
export function prepareWorkSettlement(context, id, status, result) {
|
|
643
|
+
if (status !== "completed" && (typeof result !== "string" || !result.trim()))
|
|
644
|
+
throw new AppError("usage", "Work settlement requires a nonempty --reason", 2);
|
|
301
645
|
const work = requireWork(context, id);
|
|
302
|
-
|
|
646
|
+
assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
|
|
303
647
|
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
304
648
|
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
305
|
-
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running') LIMIT 1", [
|
|
306
|
-
throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id:
|
|
649
|
+
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [work.work_id]))
|
|
650
|
+
throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: work.work_id });
|
|
307
651
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
308
|
-
const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [
|
|
652
|
+
const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
309
653
|
if (work.status === "running" && !link)
|
|
310
|
-
throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id:
|
|
654
|
+
throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: work.work_id });
|
|
655
|
+
return { work, run, link };
|
|
656
|
+
}
|
|
657
|
+
export function prepareWorkRetry(context, id, reason) {
|
|
658
|
+
if (typeof reason !== "string" || !reason.trim())
|
|
659
|
+
throw new AppError("usage", "Work retry requires a nonempty --reason", 2);
|
|
660
|
+
const work = requireWork(context, id);
|
|
661
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
662
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
663
|
+
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
664
|
+
const journal = path.join(directory, ".retry.json");
|
|
665
|
+
let pending = null;
|
|
666
|
+
if (fs.existsSync(journal)) {
|
|
667
|
+
let value;
|
|
668
|
+
try {
|
|
669
|
+
value = JSON.parse(fs.readFileSync(journal, "utf8"));
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (!(error instanceof SyntaxError))
|
|
673
|
+
throw error;
|
|
674
|
+
throw new AppError("work_retry_invalid", "Retry journal is not valid JSON", 1);
|
|
675
|
+
}
|
|
676
|
+
const item = value;
|
|
677
|
+
if (!item || typeof item !== "object" || item.work_id !== work.work_id || typeof item.archive !== "string" || !/^ATT-\d{3,}$/.test(item.archive) || !Array.isArray(item.entries) || item.entries.some(file => !["prompt.md", "result.json", "context.json"].includes(file)) || new Set(item.entries).size !== item.entries.length || !(item.result_sha256 === null || typeof item.result_sha256 === "string" && /^[a-f0-9]{64}$/.test(item.result_sha256)))
|
|
678
|
+
throw new AppError("work_retry_invalid", "Retry journal does not identify this Work", 1);
|
|
679
|
+
pending = item;
|
|
680
|
+
}
|
|
681
|
+
if (work.status !== "failed" && !(pending && work.status === "created"))
|
|
682
|
+
throw new AppError("invalid_work_state", "Only failed Work may be retried", 2);
|
|
683
|
+
const attempts = path.join(directory, "attempts");
|
|
684
|
+
let number = 1;
|
|
685
|
+
while (fs.existsSync(path.join(attempts, `ATT-${String(number).padStart(3, "0")}`)))
|
|
686
|
+
number += 1;
|
|
687
|
+
const prepared = pending ?? { work_id: work.work_id, archive: `ATT-${String(number).padStart(3, "0")}`, entries: ["prompt.md", "result.json", "context.json"].filter(file => fs.existsSync(path.join(directory, file))), result_sha256: work.result === null ? null : crypto.createHash("sha256").update(work.result).digest("hex") };
|
|
688
|
+
const archive = path.join(attempts, prepared.archive);
|
|
689
|
+
if (work.status === "failed") {
|
|
690
|
+
if (prepared.result_sha256 !== (work.result === null ? null : crypto.createHash("sha256").update(work.result).digest("hex")))
|
|
691
|
+
throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
|
|
692
|
+
const receipt = path.join(archive, "accepted-receipt.json");
|
|
693
|
+
if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== JSON.stringify({ work_id: work.work_id, status: work.status, result: work.result, result_sha256: prepared.result_sha256 }))
|
|
694
|
+
throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
|
|
695
|
+
for (const file of prepared.entries) {
|
|
696
|
+
const source = path.join(directory, file), target = path.join(archive, file);
|
|
697
|
+
if (fs.existsSync(source) && fs.existsSync(target))
|
|
698
|
+
throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
|
|
699
|
+
if (!fs.existsSync(source) && !fs.existsSync(target))
|
|
700
|
+
throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return { work, run, directory, journal, pending, prepared, archive };
|
|
704
|
+
}
|
|
705
|
+
export function retryWork(context, id, reason) {
|
|
706
|
+
const { work, run, directory, journal } = prepareWorkRetry(context, id, reason);
|
|
707
|
+
let archive = "";
|
|
708
|
+
context.db.beginWriteTransaction();
|
|
709
|
+
try {
|
|
710
|
+
const { work: current, pending, prepared, archive: targetArchive } = prepareWorkRetry(context, work.work_id, reason);
|
|
711
|
+
archive = targetArchive;
|
|
712
|
+
if (current.status === "failed") {
|
|
713
|
+
if (prepared.result_sha256 !== (current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex")))
|
|
714
|
+
throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
|
|
715
|
+
fs.mkdirSync(archive, { recursive: true });
|
|
716
|
+
if (!pending)
|
|
717
|
+
publishReceiptFile(journal, JSON.stringify(prepared));
|
|
718
|
+
const receipt = path.join(archive, "accepted-receipt.json");
|
|
719
|
+
const receiptBytes = JSON.stringify({ work_id: current.work_id, status: current.status, result: current.result, result_sha256: prepared.result_sha256 });
|
|
720
|
+
if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== receiptBytes)
|
|
721
|
+
throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
|
|
722
|
+
if (!fs.existsSync(receipt))
|
|
723
|
+
publishReceiptFile(receipt, receiptBytes);
|
|
724
|
+
for (const file of prepared.entries) {
|
|
725
|
+
const source = path.join(directory, file), target = path.join(archive, file);
|
|
726
|
+
if (fs.existsSync(source)) {
|
|
727
|
+
if (fs.existsSync(target))
|
|
728
|
+
throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
|
|
729
|
+
fs.renameSync(source, target);
|
|
730
|
+
}
|
|
731
|
+
else if (!fs.existsSync(target))
|
|
732
|
+
throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
|
|
733
|
+
}
|
|
734
|
+
for (const folder of [archive, directory]) {
|
|
735
|
+
const descriptor = fs.openSync(folder, "r");
|
|
736
|
+
try {
|
|
737
|
+
fs.fsyncSync(descriptor);
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
fs.closeSync(descriptor);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
for (const column of ["prompt_path", "result_path"]) {
|
|
744
|
+
const file = column === "prompt_path" ? "prompt.md" : "result.json";
|
|
745
|
+
context.db.run(`UPDATE work_sessions SET ${column} = ? WHERE work_id = ? AND ${column} = ?`, [path.join(archive, file), work.work_id, path.join(directory, file)]);
|
|
746
|
+
}
|
|
747
|
+
context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'failed'", [context.now(), work.work_id]);
|
|
748
|
+
}
|
|
749
|
+
context.db.exec("COMMIT");
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
context.db.exec("ROLLBACK");
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
fs.rmSync(journal, { force: true });
|
|
756
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
757
|
+
return { ok: true, work_id: work.work_id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason };
|
|
758
|
+
}
|
|
759
|
+
function sharedReviewInput(work, run, initialize = false) {
|
|
760
|
+
const kind = parsePayload(work)?.kind;
|
|
761
|
+
const relative = kind === "code-review" ? "06-code-review/review-context.json" : kind === "plan-review" ? "04-plan-review/work-context.json" : null;
|
|
762
|
+
if (!relative || !work.parent_work_id)
|
|
763
|
+
return null;
|
|
764
|
+
const file = path.join(requireRunHome(run), relative);
|
|
765
|
+
if (!fs.existsSync(file))
|
|
766
|
+
return null;
|
|
767
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
768
|
+
if (value.read_only_input?.parent_work_id !== work.parent_work_id)
|
|
769
|
+
return null;
|
|
770
|
+
if (!value.read_only_input.fingerprint && initialize) {
|
|
771
|
+
// Called under the Work-start writer reservation, after stage setup has
|
|
772
|
+
// finished. The first reviewer accepts the input shared by this group.
|
|
773
|
+
value.read_only_input.fingerprint = workspaceFingerprint(run.workspace_root);
|
|
774
|
+
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
775
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
776
|
+
fs.renameSync(temporary, file);
|
|
777
|
+
}
|
|
778
|
+
return typeof value.read_only_input.fingerprint === "string" ? value.read_only_input.fingerprint : null;
|
|
779
|
+
}
|
|
780
|
+
async function settle(context, id, status, result, progress, retry) {
|
|
781
|
+
const { work, run, link } = prepareWorkSettlement(context, id, status, result);
|
|
782
|
+
id = work.work_id;
|
|
311
783
|
let receipts = [];
|
|
312
784
|
let coordinationDrift = [];
|
|
785
|
+
const assertReadOnlyInput = () => {
|
|
786
|
+
if (status === "completed" && parsePayload(work)?.read_only === true) {
|
|
787
|
+
const shared = sharedReviewInput(work, run);
|
|
788
|
+
// The final reviewer commits the shared input boundary under the writer
|
|
789
|
+
// reservation. Concurrent finishes cannot both skip this verification.
|
|
790
|
+
if (shared && context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND work_id <> ? AND status IN ('created','running','paused') AND json_extract(payload_json, '$.read_only') = 1 AND json_extract(payload_json, '$.kind') = ? LIMIT 1", [work.project_id, work.run_id, work.parent_work_id, work.work_id, parsePayload(work)?.kind]))
|
|
791
|
+
return;
|
|
792
|
+
const contextFile = link ? path.join(path.dirname(link.prompt_path), "context.json") : "";
|
|
793
|
+
const baseline = shared ?? (contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null);
|
|
794
|
+
const current = workspaceFingerprint(run.workspace_root);
|
|
795
|
+
if (typeof baseline !== "string" || baseline !== current)
|
|
796
|
+
throw new AppError("read_only_input_changed", "The workspace changed during read-only Work; its review input is stale (the writer is unknown)", 2, { work_id: work.work_id, baseline, current });
|
|
797
|
+
}
|
|
798
|
+
};
|
|
313
799
|
if (status === "completed") {
|
|
314
|
-
validateWorkResult(work, result, run.project_root, run.workspace_root, run.id, link?.result_path ?? null);
|
|
800
|
+
validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
|
|
315
801
|
const packet = codePacket(work);
|
|
316
802
|
if (packet) {
|
|
317
803
|
// CODE packets are projected from a PLAN that already validated this
|
|
@@ -320,19 +806,28 @@ async function settle(context, id, status, result, progress) {
|
|
|
320
806
|
readCodeCheckProfile(run.workspace_root);
|
|
321
807
|
coordinationDrift = plannedAreaDrift(packet, result);
|
|
322
808
|
const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
|
|
323
|
-
|
|
809
|
+
const requiredChecks = packet.repair?.verification_check_refs?.length
|
|
810
|
+
? uniqueChecks([...packet.checks.filter((check) => check.run_at === "work"), ...packet.repair.verification_check_refs])
|
|
811
|
+
: packet.checks.filter((check) => check.run_at === "work");
|
|
812
|
+
if (retry)
|
|
813
|
+
validateFailedCheckRetry(context, { projectId: work.project_id, runId: work.run_id, receiptId: retry.receiptId, declarations: requiredChecks, reason: retry.reason });
|
|
814
|
+
receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: requiredChecks, ...(retry ? { retryReceiptId: retry.receiptId, retryReason: retry.reason } : {}), ...(progress ? { progress } : {}) });
|
|
324
815
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
325
816
|
if (failed.length)
|
|
326
817
|
throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
|
|
327
818
|
}
|
|
328
819
|
}
|
|
329
820
|
const now = context.now();
|
|
330
|
-
context.db.
|
|
821
|
+
context.db.beginWriteTransaction();
|
|
331
822
|
try {
|
|
823
|
+
assertReadOnlyInput();
|
|
824
|
+
assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
|
|
825
|
+
if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
|
|
826
|
+
throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
|
|
827
|
+
if (link?.result_path && work.parent_work_id !== null)
|
|
828
|
+
publishReceiptFile(receiptIntentPath(link.result_path, link.id), JSON.stringify({ work_id: work.work_id, work_session_id: link.id, status, result_sha256: crypto.createHash("sha256").update(result).digest("hex"), previous_sha256: receiptFileHash(link.result_path) }));
|
|
332
829
|
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
|
|
333
830
|
if (link) {
|
|
334
|
-
if (link.result_path && work.parent_work_id !== null)
|
|
335
|
-
fs.writeFileSync(link.result_path, result);
|
|
336
831
|
context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
|
|
337
832
|
const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
|
|
338
833
|
if (!stillRunning)
|
|
@@ -344,39 +839,81 @@ async function settle(context, id, status, result, progress) {
|
|
|
344
839
|
context.db.exec("ROLLBACK");
|
|
345
840
|
throw error;
|
|
346
841
|
}
|
|
842
|
+
materializePendingWorkResults(context, work.project_id, work.run_id);
|
|
843
|
+
settleRecoveryWork(context, work.work_id, status);
|
|
347
844
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
348
|
-
const
|
|
845
|
+
const control = runControlDirective(context, work.project_id, work.run_id);
|
|
846
|
+
const newlyReady = control ? [] : context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
|
|
349
847
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
|
|
350
848
|
for (const ready of newlyReady)
|
|
351
849
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
|
|
352
|
-
return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
850
|
+
return { ok: true, ...(control ? { control } : {}), work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
353
851
|
}
|
|
852
|
+
function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
|
|
354
853
|
function plannedAreaDrift(packet, result) {
|
|
355
854
|
const changed = JSON.parse(result).changed_paths;
|
|
356
855
|
if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
|
|
357
856
|
return [];
|
|
358
|
-
return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) =>
|
|
857
|
+
return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => {
|
|
858
|
+
const normalized = area.replace(/[\\/]+$/, "");
|
|
859
|
+
return item === normalized || item.startsWith(`${normalized}/`);
|
|
860
|
+
}));
|
|
359
861
|
}
|
|
360
862
|
function bindSession(context, work, run, identity, now) {
|
|
361
863
|
const priorWorkSession = context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.work_id])?.session_id ?? null;
|
|
362
864
|
const inferredParentSession = work.parent_work_id
|
|
363
865
|
? context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.parent_work_id])?.session_id ?? null
|
|
364
866
|
: priorWorkSession && priorWorkSession !== identity.sessionId ? priorWorkSession : null;
|
|
365
|
-
|
|
867
|
+
// Work ancestry and provider-tree containment are different relations. An
|
|
868
|
+
// isolated worker is logically a child Work but physically a provider root.
|
|
869
|
+
const parentSession = inferredParentSession ?? identity.parentSessionId;
|
|
870
|
+
// Stored provider ancestry uses internal keys, just like hook_events.
|
|
871
|
+
// Resolve native IDs only when rendering diagnostics; changing storage here
|
|
872
|
+
// breaks existing bindings and usage ancestry joins.
|
|
873
|
+
const providerParentSession = identity.parentSessionId;
|
|
874
|
+
const identityDetails = (sessionKey = identity.sessionId, parentKey = parentSession) => ({
|
|
875
|
+
internal_session_key: sessionKey,
|
|
876
|
+
native_session_id: sessionKey === identity.sessionId ? identity.nativeSessionId : context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, sessionKey])?.provider_session_id ?? null,
|
|
877
|
+
internal_parent_session_key: parentKey,
|
|
878
|
+
native_parent_session_id: parentKey ? context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, parentKey])?.provider_session_id ?? null : null,
|
|
879
|
+
harness: identity.harness
|
|
880
|
+
});
|
|
366
881
|
if (work.parent_work_id && !parentSession)
|
|
367
882
|
throw new AppError("parent_session_required", "Child Work requires a confirmed parent Work/Session link", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
|
|
883
|
+
if (work.launch_policy === "fresh_agent_required" && inferredParentSession && identity.parentSessionId && identity.parentSessionId !== inferredParentSession)
|
|
884
|
+
throw new AppError("native_work_parent_mismatch", "Fresh native Work must be started by a direct child of its assigned parent Session", 1, { work_id: work.work_id, effect: "no_effect", ...identityDetails(), observed_provider_parent_session_key: identity.parentSessionId });
|
|
368
885
|
if (work.launch_policy === "fresh_agent_required" && (identity.sessionId === parentSession || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? LIMIT 1", [work.project_id, work.run_id, identity.sessionId])))
|
|
369
|
-
throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id,
|
|
370
|
-
const existing = context.db.get("SELECT session_id, parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
886
|
+
throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, effect: "no_effect", recoverable: true, handoff_required: true, ...identityDetails() });
|
|
887
|
+
const existing = context.db.get("SELECT session_id, parent_session_id, provider_parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
888
|
+
// A reuse-allowed Work can follow another Work in this same Session. Its
|
|
889
|
+
// Work predecessor is not a new physical/logical parent of the Session.
|
|
890
|
+
if (existing && existing.parent_session_id && parentSession && parentSession !== identity.sessionId && existing.parent_session_id !== parentSession)
|
|
891
|
+
throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { work_id: work.work_id, ...identityDetails(existing.session_id, existing.parent_session_id), conflicting_internal_parent_session_key: parentSession });
|
|
892
|
+
if (existing?.provider_parent_session_id && providerParentSession && existing.provider_parent_session_id !== providerParentSession)
|
|
893
|
+
throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { work_id: work.work_id, ...identityDetails(existing.session_id, existing.parent_session_id), conflicting_internal_provider_parent_session_key: providerParentSession, conflicting_native_parent_session_id: context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, providerParentSession])?.provider_session_id ?? null });
|
|
894
|
+
context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = excluded.cwd, updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, run.workspace_root, now, now]);
|
|
374
895
|
reactivateBoundSession(context, work.project_id, identity.sessionId, now);
|
|
375
896
|
}
|
|
897
|
+
/** Register the trusted physical Session before a paused Work moves to it. */
|
|
898
|
+
export function bindSessionForResume(context, workId, identity, now) {
|
|
899
|
+
const work = requireWork(context, workId);
|
|
900
|
+
if (work.status !== "paused")
|
|
901
|
+
throw new AppError("invalid_work_state", "Only a paused Work can bind a resume Session", 1, { work_id: work.work_id, status: work.status });
|
|
902
|
+
bindSession(context, work, requireRun(context, work.project_id, work.run_id), identity, now);
|
|
903
|
+
}
|
|
376
904
|
function reactivateBoundSession(context, projectId, sessionId, now) {
|
|
377
905
|
context.db.run("UPDATE sessions SET status = 'active', stop_reason = NULL, stopped_at = NULL, updated_at = ? WHERE project_id = ? AND session_id = ?", [now, projectId, sessionId]);
|
|
378
906
|
}
|
|
379
|
-
function
|
|
907
|
+
export function prepareWorkResult(context, id, result) {
|
|
908
|
+
try {
|
|
909
|
+
const { work, run, link } = prepareWorkSettlement(context, id, "completed", result);
|
|
910
|
+
validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
prepareError(error, { work_id: id });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, runId, resultPath) {
|
|
380
917
|
if (!work.result_schema)
|
|
381
918
|
return;
|
|
382
919
|
if (!resultPath)
|
|
@@ -388,22 +925,39 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, res
|
|
|
388
925
|
catch {
|
|
389
926
|
throw new AppError("validation", "Work result must be JSON for its declared result schema", 2, { work_id: work.work_id, result_schema: work.result_schema });
|
|
390
927
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
validateCodeWorkResult(work, parsed, workspaceRoot);
|
|
928
|
+
validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: resultPath, data: parsed, projectRoot, runId, runRoot: runHome });
|
|
929
|
+
if (work.result_schema === "dd-flow/code-work-result@3")
|
|
930
|
+
validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
|
|
395
931
|
if (work.result_schema === "dd-flow/code-review-result@1")
|
|
396
|
-
|
|
932
|
+
validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
933
|
+
if (work.result_schema === "dd-flow/plan-review-result@1")
|
|
934
|
+
validatePlanReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
397
935
|
}
|
|
398
|
-
function validateCodeWorkResult(work, value, projectRoot) {
|
|
936
|
+
function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
399
937
|
const result = value;
|
|
938
|
+
const changedPaths = (result.changed_paths ?? []).map((item) => normalizeWorkspacePath(projectRoot, item));
|
|
939
|
+
const ignored = ignoredWorkspacePaths(projectRoot, changedPaths);
|
|
940
|
+
if (ignored.length)
|
|
941
|
+
throw new AppError("environment_repair_not_product_change", "CODE Work must record ignored environment repair separately, not as a product changed_path", 2, { work_id: work.work_id, paths: ignored });
|
|
400
942
|
if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
|
|
401
943
|
throw new AppError("work_contract_incomplete", "CODE Work cannot complete with unresolved deviations or blockers; fail the Work and report the contract mismatch", 2, { work_id: work.work_id, deviations: result.deviations ?? [], blockers: result.blockers ?? [] });
|
|
402
944
|
const packet = codePacket(work);
|
|
403
945
|
if (!packet)
|
|
404
946
|
return;
|
|
947
|
+
const acceptedObligations = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
|
|
948
|
+
for (const check of packet.checks)
|
|
949
|
+
acceptedObligations.add(check.id);
|
|
950
|
+
for (const finding of packet.repair?.review_findings ?? [])
|
|
951
|
+
for (const ref of finding.obligation_refs ?? [])
|
|
952
|
+
acceptedObligations.add(ref);
|
|
953
|
+
for (const item of result.evidence ?? []) {
|
|
954
|
+
if (!item.obligation_ref || !acceptedObligations.has(item.obligation_ref))
|
|
955
|
+
throw new AppError("evidence_obligation_unknown", "CODE Work evidence must reference an obligation assigned to this Work", 2, { work_id: work.work_id, obligation_ref: item.obligation_ref ?? null, accepted_obligation_refs: [...acceptedObligations] });
|
|
956
|
+
for (const ref of item.refs ?? [])
|
|
957
|
+
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
958
|
+
}
|
|
405
959
|
const documentUpdates = (packet.document_updates ?? []);
|
|
406
|
-
const changed = new Set(
|
|
960
|
+
const changed = new Set(changedPaths);
|
|
407
961
|
const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
|
|
408
962
|
if (missingDocuments.length)
|
|
409
963
|
throw new AppError("document_update_missing", "CODE Work did not materialize every document update assigned by PLAN", 2, { work_id: work.work_id, missing_paths: missingDocuments });
|
|
@@ -411,33 +965,73 @@ function validateCodeWorkResult(work, value, projectRoot) {
|
|
|
411
965
|
return true; const current = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); return entry.action === "create" ? entry.baseline_sha256 !== null : current === entry.baseline_sha256; }).map((entry) => entry.path);
|
|
412
966
|
if (unchangedDocuments.length)
|
|
413
967
|
throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
|
|
414
|
-
const assigned = packet.repair?.
|
|
968
|
+
const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
|
|
969
|
+
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
415
970
|
if (assigned.length) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
971
|
+
if (changedPaths.length === 0)
|
|
972
|
+
throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
|
|
973
|
+
for (const resolution of result.resolutions ?? [])
|
|
974
|
+
for (const ref of resolution.evidence_refs ?? [])
|
|
975
|
+
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
421
976
|
}
|
|
422
977
|
}
|
|
423
|
-
function
|
|
978
|
+
export function validateReviewResolutions(workId, assigned, resolutions) {
|
|
979
|
+
const resolved = new Set(resolutions.map((resolution) => resolution.finding_ref).filter((ref) => Boolean(ref)));
|
|
980
|
+
const missing = assigned.filter((finding) => !resolved.has(finding));
|
|
981
|
+
const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
|
|
982
|
+
const duplicate = resolutions.length !== resolved.size;
|
|
983
|
+
if (missing.length || unexpected.length || duplicate)
|
|
984
|
+
throw new AppError("review_repair_incomplete", "Review repair must include exactly one resolution for each assigned finding reference", 2, { work_id: workId, missing, unexpected, duplicate });
|
|
985
|
+
}
|
|
986
|
+
function renderWorkerPrompt(context, work, run, dependencies) {
|
|
424
987
|
const command = flowCommand(context);
|
|
425
988
|
const packet = codePacket(work);
|
|
426
|
-
const
|
|
989
|
+
const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
|
|
990
|
+
const finishWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "finish", work.work_id, "--result-file", resultInputPath, "--project-root", run.project_root, "--json", "--progress-jsonl"]));
|
|
991
|
+
const failWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "fail", shortWorkId(work.work_id), "--reason", "receipt path + exact external or semantic blocker", "--project-root", run.project_root, "--json"]));
|
|
992
|
+
const mergeWork = parsePayload(work)?.kind === "merge";
|
|
993
|
+
const writeBoundary = mergeWork
|
|
994
|
+
? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
|
|
995
|
+
: [`HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not."];
|
|
996
|
+
const completionRepair = mergeWork
|
|
997
|
+
? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
|
|
998
|
+
: ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then use the returned retry_command with your result. Internal invocation IDs are runtime-owned; do not copy or invent them. Do not invent a cause that does not appear in the retained output."];
|
|
999
|
+
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
427
1000
|
if (packet)
|
|
428
1001
|
codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
|
|
429
|
-
|
|
1002
|
+
if (packet)
|
|
1003
|
+
codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
|
|
1004
|
+
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), `Write the completed JSON to ${resultInputPath}. This is an attempt input, not the canonical result.json receipt. dd-flow validates the file and atomically stores the canonical receipt.`, "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `First write only the result JSON to ${resultInputPath}. Then run this standalone command:\n${finishWorkCommand}`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
|
|
430
1005
|
}
|
|
431
|
-
function resultSchemaGuidance(work) {
|
|
1006
|
+
export function resultSchemaGuidance(work, runId) {
|
|
432
1007
|
const schema = work.result_schema;
|
|
433
|
-
|
|
434
|
-
|
|
1008
|
+
const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
|
|
1009
|
+
if (schema === "dd-flow/code-work-result@3") {
|
|
1010
|
+
const packet = codePacket(work);
|
|
1011
|
+
const obligations = [...new Set([
|
|
1012
|
+
...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
|
|
1013
|
+
...(packet?.checks ?? []).map((item) => item.id),
|
|
1014
|
+
...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
|
|
1015
|
+
])];
|
|
1016
|
+
const findings = packet?.repair?.review_findings ?? [];
|
|
1017
|
+
return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
|
|
1018
|
+
"This is a shape template, not a completed result. Populate changed_paths and evidence from the actual work and existing files; empty arrays below do not establish completion.",
|
|
1019
|
+
"For a gate repair, cite the assigned check id. For a review repair, replace each resolution summary and fill evidence_refs with existing evidence proving its required outcome. Ordinary Work has resolutions: [].",
|
|
1020
|
+
"```json", JSON.stringify({ schema_id: schema, summary: "Describe the actual implementation.", changed_paths: [], evidence: [], deviations: [], blockers: [], resolutions: findings.map((finding) => ({ finding_ref: finding.finding_ref, summary: "Describe how the assigned required outcome was achieved.", evidence_refs: [] })) }, null, 2), "```",
|
|
1021
|
+
"Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
|
|
1022
|
+
}
|
|
435
1023
|
if (schema === "dd-flow/code-review-result@1") {
|
|
436
|
-
|
|
1024
|
+
const aspect = { aspect_id: "assigned_aspect_id", verdict: "pass", summary: "Conclusion.", evidence_refs: ["path/to/evidence"] };
|
|
1025
|
+
const clean = { schema_id: schema, verdict: "pass", summary: "Concise evidence-backed conclusion.", aspects: [aspect], findings: [] };
|
|
1026
|
+
const mixed = { ...clean, verdict: "findings", aspects: [aspect, { ...aspect, aspect_id: "another_assigned_aspect_id", verdict: "findings" }], findings: [{ finding_id: "FIND-001", aspect_id: "another_assigned_aspect_id", priority: "p1", problem: "Describe the evidenced defect.", impact: "Describe its material impact.", required_outcome: "Describe the required correction.", evidence_refs: ["path/to/evidence"], obligation_refs: ["assigned_obligation_ref"] }] };
|
|
1027
|
+
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Examples below show no findings and mixed aspect outcomes, not completed results. Replace placeholder ids and paths with assigned aspects, obligations and existing evidence. Choose verdict pass, findings or blocked. Keep findings: [] when none exist. Report only material, direct-evidence defects, not taste or cosmetics.", "```json", JSON.stringify(clean, null, 2), "```", "```json", JSON.stringify(mixed, null, 2), "```"];
|
|
437
1028
|
}
|
|
438
1029
|
if (schema !== "dd-flow/plan-review-result@1")
|
|
439
1030
|
return [];
|
|
440
|
-
|
|
1031
|
+
const aspect = { aspect_id: "assigned_aspect_id", verdict: "pass", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/evidence"], findings: [] };
|
|
1032
|
+
const clean = { schema_id: schema, plan_revision: 1, overall_verdict: "pass", summary: "Concise evidence-backed conclusion.", aspects: [aspect] };
|
|
1033
|
+
const mixed = { ...clean, overall_verdict: "needs_changes", aspects: [aspect, { ...aspect, aspect_id: "another_assigned_aspect_id", verdict: "needs_changes", findings: [{ finding_id: "FIND-001", severity: "high", summary: "Describe the evidenced defect.", evidence_refs: ["path/to/evidence"] }] }] };
|
|
1034
|
+
return [refs, "Examples below show no findings and mixed aspect outcomes, not completed results. Assess each assigned aspect once, replace placeholder ids, plan_revision and paths with packet values and existing evidence. Choose verdict pass, watch, needs_changes or blocked. Keep findings: [] when an aspect has no findings. Do not add fields. Number findings locally as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them.", "```json", JSON.stringify(clean, null, 2), "```", "```json", JSON.stringify(mixed, null, 2), "```"];
|
|
441
1035
|
}
|
|
442
1036
|
export function validateCodeReviewResultIdentity(work, value) {
|
|
443
1037
|
const group = codeReviewGroup(work);
|
|
@@ -458,6 +1052,64 @@ export function validateCodeReviewResultIdentity(work, value) {
|
|
|
458
1052
|
throw new AppError("review_evidence_invalid", "CODE reviewer finding ids must be local FIND-NNN ids and aspect refs must stay inside the assigned review group", 2, { work_id: work.work_id, group: group.key, required_format: "FIND-NNN", invalid, wrong_aspect: wrongAspect });
|
|
459
1053
|
}
|
|
460
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* CODE review evidence is part of the Work contract, not a late stage-level
|
|
1057
|
+
* concern. Reject it before `works.result` becomes authoritative so a later
|
|
1058
|
+
* fan-in cannot discover a malformed accepted reviewer result.
|
|
1059
|
+
*/
|
|
1060
|
+
function validateCodeReviewResult(work, value, input) {
|
|
1061
|
+
validateCodeReviewResultIdentity(work, value);
|
|
1062
|
+
const result = value;
|
|
1063
|
+
assertReviewAspectEvidence(work, result.aspects ?? [], "CODE");
|
|
1064
|
+
const findings = result.findings ?? [];
|
|
1065
|
+
// A reviewer is a child Work and cannot pause its coordinator-owned Stage.
|
|
1066
|
+
// Its structured blocked result is evidence for the coordinator, which then
|
|
1067
|
+
// either resolves the gap or pauses the Stage itself.
|
|
1068
|
+
if ((result.verdict === "pass" && findings.length > 0) || (result.verdict === "findings" && findings.length === 0)) {
|
|
1069
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer verdict must agree with whether material findings are present", 2, { work_id: work.work_id, verdict: result.verdict, findings: findings.length });
|
|
1070
|
+
}
|
|
1071
|
+
const references = [
|
|
1072
|
+
...(result.aspects ?? []).flatMap((aspect) => Array.isArray(aspect.evidence_refs) ? aspect.evidence_refs : []),
|
|
1073
|
+
...(result.findings ?? []).flatMap((finding) => Array.isArray(finding.evidence_refs) ? finding.evidence_refs : [])
|
|
1074
|
+
];
|
|
1075
|
+
for (const ref of references) {
|
|
1076
|
+
if (typeof ref !== "string")
|
|
1077
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer evidence references must be strings", 2, { work_id: work.work_id, ref });
|
|
1078
|
+
assertPortableArtifactRef(ref, input);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
export function validatePlanReviewResult(work, value, input) {
|
|
1082
|
+
const group = codeReviewGroup(work);
|
|
1083
|
+
if (!group)
|
|
1084
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer Work has no assigned review group", 2, { work_id: work.work_id });
|
|
1085
|
+
const result = value;
|
|
1086
|
+
const aspects = result.aspects ?? [];
|
|
1087
|
+
const ids = aspects.map((item) => item.aspect_id ?? "");
|
|
1088
|
+
const expected = new Set(group.aspect_ids);
|
|
1089
|
+
const missing = group.aspect_ids.filter((id) => !ids.includes(id));
|
|
1090
|
+
const unexpected = ids.filter((id) => !expected.has(id));
|
|
1091
|
+
if (new Set(ids).size !== ids.length || missing.length || unexpected.length)
|
|
1092
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
|
|
1093
|
+
assertReviewAspectEvidence(work, aspects, "PLAN");
|
|
1094
|
+
const findingIds = aspects.flatMap((aspect) => aspect.findings ?? []).map((finding) => finding.finding_id ?? "");
|
|
1095
|
+
const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
|
|
1096
|
+
if (new Set(findingIds).size !== findingIds.length || invalid.length)
|
|
1097
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer finding ids must be unique local FIND-NNN ids", 2, { work_id: work.work_id, invalid });
|
|
1098
|
+
const refs = aspects.flatMap((aspect) => [
|
|
1099
|
+
...(Array.isArray(aspect.evidence_refs) ? aspect.evidence_refs : []),
|
|
1100
|
+
...(aspect.findings ?? []).flatMap((finding) => Array.isArray(finding.evidence_refs) ? finding.evidence_refs : [])
|
|
1101
|
+
]);
|
|
1102
|
+
for (const ref of refs) {
|
|
1103
|
+
if (typeof ref !== "string")
|
|
1104
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer evidence references must be strings", 2, { work_id: work.work_id, ref });
|
|
1105
|
+
assertPortableArtifactRef(ref, input);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function assertReviewAspectEvidence(work, aspects, stage) {
|
|
1109
|
+
const missing = aspects.filter((aspect) => !Array.isArray(aspect.evidence_refs) || aspect.evidence_refs.length === 0).map((aspect) => aspect.aspect_id ?? "");
|
|
1110
|
+
if (missing.length)
|
|
1111
|
+
throw new AppError("review_evidence_invalid", `${stage} reviewer result requires non-empty evidence_refs for every assigned aspect`, 2, { work_id: work.work_id, missing_aspects: missing });
|
|
1112
|
+
}
|
|
461
1113
|
function codeReviewGroup(work) {
|
|
462
1114
|
const payload = parsePayload(work);
|
|
463
1115
|
const group = payload?.group;
|
|
@@ -468,14 +1120,18 @@ function codeReviewGroup(work) {
|
|
|
468
1120
|
? { key: value.key, aspect_ids: value.aspect_ids }
|
|
469
1121
|
: null;
|
|
470
1122
|
}
|
|
471
|
-
function requireWork(context, id
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
1123
|
+
function requireWork(context, id, explicitScope) {
|
|
1124
|
+
ensureWorkRegistry(context);
|
|
1125
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
1126
|
+
const scope = configured ? JSON.parse(configured) : undefined;
|
|
1127
|
+
const projectId = explicitScope?.projectId ?? (scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined);
|
|
1128
|
+
const runId = explicitScope?.runId ?? scope?.runId;
|
|
1129
|
+
return resolveWorkReference(context, id, { projectId, runId });
|
|
1130
|
+
}
|
|
1131
|
+
function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]); if (!run?.run_root)
|
|
1132
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
|
|
1133
|
+
function requireRunHome(run) { if (!run.run_root)
|
|
1134
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id }); return run.run_root; }
|
|
479
1135
|
function parseDependencies(work) { try {
|
|
480
1136
|
const value = JSON.parse(work.depends_on_json);
|
|
481
1137
|
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
|
|
@@ -505,24 +1161,20 @@ function validateItem(value, requireExecutionContext = false) { if (!value || ty
|
|
|
505
1161
|
throw new AppError("validation", "work item must be an object", 2); const item = value; if (typeof item.key !== "string" || !item.key || typeof item.task !== "string" || !item.task.trim())
|
|
506
1162
|
throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@5"; if (requireExecutionContext && !code)
|
|
507
1163
|
throw new AppError("validation", "CODE batch requires code-work-packet@5 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions"] : [])
|
|
508
|
-
if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas'].includes(key) && item[key].length === 0))
|
|
1164
|
+
if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas', 'checks'].includes(key) && item[key].length === 0) || (key === "checks" && item[key].length === 0 && !allowsEmptyRepairChecks(item)))
|
|
509
1165
|
throw new AppError("validation", `CODE work item requires ${['provides_checks', 'planned_write_areas'].includes(key) ? "an array" : `non-empty ${key}`}`, 2, { key: item.key }); if (item.depends_on !== undefined && (!Array.isArray(item.depends_on) || !item.depends_on.every((entry) => typeof entry === "string")))
|
|
510
1166
|
throw new AppError("validation", "depends_on must be a string array", 2); if (item.parent !== undefined && typeof item.parent !== "string")
|
|
511
1167
|
throw new AppError("validation", "parent must be a string", 2); if (item.launch_policy !== undefined && item.launch_policy !== "reuse_allowed" && item.launch_policy !== "fresh_agent_required")
|
|
512
1168
|
throw new AppError("validation", "launch_policy must be reuse_allowed or fresh_agent_required", 2); if (item.result_schema !== undefined && (typeof item.result_schema !== "string" || !item.result_schema))
|
|
513
1169
|
throw new AppError("validation", "result_schema must be a non-empty schema id", 2); const payload = code ? item : (item.payload && typeof item.payload === "object" && !Array.isArray(item.payload) ? item.payload : undefined); return { key: item.key, task: item.task, ...(Array.isArray(item.depends_on) ? { depends_on: item.depends_on } : {}), ...(typeof item.parent === "string" ? { parent: item.parent } : {}), ...(typeof item.launch_policy === "string" ? { launch_policy: item.launch_policy } : {}), ...(typeof item.result_schema === "string" ? { result_schema: item.result_schema } : {}), ...(payload ? { payload } : {}) }; }
|
|
514
|
-
function
|
|
515
|
-
return
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
|
|
519
|
-
} }
|
|
520
|
-
export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT run_home_path FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (run?.run_home_path) {
|
|
521
|
-
const obsolete = path.join(run.run_home_path, "work.json");
|
|
1170
|
+
function allowsEmptyRepairChecks(item) { const repair = item.repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
|
|
1171
|
+
return false; const value = repair; return typeof value.check_receipt_id === "string" || (Array.isArray(value.review_findings) && value.review_findings.some((finding) => typeof finding === "object" && finding !== null && Array.isArray(finding.check_refs) && finding.check_refs.some((ref) => typeof ref === "string"))) || (Array.isArray(value.semantic_unresolved) && value.semantic_unresolved.some((item) => typeof item === "string")); }
|
|
1172
|
+
export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (run?.run_root) {
|
|
1173
|
+
const obsolete = path.join(run.run_root, "work.json");
|
|
522
1174
|
if (fs.existsSync(obsolete))
|
|
523
1175
|
fs.rmSync(obsolete);
|
|
524
1176
|
} }
|
|
525
|
-
export function codeWorkGraph(context, projectId, runId) { const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
|
|
1177
|
+
export function codeWorkGraph(context, projectId, runId) { const index = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId])?.index_json ?? "{}"); const active = index.stage_runs?.some(item => item.stage === "code-review" && item.status === "running") ? "code-review" : "code"; const members = index.stage_runs?.length ? stageWorkIds(context, projectId, runId, active) : null; const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work)) && (!members || members.has(work.work_id))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
|
|
526
1178
|
function parsePayload(work) { if (!work.payload_json)
|
|
527
1179
|
return null; try {
|
|
528
1180
|
const value = JSON.parse(work.payload_json);
|
|
@@ -531,6 +1183,7 @@ function parsePayload(work) { if (!work.payload_json)
|
|
|
531
1183
|
catch {
|
|
532
1184
|
return null;
|
|
533
1185
|
} }
|
|
1186
|
+
function uniqueChecks(checks) { return checks.filter((check, index) => checks.findIndex((candidate) => candidate.id === check.id) === index); }
|
|
534
1187
|
function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@5")
|
|
535
1188
|
return null; return value; }
|
|
536
1189
|
function assertNoCycles(nodes) { const local = new Map(nodes.map((node) => [node.id, node.dependencies.filter((dependency) => nodes.some((candidate) => candidate.id === dependency))])); const active = new Set(); const done = new Set(); const visit = (id) => { if (active.has(id))
|