@arnilo/prism-coding-agent 0.2.4 → 0.2.6
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 +16 -0
- package/README.md +10 -0
- package/dist/coding-checkpoint.js +4 -0
- package/dist/diagnostics.d.ts +83 -0
- package/dist/diagnostics.js +179 -0
- package/dist/git.d.ts +27 -6
- package/dist/git.js +58 -1
- package/dist/index.d.ts +13 -4
- package/dist/index.js +13 -2
- package/dist/language/client.d.ts +25 -0
- package/dist/language/client.js +54 -0
- package/dist/language/framing.d.ts +9 -1
- package/dist/language/framing.js +89 -17
- package/dist/language/index.d.ts +1 -1
- package/dist/language/intelligence.js +58 -0
- package/dist/language/types.d.ts +32 -0
- package/dist/limits.d.ts +59 -0
- package/dist/limits.js +59 -0
- package/dist/process/index.d.ts +4 -1
- package/dist/process/index.js +1 -0
- package/dist/process/recovery.d.ts +174 -0
- package/dist/process/recovery.js +320 -0
- package/dist/process/sessions.js +714 -25
- package/dist/process/types.d.ts +128 -4
- package/dist/process/types.js +7 -1
- package/dist/repository/glob.d.ts +4 -0
- package/dist/repository/glob.js +143 -0
- package/dist/repository/indexed-search.d.ts +121 -0
- package/dist/repository/indexed-search.js +313 -0
- package/dist/repository/list.d.ts +3 -0
- package/dist/repository/list.js +119 -0
- package/dist/repository/operations.d.ts +5 -0
- package/dist/repository/operations.js +14 -0
- package/dist/repository/path.d.ts +18 -0
- package/dist/repository/path.js +91 -0
- package/dist/repository/search.d.ts +9 -0
- package/dist/repository/search.js +284 -0
- package/dist/repository/types.d.ts +138 -0
- package/dist/repository/types.js +31 -0
- package/dist/repository/walk.d.ts +22 -0
- package/dist/repository/walk.js +99 -0
- package/dist/repository.d.ts +11 -172
- package/dist/repository.js +11 -748
- package/dist/review.d.ts +150 -0
- package/dist/review.js +222 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +42 -7
- package/dist/workspace-lifecycle.d.ts +153 -0
- package/dist/workspace-lifecycle.js +629 -0
- package/package.json +3 -3
package/dist/process/sessions.js
CHANGED
|
@@ -9,6 +9,11 @@ import { OutputAccumulator } from "../output-accumulator.js";
|
|
|
9
9
|
import { resolveToCwd } from "../path-utils.js";
|
|
10
10
|
import { killProcessTree } from "../shell.js";
|
|
11
11
|
import { ProcessSessionError, resolveProcessSessionLimits, } from "./types.js";
|
|
12
|
+
import { acquireRecordLease, attachWithTimeout, buildProcessRecoveryRecord, deleteProcessRecoveryRecord, loadProcessRecoveryRecord, loadProcessRecoveryRecords, PROCESS_RECOVERY_LEASE_NAMESPACE, releaseRecordLease, resolveProcessRecoveryLimits, saveProcessRecoveryRecord, validateBackendRef, } from "./recovery.js";
|
|
13
|
+
import { ProcessRecoveryError } from "./recovery.js";
|
|
14
|
+
import { DEFAULT_MAX_TERMINAL_COLUMNS, DEFAULT_MAX_TERMINAL_ROWS } from "../limits.js";
|
|
15
|
+
/** Default TERM for PTY sessions (validated <= maxTerminalTermBytes). */
|
|
16
|
+
const DEFAULT_TERM = "xterm-256color";
|
|
12
17
|
function ownershipKey(ownership, identity) {
|
|
13
18
|
if (ownership) {
|
|
14
19
|
return `${ownership.tenantId ?? ""}:${ownership.accountId ?? ""}:${ownership.userId ?? ""}`;
|
|
@@ -34,6 +39,22 @@ function isInsideRoot(root, target) {
|
|
|
34
39
|
function nowIso() {
|
|
35
40
|
return new Date().toISOString();
|
|
36
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Start-budget for host PTY attachment (frozen cap). Timeout fails closed with
|
|
44
|
+
* ERR_PRISM_PROCESS_PTY_LIMIT; the underlying promise result is discarded.
|
|
45
|
+
*/
|
|
46
|
+
function withPtyAttachTimeout(promise, timeoutMs) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const timer = setTimeout(() => reject(new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `PTY attach timed out (${timeoutMs}ms)`)), timeoutMs);
|
|
49
|
+
promise.then((value) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
resolve(value);
|
|
52
|
+
}, (error) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(error);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
37
58
|
export function createProcessSessions(options) {
|
|
38
59
|
const workspace = resolve(options.cwd);
|
|
39
60
|
const limits = resolveProcessSessionLimits(options.limits);
|
|
@@ -41,9 +62,23 @@ export function createProcessSessions(options) {
|
|
|
41
62
|
const policy = options.policy;
|
|
42
63
|
const onEvent = options.onEvent;
|
|
43
64
|
const sandbox = options.sandbox;
|
|
65
|
+
const ptyBackend = options.ptyBackend;
|
|
66
|
+
const ptyResizeCapable = ptyBackend?.capabilities?.resize === true;
|
|
44
67
|
const sessions = new Map();
|
|
45
68
|
let disposed = false;
|
|
46
69
|
let sandboxLost = false;
|
|
70
|
+
// Durable process recovery (plan 026 Task 5): checkpoints + leases + ownerId
|
|
71
|
+
// activate the seam together; a partial recovery configuration fails closed
|
|
72
|
+
// at construction (no implicit activation, no half-durable state).
|
|
73
|
+
const checkpoints = options.checkpoints;
|
|
74
|
+
const leases = options.leases;
|
|
75
|
+
const ownerId = options.ownerId;
|
|
76
|
+
const recoveryBackend = options.recoveryBackend;
|
|
77
|
+
const recoveryLimits = resolveProcessRecoveryLimits(options.recoveryLimits);
|
|
78
|
+
const durable = checkpoints !== undefined || leases !== undefined || ownerId !== undefined || recoveryBackend !== undefined;
|
|
79
|
+
if (durable && !(checkpoints && leases && ownerId)) {
|
|
80
|
+
throw new ProcessRecoveryError("ERR_PRISM_RECOVERY_UNSUPPORTED", "durable process recovery requires checkpoints, leases, and ownerId together");
|
|
81
|
+
}
|
|
47
82
|
const emit = (event) => {
|
|
48
83
|
onEvent?.(event);
|
|
49
84
|
};
|
|
@@ -76,6 +111,21 @@ export function createProcessSessions(options) {
|
|
|
76
111
|
reconcileAllUnknown();
|
|
77
112
|
}
|
|
78
113
|
};
|
|
114
|
+
const resolveTerminal = (terminal) => {
|
|
115
|
+
const columns = terminal?.columns ?? Math.min(DEFAULT_MAX_TERMINAL_COLUMNS, limits.maxTerminalColumns);
|
|
116
|
+
const rows = terminal?.rows ?? Math.min(DEFAULT_MAX_TERMINAL_ROWS, limits.maxTerminalRows);
|
|
117
|
+
const term = terminal?.term ?? DEFAULT_TERM;
|
|
118
|
+
if (!Number.isSafeInteger(columns) || columns < 1 || columns > limits.maxTerminalColumns) {
|
|
119
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `columns must be 1..${limits.maxTerminalColumns}`);
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isSafeInteger(rows) || rows < 1 || rows > limits.maxTerminalRows) {
|
|
122
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `rows must be 1..${limits.maxTerminalRows}`);
|
|
123
|
+
}
|
|
124
|
+
if (Buffer.byteLength(term, "utf8") > limits.maxTerminalTermBytes) {
|
|
125
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `term exceeds maxTerminalTermBytes (${limits.maxTerminalTermBytes})`);
|
|
126
|
+
}
|
|
127
|
+
return { columns, rows, term };
|
|
128
|
+
};
|
|
79
129
|
const sweepExpired = () => {
|
|
80
130
|
const now = Date.now();
|
|
81
131
|
for (const record of sessions.values()) {
|
|
@@ -93,6 +143,187 @@ export function createProcessSessions(options) {
|
|
|
93
143
|
for (const resolveWait of waiters)
|
|
94
144
|
resolveWait(result);
|
|
95
145
|
};
|
|
146
|
+
// Durable transition write: fire-and-forget CAS (fence/version conflicts mean
|
|
147
|
+
// another replica moved the record first — the newer state wins). The crash
|
|
148
|
+
// window between a terminal transition and its durable write converges on
|
|
149
|
+
// recovery to attach/terminal/unknown, never a duplicate spawn.
|
|
150
|
+
const persistTransition = (record) => {
|
|
151
|
+
if (!durable || record.recoveryVersion === 0)
|
|
152
|
+
return;
|
|
153
|
+
const next = buildProcessRecoveryRecord({
|
|
154
|
+
id: record.id,
|
|
155
|
+
owner: record.owner,
|
|
156
|
+
workspace: record.workspace,
|
|
157
|
+
command: record.command,
|
|
158
|
+
args: record.args,
|
|
159
|
+
commandFingerprint: record.commandFingerprint,
|
|
160
|
+
policyDecision: record.policyDecision,
|
|
161
|
+
startedAt: record.startedAt,
|
|
162
|
+
state: record.state,
|
|
163
|
+
exitCode: record.exitCode,
|
|
164
|
+
releaseOnCancel: record.releaseOnCancel,
|
|
165
|
+
expiresAt: record.expiresAt,
|
|
166
|
+
...(record.backendRef !== undefined ? { backendRef: record.backendRef } : {}),
|
|
167
|
+
...(record.ptyTerminal !== undefined ? { pty: record.ptyTerminal } : {}),
|
|
168
|
+
fencingToken: record.recoveryFencingToken,
|
|
169
|
+
});
|
|
170
|
+
const expectedVersion = record.recoveryVersion;
|
|
171
|
+
record.recoveryVersion += 1;
|
|
172
|
+
const write = async () => {
|
|
173
|
+
try {
|
|
174
|
+
await saveProcessRecoveryRecord({
|
|
175
|
+
checkpoints: checkpoints,
|
|
176
|
+
record: next,
|
|
177
|
+
expectedVersion,
|
|
178
|
+
version: record.recoveryVersion,
|
|
179
|
+
ownership: options.ownership,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// stale fence or store failure: the durable record keeps its last state
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
void evictRecoveryOverflow();
|
|
187
|
+
// Terminal transition: release the record lease (clean shutdown makes
|
|
188
|
+
// recovery immediate). Live running/starting transitions renew it so a
|
|
189
|
+
// crashed replica's lease lapses within TTL while a live one stays
|
|
190
|
+
// fenced. Best effort on both.
|
|
191
|
+
if (isTerminalState(record.state)) {
|
|
192
|
+
if (record.recoveryLeaseToken) {
|
|
193
|
+
void releaseRecordLease({
|
|
194
|
+
leases: leases,
|
|
195
|
+
id: record.id,
|
|
196
|
+
ownerId: ownerId,
|
|
197
|
+
token: record.recoveryLeaseToken,
|
|
198
|
+
ownership: options.ownership,
|
|
199
|
+
});
|
|
200
|
+
record.recoveryLeaseToken = undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (record.recoveryLeaseToken) {
|
|
204
|
+
void leases
|
|
205
|
+
.renewLease({
|
|
206
|
+
namespace: PROCESS_RECOVERY_LEASE_NAMESPACE,
|
|
207
|
+
key: `recover:${record.id}`,
|
|
208
|
+
ownerId: ownerId,
|
|
209
|
+
token: record.recoveryLeaseToken,
|
|
210
|
+
ttlMs: recoveryLimits.leaseTtlMs,
|
|
211
|
+
...options.ownership,
|
|
212
|
+
})
|
|
213
|
+
.catch(() => {
|
|
214
|
+
// lease lapsed or fenced elsewhere; recovery is attestation-gated
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// Per-record chain: transition CAS writes must never invert order on slow
|
|
219
|
+
// stores (a later terminal write landing before the running write would
|
|
220
|
+
// fail its CAS and leave the record running forever).
|
|
221
|
+
record.recoveryWriteChain = (record.recoveryWriteChain ?? Promise.resolve()).then(write, write);
|
|
222
|
+
};
|
|
223
|
+
// Bound durable record growth: after a terminal transition, drop the oldest
|
|
224
|
+
// terminal records until at most maxRecords remain (running/starting records
|
|
225
|
+
// are never evicted). Best effort, bounded work.
|
|
226
|
+
const evictRecoveryOverflow = async () => {
|
|
227
|
+
if (!durable)
|
|
228
|
+
return;
|
|
229
|
+
try {
|
|
230
|
+
const page = await loadProcessRecoveryRecords({
|
|
231
|
+
checkpoints: checkpoints,
|
|
232
|
+
limits: { ...recoveryLimits, maxRecords: recoveryLimits.maxRecords + 1 },
|
|
233
|
+
ownership: options.ownership,
|
|
234
|
+
});
|
|
235
|
+
if (page.records.length <= recoveryLimits.maxRecords)
|
|
236
|
+
return;
|
|
237
|
+
const overflow = page.records.length - recoveryLimits.maxRecords;
|
|
238
|
+
let evicted = 0;
|
|
239
|
+
for (const { record } of [...page.records].reverse()) {
|
|
240
|
+
if (evicted >= overflow)
|
|
241
|
+
break;
|
|
242
|
+
if (record.state === "running" || record.state === "starting")
|
|
243
|
+
continue;
|
|
244
|
+
await deleteProcessRecoveryRecord({ checkpoints: checkpoints, id: record.id, ownership: options.ownership });
|
|
245
|
+
evicted += 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// best effort: caps are enforced again on the next transition
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const isTerminalState = (state) => state === "exited" || state === "killed" || state === "released" || state === "expired" || state === "unknown";
|
|
253
|
+
// Atomic starting|running -> unknown (never an exit code). Returns false when
|
|
254
|
+
// a fence/version conflict means another replica moved the record first.
|
|
255
|
+
const persistRecoveryUnknown = async (current, version, signal) => {
|
|
256
|
+
const next = buildProcessRecoveryRecord({ ...current, state: "unknown", exitCode: null, updatedAt: nowIso() });
|
|
257
|
+
try {
|
|
258
|
+
await saveProcessRecoveryRecord({
|
|
259
|
+
checkpoints: checkpoints,
|
|
260
|
+
record: next,
|
|
261
|
+
expectedVersion: version,
|
|
262
|
+
version: version + 1,
|
|
263
|
+
ownership: options.ownership,
|
|
264
|
+
signal,
|
|
265
|
+
});
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
// Reattach an attested handle into the live registry. Recovered sessions
|
|
273
|
+
// expose control (input/signal/kill/release/resize/wait) through the handle;
|
|
274
|
+
// output streaming is not re-established after a restart — the host backend
|
|
275
|
+
// owns any buffered output behind its opaque ref.
|
|
276
|
+
const attachRecoveredHandle = (current, handle, version) => {
|
|
277
|
+
const accumulator = new OutputAccumulator({
|
|
278
|
+
maxBytes: limits.maxOutputChunkBytes,
|
|
279
|
+
maxLines: 100_000,
|
|
280
|
+
maxTotalOutputBytes: limits.maxTotalOutputBytes,
|
|
281
|
+
tempFilePrefix: "prism-proc",
|
|
282
|
+
});
|
|
283
|
+
const record = {
|
|
284
|
+
id: current.id,
|
|
285
|
+
owner: current.owner,
|
|
286
|
+
workspace: current.workspace,
|
|
287
|
+
command: current.command,
|
|
288
|
+
args: current.args,
|
|
289
|
+
commandFingerprint: current.commandFingerprint,
|
|
290
|
+
policyDecision: current.policyDecision,
|
|
291
|
+
startedAt: current.startedAt,
|
|
292
|
+
releaseOnCancel: current.releaseOnCancel,
|
|
293
|
+
expiresAt: current.expiresAt,
|
|
294
|
+
state: "running",
|
|
295
|
+
exitCode: null,
|
|
296
|
+
ptyTerminal: current.pty,
|
|
297
|
+
ptyResizeAt: [],
|
|
298
|
+
accumulator,
|
|
299
|
+
waiters: [],
|
|
300
|
+
stdinClosed: false,
|
|
301
|
+
handle: null,
|
|
302
|
+
backendRef: current.backendRef,
|
|
303
|
+
recoveryFencingToken: current.fencingToken,
|
|
304
|
+
recoveryVersion: version,
|
|
305
|
+
};
|
|
306
|
+
if (current.pty !== undefined) {
|
|
307
|
+
record.pty = handle;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
record.backend = handle;
|
|
311
|
+
}
|
|
312
|
+
record.handle = makeHandle(record);
|
|
313
|
+
sessions.set(record.id, record);
|
|
314
|
+
void handle
|
|
315
|
+
.wait()
|
|
316
|
+
.then((result) => {
|
|
317
|
+
if (record.state !== "running" && record.state !== "starting")
|
|
318
|
+
return;
|
|
319
|
+
terminateRecord(record, "exited", result.exitCode);
|
|
320
|
+
})
|
|
321
|
+
.catch(() => {
|
|
322
|
+
if (record.state !== "running" && record.state !== "starting")
|
|
323
|
+
return;
|
|
324
|
+
terminateRecord(record, "unknown", null);
|
|
325
|
+
});
|
|
326
|
+
};
|
|
96
327
|
const terminateRecord = (record, state, exitCode) => {
|
|
97
328
|
if (record.state === "exited" ||
|
|
98
329
|
record.state === "killed" ||
|
|
@@ -103,8 +334,10 @@ export function createProcessSessions(options) {
|
|
|
103
334
|
}
|
|
104
335
|
const child = record.child;
|
|
105
336
|
const backend = record.backend;
|
|
337
|
+
const pty = record.pty;
|
|
106
338
|
record.child = undefined;
|
|
107
339
|
record.backend = undefined;
|
|
340
|
+
record.pty = undefined;
|
|
108
341
|
if (state === "released") {
|
|
109
342
|
try {
|
|
110
343
|
child?.stdout.removeAllListeners();
|
|
@@ -118,6 +351,7 @@ export function createProcessSessions(options) {
|
|
|
118
351
|
// best effort
|
|
119
352
|
}
|
|
120
353
|
void backend?.release().catch(() => undefined);
|
|
354
|
+
void pty?.release().catch(() => undefined);
|
|
121
355
|
}
|
|
122
356
|
else if (state === "killed" || state === "expired" || state === "unknown") {
|
|
123
357
|
if (child?.pid) {
|
|
@@ -131,6 +365,9 @@ export function createProcessSessions(options) {
|
|
|
131
365
|
if (backend) {
|
|
132
366
|
void backend.kill().catch(() => undefined);
|
|
133
367
|
}
|
|
368
|
+
if (pty) {
|
|
369
|
+
void pty.kill().catch(() => undefined);
|
|
370
|
+
}
|
|
134
371
|
}
|
|
135
372
|
else if (state === "exited" && child) {
|
|
136
373
|
try {
|
|
@@ -152,6 +389,7 @@ export function createProcessSessions(options) {
|
|
|
152
389
|
catch {
|
|
153
390
|
// ignore
|
|
154
391
|
}
|
|
392
|
+
persistTransition(record);
|
|
155
393
|
settleWaiters(record);
|
|
156
394
|
const type = state === "exited"
|
|
157
395
|
? "process_exited"
|
|
@@ -233,6 +471,9 @@ export function createProcessSessions(options) {
|
|
|
233
471
|
exitedAt: record.exitedAt,
|
|
234
472
|
state: record.state,
|
|
235
473
|
releaseOnCancel: record.releaseOnCancel,
|
|
474
|
+
pty: record.ptyTerminal !== undefined,
|
|
475
|
+
terminal: record.ptyTerminal,
|
|
476
|
+
ptyBackendMetadata: record.pty?.metadata,
|
|
236
477
|
};
|
|
237
478
|
},
|
|
238
479
|
async output(request) {
|
|
@@ -260,6 +501,13 @@ export function createProcessSessions(options) {
|
|
|
260
501
|
if (buf.byteLength > limits.maxInputBytes) {
|
|
261
502
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", `input exceeds maxInputBytes (${limits.maxInputBytes})`);
|
|
262
503
|
}
|
|
504
|
+
if (record.pty) {
|
|
505
|
+
if (buf.includes(0)) {
|
|
506
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", "NUL bytes are not permitted in PTY input");
|
|
507
|
+
}
|
|
508
|
+
await record.pty.write(buf);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
263
511
|
if (record.backend) {
|
|
264
512
|
await record.backend.write(buf);
|
|
265
513
|
return;
|
|
@@ -319,6 +567,10 @@ export function createProcessSessions(options) {
|
|
|
319
567
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot signal in state ${record.state}`);
|
|
320
568
|
}
|
|
321
569
|
await assertPolicy("process_signal", record.command, record.args, record.workspace, record.owner);
|
|
570
|
+
if (record.pty) {
|
|
571
|
+
await record.pty.signal(name);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
322
574
|
if (record.backend) {
|
|
323
575
|
await record.backend.signal(name);
|
|
324
576
|
return;
|
|
@@ -345,13 +597,31 @@ export function createProcessSessions(options) {
|
|
|
345
597
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot kill in state ${record.state}`);
|
|
346
598
|
}
|
|
347
599
|
await assertPolicy("process_kill", record.command, record.args, record.workspace, record.owner);
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
600
|
+
const ptyHandle = record.pty;
|
|
601
|
+
const backendHandle = record.backend;
|
|
602
|
+
// Mark terminal synchronously FIRST: a backend kill may resolve the
|
|
603
|
+
// wait() promise, whose handler must not race into a fabricated
|
|
604
|
+
// 'exited' before the kill is recorded. The explicit call below is
|
|
605
|
+
// the existing parity double-kill; its result is ignored.
|
|
606
|
+
if (ptyHandle || backendHandle) {
|
|
607
|
+
terminateRecord(record, "killed", null);
|
|
608
|
+
if (ptyHandle) {
|
|
609
|
+
try {
|
|
610
|
+
await ptyHandle.kill();
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
// still marked killed
|
|
614
|
+
}
|
|
351
615
|
}
|
|
352
|
-
|
|
353
|
-
|
|
616
|
+
else {
|
|
617
|
+
try {
|
|
618
|
+
await backendHandle.kill();
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// still marked killed
|
|
622
|
+
}
|
|
354
623
|
}
|
|
624
|
+
return;
|
|
355
625
|
}
|
|
356
626
|
terminateRecord(record, "killed", null);
|
|
357
627
|
},
|
|
@@ -361,17 +631,67 @@ export function createProcessSessions(options) {
|
|
|
361
631
|
if (record.state !== "running" && record.state !== "starting") {
|
|
362
632
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot release in state ${record.state}`);
|
|
363
633
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
634
|
+
const ptyRelease = record.pty;
|
|
635
|
+
const backendRelease = record.backend;
|
|
636
|
+
if (ptyRelease || backendRelease) {
|
|
637
|
+
terminateRecord(record, "released", null);
|
|
638
|
+
if (ptyRelease) {
|
|
639
|
+
try {
|
|
640
|
+
await ptyRelease.release();
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// still marked released
|
|
644
|
+
}
|
|
367
645
|
}
|
|
368
|
-
|
|
369
|
-
|
|
646
|
+
else {
|
|
647
|
+
try {
|
|
648
|
+
await backendRelease.release();
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
// still marked released
|
|
652
|
+
}
|
|
370
653
|
}
|
|
654
|
+
return;
|
|
371
655
|
}
|
|
372
656
|
terminateRecord(record, "released", null);
|
|
373
657
|
},
|
|
374
658
|
};
|
|
659
|
+
if (ptyResizeCapable) {
|
|
660
|
+
handle.resize = async (dimensions) => {
|
|
661
|
+
assertAttached();
|
|
662
|
+
sweepExpired();
|
|
663
|
+
await checkSandboxAlive();
|
|
664
|
+
if (record.state !== "running") {
|
|
665
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot resize in state ${record.state}`);
|
|
666
|
+
}
|
|
667
|
+
const columns = dimensions.columns;
|
|
668
|
+
const rows = dimensions.rows;
|
|
669
|
+
if (!Number.isSafeInteger(columns) || columns < 1 || columns > limits.maxTerminalColumns) {
|
|
670
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `columns must be 1..${limits.maxTerminalColumns}`);
|
|
671
|
+
}
|
|
672
|
+
if (!Number.isSafeInteger(rows) || rows < 1 || rows > limits.maxTerminalRows) {
|
|
673
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `rows must be 1..${limits.maxTerminalRows}`);
|
|
674
|
+
}
|
|
675
|
+
if (!record.pty || typeof record.pty.resize !== "function") {
|
|
676
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "session does not support resize");
|
|
677
|
+
}
|
|
678
|
+
const now = Date.now();
|
|
679
|
+
record.ptyResizeAt = record.ptyResizeAt.filter((t) => now - t < 60_000);
|
|
680
|
+
if (record.ptyResizeAt.length >= limits.maxTerminalResizesPerMinute) {
|
|
681
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `resize rate exceeds maxTerminalResizesPerMinute (${limits.maxTerminalResizesPerMinute})`);
|
|
682
|
+
}
|
|
683
|
+
await assertPolicy("process_resize", record.command, record.args, record.workspace, record.owner);
|
|
684
|
+
record.ptyResizeAt.push(now);
|
|
685
|
+
try {
|
|
686
|
+
await record.pty.resize({ columns, rows });
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
terminateRecord(record, "unknown", null);
|
|
690
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_BACKEND", "PTY backend resize failed");
|
|
691
|
+
}
|
|
692
|
+
record.ptyTerminal = { ...record.ptyTerminal, columns, rows };
|
|
693
|
+
};
|
|
694
|
+
}
|
|
375
695
|
return handle;
|
|
376
696
|
};
|
|
377
697
|
return {
|
|
@@ -379,12 +699,13 @@ export function createProcessSessions(options) {
|
|
|
379
699
|
assertNotDisposed();
|
|
380
700
|
sweepExpired();
|
|
381
701
|
await checkSandboxAlive();
|
|
382
|
-
if (request.pty) {
|
|
702
|
+
if (request.pty && (!ptyBackend || typeof ptyBackend.startPty !== "function")) {
|
|
383
703
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_UNSUPPORTED", "PTY not supported on this host");
|
|
384
704
|
}
|
|
385
705
|
if (!request.command || typeof request.command !== "string") {
|
|
386
706
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", "command required");
|
|
387
707
|
}
|
|
708
|
+
const ptyTerminal = request.pty ? resolveTerminal(request.terminal) : undefined;
|
|
388
709
|
if (sandbox && typeof sandbox.startProcess !== "function") {
|
|
389
710
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", "sandbox adapter does not support startProcess");
|
|
390
711
|
}
|
|
@@ -427,18 +748,117 @@ export function createProcessSessions(options) {
|
|
|
427
748
|
expiresAt: Date.now() + lifetimeMs,
|
|
428
749
|
state: "starting",
|
|
429
750
|
exitCode: null,
|
|
751
|
+
ptyTerminal,
|
|
752
|
+
ptyResizeAt: [],
|
|
430
753
|
accumulator,
|
|
431
754
|
waiters: [],
|
|
432
755
|
stdinClosed: false,
|
|
433
756
|
handle: null,
|
|
757
|
+
recoveryFencingToken: 0,
|
|
758
|
+
recoveryVersion: 0,
|
|
434
759
|
};
|
|
435
760
|
record.handle = makeHandle(record);
|
|
436
761
|
sessions.set(id, record);
|
|
762
|
+
// Durable recovery: intent is persisted BEFORE spawn. The per-record lease
|
|
763
|
+
// fences replica coordination; the fencing token is stored in the record
|
|
764
|
+
// so every later CAS write is monotonic. On any write/fence failure the
|
|
765
|
+
// start fails closed (record removed, no half-durable process).
|
|
766
|
+
let recoveryLease;
|
|
767
|
+
if (durable) {
|
|
768
|
+
const lease = await acquireRecordLease({
|
|
769
|
+
leases: leases,
|
|
770
|
+
id,
|
|
771
|
+
ownerId: ownerId,
|
|
772
|
+
ttlMs: recoveryLimits.leaseTtlMs,
|
|
773
|
+
ownership: options.ownership,
|
|
774
|
+
signal: request.signal,
|
|
775
|
+
});
|
|
776
|
+
if (!lease) {
|
|
777
|
+
sessions.delete(id);
|
|
778
|
+
throw new ProcessRecoveryError("ERR_PRISM_RECOVERY_FENCE", "recovery lease for new session is held by another replica");
|
|
779
|
+
}
|
|
780
|
+
recoveryLease = { token: lease.token };
|
|
781
|
+
record.recoveryLeaseToken = lease.token;
|
|
782
|
+
record.recoveryFencingToken = lease.fencingToken;
|
|
783
|
+
const intent = buildProcessRecoveryRecord({
|
|
784
|
+
id,
|
|
785
|
+
owner,
|
|
786
|
+
workspace,
|
|
787
|
+
command: request.command,
|
|
788
|
+
args,
|
|
789
|
+
commandFingerprint: commandFingerprint(request.command, args),
|
|
790
|
+
policyDecision,
|
|
791
|
+
startedAt: record.startedAt,
|
|
792
|
+
state: "starting",
|
|
793
|
+
exitCode: null,
|
|
794
|
+
releaseOnCancel: record.releaseOnCancel,
|
|
795
|
+
expiresAt: record.expiresAt,
|
|
796
|
+
...(ptyTerminal !== undefined ? { pty: ptyTerminal } : {}),
|
|
797
|
+
fencingToken: lease.fencingToken,
|
|
798
|
+
});
|
|
799
|
+
const saved = await saveProcessRecoveryRecord({
|
|
800
|
+
checkpoints: checkpoints,
|
|
801
|
+
record: intent,
|
|
802
|
+
expectedVersion: 0,
|
|
803
|
+
version: 1,
|
|
804
|
+
ownership: options.ownership,
|
|
805
|
+
signal: request.signal,
|
|
806
|
+
});
|
|
807
|
+
record.recoveryVersion = saved.version;
|
|
808
|
+
}
|
|
437
809
|
const onData = (buf) => {
|
|
438
|
-
|
|
810
|
+
// PTY hosts can deliver trailing bytes after the session already went
|
|
811
|
+
// terminal (host wait() resolving ahead of the last pty drain); ignore
|
|
812
|
+
// them instead of appending to a finished accumulator.
|
|
813
|
+
if (record.state === "running" || record.state === "starting") {
|
|
814
|
+
accumulator.append(buf);
|
|
815
|
+
}
|
|
439
816
|
};
|
|
440
817
|
try {
|
|
441
|
-
if (
|
|
818
|
+
if (request.pty) {
|
|
819
|
+
try {
|
|
820
|
+
const handle = await withPtyAttachTimeout(ptyBackend.startPty({
|
|
821
|
+
file: request.command,
|
|
822
|
+
args,
|
|
823
|
+
cwd,
|
|
824
|
+
env: request.env,
|
|
825
|
+
columns: ptyTerminal.columns,
|
|
826
|
+
rows: ptyTerminal.rows,
|
|
827
|
+
term: ptyTerminal.term,
|
|
828
|
+
onData,
|
|
829
|
+
}), limits.maxPtyAttachTimeoutMs);
|
|
830
|
+
const metadataJson = handle.metadata !== undefined ? JSON.stringify(handle.metadata) : undefined;
|
|
831
|
+
if (metadataJson !== undefined && Buffer.byteLength(metadataJson, "utf8") > limits.maxPtyBackendMetadataBytes) {
|
|
832
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `pty backend metadata exceeds maxPtyBackendMetadataBytes (${limits.maxPtyBackendMetadataBytes})`);
|
|
833
|
+
}
|
|
834
|
+
if (handle.ref !== undefined)
|
|
835
|
+
record.backendRef = validateBackendRef(handle.ref, recoveryLimits);
|
|
836
|
+
record.pty = handle;
|
|
837
|
+
record.state = "running";
|
|
838
|
+
persistTransition(record);
|
|
839
|
+
void handle
|
|
840
|
+
.wait()
|
|
841
|
+
.then((result) => {
|
|
842
|
+
if (record.state !== "running" && record.state !== "starting")
|
|
843
|
+
return;
|
|
844
|
+
terminateRecord(record, "exited", result.exitCode);
|
|
845
|
+
})
|
|
846
|
+
.catch(() => {
|
|
847
|
+
if (record.state !== "running" && record.state !== "starting")
|
|
848
|
+
return;
|
|
849
|
+
terminateRecord(record, "unknown", null);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
sessions.delete(id);
|
|
854
|
+
if (error instanceof ProcessSessionError)
|
|
855
|
+
throw error;
|
|
856
|
+
if (error instanceof ProcessRecoveryError)
|
|
857
|
+
throw error;
|
|
858
|
+
throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_BACKEND", "PTY backend failed to start");
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
else if (sandbox?.startProcess) {
|
|
442
862
|
const handle = await sandbox.startProcess({
|
|
443
863
|
file: request.command,
|
|
444
864
|
args,
|
|
@@ -446,8 +866,11 @@ export function createProcessSessions(options) {
|
|
|
446
866
|
env: request.env,
|
|
447
867
|
onData,
|
|
448
868
|
});
|
|
869
|
+
if (handle.ref !== undefined)
|
|
870
|
+
record.backendRef = validateBackendRef(handle.ref, recoveryLimits);
|
|
449
871
|
record.backend = handle;
|
|
450
872
|
record.state = "running";
|
|
873
|
+
persistTransition(record);
|
|
451
874
|
void handle
|
|
452
875
|
.wait()
|
|
453
876
|
.then((result) => {
|
|
@@ -489,10 +912,30 @@ export function createProcessSessions(options) {
|
|
|
489
912
|
else
|
|
490
913
|
terminateRecord(record, "exited", code);
|
|
491
914
|
});
|
|
915
|
+
persistTransition(record);
|
|
492
916
|
}
|
|
493
917
|
}
|
|
494
918
|
catch (error) {
|
|
495
919
|
sessions.delete(id);
|
|
920
|
+
if (durable) {
|
|
921
|
+
// No half-durable process: drop the intent/running record and release
|
|
922
|
+
// the recovery lease. The host store is authoritative.
|
|
923
|
+
await deleteProcessRecoveryRecord({ checkpoints: checkpoints, id, ownership: options.ownership, signal: request.signal });
|
|
924
|
+
if (recoveryLease) {
|
|
925
|
+
await releaseRecordLease({
|
|
926
|
+
leases: leases,
|
|
927
|
+
id,
|
|
928
|
+
ownerId: ownerId,
|
|
929
|
+
token: recoveryLease.token,
|
|
930
|
+
ownership: options.ownership,
|
|
931
|
+
signal: request.signal,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (error instanceof ProcessSessionError)
|
|
936
|
+
throw error;
|
|
937
|
+
if (error instanceof ProcessRecoveryError)
|
|
938
|
+
throw error;
|
|
496
939
|
throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", error instanceof Error ? error.message : String(error));
|
|
497
940
|
}
|
|
498
941
|
emit({
|
|
@@ -517,27 +960,95 @@ export function createProcessSessions(options) {
|
|
|
517
960
|
continue;
|
|
518
961
|
if (record.state !== "running" && record.state !== "starting")
|
|
519
962
|
continue;
|
|
963
|
+
const cancelPty = record.pty;
|
|
964
|
+
const cancelBackend = record.backend;
|
|
520
965
|
if (release || record.releaseOnCancel) {
|
|
521
|
-
if (
|
|
522
|
-
|
|
523
|
-
|
|
966
|
+
if (cancelPty || cancelBackend) {
|
|
967
|
+
terminateRecord(record, "released", null);
|
|
968
|
+
if (cancelPty) {
|
|
969
|
+
try {
|
|
970
|
+
await cancelPty.release();
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
// continue
|
|
974
|
+
}
|
|
524
975
|
}
|
|
525
|
-
|
|
526
|
-
|
|
976
|
+
else {
|
|
977
|
+
try {
|
|
978
|
+
await cancelBackend.release();
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
// continue
|
|
982
|
+
}
|
|
527
983
|
}
|
|
528
984
|
}
|
|
529
|
-
|
|
985
|
+
else {
|
|
986
|
+
terminateRecord(record, "released", null);
|
|
987
|
+
}
|
|
530
988
|
}
|
|
531
989
|
else {
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
|
|
990
|
+
if (cancelPty || cancelBackend) {
|
|
991
|
+
terminateRecord(record, "killed", null);
|
|
992
|
+
if (cancelPty) {
|
|
993
|
+
try {
|
|
994
|
+
await cancelPty.kill();
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
// continue
|
|
998
|
+
}
|
|
535
999
|
}
|
|
536
|
-
|
|
537
|
-
|
|
1000
|
+
else {
|
|
1001
|
+
try {
|
|
1002
|
+
await cancelBackend.kill();
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
// continue
|
|
1006
|
+
}
|
|
538
1007
|
}
|
|
539
1008
|
}
|
|
540
|
-
|
|
1009
|
+
else {
|
|
1010
|
+
terminateRecord(record, "killed", null);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Durable pass (plan 026 Task 5): cancellation of a recovered/unattached
|
|
1015
|
+
// process either reaches the attached backend (above) or records unknown —
|
|
1016
|
+
// never a fabricated exit. Lease + CAS guard every mutation so two replicas
|
|
1017
|
+
// cannot cancel the same record into different outcomes.
|
|
1018
|
+
if (durable) {
|
|
1019
|
+
const { records } = await loadProcessRecoveryRecords({
|
|
1020
|
+
checkpoints: checkpoints,
|
|
1021
|
+
limits: recoveryLimits,
|
|
1022
|
+
ownership: options.ownership,
|
|
1023
|
+
});
|
|
1024
|
+
for (const { record, version } of records) {
|
|
1025
|
+
if (record.owner !== owner)
|
|
1026
|
+
continue;
|
|
1027
|
+
if (isTerminalState(record.state))
|
|
1028
|
+
continue;
|
|
1029
|
+
if (sessions.has(record.id))
|
|
1030
|
+
continue; // handled by the live pass above
|
|
1031
|
+
const lease = await acquireRecordLease({
|
|
1032
|
+
leases: leases,
|
|
1033
|
+
id: record.id,
|
|
1034
|
+
ownerId: ownerId,
|
|
1035
|
+
ttlMs: recoveryLimits.leaseTtlMs,
|
|
1036
|
+
ownership: options.ownership,
|
|
1037
|
+
});
|
|
1038
|
+
if (!lease)
|
|
1039
|
+
continue; // another replica owns or is recovering it
|
|
1040
|
+
try {
|
|
1041
|
+
await persistRecoveryUnknown(record, version);
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
await releaseRecordLease({
|
|
1045
|
+
leases: leases,
|
|
1046
|
+
id: record.id,
|
|
1047
|
+
ownerId: ownerId,
|
|
1048
|
+
token: lease.token,
|
|
1049
|
+
ownership: options.ownership,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
541
1052
|
}
|
|
542
1053
|
}
|
|
543
1054
|
},
|
|
@@ -553,12 +1064,180 @@ export function createProcessSessions(options) {
|
|
|
553
1064
|
const markedUnknown = reconcileAllUnknown();
|
|
554
1065
|
return { markedUnknown };
|
|
555
1066
|
},
|
|
1067
|
+
async recover(recoverOptions) {
|
|
1068
|
+
assertNotDisposed();
|
|
1069
|
+
if (!durable) {
|
|
1070
|
+
throw new ProcessRecoveryError("ERR_PRISM_RECOVERY_UNSUPPORTED", "durable process recovery is not configured on this host");
|
|
1071
|
+
}
|
|
1072
|
+
sweepExpired();
|
|
1073
|
+
const { records } = await loadProcessRecoveryRecords({
|
|
1074
|
+
checkpoints: checkpoints,
|
|
1075
|
+
limits: recoveryLimits,
|
|
1076
|
+
ownership: options.ownership,
|
|
1077
|
+
signal: recoverOptions?.signal,
|
|
1078
|
+
});
|
|
1079
|
+
const report = [];
|
|
1080
|
+
let attached = 0;
|
|
1081
|
+
let terminal = 0;
|
|
1082
|
+
let unknown = 0;
|
|
1083
|
+
for (const { record } of records) {
|
|
1084
|
+
const live = sessions.get(record.id);
|
|
1085
|
+
if (live) {
|
|
1086
|
+
// Already live in this registry: nothing to do, report the truth.
|
|
1087
|
+
report.push({ id: record.id, outcome: "attached", state: live.state, exitCode: live.exitCode });
|
|
1088
|
+
if (live.state === "running" || live.state === "starting")
|
|
1089
|
+
attached += 1;
|
|
1090
|
+
else
|
|
1091
|
+
terminal += 1;
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (record.state === "exited" ||
|
|
1095
|
+
record.state === "killed" ||
|
|
1096
|
+
record.state === "released" ||
|
|
1097
|
+
record.state === "expired" ||
|
|
1098
|
+
record.state === "unknown") {
|
|
1099
|
+
report.push({ id: record.id, outcome: "terminal", state: record.state, exitCode: record.exitCode });
|
|
1100
|
+
terminal += 1;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
// starting | running durable record without a live handle: attach-if-
|
|
1104
|
+
// attested, else atomic unknown. Never fabricate an exit code.
|
|
1105
|
+
const lease = await acquireRecordLease({
|
|
1106
|
+
leases: leases,
|
|
1107
|
+
id: record.id,
|
|
1108
|
+
ownerId: ownerId,
|
|
1109
|
+
ttlMs: recoveryLimits.leaseTtlMs,
|
|
1110
|
+
ownership: options.ownership,
|
|
1111
|
+
signal: recoverOptions?.signal,
|
|
1112
|
+
});
|
|
1113
|
+
if (!lease) {
|
|
1114
|
+
// Another replica owns or is recovering this record.
|
|
1115
|
+
report.push({ id: record.id, outcome: "unknown", state: record.state, exitCode: null });
|
|
1116
|
+
unknown += 1;
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
try {
|
|
1120
|
+
// Fresh CAS read under the lease: another replica may have moved the record.
|
|
1121
|
+
const fresh = await loadProcessRecoveryRecord({
|
|
1122
|
+
checkpoints: checkpoints,
|
|
1123
|
+
id: record.id,
|
|
1124
|
+
limits: recoveryLimits,
|
|
1125
|
+
ownership: options.ownership,
|
|
1126
|
+
signal: recoverOptions?.signal,
|
|
1127
|
+
});
|
|
1128
|
+
if (!fresh) {
|
|
1129
|
+
report.push({ id: record.id, outcome: "terminal", state: "unknown", exitCode: null, error: "ERR_PRISM_RECOVERY_UNKNOWN" });
|
|
1130
|
+
terminal += 1;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const current = fresh.record;
|
|
1134
|
+
if (current.state === "exited" ||
|
|
1135
|
+
current.state === "killed" ||
|
|
1136
|
+
current.state === "released" ||
|
|
1137
|
+
current.state === "expired" ||
|
|
1138
|
+
current.state === "unknown") {
|
|
1139
|
+
report.push({ id: current.id, outcome: "terminal", state: current.state, exitCode: current.exitCode });
|
|
1140
|
+
terminal += 1;
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (Date.now() >= current.expiresAt) {
|
|
1144
|
+
// Lifetime lapsed while unrecovered: persist the expiry.
|
|
1145
|
+
const expired = buildProcessRecoveryRecord({
|
|
1146
|
+
...current,
|
|
1147
|
+
state: "expired",
|
|
1148
|
+
exitCode: null,
|
|
1149
|
+
updatedAt: nowIso(),
|
|
1150
|
+
});
|
|
1151
|
+
await saveProcessRecoveryRecord({
|
|
1152
|
+
checkpoints: checkpoints,
|
|
1153
|
+
record: expired,
|
|
1154
|
+
expectedVersion: fresh.version,
|
|
1155
|
+
version: fresh.version + 1,
|
|
1156
|
+
ownership: options.ownership,
|
|
1157
|
+
signal: recoverOptions?.signal,
|
|
1158
|
+
});
|
|
1159
|
+
report.push({ id: current.id, outcome: "terminal", state: "expired", exitCode: null });
|
|
1160
|
+
terminal += 1;
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
const active = [...sessions.values()].filter((s) => s.state === "running" || s.state === "starting").length;
|
|
1164
|
+
if (active >= limits.maxSessions) {
|
|
1165
|
+
// Cannot admit another live session: record unknown (fails closed).
|
|
1166
|
+
await persistRecoveryUnknown(current, fresh.version, recoverOptions?.signal);
|
|
1167
|
+
report.push({ id: current.id, outcome: "unknown", state: "unknown", exitCode: null });
|
|
1168
|
+
unknown += 1;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
let attachErrorCode;
|
|
1172
|
+
if (current.backendRef !== undefined && recoveryBackend) {
|
|
1173
|
+
try {
|
|
1174
|
+
const handle = await attachWithTimeout(recoveryBackend, current.backendRef, recoveryLimits.attachTimeoutMs);
|
|
1175
|
+
if (handle) {
|
|
1176
|
+
attachRecoveredHandle(current, handle, fresh.version);
|
|
1177
|
+
attached += 1;
|
|
1178
|
+
report.push({ id: current.id, outcome: "attached", state: "running", exitCode: null });
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
catch (attachError) {
|
|
1183
|
+
attachErrorCode = attachError instanceof ProcessRecoveryError ? attachError.code : "ERR_PRISM_RECOVERY_UNKNOWN";
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// No ref, no backend, unattested attach, or attach failure: atomic unknown.
|
|
1187
|
+
const saved = await persistRecoveryUnknown(current, fresh.version, recoverOptions?.signal);
|
|
1188
|
+
if (!saved) {
|
|
1189
|
+
// CAS/fence conflict: another replica moved the record; re-report its state.
|
|
1190
|
+
const again = await loadProcessRecoveryRecord({
|
|
1191
|
+
checkpoints: checkpoints,
|
|
1192
|
+
id: current.id,
|
|
1193
|
+
limits: recoveryLimits,
|
|
1194
|
+
ownership: options.ownership,
|
|
1195
|
+
signal: recoverOptions?.signal,
|
|
1196
|
+
});
|
|
1197
|
+
const reported = again?.record;
|
|
1198
|
+
report.push({
|
|
1199
|
+
id: current.id,
|
|
1200
|
+
outcome: reported && isTerminalState(reported.state) ? "terminal" : "unknown",
|
|
1201
|
+
state: reported?.state ?? "unknown",
|
|
1202
|
+
exitCode: reported?.exitCode ?? null,
|
|
1203
|
+
error: attachErrorCode,
|
|
1204
|
+
});
|
|
1205
|
+
if (reported && isTerminalState(reported.state))
|
|
1206
|
+
terminal += 1;
|
|
1207
|
+
else
|
|
1208
|
+
unknown += 1;
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
report.push({ id: current.id, outcome: "unknown", state: "unknown", exitCode: null, error: attachErrorCode });
|
|
1212
|
+
unknown += 1;
|
|
1213
|
+
}
|
|
1214
|
+
finally {
|
|
1215
|
+
await releaseRecordLease({
|
|
1216
|
+
leases: leases,
|
|
1217
|
+
id: record.id,
|
|
1218
|
+
ownerId: ownerId,
|
|
1219
|
+
token: lease.token,
|
|
1220
|
+
ownership: options.ownership,
|
|
1221
|
+
signal: recoverOptions?.signal,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return { records: report, attached, terminal, unknown };
|
|
1226
|
+
},
|
|
556
1227
|
async dispose() {
|
|
557
1228
|
if (disposed)
|
|
558
1229
|
return;
|
|
559
1230
|
disposed = true;
|
|
560
1231
|
for (const record of [...sessions.values()]) {
|
|
561
1232
|
if (record.state === "running" || record.state === "starting") {
|
|
1233
|
+
if (record.pty) {
|
|
1234
|
+
try {
|
|
1235
|
+
await record.pty.kill();
|
|
1236
|
+
}
|
|
1237
|
+
catch {
|
|
1238
|
+
// best effort
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
562
1241
|
if (record.backend) {
|
|
563
1242
|
try {
|
|
564
1243
|
await record.backend.kill();
|
|
@@ -584,6 +1263,16 @@ export function createProcessSessions(options) {
|
|
|
584
1263
|
catch {
|
|
585
1264
|
// best effort
|
|
586
1265
|
}
|
|
1266
|
+
if (record.recoveryLeaseToken) {
|
|
1267
|
+
void releaseRecordLease({
|
|
1268
|
+
leases: leases,
|
|
1269
|
+
id: record.id,
|
|
1270
|
+
ownerId: ownerId,
|
|
1271
|
+
token: record.recoveryLeaseToken,
|
|
1272
|
+
ownership: options.ownership,
|
|
1273
|
+
});
|
|
1274
|
+
record.recoveryLeaseToken = undefined;
|
|
1275
|
+
}
|
|
587
1276
|
}
|
|
588
1277
|
sessions.clear();
|
|
589
1278
|
},
|