@coreplane/switchboard 1.260.2 → 1.260.3

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.
@@ -1,18 +1,42 @@
1
1
  type WaitUntilContext = Pick<DurableObjectState, "waitUntil">;
2
2
 
3
3
  const TASKS = Symbol.for("switchboard.memory-worker.pending-background-tasks");
4
+ const REGISTRATIONS = Symbol.for("switchboard.memory-worker.background-task-registrations");
4
5
  type PendingTasks = Map<Promise<unknown>, string>;
6
+ type Registrations = string[];
5
7
 
6
- const pendingTasks = (): PendingTasks => {
7
- const root = globalThis as typeof globalThis & { [TASKS]?: PendingTasks };
8
- return (root[TASKS] ??= new Map());
8
+ type DiagnosticRoot = typeof globalThis & {
9
+ [TASKS]?: PendingTasks;
10
+ [REGISTRATIONS]?: Registrations;
9
11
  };
10
12
 
13
+ const root = (): DiagnosticRoot => globalThis as DiagnosticRoot;
14
+ const pendingTasks = (): PendingTasks => (root()[TASKS] ??= new Map());
15
+
16
+ export function beginBackgroundTaskDiagnostics(): void {
17
+ root()[REGISTRATIONS] = [];
18
+ }
19
+
20
+ export function endBackgroundTaskDiagnostics(): void {
21
+ delete root()[REGISTRATIONS];
22
+ }
23
+
24
+ export function backgroundTaskDiagnostics(): {
25
+ registeredBackgroundTasks: string[];
26
+ pendingPromises: string[];
27
+ } {
28
+ return {
29
+ registeredBackgroundTasks: [...(root()[REGISTRATIONS] ?? [])],
30
+ pendingPromises: [...pendingTasks().values()],
31
+ };
32
+ }
33
+
11
34
  /** Keep deliberately detached Worker work in the runtime's request lifetime.
12
35
  * The process-wide label set is also the test seam: a case cannot finish while
13
36
  * work it started is still able to contend with the next case. */
14
37
  export function holdBackgroundTask(context: WaitUntilContext, label: string, task: Promise<unknown>): void {
15
38
  const pending = pendingTasks();
39
+ root()[REGISTRATIONS]?.push(label);
16
40
  const held = task.finally(() => pending.delete(held));
17
41
  pending.set(held, label);
18
42
  context.waitUntil(held);
@@ -0,0 +1,227 @@
1
+ import { afterEach, beforeEach, type TestContext } from "vitest";
2
+ import {
3
+ assertNoPendingBackgroundTasks,
4
+ backgroundTaskDiagnostics,
5
+ beginBackgroundTaskDiagnostics,
6
+ endBackgroundTaskDiagnostics,
7
+ } from "./backgroundTasks.ts";
8
+ import { MEMORY_DIAGNOSTICS_ANNOTATION, type MemoryTestAnnotation } from "./testDiagnosticsProtocol.ts";
9
+
10
+ type TimerKind = "timeout" | "interval";
11
+ type TimerHandler = (...args: unknown[]) => unknown;
12
+ type TimerSetter = (
13
+ handler: TimerHandler,
14
+ delay?: number,
15
+ ...args: unknown[]
16
+ ) => ReturnType<typeof globalThis.setTimeout>;
17
+ interface PendingTimer {
18
+ id: ReturnType<typeof setTimeout>;
19
+ kind: TimerKind;
20
+ label: string;
21
+ owner: string;
22
+ }
23
+ interface RunningTest {
24
+ name: string;
25
+ trackTimers: boolean;
26
+ timers: Map<ReturnType<typeof setTimeout>, PendingTimer>;
27
+ }
28
+ interface TimerOriginals {
29
+ setTimeout: typeof globalThis.setTimeout;
30
+ clearTimeout: typeof globalThis.clearTimeout;
31
+ setInterval: typeof globalThis.setInterval;
32
+ clearInterval: typeof globalThis.clearInterval;
33
+ }
34
+
35
+ const RUNNING = Symbol.for("switchboard.memory-worker.running-test");
36
+ const TIMER_ORIGINALS = Symbol.for("switchboard.memory-worker.timer-originals");
37
+ const REJECTION_TRAP = Symbol.for("switchboard.memory-worker.rejection-trap");
38
+
39
+ type DiagnosticGlobal = typeof globalThis & {
40
+ [RUNNING]?: RunningTest;
41
+ [TIMER_ORIGINALS]?: TimerOriginals;
42
+ [REJECTION_TRAP]?: boolean;
43
+ __vitest_worker__?: { ctx?: { poolId?: number; workerId?: number } };
44
+ process?: {
45
+ env?: Record<string, string | undefined>;
46
+ prependListener?: (event: "unhandledRejection", listener: (reason: unknown) => void) => void;
47
+ };
48
+ };
49
+
50
+ const diagnosticGlobal = (): DiagnosticGlobal => globalThis as DiagnosticGlobal;
51
+
52
+ function reasonText(reason: unknown): string {
53
+ return reason instanceof Error ? reason.message : String(reason);
54
+ }
55
+
56
+ function runningTestName(): string {
57
+ return diagnosticGlobal()[RUNNING]?.name ?? "<no test>";
58
+ }
59
+
60
+ function unhandledRejection(reason: unknown): never {
61
+ throw new Error(`unhandled rejection while "${runningTestName()}" was running: ${reasonText(reason)}`);
62
+ }
63
+
64
+ function installUnhandledRejectionTrap(): void {
65
+ const root = diagnosticGlobal();
66
+ if (root[REJECTION_TRAP]) return;
67
+ root[REJECTION_TRAP] = true;
68
+
69
+ if (typeof root.addEventListener === "function") {
70
+ root.addEventListener("unhandledrejection", (event) => {
71
+ const rejection = event as PromiseRejectionEvent;
72
+ rejection.preventDefault();
73
+ unhandledRejection(rejection.reason);
74
+ });
75
+ return;
76
+ }
77
+
78
+ root.process?.prependListener?.("unhandledRejection", unhandledRejection);
79
+ }
80
+
81
+ function timerLabel(kind: TimerKind, handler: TimerHandler, delay?: number): string {
82
+ const callback = typeof handler === "function" && handler.name ? handler.name : "anonymous";
83
+ return `${kind} ${callback} (${delay ?? 0}ms)`;
84
+ }
85
+
86
+ function strayTimerError(timer: PendingTimer): Error {
87
+ return new Error(`stray ${timer.label} from "${timer.owner}" fired while "${runningTestName()}" was running`);
88
+ }
89
+
90
+ function installTimerTrap(): void {
91
+ const root = diagnosticGlobal();
92
+ if (root[TIMER_ORIGINALS]) return;
93
+
94
+ const originals: TimerOriginals = {
95
+ setTimeout: root.setTimeout.bind(root),
96
+ clearTimeout: root.clearTimeout.bind(root),
97
+ setInterval: root.setInterval.bind(root),
98
+ clearInterval: root.clearInterval.bind(root),
99
+ };
100
+ root[TIMER_ORIGINALS] = originals;
101
+
102
+ root.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
103
+ const running = root[RUNNING];
104
+ if (!running?.trackTimers || typeof handler !== "function") {
105
+ return (originals.setTimeout as unknown as TimerSetter)(handler, delay, ...args);
106
+ }
107
+
108
+ const pending: PendingTimer = {
109
+ id: undefined as unknown as ReturnType<typeof setTimeout>,
110
+ kind: "timeout",
111
+ label: timerLabel("timeout", handler, delay),
112
+ owner: running.name,
113
+ };
114
+ const id = (originals.setTimeout as unknown as TimerSetter)(
115
+ (...callbackArgs: unknown[]) => {
116
+ running.timers.delete(id);
117
+ if (root[RUNNING]?.name !== pending.owner) throw strayTimerError(pending);
118
+ handler(...callbackArgs);
119
+ },
120
+ delay,
121
+ ...args,
122
+ );
123
+ pending.id = id;
124
+ running.timers.set(id, pending);
125
+ return id;
126
+ }) as typeof globalThis.setTimeout;
127
+
128
+ root.setInterval = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
129
+ const running = root[RUNNING];
130
+ if (!running?.trackTimers || typeof handler !== "function") {
131
+ return (originals.setInterval as unknown as TimerSetter)(handler, delay, ...args);
132
+ }
133
+
134
+ const pending: PendingTimer = {
135
+ id: undefined as unknown as ReturnType<typeof setTimeout>,
136
+ kind: "interval",
137
+ label: timerLabel("interval", handler, delay),
138
+ owner: running.name,
139
+ };
140
+ const id = (originals.setInterval as unknown as TimerSetter)(
141
+ (...callbackArgs: unknown[]) => {
142
+ if (root[RUNNING]?.name !== pending.owner) throw strayTimerError(pending);
143
+ handler(...callbackArgs);
144
+ },
145
+ delay,
146
+ ...args,
147
+ );
148
+ pending.id = id;
149
+ running.timers.set(id, pending);
150
+ return id;
151
+ }) as typeof globalThis.setInterval;
152
+
153
+ root.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
154
+ root[RUNNING]?.timers.delete(id as ReturnType<typeof setTimeout>);
155
+ return originals.clearTimeout(id);
156
+ }) as typeof globalThis.clearTimeout;
157
+ root.clearInterval = ((id?: ReturnType<typeof setInterval>) => {
158
+ root[RUNNING]?.timers.delete(id as ReturnType<typeof setTimeout>);
159
+ return originals.clearInterval(id);
160
+ }) as typeof globalThis.clearInterval;
161
+ }
162
+
163
+ function poolWorker(): string {
164
+ const root = diagnosticGlobal();
165
+ const poolId = root.__vitest_worker__?.ctx?.poolId ?? root.process?.env?.VITEST_POOL_ID;
166
+ const workerId = root.__vitest_worker__?.ctx?.workerId ?? root.process?.env?.VITEST_WORKER_ID;
167
+ return `pool ${poolId ?? "unknown"} / worker ${workerId ?? "unknown"}`;
168
+ }
169
+
170
+ function pendingTimerLabels(running: RunningTest): string[] {
171
+ running.trackTimers = false;
172
+ return [...running.timers.values()].map((timer) => timer.label);
173
+ }
174
+
175
+ function clearPendingTimers(running: RunningTest): void {
176
+ const originals = diagnosticGlobal()[TIMER_ORIGINALS];
177
+ if (!originals) return;
178
+ for (const timer of running.timers.values()) {
179
+ if (timer.kind === "interval") originals.clearInterval(timer.id);
180
+ else originals.clearTimeout(timer.id);
181
+ }
182
+ running.timers.clear();
183
+ }
184
+
185
+ function testName(context: TestContext): string {
186
+ return context.task.fullTestName || context.task.name;
187
+ }
188
+
189
+ export function installMemoryTestDiagnostics(): void {
190
+ installUnhandledRejectionTrap();
191
+ installTimerTrap();
192
+
193
+ beforeEach((context) => {
194
+ diagnosticGlobal()[RUNNING] = { name: testName(context), trackTimers: true, timers: new Map() };
195
+ beginBackgroundTaskDiagnostics();
196
+ });
197
+
198
+ afterEach((context) => {
199
+ const root = diagnosticGlobal();
200
+ const running = root[RUNNING] ?? { name: testName(context), trackTimers: false, timers: new Map() };
201
+ const background = backgroundTaskDiagnostics();
202
+ const timers = pendingTimerLabels(running);
203
+ const annotation: MemoryTestAnnotation = {
204
+ poolWorker: poolWorker(),
205
+ ...background,
206
+ pendingTimers: timers,
207
+ };
208
+
209
+ const problems: Error[] = [];
210
+ try {
211
+ assertNoPendingBackgroundTasks();
212
+ } catch (error) {
213
+ problems.push(error instanceof Error ? error : new Error(String(error)));
214
+ }
215
+ if (timers.length > 0) {
216
+ problems.push(new Error(`test ended with ${timers.length} pending timer(s): ${timers.join(", ")}`));
217
+ }
218
+
219
+ (context.task.meta as Record<string, unknown>)[MEMORY_DIAGNOSTICS_ANNOTATION] = annotation;
220
+ clearPendingTimers(running);
221
+ endBackgroundTaskDiagnostics();
222
+ delete root[RUNNING];
223
+
224
+ if (problems.length === 1) throw problems[0];
225
+ if (problems.length > 1) throw new AggregateError(problems, "test ended with pending asynchronous work");
226
+ });
227
+ }
@@ -0,0 +1,22 @@
1
+ export const MEMORY_DIAGNOSTICS_ANNOTATION = "memory-worker-diagnostics";
2
+
3
+ export interface MemoryTestAnnotation {
4
+ poolWorker: string;
5
+ registeredBackgroundTasks: string[];
6
+ pendingPromises: string[];
7
+ pendingTimers: string[];
8
+ }
9
+
10
+ export interface MemoryTestDiagnostic extends MemoryTestAnnotation {
11
+ test: string;
12
+ file: string;
13
+ state: "passed" | "failed" | "skipped" | "pending";
14
+ durationMs: number;
15
+ startedAt: number;
16
+ }
17
+
18
+ export interface MemoryTestDiagnosticsArtifact {
19
+ reason: "passed" | "failed" | "interrupted";
20
+ unhandledErrors: string[];
21
+ tests: MemoryTestDiagnostic[];
22
+ }
@@ -0,0 +1,88 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Reporter, TestRunEndReason } from "vitest/reporters";
4
+ import {
5
+ MEMORY_DIAGNOSTICS_ANNOTATION,
6
+ type MemoryTestAnnotation,
7
+ type MemoryTestDiagnostic,
8
+ type MemoryTestDiagnosticsArtifact,
9
+ } from "./testDiagnosticsProtocol.ts";
10
+
11
+ type TestCase = Parameters<NonNullable<Reporter["onTestCaseResult"]>>[0];
12
+
13
+ export interface MemoryDiagnosticsReporterOptions {
14
+ outputFile?: string;
15
+ writeLog?: (line: string) => void;
16
+ }
17
+
18
+ function annotationFor(testCase: TestCase): MemoryTestAnnotation {
19
+ const annotation = (testCase.meta() as Record<string, unknown>)[MEMORY_DIAGNOSTICS_ANNOTATION];
20
+ if (
21
+ typeof annotation === "object" &&
22
+ annotation !== null &&
23
+ "poolWorker" in annotation &&
24
+ typeof annotation.poolWorker === "string"
25
+ ) {
26
+ return annotation as MemoryTestAnnotation;
27
+ }
28
+ return {
29
+ poolWorker: "unknown",
30
+ registeredBackgroundTasks: [],
31
+ pendingPromises: [],
32
+ pendingTimers: [],
33
+ };
34
+ }
35
+
36
+ function errorMessage(error: { message?: string } | unknown): string {
37
+ return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
38
+ ? error.message
39
+ : String(error);
40
+ }
41
+
42
+ export class MemoryDiagnosticsReporter implements Reporter {
43
+ readonly #outputFile: string;
44
+ readonly #writeLog: (line: string) => void;
45
+ readonly #tests: MemoryTestDiagnostic[] = [];
46
+
47
+ constructor(options: MemoryDiagnosticsReporterOptions = {}) {
48
+ this.#outputFile =
49
+ options.outputFile ??
50
+ process.env.MEMORY_TEST_DIAGNOSTICS_PATH ??
51
+ path.join(process.cwd(), "artifacts", "memory-test-diagnostics.json");
52
+ this.#writeLog = options.writeLog ?? ((line) => process.stdout.write(`${line}\n`));
53
+ }
54
+
55
+ onTestCaseResult(testCase: TestCase): void {
56
+ const diagnostic = testCase.diagnostic();
57
+ const result = testCase.result();
58
+ this.#tests.push({
59
+ test: testCase.fullName,
60
+ file: testCase.module.moduleId,
61
+ state: result.state,
62
+ durationMs: diagnostic?.duration ?? 0,
63
+ startedAt: diagnostic?.startTime ?? 0,
64
+ ...annotationFor(testCase),
65
+ });
66
+ }
67
+
68
+ async onTestRunEnd(
69
+ _testModules: Parameters<NonNullable<Reporter["onTestRunEnd"]>>[0],
70
+ unhandledErrors: Parameters<NonNullable<Reporter["onTestRunEnd"]>>[1],
71
+ reason: TestRunEndReason,
72
+ ): Promise<void> {
73
+ const artifact: MemoryTestDiagnosticsArtifact = {
74
+ reason,
75
+ unhandledErrors: unhandledErrors.map(errorMessage),
76
+ tests: this.#tests,
77
+ };
78
+ await mkdir(path.dirname(this.#outputFile), { recursive: true });
79
+ await writeFile(this.#outputFile, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
80
+
81
+ const slowest = [...this.#tests].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
82
+ const summary =
83
+ slowest.length === 0
84
+ ? "no completed tests"
85
+ : slowest.map((test) => `${Math.round(test.durationMs)}ms ${test.test}`).join("; ");
86
+ this.#writeLog(`[memory diagnostics] slowest ${slowest.length}: ${summary}`);
87
+ }
88
+ }
@@ -7,11 +7,23 @@
7
7
  "module": "esnext",
8
8
  "moduleResolution": "bundler",
9
9
  "lib": ["esnext"],
10
- "types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers/types"],
10
+ "types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers/types", "node"],
11
11
  "strict": true,
12
12
  "noEmit": true,
13
13
  "skipLibCheck": true,
14
14
  "allowImportingTsExtensions": true
15
15
  },
