@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.105
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 +23 -8
- package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/services/cleanup.js +81 -32
- package/dist/services/code-checks.js +18 -19
- package/dist/services/continuation-outcome.js +1 -1
- package/dist/services/controller-fanout.js +3 -3
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/hooks.js +150 -42
- package/dist/services/lanes.js +23 -18
- package/dist/services/lifecycle-command.js +11 -2
- package/dist/services/lifecycle-invocations.js +291 -54
- package/dist/services/managed-processes.js +35 -23
- package/dist/services/merge-queue.js +71 -67
- package/dist/services/merge-server.js +3 -11
- package/dist/services/migrations.js +28 -12
- package/dist/services/plan-runtime.js +8 -4
- package/dist/services/projects.js +3 -11
- package/dist/services/protocols.js +98 -68
- package/dist/services/recovery-snapshot-database.js +43 -8
- package/dist/services/run-control-worker.js +15 -32
- package/dist/services/run-control.js +3 -17
- package/dist/services/run-controller-capture.js +58 -27
- package/dist/services/run-controller-process.js +21 -30
- package/dist/services/run-controller-recovery.js +36 -37
- package/dist/services/run-controller.js +81 -86
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +19 -64
- package/dist/services/run-recovery-runtime.js +24 -14
- package/dist/services/run-recovery.js +56 -54
- package/dist/services/runs.js +218 -160
- package/dist/services/runtime-budget.js +19 -45
- package/dist/services/runtime-scope-capture.js +8 -6
- package/dist/services/runtime-scope-control.js +49 -39
- package/dist/services/runtime-scope-resume.js +134 -122
- package/dist/services/runtime-scope-worker.js +48 -49
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/stage-context.js +6 -1
- package/dist/services/stage-lifecycle.js +1 -1
- package/dist/services/stage-pause.js +13 -29
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/usage.js +2 -9
- package/dist/services/vnext-code-review.js +10 -7
- package/dist/services/vnext-code.js +9 -17
- package/dist/services/vnext-merge.js +64 -21
- package/dist/services/vnext-plan-review.js +43 -30
- package/dist/services/vnext-plan.js +28 -24
- package/dist/services/vnext-protocolize.js +183 -20
- package/dist/services/vnext-specify.js +27 -19
- package/dist/services/work-registry.js +229 -150
- package/dist/storage/database.js +286 -56
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/writer-contract.js +3 -3
- package/dist/storage/writer-migration.js +24 -41
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +48 -23
|
@@ -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, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
9
|
+
import { commandOption, commandPosition, 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";
|
|
@@ -20,6 +20,7 @@ import { isRegisteredRepair } from "./repair-intents.js";
|
|
|
20
20
|
import { stageAttempt } from "./stage-work-graph.js";
|
|
21
21
|
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
22
22
|
import { canonicalPath } from "../storage/paths.js";
|
|
23
|
+
import { runtimeCommand } from "./runtime-command.js";
|
|
23
24
|
/** Auxiliary commands have no invocation capability, but may not escape their
|
|
24
25
|
* native daemon's project/RUN. Context aliases never grant mutation authority. */
|
|
25
26
|
export function assertAuxiliaryCommandScope(context, command, projectRoot) {
|
|
@@ -98,11 +99,15 @@ function invocation(command) {
|
|
|
98
99
|
}
|
|
99
100
|
return parsed.invocation;
|
|
100
101
|
}
|
|
102
|
+
function launchExpression(parsed) {
|
|
103
|
+
const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
|
|
104
|
+
return parsed.launcher ? `${quote([parsed.launcher])} ${executable}` : executable;
|
|
105
|
+
}
|
|
101
106
|
// Input data may be supplied later (stdin/file contents); authority is bound to
|
|
102
107
|
// the exact operation and literal argv. Existing lifecycle validation owns data.
|
|
103
108
|
function fingerprint(parsed) {
|
|
104
109
|
const projected = publicInvocationArgs(parsed);
|
|
105
|
-
const publicParsed = invocation(`${
|
|
110
|
+
const publicParsed = invocation(`${launchExpression(parsed)} ${quoteModelFacingArgs(projected)}`);
|
|
106
111
|
const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl"].includes(key))
|
|
107
112
|
.map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
|
|
108
113
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
@@ -194,7 +199,7 @@ function withoutInvocationId(command, aliases) {
|
|
|
194
199
|
argv[index] = normalizedInvocationValue("positional", argv[index]);
|
|
195
200
|
}
|
|
196
201
|
const env = Object.fromEntries(Object.entries(parsed.env).filter(([key]) => !["DD_FLOW_INVOCATION_SCOPE", "DD_FLOW_HOME", "DD_FLOW_BIN"].includes(key)));
|
|
197
|
-
const executable =
|
|
202
|
+
const executable = launchExpression(parsed);
|
|
198
203
|
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
199
204
|
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
200
205
|
return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
@@ -205,8 +210,35 @@ function publicInvocationCommand(context, row, command) {
|
|
|
205
210
|
const rendered = renderInvocationCommand(row, command);
|
|
206
211
|
const scope = JSON.parse(row.scope_json);
|
|
207
212
|
const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
213
|
+
context.captureLifecycleAssignment?.(row.id);
|
|
208
214
|
return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
|
|
209
215
|
}
|
|
216
|
+
/** Bind issued commands to the exact bytes sent to the model, not to an
|
|
217
|
+
* internal marker which is intentionally absent from that prompt. */
|
|
218
|
+
export function bindLifecyclePacketAssignments(context, prompt, ids) {
|
|
219
|
+
const unique = [...new Set(ids)];
|
|
220
|
+
if (!unique.length)
|
|
221
|
+
return;
|
|
222
|
+
const digest = crypto.createHash("sha256").update(prompt).digest("hex");
|
|
223
|
+
context.db.writeTransaction(() => {
|
|
224
|
+
for (const id of unique) {
|
|
225
|
+
const row = load(context, id);
|
|
226
|
+
if (row.status !== "issued")
|
|
227
|
+
throw new AppError("invocation_packet_conflict", "Packet contains a lifecycle assignment already in flight", 1, { invocation_id: id });
|
|
228
|
+
context.db.run("INSERT OR IGNORE INTO lifecycle_packet_assignments (packet_sha256, invocation_id) VALUES (?, ?)", [digest, id]);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** Stage renderers issue their commands before the final packet is assembled.
|
|
233
|
+
* Bind only issued commands visibly present in that exact published packet. */
|
|
234
|
+
export function bindLifecyclePublishedPacket(context, prompt) {
|
|
235
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
236
|
+
if (!scope)
|
|
237
|
+
return;
|
|
238
|
+
const issued = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'issued' ORDER BY rowid", [scope]);
|
|
239
|
+
const ids = issued.filter(row => prompt.includes(publicInvocationCommand(context, row, retainedCommandWithoutInvocationId(row)))).map(row => row.id);
|
|
240
|
+
bindLifecyclePacketAssignments(context, prompt, ids);
|
|
241
|
+
}
|
|
210
242
|
function retainedCommandWithoutInvocationId(row) {
|
|
211
243
|
const marker = ` --invocation-id ${row.id}`;
|
|
212
244
|
const at = row.command.indexOf(marker);
|
|
@@ -248,7 +280,7 @@ function canonicalManagedCommand(context, command, scope) {
|
|
|
248
280
|
const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
|
|
249
281
|
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
250
282
|
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
251
|
-
const executable =
|
|
283
|
+
const executable = launchExpression(parsed);
|
|
252
284
|
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
253
285
|
return `${prefix} ${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
254
286
|
}
|
|
@@ -391,9 +423,29 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
|
391
423
|
return;
|
|
392
424
|
const error = outcome.error ? errorRecord(outcome.error) : undefined;
|
|
393
425
|
const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
|
|
394
|
-
const
|
|
426
|
+
const correlationKey = parsed.kind === "standalone" ? fingerprint(parsed.invocation)
|
|
427
|
+
: JSON.stringify([auxiliary.operation, auxiliary.operation === "stage_block" ? auxiliary.args.positional[2] ?? null : auxiliary.args.options.get("run")?.[0] ?? null, auxiliary.args.options.get("stage")?.[0] ?? null]);
|
|
428
|
+
const record = { ...anchor, correlation_key: correlationKey, ...(parsed.kind === "standalone" ? { fingerprint: correlationKey } : {}), ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? auxiliary ? "pending_continuation" : correctable ? "correctable" : "fatal" : "completed" };
|
|
395
429
|
try {
|
|
396
430
|
context.db.writeTransaction(() => {
|
|
431
|
+
if (error && parsed.kind === "standalone" && !auxiliary && !invocationId && !context.env.DD_FLOW_INVOCATION_SCOPE
|
|
432
|
+
&& error.details.effect === "no_effect" && isRetryableLifecycleRejection(error.code, error.details)
|
|
433
|
+
&& !isRegisteredRepair(context, error.details.continuation, { projectId: anchor.project_id, runId: anchor.run_id })) {
|
|
434
|
+
const signature = preparationRejectionSignature(new AppError(error.code, error.message, 1, error.details));
|
|
435
|
+
error.details.hook_retry_signature = signature;
|
|
436
|
+
const repeated = context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND id <> ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.operation') = ? AND json_extract(outcome_json, '$.error.details.hook_retry_signature') = ? LIMIT 1", [anchor.project_id, anchor.hook_event_id, anchor.run_id, anchor.generation, anchor.operation, signature]);
|
|
437
|
+
if (!repeated) {
|
|
438
|
+
const argv = parsed.invocation.argv.filter((value, index, all) => value !== "--hook-event-id" && all[index - 1] !== "--hook-event-id" && value !== "--invocation-id" && all[index - 1] !== "--invocation-id");
|
|
439
|
+
error.details.retry_command = runtimeCommand(context, argv);
|
|
440
|
+
error.details.retry_instruction = "Correct the evidenced input, then run retry_command once as a standalone command in this Session. The rejected attempt had no lifecycle effect.";
|
|
441
|
+
error.details.recoverable = true;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
error.details.recoverable = false;
|
|
445
|
+
error.details.repeated_without_progress = true;
|
|
446
|
+
record.disposition = "fatal";
|
|
447
|
+
}
|
|
448
|
+
}
|
|
397
449
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
|
|
398
450
|
const previous = retained?.outcome_json ? JSON.parse(retained.outcome_json) : null;
|
|
399
451
|
if (previous?.storage_retry === 1)
|
|
@@ -457,7 +509,7 @@ export function lifecycleRetryCommands(context, stage) {
|
|
|
457
509
|
}
|
|
458
510
|
export function managedInvocationContext(context, input) {
|
|
459
511
|
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
460
|
-
if (
|
|
512
|
+
if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli"].includes(input.harness))
|
|
461
513
|
return { ...context, env };
|
|
462
514
|
const project = requireProjectByRoot(context, input.projectRoot);
|
|
463
515
|
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
@@ -467,7 +519,7 @@ export function managedInvocationContext(context, input) {
|
|
|
467
519
|
// Codex owns a single root Session per controller and persists that root as
|
|
468
520
|
// a string. Child agent_id values arrive later through the native hook and
|
|
469
521
|
// are checked against this root scope; they are not controller Sessions.
|
|
470
|
-
const rootSessionId = input.harness
|
|
522
|
+
const rootSessionId = input.harness !== "zcode-acp"
|
|
471
523
|
? (typeof session === "string" ? session : session?.root_provider_session_id ?? session?.provider_session_id ?? null)
|
|
472
524
|
: (typeof session === "string" ? null : session?.root_provider_session_id ?? null);
|
|
473
525
|
if (!state.daemon_id || !rootSessionId)
|
|
@@ -569,8 +621,7 @@ const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "
|
|
|
569
621
|
export function settleLifecycleRejection(context, id, error) {
|
|
570
622
|
storage(context);
|
|
571
623
|
const details = { ...error.details };
|
|
572
|
-
context.db.
|
|
573
|
-
try {
|
|
624
|
+
context.db.writeTransaction(() => {
|
|
574
625
|
const prior = load(context, id);
|
|
575
626
|
if (prior.status !== "executing")
|
|
576
627
|
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
@@ -593,18 +644,20 @@ export function settleLifecycleRejection(context, id, error) {
|
|
|
593
644
|
details.recovery_instruction = "Do not retry this command in the current Session. End the coordinator Turn; the controller will launch the already-registered Work in a fresh child Session.";
|
|
594
645
|
}
|
|
595
646
|
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
catch (error) {
|
|
600
|
-
context.db.exec("ROLLBACK");
|
|
601
|
-
throw error;
|
|
602
|
-
}
|
|
647
|
+
});
|
|
648
|
+
Object.assign(error.details, details);
|
|
603
649
|
}
|
|
604
650
|
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
605
651
|
* never-executed commands receive new authority; unknown effects never replay. */
|
|
652
|
+
export function hasLifecyclePacketAssignments(context, originalPrompt) {
|
|
653
|
+
const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
|
|
654
|
+
return Boolean(context.db.get("SELECT 1 FROM lifecycle_packet_assignments WHERE packet_sha256 = ? LIMIT 1", [digest]));
|
|
655
|
+
}
|
|
606
656
|
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
607
|
-
const
|
|
657
|
+
const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
|
|
658
|
+
const ids = context.db.all("SELECT invocation_id FROM lifecycle_packet_assignments WHERE packet_sha256 = ? ORDER BY rowid", [digest]).map(row => row.invocation_id);
|
|
659
|
+
if (!ids.length)
|
|
660
|
+
throw new AppError("invocation_recovery_unbound", "Original packet has no verified lifecycle assignment binding; reconcile it before continuing", 1);
|
|
608
661
|
const recovered = [];
|
|
609
662
|
for (const id of ids) {
|
|
610
663
|
let prior = load(context, id);
|
|
@@ -633,7 +686,7 @@ export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
|
633
686
|
throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
|
|
634
687
|
argv.splice(at, 2);
|
|
635
688
|
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
636
|
-
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${
|
|
689
|
+
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${launchExpression(parsed)} ${quote(argv)} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
|
|
637
690
|
const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
|
|
638
691
|
recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
|
|
639
692
|
}
|
|
@@ -677,8 +730,10 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
|
677
730
|
if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
|
|
678
731
|
if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
|
|
679
732
|
return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
|
|
680
|
-
if (parsed.operation === "stage_finish")
|
|
681
|
-
|
|
733
|
+
if (parsed.operation === "stage_finish") {
|
|
734
|
+
assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
682
737
|
}
|
|
683
738
|
if ((guard?.generation ?? 0) !== scope.generation)
|
|
684
739
|
throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
|
|
@@ -753,11 +808,112 @@ function checkNativeIdentity(scope, identity, id) {
|
|
|
753
808
|
throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: id });
|
|
754
809
|
}
|
|
755
810
|
}
|
|
811
|
+
function managedNativeOwner(context, identity, harness, projectId) {
|
|
812
|
+
const owners = [];
|
|
813
|
+
const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
|
|
814
|
+
if (hasTable("run_controllers")) {
|
|
815
|
+
const controllers = context.db.all("SELECT c.run_id, r.status AS run_status, c.status, c.generation, c.state_json FROM run_controllers c JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id WHERE c.project_id = ?", [projectId]);
|
|
816
|
+
for (const controller of controllers) {
|
|
817
|
+
if (!controller.state_json.includes(identity.rootSessionId))
|
|
818
|
+
continue;
|
|
819
|
+
const state = JSON.parse(controller.state_json);
|
|
820
|
+
for (const [index, session] of (state.sessions ?? []).entries()) {
|
|
821
|
+
if (session.id !== identity.rootSessionId)
|
|
822
|
+
continue;
|
|
823
|
+
owners.push({ runId: controller.run_id, rootSessionId: session.id,
|
|
824
|
+
stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation,
|
|
825
|
+
active: ["running", "paused", "waiting_for_user", "blocked"].includes(controller.run_status)
|
|
826
|
+
&& !["completed", "cancelled", "superseded"].includes(controller.status)
|
|
827
|
+
&& state.current_session === index && session.stopped !== true });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (hasTable("work_launches")) {
|
|
832
|
+
const launches = context.db.all("SELECT w.run_id, r.status AS run_status, l.work_id, l.operation_id, l.status, l.state_dir, l.provider_session_id, l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id JOIN runs r ON r.project_id = w.project_id AND r.id = w.run_id WHERE w.project_id = ? AND l.provider_session_id = ?", [projectId, identity.rootSessionId]);
|
|
833
|
+
for (const launch of launches) {
|
|
834
|
+
// Cooperative pause/drain may retain an admitted Work settlement while
|
|
835
|
+
// the RUN is paused; only terminal RUN states retire its native root.
|
|
836
|
+
const active = ["running", "paused", "waiting_for_user", "blocked"].includes(launch.run_status)
|
|
837
|
+
&& ["launching", "start_requested"].includes(launch.status);
|
|
838
|
+
if (!active)
|
|
839
|
+
throw new AppError("invocation_scope_unproven", "External Work native root is no longer active", 1, { effect: "no_effect" });
|
|
840
|
+
const profile = JSON.parse(launch.profile_json);
|
|
841
|
+
const owner = profile.runtime_adapter?.owner;
|
|
842
|
+
if (!owner || owner.owner_id !== `run:${launch.run_id}:work:${launch.work_id}` || owner.project_id !== projectId
|
|
843
|
+
|| owner.run_id !== launch.run_id || owner.work_id !== launch.work_id || owner.operation_id !== launch.operation_id || owner.state_dir !== launch.state_dir) {
|
|
844
|
+
throw new AppError("invocation_scope_unproven", "External Work has no matching persisted runtime owner", 1, { effect: "no_effect" });
|
|
845
|
+
}
|
|
846
|
+
owners.push({ runId: launch.run_id, rootSessionId: launch.provider_session_id, stateDir: launch.state_dir,
|
|
847
|
+
harness: runtimeHarness(profile.worker), ownerId: owner.owner_id, active });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (hasTable("merge_requests")) {
|
|
851
|
+
const requests = context.db.all("SELECT m.merge_request_id, m.run_id, r.status AS run_status, m.status, m.executor_work_id, m.dispatch_owner, m.dispatch_lease_token, m.adapter_receipt_json FROM merge_requests m JOIN runs r ON r.project_id = m.project_id AND r.id = m.run_id WHERE m.project_id = ? AND m.execution_route = 'server' AND CASE WHEN json_valid(m.adapter_receipt_json) THEN json_extract(m.adapter_receipt_json, '$.session_id') = ? ELSE 0 END", [projectId, identity.rootSessionId]);
|
|
852
|
+
for (const request of requests) {
|
|
853
|
+
const active = ["running", "paused", "waiting_for_user", "blocked"].includes(request.run_status)
|
|
854
|
+
&& ["dispatching", "active", "waiting_user", "action_required", "recovery_required"].includes(request.status);
|
|
855
|
+
if (!active)
|
|
856
|
+
throw new AppError("invocation_scope_unproven", "Server MERGE native root is no longer active", 1, { effect: "no_effect" });
|
|
857
|
+
const receipt = JSON.parse(request.adapter_receipt_json);
|
|
858
|
+
if (receipt.controller_id) {
|
|
859
|
+
const controller = context.db.get("SELECT generation, status, state_json FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ?", [receipt.controller_id, projectId, request.run_id]);
|
|
860
|
+
const state = controller && JSON.parse(controller.state_json);
|
|
861
|
+
const session = state?.current_session === undefined || state.current_session === null ? undefined : state.sessions?.[state.current_session];
|
|
862
|
+
if (!request.dispatch_lease_token || request.dispatch_owner !== receipt.controller_id
|
|
863
|
+
|| !controller || ["completed", "cancelled", "superseded"].includes(controller.status)
|
|
864
|
+
|| state?.merge_claim?.request_id !== request.merge_request_id || state.merge_claim.token !== request.dispatch_lease_token
|
|
865
|
+
|| state.merge_claim.work_id !== request.executor_work_id || !session || session.stopped === true
|
|
866
|
+
|| session.id !== receipt.session_id || session.state_dir !== receipt.state_dir || !receipt.profile
|
|
867
|
+
|| JSON.stringify(session.profile) !== JSON.stringify(receipt.profile)) {
|
|
868
|
+
throw new AppError("invocation_scope_unproven", "Controller MERGE root does not match its dispatch lease and retained Session", 1, { effect: "no_effect" });
|
|
869
|
+
}
|
|
870
|
+
// The controller Session above is already an owner candidate; its
|
|
871
|
+
// daemon binding and generation are checked once in the common loop.
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const owner = receipt.owner;
|
|
875
|
+
if (!receipt.session_id || !receipt.state_dir || !receipt.profile || !owner
|
|
876
|
+
|| owner.owner_id !== `run:${request.run_id}:merge:${request.merge_request_id}` || owner.project_id !== projectId
|
|
877
|
+
|| owner.run_id !== request.run_id || owner.work_id !== request.executor_work_id || owner.state_dir !== receipt.state_dir
|
|
878
|
+
|| owner.operation_id !== `${request.merge_request_id}:${request.dispatch_lease_token}`) {
|
|
879
|
+
throw new AppError("invocation_scope_unproven", "Server MERGE has no matching persisted runtime owner", 1, { effect: "no_effect" });
|
|
880
|
+
}
|
|
881
|
+
owners.push({ runId: request.run_id, rootSessionId: receipt.session_id, stateDir: receipt.state_dir,
|
|
882
|
+
harness: runtimeHarness(receipt.profile), ownerId: owner.owner_id, active });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
for (const owner of owners) {
|
|
886
|
+
if (!owner.active)
|
|
887
|
+
throw new AppError("invocation_scope_unproven", "Managed native root is no longer active", 1, { effect: "no_effect" });
|
|
888
|
+
if (owner.harness !== harness)
|
|
889
|
+
throw new AppError("invocation_scope_unproven", "Native root belongs to another managed harness", 1, { effect: "no_effect" });
|
|
890
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: owner.stateDir, projectId, runId: owner.runId, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) });
|
|
891
|
+
if (binding.process.kind !== (harness === "grok-acp" ? "grok-daemon" : "agy-daemon")) {
|
|
892
|
+
throw new AppError("invocation_scope_unproven", "Native daemon kind differs from its persisted harness", 1, { effect: "no_effect" });
|
|
893
|
+
}
|
|
894
|
+
if (binding.state.daemon_id !== identity.daemonId)
|
|
895
|
+
throw new AppError("invocation_scope_unproven", "Native daemon differs from its persisted launch", 1, { effect: "no_effect" });
|
|
896
|
+
const root = binding.state.sessions?.some(native => typeof native === "string" ? native === identity.rootSessionId
|
|
897
|
+
: native.provider_session_id === identity.rootSessionId && (native.root_provider_session_id ?? identity.rootSessionId) === identity.rootSessionId);
|
|
898
|
+
const generation = JSON.parse(binding.process.metadata_json).run_generation;
|
|
899
|
+
if (!root || !Number.isSafeInteger(generation) || generation < 0 || owner.generation !== undefined && generation !== owner.generation) {
|
|
900
|
+
throw new AppError("invocation_scope_unproven", "Managed native root or RUN generation is not persisted", 1, { effect: "no_effect" });
|
|
901
|
+
}
|
|
902
|
+
return { projectId, runId: owner.runId, generation };
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
/** recordReceipt is a synchronous SQL-only hook ingress callback. Native
|
|
907
|
+
* adapters must prepare any filesystem or process evidence before calling. */
|
|
756
908
|
export function observeLifecycleInvocation(context, input) {
|
|
757
909
|
storage(context);
|
|
758
910
|
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
759
911
|
const analysis = parseLifecycleCommand(input.command);
|
|
760
912
|
if (analysis.kind !== "standalone" || analysis.invocation.wrapped) {
|
|
913
|
+
if (analysis.kind !== "none" && ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
|
|
914
|
+
&& managedNativeOwner(context, input.identity, input.harness, input.projectId)) {
|
|
915
|
+
throw new AppError("invocation_command_invalid", "Managed native lifecycle call must be one direct standalone command", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
916
|
+
}
|
|
761
917
|
const eventKey = input.recordReceipt();
|
|
762
918
|
if (analysis.kind !== "none") {
|
|
763
919
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
|
|
@@ -797,6 +953,16 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
797
953
|
// public ID is corrected only by an exact operation/target match inside the
|
|
798
954
|
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
799
955
|
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
956
|
+
const owner = ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
|
|
957
|
+
? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
|
|
958
|
+
if (owner) {
|
|
959
|
+
if (!initial)
|
|
960
|
+
throw new AppError("invocation_assignment_missing", "Managed native lifecycle call has no issued assignment", 1, { effect: "no_effect", recoverable: false });
|
|
961
|
+
const scope = JSON.parse(initial.scope_json);
|
|
962
|
+
if (requireProjectByRoot(context, scope.projectRoot).id !== owner.projectId || scope.runId !== owner.runId || scope.generation !== owner.generation) {
|
|
963
|
+
throw new AppError("invocation_scope_mismatch", "Native daemon owner does not match issued RUN authority", 1, { effect: "no_effect", recoverable: false });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
800
966
|
if (!initial) {
|
|
801
967
|
const eventKey = input.recordReceipt();
|
|
802
968
|
if (candidates.length > 1)
|
|
@@ -814,21 +980,16 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
814
980
|
const identity = input.identity;
|
|
815
981
|
checkNativeIdentity(scope, identity, initial.id);
|
|
816
982
|
const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
|
|
817
|
-
context.db.
|
|
818
|
-
try {
|
|
983
|
+
return context.db.writeTransaction(() => {
|
|
819
984
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
820
|
-
if (row.status === "settled")
|
|
821
|
-
context.db.exec("COMMIT");
|
|
985
|
+
if (row.status === "settled")
|
|
822
986
|
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
823
|
-
}
|
|
824
987
|
// Observation records native facts only. CLI owns deadline, argument and
|
|
825
988
|
// RUN-state validation, including decisions about retrying an invocation.
|
|
826
989
|
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
|
|
827
|
-
context.db.exec("COMMIT");
|
|
828
990
|
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
829
991
|
}
|
|
830
992
|
if (row.identity_json === identityJson) {
|
|
831
|
-
context.db.exec("COMMIT");
|
|
832
993
|
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
833
994
|
}
|
|
834
995
|
const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
|
|
@@ -846,15 +1007,10 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
846
1007
|
// is safe. Keep the newest receipt until the CLI claims the attempt. The
|
|
847
1008
|
// observed -> executing compare-and-swap remains the exactly-once boundary.
|
|
848
1009
|
const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id]);
|
|
849
|
-
context.db.exec("COMMIT");
|
|
850
1010
|
// Once execution has been claimed, later PreToolUse observations are
|
|
851
1011
|
// retained as hook evidence but cannot replace the executor's receipt.
|
|
852
1012
|
return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, ...resolved };
|
|
853
|
-
}
|
|
854
|
-
catch (error) {
|
|
855
|
-
context.db.exec("ROLLBACK");
|
|
856
|
-
throw error;
|
|
857
|
-
}
|
|
1013
|
+
});
|
|
858
1014
|
}
|
|
859
1015
|
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
860
1016
|
* matching. A hook-added event key must name the same persisted native call. */
|
|
@@ -894,15 +1050,17 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
|
894
1050
|
return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
|
|
895
1051
|
}
|
|
896
1052
|
/** Keep shell diagnostics distinct from genuinely absent native delivery. */
|
|
897
|
-
export function observedLifecycleDiagnostic(context, command, daemonId) {
|
|
1053
|
+
export function observedLifecycleDiagnostic(context, command, daemonId, invocationId) {
|
|
898
1054
|
const analysis = parseLifecycleCommand(command);
|
|
899
1055
|
if (analysis.kind !== "standalone")
|
|
900
1056
|
return null;
|
|
901
1057
|
const commandFingerprint = fingerprint(analysis.invocation);
|
|
902
|
-
const
|
|
1058
|
+
const issuedAt = invocationId ? context.db.get("SELECT created_at FROM lifecycle_invocations WHERE id = ?", [invocationId])?.created_at : undefined;
|
|
1059
|
+
const rows = context.db.all(`SELECT id, event_key, outcome_json FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NOT NULL
|
|
1060
|
+
${issuedAt ? "AND created_at >= ?" : ""} ORDER BY id DESC LIMIT 20`, issuedAt ? [daemonId, issuedAt] : [daemonId]).flatMap(row => {
|
|
903
1061
|
try {
|
|
904
1062
|
const outcome = JSON.parse(row.outcome_json);
|
|
905
|
-
return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key }] : [];
|
|
1063
|
+
return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key, hook_event_id: row.id }] : [];
|
|
906
1064
|
}
|
|
907
1065
|
catch {
|
|
908
1066
|
return [];
|
|
@@ -944,23 +1102,58 @@ export function observedLifecycleInvocation(context, command, daemonId) {
|
|
|
944
1102
|
throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
|
|
945
1103
|
return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
|
|
946
1104
|
}
|
|
947
|
-
/**
|
|
948
|
-
*
|
|
949
|
-
*
|
|
1105
|
+
/** Restore private assignment arguments without discarding the provider's
|
|
1106
|
+
* verified public input. The fingerprint deliberately projects semantic
|
|
1107
|
+
* values such as a Work failure reason and a Work result-file path. */
|
|
950
1108
|
export function resolveObservedLifecycleArgs(context, id, actualArgs) {
|
|
951
1109
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
952
1110
|
if (!row)
|
|
953
1111
|
throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
|
|
954
|
-
const retained = invocation(row.command), actual = invocation(`${
|
|
1112
|
+
const retained = invocation(row.command), actual = invocation(`${launchExpression(retained)} ${quoteModelFacingArgs(actualArgs)}`);
|
|
955
1113
|
if (fingerprint(actual) !== row.fingerprint)
|
|
956
1114
|
throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
957
1115
|
for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
|
|
958
1116
|
const supplied = commandOption(actual, key), expected = commandOption(retained, key);
|
|
959
|
-
|
|
1117
|
+
const exactInput = ["result-file", "decision-file", "verification-file"].includes(key);
|
|
1118
|
+
const actualValue = exactInput && supplied !== undefined
|
|
1119
|
+
? commandOption(invocation(`${launchExpression(retained)} ${quote(expandLifecycleInvocationArgs(context, actual.argv, JSON.parse(row.scope_json), retained.operation))}`), key)
|
|
1120
|
+
: supplied;
|
|
1121
|
+
if (supplied !== undefined && (exactInput ? path.resolve(actualValue ?? "") !== path.resolve(expected ?? "")
|
|
1122
|
+
: normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? ""))) {
|
|
960
1123
|
throw new AppError("invocation_argument_mismatch", `--${key} is owned by the managed assignment and does not match it`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: key });
|
|
961
1124
|
}
|
|
962
1125
|
}
|
|
963
|
-
|
|
1126
|
+
const argv = [...retained.argv];
|
|
1127
|
+
const publicKey = retained.operation === "work_fail" ? "reason" : retained.operation === "work_finish" ? "result-file" : null;
|
|
1128
|
+
if (publicKey && commandOption(actual, publicKey) !== undefined) {
|
|
1129
|
+
const index = argv.indexOf(`--${publicKey}`);
|
|
1130
|
+
if (index >= 0 && index + 1 < argv.length)
|
|
1131
|
+
argv[index + 1] = commandOption(actual, publicKey);
|
|
1132
|
+
else {
|
|
1133
|
+
const inline = argv.findIndex(value => value.startsWith(`--${publicKey}=`));
|
|
1134
|
+
if (inline >= 0)
|
|
1135
|
+
argv[inline] = `--${publicKey}=${commandOption(actual, publicKey)}`;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return argv;
|
|
1139
|
+
}
|
|
1140
|
+
/** Once native admission is committed, retain the actual public semantics so
|
|
1141
|
+
* an authorized successor cannot silently revert to the issued placeholder. */
|
|
1142
|
+
export function retainObservedLifecycleSemantics(context, id, argv) {
|
|
1143
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
1144
|
+
if (!row || row.status !== "executing")
|
|
1145
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt is not executing", 1, { invocation_id: id });
|
|
1146
|
+
const parsed = invocation(row.command);
|
|
1147
|
+
if (!["work_fail", "work_finish"].includes(parsed.operation))
|
|
1148
|
+
return;
|
|
1149
|
+
const prefix = Object.entries(parsed.env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
1150
|
+
const executable = launchExpression(parsed);
|
|
1151
|
+
const suffix = row.command.slice(shellSuffixOffset(row.command)).trimStart();
|
|
1152
|
+
const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(argv)}${suffix ? ` ${suffix}` : ""}`;
|
|
1153
|
+
checkCommand(row, command);
|
|
1154
|
+
if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
|
|
1155
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
|
|
1156
|
+
}
|
|
964
1157
|
}
|
|
965
1158
|
export function settleLifecyclePreparationRejection(context, id, error) {
|
|
966
1159
|
return context.db.writeTransaction(() => {
|
|
@@ -994,7 +1187,7 @@ export function settleLifecyclePreparationRejection(context, id, error) {
|
|
|
994
1187
|
});
|
|
995
1188
|
}
|
|
996
1189
|
function preparationRejectionSignature(error) {
|
|
997
|
-
const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature"]);
|
|
1190
|
+
const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature"]);
|
|
998
1191
|
const normalize = (value) => {
|
|
999
1192
|
if (Array.isArray(value))
|
|
1000
1193
|
return value.map(normalize);
|
|
@@ -1005,11 +1198,45 @@ function preparationRejectionSignature(error) {
|
|
|
1005
1198
|
return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
|
|
1006
1199
|
}
|
|
1007
1200
|
export function currentLifecycleFailure(context, input) {
|
|
1201
|
+
if (input.turn !== undefined) {
|
|
1202
|
+
const hooks = context.db.all(`SELECT id, outcome_json FROM hook_events
|
|
1203
|
+
WHERE json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
|
|
1204
|
+
AND json_extract(outcome_json, '$.stage') = ? AND json_extract(outcome_json, '$.controller_turn') = ?
|
|
1205
|
+
${input.projectId ? "AND project_id = ?" : ""}
|
|
1206
|
+
ORDER BY id DESC`, [input.runId, input.generation, input.stage, input.turn, ...(input.projectId ? [input.projectId] : [])]);
|
|
1207
|
+
const completed = new Set();
|
|
1208
|
+
const activeAttempt = input.projectId ? stageAttempt(context, input.projectId, input.runId, input.stage) : null;
|
|
1209
|
+
for (const hook of hooks) {
|
|
1210
|
+
let outcome;
|
|
1211
|
+
try {
|
|
1212
|
+
outcome = JSON.parse(hook.outcome_json);
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
if (activeAttempt && outcome.attempt && outcome.attempt !== activeAttempt)
|
|
1218
|
+
continue;
|
|
1219
|
+
const key = JSON.stringify([outcome.operation ?? "", outcome.session_id ?? null, outcome.correlation_key ?? outcome.fingerprint ?? hook.id]);
|
|
1220
|
+
if (outcome.disposition === "completed") {
|
|
1221
|
+
completed.add(key);
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (completed.has(key) || !outcome.error?.code)
|
|
1225
|
+
continue;
|
|
1226
|
+
const continuation = outcome.error.details?.continuation;
|
|
1227
|
+
if (continuation && typeof continuation === "object"
|
|
1228
|
+
&& continuation.run_id === input.runId
|
|
1229
|
+
&& continuation.stage === input.stage
|
|
1230
|
+
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
1231
|
+
continue;
|
|
1232
|
+
return { code: outcome.error.code, message: outcome.error.message ?? "Lifecycle command failed", details: outcome.error.details ?? {}, hook_event_id: hook.id };
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1008
1235
|
const attempts = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY updated_at DESC, rowid DESC");
|
|
1009
1236
|
const scoped = attempts.filter(row => {
|
|
1010
1237
|
try {
|
|
1011
1238
|
const scope = JSON.parse(row.scope_json);
|
|
1012
|
-
return scope.runId === input.runId && scope.generation === input.generation;
|
|
1239
|
+
return scope.runId === input.runId && scope.generation === input.generation && (!input.projectRoot || scope.projectRoot === input.projectRoot);
|
|
1013
1240
|
}
|
|
1014
1241
|
catch {
|
|
1015
1242
|
return false;
|
|
@@ -1018,7 +1245,9 @@ export function currentLifecycleFailure(context, input) {
|
|
|
1018
1245
|
const fingerprints = [...new Set(scoped.map(row => row.fingerprint))];
|
|
1019
1246
|
for (const fingerprint of fingerprints) {
|
|
1020
1247
|
const rows = scoped.filter(row => row.fingerprint === fingerprint);
|
|
1021
|
-
|
|
1248
|
+
// An issued successor has not performed the correction. Once the Turn
|
|
1249
|
+
// ended it cannot erase the predecessor's actual rejection.
|
|
1250
|
+
if (rows.some(row => ["observed", "executing"].includes(row.status)))
|
|
1022
1251
|
continue;
|
|
1023
1252
|
const row = rows.find(candidate => candidate.status === "settled");
|
|
1024
1253
|
if (!row)
|
|
@@ -1039,9 +1268,23 @@ export function currentLifecycleFailure(context, input) {
|
|
|
1039
1268
|
catch {
|
|
1040
1269
|
continue;
|
|
1041
1270
|
}
|
|
1042
|
-
|
|
1271
|
+
let stage = parsed.operation.startsWith("merge_") ? "merge" : commandOption(parsed, "stage");
|
|
1272
|
+
if (!stage && parsed.operation.startsWith("work_") && input.runId) {
|
|
1273
|
+
try {
|
|
1274
|
+
const work = resolveWorkReference(context, commandPosition(parsed, 0) ?? "", { runId: input.runId });
|
|
1275
|
+
const membership = context.db.get("SELECT stage FROM stage_work_memberships WHERE run_id = ? AND work_id = ? ORDER BY rowid DESC LIMIT 1", [input.runId, work.work_id]);
|
|
1276
|
+
stage = membership?.stage;
|
|
1277
|
+
}
|
|
1278
|
+
catch { /* A stale or unbound Work is not evidence for this Stage. */ }
|
|
1279
|
+
}
|
|
1043
1280
|
if (stage !== input.stage)
|
|
1044
1281
|
continue;
|
|
1282
|
+
const continuation = outcome.error.details?.continuation;
|
|
1283
|
+
if (continuation && typeof continuation === "object"
|
|
1284
|
+
&& continuation.run_id === input.runId
|
|
1285
|
+
&& continuation.stage === input.stage
|
|
1286
|
+
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
1287
|
+
continue;
|
|
1045
1288
|
return { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {}, invocation_id: row.id };
|
|
1046
1289
|
}
|
|
1047
1290
|
return null;
|
|
@@ -1097,19 +1340,13 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
1097
1340
|
continue;
|
|
1098
1341
|
}
|
|
1099
1342
|
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
|
|
1100
|
-
context.db.
|
|
1101
|
-
try {
|
|
1343
|
+
context.db.writeTransaction(() => {
|
|
1102
1344
|
const current = load(context, input.id);
|
|
1103
1345
|
if (["issued", "expired"].includes(current.status)) {
|
|
1104
1346
|
input.assertCurrent(JSON.parse(current.scope_json));
|
|
1105
1347
|
settleUnadmittedInvocation(context, current, receiptTimeout(input.id));
|
|
1106
1348
|
}
|
|
1107
|
-
|
|
1108
|
-
}
|
|
1109
|
-
catch (error) {
|
|
1110
|
-
context.db.exec("ROLLBACK");
|
|
1111
|
-
throw error;
|
|
1112
|
-
}
|
|
1349
|
+
});
|
|
1113
1350
|
continue;
|
|
1114
1351
|
}
|
|
1115
1352
|
await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
|