@kylecheng3146/agent-ops 0.5.2 → 0.6.0
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/README.md +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { sha256 } from "../fs/hash.js";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { budgetExpired } from "./scheduler.js";
|
|
5
|
+
import { MAX_ACTIVE_WORKERS } from "./service.js";
|
|
6
|
+
const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
|
7
|
+
const SHA = /^[a-f0-9]{40,64}$/u;
|
|
8
|
+
function error(code, message) {
|
|
9
|
+
return new AgentOpsError(code, message);
|
|
10
|
+
}
|
|
11
|
+
function assertId(value, name) {
|
|
12
|
+
if (!ID.test(value))
|
|
13
|
+
throw error("RUN_IDENTITY_INVALID", `${name} is invalid.`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function assertSha(value, name) {
|
|
17
|
+
if (!SHA.test(value))
|
|
18
|
+
throw error("RUN_DIGEST_INVALID", `${name} must be a Git SHA or digest.`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function nowIso(now) {
|
|
22
|
+
const value = now();
|
|
23
|
+
if (!Number.isFinite(Date.parse(value)))
|
|
24
|
+
throw error("RUN_CLOCK_INVALID", "Run clock returned an invalid timestamp.");
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function activeWorker(worker) {
|
|
28
|
+
return ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status);
|
|
29
|
+
}
|
|
30
|
+
function replaceWorker(state, worker) {
|
|
31
|
+
const index = state.workers.findIndex((candidate) => candidate.workerId === worker.workerId);
|
|
32
|
+
if (index < 0)
|
|
33
|
+
throw error("RUN_WORKER_NOT_FOUND", `Worker not found: ${worker.workerId}`);
|
|
34
|
+
const workers = [...state.workers];
|
|
35
|
+
workers[index] = worker;
|
|
36
|
+
return { ...state, workers };
|
|
37
|
+
}
|
|
38
|
+
function replaceTask(state, taskId, update) {
|
|
39
|
+
const index = state.tasks.findIndex((candidate) => candidate.taskId === taskId);
|
|
40
|
+
if (index < 0)
|
|
41
|
+
throw error("RUN_TASK_NOT_FOUND", `Run task not found: ${taskId}`);
|
|
42
|
+
const tasks = [...state.tasks];
|
|
43
|
+
tasks[index] = { ...tasks[index], ...update };
|
|
44
|
+
return { ...state, tasks };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Owns the mapping between a run worker and a native session. Every write
|
|
48
|
+
* operation is generation checked. A native goal's `complete` state is only
|
|
49
|
+
* an observation; the final proof path is separate and explicit.
|
|
50
|
+
*/
|
|
51
|
+
export class RunSupervisor {
|
|
52
|
+
#repository;
|
|
53
|
+
#host;
|
|
54
|
+
#now;
|
|
55
|
+
#leaseMs;
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.#repository = options.repository;
|
|
58
|
+
this.#host = options.host;
|
|
59
|
+
this.#now = options.now ?? (() => new Date().toISOString());
|
|
60
|
+
this.#leaseMs = options.leaseMs ?? 30_000;
|
|
61
|
+
if (!Number.isSafeInteger(this.#leaseMs) || this.#leaseMs < 1_000 || this.#leaseMs > 10 * 60_000) {
|
|
62
|
+
throw error("RUN_LEASE_INVALID", "Worker lease must be between one second and ten minutes.");
|
|
63
|
+
}
|
|
64
|
+
if (this.#host.host !== "claude" && this.#host.host !== "codex") {
|
|
65
|
+
throw error("RUN_HOST_UNSUPPORTED", "Run workers support Claude and Codex only.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async registerWorker(runId, input) {
|
|
69
|
+
assertId(input.taskId, "taskId");
|
|
70
|
+
assertId(input.ownerSessionId, "ownerSessionId");
|
|
71
|
+
const workerId = `worker-${randomUUID().replaceAll("-", "").slice(0, 20)}`;
|
|
72
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
73
|
+
if (current.status !== "active" && current.status !== "awaiting-input") {
|
|
74
|
+
throw error("RUN_NOT_ACTIVE", `Run ${runId} cannot register a worker while ${current.status}.`);
|
|
75
|
+
}
|
|
76
|
+
const running = current.workers.filter(activeWorker).length;
|
|
77
|
+
if (running >= Math.min(current.jobs, MAX_ACTIVE_WORKERS)) {
|
|
78
|
+
throw error("RUN_WORKER_LIMIT", `Run ${runId} already has ${running} active worker(s).`);
|
|
79
|
+
}
|
|
80
|
+
if (current.workers.some((worker) => worker.taskId === input.taskId && activeWorker(worker))) {
|
|
81
|
+
throw error("RUN_TASK_ALREADY_ASSIGNED", `Task ${input.taskId} already has an active worker.`);
|
|
82
|
+
}
|
|
83
|
+
const worker = {
|
|
84
|
+
workerId,
|
|
85
|
+
taskId: input.taskId,
|
|
86
|
+
host: current.host,
|
|
87
|
+
ownerSessionId: input.ownerSessionId,
|
|
88
|
+
nativeSessionId: null,
|
|
89
|
+
nativeJobId: null,
|
|
90
|
+
worktree: input.worktree ?? null,
|
|
91
|
+
processId: null,
|
|
92
|
+
processIdentity: null,
|
|
93
|
+
generation: 1,
|
|
94
|
+
status: "assigned",
|
|
95
|
+
leaseExpiresAt: null,
|
|
96
|
+
heartbeatAt: null,
|
|
97
|
+
stopIntent: null,
|
|
98
|
+
nativeGoalState: "inactive",
|
|
99
|
+
lastFailure: null
|
|
100
|
+
};
|
|
101
|
+
const next = replaceTask({ ...current, workers: [...current.workers, worker] }, input.taskId, {
|
|
102
|
+
status: "ready",
|
|
103
|
+
workerId
|
|
104
|
+
});
|
|
105
|
+
return { ...next, status: "active" };
|
|
106
|
+
});
|
|
107
|
+
const worker = state.workers.find((candidate) => candidate.workerId === workerId);
|
|
108
|
+
if (worker === undefined)
|
|
109
|
+
throw error("RUN_WORKER_NOT_FOUND", `Registered worker disappeared: ${workerId}`);
|
|
110
|
+
return { workerId, taskId: input.taskId, ownerSessionId: input.ownerSessionId, generation: worker.generation, worktree: worker.worktree };
|
|
111
|
+
}
|
|
112
|
+
async startWorker(runId, workerId, generation, goal) {
|
|
113
|
+
assertId(workerId, "workerId");
|
|
114
|
+
if (!Number.isSafeInteger(generation) || generation < 1)
|
|
115
|
+
throw error("RUN_GENERATION_INVALID", "Worker generation is invalid.");
|
|
116
|
+
const before = await this.#repository.read(runId);
|
|
117
|
+
if (before === null)
|
|
118
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
119
|
+
if (before.host !== this.#host.host)
|
|
120
|
+
throw error("RUN_HOST_MISMATCH", `Run ${runId} is assigned to ${before.host}, not ${this.#host.host}.`);
|
|
121
|
+
const worker = before.workers.find((candidate) => candidate.workerId === workerId);
|
|
122
|
+
if (worker === undefined)
|
|
123
|
+
throw error("RUN_WORKER_NOT_FOUND", `Worker not found: ${workerId}`);
|
|
124
|
+
if (worker.generation !== generation || worker.status !== "assigned")
|
|
125
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} is no longer assigned at generation ${generation}.`);
|
|
126
|
+
const starting = await this.#repository.mutate(runId, (current) => {
|
|
127
|
+
if (current.status !== "active" || current.disableRestart || current.awaitingResume)
|
|
128
|
+
throw error("RUN_START_DISABLED", "Run state no longer permits starting a writer.");
|
|
129
|
+
if (budgetExpired(current, Date.parse(this.#now())))
|
|
130
|
+
throw error("RUN_BUDGET_EXHAUSTED", "The cumulative run budget cannot be reset by starting another worker.");
|
|
131
|
+
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
132
|
+
if (currentWorker === undefined || currentWorker.generation !== generation || currentWorker.status !== "assigned") {
|
|
133
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} changed before native start.`);
|
|
134
|
+
}
|
|
135
|
+
return replaceWorker(replaceTask(current, currentWorker.taskId, { status: "running" }), {
|
|
136
|
+
...currentWorker,
|
|
137
|
+
status: "starting",
|
|
138
|
+
leaseExpiresAt: new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString(),
|
|
139
|
+
heartbeatAt: this.#now()
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
const startingWorker = starting.workers.find((candidate) => candidate.workerId === workerId);
|
|
143
|
+
let native;
|
|
144
|
+
try {
|
|
145
|
+
native = await this.#host.start({
|
|
146
|
+
runId,
|
|
147
|
+
workerId,
|
|
148
|
+
taskId: startingWorker.taskId,
|
|
149
|
+
goal,
|
|
150
|
+
ownerSessionId: startingWorker.ownerSessionId,
|
|
151
|
+
generation,
|
|
152
|
+
worktree: startingWorker.worktree,
|
|
153
|
+
contractHash: before.currentContractHash
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch (cause) {
|
|
157
|
+
await this.#repository.mutate(runId, (current) => {
|
|
158
|
+
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
159
|
+
if (currentWorker === undefined || currentWorker.generation !== generation)
|
|
160
|
+
return current;
|
|
161
|
+
if (current.status !== "active" || current.disableRestart || currentWorker.status !== "starting")
|
|
162
|
+
return current;
|
|
163
|
+
return replaceWorker(replaceTask(current, currentWorker.taskId, { status: "ready", workerId: null }), {
|
|
164
|
+
...currentWorker,
|
|
165
|
+
status: "blocked",
|
|
166
|
+
leaseExpiresAt: null,
|
|
167
|
+
heartbeatAt: this.#now(),
|
|
168
|
+
lastFailure: {
|
|
169
|
+
id: randomUUID(), checkId: null, pinId: null, failureClass: "native-start",
|
|
170
|
+
fingerprint: null, diagnosticDigest: null, round: 0, observedAt: this.#now(), usefulProgress: false
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
throw new AgentOpsError("RUN_NATIVE_START_FAILED", `Native ${this.#host.host} worker could not start.`, { cause });
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return (await this.#repository.mutate(runId, (current) => {
|
|
178
|
+
if (current.status !== "active" || current.disableRestart || current.awaitingResume || budgetExpired(current, Date.parse(this.#now())))
|
|
179
|
+
throw error("RUN_START_DISABLED", "Run stopped or exhausted its budget during native registration.");
|
|
180
|
+
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
181
|
+
if (currentWorker === undefined || currentWorker.generation !== generation || currentWorker.status !== "starting") {
|
|
182
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} changed while native session started.`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...replaceWorker(current, {
|
|
186
|
+
...currentWorker,
|
|
187
|
+
status: "running",
|
|
188
|
+
nativeSessionId: native.nativeSessionId,
|
|
189
|
+
nativeJobId: native.nativeJobId,
|
|
190
|
+
processId: native.processId,
|
|
191
|
+
processIdentity: native.processIdentity,
|
|
192
|
+
nativeGoalState: "active",
|
|
193
|
+
leaseExpiresAt: new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString(),
|
|
194
|
+
heartbeatAt: this.#now()
|
|
195
|
+
}),
|
|
196
|
+
nativeInstance: native.instance ?? current.nativeInstance
|
|
197
|
+
};
|
|
198
|
+
})).workers.find((candidate) => candidate.workerId === workerId);
|
|
199
|
+
}
|
|
200
|
+
catch (cause) {
|
|
201
|
+
// A stop/revision may win the race while the host is bootstrapping. Do
|
|
202
|
+
// not leave that unregistered process writing into the worktree.
|
|
203
|
+
try {
|
|
204
|
+
await this.#host.stop({
|
|
205
|
+
runId,
|
|
206
|
+
nativeSessionId: native.nativeSessionId,
|
|
207
|
+
nativeJobId: native.nativeJobId,
|
|
208
|
+
processId: native.processId,
|
|
209
|
+
processIdentity: native.processIdentity,
|
|
210
|
+
instance: native.instance,
|
|
211
|
+
reason: "worker registration lost during start",
|
|
212
|
+
generation
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// The original stale-registration error is retained; reconcile will
|
|
217
|
+
// keep the run fenced until the host reports process death.
|
|
218
|
+
}
|
|
219
|
+
throw cause;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Issue a new lease only after the prior registered process is confirmed dead. */
|
|
223
|
+
async reassignWorker(runId, workerId, generation) {
|
|
224
|
+
const state = await this.#repository.read(runId);
|
|
225
|
+
const worker = state?.workers.find(w => w.workerId === workerId);
|
|
226
|
+
if (state === null || state === undefined || worker === undefined || worker.generation !== generation ||
|
|
227
|
+
state.status !== "active" || state.disableRestart || state.awaitingResume ||
|
|
228
|
+
!["stopped", "fenced", "running", "idle", "starting"].includes(worker.status))
|
|
229
|
+
throw error("RUN_WORKER_STALE", "A new lease requires the current active run and writer generation.");
|
|
230
|
+
if (worker.nativeSessionId !== null) {
|
|
231
|
+
const observation = await this.#host.inspect({ nativeSessionId: worker.nativeSessionId, nativeJobId: worker.nativeJobId,
|
|
232
|
+
processId: worker.processId, processIdentity: worker.processIdentity, instance: state.nativeInstance });
|
|
233
|
+
if (observation.processAlive)
|
|
234
|
+
throw error("RUN_WRITER_STILL_ALIVE", "Confirm the old writer and its process identity are dead before takeover.");
|
|
235
|
+
}
|
|
236
|
+
return (await this.#repository.mutate(runId, current => {
|
|
237
|
+
const saved = current.workers.find(w => w.workerId === workerId);
|
|
238
|
+
if (current.status !== "active" || current.disableRestart || saved?.generation !== generation || saved.status !== worker.status)
|
|
239
|
+
throw error("RUN_WORKER_STALE", "The run changed while confirming process death.");
|
|
240
|
+
const active = current.workers.filter(w => w.workerId !== workerId && activeWorker(w)).length;
|
|
241
|
+
if (active >= current.jobs)
|
|
242
|
+
throw error("RUN_WORKER_LIMIT", "No writer slot is available for recovery.");
|
|
243
|
+
return replaceWorker(current, { ...saved, generation: saved.generation + 1, status: "assigned", processId: null,
|
|
244
|
+
processIdentity: null, leaseExpiresAt: null, heartbeatAt: this.#now(), stopIntent: null, nativeGoalState: "inactive",
|
|
245
|
+
...(saved.host === "codex" && saved.nativeJobId === null && saved.nativeGoalState === "inactive" ? { nativeSessionId: null } : {}) });
|
|
246
|
+
})).workers.find(w => w.workerId === workerId);
|
|
247
|
+
}
|
|
248
|
+
async heartbeat(runId, workerId, generation) {
|
|
249
|
+
return (await this.#repository.mutate(runId, (current) => {
|
|
250
|
+
const worker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
251
|
+
if (worker === undefined || worker.generation !== generation || !activeWorker(worker))
|
|
252
|
+
throw error("RUN_WORKER_STALE", `Heartbeat rejected for stale worker ${workerId}.`);
|
|
253
|
+
return replaceWorker(current, {
|
|
254
|
+
...worker,
|
|
255
|
+
heartbeatAt: this.#now(),
|
|
256
|
+
leaseExpiresAt: new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString()
|
|
257
|
+
});
|
|
258
|
+
})).workers.find((candidate) => candidate.workerId === workerId);
|
|
259
|
+
}
|
|
260
|
+
/** Resume an existing native session only after the run lease is current. */
|
|
261
|
+
async resumeWorker(runId, workerId, generation, goal) {
|
|
262
|
+
const state = await this.#repository.read(runId);
|
|
263
|
+
if (state === null)
|
|
264
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
265
|
+
if (state.status !== "active" || state.awaitingResume || state.disableRestart) {
|
|
266
|
+
throw error("RUN_RESUME_DISABLED", `Run ${runId} is not eligible for native resume.`);
|
|
267
|
+
}
|
|
268
|
+
const worker = state.workers.find((candidate) => candidate.workerId === workerId);
|
|
269
|
+
if (worker === undefined || worker.generation !== generation || !["idle", "running"].includes(worker.status) || worker.nativeSessionId === null) {
|
|
270
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} has no resumable native session at generation ${generation}.`);
|
|
271
|
+
}
|
|
272
|
+
await this.#host.resume({
|
|
273
|
+
nativeSessionId: worker.nativeSessionId,
|
|
274
|
+
nativeJobId: worker.nativeJobId,
|
|
275
|
+
processId: worker.processId,
|
|
276
|
+
processIdentity: worker.processIdentity,
|
|
277
|
+
instance: state.nativeInstance,
|
|
278
|
+
goal,
|
|
279
|
+
generation,
|
|
280
|
+
contractHash: state.currentContractHash
|
|
281
|
+
});
|
|
282
|
+
return (await this.#repository.mutate(runId, (current) => {
|
|
283
|
+
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
284
|
+
if (currentWorker === undefined || currentWorker.generation !== generation || currentWorker.nativeSessionId !== worker.nativeSessionId) {
|
|
285
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} changed while resuming.`);
|
|
286
|
+
}
|
|
287
|
+
return replaceWorker(current, {
|
|
288
|
+
...currentWorker,
|
|
289
|
+
status: "running",
|
|
290
|
+
nativeGoalState: "active",
|
|
291
|
+
heartbeatAt: this.#now(),
|
|
292
|
+
leaseExpiresAt: new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString()
|
|
293
|
+
});
|
|
294
|
+
})).workers.find((candidate) => candidate.workerId === workerId);
|
|
295
|
+
}
|
|
296
|
+
/** Persist a structured FAIL and stop a worker after two identical stagnant rounds. */
|
|
297
|
+
async recordFailure(runId, workerId, generation, input) {
|
|
298
|
+
assertId(workerId, "workerId");
|
|
299
|
+
assertId(input.failureClass, "failureClass");
|
|
300
|
+
if (input.checkId !== null)
|
|
301
|
+
assertId(input.checkId, "checkId");
|
|
302
|
+
if (input.pinId !== null)
|
|
303
|
+
assertId(input.pinId, "pinId");
|
|
304
|
+
if (input.fingerprint !== null)
|
|
305
|
+
assertSha(input.fingerprint, "fingerprint");
|
|
306
|
+
if (input.diagnosticDigest !== null)
|
|
307
|
+
assertSha(input.diagnosticDigest, "diagnosticDigest");
|
|
308
|
+
if (input.phase !== undefined && input.phase !== null)
|
|
309
|
+
assertId(input.phase, "phase");
|
|
310
|
+
if (input.progressDigest !== undefined && input.progressDigest !== null)
|
|
311
|
+
assertSha(input.progressDigest, "progressDigest");
|
|
312
|
+
if (!Number.isSafeInteger(input.round) || input.round < 0 || typeof input.usefulProgress !== "boolean") {
|
|
313
|
+
throw error("RUN_FAILURE_INVALID", "Structured worker failure has invalid round or progress metadata.");
|
|
314
|
+
}
|
|
315
|
+
const failure = {
|
|
316
|
+
id: randomUUID(),
|
|
317
|
+
checkId: input.checkId,
|
|
318
|
+
pinId: input.pinId,
|
|
319
|
+
failureClass: input.failureClass,
|
|
320
|
+
fingerprint: input.fingerprint,
|
|
321
|
+
diagnosticDigest: input.diagnosticDigest,
|
|
322
|
+
...(input.phase === undefined ? {} : { phase: input.phase }),
|
|
323
|
+
...(input.progressDigest === undefined ? {} : { progressDigest: input.progressDigest }),
|
|
324
|
+
round: input.round,
|
|
325
|
+
observedAt: this.#now(),
|
|
326
|
+
usefulProgress: input.usefulProgress
|
|
327
|
+
};
|
|
328
|
+
const failureEventId = randomUUID();
|
|
329
|
+
const saved = await this.#repository.mutate(runId, (current) => {
|
|
330
|
+
const worker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
331
|
+
if (worker === undefined || worker.generation !== generation ||
|
|
332
|
+
(!activeWorker(worker) && !["fenced", "stopped"].includes(worker.status))) {
|
|
333
|
+
throw error("RUN_WORKER_STALE", `Failure report rejected for stale worker ${workerId}.`);
|
|
334
|
+
}
|
|
335
|
+
const previous = worker.lastFailure;
|
|
336
|
+
const sameSemanticFailure = previous !== null &&
|
|
337
|
+
previous.failureClass === failure.failureClass &&
|
|
338
|
+
previous.checkId === failure.checkId &&
|
|
339
|
+
previous.pinId === failure.pinId &&
|
|
340
|
+
previous.fingerprint === failure.fingerprint &&
|
|
341
|
+
(previous.phase ?? null) === (failure.phase ?? null) &&
|
|
342
|
+
(previous.progressDigest === undefined || failure.progressDigest === undefined ||
|
|
343
|
+
previous.progressDigest === failure.progressDigest);
|
|
344
|
+
const noProgress = previous !== null && !previous.usefulProgress && !failure.usefulProgress &&
|
|
345
|
+
sameSemanticFailure;
|
|
346
|
+
const next = replaceWorker(current, {
|
|
347
|
+
...worker,
|
|
348
|
+
lastFailure: failure
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
...next,
|
|
352
|
+
events: [...next.events, {
|
|
353
|
+
id: failureEventId, at: failure.observedAt, type: "diagnostic", code: noProgress ? "RUN_NO_PROGRESS_REPEAT" : "RUN_FAILURE_RECORDED",
|
|
354
|
+
workerId, taskId: worker.taskId,
|
|
355
|
+
detail: JSON.stringify({ checkId: failure.checkId, pinId: failure.pinId, phase: failure.phase ?? null, failureClass: failure.failureClass, fingerprint: failure.fingerprint, progressDigest: failure.progressDigest ?? null, diagnosticDigest: failure.diagnosticDigest, round: failure.round, usefulProgress: failure.usefulProgress })
|
|
356
|
+
}].slice(-2_000),
|
|
357
|
+
...(noProgress ? { status: "active" } : {})
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
// Keep the transition marker explicit in the event stream and delegate
|
|
361
|
+
// native fencing to stopWorker.
|
|
362
|
+
const repeat = saved.events.some((event) => event.id === failureEventId && event.code === "RUN_NO_PROGRESS_REPEAT");
|
|
363
|
+
if (!repeat)
|
|
364
|
+
return { state: saved, noProgress: false };
|
|
365
|
+
await this.stopWorker(runId, workerId, generation, "no-progress");
|
|
366
|
+
const blocked = await this.#repository.mutate(runId, (current) => {
|
|
367
|
+
const worker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
368
|
+
if (worker === undefined)
|
|
369
|
+
throw error("RUN_WORKER_NOT_FOUND", `Worker not found: ${workerId}`);
|
|
370
|
+
const task = current.tasks.find((candidate) => candidate.taskId === worker.taskId);
|
|
371
|
+
const next = replaceWorker(current, { ...worker, status: "blocked", leaseExpiresAt: null });
|
|
372
|
+
return task === undefined ? next : replaceTask(next, worker.taskId, { status: "blocked", blockedReason: `no-progress:${failure.failureClass}` });
|
|
373
|
+
});
|
|
374
|
+
return { state: blocked, noProgress: true };
|
|
375
|
+
}
|
|
376
|
+
/** Send a bounded structured repair instruction without persisting its text. */
|
|
377
|
+
async sendRepair(runId, workerId, generation, message) {
|
|
378
|
+
assertId(workerId, "workerId");
|
|
379
|
+
const clean = message.trim();
|
|
380
|
+
if (clean.length === 0 || clean.length > 16_384 || clean.includes("\0"))
|
|
381
|
+
throw error("RUN_REPAIR_INVALID", "Repair instruction is empty or too large.");
|
|
382
|
+
const state = await this.#repository.read(runId);
|
|
383
|
+
const worker = state?.workers.find((candidate) => candidate.workerId === workerId);
|
|
384
|
+
if (state === null || state === undefined)
|
|
385
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
386
|
+
if (worker === undefined || worker.generation !== generation || !activeWorker(worker) || worker.nativeSessionId === null) {
|
|
387
|
+
throw error("RUN_WORKER_STALE", `Repair rejected for stale worker ${workerId}.`);
|
|
388
|
+
}
|
|
389
|
+
await this.#host.send({
|
|
390
|
+
nativeSessionId: worker.nativeSessionId,
|
|
391
|
+
nativeJobId: worker.nativeJobId,
|
|
392
|
+
processId: worker.processId,
|
|
393
|
+
processIdentity: worker.processIdentity,
|
|
394
|
+
instance: state.nativeInstance,
|
|
395
|
+
message: clean,
|
|
396
|
+
generation
|
|
397
|
+
});
|
|
398
|
+
await this.#repository.appendEvent(runId, {
|
|
399
|
+
type: "diagnostic", code: "RUN_REPAIR_SENT", workerId, taskId: worker.taskId,
|
|
400
|
+
detail: sha256(clean)
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async observeWorker(runId, workerId, generation) {
|
|
404
|
+
const state = await this.#repository.read(runId);
|
|
405
|
+
if (state === null)
|
|
406
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
407
|
+
const worker = state.workers.find((candidate) => candidate.workerId === workerId);
|
|
408
|
+
if (worker === undefined || worker.generation !== generation || worker.nativeSessionId === null)
|
|
409
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} has no current native session.`);
|
|
410
|
+
const observation = await this.#host.inspect({ nativeSessionId: worker.nativeSessionId, nativeJobId: worker.nativeJobId, processId: worker.processId, processIdentity: worker.processIdentity, instance: state.nativeInstance });
|
|
411
|
+
await this.#repository.mutate(runId, (current) => {
|
|
412
|
+
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
413
|
+
if (currentWorker === undefined || currentWorker.generation !== generation)
|
|
414
|
+
return current;
|
|
415
|
+
const idle = observation.processAlive && observation.nativeGoalState !== "active";
|
|
416
|
+
const stoppedUnexpectedly = !observation.processAlive && currentWorker.status !== "handing-off" && currentWorker.status !== "fenced" && currentWorker.status !== "stopped";
|
|
417
|
+
const observed = replaceWorker(current, {
|
|
418
|
+
...currentWorker,
|
|
419
|
+
status: currentWorker.status === "handing-off" ? currentWorker.status : idle ? "idle" : observation.processAlive ? "running" : "stopped",
|
|
420
|
+
nativeGoalState: observation.nativeGoalState,
|
|
421
|
+
processIdentity: observation.processIdentity ?? currentWorker.processIdentity,
|
|
422
|
+
heartbeatAt: this.#now(),
|
|
423
|
+
leaseExpiresAt: observation.processAlive ? new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString() : null,
|
|
424
|
+
// Preserve the durable native identity; reassignWorker increments the lease only after confirmed death.
|
|
425
|
+
});
|
|
426
|
+
return stoppedUnexpectedly
|
|
427
|
+
? replaceTask(observed, currentWorker.taskId, { status: "ready", workerId: null, blockedReason: "native-process-exited" })
|
|
428
|
+
: observed;
|
|
429
|
+
});
|
|
430
|
+
return observation;
|
|
431
|
+
}
|
|
432
|
+
async beginHandoff(runId, request) {
|
|
433
|
+
assertId(request.workerId, "workerId");
|
|
434
|
+
assertSha(request.deliveryDigest, "deliveryDigest");
|
|
435
|
+
assertSha(request.contractDigest, "contractDigest");
|
|
436
|
+
assertSha(request.sourceCommit, "sourceCommit");
|
|
437
|
+
const existingState = await this.#repository.read(runId);
|
|
438
|
+
const existingWorker = existingState?.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
439
|
+
const existingIntent = existingWorker?.generation === request.generation && ["handing-off", "fenced", "delivered", "stopped"].includes(existingWorker.status) &&
|
|
440
|
+
existingWorker.stopIntent?.deliveryDigest === request.deliveryDigest && existingWorker.stopIntent.contractDigest === request.contractDigest &&
|
|
441
|
+
existingWorker.stopIntent.sourceCommit === request.sourceCommit
|
|
442
|
+
? existingWorker.stopIntent
|
|
443
|
+
: null;
|
|
444
|
+
if (existingIntent !== null)
|
|
445
|
+
return existingIntent;
|
|
446
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
447
|
+
const worker = current.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
448
|
+
if (worker === undefined || worker.generation !== request.generation) {
|
|
449
|
+
throw error("RUN_HANDOFF_STALE", `Worker ${request.workerId} cannot hand off at generation ${request.generation}.`);
|
|
450
|
+
}
|
|
451
|
+
if (worker.status === "handing-off" && worker.stopIntent !== null &&
|
|
452
|
+
worker.stopIntent.deliveryDigest === request.deliveryDigest && worker.stopIntent.contractDigest === request.contractDigest) {
|
|
453
|
+
return current;
|
|
454
|
+
}
|
|
455
|
+
if (!["running", "idle"].includes(worker.status))
|
|
456
|
+
throw error("RUN_HANDOFF_STALE", `Worker ${request.workerId} cannot hand off from ${worker.status}.`);
|
|
457
|
+
const intent = {
|
|
458
|
+
runId,
|
|
459
|
+
workerId: request.workerId,
|
|
460
|
+
generation: request.generation,
|
|
461
|
+
nativeSessionId: worker.nativeSessionId,
|
|
462
|
+
reason: request.reason ?? "handoff",
|
|
463
|
+
deliveryDigest: request.deliveryDigest,
|
|
464
|
+
contractDigest: request.contractDigest,
|
|
465
|
+
sourceCommit: request.sourceCommit,
|
|
466
|
+
expiresAt: new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString(),
|
|
467
|
+
confirmedDeadAt: null
|
|
468
|
+
};
|
|
469
|
+
return replaceWorker(current, { ...worker, status: "handing-off", stopIntent: intent });
|
|
470
|
+
});
|
|
471
|
+
const worker = state.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
472
|
+
const intent = worker.stopIntent;
|
|
473
|
+
if (intent === null)
|
|
474
|
+
throw error("RUN_HANDOFF_MISSING", `Worker ${request.workerId} did not receive a stop intent.`);
|
|
475
|
+
if (worker.nativeSessionId !== null) {
|
|
476
|
+
await this.#host.stop({ runId, nativeSessionId: worker.nativeSessionId, nativeJobId: worker.nativeJobId, processId: worker.processId, processIdentity: worker.processIdentity, instance: state.nativeInstance, reason: "atomic delivery handoff", generation: request.generation });
|
|
477
|
+
}
|
|
478
|
+
return intent;
|
|
479
|
+
}
|
|
480
|
+
async confirmHandoff(runId, request, confirmation) {
|
|
481
|
+
if (!confirmation.processDead)
|
|
482
|
+
throw error("RUN_HANDOFF_PROCESS_ALIVE", `Worker ${request.workerId} is still alive; delivery remains fenced.`);
|
|
483
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
484
|
+
const worker = current.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
485
|
+
if (worker === undefined || worker.generation !== request.generation ||
|
|
486
|
+
worker.stopIntent === null || worker.stopIntent.deliveryDigest !== request.deliveryDigest || worker.stopIntent.contractDigest !== request.contractDigest) {
|
|
487
|
+
throw error("RUN_HANDOFF_MISMATCH", `Worker ${request.workerId} handoff does not match its current stop intent.`);
|
|
488
|
+
}
|
|
489
|
+
if (["fenced", "stopped"].includes(worker.status) && worker.stopIntent.confirmedDeadAt !== null)
|
|
490
|
+
return current;
|
|
491
|
+
if (worker.status !== "handing-off")
|
|
492
|
+
throw error("RUN_HANDOFF_MISMATCH", `Worker ${request.workerId} is not awaiting handoff confirmation.`);
|
|
493
|
+
const confirmedAt = this.#now();
|
|
494
|
+
const intent = { ...worker.stopIntent, confirmedDeadAt: confirmedAt };
|
|
495
|
+
const updated = replaceWorker(current, {
|
|
496
|
+
...worker,
|
|
497
|
+
status: "fenced",
|
|
498
|
+
stopIntent: intent,
|
|
499
|
+
processIdentity: confirmation.processIdentity ?? worker.processIdentity,
|
|
500
|
+
nativeGoalState: "inactive",
|
|
501
|
+
leaseExpiresAt: null,
|
|
502
|
+
heartbeatAt: confirmedAt
|
|
503
|
+
});
|
|
504
|
+
return replaceTask(updated, worker.taskId, { status: "awaiting-delivery" });
|
|
505
|
+
});
|
|
506
|
+
const worker = state.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
507
|
+
return { worker, deliveryDigest: request.deliveryDigest, sourceCommit: request.sourceCommit, taskId: worker.taskId };
|
|
508
|
+
}
|
|
509
|
+
async publishDelivery(runId, request, input) {
|
|
510
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
511
|
+
const worker = current.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
512
|
+
if (current.status !== "active" || current.disableRestart || current.awaitingResume ||
|
|
513
|
+
worker === undefined || worker.generation !== request.generation ||
|
|
514
|
+
worker.stopIntent == null || worker.stopIntent.confirmedDeadAt === null ||
|
|
515
|
+
worker.stopIntent.deliveryDigest !== request.deliveryDigest ||
|
|
516
|
+
worker.stopIntent.contractDigest !== request.contractDigest || worker.stopIntent.sourceCommit !== request.sourceCommit) {
|
|
517
|
+
throw error("RUN_DELIVERY_UNFENCED", `Worker ${request.workerId} must be fenced before delivery.`);
|
|
518
|
+
}
|
|
519
|
+
if (worker.status === "delivered")
|
|
520
|
+
return current;
|
|
521
|
+
if (worker.status !== "fenced")
|
|
522
|
+
throw error("RUN_DELIVERY_UNFENCED", `Worker ${request.workerId} must be fenced before delivery.`);
|
|
523
|
+
const next = replaceWorker(current, { ...worker, status: "delivered" });
|
|
524
|
+
const task = next.tasks.find((candidate) => candidate.taskId === worker.taskId);
|
|
525
|
+
if (task === undefined)
|
|
526
|
+
throw error("RUN_TASK_NOT_FOUND", `Run task not found: ${worker.taskId}`);
|
|
527
|
+
return replaceTask(next, worker.taskId, {
|
|
528
|
+
status: "delivered",
|
|
529
|
+
deliveryDigest: request.deliveryDigest,
|
|
530
|
+
sourceCommit: request.sourceCommit,
|
|
531
|
+
blockedReason: input.noChange ? `verified-no-change:${input.artifactRefs.join(",")}` : null
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
return state.workers.find((candidate) => candidate.workerId === request.workerId);
|
|
535
|
+
}
|
|
536
|
+
async reconcile(runId) {
|
|
537
|
+
const before = await this.#repository.read(runId);
|
|
538
|
+
if (before === null)
|
|
539
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
540
|
+
for (const worker of before.workers.filter((candidate) => candidate.nativeSessionId !== null && activeWorker(candidate))) {
|
|
541
|
+
try {
|
|
542
|
+
await this.observeWorker(runId, worker.workerId, worker.generation);
|
|
543
|
+
}
|
|
544
|
+
catch (errorValue) {
|
|
545
|
+
if (errorValue instanceof AgentOpsError && errorValue.code === "RUN_WORKER_STALE")
|
|
546
|
+
continue;
|
|
547
|
+
throw errorValue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return (await this.#repository.mutate(runId, (current) => {
|
|
551
|
+
if (current.awaitingResume || current.disableRestart)
|
|
552
|
+
return current;
|
|
553
|
+
return { ...current, updatedAt: this.#now() };
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
async finalize(runId, proof) {
|
|
557
|
+
assertSha(proof.targetCommit, "targetCommit");
|
|
558
|
+
assertSha(proof.candidateCommit, "candidateCommit");
|
|
559
|
+
if (!proof.verificationPass || !proof.reviewPass || !proof.taskStateComplete || !proof.receiptWritten || proof.receiptDigest === null) {
|
|
560
|
+
throw error("RUN_FINAL_PROOF_REQUIRED", "Run finalization requires verification, dual review, complete task state, and a receipt.");
|
|
561
|
+
}
|
|
562
|
+
return await this.#repository.mutate(runId, (current) => {
|
|
563
|
+
if (current.status !== "active" || current.disableRestart)
|
|
564
|
+
throw error("RUN_FINAL_PROOF_STOPPED", "A stopped or disabled run cannot accept a delayed completion result.");
|
|
565
|
+
if (current.workers.some((worker) => activeWorker(worker)))
|
|
566
|
+
throw error("RUN_WRITERS_ACTIVE", "Stop and fence every writer before finalization.");
|
|
567
|
+
return { ...current, status: "complete", disableRestart: true, awaitingResume: false };
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async stopWorker(runId, workerId, generation, reason) {
|
|
571
|
+
const state = await this.#repository.read(runId);
|
|
572
|
+
if (state === null)
|
|
573
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
574
|
+
const worker = state.workers.find((candidate) => candidate.workerId === workerId);
|
|
575
|
+
if (worker === undefined || worker.generation !== generation)
|
|
576
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} is stale.`);
|
|
577
|
+
const request = {
|
|
578
|
+
workerId, generation, deliveryDigest: worker.stopIntent?.deliveryDigest ?? "0".repeat(64),
|
|
579
|
+
contractDigest: worker.stopIntent?.contractDigest ?? "0".repeat(64),
|
|
580
|
+
sourceCommit: worker.stopIntent?.sourceCommit ?? state.tasks.find(task => task.taskId === worker.taskId)?.sourceCommit ?? "0".repeat(40), reason
|
|
581
|
+
};
|
|
582
|
+
if (!["running", "idle"].includes(worker.status)) {
|
|
583
|
+
await this.#repository.mutate(runId, current => {
|
|
584
|
+
const saved = current.workers.find(w => w.workerId === workerId);
|
|
585
|
+
if (saved?.generation !== generation)
|
|
586
|
+
throw error("RUN_WORKER_STALE", "Writer generation changed before stop intent.");
|
|
587
|
+
return replaceWorker(current, { ...saved, stopIntent: saved.stopIntent ?? {
|
|
588
|
+
runId, workerId, generation, nativeSessionId: saved.nativeSessionId, reason,
|
|
589
|
+
deliveryDigest: request.deliveryDigest, contractDigest: request.contractDigest, sourceCommit: request.sourceCommit,
|
|
590
|
+
expiresAt: new Date(Date.parse(this.#now()) + this.#leaseMs).toISOString(), confirmedDeadAt: null
|
|
591
|
+
} });
|
|
592
|
+
});
|
|
593
|
+
if (worker.nativeSessionId !== null) {
|
|
594
|
+
await this.#host.stop({ runId, nativeSessionId: worker.nativeSessionId, nativeJobId: worker.nativeJobId,
|
|
595
|
+
processId: worker.processId, processIdentity: worker.processIdentity, instance: state.nativeInstance, reason, generation });
|
|
596
|
+
const observed = await this.#host.inspect({ nativeSessionId: worker.nativeSessionId, nativeJobId: worker.nativeJobId,
|
|
597
|
+
processId: worker.processId, processIdentity: worker.processIdentity, instance: state.nativeInstance });
|
|
598
|
+
if (observed.processAlive)
|
|
599
|
+
throw error("RUN_WRITER_STILL_ALIVE", "Native process death is required before recording stopped.");
|
|
600
|
+
}
|
|
601
|
+
return (await this.#repository.mutate(runId, current => {
|
|
602
|
+
const saved = current.workers.find(w => w.workerId === workerId);
|
|
603
|
+
if (saved?.generation !== generation)
|
|
604
|
+
throw error("RUN_WORKER_STALE", "Writer generation changed during stop.");
|
|
605
|
+
return replaceWorker(current, { ...saved, status: ["delivered", "blocked"].includes(saved.status) ? saved.status : "stopped", leaseExpiresAt: null,
|
|
606
|
+
stopIntent: saved.stopIntent === null ? null : { ...saved.stopIntent, confirmedDeadAt: this.#now() } });
|
|
607
|
+
})).workers.find(candidate => candidate.workerId === workerId);
|
|
608
|
+
}
|
|
609
|
+
await this.beginHandoff(runId, request);
|
|
610
|
+
const fencedState = await this.#repository.read(runId);
|
|
611
|
+
const fencedWorker = fencedState?.workers.find((candidate) => candidate.workerId === workerId);
|
|
612
|
+
if (fencedState === null || fencedState === undefined || fencedWorker === undefined || fencedWorker.nativeSessionId === null) {
|
|
613
|
+
throw error("RUN_HANDOFF_MISSING", `Worker ${workerId} lost its native identity while stopping.`);
|
|
614
|
+
}
|
|
615
|
+
const observation = await this.#host.inspect({
|
|
616
|
+
nativeSessionId: fencedWorker.nativeSessionId,
|
|
617
|
+
nativeJobId: fencedWorker.nativeJobId,
|
|
618
|
+
processId: fencedWorker.processId,
|
|
619
|
+
processIdentity: fencedWorker.processIdentity,
|
|
620
|
+
instance: fencedState.nativeInstance
|
|
621
|
+
});
|
|
622
|
+
const confirmed = await this.confirmHandoff(runId, request, {
|
|
623
|
+
processDead: !observation.processAlive,
|
|
624
|
+
...(observation.processIdentity === null ? {} : { processIdentity: observation.processIdentity })
|
|
625
|
+
});
|
|
626
|
+
return (await this.#repository.mutate(runId, (current) => {
|
|
627
|
+
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
628
|
+
if (currentWorker === undefined || currentWorker.generation !== generation)
|
|
629
|
+
throw error("RUN_WORKER_STALE", `Worker ${workerId} changed while stopping.`);
|
|
630
|
+
return replaceWorker(current, { ...currentWorker, status: "stopped", leaseExpiresAt: null });
|
|
631
|
+
})).workers.find((candidate) => candidate.workerId === confirmed.worker.workerId);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** The maximum number of writer slots F allows in one run. */
|
|
635
|
+
export const MAX_ACTIVE_WRITERS = MAX_ACTIVE_WORKERS;
|