16
- "include": ["worker.ts", "worker.test.ts", "friction.test.ts", "schedules.test.ts", "test-env.d.ts"]
16
+ "include": [
17
+ "worker.ts",
18
+ "worker.test.ts",
19
+ "schedules.test.ts",
20
+ "backgroundTasks.ts",
21
+ "backgroundTasks.test.ts",
22
+ "testDiagnostics.ts",
23
+ "testDiagnosticsProtocol.ts",
24
+ "testDiagnosticsReporter.ts",
25
+ "testSetup.ts",
26
+ "vitest.config.ts",
27
+ "test-env.d.ts"
28
+ ]
17
29
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.2",
3
+ "version": "1.260.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.260.2",
9
+ "version": "1.260.3",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.260.2",
20448
+ "version": "1.260.3",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.2",
3
+ "version": "1.260.3",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.260.2",
3
- "commit": "95c0153dc141f2e586238634e66970fea994bf7b",
4
- "builtAt": "2026-09-21T20:25:49.397Z"
2
+ "version": "1.260.3",
3
+ "commit": "9f64917c9c2520b2797f3d5e6a73494879408e26",
4
+ "builtAt": "2026-09-22T05:04:08.797Z"
5
5
  }
@@ -191,7 +191,7 @@ const CONTRACT_HEADINGS_LIST = Object.values(CONTRACT_SECTION_HEADINGS)
191
191
  // assembled by the child, which would choose what to leave out. Both coding
