omp-conductor 0.10.0 → 0.12.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 +35 -2
- package/package.json +1 -1
- package/src/board.ts +124 -10
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +238 -27
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +25 -0
- package/src/fleet.ts +1 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +110 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +152 -14
package/src/types.ts
CHANGED
|
@@ -114,6 +114,14 @@ export interface RepoTarget {
|
|
|
114
114
|
* the worker brief.
|
|
115
115
|
*/
|
|
116
116
|
graphProject?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Ordered-migration-chain guard (#227): repo-relative directory whose *.py
|
|
119
|
+
* files declare `revision`/`down_revision`. When set, `conductor_pr_merge`
|
|
120
|
+
* refuses a merge that would collide with the base branch's chain (id reuse,
|
|
121
|
+
* a deleted migration, or a combined graph with more than one head). Absent
|
|
122
|
+
* = no chain check.
|
|
123
|
+
*/
|
|
124
|
+
migrations?: { dir: string };
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
/**
|
|
@@ -140,6 +148,59 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
|
140
148
|
*/
|
|
141
149
|
export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The four answers the wizard offers for "how loud should the orchestrator be".
|
|
153
|
+
* Three are legacy presets; `quiet` is the explicit form: interrupt only for
|
|
154
|
+
* escalations, fleet stops and confirmed failures, with one daily rollup
|
|
155
|
+
* (#229).
|
|
156
|
+
*/
|
|
157
|
+
export type ReportScopeChoice = ReportScope | "quiet";
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Which categories may interrupt the operator's phone. Each maps to a tier-2
|
|
161
|
+
* escalation channel: `tier2` (a plain decision), `decision-needed`, a
|
|
162
|
+
* fleet-stopping condition, a confirmed failure, or any material event. What an
|
|
163
|
+
* `interruptOn` list omits is not dropped — it accumulates for the digest.
|
|
164
|
+
* Data rather than booleans so the config validator, the escalator and the
|
|
165
|
+
* orchestrator prompt all enumerate the same vocabulary (#229).
|
|
166
|
+
*/
|
|
167
|
+
export const INTERRUPT_CATEGORIES = [
|
|
168
|
+
"tier2",
|
|
169
|
+
"decision-needed",
|
|
170
|
+
"fleet-stopped",
|
|
171
|
+
"confirmed-failure",
|
|
172
|
+
"material",
|
|
173
|
+
] as const;
|
|
174
|
+
|
|
175
|
+
export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
|
|
176
|
+
|
|
177
|
+
/** When the daily/report rollup is due. */
|
|
178
|
+
export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
|
|
179
|
+
|
|
180
|
+
export type DigestCadence = (typeof DIGEST_CADENCES)[number];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Explicit reporting policy: which categories interrupt, and when the daily
|
|
184
|
+
* rollup happens. `scopePreset` is set when the config came from a legacy
|
|
185
|
+
* `scope:` preset, and drives the preset tick prompt text.
|
|
186
|
+
*/
|
|
187
|
+
export interface ReportingPolicy {
|
|
188
|
+
interruptOn: InterruptCategory[];
|
|
189
|
+
digest: { cadence: DigestCadence; at?: string; timezone?: string };
|
|
190
|
+
/** Set when the policy came from a legacy `scope` preset. */
|
|
191
|
+
scopePreset?: ReportScope;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The default policy: the legacy `material` preset, spelled out so an explicit
|
|
196
|
+
* config can name it and the wizard can show it.
|
|
197
|
+
*/
|
|
198
|
+
export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
|
|
199
|
+
interruptOn: [...INTERRUPT_CATEGORIES],
|
|
200
|
+
digest: { cadence: "per-tick" },
|
|
201
|
+
scopePreset: DEFAULT_REPORT_SCOPE,
|
|
202
|
+
};
|
|
203
|
+
|
|
143
204
|
/**
|
|
144
205
|
* The irreversible tool-call shapes this package recognises and gates. Data
|
|
145
206
|
* here rather than in `release-policy.ts` because the config validator, the
|
|
@@ -431,6 +492,9 @@ export const LABEL_REASONS = [
|
|
|
431
492
|
"promoted-to-queue",
|
|
432
493
|
/** Re-briefed after a failed attempt, and put back on the queue. */
|
|
433
494
|
"re-briefed",
|
|
495
|
+
/** Delivered: the PR merged and the issue closed, and the queue label comes
|
|
496
|
+
* off as completed lifecycle cleanup — not superseded, not a re-brief (#230). */
|
|
497
|
+
"completed",
|
|
434
498
|
/** Parked: it needs a decision only a human can make. */
|
|
435
499
|
"needs-human",
|
|
436
500
|
"duplicate",
|
|
@@ -534,11 +598,13 @@ export interface ProjectConfig {
|
|
|
534
598
|
*/
|
|
535
599
|
policy?: ProjectPolicy;
|
|
536
600
|
/**
|
|
537
|
-
* How loud the orchestrator is
|
|
538
|
-
*
|
|
539
|
-
*
|
|
601
|
+
* How loud the orchestrator is, and when the daily rollup happens. Optional
|
|
602
|
+
* on disk — a config written before this key existed loads as
|
|
603
|
+
* {@link DEFAULT_REPORT_POLICY} — so read it through `resolveReportScope`
|
|
604
|
+
* rather than reaching for `.scope` directly. A legacy `scope:` value is
|
|
605
|
+
* accepted and materialised as a preset (`.scopePreset`).
|
|
540
606
|
*/
|
|
541
|
-
reporting?:
|
|
607
|
+
reporting?: ReportingPolicy;
|
|
542
608
|
/** Parent directory for per-run worktrees. */
|
|
543
609
|
workspaceRoot: string;
|
|
544
610
|
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
@@ -832,6 +898,7 @@ export const FAILURE_CLASSES = [
|
|
|
832
898
|
"orphan-dirty",
|
|
833
899
|
"settlement-stuck",
|
|
834
900
|
"provider-credit",
|
|
901
|
+
"provider-transient",
|
|
835
902
|
"unknown",
|
|
836
903
|
] as const;
|
|
837
904
|
|
|
@@ -969,6 +1036,9 @@ export interface DispatchSummary {
|
|
|
969
1036
|
ready: number;
|
|
970
1037
|
routed: number;
|
|
971
1038
|
admitted: number;
|
|
1039
|
+
/** Ready issues carrying a state label — claimed by a live or settling run.
|
|
1040
|
+
* Optional: persisted old rows lack it, so readers use `?? 0` (#228). */
|
|
1041
|
+
claimed?: number;
|
|
972
1042
|
/** True only for system/API failures, never ordinary policy holds. */
|
|
973
1043
|
degraded: boolean;
|
|
974
1044
|
holds: AdmissionHoldSummary[];
|
|
@@ -1223,6 +1293,15 @@ export interface Store {
|
|
|
1223
1293
|
wasNotified(key: string): boolean;
|
|
1224
1294
|
recordDispatch(project: string, summary: DispatchSummary): void;
|
|
1225
1295
|
latestDispatch(project: string): DispatchSummary | undefined;
|
|
1296
|
+
/** The newest `digest:` dedupe key a project has run toward without ending in
|
|
1297
|
+
* failure — what the off-schedule-digest refusal compares against (#229). */
|
|
1298
|
+
lastDigestDedupeKey(project: string): string | undefined;
|
|
1299
|
+
/** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
|
|
1300
|
+
addHeldNotice(notice: HeldNoticeDraft): void;
|
|
1301
|
+
/** Held notices still owed (not yet re-surfaced by a digest pass). */
|
|
1302
|
+
undigestedNotices(project: string): HeldNotice[];
|
|
1303
|
+
/** Mark every undigested notice for a project as re-surfaced. */
|
|
1304
|
+
markNoticesDigested(project: string, at: number): void;
|
|
1226
1305
|
/** Add one bounded observation to the per-day friction rollup. */
|
|
1227
1306
|
recordFriction(project: string, observation: FrictionObservation): void;
|
|
1228
1307
|
/** Repeated signals not surfaced within the supplied cooldown window. */
|
|
@@ -1385,6 +1464,28 @@ export interface Escalation {
|
|
|
1385
1464
|
summary: string;
|
|
1386
1465
|
detail?: string;
|
|
1387
1466
|
runId?: string;
|
|
1467
|
+
/** Tier-2-only interrupt category; defaults to `"tier2"`. Drives whether the
|
|
1468
|
+
* escalation pages now or is held for the digest under the project's
|
|
1469
|
+
* `reporting.interruptOn` policy (#229). */
|
|
1470
|
+
category?: InterruptCategory;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** A tier-2 escalation the project's interrupt policy deferred to the digest. */
|
|
1474
|
+
export interface HeldNotice {
|
|
1475
|
+
id: string;
|
|
1476
|
+
category: InterruptCategory;
|
|
1477
|
+
summary: string;
|
|
1478
|
+
detail: string;
|
|
1479
|
+
createdAt: number;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** What it takes to persist one held notice. */
|
|
1483
|
+
export interface HeldNoticeDraft {
|
|
1484
|
+
project: string;
|
|
1485
|
+
category: InterruptCategory;
|
|
1486
|
+
summary: string;
|
|
1487
|
+
detail: string;
|
|
1488
|
+
createdAt: number;
|
|
1388
1489
|
}
|
|
1389
1490
|
|
|
1390
1491
|
/**
|
|
@@ -1511,6 +1612,11 @@ export const VERB_REFUSALS = [
|
|
|
1511
1612
|
"release-shape-not-executable",
|
|
1512
1613
|
/** Config unreadable, project unknown, repo unrouted. Fail closed. */
|
|
1513
1614
|
"config-unreadable",
|
|
1615
|
+
/** Merging would corrupt the ordered migration chain in `migrations.dir` (#227). */
|
|
1616
|
+
"chain-conflict",
|
|
1617
|
+
/** The PR diff or the base-branch chain could not be read — fail closed rather
|
|
1618
|
+
* than merge blind into a chain that might be about to fork (#227). */
|
|
1619
|
+
"chain-unreadable",
|
|
1514
1620
|
/** The privileged half ran and the underlying command failed. */
|
|
1515
1621
|
"action-failed",
|
|
1516
1622
|
] as const;
|
package/src/verbs/server.ts
CHANGED
|
@@ -42,6 +42,8 @@ import { randomUUID } from "node:crypto";
|
|
|
42
42
|
import { createServer, type Server, type Socket } from "node:net";
|
|
43
43
|
|
|
44
44
|
import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
|
|
45
|
+
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
46
|
+
import type { readBaseChain as readBaseChainType } from "../gitops.ts";
|
|
45
47
|
import { releaseRefusal } from "../release-policy.ts";
|
|
46
48
|
import { LIVE_STATES } from "../store.ts";
|
|
47
49
|
import type {
|
|
@@ -181,6 +183,12 @@ export interface VerbDeps {
|
|
|
181
183
|
pausedAt: () => number | undefined;
|
|
182
184
|
log: (message: string) => void;
|
|
183
185
|
now: () => number;
|
|
186
|
+
/**
|
|
187
|
+
* Read-only access to the ordered-migration-chain guard. A read seam, kept
|
|
188
|
+
* off `VerbActions` (the mutation surface) on purpose: `prMergeVerb` consults
|
|
189
|
+
* whether the merge would fork the chain, it never changes it (#227).
|
|
190
|
+
*/
|
|
191
|
+
chain: { readBaseChain: typeof readBaseChainType };
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
/**
|
|
@@ -728,6 +736,47 @@ async function prMergeVerb(
|
|
|
728
736
|
);
|
|
729
737
|
}
|
|
730
738
|
|
|
739
|
+
const repoTarget = Object.values(project.routing.repos).find((r) => r.name === target.repo);
|
|
740
|
+
const chainDir = repoTarget?.migrations?.dir;
|
|
741
|
+
if (chainDir !== undefined && repoTarget !== undefined) {
|
|
742
|
+
// Checked against the base tip at merge time, not against a stale advisory:
|
|
743
|
+
// the author hit two merged PRs leaving main with two Alembic heads (#227).
|
|
744
|
+
const diff = await deps.tracker.prDiff(prUrl).catch(() => undefined);
|
|
745
|
+
if (diff === undefined) {
|
|
746
|
+
return refuse(
|
|
747
|
+
"chain-unreadable",
|
|
748
|
+
`refused: the diff of ${prUrl} could not be read, so the migration chain in ${chainDir} cannot be verified. Failing closed: a wrong merge here is a silent production schema drift (#227).`,
|
|
749
|
+
target.issue,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
const { added, changed, deleted } = chainEntriesFromDiff(diff.files, chainDir);
|
|
753
|
+
if (added.length + changed.length + deleted.length > 0) {
|
|
754
|
+
if (diff.truncated) {
|
|
755
|
+
return refuse(
|
|
756
|
+
"chain-unreadable",
|
|
757
|
+
`refused: the diff of ${prUrl} was truncated and it touches ${chainDir} — the chain cannot be verified from a partial diff.`,
|
|
758
|
+
target.issue,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
const base = await deps.chain.readBaseChain(project, repoTarget, chainDir);
|
|
762
|
+
if (!base.ok) {
|
|
763
|
+
return refuse(
|
|
764
|
+
"chain-unreadable",
|
|
765
|
+
`refused: could not read ${chainDir} at the tip of ${repoTarget.defaultBranch} (${base.stderr}).`,
|
|
766
|
+
target.issue,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const violations = chainViolations({ base: base.entries, added, changed, deleted });
|
|
770
|
+
if (violations.length > 0) {
|
|
771
|
+
return refuse(
|
|
772
|
+
"chain-conflict",
|
|
773
|
+
`refused: merging ${prUrl} would corrupt the ordered migration chain in ${chainDir} — ${violations.join("; ")}. Rebase onto the current tip, regenerate the migration, and call again.`,
|
|
774
|
+
target.issue,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
731
780
|
const outcome = await deps.actions.mergePr(prUrl, headSha);
|
|
732
781
|
if (!outcome.ok) {
|
|
733
782
|
return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, target.issue);
|
package/src/worker.ts
CHANGED
|
@@ -25,6 +25,11 @@ const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
|
|
|
25
25
|
/** `{{KEY}}` placeholders in a brief template. */
|
|
26
26
|
const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
27
27
|
|
|
28
|
+
function scheduleWallClock(callback: () => void, delayMs: number): () => void {
|
|
29
|
+
const timer = setTimeout(callback, delayMs);
|
|
30
|
+
return () => clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
34
|
* Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
|
|
30
35
|
* turn counter caught a loop, or the wall clock caught a session that was stuck
|
|
@@ -32,6 +37,24 @@ const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
|
32
37
|
*/
|
|
33
38
|
export type KilledBy = "turns" | "wallclock";
|
|
34
39
|
|
|
40
|
+
export type WorkerPausePhase = "running" | "pausing" | "paused";
|
|
41
|
+
|
|
42
|
+
export interface WorkerPauseControl {
|
|
43
|
+
phase(): WorkerPausePhase;
|
|
44
|
+
/** Cooperative park; resolves when the harness is idle. Rejects when the
|
|
45
|
+
* run is settling, cap-killed, or already pausing/paused. */
|
|
46
|
+
pause(): Promise<void>;
|
|
47
|
+
/** Returns a parked session to work with a continuation prompt. Throws
|
|
48
|
+
* when the phase is not `paused`. */
|
|
49
|
+
resume(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a resumed session is told. One literal so tests can pin it. */
|
|
53
|
+
export const RESUME_PROMPT =
|
|
54
|
+
"The operator paused this session and has now resumed it. Continue exactly where you left off: " +
|
|
55
|
+
"re-check the outcome of your last action before repeating it, then keep working your original " +
|
|
56
|
+
"brief to the same report contract.";
|
|
57
|
+
|
|
35
58
|
export interface WorkerOpts {
|
|
36
59
|
brief: string;
|
|
37
60
|
cwd: string;
|
|
@@ -87,15 +110,21 @@ export interface WorkerOpts {
|
|
|
87
110
|
* would be a path that fails to open rather than an absence.
|
|
88
111
|
*/
|
|
89
112
|
onSessionFile?: (path: string) => void;
|
|
113
|
+
/**
|
|
114
|
+
* Installs the run's operator pause controller the moment the session
|
|
115
|
+
* exists; absent = no pause surface (tests, one-shot callers).
|
|
116
|
+
*/
|
|
117
|
+
onPauseControl?: (control: WorkerPauseControl) => void;
|
|
90
118
|
}
|
|
91
119
|
|
|
92
120
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* never pass it and a test can hand over a fake without a live peer dependency.
|
|
121
|
+
* Session creation plus the clock seam needed to prove wall-clock behavior
|
|
122
|
+
* without sleeping. Production callers use the defaults.
|
|
96
123
|
*/
|
|
97
124
|
export interface RunWorkerDeps {
|
|
98
125
|
createSession: typeof createSession;
|
|
126
|
+
now?: () => number;
|
|
127
|
+
schedule?: (callback: () => void, delayMs: number) => () => void;
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
export interface WorkerResult {
|
|
@@ -203,6 +232,8 @@ export async function runWorker(
|
|
|
203
232
|
): Promise<WorkerResult> {
|
|
204
233
|
const { workerWallClockMs } = o.caps;
|
|
205
234
|
const maxTurns = o.maxTurns ?? (() => o.caps.workerMaxTurns);
|
|
235
|
+
const now = deps.now ?? Date.now;
|
|
236
|
+
const schedule = deps.schedule ?? scheduleWallClock;
|
|
206
237
|
|
|
207
238
|
const session = await deps.createSession({
|
|
208
239
|
cwd: o.cwd,
|
|
@@ -249,18 +280,32 @@ export async function runWorker(
|
|
|
249
280
|
// merged (#217).
|
|
250
281
|
let claim: { prUrl: string; headSha: string } | undefined;
|
|
251
282
|
let killedBy: KilledBy | undefined;
|
|
252
|
-
//
|
|
253
|
-
let
|
|
283
|
+
// Canceler for the armed wall clock; invoked on every exit path below.
|
|
284
|
+
let cancelWallClock: (() => void) | undefined;
|
|
285
|
+
let wallClockRemainingMs = workerWallClockMs;
|
|
286
|
+
let wallClockArmedAt = now();
|
|
287
|
+
let pausePhase: WorkerPausePhase = "running";
|
|
288
|
+
let resumeWaiter: PromiseWithResolvers<string> | undefined;
|
|
289
|
+
// A terminal agent_end or cap has settled the run.
|
|
290
|
+
let done = false;
|
|
254
291
|
// Resolved by the first terminal `agent_end`, and by every cap kill. Only
|
|
255
292
|
// ever awaited when the harness has already said it is not finished.
|
|
256
293
|
const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
|
|
294
|
+
// Resolves whenever a pause request must interrupt the existing
|
|
295
|
+
// non-terminal agent_end wait. Re-armed after its resume prompt is consumed.
|
|
296
|
+
let pauseRequested = Promise.withResolvers<void>();
|
|
257
297
|
// Set by a non-terminal `agent_end`: the harness will resume this session.
|
|
258
298
|
let resuming = false;
|
|
259
299
|
|
|
260
300
|
const clearWallClock = () => {
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
301
|
+
if (cancelWallClock === undefined) return;
|
|
302
|
+
cancelWallClock();
|
|
303
|
+
cancelWallClock = undefined;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const armWallClock = () => {
|
|
307
|
+
wallClockArmedAt = now();
|
|
308
|
+
cancelWallClock = schedule(() => kill("wallclock"), wallClockRemainingMs);
|
|
264
309
|
};
|
|
265
310
|
|
|
266
311
|
const kill = (by: KilledBy) => {
|
|
@@ -271,9 +316,80 @@ export async function runWorker(
|
|
|
271
316
|
session.abort();
|
|
272
317
|
// An aborted session may never reach a terminal `agent_end`. The cap is the
|
|
273
318
|
// outcome now, so nothing may still be waiting for one.
|
|
319
|
+
done = true;
|
|
274
320
|
settle();
|
|
275
321
|
};
|
|
276
322
|
|
|
323
|
+
const takeResumePrompt = async (): Promise<string | undefined> => {
|
|
324
|
+
const waiter = resumeWaiter;
|
|
325
|
+
if (waiter === undefined) return undefined;
|
|
326
|
+
const prompt = await Promise.race([
|
|
327
|
+
waiter.promise,
|
|
328
|
+
settled.then(() => undefined),
|
|
329
|
+
]);
|
|
330
|
+
if (resumeWaiter === waiter) {
|
|
331
|
+
resumeWaiter = undefined;
|
|
332
|
+
pauseRequested = Promise.withResolvers<void>();
|
|
333
|
+
}
|
|
334
|
+
return killedBy !== undefined || done ? undefined : prompt;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
o.onPauseControl?.({
|
|
338
|
+
phase: () => pausePhase,
|
|
339
|
+
pause: async () => {
|
|
340
|
+
if (killedBy !== undefined || done) {
|
|
341
|
+
throw new Error("the run is settling; nothing left to pause");
|
|
342
|
+
}
|
|
343
|
+
if (pausePhase !== "running") throw new Error(`the worker is already ${pausePhase}`);
|
|
344
|
+
if (resumeWaiter !== undefined) {
|
|
345
|
+
throw new Error("the worker resume is still starting");
|
|
346
|
+
}
|
|
347
|
+
pausePhase = "pausing";
|
|
348
|
+
resumeWaiter = Promise.withResolvers<string>();
|
|
349
|
+
pauseRequested.resolve();
|
|
350
|
+
// Bank the remaining wall clock before the abort: a slow drain must not
|
|
351
|
+
// be cap-killed mid-park (#238 acceptance 3).
|
|
352
|
+
wallClockRemainingMs = Math.max(
|
|
353
|
+
1_000,
|
|
354
|
+
wallClockRemainingMs - (now() - wallClockArmedAt),
|
|
355
|
+
);
|
|
356
|
+
clearWallClock();
|
|
357
|
+
try {
|
|
358
|
+
await session.park();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
pausePhase = "running";
|
|
361
|
+
if (killedBy !== undefined || done) {
|
|
362
|
+
resumeWaiter = undefined;
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
// The abort may already have unwound prompt() even though park itself
|
|
366
|
+
// failed. Wake that path with the same defensive continuation rather
|
|
367
|
+
// than leaving the worker hung on an orphaned waiter.
|
|
368
|
+
armWallClock();
|
|
369
|
+
resumeWaiter?.resolve(RESUME_PROMPT);
|
|
370
|
+
throw err;
|
|
371
|
+
}
|
|
372
|
+
if (killedBy !== undefined || done) {
|
|
373
|
+
pausePhase = "running";
|
|
374
|
+
resumeWaiter = undefined;
|
|
375
|
+
throw new Error("the run settled while pausing");
|
|
376
|
+
}
|
|
377
|
+
pausePhase = "paused";
|
|
378
|
+
},
|
|
379
|
+
resume: () => {
|
|
380
|
+
if (pausePhase !== "paused") {
|
|
381
|
+
throw new Error(
|
|
382
|
+
pausePhase === "pausing"
|
|
383
|
+
? "still pausing — wait until it reports paused"
|
|
384
|
+
: "the worker is not paused",
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
pausePhase = "running";
|
|
388
|
+
armWallClock();
|
|
389
|
+
resumeWaiter?.resolve(RESUME_PROMPT);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
277
393
|
session.on("turn_start", () => {
|
|
278
394
|
// The documented watchdog signal, and the honest one: `turn_start` fires
|
|
279
395
|
// exactly once per turn, whereas one turn can emit several assistant
|
|
@@ -326,6 +442,8 @@ export async function runWorker(
|
|
|
326
442
|
// all — is a finished run.
|
|
327
443
|
const isTerminal = field(event, "isTerminal");
|
|
328
444
|
if (shouldComplete(typeof isTerminal === "boolean" ? { isTerminal } : {})) {
|
|
445
|
+
if (pausePhase !== "running") return;
|
|
446
|
+
done = true;
|
|
329
447
|
settle();
|
|
330
448
|
return;
|
|
331
449
|
}
|
|
@@ -336,14 +454,33 @@ export async function runWorker(
|
|
|
336
454
|
// callback does not drop the handle itself: every exit runs `clearWallClock()`
|
|
337
455
|
// exactly once instead, and clearing an already-fired handle is a documented
|
|
338
456
|
// no-op — cheaper than assuming a fired timer holds nothing.
|
|
339
|
-
|
|
457
|
+
armWallClock();
|
|
340
458
|
|
|
341
459
|
try {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
460
|
+
let next: string | undefined = o.brief;
|
|
461
|
+
while (next !== undefined) {
|
|
462
|
+
try {
|
|
463
|
+
await session.prompt(next);
|
|
464
|
+
} catch (cause) {
|
|
465
|
+
// Our own abort surfaces here on some paths: a cap kill (existing
|
|
466
|
+
// behavior) or an operator park (new). Anything else is a real failure.
|
|
467
|
+
if (killedBy === undefined && resumeWaiter === undefined) throw cause;
|
|
468
|
+
}
|
|
469
|
+
next = undefined;
|
|
470
|
+
if (killedBy !== undefined) break;
|
|
471
|
+
if (resumeWaiter === undefined && resuming && !done) {
|
|
472
|
+
// A non-terminal agent_end ordinarily waits for the harness to finish
|
|
473
|
+
// later. A pause request must wake that wait so resume can prompt the
|
|
474
|
+
// same session instead of hanging behind the old settlement promise.
|
|
475
|
+
await Promise.race([settled, pauseRequested.promise]);
|
|
476
|
+
}
|
|
477
|
+
if (killedBy !== undefined) break;
|
|
478
|
+
if (resumeWaiter !== undefined) {
|
|
479
|
+
next = await takeResumePrompt();
|
|
480
|
+
if (next === undefined) break;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
347
484
|
} catch (cause) {
|
|
348
485
|
// Our own abort surfaces here on some paths; that is a kill, not a crash.
|
|
349
486
|
if (killedBy === undefined) {
|
|
@@ -356,6 +493,7 @@ export async function runWorker(
|
|
|
356
493
|
});
|
|
357
494
|
}
|
|
358
495
|
} finally {
|
|
496
|
+
done = true;
|
|
359
497
|
// Runs on every exit, including the early return above: a live timer keeps
|
|
360
498
|
// the dispatcher process alive long after the run it was guarding.
|
|
361
499
|
clearWallClock();
|