@deksden-com/dd-flow-cli 0.9.0-beta.19 → 0.9.0-beta.22
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 +20 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/run-cli.js +8 -4
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +4 -6
- package/dist/services/eval-snapshots.js +218 -12
- package/dist/services/hooks.js +67 -28
- package/dist/services/lifecycle-command.js +3 -1
- package/dist/services/merge-server.js +27 -7
- package/dist/services/run-recovery.js +170 -20
- package/dist/services/runs.js +15 -0
- package/dist/services/vnext-merge.js +34 -9
- package/dist/services/work-registry.js +40 -13
- package/dist/storage/database.js +9 -0
- package/package.json +2 -2
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";
|
|
@@ -323,10 +324,12 @@ export function handleCodexHook(context, input) {
|
|
|
323
324
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
324
325
|
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
325
326
|
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
326
|
-
const
|
|
327
|
-
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
|
|
327
|
+
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
328
328
|
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
329
329
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
330
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: stringValue(payload.cwd) ?? null });
|
|
331
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
332
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
|
|
330
333
|
const observedSession = flowPayload
|
|
331
334
|
? bindObservedFlowSession(context, project, {
|
|
332
335
|
...flowPayload,
|
|
@@ -337,13 +340,15 @@ export function handleCodexHook(context, input) {
|
|
|
337
340
|
: undefined;
|
|
338
341
|
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
339
342
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
340
|
-
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
341
343
|
const inserted = recordHookEvent(context, {
|
|
342
344
|
projectId: project.id,
|
|
343
345
|
protocolId,
|
|
344
346
|
harness: "codex-desktop",
|
|
345
347
|
providerSessionId: providerSessionId ?? null,
|
|
346
348
|
parentSessionId,
|
|
349
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
350
|
+
// state or an agent-supplied shell command/payload.
|
|
351
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
347
352
|
sessionId: effectiveSessionId,
|
|
348
353
|
agentId: rawChildProviderSessionId ?? null,
|
|
349
354
|
turnId: turnId ?? null,
|
|
@@ -375,7 +380,7 @@ export function handleCodexHook(context, input) {
|
|
|
375
380
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
376
381
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
377
382
|
protocol_id: protocolId,
|
|
378
|
-
...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
|
|
383
|
+
...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
|
|
379
384
|
? {
|
|
380
385
|
hookSpecificOutput: {
|
|
381
386
|
hookEventName: "PreToolUse",
|
|
@@ -454,6 +459,10 @@ export function handleZcodeEvent(context, input) {
|
|
|
454
459
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
455
460
|
const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
|
|
456
461
|
const agentId = stringValue(runtime.agentId);
|
|
462
|
+
const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
463
|
+
harness: "zcode-acp", providerSessionId, command
|
|
464
|
+
})).digest("hex");
|
|
465
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
457
466
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
458
467
|
...flowPayload,
|
|
459
468
|
harness: "zcode-acp",
|
|
@@ -461,9 +470,6 @@ export function handleZcodeEvent(context, input) {
|
|
|
461
470
|
agent_id: agentId ?? null,
|
|
462
471
|
parent_session_id: parentSessionId
|
|
463
472
|
}, sessionId) : undefined;
|
|
464
|
-
const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
465
|
-
harness: "zcode-acp", providerSessionId, command
|
|
466
|
-
})).digest("hex");
|
|
467
473
|
const payload = {
|
|
468
474
|
session_id: providerSessionId,
|
|
469
475
|
agent_id: agentId,
|
|
@@ -560,12 +566,13 @@ export function handleGrokEvent(context, input) {
|
|
|
560
566
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
561
567
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
562
568
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
563
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
564
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
565
|
-
}, sessionId) : undefined;
|
|
566
569
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
567
570
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
568
571
|
})).digest("hex");
|
|
572
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
573
|
+
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
574
|
+
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
575
|
+
}, sessionId) : undefined;
|
|
569
576
|
const payload = {
|
|
570
577
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
571
578
|
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
|
|
@@ -581,7 +588,7 @@ export function handleGrokEvent(context, input) {
|
|
|
581
588
|
});
|
|
582
589
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
583
590
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
584
|
-
return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
|
|
591
|
+
return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
|
|
585
592
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
586
593
|
: result;
|
|
587
594
|
}
|
|
@@ -664,6 +671,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
664
671
|
}
|
|
665
672
|
}
|
|
666
673
|
const agentId = stringValue(session.agent_id);
|
|
674
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
|
|
667
675
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
668
676
|
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
669
677
|
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
@@ -791,6 +799,10 @@ function allowsStdinLifecycleCompound(facts) {
|
|
|
791
799
|
}
|
|
792
800
|
function lifecycleMatchKey(facts, projectRoot) {
|
|
793
801
|
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
802
|
+
if (invocation.operation === "recovery_accept") {
|
|
803
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
804
|
+
return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
|
|
805
|
+
}
|
|
794
806
|
if (bootstrapStageStart)
|
|
795
807
|
return bootstrapMatchKey({
|
|
796
808
|
projectRoot,
|
|
@@ -807,7 +819,7 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
807
819
|
if (stageResume && runId && stageName && workId)
|
|
808
820
|
return stageResumeMatchKey(runId, stageName, workId, projectRoot);
|
|
809
821
|
if (workStart && workId)
|
|
810
|
-
return workStartMatchKey(workId, projectRoot);
|
|
822
|
+
return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
|
|
811
823
|
if (workFinish && workId)
|
|
812
824
|
return workLifecycleMatchKey("work_finish", workId, projectRoot);
|
|
813
825
|
if (workFail && workId)
|
|
@@ -817,6 +829,9 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
817
829
|
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
818
830
|
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
819
831
|
}
|
|
832
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
833
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
834
|
+
}
|
|
820
835
|
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
821
836
|
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
822
837
|
}
|
|
@@ -903,21 +918,24 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
903
918
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
904
919
|
export function findRecentMatchingHookEvent(context, input) {
|
|
905
920
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
906
|
-
const find = () => context.db.
|
|
921
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
907
922
|
WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
|
|
908
|
-
ORDER BY id DESC LIMIT
|
|
909
|
-
let
|
|
923
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter]);
|
|
924
|
+
let events = find();
|
|
910
925
|
// Some harnesses publish nested-agent tool events concurrently with command
|
|
911
926
|
// execution. Give the trusted event a short bounded window to reach SQLite.
|
|
912
927
|
const deadline = Date.now() + 250;
|
|
913
|
-
while (
|
|
928
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
914
929
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
915
|
-
|
|
930
|
+
events = find();
|
|
916
931
|
}
|
|
917
|
-
if (!
|
|
932
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
918
933
|
throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
|
|
919
934
|
}
|
|
920
|
-
|
|
935
|
+
if (events.length > 1) {
|
|
936
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
|
|
937
|
+
}
|
|
938
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
921
939
|
}
|
|
922
940
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
923
941
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -940,7 +958,7 @@ export function bootstrapMatchKey(input) {
|
|
|
940
958
|
})).digest("hex");
|
|
941
959
|
}
|
|
942
960
|
export function claimWorkStartHookEvent(context, input) {
|
|
943
|
-
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
|
|
961
|
+
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
|
|
944
962
|
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]);
|
|
945
963
|
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
|
|
946
964
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
@@ -958,7 +976,9 @@ export function assertStageStartHookEvent(context, input) {
|
|
|
958
976
|
if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
|
|
959
977
|
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 });
|
|
960
978
|
}
|
|
961
|
-
|
|
979
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
980
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
981
|
+
return identity;
|
|
962
982
|
}
|
|
963
983
|
export function claimStageStartHookEvent(context, input) {
|
|
964
984
|
assertStageStartHookEvent(context, input);
|
|
@@ -996,16 +1016,19 @@ export function claimStageResumeHookEvent(context, input) {
|
|
|
996
1016
|
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
997
1017
|
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 });
|
|
998
1018
|
}
|
|
1019
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1020
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
999
1021
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
1000
1022
|
if (claimed.changes !== 1)
|
|
1001
1023
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
1002
|
-
return
|
|
1024
|
+
return identity;
|
|
1003
1025
|
}
|
|
1004
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1026
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
1005
1027
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
1006
1028
|
operation: "work_start",
|
|
1007
1029
|
work_id: workId,
|
|
1008
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1030
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1031
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
1009
1032
|
})).digest("hex");
|
|
1010
1033
|
}
|
|
1011
1034
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -1389,12 +1412,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1389
1412
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1390
1413
|
}
|
|
1391
1414
|
function recordHookEvent(context, input) {
|
|
1392
|
-
|
|
1393
|
-
if (existing)
|
|
1415
|
+
if (assertHookEventReplay(context, input))
|
|
1394
1416
|
return false;
|
|
1395
1417
|
context.db.run(`INSERT INTO hook_events
|
|
1396
|
-
|
|
1397
|
-
|
|
1418
|
+
(project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
|
|
1419
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1398
1420
|
input.projectId,
|
|
1399
1421
|
input.protocolId,
|
|
1400
1422
|
input.harness,
|
|
@@ -1421,6 +1443,23 @@ function recordHookEvent(context, input) {
|
|
|
1421
1443
|
]);
|
|
1422
1444
|
return true;
|
|
1423
1445
|
}
|
|
1446
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1447
|
+
function assertHookEventReplay(context, input) {
|
|
1448
|
+
const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
|
|
1449
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1450
|
+
if (existing) {
|
|
1451
|
+
const observed = {
|
|
1452
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1453
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1454
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1455
|
+
};
|
|
1456
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1457
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1458
|
+
}
|
|
1459
|
+
return true;
|
|
1460
|
+
}
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1424
1463
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1425
1464
|
if (explicitRoot)
|
|
1426
1465
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -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,6 +170,8 @@ 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";
|
|
@@ -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 {
|