192
192
  // prompts carry this verbatim so the resident and sandbox children read the
193
193
  // same rule; the review prompts name the same block and the same severity.
194
- const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CONTRACT_HEADING}\` block — its sub-headings, in this order: ${CONTRACT_HEADINGS_LIST} — it is the contract for one plan unit, rendered by Switchboard from the plan itself, and it outranks any free-text task beside it. Do its first instruction first: the rebase of the unit's branch onto the merged parent (a conflict ends the unit — report it and stop; never resolve it by force). Then implement the unit's section as written: every test scenario it lists is added as a test, every spec row it names is updated so its proof binding resolves, the agent rules are followed, and no guard it names is weakened. The review is handed the same block and checks the diff against it: a test scenario the unit listed and the diff did not add is a finding at minor severity — the same severity as a spec contradiction. Never edit the plan record itself; where the unit is wrong or a criterion could not be proven, say so in the handoff and in your final message.`;
194
+ const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CONTRACT_HEADING}\` block — its sub-headings, in this order: ${CONTRACT_HEADINGS_LIST} — it is the contract for one plan unit, rendered by Switchboard from the plan itself, and it outranks any free-text task beside it. Do its first instruction first: the rebase of the unit's branch onto the merged parent. Then implement the unit's section as written: every test scenario it lists is added as a test, every spec row it names is updated so its proof binding resolves, the agent rules are followed, and no guard it names is weakened. The review is handed the same block and checks the diff against it: a test scenario the unit listed and the diff did not add is a finding at minor severity — the same severity as a spec contradiction. Never edit the plan record itself; where the unit is wrong or a criterion could not be proven, say so in the handoff and in your final message.`;
195
195
 
