@deksden-com/dd-flow-cli 0.9.0-beta.53 → 0.9.0-beta.54
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 +6 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +33 -17
- package/dist/harness-runtime/lib/dd-zcode.mjs +20 -5
- package/dist/services/cli-operation-classifier.js +5 -1
- package/dist/services/lifecycle-invocations.js +71 -6
- package/dist/services/stage-pause.js +11 -3
- package/dist/services/vnext-specify.js +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.54
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Preserve current lifecycle command input, atomically settle retry authority, select current resume commands, and stop ZCode productive admission immediately on fatal identity errors. Reject ambiguous result inputs and keep diagnostic commands read-only.
|
|
8
|
+
|
|
3
9
|
## 0.9.0-beta.53
|
|
4
10
|
|
|
5
11
|
### 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.54",
|
|
4
|
+
"cli_commit": "eb2cb9746a9ee386d40f63791f5dbe0847bf5017",
|
|
5
|
+
"built_at": "2026-09-13T12:29:37.211Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.0",
|
|
8
8
|
"commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { quote } from "shell-quote";
|
|
4
|
-
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation,
|
|
4
|
+
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation, lifecycleInvocationScope, settleLifecycleInvocation, settleLifecycleRejection } from "../services/lifecycle-invocations.js";
|
|
5
5
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
6
6
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
7
7
|
import { helpForArgs } from "./help.js";
|
|
@@ -72,7 +72,15 @@ const defaultIo = {
|
|
|
72
72
|
};
|
|
73
73
|
const flagValue = "__dd_flow_flag__";
|
|
74
74
|
export async function runCli(args, io = defaultIo, env = process.env) {
|
|
75
|
-
|
|
75
|
+
let output;
|
|
76
|
+
try {
|
|
77
|
+
output = parseOutputOptions(args);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
writeJson(io.stderr, { ok: false, error: { code: "usage", message } });
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
76
84
|
const progress = progressForCommand(output.args);
|
|
77
85
|
let heartbeat;
|
|
78
86
|
let invocationSettlement;
|
|
@@ -90,6 +98,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
90
98
|
io.stdout.write(`${help}\n`);
|
|
91
99
|
return 0;
|
|
92
100
|
}
|
|
101
|
+
assertSingletonOptions(output.args);
|
|
93
102
|
if (io.stdin?.isTTY && ["work", "stage"].includes(output.args[0] ?? "") && output.args[1] === "finish"
|
|
94
103
|
&& hasOption(parseArgs(output.args.slice(2)), "result-stdin")) {
|
|
95
104
|
throw new AppError("usage", "--result-stdin requires non-interactive input. Pipe or redirect the complete JSON into this command, or use --result-file; interactive terminals can truncate long JSON and wait indefinitely for EOF.", 2);
|
|
@@ -113,7 +122,9 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
113
122
|
if (routed?.routed && !routed.inProcess) {
|
|
114
123
|
if (heartbeat)
|
|
115
124
|
clearInterval(heartbeat);
|
|
116
|
-
emitHumanProgress(io, output, progress, "done");
|
|
125
|
+
emitHumanProgress(io, output, progress, routed.exitCode === 0 ? "done" : "failed");
|
|
126
|
+
if (output.progressJsonl && routed.exitCode !== 0)
|
|
127
|
+
writeProgressJsonl(io, "failed", "Routed engine failed", { exit_code: routed.exitCode });
|
|
117
128
|
return routed.exitCode;
|
|
118
129
|
}
|
|
119
130
|
const dispatchContext = routed?.inProcess
|
|
@@ -168,21 +179,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
168
179
|
if (heartbeat)
|
|
169
180
|
clearInterval(heartbeat);
|
|
170
181
|
if (invocationSettlement) {
|
|
171
|
-
// Optional retry preparation must never prevent recording the primary
|
|
172
|
-
// operation rejection. Otherwise a known failure becomes an unknown run.
|
|
173
|
-
if (isAppError(error) && isRetryableLifecycleRejection(error.code)) {
|
|
174
|
-
try {
|
|
175
|
-
error.details.retry_command = retryLifecycleInvocationCommand(invocationSettlement.context, invocationSettlement.id);
|
|
176
|
-
error.details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
177
|
-
}
|
|
178
|
-
catch (retryError) {
|
|
179
|
-
error.details.retry_preparation_error = String(retryError);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
182
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
if (isAppError(error))
|
|
184
|
+
settleLifecycleRejection(invocationSettlement.context, invocationSettlement.id, { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details });
|
|
185
|
+
else
|
|
186
|
+
settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
|
|
186
187
|
}
|
|
187
188
|
catch (persistenceError) {
|
|
188
189
|
io.stderr.write(`Invocation settlement persistence failed: ${String(persistenceError)}\n`);
|
|
@@ -2442,6 +2443,21 @@ function parseFlowFlagOverrides(values) {
|
|
|
2442
2443
|
function parseArgs(args) {
|
|
2443
2444
|
return parseCommandArgs(args);
|
|
2444
2445
|
}
|
|
2446
|
+
/** Most CLI options name one authority, path or target. Silently taking the
|
|
2447
|
+
* last duplicate can widen a mutation after an operator typo. Keep only the
|
|
2448
|
+
* options consumed as intentional arrays repeatable. */
|
|
2449
|
+
function assertSingletonOptions(args) {
|
|
2450
|
+
const repeatable = new Set(["alias", "flag", "hide", "check-ref", "evidence", "from-check", "from-finding", "from-unresolved", "on", "origin-work"]);
|
|
2451
|
+
const parsed = parseCommandArgs(args);
|
|
2452
|
+
if (args[0] === "stage" && args[1] === "finish"
|
|
2453
|
+
&& ["result-file", "semantic-file", "data", "result-stdin"].filter(name => parsed.options.has(name)).length > 1) {
|
|
2454
|
+
throw new AppError("usage", "Stage finish requires exactly one result input; result-file, semantic-file, data and result-stdin are mutually exclusive", 2);
|
|
2455
|
+
}
|
|
2456
|
+
for (const [name, values] of parsed.options) {
|
|
2457
|
+
if (!repeatable.has(name) && values.length > 1)
|
|
2458
|
+
throw new AppError("usage", `--${name} may be supplied only once`, 2);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2445
2461
|
function requiredPosition(parsed, index, label) {
|
|
2446
2462
|
const value = parsed.positional[index];
|
|
2447
2463
|
if (!value) {
|
|
@@ -11,6 +11,9 @@ import { checkObservedProfile, observeModel, observedFields } from "./model-obse
|
|
|
11
11
|
const ZCODE_HARNESS_CONTRACT = "dd-zcode-harness@1";
|
|
12
12
|
|
|
13
13
|
function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
|
|
14
|
+
function fatalLifecycleError(error) {
|
|
15
|
+
return ["invocation_unknown", "invocation_identity_mismatch", "invocation_identity_conflict", "invocation_receipt_missing", "invocation_command_mismatch", "invocation_scope_mismatch", "invocation_generation_stale", "invocation_expired", "zcode_parent_identity_missing", "zcode_tool_identity_conflict"].includes(error?.code);
|
|
16
|
+
}
|
|
14
17
|
function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
|
|
15
18
|
function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
16
19
|
function now() { return new Date().toISOString(); }
|
|
@@ -134,9 +137,17 @@ export class AcpBridge {
|
|
|
134
137
|
if (message.method === "session/update" && this.options.onLifecycleNotification) {
|
|
135
138
|
const observer = this.options.onLifecycleNotification;
|
|
136
139
|
const pending = Promise.resolve().then(() => observer(message)).catch(async error => {
|
|
137
|
-
this.notificationError
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
if (!this.notificationError || (fatalLifecycleError(error) && !fatalLifecycleError(this.notificationError))) this.notificationError = error;
|
|
141
|
+
// A missing/foreign authority is not a recoverable CLI validation
|
|
142
|
+
// result. Stop admitting productive requests immediately; the managed
|
|
143
|
+
// controller still owns physical stop/drain and evidence capture.
|
|
144
|
+
if (fatalLifecycleError(error)) this.rejectAll(error);
|
|
145
|
+
try {
|
|
146
|
+
await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
|
|
147
|
+
session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
|
|
148
|
+
} catch (persistenceError) {
|
|
149
|
+
process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message, persistence_error: persistenceError.message })}\n`);
|
|
150
|
+
}
|
|
140
151
|
});
|
|
141
152
|
this.lifecycleNotifications.add(pending);
|
|
142
153
|
void pending.then(() => this.lifecycleNotifications.delete(pending), () => this.lifecycleNotifications.delete(pending));
|
|
@@ -224,7 +235,7 @@ export class AcpBridge {
|
|
|
224
235
|
}
|
|
225
236
|
|
|
226
237
|
request(method, params = {}, timeoutMs = 30_000, { idleSessionId = null, idleTimeoutMs = null } = {}) {
|
|
227
|
-
if (this.notificationError?.code === "profile_integrity_violation" && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
|
|
238
|
+
if ((this.notificationError?.code === "profile_integrity_violation" || fatalLifecycleError(this.notificationError)) && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
|
|
228
239
|
const id = this.nextId++;
|
|
229
240
|
return new Promise((resolve, reject) => {
|
|
230
241
|
const finish = (error) => {
|
|
@@ -270,7 +281,11 @@ export class AcpBridge {
|
|
|
270
281
|
notify(method, params = {}) { this.send({ jsonrpc: "2.0", method, params }); }
|
|
271
282
|
send(message) { this.journal.write("outbound", message); this.child.stdin.write(`${JSON.stringify(message)}\n`); }
|
|
272
283
|
rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); pending.reject(error); } this.pending.clear(); }
|
|
273
|
-
async flush() {
|
|
284
|
+
async flush() {
|
|
285
|
+
try { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); }
|
|
286
|
+
catch (error) { throw this.notificationError ?? error; }
|
|
287
|
+
if (this.notificationError) throw this.notificationError;
|
|
288
|
+
}
|
|
274
289
|
async close() {
|
|
275
290
|
let flushError = null;
|
|
276
291
|
try { await this.flush(); } catch (error) { flushError = error; }
|
|
@@ -131,7 +131,11 @@ function isReadOnlyDiagnostic(family, command, action) {
|
|
|
131
131
|
if (family === "plan")
|
|
132
132
|
return command === "status";
|
|
133
133
|
if (family === "run")
|
|
134
|
-
return command === "status" || command === "list" || (["drive", "control"].includes(command ?? "") && action === "status");
|
|
134
|
+
return command === "status" || command === "list" || (command === "recovery" && action === "inspect") || (["drive", "control"].includes(command ?? "") && action === "status");
|
|
135
|
+
if (family === "work")
|
|
136
|
+
return ["status", "ls", "show"].includes(command ?? "");
|
|
137
|
+
if (family === "stage")
|
|
138
|
+
return command === "fanout" && action === "status";
|
|
135
139
|
if (family === "lane") {
|
|
136
140
|
return (command === "status" ||
|
|
137
141
|
command === "waiters" ||
|
|
@@ -9,12 +9,13 @@ import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettl
|
|
|
9
9
|
import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
10
10
|
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
11
11
|
// unknown-effect failures. These validators reject before accepting the result.
|
|
12
|
-
export function isRetryableLifecycleRejection(code) {
|
|
12
|
+
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
13
13
|
return ["validation", "schema_validation", "work_checks_failed", "code_review_gate_failed",
|
|
14
14
|
"review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
15
15
|
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
16
16
|
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
17
|
-
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
17
|
+
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
18
|
+
|| (code === "invalid_work_state" && details.retryable_no_effect === true);
|
|
18
19
|
}
|
|
19
20
|
function storage(context) {
|
|
20
21
|
context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
|
|
@@ -51,6 +52,30 @@ function load(context, id) {
|
|
|
51
52
|
export function lifecycleInvocationScope(context, id) {
|
|
52
53
|
return JSON.parse(load(context, id).scope_json);
|
|
53
54
|
}
|
|
55
|
+
/** Current, explicitly-issued replacements for commands from the resumed stage.
|
|
56
|
+
* Original prompts remain immutable evidence; callers may present this small
|
|
57
|
+
* overlay after a state transition made an earlier no-effect rejection retryable. */
|
|
58
|
+
export function lifecycleRetryCommands(context, stage) {
|
|
59
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
60
|
+
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
61
|
+
return [];
|
|
62
|
+
return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
63
|
+
.flatMap((row) => {
|
|
64
|
+
try {
|
|
65
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
66
|
+
const retry = outcome?.error?.details?.retry_command;
|
|
67
|
+
const parsed = invocation(row.command);
|
|
68
|
+
if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
|
|
69
|
+
return [];
|
|
70
|
+
const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
|
|
71
|
+
const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
|
|
72
|
+
return successor.id === latest?.id && successor.status === "issued" ? [retry] : [];
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
54
79
|
export function managedInvocationContext(context, input) {
|
|
55
80
|
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
56
81
|
if (input.harness !== "zcode-acp")
|
|
@@ -81,15 +106,16 @@ export function managedLifecycleCommand(context, command) {
|
|
|
81
106
|
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
82
107
|
if (!retained)
|
|
83
108
|
throw new AppError("invocation_command_unprepared", "Read-only inspection has no retained lifecycle command", 1);
|
|
84
|
-
return retained
|
|
109
|
+
return renderInvocationCommand(retained, command);
|
|
85
110
|
}
|
|
86
111
|
storage(context);
|
|
87
112
|
context.db.exec("SAVEPOINT invocation_command");
|
|
88
113
|
try {
|
|
89
114
|
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)]);
|
|
90
115
|
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
116
|
+
const rendered = renderInvocationCommand(prior, command);
|
|
91
117
|
context.db.exec("RELEASE invocation_command");
|
|
92
|
-
return
|
|
118
|
+
return rendered;
|
|
93
119
|
}
|
|
94
120
|
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
95
121
|
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
@@ -112,6 +138,31 @@ export function retryLifecycleInvocationCommand(context, id) {
|
|
|
112
138
|
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
113
139
|
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
|
|
114
140
|
}
|
|
141
|
+
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
142
|
+
* A crash must leave either the old attempt executing, or both the settled
|
|
143
|
+
* outcome and its successor; never a usable successor beside an unknown effect. */
|
|
144
|
+
export function settleLifecycleRejection(context, id, error) {
|
|
145
|
+
storage(context);
|
|
146
|
+
const details = { ...error.details };
|
|
147
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
148
|
+
try {
|
|
149
|
+
const prior = load(context, id);
|
|
150
|
+
if (prior.status !== "executing")
|
|
151
|
+
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
152
|
+
if (isRetryableLifecycleRejection(error.code, error.details)) {
|
|
153
|
+
const retry = retryLifecycleInvocationCommand(context, id);
|
|
154
|
+
details.retry_command = retry;
|
|
155
|
+
details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
156
|
+
}
|
|
157
|
+
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
158
|
+
context.db.exec("COMMIT");
|
|
159
|
+
Object.assign(error.details, details);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
context.db.exec("ROLLBACK");
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
115
166
|
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
116
167
|
* never-executed commands receive new authority; unknown effects never replay. */
|
|
117
168
|
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
@@ -206,14 +257,28 @@ export function issueLifecycleInvocation(context, command, scope) {
|
|
|
206
257
|
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
207
258
|
}
|
|
208
259
|
const id = crypto.randomUUID();
|
|
209
|
-
const
|
|
210
|
-
const prepared = `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
260
|
+
const prepared = commandWithInvocationId(command, id);
|
|
211
261
|
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
212
262
|
const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
213
263
|
checkCommand(row, prepared);
|
|
214
264
|
context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
|
|
215
265
|
return { id, command: prepared };
|
|
216
266
|
}
|
|
267
|
+
function commandWithInvocationId(command, id) {
|
|
268
|
+
const at = shellSuffixOffset(command);
|
|
269
|
+
return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
270
|
+
}
|
|
271
|
+
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
272
|
+
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
273
|
+
* still names the exact persisted operation and project. */
|
|
274
|
+
function renderInvocationCommand(row, command) {
|
|
275
|
+
const parsed = invocation(command);
|
|
276
|
+
if (commandOption(parsed, "invocation-id"))
|
|
277
|
+
throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
|
|
278
|
+
const rendered = commandWithInvocationId(command, row.id);
|
|
279
|
+
checkCommand(row, rendered);
|
|
280
|
+
return rendered;
|
|
281
|
+
}
|
|
217
282
|
/** Native observer and receipt writer commit together. Never await I/O inside
|
|
218
283
|
* this transaction; the callback must write to this same SQLite connection. */
|
|
219
284
|
export function observeLifecycleInvocation(context, input) {
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { quote } from "shell-quote";
|
|
6
|
-
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
6
|
+
import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
7
7
|
import { AppError } from "../shared/errors.js";
|
|
8
8
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
|
|
@@ -73,16 +73,22 @@ export function resumeStageAfterUser(context, input) {
|
|
|
73
73
|
errorCode: "trusted_stage_resume_required",
|
|
74
74
|
operation: "stage resume"
|
|
75
75
|
}).eventKey;
|
|
76
|
-
const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
|
|
77
|
-
fs.writeFileSync(answerPath, input.answer);
|
|
78
76
|
const now = context.now();
|
|
79
77
|
const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
80
78
|
if (!workSession)
|
|
81
79
|
throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
|
|
80
|
+
const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
|
|
82
81
|
context.db.exec("BEGIN IMMEDIATE");
|
|
83
82
|
let identity;
|
|
84
83
|
try {
|
|
85
84
|
identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
86
92
|
if (workSession.session_id !== identity.sessionId) {
|
|
87
93
|
bindSessionForResume(context, work.work_id, identity, now);
|
|
88
94
|
context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
|
|
@@ -101,6 +107,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
101
107
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
102
108
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
|
|
103
109
|
const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
|
|
110
|
+
const retryCommands = lifecycleRetryCommands(context, stage.stage);
|
|
104
111
|
const continuation = [
|
|
105
112
|
prompt.trimEnd(),
|
|
106
113
|
"",
|
|
@@ -109,6 +116,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
109
116
|
`- Question source: ${stage.pause.question_path}`,
|
|
110
117
|
`- Answer source: ${answerPath}`,
|
|
111
118
|
"- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
|
|
119
|
+
...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; use only these replacements, never invent an invocation id:", ...retryCommands.map(command => ` ${command}`)] : []),
|
|
112
120
|
"",
|
|
113
121
|
"<user_question>",
|
|
114
122
|
fs.readFileSync(stage.pause.question_path, "utf8"),
|
|
@@ -128,7 +128,9 @@ export function submitVnextSpecify(context, input) {
|
|
|
128
128
|
if (work.project_id !== project.id)
|
|
129
129
|
throw new AppError("project_mismatch", "Work does not belong to --project-root", 1, { work_id: work.work_id });
|
|
130
130
|
if (work.status !== "running") {
|
|
131
|
-
|
|
131
|
+
// This check precedes all result reads and mutations. A later legal state
|
|
132
|
+
// transition may make a new invocation appropriate, but never replays this one.
|
|
133
|
+
throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status, retryable_no_effect: true });
|
|
132
134
|
}
|
|
133
135
|
const run = requireRun(context, projectRoot, work.run_id);
|
|
134
136
|
if (runStatus(context, project.id, run.id) !== "running") {
|