@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.
- package/dist/assets/deploy/cloudflare-memory/backgroundTasks.ts +27 -3
- package/dist/assets/deploy/cloudflare-memory/testDiagnostics.ts +227 -0
- package/dist/assets/deploy/cloudflare-memory/testDiagnosticsProtocol.ts +22 -0
- package/dist/assets/deploy/cloudflare-memory/testDiagnosticsReporter.ts +88 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +14 -2
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +6 -5
- package/dist/assets/src/core/coordinator/contract.ts +48 -2
- package/dist/assets/src/core/coordinator/driver.ts +36 -1
- package/dist/assets/src/core/reviewVerdict.ts +15 -5
- package/dist/assets/src/core/ship/contract.ts +1 -1
- package/dist/assets/src/core/ship/coordinator.ts +206 -78
- package/dist/cli.js +372 -108
- package/package.json +1 -1
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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": [
|
|
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.
|
|
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.
|
|
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.
|
|
20448
|
+
"version": "1.260.3",
|
|
20449
20449
|
"license": "Apache-2.0",
|
|
20450
20450
|
"dependencies": {
|
|
20451
20451
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.260.
|
|
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",
|
package/dist/assets/source.json
CHANGED
|
@@ -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
|
|
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
|
|
293
|
-
"AGENTS.md says how a generated file is regenerated
|
|
294
|
-
"
|
|
295
|
-
"
|
|
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 {
|
|
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") {
|