196
196
  // The unit handoff (docs/reference/specs/agent-coding.md item 9; agent-ship.md
197
197
  // item 14): the contract's return edge, as data. A child that ran for a plan
@@ -289,10 +289,11 @@ export const FAST_GATES_BEFORE_PUSH =
289
289
  // (src/core/ship/contract.ts).
290
290
  export const REBASE_BEFORE_PUSH =
291
291
  "REBASE BEFORE EVERY PUSH — always, not configurable. Immediately before each push: fetch your base branch, " +
292
- "rebase your branch onto it, resolve any conflict with the context you already have (the repository's " +
293
- "AGENTS.md says how a generated file is regenerated — regenerate it, never hand-merge it), re-run THE FAST " +
294
- "GATES on the rebased tree, and only then push. Every head that reaches review is then current with its " +
295
- "base when it lands, and no unit ends merge-ready behind a sibling that merged first.";
292
+ "rebase your branch onto it, and resolve any conflict with the context you already have in one bounded model " +
293
+ "round — the thread's context and the repository's AGENTS.md (which says how a generated file is regenerated: " +
294
+ "regenerate it, never hand-merge it). If you cannot resolve it inside that bound, end the round; otherwise " +
295
+ "re-run THE FAST GATES on the rebased tree, and only then push. Every head that reaches review is then current " +
296
+ "with its base when it lands, and no unit ends merge-ready behind a sibling that merged first.";
296
297
 
