@wjarka/cezarion 0.14.9-dev.915 → 0.14.9-dev.936
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/dist/ci-wait/controller.d.ts +12 -12
- package/dist/core/provider-auth.d.ts +99 -1
- package/dist/core/provider-auth.js +203 -2
- package/dist/core/provider-auth.js.map +1 -1
- package/dist/delegation/process-liveness.d.ts +66 -0
- package/dist/delegation/process-liveness.js +215 -0
- package/dist/delegation/process-liveness.js.map +1 -0
- package/dist/delegation/routes.d.ts +54 -54
- package/dist/delegation/service.d.ts +2 -0
- package/dist/delegation/service.js +15 -3
- package/dist/delegation/service.js.map +1 -1
- package/dist/runs/agent-tmpdir.d.ts +2 -0
- package/dist/runs/agent-tmpdir.js +5 -2
- package/dist/runs/agent-tmpdir.js.map +1 -1
- package/dist/runs/store.d.ts +10 -0
- package/dist/runs/store.js +76 -0
- package/dist/runs/store.js.map +1 -1
- package/dist/server/provider-auth-runtime.d.ts +30 -4
- package/dist/server/provider-auth-runtime.js +71 -6
- package/dist/server/provider-auth-runtime.js.map +1 -1
- package/dist/server/server.d.ts +1221 -1221
- package/dist/workflows/run.d.ts +55 -1
- package/dist/workflows/run.js +235 -10
- package/dist/workflows/run.js.map +1 -1
- package/dist/workspace/agent-profiles.d.ts +22 -0
- package/dist/workspace/agent-profiles.js +15 -0
- package/dist/workspace/agent-profiles.js.map +1 -1
- package/package.json +3 -3
package/dist/workflows/run.d.ts
CHANGED
|
@@ -219,6 +219,16 @@ export declare function pastedAttachmentsNote(attachments: PersistedAttachment[]
|
|
|
219
219
|
export declare const VARIANT_LETTERS: readonly ['A', 'B', 'C'];
|
|
220
220
|
/** Only an original workflow that has never executed may return to its queue. */
|
|
221
221
|
export declare function isUntouchedCancelledRun(run: RunRecord): boolean;
|
|
222
|
+
/** #469: why destroy could not prove a crashed generation's termination. */
|
|
223
|
+
export type WorkerTerminationBlocker = {
|
|
224
|
+
kind: 'unreadable';
|
|
225
|
+
} | {
|
|
226
|
+
kind: 'controller';
|
|
227
|
+
pid: number;
|
|
228
|
+
} | {
|
|
229
|
+
kind: 'processes';
|
|
230
|
+
pids: number[];
|
|
231
|
+
};
|
|
222
232
|
/**
|
|
223
233
|
* The mini workflow engine: executes a `WorkflowDef` against a repo, one step
|
|
224
234
|
* at a time, persisting every event to the RunStore (which the SSE endpoints
|
|
@@ -248,9 +258,53 @@ export declare class RunManager {
|
|
|
248
258
|
* exception or an interrupt request cannot stand in for session termination. */
|
|
249
259
|
private trackWorkerSessionResult;
|
|
250
260
|
private persistWorkerCompletion;
|
|
261
|
+
/** #469: the generation a dead controller left `starting`, if this manager may judge it. A
|
|
262
|
+
* generation this process controls is never judged here: the in-memory execution map owns it,
|
|
263
|
+
* and a disposed manager's still-running sessions look like orphans. `busy` is transient (this
|
|
264
|
+
* manager holds the run); `unknown` is a present record that proves nothing. */
|
|
265
|
+
private orphanState;
|
|
266
|
+
private orphanedWorkerGeneration;
|
|
267
|
+
/** Completes a crashed generation's proof once every one of its processes is proven gone (#469).
|
|
268
|
+
* Sync and signal-free; recovery, resume, delivery, collect and destroy then take their normal paths.
|
|
269
|
+
* A non-gone probe is cached briefly: on darwin the scan is a synchronous `lsof` on the event loop. */
|
|
270
|
+
settleOrphanedWorkerExecution(runId: string, opts?: {
|
|
271
|
+
admitting?: boolean;
|
|
272
|
+
fresh?: boolean;
|
|
273
|
+
}): boolean;
|
|
274
|
+
private readonly orphanProbes;
|
|
275
|
+
private readonly orphanReprobes;
|
|
276
|
+
private readonly orphanBlockers;
|
|
277
|
+
private readonly reportedOrphanBlockers;
|
|
278
|
+
private orphanReprobeMs;
|
|
279
|
+
private orphanReprobeLimitMs;
|
|
280
|
+
private orphanReprobeSlowMs;
|
|
281
|
+
private orphanTermGraceMs;
|
|
282
|
+
/** #469: a survivor that dies after recovery has no other wake source (no exit callback for a
|
|
283
|
+
* process another cezar spawned). Unref'd; finalization's `run` event then lets worker waits and
|
|
284
|
+
* outcomes observe it. Fast for the first window, then slow but uncapped, so a long-lived
|
|
285
|
+
* survivor is still noticed when it exits. A tick while this manager holds the run is skipped;
|
|
286
|
+
* only terminal reasons (proof settled, generation changed, run gone, unknown record, dispose)
|
|
287
|
+
* stop it. */
|
|
288
|
+
private armOrphanReprobe;
|
|
289
|
+
private clearOrphanReprobe;
|
|
290
|
+
/** Why the last destroy could not prove termination (#469), and whether that differs from the
|
|
291
|
+
* reason last reported, so a retry loop appends one lifecycle event per distinct blocker set. */
|
|
292
|
+
takeWorkerTerminationBlocker(runId: string): {
|
|
293
|
+
blocker: WorkerTerminationBlocker;
|
|
294
|
+
changed: boolean;
|
|
295
|
+
} | undefined;
|
|
296
|
+
/** Continue over a live orphan names what still runs; other refusals keep the generic text. */
|
|
297
|
+
private orphanAdmissionRefusal;
|
|
298
|
+
/** Destroy only (#469): signal the recorded, token-verified survivors of a generation whose
|
|
299
|
+
* controller is proven dead, then finalize on `gone`. Scan-only processes are never signalled. */
|
|
300
|
+
private reapOrphanedWorker;
|
|
301
|
+
/** Best effort: a failed write leaves the working-directory scan as this process's evidence. */
|
|
302
|
+
private recordWorkerProcess;
|
|
251
303
|
private trackTurnEnd;
|
|
252
304
|
requestWorkerStop(runId: string): WorkerStopResult;
|
|
253
|
-
awaitRunTermination(runId: string, timeoutMs: number
|
|
305
|
+
awaitRunTermination(runId: string, timeoutMs: number, opts?: {
|
|
306
|
+
reapOrphans?: boolean;
|
|
307
|
+
}): Promise<boolean>;
|
|
254
308
|
/** Private cleanup capability, never serialized. Obtain after stop/termination
|
|
255
309
|
* under the service's destroy lock; each invocation rechecks the same generation. */
|
|
256
310
|
getWorkerNoMaterializationProof(runId: string): WorkerNoMaterializationProof | undefined;
|
package/dist/workflows/run.js
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { parseAskMarkerResult, stripAskMarker, } from '../core/ask.js';
|
|
11
11
|
import { hasRegisteredRunProcess, onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
|
|
12
|
+
import { inspectGeneration, isCurrentProcess, processStartToken, recordedProcessLive } from '../delegation/process-liveness.js';
|
|
12
13
|
import { parseUsageLimit } from '../core/usage-limit.js';
|
|
13
14
|
import { createRunner } from '../core/runner-factory.js';
|
|
14
15
|
import { modelConflictsWithRunner } from '../core/model-presets.js';
|
|
@@ -41,7 +42,7 @@ import { answersQuestion, openQuestions, questionMessage } from '../delegation/q
|
|
|
41
42
|
import { workerOutcome } from '../runs/delegation-state.js';
|
|
42
43
|
import { loadWorkflows } from './load.js';
|
|
43
44
|
import { reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
|
|
44
|
-
import { AgentTempDirError, agentTmpEnv, removeAgentTmpDir, sweepAgentTmpDirs, } from '../runs/agent-tmpdir.js';
|
|
45
|
+
import { AgentTempDirError, agentTmpEnv, agentTmpDirLocations, removeAgentTmpDir, sweepAgentTmpDirs, } from '../runs/agent-tmpdir.js';
|
|
45
46
|
import { extractTaskRefs, refineTaskRefs, titleRefNumber } from '../runs/task-refs.js';
|
|
46
47
|
import { parseTaskMarkers, stripTaskMarkers } from '../runs/task-markers.js';
|
|
47
48
|
import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled, postValidateTitle } from '../runs/auto-name.js';
|
|
@@ -426,6 +427,12 @@ export function isUntouchedCancelledRun(run) {
|
|
|
426
427
|
return run.status === 'cancelled' && !run.startedAt && !!run.workflowDef &&
|
|
427
428
|
run.steps.every(step => step.status === 'pending' && !step.startedAt && !step.sessionId);
|
|
428
429
|
}
|
|
430
|
+
/** #469: how long a non-gone orphan probe stands before the next scan. */
|
|
431
|
+
const ORPHAN_PROBE_CACHE_MS = 2_000;
|
|
432
|
+
/** #469: admission refused because a process of the previous generation still runs. */
|
|
433
|
+
class WorkerOrphanAliveError extends Error {
|
|
434
|
+
}
|
|
435
|
+
const admissionError = (error, fallback) => error instanceof WorkerOrphanAliveError ? error.message : fallback;
|
|
429
436
|
/**
|
|
430
437
|
* The mini workflow engine: executes a `WorkflowDef` against a repo, one step
|
|
431
438
|
* at a time, persisting every event to the RunStore (which the SSE endpoints
|
|
@@ -455,6 +462,13 @@ export class RunManager {
|
|
|
455
462
|
beginWorkerExecution(runId, admitted = true) {
|
|
456
463
|
if (this.store.getRun(runId)?.delegation?.role !== 'worker')
|
|
457
464
|
return;
|
|
465
|
+
// #469: one site for Continue, --resume, parent replies and queued revival after a crash.
|
|
466
|
+
// Fresh: admission is one-shot, so a cached "alive" must not refuse an orphan that has since died.
|
|
467
|
+
if (!this.executions.has(runId))
|
|
468
|
+
this.settleOrphanedWorkerExecution(runId, { admitting: true, fresh: true });
|
|
469
|
+
const refusal = this.executions.has(runId) ? undefined : this.orphanAdmissionRefusal(runId);
|
|
470
|
+
if (refusal)
|
|
471
|
+
throw new WorkerOrphanAliveError(refusal);
|
|
458
472
|
const existing = this.executions.get(runId);
|
|
459
473
|
if (existing) {
|
|
460
474
|
const proof = this.store.readWorkerExecution(runId);
|
|
@@ -469,6 +483,9 @@ export class RunManager {
|
|
|
469
483
|
const promise = new Promise(done => { resolve = done; });
|
|
470
484
|
this.executions.set(runId, { generation, admitted, promise, resolve, sessions: new Set(), turns: new Set(), deliveries: new Set() });
|
|
471
485
|
this.finalizedWorkers.delete(runId);
|
|
486
|
+
// #469: an earlier generation's destroy blocker never describes this one.
|
|
487
|
+
this.orphanBlockers.delete(runId);
|
|
488
|
+
this.reportedOrphanBlockers.delete(runId);
|
|
472
489
|
this.stoppedWorkers.delete(runId);
|
|
473
490
|
}
|
|
474
491
|
async finishWorkerExecution(runId) {
|
|
@@ -518,6 +535,196 @@ export class RunManager {
|
|
|
518
535
|
}
|
|
519
536
|
catch { /* Finalized in this process; preserve exact generation for an explicit retry. */ }
|
|
520
537
|
}
|
|
538
|
+
/** #469: the generation a dead controller left `starting`, if this manager may judge it. A
|
|
539
|
+
* generation this process controls is never judged here: the in-memory execution map owns it,
|
|
540
|
+
* and a disposed manager's still-running sessions look like orphans. `busy` is transient (this
|
|
541
|
+
* manager holds the run); `unknown` is a present record that proves nothing. */
|
|
542
|
+
orphanState(runId, admitting = false) {
|
|
543
|
+
const run = this.store.getRun(runId);
|
|
544
|
+
if (run?.delegation?.role !== 'worker' || this.disposed)
|
|
545
|
+
return { state: 'none' };
|
|
546
|
+
if (this.executions.has(runId) || this.active.has(runId) || this.starting.has(runId) || (!admitting && this.queue.includes(runId)))
|
|
547
|
+
return { state: 'busy' };
|
|
548
|
+
const proof = this.store.readWorkerExecution(runId);
|
|
549
|
+
if (proof?.phase !== 'starting')
|
|
550
|
+
return { state: 'none' };
|
|
551
|
+
const record = this.store.readWorkerProcesses(runId, proof.generation);
|
|
552
|
+
// A present record that proves nothing (unreadable, malformed, another generation) blocks both
|
|
553
|
+
// finalization and reaping; only an absent one is legacy, scan-only evidence.
|
|
554
|
+
if (record === 'unknown')
|
|
555
|
+
return { state: 'unknown' };
|
|
556
|
+
if (record !== 'absent' && isCurrentProcess(record.controller))
|
|
557
|
+
return { state: 'none' };
|
|
558
|
+
// Finalization deletes the scratch too, so a process working there keeps the generation alive.
|
|
559
|
+
return { state: 'orphan', generation: proof.generation, ...(record === 'absent' ? {} : { record }),
|
|
560
|
+
paths: [run.delegation.workspace.path, ...agentTmpDirLocations(this.dataDir, runId)],
|
|
561
|
+
// No process of this worker can predate its record (1 s slack for tick rounding).
|
|
562
|
+
...(Number.isFinite(Date.parse(run.createdAt)) ? { since: Date.parse(run.createdAt) - 1_000 } : {}) };
|
|
563
|
+
}
|
|
564
|
+
orphanedWorkerGeneration(runId, admitting = false) {
|
|
565
|
+
const orphan = this.orphanState(runId, admitting);
|
|
566
|
+
return orphan.state === 'orphan' ? orphan : undefined;
|
|
567
|
+
}
|
|
568
|
+
/** Completes a crashed generation's proof once every one of its processes is proven gone (#469).
|
|
569
|
+
* Sync and signal-free; recovery, resume, delivery, collect and destroy then take their normal paths.
|
|
570
|
+
* A non-gone probe is cached briefly: on darwin the scan is a synchronous `lsof` on the event loop. */
|
|
571
|
+
settleOrphanedWorkerExecution(runId, opts = {}) {
|
|
572
|
+
const orphan = this.orphanedWorkerGeneration(runId, opts.admitting);
|
|
573
|
+
if (!orphan)
|
|
574
|
+
return false;
|
|
575
|
+
const cached = this.orphanProbes.get(runId);
|
|
576
|
+
if (!opts.fresh && cached?.generation === orphan.generation && Date.now() - cached.at < ORPHAN_PROBE_CACHE_MS)
|
|
577
|
+
return false;
|
|
578
|
+
const probe = inspectGeneration(orphan);
|
|
579
|
+
if (probe.liveness !== 'gone') {
|
|
580
|
+
this.orphanProbes.set(runId, { generation: orphan.generation, at: Date.now(), probe });
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
// A stale `alive` must not outlive a `gone` probe, even when the commit below fails.
|
|
584
|
+
this.orphanProbes.delete(runId);
|
|
585
|
+
try {
|
|
586
|
+
if (!this.store.commitWorkerExecutionComplete(runId, orphan.generation))
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
this.orphanProbes.delete(runId);
|
|
593
|
+
this.orphanBlockers.delete(runId);
|
|
594
|
+
this.reportedOrphanBlockers.delete(runId);
|
|
595
|
+
this.clearOrphanReprobe(runId);
|
|
596
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: "the interrupted worker's processes are gone; its execution was finalized" });
|
|
597
|
+
removeAgentTmpDir(this.dataDir, runId);
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
orphanProbes = new Map();
|
|
601
|
+
orphanReprobes = new Map();
|
|
602
|
+
orphanBlockers = new Map();
|
|
603
|
+
reportedOrphanBlockers = new Map();
|
|
604
|
+
// Private and overridable so tests need not wait out production cadence.
|
|
605
|
+
orphanReprobeMs = 15_000;
|
|
606
|
+
orphanReprobeLimitMs = 15 * 60_000;
|
|
607
|
+
orphanReprobeSlowMs = 60_000;
|
|
608
|
+
orphanTermGraceMs = 10_000;
|
|
609
|
+
/** #469: a survivor that dies after recovery has no other wake source (no exit callback for a
|
|
610
|
+
* process another cezar spawned). Unref'd; finalization's `run` event then lets worker waits and
|
|
611
|
+
* outcomes observe it. Fast for the first window, then slow but uncapped, so a long-lived
|
|
612
|
+
* survivor is still noticed when it exits. A tick while this manager holds the run is skipped;
|
|
613
|
+
* only terminal reasons (proof settled, generation changed, run gone, unknown record, dispose)
|
|
614
|
+
* stop it. */
|
|
615
|
+
armOrphanReprobe(runId) {
|
|
616
|
+
const armed = this.orphanedWorkerGeneration(runId);
|
|
617
|
+
if (this.disposed || this.orphanReprobes.has(runId) || !armed)
|
|
618
|
+
return;
|
|
619
|
+
const slowAfter = Date.now() + this.orphanReprobeLimitMs;
|
|
620
|
+
const schedule = () => {
|
|
621
|
+
const timer = setTimeout(() => {
|
|
622
|
+
const orphan = this.orphanState(runId);
|
|
623
|
+
if (orphan.state === 'busy')
|
|
624
|
+
return schedule();
|
|
625
|
+
if (orphan.state !== 'orphan' || orphan.generation !== armed.generation || this.settleOrphanedWorkerExecution(runId))
|
|
626
|
+
this.clearOrphanReprobe(runId);
|
|
627
|
+
else
|
|
628
|
+
schedule();
|
|
629
|
+
}, Date.now() < slowAfter ? this.orphanReprobeMs : this.orphanReprobeSlowMs);
|
|
630
|
+
timer.unref?.();
|
|
631
|
+
this.orphanReprobes.set(runId, timer);
|
|
632
|
+
};
|
|
633
|
+
schedule();
|
|
634
|
+
}
|
|
635
|
+
clearOrphanReprobe(runId) {
|
|
636
|
+
const timer = this.orphanReprobes.get(runId);
|
|
637
|
+
if (timer)
|
|
638
|
+
clearTimeout(timer);
|
|
639
|
+
this.orphanReprobes.delete(runId);
|
|
640
|
+
}
|
|
641
|
+
/** Why the last destroy could not prove termination (#469), and whether that differs from the
|
|
642
|
+
* reason last reported, so a retry loop appends one lifecycle event per distinct blocker set. */
|
|
643
|
+
takeWorkerTerminationBlocker(runId) {
|
|
644
|
+
const blocker = this.orphanBlockers.get(runId);
|
|
645
|
+
if (!blocker)
|
|
646
|
+
return undefined;
|
|
647
|
+
const key = JSON.stringify(blocker);
|
|
648
|
+
const changed = this.reportedOrphanBlockers.get(runId) !== key;
|
|
649
|
+
this.reportedOrphanBlockers.set(runId, key);
|
|
650
|
+
return { blocker, changed };
|
|
651
|
+
}
|
|
652
|
+
/** Continue over a live orphan names what still runs; other refusals keep the generic text. */
|
|
653
|
+
orphanAdmissionRefusal(runId) {
|
|
654
|
+
const orphan = this.orphanedWorkerGeneration(runId, true);
|
|
655
|
+
const probe = orphan && this.orphanProbes.get(runId);
|
|
656
|
+
if (!probe || probe.generation !== orphan.generation)
|
|
657
|
+
return undefined;
|
|
658
|
+
if (probe.probe.controller !== undefined)
|
|
659
|
+
return `the worker is still controlled by a live cezar (pid ${probe.probe.controller})`;
|
|
660
|
+
const pid = probe.probe.pids[0];
|
|
661
|
+
return pid === undefined ? undefined : `a process of the previous execution is still running (pid ${pid})`;
|
|
662
|
+
}
|
|
663
|
+
/** Destroy only (#469): signal the recorded, token-verified survivors of a generation whose
|
|
664
|
+
* controller is proven dead, then finalize on `gone`. Scan-only processes are never signalled. */
|
|
665
|
+
async reapOrphanedWorker(runId, timeoutMs) {
|
|
666
|
+
const deadline = Date.now() + Math.min(30_000, Math.max(0, Number.isFinite(timeoutMs) ? timeoutMs : 0));
|
|
667
|
+
this.orphanBlockers.delete(runId);
|
|
668
|
+
const initial = this.orphanState(runId);
|
|
669
|
+
if (initial.state === 'unknown') {
|
|
670
|
+
this.orphanBlockers.set(runId, { kind: 'unreadable' });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const orphan = this.orphanedWorkerGeneration(runId);
|
|
674
|
+
if (!orphan || this.settleOrphanedWorkerExecution(runId, { fresh: true }))
|
|
675
|
+
return;
|
|
676
|
+
const same = () => this.orphanedWorkerGeneration(runId)?.generation === orphan.generation;
|
|
677
|
+
// >= 500 ms: every probe may be a synchronous darwin `lsof`.
|
|
678
|
+
const pause = () => new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(500, deadline - Date.now()))));
|
|
679
|
+
// A live controller is another cezar's: nothing to signal or wait for. Otherwise signal only
|
|
680
|
+
// recorded, token-verified survivors (none for a legacy record-less generation), then wait
|
|
681
|
+
// out every other holder until the deadline; the scan's processes are never signalled.
|
|
682
|
+
if (!orphan.record || !recordedProcessLive(orphan.record.controller)) {
|
|
683
|
+
if (orphan.record) {
|
|
684
|
+
const targets = orphan.record.processes.filter(entry => entry.startToken !== undefined && recordedProcessLive(entry));
|
|
685
|
+
const signal = (name) => {
|
|
686
|
+
for (const entry of targets) {
|
|
687
|
+
if (!same())
|
|
688
|
+
return;
|
|
689
|
+
// Re-verified immediately before every signal, exactly: a reused PID is never touched.
|
|
690
|
+
if (processStartToken(entry.pid) === entry.startToken)
|
|
691
|
+
try {
|
|
692
|
+
process.kill(entry.pid, name);
|
|
693
|
+
}
|
|
694
|
+
catch { /* already gone */ }
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
signal('SIGTERM');
|
|
698
|
+
const killAt = Math.min(deadline, Date.now() + this.orphanTermGraceMs);
|
|
699
|
+
while (Date.now() < killAt && targets.some(recordedProcessLive))
|
|
700
|
+
await pause();
|
|
701
|
+
if (targets.some(recordedProcessLive))
|
|
702
|
+
signal('SIGKILL');
|
|
703
|
+
}
|
|
704
|
+
while (!this.settleOrphanedWorkerExecution(runId, { fresh: true }) && Date.now() < deadline && same())
|
|
705
|
+
await pause();
|
|
706
|
+
}
|
|
707
|
+
if (!same())
|
|
708
|
+
return;
|
|
709
|
+
const probe = this.orphanProbes.get(runId)?.probe;
|
|
710
|
+
if (probe?.controller !== undefined)
|
|
711
|
+
this.orphanBlockers.set(runId, { kind: 'controller', pid: probe.controller });
|
|
712
|
+
else if (probe?.pids.length)
|
|
713
|
+
this.orphanBlockers.set(runId, { kind: 'processes', pids: probe.pids });
|
|
714
|
+
}
|
|
715
|
+
/** Best effort: a failed write leaves the working-directory scan as this process's evidence. */
|
|
716
|
+
recordWorkerProcess(runId, pid) {
|
|
717
|
+
const generation = this.executions.get(runId)?.generation;
|
|
718
|
+
if (!generation)
|
|
719
|
+
return;
|
|
720
|
+
try {
|
|
721
|
+
if (!this.store.appendWorkerProcess(runId, generation, pid))
|
|
722
|
+
console.warn(`[cez] worker ${runId} process record unavailable; relying on the working-directory scan`);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
console.warn(`[cez] worker ${runId} process record write failed; relying on the working-directory scan`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
521
728
|
trackTurnEnd(runId, text) {
|
|
522
729
|
const task = this.recordTurnEnd(runId, text);
|
|
523
730
|
const turns = this.executions.get(runId)?.turns;
|
|
@@ -532,12 +739,16 @@ export class RunManager {
|
|
|
532
739
|
this.cancel(runId);
|
|
533
740
|
return { workerId: runId, state: !this.executions.has(runId) && this.store.readWorkerExecution(runId)?.phase === 'complete' ? 'terminated' : 'stopping' };
|
|
534
741
|
}
|
|
535
|
-
async awaitRunTermination(runId, timeoutMs) {
|
|
742
|
+
async awaitRunTermination(runId, timeoutMs, opts = {}) {
|
|
536
743
|
const execution = this.executions.get(runId);
|
|
537
744
|
if (!execution) {
|
|
538
745
|
const generation = this.finalizedWorkers.get(runId);
|
|
539
746
|
if (!this.disposed && generation)
|
|
540
747
|
this.persistWorkerCompletion(runId, generation);
|
|
748
|
+
else if (!generation && opts.reapOrphans)
|
|
749
|
+
await this.reapOrphanedWorker(runId, timeoutMs);
|
|
750
|
+
else if (!generation)
|
|
751
|
+
this.settleOrphanedWorkerExecution(runId);
|
|
541
752
|
return this.store.readWorkerExecution(runId)?.phase === 'complete';
|
|
542
753
|
}
|
|
543
754
|
if (this.disposed)
|
|
@@ -717,6 +928,11 @@ export class RunManager {
|
|
|
717
928
|
this.ciResources.release();
|
|
718
929
|
this.delegationProvisioner = undefined;
|
|
719
930
|
this.finalizedWorkers.clear();
|
|
931
|
+
for (const runId of [...this.orphanReprobes.keys()])
|
|
932
|
+
this.clearOrphanReprobe(runId);
|
|
933
|
+
this.orphanProbes.clear();
|
|
934
|
+
this.orphanBlockers.clear();
|
|
935
|
+
this.reportedOrphanBlockers.clear();
|
|
720
936
|
for (const settle of this.terminationWaiters)
|
|
721
937
|
settle();
|
|
722
938
|
this.store.off('run', this.onDelegationRun);
|
|
@@ -1480,11 +1696,11 @@ export class RunManager {
|
|
|
1480
1696
|
try {
|
|
1481
1697
|
this.beginWorkerExecution(candidate);
|
|
1482
1698
|
}
|
|
1483
|
-
catch {
|
|
1699
|
+
catch (error) {
|
|
1484
1700
|
this.queue.splice(next, 1);
|
|
1485
1701
|
this.pendingJobs.delete(candidate);
|
|
1486
1702
|
this.pendingContinuations.delete(candidate);
|
|
1487
|
-
this.store.updateRun(candidate, { status: 'failed', error: 'worker execution checkpoint unavailable', finishedAt: new Date().toISOString() });
|
|
1703
|
+
this.store.updateRun(candidate, { status: 'failed', error: admissionError(error, 'worker execution checkpoint unavailable'), finishedAt: new Date().toISOString() });
|
|
1488
1704
|
continue;
|
|
1489
1705
|
}
|
|
1490
1706
|
}
|
|
@@ -1722,6 +1938,11 @@ export class RunManager {
|
|
|
1722
1938
|
this.store.appendEvent(run.id, { type: 'note', message: `replaying ${replayed.length} message${replayed.length === 1 ? '' : 's'} accepted before the restart but not read` });
|
|
1723
1939
|
}
|
|
1724
1940
|
}
|
|
1941
|
+
// #469: a generation whose processes died with the old controller is finalized first, so it
|
|
1942
|
+
// re-launches through the ordinary Continue path and its scratch is not retained.
|
|
1943
|
+
for (const run of this.store.listRuns())
|
|
1944
|
+
if (run.delegation?.role === 'worker' && !this.settleOrphanedWorkerExecution(run.id))
|
|
1945
|
+
this.armOrphanReprobe(run.id);
|
|
1725
1946
|
this.reconcileWorkerWaits();
|
|
1726
1947
|
const live = this.store
|
|
1727
1948
|
.listRuns()
|
|
@@ -4503,8 +4724,8 @@ export class RunManager {
|
|
|
4503
4724
|
} };
|
|
4504
4725
|
this.beginWorkerExecution(runId, false);
|
|
4505
4726
|
}
|
|
4506
|
-
catch {
|
|
4507
|
-
return { ok: false, error: 'original execution checkpoint unavailable' };
|
|
4727
|
+
catch (error) {
|
|
4728
|
+
return { ok: false, error: admissionError(error, 'original execution checkpoint unavailable') };
|
|
4508
4729
|
}
|
|
4509
4730
|
const content = [
|
|
4510
4731
|
...(opts.text?.trim() ? [{ type: 'text', text: opts.text.trim() }] : []), ...(opts.images ?? []),
|
|
@@ -4525,8 +4746,8 @@ export class RunManager {
|
|
|
4525
4746
|
try {
|
|
4526
4747
|
this.beginWorkerExecution(runId, !deferForCapacity);
|
|
4527
4748
|
}
|
|
4528
|
-
catch {
|
|
4529
|
-
return { ok: false, error: 'worker execution checkpoint unavailable' };
|
|
4749
|
+
catch (error) {
|
|
4750
|
+
return { ok: false, error: admissionError(error, 'worker execution checkpoint unavailable') };
|
|
4530
4751
|
}
|
|
4531
4752
|
// Everything that could refuse this continuation has now passed, so a pending usage-limit
|
|
4532
4753
|
// resume is superseded either way: this IS that resume (it re-stamps its own counter), or a
|
|
@@ -5168,8 +5389,10 @@ export class RunManager {
|
|
|
5168
5389
|
try {
|
|
5169
5390
|
this.flushDeferred(runId);
|
|
5170
5391
|
this.flushAgentInputs(runId);
|
|
5171
|
-
if (session.pid !== undefined)
|
|
5392
|
+
if (session.pid !== undefined) {
|
|
5172
5393
|
registerRunProcess(runId, session.pid);
|
|
5394
|
+
this.recordWorkerProcess(runId, session.pid);
|
|
5395
|
+
}
|
|
5173
5396
|
setupComplete = true;
|
|
5174
5397
|
const result = await session.result.finally(() => state.agentInputFlight?.settled).finally(() => this.requeueUnreadAtClose(runId, state, session));
|
|
5175
5398
|
if (this.preserveRunAfterDisposal(runId, state))
|
|
@@ -6033,8 +6256,10 @@ export class RunManager {
|
|
|
6033
6256
|
try {
|
|
6034
6257
|
this.flushDeferred(runId);
|
|
6035
6258
|
this.flushAgentInputs(runId);
|
|
6036
|
-
if (session.pid !== undefined)
|
|
6259
|
+
if (session.pid !== undefined) {
|
|
6037
6260
|
registerRunProcess(runId, session.pid);
|
|
6261
|
+
this.recordWorkerProcess(runId, session.pid);
|
|
6262
|
+
}
|
|
6038
6263
|
setupComplete = true;
|
|
6039
6264
|
const result = await session.result.finally(() => state.agentInputFlight?.settled).finally(() => this.requeueUnreadAtClose(runId, state, session));
|
|
6040
6265
|
if (this.isDisposedDelegatedRun(runId))
|