@deksden-com/dd-flow-cli 0.9.0-beta.87 → 0.9.0-beta.88
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 +8 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +21 -3
- package/dist/services/lifecycle-invocations.js +59 -12
- package/dist/services/run-controller.js +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.88
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5f3806c: Keep managed HITL answer paths inside retained runtime authority, preserve early
|
|
8
|
+
native CLI failures, and fail controllers conclusively when an answer turn does
|
|
9
|
+
not resume its assigned pause.
|
|
10
|
+
|
|
3
11
|
## 0.9.0-beta.87
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.88",
|
|
4
|
+
"cli_commit": "6cd7da3104a366d4b2866fa31ef8635949e79143",
|
|
5
|
+
"built_at": "2026-09-21T00:22:16.324Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "acaae844a871ddfbc49fd66355373f166dc3e683",
|
package/dist/cli/help.js
CHANGED
|
@@ -233,6 +233,8 @@ Usage:
|
|
|
233
233
|
dd-flow stage start --bootstrap --project-root <root> --stage specify --subject <safe-subject> (--intake-file <path>|--intake-stdin) [--require-session-binding] --json
|
|
234
234
|
dd-flow stage pause <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--question-file <path>|--question-stdin) --json
|
|
235
235
|
dd-flow stage resume <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--answer-file <path>|--answer-stdin) --json
|
|
236
|
+
|
|
237
|
+
Managed resume commands restore the accepted answer file from runtime state. Execute the returned command without adding an answer path or recreating the answer. The explicit answer flags above are for unmanaged calls.
|
|
236
238
|
dd-flow stage block <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --kind engine|harness|environment --code <code> (--summary-file <path>|--summary-stdin) [--retryable] --json
|
|
237
239
|
dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
|
|
238
240
|
dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { quote } from "shell-quote";
|
|
5
|
-
import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
|
|
5
|
+
import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
|
|
6
6
|
import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
|
|
7
7
|
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
|
|
8
8
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
@@ -132,12 +132,13 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
132
132
|
const lifecycleCommand = parseLifecycleCommand(publicCommand);
|
|
133
133
|
if (lifecycleCommand.kind === "standalone")
|
|
134
134
|
validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
|
|
135
|
-
const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
|
|
135
|
+
const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
136
136
|
if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
|
|
137
137
|
const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
138
138
|
if (diagnostic)
|
|
139
139
|
throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
140
|
-
|
|
140
|
+
const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
141
|
+
throw new AppError(nativeEvent ? "invocation_assignment_missing" : "invocation_receipt_missing", nativeEvent ? "Native call was observed, but its arguments do not match an issued lifecycle assignment" : "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
|
|
141
142
|
}
|
|
142
143
|
const invocationId = observedInvocation?.id ?? suppliedInvocationId;
|
|
143
144
|
let boundInvocationArgs;
|
|
@@ -266,6 +267,23 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
266
267
|
}
|
|
267
268
|
catch (caught) {
|
|
268
269
|
const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
|
|
270
|
+
if (!settleHook && !invocationSettlement && env.DD_FLOW_DAEMON_ID) {
|
|
271
|
+
let earlyContext;
|
|
272
|
+
try {
|
|
273
|
+
const command = quote(["dd-flow", ...output.args]);
|
|
274
|
+
const event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
|
|
275
|
+
if (event) {
|
|
276
|
+
earlyContext = createContext({ ...env }, "hook");
|
|
277
|
+
observeLifecycleCommand(earlyContext, command, event)({ error });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (persistence) {
|
|
281
|
+
error.details.lifecycle_diagnostic_error = String(persistence);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
earlyContext?.db.close?.();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
269
287
|
if (diagnosticHome && (!isAppError(caught) || error.code === "storage_write_failed")) {
|
|
270
288
|
try {
|
|
271
289
|
const directory = path.join(diagnosticHome, "diagnostics", "cli");
|
|
@@ -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, 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";
|
|
@@ -68,7 +68,7 @@ function fingerprint(parsed) {
|
|
|
68
68
|
const runtimeOwnedOptions = {
|
|
69
69
|
stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
|
|
70
70
|
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"]),
|
|
71
|
+
stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
|
|
72
72
|
work_start: new Set(["project-root", "recovery-id"]),
|
|
73
73
|
work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
|
|
74
74
|
merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
|
|
@@ -278,7 +278,7 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
278
278
|
}
|
|
279
279
|
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
280
280
|
* atomic controller notification; missing notifications reconcile from the row. */
|
|
281
|
-
export function observeLifecycleCommand(context, command) {
|
|
281
|
+
export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
282
282
|
const parsed = parseLifecycleCommand(command);
|
|
283
283
|
if (parsed.kind !== "standalone")
|
|
284
284
|
return () => { };
|
|
@@ -335,9 +335,12 @@ export function observeLifecycleCommand(context, command) {
|
|
|
335
335
|
return;
|
|
336
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 };
|
|
337
337
|
};
|
|
338
|
-
|
|
338
|
+
if (nativeEvent)
|
|
339
|
+
context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
|
|
340
|
+
else
|
|
341
|
+
observeCommandHook(context, command);
|
|
339
342
|
const invocationId = commandOption(parsed.invocation, "invocation-id");
|
|
340
|
-
if (!anchor && invocationId) {
|
|
343
|
+
if (!anchor && invocationId && !nativeEvent) {
|
|
341
344
|
const attempt = load(context, invocationId);
|
|
342
345
|
checkCommand(attempt, command);
|
|
343
346
|
if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
|
|
@@ -441,9 +444,10 @@ export function managedInvocationContext(context, input) {
|
|
|
441
444
|
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
442
445
|
return { ...context, env };
|
|
443
446
|
}
|
|
444
|
-
/** Generated commands retain their attempt across repeated rendering.
|
|
445
|
-
*
|
|
446
|
-
|
|
447
|
+
/** Generated commands retain their attempt across repeated rendering. Execution
|
|
448
|
+
* can issue a retry; accepted HITL intent can replace an unobserved placeholder
|
|
449
|
+
* or issue the next pause's assignment after successful settlement. */
|
|
450
|
+
export function managedLifecycleCommand(context, command, options = {}) {
|
|
447
451
|
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
448
452
|
if (!configured)
|
|
449
453
|
return command;
|
|
@@ -474,7 +478,18 @@ export function managedLifecycleCommand(context, command) {
|
|
|
474
478
|
return context.db.writeTransaction(() => {
|
|
475
479
|
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
480
|
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
477
|
-
|
|
481
|
+
const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
|
|
482
|
+
&& (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
|
|
483
|
+
if (!replacesAnswer)
|
|
484
|
+
return publicInvocationCommand(context, prior, command);
|
|
485
|
+
// The pause packet can prepare a placeholder before the controller has
|
|
486
|
+
// accepted an answer. Replace only an unobserved assignment; never change
|
|
487
|
+
// the authority of a native call already in flight.
|
|
488
|
+
const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
|
|
489
|
+
if (prior.status !== "issued" && !completed)
|
|
490
|
+
throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
|
|
491
|
+
if (prior.status === "issued")
|
|
492
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
|
|
478
493
|
}
|
|
479
494
|
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
480
495
|
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
@@ -807,8 +822,38 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
807
822
|
throw error;
|
|
808
823
|
}
|
|
809
824
|
}
|
|
810
|
-
/**
|
|
811
|
-
*
|
|
825
|
+
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
826
|
+
* matching. A hook-added event key must name the same persisted native call. */
|
|
827
|
+
export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
828
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
|
|
829
|
+
return null;
|
|
830
|
+
const signature = (value) => {
|
|
831
|
+
const parsed = parseLifecycleCommand(value);
|
|
832
|
+
if (parsed.kind !== "standalone" || parsed.invocation.wrapped)
|
|
833
|
+
return null;
|
|
834
|
+
return JSON.stringify([parsed.invocation.operation, parsed.invocation.args.positional,
|
|
835
|
+
[...parsed.invocation.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
|
|
836
|
+
};
|
|
837
|
+
const expected = signature(command);
|
|
838
|
+
if (!expected)
|
|
839
|
+
return null;
|
|
840
|
+
const eventKey = commandOption(invocation(command), "hook-event-id");
|
|
841
|
+
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 => {
|
|
842
|
+
if (eventKey && row.event_key !== eventKey)
|
|
843
|
+
return false;
|
|
844
|
+
try {
|
|
845
|
+
const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
|
|
846
|
+
return typeof observed === "string" && signature(observed) === expected;
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
// Diagnostic correlation only: never use this as execution authority, and
|
|
853
|
+
// never choose the latest of several identical native calls.
|
|
854
|
+
return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
|
|
855
|
+
}
|
|
856
|
+
/** Keep shell diagnostics distinct from genuinely absent native delivery. */
|
|
812
857
|
export function observedLifecycleDiagnostic(context, command, daemonId) {
|
|
813
858
|
const analysis = parseLifecycleCommand(command);
|
|
814
859
|
if (analysis.kind !== "standalone")
|
|
@@ -833,7 +878,7 @@ export function observedLifecycleDiagnostic(context, command, daemonId) {
|
|
|
833
878
|
/** Resolve only a command that a native hook has already bound to an issued
|
|
834
879
|
* attempt. This is the CLI-side half of admission for model-facing commands
|
|
835
880
|
* that intentionally omit the internal UUID. */
|
|
836
|
-
export function observedLifecycleInvocation(context, command) {
|
|
881
|
+
export function observedLifecycleInvocation(context, command, daemonId) {
|
|
837
882
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
838
883
|
return null;
|
|
839
884
|
const analysis = parseLifecycleCommand(command);
|
|
@@ -843,6 +888,8 @@ export function observedLifecycleInvocation(context, command) {
|
|
|
843
888
|
.filter(row => {
|
|
844
889
|
try {
|
|
845
890
|
const scope = JSON.parse(row.scope_json);
|
|
891
|
+
if (daemonId && scope.daemonId !== daemonId)
|
|
892
|
+
return false;
|
|
846
893
|
return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
|
|
847
894
|
}
|
|
848
895
|
catch {
|
|
@@ -598,19 +598,25 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
598
598
|
const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
|
|
599
599
|
if (!pause || state.current_session === null)
|
|
600
600
|
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
|
|
601
|
+
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
602
|
if (!answer) {
|
|
603
603
|
await delay(1000);
|
|
604
604
|
continue;
|
|
605
605
|
}
|
|
606
|
+
if (answer.status === "completed")
|
|
607
|
+
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
608
|
const input = JSON.parse(answer.input_json);
|
|
607
609
|
if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
|
|
608
610
|
throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
|
|
609
611
|
assertOwner();
|
|
610
612
|
// The accepted answer is logical intent; only its generation-bound
|
|
611
613
|
// 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
|
|
614
|
+
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
615
|
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}`);
|
|
616
|
+
const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
|
|
617
|
+
if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
|
|
618
|
+
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" });
|
|
619
|
+
}
|
|
614
620
|
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
621
|
state.last_continuation = null;
|
|
616
622
|
saveState(context, row, state);
|