297
298
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
298
299
 
@@ -10,7 +10,13 @@
10
10
  //
11
11
  import { IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
12
12
  import { isVerbosity, type Verbosity } from "../verbosity.js";
13
- import { isAddressSeverity, type AddressSeverity, type AddressSeveritySource } from "../ship/coordinator.js";
13
+ import { isFindingShape } from "../reviewVerdict.js";
14
+ import {
15
+ isAddressSeverity,
16
+ type AddressSeverity,
17
+ type AddressSeveritySource,
18
+ type HumanGatePending,
19
+ } from "../ship/coordinator.js";
14
20
  import { isHandoffShape, type Handoff } from "../ship/handoff.js";
15
21
 
16
22
  // A coordinator is a Workflow instance in the shim Worker whose children are
@@ -421,6 +427,8 @@ export interface UnitIdle {
421
427
  runId?: string;
422
428
  spendUsd: number | null;
423
429
  handoff?: Handoff;
430
+ /** The human-gated question this idle is waiting on. */
431
+ humanGate?: HumanGatePending;
424
432
  wakes: number;
425
433
  }
426
434
 
@@ -439,6 +447,7 @@ export type UnitWakeAnswer =
439
447
  texts: string[];
440
448
  senders: string[];
441
449
  leaseMs?: number;
450
+ humanGate?: HumanGatePending;
442
451
  }
