@deksden-com/dd-flow-cli 0.9.0-beta.87 → 0.9.0-beta.89
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 +14 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/command-inputs.js +13 -10
- package/dist/cli/help.js +4 -0
- package/dist/cli/hook-ingress.js +2 -2
- package/dist/cli/run-cli.js +48 -15
- package/dist/services/codex-hook-delivery.js +3 -3
- package/dist/services/command-context.js +80 -0
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +4 -2
- package/dist/services/hooks.js +21 -15
- package/dist/services/lifecycle-command.js +27 -0
- package/dist/services/lifecycle-invocations.js +119 -38
- package/dist/services/recovery-snapshot-database.js +1 -1
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-controller.js +12 -3
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/stage-pause.js +7 -21
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/vnext-code-review.js +13 -11
- package/dist/services/vnext-code.js +39 -21
- package/dist/services/vnext-fanout.js +27 -6
- package/dist/services/vnext-merge.js +13 -10
- package/dist/services/vnext-plan-review.js +6 -5
- package/dist/services/work-registry.js +18 -9
- package/dist/storage/database.js +15 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
6
6
|
import { AppError, errorRecord } from "../shared/errors.js";
|
|
7
7
|
import { shortEntityReference } from "../shared/entity-references.js";
|
|
8
8
|
import { resolveWorkReference } from "../storage/work-references.js";
|
|
9
|
-
import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
9
|
+
import { commandOption, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
10
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
12
12
|
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
@@ -15,8 +15,49 @@ import { appendEvent } from "./run-controller-state.js";
|
|
|
15
15
|
import { recoverCommittedWorkStart } from "./work-registry.js";
|
|
16
16
|
import { observeCommandHook } from "./hooks.js";
|
|
17
17
|
import { runtimeHarness } from "./execution-policy.js";
|
|
18
|
-
import {
|
|
18
|
+
import { normalizedPathAlias, resolveContextualPaths } from "./command-context.js";
|
|
19
|
+
import { isRegisteredRepair } from "./repair-intents.js";
|
|
20
|
+
import { stageAttempt } from "./stage-work-graph.js";
|
|
19
21
|
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
22
|
+
import { canonicalPath } from "../storage/paths.js";
|
|
23
|
+
/** Auxiliary commands have no invocation capability, but may not escape their
|
|
24
|
+
* native daemon's project/RUN. Context aliases never grant mutation authority. */
|
|
25
|
+
export function assertAuxiliaryCommandScope(context, command, projectRoot) {
|
|
26
|
+
const auxiliary = continuationCommand(command);
|
|
27
|
+
const daemonId = context.env.DD_FLOW_DAEMON_ID;
|
|
28
|
+
if (!auxiliary || !daemonId)
|
|
29
|
+
return;
|
|
30
|
+
const event = observedLifecycleNativeEvent(context, command, daemonId);
|
|
31
|
+
if (!event)
|
|
32
|
+
throw new AppError("invocation_receipt_missing", "Auxiliary command has no exact native receipt", 1, { phase: "prepare", effect: "no_effect" });
|
|
33
|
+
const project = context.db.get("SELECT root FROM projects WHERE id = ?", [event.project_id]);
|
|
34
|
+
if (!project || !projectRoot || canonicalPath(projectRoot, false) !== project.root)
|
|
35
|
+
throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native project scope", 1, { phase: "prepare", effect: "no_effect" });
|
|
36
|
+
for (const record of managedProcessStatus(context)) {
|
|
37
|
+
if (record.project_id !== event.project_id || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
38
|
+
continue;
|
|
39
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
40
|
+
if (typeof metadata.state_dir !== "string")
|
|
41
|
+
continue;
|
|
42
|
+
let state;
|
|
43
|
+
try {
|
|
44
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (state.daemon_id !== daemonId)
|
|
50
|
+
continue;
|
|
51
|
+
requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: event.project_id, runId: record.run_id });
|
|
52
|
+
const reference = auxiliary.args.options.get("run")?.[0] ?? auxiliary.args.positional[2];
|
|
53
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [event.project_id, reference, reference]);
|
|
54
|
+
if (run?.id !== record.run_id)
|
|
55
|
+
throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native RUN scope", 1, { phase: "prepare", effect: "no_effect" });
|
|
56
|
+
assertRunMutationAllowed(context, event.project_id, record.run_id, metadata.run_generation);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw new AppError("invocation_scope_unavailable", "Auxiliary command has no live managed daemon binding", 1, { phase: "prepare", effect: "no_effect" });
|
|
60
|
+
}
|
|
20
61
|
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
21
62
|
// unknown-effect failures. These validators reject before accepting the result.
|
|
22
63
|
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
@@ -68,7 +109,7 @@ function fingerprint(parsed) {
|
|
|
68
109
|
const runtimeOwnedOptions = {
|
|
69
110
|
stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
|
|
70
111
|
stage_finish: new Set(["project-root", "result-file", "decision-file", "verification-file"]),
|
|
71
|
-
stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root"]),
|
|
112
|
+
stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
|
|
72
113
|
work_start: new Set(["project-root", "recovery-id"]),
|
|
73
114
|
work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
|
|
74
115
|
merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
|
|
@@ -122,9 +163,6 @@ function normalizedInvocationValue(key, value) {
|
|
|
122
163
|
}
|
|
123
164
|
/** Shell/JSON builders sometimes preserve the shell escape before an alias.
|
|
124
165
|
* Accept it only for the small declared alias vocabulary. */
|
|
125
|
-
function normalizedPathAlias(value) {
|
|
126
|
-
return value.replace(/^\\+(?=@(?:project|workspace|run)(?:\/|$))/, "");
|
|
127
|
-
}
|
|
128
166
|
function quoteModelFacingArgs(argv) {
|
|
129
167
|
return argv.map(value => /^@(project|workspace|run)(?:\/.*)?$/.test(value)
|
|
130
168
|
? `'${value.replaceAll("'", `'\\''`)}'`
|
|
@@ -181,23 +219,11 @@ export function expandLifecycleInvocationArgs(context, args, scope, operation) {
|
|
|
181
219
|
const pathOptions = contextualPathOptionsForLifecycle(operation);
|
|
182
220
|
const run = scope.runId ? context.db.get("SELECT id, short_id, workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
183
221
|
const roots = { project: scope.projectRoot, workspace: run?.workspace_root, run: run?.run_root };
|
|
184
|
-
return args.map((value, index) => {
|
|
222
|
+
return resolveContextualPaths(args.map((value, index) => {
|
|
185
223
|
if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
|
|
186
224
|
return run.id;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return value;
|
|
190
|
-
value = normalizedPathAlias(value);
|
|
191
|
-
if (!value.startsWith("@"))
|
|
192
|
-
return value;
|
|
193
|
-
const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
|
|
194
|
-
if (!match)
|
|
195
|
-
throw new AppError("usage", `Unknown path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
196
|
-
const root = roots[match[1]];
|
|
197
|
-
if (!root)
|
|
198
|
-
throw new AppError("usage", `Path alias @${match[1]} is unavailable without a bound RUN`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
199
|
-
return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
|
|
200
|
-
});
|
|
225
|
+
return value;
|
|
226
|
+
}), pathOptions, roots);
|
|
201
227
|
}
|
|
202
228
|
/** Validate alias spelling before native-receipt lookup, so a correctable
|
|
203
229
|
* command typo is never reported as missing infrastructure evidence. */
|
|
@@ -259,7 +285,7 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
259
285
|
const outcome = JSON.parse(row.outcome_json);
|
|
260
286
|
if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
|
|
261
287
|
throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
|
|
262
|
-
if (outcome.disposition === "fatal")
|
|
288
|
+
if (outcome.disposition === "fatal" && !isRegisteredRepair(context, outcome.error?.details?.continuation, { projectId: project.id, runId: scope.runId }))
|
|
263
289
|
throw new AppError(outcome.error?.code ?? "lifecycle_outcome_unknown", outcome.error?.message || "Mandatory lifecycle failed (historical receipt has no message)", 1, { ...outcome.error?.details, lifecycle_outcome: outcome });
|
|
264
290
|
}
|
|
265
291
|
}
|
|
@@ -272,15 +298,16 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
272
298
|
ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
|
|
273
299
|
for (const row of rows) {
|
|
274
300
|
const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
|
|
275
|
-
if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details))
|
|
301
|
+
if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details) && !isRegisteredRepair(context, error.details?.continuation, { projectId: project.id, runId: scope.runId }))
|
|
276
302
|
throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
|
|
277
303
|
}
|
|
278
304
|
}
|
|
279
305
|
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
280
306
|
* atomic controller notification; missing notifications reconcile from the row. */
|
|
281
|
-
export function observeLifecycleCommand(context, command) {
|
|
307
|
+
export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
282
308
|
const parsed = parseLifecycleCommand(command);
|
|
283
|
-
|
|
309
|
+
const auxiliary = continuationCommand(command);
|
|
310
|
+
if (parsed.kind !== "standalone" && !auxiliary)
|
|
284
311
|
return () => { };
|
|
285
312
|
let anchor;
|
|
286
313
|
context.observeLifecycleHook = (projectId, eventId) => {
|
|
@@ -291,6 +318,7 @@ export function observeLifecycleCommand(context, command) {
|
|
|
291
318
|
return;
|
|
292
319
|
let runId = context.db.get("SELECT run_id FROM sessions WHERE project_id = ? AND harness = ? AND (session_id = ? OR session_id = ? OR provider_session_id = ?) AND run_id IS NOT NULL ORDER BY session_id = ? DESC LIMIT 1", [projectId, event.harness, event.session_id, event.parent_session_id, event.parent_session_id, event.session_id])?.run_id;
|
|
293
320
|
let generation;
|
|
321
|
+
let correlation = {};
|
|
294
322
|
// A first Stage command precedes logical Session binding. Its native root
|
|
295
323
|
// is already owned by the controller's retained create receipt.
|
|
296
324
|
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")) {
|
|
@@ -305,6 +333,8 @@ export function observeLifecycleCommand(context, command) {
|
|
|
305
333
|
return;
|
|
306
334
|
runId = owners[0].run_id;
|
|
307
335
|
generation = owners[0].generation;
|
|
336
|
+
const current = JSON.parse(owners[0].state_json);
|
|
337
|
+
correlation = { stage: current.stage, ...(current.stage ? { attempt: stageAttempt(context, projectId, runId, current.stage) } : {}), controller_turn: current.turns };
|
|
308
338
|
}
|
|
309
339
|
}
|
|
310
340
|
if (event.daemon_id) {
|
|
@@ -333,11 +363,14 @@ export function observeLifecycleCommand(context, command) {
|
|
|
333
363
|
}
|
|
334
364
|
if (!runId)
|
|
335
365
|
return;
|
|
336
|
-
anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.invocation.operation };
|
|
366
|
+
anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.kind === "standalone" ? parsed.invocation.operation : auxiliary.operation, ...correlation, ...(auxiliary ? { auxiliary: true } : {}) };
|
|
337
367
|
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
368
|
+
if (nativeEvent)
|
|
369
|
+
context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
|
|
370
|
+
else
|
|
371
|
+
observeCommandHook(context, command);
|
|
372
|
+
const invocationId = parsed.kind === "standalone" ? commandOption(parsed.invocation, "invocation-id") : undefined;
|
|
373
|
+
if (!anchor && invocationId && !nativeEvent) {
|
|
341
374
|
const attempt = load(context, invocationId);
|
|
342
375
|
checkCommand(attempt, command);
|
|
343
376
|
if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
|
|
@@ -356,7 +389,7 @@ export function observeLifecycleCommand(context, command) {
|
|
|
356
389
|
return;
|
|
357
390
|
const error = outcome.error ? errorRecord(outcome.error) : undefined;
|
|
358
391
|
const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
|
|
359
|
-
const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? correctable ? "correctable" : "fatal" : "completed" };
|
|
392
|
+
const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? auxiliary ? "pending_continuation" : correctable ? "correctable" : "fatal" : "completed" };
|
|
360
393
|
try {
|
|
361
394
|
context.db.writeTransaction(() => {
|
|
362
395
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
|
|
@@ -441,9 +474,10 @@ export function managedInvocationContext(context, input) {
|
|
|
441
474
|
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
442
475
|
return { ...context, env };
|
|
443
476
|
}
|
|
444
|
-
/** Generated commands retain their attempt across repeated rendering.
|
|
445
|
-
*
|
|
446
|
-
|
|
477
|
+
/** Generated commands retain their attempt across repeated rendering. Execution
|
|
478
|
+
* can issue a retry; accepted HITL intent can replace an unobserved placeholder
|
|
479
|
+
* or issue the next pause's assignment after successful settlement. */
|
|
480
|
+
export function managedLifecycleCommand(context, command, options = {}) {
|
|
447
481
|
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
448
482
|
if (!configured)
|
|
449
483
|
return command;
|
|
@@ -474,7 +508,18 @@ export function managedLifecycleCommand(context, command) {
|
|
|
474
508
|
return context.db.writeTransaction(() => {
|
|
475
509
|
const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
|
|
476
510
|
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
477
|
-
|
|
511
|
+
const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
|
|
512
|
+
&& (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
|
|
513
|
+
if (!replacesAnswer)
|
|
514
|
+
return publicInvocationCommand(context, prior, command);
|
|
515
|
+
// The pause packet can prepare a placeholder before the controller has
|
|
516
|
+
// accepted an answer. Replace only an unobserved assignment; never change
|
|
517
|
+
// the authority of a native call already in flight.
|
|
518
|
+
const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
|
|
519
|
+
if (prior.status !== "issued" && !completed)
|
|
520
|
+
throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
|
|
521
|
+
if (prior.status === "issued")
|
|
522
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
|
|
478
523
|
}
|
|
479
524
|
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
480
525
|
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
@@ -535,7 +580,9 @@ export function settleLifecycleRejection(context, id, error) {
|
|
|
535
580
|
details.recoverable = true;
|
|
536
581
|
if (storageRetry)
|
|
537
582
|
details.storage_retry = 1;
|
|
538
|
-
details.retry_instruction =
|
|
583
|
+
details.retry_instruction = isRegisteredRepair(context, details.continuation)
|
|
584
|
+
? "The repair is already registered. End this coordinator turn so the controller can launch its fresh child. Use retry_command only after that repair completes, with fresh semantic verification."
|
|
585
|
+
: "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
539
586
|
}
|
|
540
587
|
else if (isCorrectableLifecycleRejection(error.code, error.details)) {
|
|
541
588
|
details.effect = "no_effect";
|
|
@@ -807,8 +854,40 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
807
854
|
throw error;
|
|
808
855
|
}
|
|
809
856
|
}
|
|
810
|
-
/**
|
|
811
|
-
*
|
|
857
|
+
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
858
|
+
* matching. A hook-added event key must name the same persisted native call. */
|
|
859
|
+
export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
860
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
|
|
861
|
+
return null;
|
|
862
|
+
const signature = (value) => {
|
|
863
|
+
const parsed = parseLifecycleCommand(value);
|
|
864
|
+
const candidate = parsed.kind === "standalone" && !parsed.invocation.wrapped ? parsed.invocation : continuationCommand(value);
|
|
865
|
+
if (!candidate)
|
|
866
|
+
return null;
|
|
867
|
+
return JSON.stringify([candidate.operation, candidate.args.positional,
|
|
868
|
+
[...candidate.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
|
|
869
|
+
};
|
|
870
|
+
const expected = signature(command);
|
|
871
|
+
if (!expected)
|
|
872
|
+
return null;
|
|
873
|
+
const parsed = parseLifecycleCommand(command);
|
|
874
|
+
const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
|
|
875
|
+
const matches = context.db.all("SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL", [daemonId]).filter(row => {
|
|
876
|
+
if (eventKey && row.event_key !== eventKey)
|
|
877
|
+
return false;
|
|
878
|
+
try {
|
|
879
|
+
const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
|
|
880
|
+
return typeof observed === "string" && signature(observed) === expected;
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
// Diagnostic correlation only: never use this as execution authority, and
|
|
887
|
+
// never choose the latest of several identical native calls.
|
|
888
|
+
return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
|
|
889
|
+
}
|
|
890
|
+
/** Keep shell diagnostics distinct from genuinely absent native delivery. */
|
|
812
891
|
export function observedLifecycleDiagnostic(context, command, daemonId) {
|
|
813
892
|
const analysis = parseLifecycleCommand(command);
|
|
814
893
|
if (analysis.kind !== "standalone")
|
|
@@ -833,7 +912,7 @@ export function observedLifecycleDiagnostic(context, command, daemonId) {
|
|
|
833
912
|
/** Resolve only a command that a native hook has already bound to an issued
|
|
834
913
|
* attempt. This is the CLI-side half of admission for model-facing commands
|
|
835
914
|
* that intentionally omit the internal UUID. */
|
|
836
|
-
export function observedLifecycleInvocation(context, command) {
|
|
915
|
+
export function observedLifecycleInvocation(context, command, daemonId) {
|
|
837
916
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
838
917
|
return null;
|
|
839
918
|
const analysis = parseLifecycleCommand(command);
|
|
@@ -843,6 +922,8 @@ export function observedLifecycleInvocation(context, command) {
|
|
|
843
922
|
.filter(row => {
|
|
844
923
|
try {
|
|
845
924
|
const scope = JSON.parse(row.scope_json);
|
|
925
|
+
if (daemonId && scope.daemonId !== daemonId)
|
|
926
|
+
return false;
|
|
846
927
|
return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
|
|
847
928
|
}
|
|
848
929
|
catch {
|
|
@@ -49,7 +49,7 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
|
|
|
49
49
|
merge_servers: "0", merge_sessions: "0", merge_queue: "0", lanes: "0", lane_locks: "0", lane_waiters: "0",
|
|
50
50
|
sqlite_sequence: "0"
|
|
51
51
|
};
|
|
52
|
-
for (const table of ["works", "check_receipts", "merge_requests", "flow_run_flag_mutations", "usage", "flow_session_segments", "flow_jobs", "run_recovery_guards", "run_controllers", "run_controls"])
|
|
52
|
+
for (const table of ["works", "stage_work_memberships", "repair_intents", "check_receipts", "merge_requests", "flow_run_flag_mutations", "usage", "flow_session_segments", "flow_jobs", "run_recovery_guards", "run_controllers", "run_controls"])
|
|
53
53
|
rules[table] = ownRun;
|
|
54
54
|
for (const table of ["plan_bindings", "plan_progress", "worktree_records", "audit_events"])
|
|
55
55
|
rules[table] = `${ownProject} AND protocol_id IN (${protocols})`;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { stageAttempt, stageCycle, bindStageWork } from "./stage-work-graph.js";
|
|
3
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
/** Checks finish before entering this synchronous registration transaction. */
|
|
6
|
+
export function ensureAggregateRepair(context, input, create) {
|
|
7
|
+
if (!input.failures.length || input.failures.some(receipt => receipt.status !== "failed" && receipt.status !== "aborted"))
|
|
8
|
+
throw new AppError("repair_intent_invalid", "Repair requires settled failed receipts", 1);
|
|
9
|
+
const causes = input.failures.map(receipt => ({ id: receipt.id, epoch: receipt.verification_epoch, fingerprint: receipt.workspace_fingerprint })).sort((a, b) => a.id.localeCompare(b.id));
|
|
10
|
+
return ensureRepairIntent(context, { ...input, causes: { receipts: causes, origins: [...(input.origins ?? [])].sort() } }, create);
|
|
11
|
+
}
|
|
12
|
+
export function ensureRepairIntent(context, input, create) {
|
|
13
|
+
return context.db.writeTransaction(() => {
|
|
14
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
15
|
+
const attempt = stageAttempt(context, input.projectId, input.runId, input.stage);
|
|
16
|
+
const payload = JSON.stringify({ project_id: input.projectId, run_id: input.runId, stage: input.stage, attempt, cycle: stageCycle(context, input.projectId, input.runId, input.stage), causes: input.causes });
|
|
17
|
+
const identity = crypto.createHash("sha256").update(payload).digest("hex");
|
|
18
|
+
const prior = context.db.get("SELECT work_id, payload_json FROM repair_intents WHERE identity = ?", [identity]);
|
|
19
|
+
if (prior && prior.payload_json !== payload)
|
|
20
|
+
throw new AppError("repair_intent_conflict", "Repair identity has different causes", 1);
|
|
21
|
+
const workId = prior?.work_id ?? create();
|
|
22
|
+
if (!prior) {
|
|
23
|
+
bindStageWork(context, { projectId: input.projectId, runId: input.runId, workId, stage: input.stage, attempt });
|
|
24
|
+
context.db.run("INSERT INTO repair_intents (identity, project_id, run_id, stage, attempt, payload_hash, payload_json, work_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [identity, input.projectId, input.runId, input.stage, attempt, identity, payload, workId, context.now()]);
|
|
25
|
+
}
|
|
26
|
+
return { schema_id: "dd-flow/continuation@1", kind: "repair_required", project_id: input.projectId, run_id: input.runId, stage: input.stage, attempt, identity, work_id: workId };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function isRegisteredRepair(context, value, scope) {
|
|
30
|
+
if (!value || typeof value !== "object")
|
|
31
|
+
return false;
|
|
32
|
+
const receipt = value;
|
|
33
|
+
if (receipt.schema_id !== "dd-flow/continuation@1" || receipt.kind !== "repair_required")
|
|
34
|
+
return false;
|
|
35
|
+
if (scope && (receipt.project_id !== scope.projectId || receipt.run_id !== scope.runId))
|
|
36
|
+
return false;
|
|
37
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'repair_intents'"))
|
|
38
|
+
return false;
|
|
39
|
+
return Boolean(context.db.get("SELECT 1 FROM repair_intents i JOIN works w ON w.work_id = i.work_id AND w.project_id = i.project_id AND w.run_id = i.run_id WHERE i.identity = ? AND i.project_id = ? AND i.run_id = ? AND i.stage = ? AND i.attempt = ? AND i.work_id = ?", [receipt.identity, receipt.project_id, receipt.run_id, receipt.stage, receipt.attempt, receipt.work_id]));
|
|
40
|
+
}
|
|
@@ -26,6 +26,7 @@ import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFan
|
|
|
26
26
|
import { claimQueuedMergeRequest } from "./merge-server.js";
|
|
27
27
|
import { prepareVnextCodeWorkspace } from "./vnext-code.js";
|
|
28
28
|
import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
|
|
29
|
+
import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
|
|
29
30
|
import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady } from "./run-controller-process.js";
|
|
30
31
|
import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
|
|
31
32
|
import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
|
|
@@ -598,19 +599,25 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
598
599
|
const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
|
|
599
600
|
if (!pause || state.current_session === null)
|
|
600
601
|
throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
|
|
601
|
-
const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status
|
|
602
|
+
const answer = context.db.get("SELECT operation_id, input_json, status FROM run_controller_operations WHERE controller_id = ? AND status IN ('accepted','completed') AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
|
|
602
603
|
if (!answer) {
|
|
603
604
|
await delay(1000);
|
|
604
605
|
continue;
|
|
605
606
|
}
|
|
607
|
+
if (answer.status === "completed")
|
|
608
|
+
throw new AppError("controller_answer_not_applied", "Completed answer operation still has its original HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage });
|
|
606
609
|
const input = JSON.parse(answer.input_json);
|
|
607
610
|
if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
|
|
608
611
|
throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
|
|
609
612
|
assertOwner();
|
|
610
613
|
// The accepted answer is logical intent; only its generation-bound
|
|
611
614
|
// adapter operation represents a physical dispatch that stop must drain.
|
|
612
|
-
const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json
|
|
615
|
+
const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
|
|
613
616
|
const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
|
|
617
|
+
const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
|
|
618
|
+
if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
|
|
619
|
+
throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
|
|
620
|
+
}
|
|
614
621
|
context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
|
|
615
622
|
state.last_continuation = null;
|
|
616
623
|
saveState(context, row, state);
|
|
@@ -744,7 +751,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
744
751
|
saveState(context, row, state);
|
|
745
752
|
continue;
|
|
746
753
|
}
|
|
747
|
-
|
|
754
|
+
if (state.last_continuation === fanout.fingerprint)
|
|
755
|
+
assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
|
|
756
|
+
retainControllerContinuation(state, fanout.fingerprint, { code: "fanout_stage_nonprogressing", message: "The coordinator repeated an unchanged Work-graph continuation", details: { stage: next.stage, before: state.last_continuation, after: fanout.fingerprint, controller_turn: state.turns } });
|
|
748
757
|
saveState(context, row, state);
|
|
749
758
|
await prompt(session, fanout.prompt);
|
|
750
759
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
/** Only the trusted launcher is a shell fragment; all caller data is argv. */
|
|
7
|
+
export function runtimeCommand(context, args) {
|
|
8
|
+
return `${flowCommand(context)} ${args.map(shellArgument).join(" ")}`;
|
|
9
|
+
}
|
|
10
|
+
export function shellArgument(value) {
|
|
11
|
+
return /^[A-Za-z0-9_./:@=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
12
|
+
}
|
|
13
|
+
export function flowCommand(context) {
|
|
14
|
+
const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
|
|
15
|
+
const quoted = (value) => `"${value.replace(/[\\"$`]/g, character => `\\${character}`)}"`;
|
|
16
|
+
const home = `DD_FLOW_HOME=${quoted(context.ddFlowHome)}`;
|
|
17
|
+
// The runner binds this variable to its isolated published executable.
|
|
18
|
+
if (context.env.DD_FLOW_BIN && path.isAbsolute(context.env.DD_FLOW_BIN))
|
|
19
|
+
return `${home} "$DD_FLOW_BIN"`;
|
|
20
|
+
const isolated = path.join(context.ddFlowHome, "bin", "dd-flow");
|
|
21
|
+
if (context.ddFlowHome !== defaultHome && fs.existsSync(isolated))
|
|
22
|
+
return `${home} ${quoted(isolated)}`;
|
|
23
|
+
return context.ddFlowHome === defaultHome ? "dd-flow" : `${home} dd-flow`;
|
|
24
|
+
}
|
|
25
|
+
export function recoveryEvidenceInput(runRoot, receiptId) {
|
|
26
|
+
const basename = crypto.createHash("sha256").update(receiptId).digest("hex").slice(0, 20);
|
|
27
|
+
return {
|
|
28
|
+
schema_id: "dd-flow/continuation@1",
|
|
29
|
+
kind: "needs_input",
|
|
30
|
+
actor: "coordinator",
|
|
31
|
+
input_file: path.join(runRoot, `check-recovery-${basename}.json`),
|
|
32
|
+
input_contract: { reason: "Nonempty evidence explaining what environment was restored; do not invent recovery." },
|
|
33
|
+
instruction: "Only after restoring the environment, write this JSON file. Then use command_after_input. This is not permission to change product code or bypass the failed check."
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function checkRetryArgs(context, projectRoot, runId, receiptId) {
|
|
37
|
+
if (!receiptId)
|
|
38
|
+
return [];
|
|
39
|
+
const run = context.db.get("SELECT run_root FROM runs WHERE project_root = ? AND id = ?", [projectRoot, runId]);
|
|
40
|
+
if (!run?.run_root)
|
|
41
|
+
throw new AppError("runtime_missing", "Check recovery requires a bound RUN root", 1);
|
|
42
|
+
return ["--retry-check", receiptId, "--reason-file", recoveryEvidenceInput(run.run_root, receiptId).input_file];
|
|
43
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { quote } from "shell-quote";
|
|
6
5
|
import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
@@ -13,6 +12,8 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
13
12
|
import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
|
|
14
13
|
import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
|
|
15
14
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
15
|
+
import { runtimeCommand } from "./runtime-command.js";
|
|
16
|
+
export { flowCommand } from "./runtime-command.js";
|
|
16
17
|
export function prepareStagePause(context, input) {
|
|
17
18
|
if (typeof input.question !== "string" || !input.question.trim())
|
|
18
19
|
throw new AppError("validation", "stage pause requires a non-empty user question", 2);
|
|
@@ -159,8 +160,10 @@ export function resumeStageAfterUser(context, input) {
|
|
|
159
160
|
return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
|
|
160
161
|
}
|
|
161
162
|
export function stagePauseCommand(context, input) {
|
|
162
|
-
const
|
|
163
|
-
|
|
163
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
164
|
+
const run = requireRun(context, project.id, input.runId);
|
|
165
|
+
const questionInputPath = path.join(requireRunHome(run), "works", input.workId, "question-input.md");
|
|
166
|
+
return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "pause", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--question-file", questionInputPath, "--project-root", input.projectRoot, "--json"]));
|
|
164
167
|
}
|
|
165
168
|
/** Render separate file-write and lifecycle-call instructions for HITL. */
|
|
166
169
|
export function stagePauseCommandTemplate(command) {
|
|
@@ -185,24 +188,7 @@ Then run this standalone command:
|
|
|
185
188
|
${command}`;
|
|
186
189
|
}
|
|
187
190
|
function stageResumeCommand(context, input) {
|
|
188
|
-
return managedLifecycleCommand(context,
|
|
189
|
-
}
|
|
190
|
-
export function flowCommand(context) {
|
|
191
|
-
const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
|
|
192
|
-
// Desktop/ACP tool processes are not required to inherit the environment of
|
|
193
|
-
// the adapter that created their Session. In an isolated runtime, a bare
|
|
194
|
-
// `dd-flow` can therefore select the host-global router despite a correct
|
|
195
|
-
// DD_FLOW_HOME. The runner supplies an absolute executable for that case;
|
|
196
|
-
// make every generated lifecycle command self-contained.
|
|
197
|
-
const executable = context.env.DD_FLOW_BIN;
|
|
198
|
-
if (executable && path.isAbsolute(executable)) {
|
|
199
|
-
return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} "$DD_FLOW_BIN"`;
|
|
200
|
-
}
|
|
201
|
-
const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
|
|
202
|
-
if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
|
|
203
|
-
return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
|
|
204
|
-
}
|
|
205
|
-
return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
|
|
191
|
+
return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "resume", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--answer-file", input.answerInputPath, "--project-root", input.projectRoot, "--json"]));
|
|
206
192
|
}
|
|
207
193
|
function requireRun(context, projectId, id) {
|
|
208
194
|
const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
export function stageAttempt(context, projectId, runId, stage) {
|
|
3
|
+
const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
4
|
+
if (!run)
|
|
5
|
+
throw new AppError("not_found", "Stage graph RUN is unavailable", 1);
|
|
6
|
+
const stages = JSON.parse(run.index_json).stage_runs ?? [];
|
|
7
|
+
return [...stages].reverse().find(item => item.stage === stage)?.attempt ?? "try-001";
|
|
8
|
+
}
|
|
9
|
+
export function bindStageWork(context, input) {
|
|
10
|
+
context.db.run("INSERT OR IGNORE INTO stage_work_memberships (project_id, run_id, work_id, stage, attempt, cycle) VALUES (?, ?, ?, ?, ?, ?)", [input.projectId, input.runId, input.workId, input.stage, input.attempt ?? stageAttempt(context, input.projectId, input.runId, input.stage), input.cycle ?? stageCycle(context, input.projectId, input.runId, input.stage)]);
|
|
11
|
+
}
|
|
12
|
+
export function stageCycle(context, projectId, runId, stage) {
|
|
13
|
+
if (stage !== "code" && stage !== "code-review")
|
|
14
|
+
return "";
|
|
15
|
+
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
16
|
+
const cycle = row ? JSON.parse(row.index_json).variables?.["merge.source_repair"]?.cycle : undefined;
|
|
17
|
+
return typeof cycle === "string" ? cycle : stage === "code-review" ? "initial" : "";
|
|
18
|
+
}
|
|
19
|
+
export function stageWorkIds(context, projectId, runId, stage) {
|
|
20
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stage_work_memberships'"))
|
|
21
|
+
throw new AppError("stage_graph_compatibility_required", "Stage Work membership is unavailable; use the RUN's pinned engine", 1);
|
|
22
|
+
return new Set(context.db.all("SELECT work_id FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND stage = ? AND attempt = ? AND cycle = ?", [projectId, runId, stage, stageAttempt(context, projectId, runId, stage), stageCycle(context, projectId, runId, stage)]).map(row => row.work_id));
|
|
23
|
+
}
|
|
24
|
+
/** Generic Work APIs also serve non-stage runs; null preserves that contract. */
|
|
25
|
+
export function currentStageWorkIds(context, projectId, runId) {
|
|
26
|
+
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
27
|
+
if (!row)
|
|
28
|
+
return null;
|
|
29
|
+
const index = JSON.parse(row.index_json);
|
|
30
|
+
const stage = [...(index.stage_runs ?? [])].reverse().find(item => item.status === "running")?.stage;
|
|
31
|
+
return stage ? stageWorkIds(context, projectId, runId, stage) : null;
|
|
32
|
+
}
|
|
33
|
+
export function stageGraphScope(context, projectId, runId, stage) {
|
|
34
|
+
return { project_id: projectId, run_id: runId, attempt: stageAttempt(context, projectId, runId, stage), cycle: stageCycle(context, projectId, runId, stage) };
|
|
35
|
+
}
|