@deksden-com/dd-flow-cli 0.9.0-beta.18 → 0.9.0-beta.21
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 +18 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +5 -3
- package/dist/cli/run-cli.js +69 -12
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +4 -6
- package/dist/services/eval-snapshots.js +230 -16
- package/dist/services/hooks.js +107 -20
- package/dist/services/lifecycle-command.js +11 -1
- package/dist/services/merge-server.js +27 -7
- package/dist/services/run-recovery.js +254 -0
- package/dist/services/runs.js +15 -0
- package/dist/services/stage-lifecycle.js +3 -0
- package/dist/services/vnext-fanout.js +40 -0
- package/dist/services/vnext-merge.js +34 -9
- package/dist/services/work-registry.js +105 -12
- package/dist/storage/database.js +36 -1
- package/package.json +13 -12
package/dist/services/hooks.js
CHANGED
|
@@ -8,6 +8,7 @@ import { parseJsonObject } from "../shared/json.js";
|
|
|
8
8
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
10
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
|
+
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
11
12
|
import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
|
|
12
13
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
14
|
import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
|
|
@@ -285,7 +286,7 @@ export function handleCodexHook(context, input) {
|
|
|
285
286
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
286
287
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
287
288
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
288
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
289
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
289
290
|
return {
|
|
290
291
|
ok: false,
|
|
291
292
|
observed: false,
|
|
@@ -299,14 +300,25 @@ export function handleCodexHook(context, input) {
|
|
|
299
300
|
}
|
|
300
301
|
};
|
|
301
302
|
}
|
|
302
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
303
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
303
304
|
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
304
305
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
305
306
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
306
307
|
if (bootstrapStageStart && commandProjectRoot) {
|
|
307
308
|
registerProject(context, { root: commandProjectRoot });
|
|
308
309
|
}
|
|
309
|
-
|
|
310
|
+
let project;
|
|
311
|
+
try {
|
|
312
|
+
project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
// A terminal command outside a registered flow gets no trusted receipt;
|
|
316
|
+
// the CLI will fail closed if it is actually executed. This keeps hooks
|
|
317
|
+
// inert for unrelated shell-parser probes and stale project directories.
|
|
318
|
+
if (stageFinish || stagePause || workFinish || workFail)
|
|
319
|
+
return { ok: true, observed: false, reason: "project_not_registered" };
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
310
322
|
if (!project)
|
|
311
323
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
312
324
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
@@ -333,6 +345,9 @@ export function handleCodexHook(context, input) {
|
|
|
333
345
|
harness: "codex-desktop",
|
|
334
346
|
providerSessionId: providerSessionId ?? null,
|
|
335
347
|
parentSessionId,
|
|
348
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
349
|
+
// state or an agent-supplied shell command/payload.
|
|
350
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
336
351
|
sessionId: effectiveSessionId,
|
|
337
352
|
agentId: rawChildProviderSessionId ?? null,
|
|
338
353
|
turnId: turnId ?? null,
|
|
@@ -364,7 +379,7 @@ export function handleCodexHook(context, input) {
|
|
|
364
379
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
365
380
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
366
381
|
protocol_id: protocolId,
|
|
367
|
-
...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
|
|
382
|
+
...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
|
|
368
383
|
? {
|
|
369
384
|
hookSpecificOutput: {
|
|
370
385
|
hookEventName: "PreToolUse",
|
|
@@ -414,7 +429,7 @@ export function handleZcodeEvent(context, input) {
|
|
|
414
429
|
const lifecycle = lifecycleFacts(command);
|
|
415
430
|
if (!lifecycle)
|
|
416
431
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
417
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
432
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
418
433
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
|
|
419
434
|
standalone_command: lifecycle.invocation.command
|
|
420
435
|
});
|
|
@@ -521,12 +536,12 @@ export function handleGrokEvent(context, input) {
|
|
|
521
536
|
const lifecycle = lifecycleFacts(command);
|
|
522
537
|
if (!lifecycle)
|
|
523
538
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
524
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
539
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
525
540
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
526
541
|
standalone_command: lifecycle.invocation.command
|
|
527
542
|
});
|
|
528
543
|
}
|
|
529
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
544
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
530
545
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
531
546
|
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
532
547
|
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
@@ -570,7 +585,7 @@ export function handleGrokEvent(context, input) {
|
|
|
570
585
|
});
|
|
571
586
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
572
587
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
573
|
-
return (flowPayload || stageStart || stageResume || workStart)
|
|
588
|
+
return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
|
|
574
589
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
575
590
|
: result;
|
|
576
591
|
}
|
|
@@ -635,7 +650,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
635
650
|
const lifecycle = lifecycleFacts(command);
|
|
636
651
|
if (!lifecycle)
|
|
637
652
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
638
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
653
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
639
654
|
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
640
655
|
}
|
|
641
656
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
@@ -747,25 +762,43 @@ function lifecycleFacts(command) {
|
|
|
747
762
|
return null;
|
|
748
763
|
const invocation = analysis.invocation;
|
|
749
764
|
const stageStart = invocation.operation === "stage_start";
|
|
765
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
766
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
750
767
|
const stageResume = invocation.operation === "stage_resume";
|
|
751
768
|
const workStart = invocation.operation === "work_start";
|
|
769
|
+
const workFinish = invocation.operation === "work_finish";
|
|
770
|
+
const workFail = invocation.operation === "work_fail";
|
|
752
771
|
return {
|
|
753
772
|
analysis,
|
|
754
773
|
invocation,
|
|
755
774
|
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
756
775
|
stageStart,
|
|
776
|
+
stageFinish,
|
|
777
|
+
stagePause,
|
|
757
778
|
stageResume,
|
|
758
|
-
workStart,
|
|
779
|
+
workStart, workFinish, workFail,
|
|
759
780
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
760
781
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
761
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
762
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
763
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
782
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
783
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
784
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
764
785
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
765
786
|
};
|
|
766
787
|
}
|
|
788
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
789
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
790
|
+
// not a second authority. Everything else remains standalone-only.
|
|
791
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
792
|
+
const operation = facts.invocation.operation;
|
|
793
|
+
return (operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
794
|
+
|| ((operation === "stage_pause" || operation === "stage_resume") && facts.invocation.heredoc);
|
|
795
|
+
}
|
|
767
796
|
function lifecycleMatchKey(facts, projectRoot) {
|
|
768
|
-
const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
|
|
797
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
798
|
+
if (invocation.operation === "recovery_accept") {
|
|
799
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
800
|
+
return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
|
|
801
|
+
}
|
|
769
802
|
if (bootstrapStageStart)
|
|
770
803
|
return bootstrapMatchKey({
|
|
771
804
|
projectRoot,
|
|
@@ -775,12 +808,61 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
775
808
|
});
|
|
776
809
|
if (stageStart && runId && stageName)
|
|
777
810
|
return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
|
|
811
|
+
if (stageFinish && runId && stageName)
|
|
812
|
+
return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
|
|
813
|
+
if (stagePause && runId && stageName && workId)
|
|
814
|
+
return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
|
|
778
815
|
if (stageResume && runId && stageName && workId)
|
|
779
816
|
return stageResumeMatchKey(runId, stageName, workId, projectRoot);
|
|
780
817
|
if (workStart && workId)
|
|
781
|
-
return workStartMatchKey(workId, projectRoot);
|
|
818
|
+
return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
|
|
819
|
+
if (workFinish && workId)
|
|
820
|
+
return workLifecycleMatchKey("work_finish", workId, projectRoot);
|
|
821
|
+
if (workFail && workId)
|
|
822
|
+
return workLifecycleMatchKey("work_fail", workId, projectRoot);
|
|
782
823
|
return null;
|
|
783
824
|
}
|
|
825
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
826
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
827
|
+
}
|
|
828
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
829
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
830
|
+
}
|
|
831
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
832
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
833
|
+
}
|
|
834
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
835
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
836
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
837
|
+
const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
|
|
838
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
839
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
840
|
+
}).eventKey;
|
|
841
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
842
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
843
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey });
|
|
844
|
+
}
|
|
845
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
846
|
+
if (claimed.changes !== 1)
|
|
847
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
848
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
849
|
+
}
|
|
850
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
851
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
852
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
853
|
+
const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
854
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
855
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
856
|
+
}).eventKey;
|
|
857
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
858
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
859
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey });
|
|
860
|
+
}
|
|
861
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
862
|
+
if (claimed.changes !== 1)
|
|
863
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
864
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
865
|
+
}
|
|
784
866
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
785
867
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
786
868
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -869,7 +951,7 @@ export function bootstrapMatchKey(input) {
|
|
|
869
951
|
})).digest("hex");
|
|
870
952
|
}
|
|
871
953
|
export function claimWorkStartHookEvent(context, input) {
|
|
872
|
-
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
|
|
954
|
+
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
|
|
873
955
|
const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
874
956
|
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
|
|
875
957
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
@@ -887,7 +969,9 @@ export function assertStageStartHookEvent(context, input) {
|
|
|
887
969
|
if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
|
|
888
970
|
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
|
|
889
971
|
}
|
|
890
|
-
|
|
972
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
973
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
974
|
+
return identity;
|
|
891
975
|
}
|
|
892
976
|
export function claimStageStartHookEvent(context, input) {
|
|
893
977
|
assertStageStartHookEvent(context, input);
|
|
@@ -925,16 +1009,19 @@ export function claimStageResumeHookEvent(context, input) {
|
|
|
925
1009
|
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
926
1010
|
throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
|
|
927
1011
|
}
|
|
1012
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1013
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
928
1014
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
929
1015
|
if (claimed.changes !== 1)
|
|
930
1016
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
931
|
-
return
|
|
1017
|
+
return identity;
|
|
932
1018
|
}
|
|
933
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1019
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
934
1020
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
935
1021
|
operation: "work_start",
|
|
936
1022
|
work_id: workId,
|
|
937
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1023
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1024
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
938
1025
|
})).digest("hex");
|
|
939
1026
|
}
|
|
940
1027
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -138,7 +138,7 @@ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
|
|
|
138
138
|
executable,
|
|
139
139
|
argv,
|
|
140
140
|
env,
|
|
141
|
-
args: parseCommandArgs(argv.slice(2)),
|
|
141
|
+
args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
|
|
142
142
|
command: quote(segment.words),
|
|
143
143
|
rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
|
|
144
144
|
wrapped,
|
|
@@ -170,15 +170,25 @@ function wrappedInvocationFromSegment(segment) {
|
|
|
170
170
|
return parseLifecycleCommandInner(payload, true);
|
|
171
171
|
}
|
|
172
172
|
function lifecycleOperation(argv) {
|
|
173
|
+
if (argv[0] === "run" && argv[1] === "recovery" && argv[2] === "accept")
|
|
174
|
+
return "recovery_accept";
|
|
173
175
|
const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
|
|
174
176
|
if (key === "session register")
|
|
175
177
|
return "session_register";
|
|
176
178
|
if (key === "stage start")
|
|
177
179
|
return "stage_start";
|
|
180
|
+
if (key === "stage finish")
|
|
181
|
+
return "stage_finish";
|
|
182
|
+
if (key === "stage pause")
|
|
183
|
+
return "stage_pause";
|
|
178
184
|
if (key === "stage resume")
|
|
179
185
|
return "stage_resume";
|
|
180
186
|
if (key === "work start")
|
|
181
187
|
return "work_start";
|
|
188
|
+
if (key === "work finish")
|
|
189
|
+
return "work_finish";
|
|
190
|
+
if (key === "work fail")
|
|
191
|
+
return "work_fail";
|
|
182
192
|
return null;
|
|
183
193
|
}
|
|
184
194
|
export function parseCommandArgs(argv) {
|
|
@@ -6,6 +6,7 @@ import { flowCommand } from "./stage-pause.js";
|
|
|
6
6
|
import { validateSchema } from "./schema-validation.js";
|
|
7
7
|
import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
8
8
|
import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
|
|
9
|
+
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
9
10
|
/** A deterministic dispatcher; only the launched harness Session performs agent work. */
|
|
10
11
|
export async function serveMergeRequests(context, input) {
|
|
11
12
|
const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
|
|
@@ -59,12 +60,23 @@ export function mergeServerStatus(context) { return { ok: true, servers: context
|
|
|
59
60
|
export function stopMergeServer(context, input) { const changed = context.db.run("UPDATE merge_servers SET status = 'stopping', heartbeat_at = ? WHERE server_id = ? AND status = 'running'", [context.now(), input.serverId]); if (changed.changes !== 1)
|
|
60
61
|
throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
|
|
61
62
|
async function dispatch(context, input) {
|
|
63
|
+
let generation;
|
|
64
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
62
65
|
const token = crypto.randomUUID();
|
|
63
66
|
const leaseUntil = new Date(Date.now() + 120_000).toISOString();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
try {
|
|
68
|
+
generation = assertRunMutationAllowed(context, input.request.project_id, input.request.run_id);
|
|
69
|
+
const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
|
|
70
|
+
context.db.exec("COMMIT");
|
|
71
|
+
if (claimed.changes !== 1)
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
context.db.exec("ROLLBACK");
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const leaseHeartbeat = setInterval(() => { if (guarded(context, input.request) || (recoveryGuard(context, input.request.project_id, input.request.run_id)?.generation ?? 0) !== generation)
|
|
79
|
+
return; const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
|
|
68
80
|
leaseHeartbeat.unref();
|
|
69
81
|
const stateDir = path.join(input.root, input.request.merge_request_id);
|
|
70
82
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
@@ -77,13 +89,14 @@ async function dispatch(context, input) {
|
|
|
77
89
|
const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
|
|
78
90
|
const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
|
|
79
91
|
try {
|
|
80
|
-
const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) });
|
|
92
|
+
const adapterCall = (args, timeoutMs, progressMessage) => { assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation); return runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) }); };
|
|
81
93
|
await adapterCall(["daemon", "start", ...common]);
|
|
82
94
|
const created = await adapterCall(["session", "create", ...common]);
|
|
83
95
|
const sessionId = adapterSessionId(created);
|
|
84
96
|
if (!sessionId)
|
|
85
97
|
throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
|
|
86
98
|
const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
|
|
99
|
+
assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation);
|
|
87
100
|
const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
|
|
88
101
|
context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ profile: input.profile, executable, session_id: sessionId, response: prompted }), context.now(), input.request.merge_request_id]);
|
|
89
102
|
event(input.journal, { type: "adapter_settled", request_id: input.request.merge_request_id, session_id: sessionId, request_status: request?.status ?? null, at: context.now() });
|
|
@@ -91,6 +104,10 @@ async function dispatch(context, input) {
|
|
|
91
104
|
context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
|
|
92
105
|
}
|
|
93
106
|
catch (error) {
|
|
107
|
+
if (error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code)) {
|
|
108
|
+
event(input.journal, { type: "recovery_dispatch_blocked", request_id: input.request.merge_request_id, code: error.code, at: context.now() });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
94
111
|
const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
|
|
95
112
|
if (request?.status === "dispatching")
|
|
96
113
|
context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
|
|
@@ -102,9 +119,11 @@ async function dispatch(context, input) {
|
|
|
102
119
|
clearInterval(leaseHeartbeat);
|
|
103
120
|
}
|
|
104
121
|
}
|
|
105
|
-
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
122
|
+
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (guarded(context, request) || selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
106
123
|
return false; selected.add(request.project_id); return true; }).slice(0, limit); }
|
|
107
|
-
function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
|
|
124
|
+
function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id, project_id, run_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
|
|
125
|
+
if (guarded(context, request))
|
|
126
|
+
continue;
|
|
108
127
|
const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
|
|
109
128
|
if (work?.status === "created")
|
|
110
129
|
context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_requeued" }), context.now(), request.merge_request_id]);
|
|
@@ -119,6 +138,7 @@ catch (error) {
|
|
|
119
138
|
} validateSchema({ schemaName: "agent-profile", file }); if (value.id !== id)
|
|
120
139
|
throw new AppError("agent_profile_invalid", "Agent profile id does not match its requested filename", 2, { file, expected: id, actual: value.id ?? null }); return value; }
|
|
121
140
|
function event(file, value) { fs.appendFileSync(file, `${JSON.stringify(value)}\n`); }
|
|
141
|
+
function guarded(context, request) { const guard = recoveryGuard(context, request.project_id, request.run_id); return Boolean(guard && ["draining", "sealed", "resuming"].includes(guard.status)); }
|
|
122
142
|
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
123
143
|
function processAlive(pid) { if (!pid)
|
|
124
144
|
return false; try {
|