443
452
  | { kind: "answered"; reply: string }
444
453
  | { kind: "stopped" }
@@ -579,6 +588,23 @@ const isRoundGate = (v: unknown): boolean =>
579
588
  Array.isArray(v.findings) &&
580
589
  v.findings.every((f) => typeof f === "string");
581
590
 
591
+ export const isHumanGatePending = (v: unknown): v is HumanGatePending =>
592
+ isObject(v) &&
593
+ isObject(v.pr) &&
594
+ typeof v.pr.number === "number" &&
595
+ Number.isInteger(v.pr.number) &&
596
+ v.pr.number > 0 &&
597
+ isText(v.pr.url) &&
598
+ typeof v.round === "number" &&
599
+ Number.isInteger(v.round) &&
600
+ v.round >= 1 &&
601
+ Array.isArray(v.findings) &&
602
+ v.findings.every((finding: unknown) => isFindingShape(finding)) &&
603
+ (v.verdict === "approve" || v.verdict === "request_changes") &&
604
+ (v.reviewRunId === undefined || isText(v.reviewRunId)) &&
605
+ (v.headSha === undefined || isText(v.headSha)) &&
606
+ (v.askedAt === undefined || isFinite(v.askedAt));
607
+
582
608
  const isUnitIdle = (v: unknown): boolean =>
