@deksden-com/dd-flow-cli 0.9.0-beta.122 → 0.9.0-beta.124
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/README.md +23 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/run-cli.js +3 -0
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +100 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +117 -21
- package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +58 -13
- package/dist/harness-runtime/lib/dd-codex.mjs +16 -26
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +19 -12
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +42 -10
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +57 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +25 -23
- package/dist/harness-runtime/lib/dd-zcode.mjs +24 -14
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/managed-daemon.d.mts +2 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +100 -31
- package/dist/harness-runtime/lib/model-observations.mjs +16 -9
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/services/code-checks.js +14 -18
- package/dist/services/codex-capacity.js +251 -31
- package/dist/services/controller-fanout.js +1 -0
- package/dist/services/eval-snapshots.js +13 -8
- package/dist/services/external-work-launch.js +36 -11
- package/dist/services/harness-adapter.js +21 -15
- package/dist/services/managed-processes.js +14 -6
- package/dist/services/merge-server.js +35 -12
- package/dist/services/review-copy.js +21 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-controller-adapter.js +33 -8
- package/dist/services/run-controller-recovery.js +26 -2
- package/dist/services/run-controller.js +91 -24
- package/dist/services/run-observations.js +7 -2
- package/dist/services/run-recovery-runtime.js +49 -3
- package/dist/services/runs.js +1 -1
- package/dist/services/stage-report-renderer.js +25 -2
- package/dist/services/verification-matrix.js +57 -8
- package/dist/services/vnext-code-review.js +22 -8
- package/dist/services/vnext-merge.js +20 -2
- package/dist/services/vnext-plan-review.js +87 -19
- package/dist/services/work-registry.js +11 -2
- package/package.json +1 -1
|
@@ -1,34 +1,211 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { CAPACITY_POLICY, capacityBackoff, nativeItemsSettled, normalizeCodexFailure, overloadBurst, parseCapacityOperation, refusalObservation, terminalCodexOverload } from "../harness-runtime/lib/codex-capacity-policy.mjs";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
/** Permit acquisition must still belong to the original task, not merely an
|
|
6
|
+
* open RUN. Controls/inspection and ordinary successor prompts are unaffected. */
|
|
7
|
+
export function assertCapacityOwnerAdmission(context, record, operationId, binding) {
|
|
8
|
+
if (!operationId || !record.project_id || !record.run_id)
|
|
9
|
+
return;
|
|
10
|
+
const root = parseCapacityOperation(operationId).root;
|
|
11
|
+
const childOrdinal = /:child:[a-f0-9]+:followup:(\d+)$/.exec(root)?.[1];
|
|
12
|
+
if (childOrdinal && (!Number.isSafeInteger(Number(childOrdinal)) || Number(childOrdinal) < 1 || String(Number(childOrdinal)) !== childOrdinal))
|
|
13
|
+
throw new AppError("capacity_continuation_conflict", "Invalid native child continuation ordinal", 1);
|
|
14
|
+
if (root === operationId && !childOrdinal)
|
|
15
|
+
return;
|
|
16
|
+
const reject = () => { throw new AppError("capacity_owner_boundary_changed", "Queued continuation no longer belongs to an unfinished task; reconcile its durable state", 1, { operation_id: operationId, work_id: record.work_id, run_id: record.run_id }); };
|
|
17
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
18
|
+
const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
|
|
19
|
+
const parsed = parseCapacityOperation(operationId);
|
|
20
|
+
if (parsed.ordinal > 0 && !["judge", "probe"].includes(metadata.role ?? "")) {
|
|
21
|
+
if (!binding?.nativeSessionId || !binding.promptSha256)
|
|
22
|
+
reject();
|
|
23
|
+
let intent;
|
|
24
|
+
const predecessor = parsed.ordinal === 1 ? root : `${root}:capacity:${parsed.ordinal - 1}:session.prompt`;
|
|
25
|
+
if (hasTable("run_controller_operations")) {
|
|
26
|
+
const saved = context.db.get("SELECT receipt_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation = ? AND status = 'failed'", [predecessor, record.owner_id, metadata.run_generation ?? 0]);
|
|
27
|
+
if (saved)
|
|
28
|
+
intent = JSON.parse(saved.receipt_json).capacity_intent;
|
|
29
|
+
}
|
|
30
|
+
if (!intent && record.work_id)
|
|
31
|
+
for (const table of ["work_launches", "merge_requests"]) {
|
|
32
|
+
if (!hasTable(table))
|
|
33
|
+
continue;
|
|
34
|
+
const rows = table === "work_launches"
|
|
35
|
+
? context.db.all("SELECT l.adapter_receipt_json FROM work_launches l JOIN works w ON w.work_id = l.work_id WHERE l.work_id = ? AND w.project_id = ? AND w.run_id = ?", [record.work_id, record.project_id, record.run_id])
|
|
36
|
+
: context.db.all("SELECT adapter_receipt_json FROM merge_requests WHERE executor_work_id = ? AND project_id = ? AND run_id = ?", [record.work_id, record.project_id, record.run_id]);
|
|
37
|
+
for (const saved of rows) {
|
|
38
|
+
const retained = JSON.parse(saved.adapter_receipt_json ?? "null");
|
|
39
|
+
intent = retained?.capacity_intents?.find((item) => item.operation_id === operationId);
|
|
40
|
+
if (intent)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
if (intent)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
if (!intent || intent.policy !== CAPACITY_POLICY || intent.root_operation_id !== root || intent.ordinal !== parsed.ordinal
|
|
47
|
+
|| intent.predecessor !== predecessor || intent.operation_id !== operationId || intent.refusal?.turn_id !== intent.turn_id
|
|
48
|
+
|| intent.refusal?.ordinal !== parsed.ordinal - 1 || intent.refusal?.session_id !== intent.session_id
|
|
49
|
+
|| !Number.isFinite(Date.parse(intent.not_before)) || Date.parse(intent.not_before) > Date.parse(context.now())
|
|
50
|
+
|| intent.deadline !== null && (typeof intent.deadline !== "number" || intent.deadline <= Date.parse(context.now()))
|
|
51
|
+
|| binding?.nativeSessionId !== undefined && intent.session_id !== binding.nativeSessionId
|
|
52
|
+
|| binding?.promptSha256 !== undefined && intent.prompt_sha256 !== binding.promptSha256)
|
|
53
|
+
reject();
|
|
54
|
+
}
|
|
55
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'run_recovery_bindings'")) {
|
|
56
|
+
if (context.db.get("SELECT 1 FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND g.status = 'recovered' AND b.status = 'accepted' AND json_extract(b.runtime_dispatch_json, '$.operation_id') = ?", [record.project_id, record.run_id, root]))
|
|
57
|
+
reject();
|
|
58
|
+
}
|
|
59
|
+
if (record.work_id) {
|
|
60
|
+
const work = context.db.get("SELECT status FROM works WHERE work_id = ? AND project_id = ? AND run_id = ?", [record.work_id, record.project_id, record.run_id]);
|
|
61
|
+
const merge = context.db.get("SELECT status FROM merge_requests WHERE executor_work_id = ? AND project_id = ? AND run_id = ?", [record.work_id, record.project_id, record.run_id]);
|
|
62
|
+
if (merge && !["dispatching", "active"].includes(merge.status))
|
|
63
|
+
reject();
|
|
64
|
+
// MERGE owns the Stage/request boundary: finishing its Work does not
|
|
65
|
+
// necessarily commit stage finish yet. External workers own only Work.
|
|
66
|
+
const mergeOwner = metadata.role === "merge";
|
|
67
|
+
if (!work || !(merge && mergeOwner ? ["created", "running", "completed"] : ["created", "running"]).includes(work.status))
|
|
68
|
+
reject();
|
|
69
|
+
}
|
|
70
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'"))
|
|
71
|
+
return;
|
|
72
|
+
const controller = context.db.get("SELECT state_json FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ?", [record.owner_id, record.project_id, record.run_id]);
|
|
73
|
+
if (!controller)
|
|
74
|
+
return;
|
|
75
|
+
const state = JSON.parse(controller.state_json);
|
|
76
|
+
const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [record.project_id, record.run_id]);
|
|
77
|
+
const index = JSON.parse(run?.index_json ?? "{}");
|
|
78
|
+
if (state.stage && (index.current_stage && index.current_stage !== state.stage || index.stage_runs?.some(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status))))
|
|
79
|
+
reject();
|
|
80
|
+
const child = Object.entries(state.child_capacity ?? {}).find(([, intent]) => intent.operation_id === root)?.[0];
|
|
81
|
+
if (childOrdinal && !child)
|
|
82
|
+
reject();
|
|
83
|
+
if (child && context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN sessions s ON s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND s.provider_session_id = ? AND w.status <> 'running'", [record.project_id, record.run_id, child]))
|
|
84
|
+
reject();
|
|
85
|
+
}
|
|
86
|
+
export function capacityDelay(error, ordinal, now = Date.now()) {
|
|
87
|
+
return capacityBackoff(error, ordinal, now);
|
|
88
|
+
}
|
|
89
|
+
/** Continue a confirmed terminal Turn, never replay its completed effects. */
|
|
4
90
|
export async function continueCodexCapacity(input) {
|
|
5
91
|
let { error, operationId } = input;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
92
|
+
const history = [...(input.retainedIntents?.() ?? [])];
|
|
93
|
+
const parsed = parseCapacityOperation(input.operationId);
|
|
94
|
+
const rootId = parsed.root;
|
|
95
|
+
const text = input.continuationText ?? "Continue the same assigned task in this Session. Check current durable RUN, Work and Stage state first. Never repeat an observed lifecycle command, accepted HITL answer, check or merge apply; use only an exact issued successor. Stop at the assigned boundary.";
|
|
96
|
+
const promptSha = crypto.createHash("sha256").update(text).digest("hex");
|
|
97
|
+
let ordinal = parsed.ordinal + 1;
|
|
98
|
+
const conflict = (message) => { throw new AppError("capacity_continuation_conflict", message, 1, { operation_id: operationId, root_operation_id: rootId, session_id: input.sessionId }); };
|
|
99
|
+
const refusals = [...(input.retainedFailures?.() ?? [])];
|
|
100
|
+
if (parsed.ordinal > history.length)
|
|
101
|
+
conflict("Continuation has no complete retained predecessor chain");
|
|
102
|
+
for (let index = 0; index < history.length; index++) {
|
|
103
|
+
const intent = history[index];
|
|
104
|
+
const expected = index + 1;
|
|
105
|
+
if (intent.ordinal !== expected || intent.operation_id !== `${rootId}:capacity:${expected}:session.prompt`
|
|
106
|
+
|| intent.predecessor !== (expected === 1 ? rootId : `${rootId}:capacity:${expected - 1}:session.prompt`)
|
|
107
|
+
|| intent.prompt_sha256 !== promptSha || intent.policy !== CAPACITY_POLICY || intent.root_operation_id !== rootId || intent.session_id !== input.sessionId
|
|
108
|
+
|| intent.deadline !== (input.deadline ?? null) || !intent.refusal || intent.refusal.ordinal !== expected - 1 || intent.refusal.turn_id !== intent.turn_id
|
|
109
|
+
|| !Number.isFinite(Date.parse(intent.not_before)))
|
|
110
|
+
conflict("Retained capacity chain is incomplete or belongs to another policy/task");
|
|
111
|
+
const previous = refusals.find(item => item.ordinal === intent.refusal.ordinal);
|
|
112
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(intent.refusal))
|
|
113
|
+
conflict("Retained failure observation changed");
|
|
114
|
+
if (!previous)
|
|
115
|
+
refusals.push(intent.refusal);
|
|
116
|
+
}
|
|
117
|
+
refusals.sort((a, b) => a.ordinal - b.ordinal);
|
|
118
|
+
for (let index = 0; index < refusals.length; index++) {
|
|
119
|
+
const refusal = refusals[index];
|
|
120
|
+
if (refusal.ordinal !== index || refusal.policy !== CAPACITY_POLICY || refusal.session_id !== input.sessionId || !refusal.turn_id
|
|
121
|
+
|| refusals.slice(0, index).some(item => item.turn_id === refusal.turn_id) || !Number.isFinite(Date.parse(refusal.first_observed_at)))
|
|
122
|
+
conflict("Retained refusal chain is invalid");
|
|
123
|
+
}
|
|
124
|
+
// Reconcile a retained dispatch before preparing another. Native absence is
|
|
125
|
+
// the only safe way to reuse a prepared intent; a lost reply is not absence.
|
|
126
|
+
for (const intent of history) {
|
|
127
|
+
if (intent.ordinal < ordinal)
|
|
128
|
+
continue;
|
|
129
|
+
if (intent.predecessor !== operationId || intent.ordinal !== ordinal || intent.operation_id !== `${rootId}:capacity:${ordinal}:session.prompt`)
|
|
130
|
+
conflict("Retained successor skips its predecessor");
|
|
131
|
+
if (intent.prompt_sha256 !== promptSha || !intent.turn_id || !Number.isFinite(Date.parse(intent.not_before)))
|
|
10
132
|
throw error;
|
|
11
|
-
|
|
12
|
-
let prior;
|
|
133
|
+
let observed;
|
|
13
134
|
try {
|
|
14
|
-
|
|
135
|
+
observed = await input.inspectOperation(intent.operation_id);
|
|
136
|
+
}
|
|
137
|
+
catch (cause) {
|
|
138
|
+
let code = cause;
|
|
139
|
+
for (let depth = 0; depth < 4 && code?.code === "harness_adapter_failed"; depth++)
|
|
140
|
+
code = code.details?.cause;
|
|
141
|
+
if (code?.code !== "operation_not_found")
|
|
142
|
+
throw cause;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (observed.operation_id !== intent.operation_id || observed.session_id !== input.sessionId || observed.operation !== "session.prompt" || observed.primary_error)
|
|
146
|
+
throw error;
|
|
147
|
+
if (observed.state === "completed" && observed.result && typeof observed.result === "object") {
|
|
148
|
+
input.signal?.throwIfAborted();
|
|
149
|
+
input.assertCurrent();
|
|
150
|
+
const boundary = await input.decision?.() ?? "continue";
|
|
151
|
+
input.signal?.throwIfAborted();
|
|
152
|
+
input.assertCurrent();
|
|
153
|
+
if (boundary === "reconciliation_blocked")
|
|
154
|
+
throw new AppError("capacity_continuation_blocked", "Retained completion cannot be reconciled with its owner boundary", 1);
|
|
155
|
+
return boundary === "paused" || boundary === "completed" ? { provider_session_id: input.sessionId, capacity_decision: boundary } : observed.result;
|
|
15
156
|
}
|
|
16
|
-
|
|
157
|
+
if (observed.state !== "failed" || !terminalCodexOverload(observed.error, input.sessionId))
|
|
158
|
+
throw error;
|
|
159
|
+
operationId = intent.operation_id;
|
|
160
|
+
error = observed.error;
|
|
161
|
+
ordinal += 1;
|
|
162
|
+
}
|
|
163
|
+
const decide = async () => {
|
|
164
|
+
input.signal?.throwIfAborted();
|
|
165
|
+
input.assertCurrent();
|
|
166
|
+
const decision = await input.decision?.() ?? "continue";
|
|
167
|
+
input.signal?.throwIfAborted();
|
|
168
|
+
input.assertCurrent();
|
|
169
|
+
if (decision === "reconciliation_blocked")
|
|
170
|
+
throw new AppError("capacity_continuation_blocked", "Native refusal cannot be reconciled with its durable owner boundary", 1, { primary_error: error, root_operation_id: rootId, provider_session_id: input.sessionId });
|
|
171
|
+
if (decision === "continue" && input.deadline !== undefined && Date.now() >= input.deadline)
|
|
172
|
+
throw new AppError("capacity_owner_deadline_exceeded", "Continuation exhausted its original owner deadline", 1, { primary_error: error, deadline: input.deadline, root_operation_id: rootId, provider_session_id: input.sessionId });
|
|
173
|
+
return decision === "continue" ? null : { provider_session_id: input.sessionId, capacity_decision: decision };
|
|
174
|
+
};
|
|
175
|
+
for (;; ordinal += 1) {
|
|
176
|
+
if (!Number.isSafeInteger(ordinal))
|
|
177
|
+
conflict("Continuation ordinal overflow");
|
|
178
|
+
if (!terminalCodexOverload(error, input.sessionId)) {
|
|
17
179
|
if (ordinal === 1)
|
|
18
180
|
return null;
|
|
19
181
|
throw error;
|
|
20
182
|
}
|
|
183
|
+
const completed = await decide();
|
|
184
|
+
if (completed)
|
|
185
|
+
return completed;
|
|
186
|
+
const prior = await input.inspectOperation(operationId);
|
|
21
187
|
const failure = prior.error;
|
|
22
|
-
const
|
|
188
|
+
const confirmed = normalizeCodexFailure(failure, input.sessionId);
|
|
189
|
+
const turnId = confirmed?.turn_id;
|
|
190
|
+
input.signal?.throwIfAborted();
|
|
191
|
+
input.assertCurrent();
|
|
23
192
|
if (prior.operation_id !== operationId || prior.operation !== "session.prompt" || prior.session_id !== input.sessionId
|
|
24
|
-
|| prior.state !== "failed" || prior.primary_error || !turnId || !terminalCodexOverload(failure, input.sessionId, turnId)
|
|
25
|
-
|| failure.details?.native_turn_items?.observed !== true || failure.details.native_turn_items.possible_effects !== false) {
|
|
193
|
+
|| prior.state !== "failed" || prior.primary_error || !turnId || !terminalCodexOverload(failure, input.sessionId, turnId)) {
|
|
26
194
|
if (ordinal === 1)
|
|
27
195
|
return null;
|
|
28
196
|
throw error;
|
|
29
197
|
}
|
|
30
198
|
const settled = async () => {
|
|
31
|
-
|
|
199
|
+
input.signal?.throwIfAborted();
|
|
200
|
+
input.assertCurrent();
|
|
201
|
+
const seen = await input.inspectSession(turnId);
|
|
202
|
+
input.signal?.throwIfAborted();
|
|
203
|
+
input.assertCurrent();
|
|
204
|
+
if (seen.terminal_turn_id !== undefined && seen.terminal_turn_id !== turnId)
|
|
205
|
+
return false;
|
|
206
|
+
const items = seen.terminal_turn_id === turnId && seen.terminal_status === "failed" ? seen.native_turn_items : confirmed?.native_turn_items;
|
|
207
|
+
if (!nativeItemsSettled(items))
|
|
208
|
+
return false;
|
|
32
209
|
const settlement = seen.settlement;
|
|
33
210
|
const ids = Array.isArray(seen.tree_session_ids) ? seen.tree_session_ids : [];
|
|
34
211
|
const observations = Array.isArray(seen.tree_observations) ? seen.tree_observations : [];
|
|
@@ -38,34 +215,77 @@ export async function continueCodexCapacity(input) {
|
|
|
38
215
|
&& Array.isArray(seen.unsettled_sessions) && seen.unsettled_sessions.length === 0
|
|
39
216
|
: ids.length === 1 && ids[0] === input.sessionId;
|
|
40
217
|
return seen.provider_session_id === input.sessionId && seen.settled === true && treeProven
|
|
41
|
-
&& (!settlement ||
|
|
218
|
+
&& (!settlement || settlement.state === "not_required" || settlement.state === "settled" && settlement.operation_ids?.includes(operationId) === true);
|
|
42
219
|
};
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
if (!await settled()) {
|
|
46
|
-
if (ordinal === 1)
|
|
47
|
-
return null;
|
|
48
|
-
throw error;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
220
|
+
if (!await settled()) {
|
|
52
221
|
if (ordinal === 1)
|
|
53
222
|
return null;
|
|
54
223
|
throw error;
|
|
55
224
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
225
|
+
const native = normalizeCodexFailure(failure, input.sessionId, turnId);
|
|
226
|
+
let refusal = refusals.find(item => item.ordinal === ordinal - 1);
|
|
227
|
+
if (refusal && (refusal.turn_id !== turnId || refusal.session_id !== input.sessionId || refusal.policy !== CAPACITY_POLICY))
|
|
228
|
+
conflict("Failure ordinal identifies a different native Turn");
|
|
229
|
+
if (refusal) {
|
|
230
|
+
const expected = refusalObservation(failure, ordinal - 1, refusal.first_observed_at);
|
|
231
|
+
if (refusal.native_terminal_at !== expected.native_terminal_at || refusal.native_terminal_basis !== expected.native_terminal_basis)
|
|
232
|
+
conflict("Retained refusal time differs from its native receipt");
|
|
233
|
+
}
|
|
234
|
+
if (!refusal) {
|
|
235
|
+
if (refusals.some(item => item.turn_id === turnId))
|
|
236
|
+
conflict("A cached native Turn cannot be a second refusal");
|
|
237
|
+
// A cached legacy failure cannot be assigned a new time during reattach.
|
|
238
|
+
if (ordinal > 1 && parsed.ordinal > 0 && !native.observed_at)
|
|
239
|
+
conflict("Retained continuation has no original failure time");
|
|
240
|
+
const observedAt = typeof native.observed_at === "string" && Number.isFinite(Date.parse(native.observed_at)) ? native.observed_at : new Date().toISOString();
|
|
241
|
+
refusal = refusalObservation(failure, ordinal - 1, observedAt);
|
|
242
|
+
input.retainFailure?.(operationId, refusal);
|
|
243
|
+
refusals.push(refusal);
|
|
244
|
+
}
|
|
245
|
+
const burst = overloadBurst(refusals);
|
|
246
|
+
if (burst)
|
|
247
|
+
throw new AppError("provider_overload_burst", "Two consecutive continuation Turns were overloaded within two minutes", 1, { ...burst, root_operation_id: rootId, provider_session_id: input.sessionId, continuation_count: ordinal - 1, primary_error: failure });
|
|
248
|
+
const nextId = `${rootId}:capacity:${ordinal}:session.prompt`;
|
|
249
|
+
const existing = history.find(item => item.operation_id === nextId);
|
|
250
|
+
if (existing && (existing.predecessor !== operationId || existing.turn_id !== turnId || existing.prompt_sha256 !== promptSha || existing.ordinal !== ordinal))
|
|
61
251
|
throw error;
|
|
252
|
+
const wait = existing ? Math.max(0, Date.parse(existing.not_before) - Date.now()) : capacityDelay(failure, ordinal);
|
|
253
|
+
if (!Number.isFinite(wait) || Date.now() + wait > 8.64e15 || input.deadline !== undefined && Date.now() + wait >= input.deadline)
|
|
254
|
+
throw new AppError("capacity_owner_deadline_exceeded", "Continuation wait exceeds its original owner deadline", 1, { primary_error: failure, deadline: input.deadline ?? null });
|
|
255
|
+
if (!existing) {
|
|
256
|
+
const intent = { predecessor: operationId, operation_id: nextId, turn_id: turnId, ordinal, prompt_sha256: promptSha, not_before: new Date(Date.now() + wait).toISOString(), policy: CAPACITY_POLICY, root_operation_id: rootId, session_id: input.sessionId, deadline: input.deadline ?? null, refusal };
|
|
257
|
+
input.retainIntent(intent);
|
|
258
|
+
history.push(intent);
|
|
259
|
+
}
|
|
260
|
+
// Node timers truncate large delays. Bounded slices preserve cancellation
|
|
261
|
+
// and owner checks; monotonic elapsed time does not renew a Retry-After.
|
|
262
|
+
const until = performance.now() + wait;
|
|
263
|
+
while (performance.now() < until) {
|
|
264
|
+
await delay(Math.min(250, Math.max(0, until - performance.now())), undefined, { signal: input.signal });
|
|
265
|
+
const boundary = await decide();
|
|
266
|
+
if (boundary)
|
|
267
|
+
return boundary;
|
|
268
|
+
}
|
|
269
|
+
const afterWait = await decide();
|
|
270
|
+
if (afterWait)
|
|
271
|
+
return afterWait;
|
|
272
|
+
if (!await settled())
|
|
273
|
+
throw new AppError("capacity_continuation_blocked", "Native predecessor settlement changed during capacity wait", 1, { primary_error: failure });
|
|
274
|
+
const beforeDispatch = await decide();
|
|
275
|
+
if (beforeDispatch)
|
|
276
|
+
return beforeDispatch;
|
|
62
277
|
try {
|
|
63
|
-
return await input.prompt(nextId,
|
|
278
|
+
return await input.prompt(nextId, text, ordinal);
|
|
64
279
|
}
|
|
65
280
|
catch (nextError) {
|
|
281
|
+
// A task can complete/pause while the daemon queues for its permit.
|
|
282
|
+
// Admission then refuses the stale prompt; accept that owner decision
|
|
283
|
+
// before treating the transport's rejection as another provider failure.
|
|
284
|
+
const finished = await decide();
|
|
285
|
+
if (finished)
|
|
286
|
+
return finished;
|
|
66
287
|
error = nextError;
|
|
67
288
|
operationId = nextId;
|
|
68
289
|
}
|
|
69
290
|
}
|
|
70
|
-
throw error;
|
|
71
291
|
}
|
|
@@ -138,6 +138,7 @@ export function controllerNativeChildren(receipt, rootSessionId) {
|
|
|
138
138
|
const settled = rootChild && status === "unknown" && settledTree.has(id) ? "settled_by_root" : status;
|
|
139
139
|
candidates.push({ session_id: id, parent_session_id: parent ?? "", status: settled, source, ...(rootChild ? {} : { provenance: "parent_mismatch" }),
|
|
140
140
|
...(text(value, ["terminal_turn_id"]) ? { terminal_turn_id: text(value, ["terminal_turn_id"]) } : {}),
|
|
141
|
+
...(text(value, ["terminal_observed_at"]) ? { terminal_observed_at: text(value, ["terminal_observed_at"]) } : {}),
|
|
141
142
|
...(value.provider_error !== undefined ? { provider_error: value.provider_error } : {}),
|
|
142
143
|
...(value.native_turn_items && typeof value.native_turn_items === "object" ? { native_turn_items: value.native_turn_items } : {}) });
|
|
143
144
|
};
|
|
@@ -409,7 +409,12 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
409
409
|
let result, primary, failed = false;
|
|
410
410
|
try {
|
|
411
411
|
const runtimeLineage = recovery ? null : detachSnapshotRuntimeOwners(restored, snapshot, manifest);
|
|
412
|
+
const originalRunRoot = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id])?.run_root;
|
|
412
413
|
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
|
|
414
|
+
const publicationRun = restored.db.get("SELECT project_id, run_root FROM runs WHERE id = ?", [manifest.run_id]);
|
|
415
|
+
if (!originalRunRoot || !publicationRun?.run_root || !isInside(manifest.dd_flow_home, originalRunRoot) || !isInside(context.ddFlowHome, publicationRun.run_root))
|
|
416
|
+
throw new AppError("snapshot_invalid", "Restored publication roots are not bound to their runtime", 1);
|
|
417
|
+
restored.db.run("INSERT INTO audit_events (project_id, event_type, forced, payload_json, created_at) VALUES (?, 'flow_run.publication_relocation', 0, ?, ?)", [publicationRun.project_id, JSON.stringify({ run_id: manifest.run_id, from: originalRunRoot, to: publicationRun.run_root, source_manifest_sha256: sourceManifestSha256, import_id: importId }), restored.now()]);
|
|
413
418
|
registerProject(restored, { root: projectRoot });
|
|
414
419
|
requireProjectByRoot(restored, projectRoot);
|
|
415
420
|
if (selectedProfile && routingInput && runtimeLineage) {
|
|
@@ -423,14 +428,6 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
423
428
|
fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
424
429
|
importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
|
|
425
430
|
}
|
|
426
|
-
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
427
|
-
if (!recovery)
|
|
428
|
-
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
429
|
-
else
|
|
430
|
-
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
431
|
-
if (!status.run.run_root)
|
|
432
|
-
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
433
|
-
const runHome = status.run.run_root;
|
|
434
431
|
const relocation = recovery ? {
|
|
435
432
|
schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
|
|
436
433
|
path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
|
|
@@ -440,6 +437,14 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
440
437
|
fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
|
|
441
438
|
writeJson(relocationFile, relocation);
|
|
442
439
|
}
|
|
440
|
+
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
441
|
+
if (!recovery)
|
|
442
|
+
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
443
|
+
else
|
|
444
|
+
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
445
|
+
if (!status.run.run_root)
|
|
446
|
+
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
447
|
+
const runHome = status.run.run_root;
|
|
443
448
|
const expectedWorkspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome);
|
|
444
449
|
if (physicalPath(workspaceRoot) !== physicalPath(expectedWorkspaceRoot))
|
|
445
450
|
throw new AppError("snapshot_workspace_restore_failed", "Restored workspace differs from the import target", 1, { workspace_root: workspaceRoot, expected_workspace_root: expectedWorkspaceRoot });
|
|
@@ -93,7 +93,7 @@ export async function launchExternalWork(context, input) {
|
|
|
93
93
|
const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", isolatedWorkspace ?? run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
|
|
94
94
|
const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
|
|
95
95
|
try {
|
|
96
|
-
const call = (args, timeoutMs, id = `${operationId}:${args[0]}.${args[1]}`) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: id, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
96
|
+
const call = (args, timeoutMs, id = `${operationId}:${args[0]}.${args[1]}`) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: id, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
97
97
|
await call(["daemon", "start", ...common]);
|
|
98
98
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
99
99
|
const created = await call(["session", "create", ...common], runtimeHarness(resolved.worker) === "zcode-acp" ? 180_000 : undefined);
|
|
@@ -111,6 +111,8 @@ export async function launchExternalWork(context, input) {
|
|
|
111
111
|
const promptFile = path.join(root, "launch.md");
|
|
112
112
|
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n\n${lifecycleRetryInstruction}${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
|
|
113
113
|
const promptOperationId = `${operationId}:session.prompt`;
|
|
114
|
+
const promptDeadline = Date.now() + 45 * 60_000;
|
|
115
|
+
context.db.run("UPDATE work_launches SET adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'launching'", [JSON.stringify({ create: created, prompt_deadline: promptDeadline }), context.now(), operationId]);
|
|
114
116
|
let prompted;
|
|
115
117
|
try {
|
|
116
118
|
prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
|
|
@@ -118,37 +120,60 @@ export async function launchExternalWork(context, input) {
|
|
|
118
120
|
catch (error) {
|
|
119
121
|
if (runtimeHarness(resolved.worker) !== "codex-desktop")
|
|
120
122
|
throw error;
|
|
121
|
-
const
|
|
123
|
+
const retained = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
|
|
124
|
+
const capacityIntents = retained?.adapter_receipt_json ? JSON.parse(retained.adapter_receipt_json).capacity_intents ?? [] : [];
|
|
125
|
+
const capacityFailures = retained?.adapter_receipt_json ? JSON.parse(retained.adapter_receipt_json).capacity_failures ?? [] : [];
|
|
126
|
+
const retainCapacity = () => {
|
|
127
|
+
const changed = context.db.run("UPDATE work_launches SET adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'launching'", [JSON.stringify({ create: created, prompt_deadline: promptDeadline, capacity_intents: capacityIntents, capacity_failures: capacityFailures }), context.now(), operationId]);
|
|
128
|
+
if (changed.changes !== 1)
|
|
129
|
+
throw new AppError("capacity_continuation_blocked", "External Work launch owner changed", 1);
|
|
130
|
+
};
|
|
131
|
+
const resumed = await continueCodexCapacity({ error, sessionId, operationId: promptOperationId, allowSettledDescendants: true,
|
|
132
|
+
...(input.signal ? { signal: input.signal } : {}), deadline: promptDeadline,
|
|
133
|
+
continuationText: `Continue only Work ${work.work_id} in RUN ${work.run_id}, Stage ${stage ?? "current"}, in the same Session. Inspect its current durable state and the retained Work packet. Never repeat Work start or a completed tool, accepted answer, check or lifecycle command. Use only the current exact issued successor and finish at the original Work boundary.`,
|
|
134
|
+
retainedIntents: () => capacityIntents,
|
|
135
|
+
retainedFailures: () => capacityFailures,
|
|
136
|
+
retainFailure: (_id, refusal) => { capacityFailures.push(refusal); retainCapacity(); },
|
|
122
137
|
inspectOperation: id => call(["daemon", "operation", "--state-dir", root, "--operation-id", id, "--json"], 30_000, `${id}:capacity-observe`),
|
|
123
|
-
inspectSession:
|
|
138
|
+
inspectSession: turnId => call(["session", "inspect", "--session-id", sessionId, ...(turnId ? ["--turn-id", turnId] : []), ...common], 30_000, `${operationId}:capacity-inspect`),
|
|
124
139
|
assertCurrent: () => {
|
|
125
140
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
126
141
|
const current = context.db.get("SELECT w.status, l.status AS launch_status FROM works w JOIN work_launches l ON l.work_id = w.work_id WHERE w.work_id = ? AND l.operation_id = ?", [work.work_id, operationId]);
|
|
127
|
-
if (current
|
|
142
|
+
if (!current || !["created", "running", "paused", "completed"].includes(current.status) || current.launch_status !== "launching")
|
|
128
143
|
throw new AppError("capacity_continuation_blocked", "External Work changed before its native continuation", 1, { work_id: work.work_id, state: current });
|
|
129
144
|
if (reviewCopy && reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== reviewCopy.source_sha256)
|
|
130
145
|
throw new AppError("read_only_input_changed", "Authoritative review workspace changed during capacity wait", 2);
|
|
131
146
|
},
|
|
147
|
+
decision: () => {
|
|
148
|
+
const status = context.db.get("SELECT status FROM works WHERE work_id = ?", [work.work_id])?.status;
|
|
149
|
+
return status === "completed" ? "completed" : status === "paused" ? "paused" : "continue";
|
|
150
|
+
},
|
|
132
151
|
retainIntent: intent => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
throw new AppError("capacity_continuation_blocked", "External Work launch owner changed", 1);
|
|
152
|
+
capacityIntents.push(intent);
|
|
153
|
+
retainCapacity();
|
|
136
154
|
},
|
|
137
155
|
prompt: (id, text, ordinal) => {
|
|
138
156
|
const file = path.join(root, `capacity-${ordinal}.md`);
|
|
139
|
-
fs.
|
|
140
|
-
|
|
157
|
+
if (fs.existsSync(file)) {
|
|
158
|
+
if (fs.readFileSync(file, "utf8") !== text)
|
|
159
|
+
throw new AppError("capacity_continuation_conflict", "Retained Work continuation prompt differs", 1);
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
fs.writeFileSync(file, text, { flag: "wx", mode: 0o600 });
|
|
163
|
+
return call(["session", "prompt", "--session-id", sessionId, "--prompt-file", file, ...common], Math.max(1, promptDeadline - Date.now()), id);
|
|
141
164
|
} });
|
|
142
165
|
if (!resumed)
|
|
143
166
|
throw error;
|
|
144
167
|
prompted = resumed;
|
|
145
168
|
}
|
|
146
169
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
147
|
-
context.db.
|
|
170
|
+
const saved = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
|
|
171
|
+
context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ ...(saved?.adapter_receipt_json ? JSON.parse(saved.adapter_receipt_json) : {}), create: created, prompt: prompted }), context.now(), operationId]);
|
|
148
172
|
return { ok: true, work_id: work.work_id, operation_id: operationId, status: "start_requested", session: { harness_id: runtimeHarness(resolved.worker), session_id: sessionId }, profile_hash: resolved.profile_hash, journal_path: journal };
|
|
149
173
|
}
|
|
150
174
|
catch (error) {
|
|
151
|
-
context.db.
|
|
175
|
+
const saved = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
|
|
176
|
+
context.db.run("UPDATE work_launches SET status = 'failed', adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ ...(saved?.adapter_receipt_json ? JSON.parse(saved.adapter_receipt_json) : {}), error: errorRecord(error) }), context.now(), operationId]);
|
|
152
177
|
throw error;
|
|
153
178
|
}
|
|
154
179
|
}
|
|
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
-
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
6
|
+
import { stopProcessGroup } from "../harness-runtime/lib/managed-daemon.mjs";
|
|
6
7
|
import { recoveryRpcTimeout } from "./recovery-observation-budget.js";
|
|
7
8
|
const maxAdapterOutputBytes = 1_000_000;
|
|
8
9
|
/** Native hooks need a real executable, not a PATH-dependent shell expression. */
|
|
@@ -64,18 +65,26 @@ export function runHarnessAdapter(input) {
|
|
|
64
65
|
let outputTooLarge = false;
|
|
65
66
|
let timedOut = false;
|
|
66
67
|
let aborted = false;
|
|
67
|
-
let
|
|
68
|
+
let cleanup;
|
|
68
69
|
let settled = false;
|
|
69
70
|
let callbackError;
|
|
70
71
|
let spawnError;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
const stopTransport = () => {
|
|
73
|
+
cleanup ??= stopProcessGroup(child, 5_000, { detached }).catch(error => {
|
|
74
|
+
if (timer)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
if (heartbeat)
|
|
77
|
+
clearInterval(heartbeat);
|
|
78
|
+
input.signal?.removeEventListener("abort", abort);
|
|
79
|
+
const details = { cleanup_error: errorRecord(error) };
|
|
80
|
+
if (callbackError instanceof Error) {
|
|
81
|
+
Object.assign(callbackError, { cleanup_error: details.cleanup_error, details: { ...callbackError.details, ...details } });
|
|
82
|
+
reject(callbackError);
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
reject(new AppError(outputTooLarge ? "harness_adapter_output_limit" : timedOut ? "harness_adapter_timeout" : aborted ? "harness_adapter_aborted" : "harness_adapter_cleanup_unconfirmed", "Adapter transport cleanup could not be confirmed", 1, details));
|
|
86
|
+
});
|
|
87
|
+
};
|
|
79
88
|
const deliver = (callback) => {
|
|
80
89
|
try {
|
|
81
90
|
const result = callback();
|
|
@@ -109,7 +118,7 @@ export function runHarnessAdapter(input) {
|
|
|
109
118
|
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout = append(stdout, chunk); });
|
|
110
119
|
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr = append(stderr, chunk); });
|
|
111
120
|
child.once("error", (error) => { spawnError = error; });
|
|
112
|
-
child.once("close", (status, signal) => {
|
|
121
|
+
child.once("close", async (status, signal) => {
|
|
113
122
|
if (settled)
|
|
114
123
|
return;
|
|
115
124
|
settled = true;
|
|
@@ -118,10 +127,7 @@ export function runHarnessAdapter(input) {
|
|
|
118
127
|
input.signal?.removeEventListener("abort", abort);
|
|
119
128
|
if (heartbeat)
|
|
120
129
|
clearInterval(heartbeat);
|
|
121
|
-
|
|
122
|
-
clearTimeout(killTimer);
|
|
123
|
-
terminate("SIGKILL");
|
|
124
|
-
}
|
|
130
|
+
await cleanup;
|
|
125
131
|
const progressFailure = callbackError;
|
|
126
132
|
deliver(() => evidence({ status, signal, timed_out: timedOut, aborted, stdout, stderr, ...(spawnError ? { error: spawnError.message } : {}) }));
|
|
127
133
|
if (spawnError)
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import net from "node:net";
|
|
6
6
|
import { getResourceDatabase } from "../storage/database.js";
|
|
7
|
-
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
8
8
|
export function managedProcessBinding(record, home) {
|
|
9
9
|
return { resource_home: home, process_id: record.id, registration_sha256: crypto.createHash("sha256").update(JSON.stringify([home, record.id, record.kind, record.owner_id, record.lease_token, record.pid, record.pid_started_at, record.project_id, record.run_id, record.work_id, record.check_id, record.operation_id, record.metadata_json])).digest("hex") };
|
|
10
10
|
}
|
|
@@ -339,14 +339,19 @@ export async function stopManagedProcess(context, input) {
|
|
|
339
339
|
return record;
|
|
340
340
|
db.run("UPDATE managed_processes SET state = 'stopping', updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','orphaned')", [context.now(), record.id, record.lease_token]);
|
|
341
341
|
if (processTreeIsAlive(record)) {
|
|
342
|
-
|
|
342
|
+
const signalErrors = [];
|
|
343
|
+
const termError = terminateOwnedProcess(record, "SIGTERM");
|
|
344
|
+
if (termError)
|
|
345
|
+
signalErrors.push(errorRecord(termError));
|
|
343
346
|
await delay(graceMs);
|
|
344
|
-
if (input.force !== false && processTreeIsAlive(record)) {
|
|
345
|
-
terminateOwnedProcess(record, "SIGKILL");
|
|
347
|
+
if (!termError && input.force !== false && processTreeIsAlive(record)) {
|
|
348
|
+
const killError = terminateOwnedProcess(record, "SIGKILL");
|
|
349
|
+
if (killError)
|
|
350
|
+
signalErrors.push(errorRecord(killError));
|
|
346
351
|
await delay(250);
|
|
347
352
|
}
|
|
348
353
|
if (processTreeIsAlive(record))
|
|
349
|
-
throw Object.assign(new Error("Owned process tree could not be safely stopped; resources retained"), { code: "process_tree_not_settled", details: { process_id: record.id } });
|
|
354
|
+
throw Object.assign(new Error("Owned process tree could not be safely stopped; resources retained"), { code: "process_tree_not_settled", details: { process_id: record.id, signal_errors: signalErrors } });
|
|
350
355
|
}
|
|
351
356
|
if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" }))
|
|
352
357
|
throw Object.assign(new Error("Managed process ownership changed before settlement"), { code: "process_lease_lost" });
|
|
@@ -460,7 +465,10 @@ function terminateOwnedProcess(record, signal) {
|
|
|
460
465
|
try {
|
|
461
466
|
process.kill(process.platform === "win32" || group !== record.pid ? record.pid : -group, signal);
|
|
462
467
|
}
|
|
463
|
-
catch {
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (error.code !== "ESRCH")
|
|
470
|
+
return Object.assign(error, { details: { process_id: record.id, pid: record.pid, pgid: group ?? null, signal, phase: "owned_process_stop" } });
|
|
471
|
+
}
|
|
464
472
|
}
|
|
465
473
|
function parseMetadata(value) { try {
|
|
466
474
|
const parsed = JSON.parse(value);
|