583
609
  isObject(v) &&
584
610
  isText(v.why, IDLE_WHY_MAX) &&
@@ -588,6 +614,7 @@ const isUnitIdle = (v: unknown): boolean =>
588
614
  (v.runId === undefined || isText(v.runId)) &&
589
615
  (v.spendUsd === null || isFinite(v.spendUsd)) &&
590
616
  (v.handoff === undefined || isHandoffShape(v.handoff)) &&
617
+ (v.humanGate === undefined || isHumanGatePending(v.humanGate)) &&
591
618
  isCount(v.wakes);
592
619
 
593
620
  const isSegment = (v: unknown): boolean =>
@@ -616,7 +643,8 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
616
643
  v.texts.every((text) => typeof text === "string") &&
617
644
  Array.isArray(v.senders) &&
618
645
  v.senders.every((sender) => isText(sender)) &&
619
- (v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0))
646
+ (v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0)) &&
647
+ (v.humanGate === undefined || isHumanGatePending(v.humanGate))
620
648
  );
621
649
  };
622
650
 
@@ -693,6 +721,24 @@ export type RunFinishedSend =
693
721
  | { kind: "no-binding"; instance: string }
694
722
  | { kind: "failed"; instance: string; type: string; reason: string };
695
723
 
724
+ /** Wake one unit after durable outside input was appended. The payload is
725
+ * deliberately empty: the unit re-reads its own event list before acting. */
726
+ export async function sendUnitNudge(
727
+ workflow: WorkflowSender | undefined,
728
+ key: { instanceId: string; unit: string },
729
+ ): Promise<RunFinishedSend> {
730
+ const instance = key.instanceId;
731
+ if (!workflow) return { kind: "no-binding", instance };
732
+ const type = unitNudgeEventType(key);
733
+ try {
734
+ const handle = await workflow.get(instance);
735
+ await handle.sendEvent({ type, payload: {} });
736
+ return { kind: "sent", instance, type };
737
+ } catch (err) {
738
+ return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
739
+ }
740
+ }
741
+
696
742
  /** The one send per settled head (http-ingress.md item 12): best effort like
697
743
  * `sendRunFinished` — a refusal is answered, never thrown, and the parent's
698
744
  * bounded merge wait times out on its own. */
@@ -40,6 +40,7 @@
40
40
  // the shim Worker imports this by relative path.
41
41
 
42
42
  import { DAY_MS, DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
43
+ import { isFindingShape, type Finding } from "../reviewVerdict.js";
43
44
  import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
44
45
  import {
45
46
  applyReturn,
@@ -343,6 +344,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
343
344
  // they were written (the run record's validator), read here as they are.
344
345
  const facts = run as unknown as Omit<Extract<ChildFacts, { finished: true }>, "finished" | "status">;
345
346
  const {
347
+ finishedAt,
348
+ reviewAskedAt,
346
349
  finalReply,
347
350
  pr,
348
351
  headSha,
@@ -367,6 +370,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
367
370
  run: {
368
371
  finished: true,
369
372
  status: run.status as Extract<ChildFacts, { finished: true }>["status"],
373
+ ...(typeof finishedAt === "number" ? { finishedAt } : {}),
374
+ ...(typeof reviewAskedAt === "number" ? { reviewAskedAt } : {}),
370
375
  ...(finalReply !== undefined ? { finalReply } : {}),
371
376
  ...(pr !== undefined ? { pr } : {}),
372
377
  ...(headSha !== undefined ? { headSha } : {}),
@@ -403,6 +408,33 @@ function isCommitChecks(v: unknown): v is { total: number; pending: string[]; fa
403
408
  return typeof c.total === "number" && names(c.pending) && names(c.failed);
404
409
  }
405
410
 
411
+ function entryHumanGate(v: unknown):
412
+ | {
413
+ round: number;
414
+ findings: Finding[];
415
+ verdict: "approve" | "request_changes";
416
+ answer: string;
417
+ author: string;
418
+ commentId: string;
419
+ }
420
+ | undefined {
421
+ if (typeof v !== "object" || v === null) return undefined;
422
+ const row = v as Record<string, unknown>;
423
+ if (typeof row.round !== "number" || !Number.isInteger(row.round) || row.round < 1) return undefined;
424
+ if (!Array.isArray(row.findings) || !row.findings.every(isFindingShape)) return undefined;
425
+ if (row.verdict !== "approve" && row.verdict !== "request_changes") return undefined;
426
+ if (typeof row.answer !== "string" || typeof row.author !== "string" || typeof row.commentId !== "string")
427
+ return undefined;
428
+ return {
429
+ round: row.round,
430
+ findings: row.findings,
431
+ verdict: row.verdict,
432
+ answer: row.answer,
433
+ author: row.author,
434
+ commentId: row.commentId,
435
+ };
436
+ }
437
+
406
438
  function prCheckReturn(step: string, a: BotAnswer): StepReturn {
407
439
  const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
408
440
  if (ok === true && state === "none") {
@@ -448,6 +480,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
448
480
  // The base's merge-queue rule beside the checks (issue 2011): the
449
481
  // merge:person report says the person's merge is queued.
450
482
  ...(typeof a.body.baseHasMergeQueue === "boolean" ? { baseHasMergeQueue: a.body.baseHasMergeQueue } : {}),
483
+ ...(entryHumanGate(a.body.humanGate) !== undefined ? { humanGate: entryHumanGate(a.body.humanGate)! } : {}),
451
484
  },
452
485
  at,
453
486
  };
@@ -961,6 +994,7 @@ async function runUnit(
961
994
  ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
962
995
  spendUsd: note.ending.spendUsd,
963
996
  ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
997
+ ...(note.ending.humanGate !== undefined ? { humanGate: note.ending.humanGate } : {}),
964
998
  }
965
999
  : {}),
966
1000
  },
@@ -1171,6 +1205,7 @@ async function waitOnIdle(
1171
1205
  ...(answer.runId !== undefined ? { previousRunId: answer.runId } : {}),
1172
1206
  ...(answer.handoff !== undefined ? { previousHandoff: answer.handoff } : {}),
1173
1207
  ...(answer.texts.length > 0 ? { texts: answer.texts } : {}),
1208
+ ...(answer.humanGate !== undefined ? { humanGate: answer.humanGate } : {}),
1174
1209
  ...(answer.leaseMs !== undefined
1175
1210
  ? { resume: { leaseMs: answer.leaseMs, attempt: (currentSession?.resume?.attempt ?? 0) + 1 } }
1176
1211
  : {}),
@@ -1240,7 +1275,7 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
1240
1275
  next,
1241
1276
  stepPrefixOf(next, session),
1242
1277
  ending,
1243
- plan.idleDays,
1278
+ ending.parkDays ?? plan.idleDays,
1244
1279
  session,
1245
1280
  );
1246
1281
  if (parked.kind === "ending") {