@coreplane/switchboard 1.260.1 → 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/bin/switchboard.js +15 -11
- package/dist/assets/deploy/cloudflare-memory/backgroundTasks.ts +49 -0
- 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/deploy/cloudflare-memory/worker.ts +36 -16
- package/dist/assets/deploy/cloudflare-resident/worker.ts +39 -18
- 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 +8 -7
- package/dist/assets/src/core/budgets.ts +7 -0
- package/dist/assets/src/core/coordinator/contract.ts +48 -2
- package/dist/assets/src/core/coordinator/driver.ts +74 -6
- package/dist/assets/src/core/reviewVerdict.ts +15 -5
- package/dist/assets/src/core/reviewedHead.ts +16 -2
- package/dist/assets/src/core/ship/contract.ts +1 -1
- package/dist/assets/src/core/ship/coordinator.ts +303 -80
- package/dist/cli.js +912 -248
- package/package.json +1 -1
package/bin/switchboard.js
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The `switchboard` bin
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the checkout — where npx prefers the workspace over the registry — found no
|
|
8
|
-
// link and died with `sh: switchboard: command not found`. The published package
|
|
9
|
-
// always carries the bundle, so there this is one extra module load; a checkout
|
|
10
|
-
// that has not built the package is told what to run.
|
|
2
|
+
// The published `switchboard` bin hands the process to the bundle beside it.
|
|
3
|
+
// In a repository checkout npm resolves the package name to this workspace too,
|
|
4
|
+
// but an ignored dist/ may be older than source. The checkout therefore never
|
|
5
|
+
// trusts dist: operators run the root source script, while a tarball runs only
|
|
6
|
+
// the bundle that its build packed.
|
|
11
7
|
import { existsSync } from "node:fs";
|
|
12
8
|
import { fileURLToPath } from "node:url";
|
|
13
9
|
|
|
14
10
|
const bundle = new URL("../dist/cli.js", import.meta.url);
|
|
11
|
+
const checkoutRoot = new URL("../../../", import.meta.url);
|
|
12
|
+
const runsFromCheckout =
|
|
13
|
+
existsSync(new URL("project.json", checkoutRoot)) && existsSync(new URL("src/cli.ts", checkoutRoot));
|
|
15
14
|
|
|
16
|
-
if (
|
|
15
|
+
if (runsFromCheckout) {
|
|
17
16
|
console.error(
|
|
18
|
-
|
|
17
|
+
"switchboard: npx resolved the package name to this checkout; its ignored dist/ is not authoritative. Run `npm run --silent cli -- <group> <verb> …` from the checkout root instead.",
|
|
19
18
|
);
|
|
20
19
|
process.exit(1);
|
|
21
20
|
}
|
|
22
21
|
|
|
22
|
+
if (!existsSync(bundle)) {
|
|
23
|
+
console.error(`switchboard: ${fileURLToPath(bundle)} is missing — the published package is incomplete.`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
// From here the bundle is the script: its entry claim (src/invokedAsScript.ts)
|
|
24
28
|
// and its usage spelling (`programName`, src/cli.ts) read argv[1].
|
|
25
29
|
process.argv[1] = fileURLToPath(bundle);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
type WaitUntilContext = Pick<DurableObjectState, "waitUntil">;
|
|
2
|
+
|
|
3
|
+
const TASKS = Symbol.for("switchboard.memory-worker.pending-background-tasks");
|
|
4
|
+
const REGISTRATIONS = Symbol.for("switchboard.memory-worker.background-task-registrations");
|
|
5
|
+
type PendingTasks = Map<Promise<unknown>, string>;
|
|
6
|
+
type Registrations = string[];
|
|
7
|
+
|
|
8
|
+
type DiagnosticRoot = typeof globalThis & {
|
|
9
|
+
[TASKS]?: PendingTasks;
|
|
10
|
+
[REGISTRATIONS]?: Registrations;
|
|
11
|
+
};
|
|
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
|
+
|
|
34
|
+
/** Keep deliberately detached Worker work in the runtime's request lifetime.
|
|
35
|
+
* The process-wide label set is also the test seam: a case cannot finish while
|
|
36
|
+
* work it started is still able to contend with the next case. */
|
|
37
|
+
export function holdBackgroundTask(context: WaitUntilContext, label: string, task: Promise<unknown>): void {
|
|
38
|
+
const pending = pendingTasks();
|
|
39
|
+
root()[REGISTRATIONS]?.push(label);
|
|
40
|
+
const held = task.finally(() => pending.delete(held));
|
|
41
|
+
pending.set(held, label);
|
|
42
|
+
context.waitUntil(held);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertNoPendingBackgroundTasks(): void {
|
|
46
|
+
const labels = [...pendingTasks().values()];
|
|
47
|
+
if (labels.length === 0) return;
|
|
48
|
+
throw new Error(`test ended with ${labels.length} pending background task(s): ${labels.join(", ")}`);
|
|
49
|
+
}
|
|
@@ -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
|
}
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
selectReclaim,
|
|
79
79
|
} from "../../src/core/runLedger/decisions.ts";
|
|
80
80
|
import { intakeReceiptRetentionMs, minutesToMs, PLANE } from "../../src/core/budgets.ts";
|
|
81
|
+
import { holdBackgroundTask } from "./backgroundTasks.ts";
|
|
81
82
|
import {
|
|
82
83
|
causeOfClose,
|
|
83
84
|
causeOfReclaim,
|
|
@@ -2058,7 +2059,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2058
2059
|
/** The admission ask (`POST /plane/admit`, record 0064 "The queue"): one
|
|
2059
2060
|
* transaction decides and writes — `admitted` reserves the thread,
|
|
2060
2061
|
* `queued` stores the request under the minted id. */
|
|
2061
|
-
planeAdmit(
|
|
2062
|
+
async planeAdmit(
|
|
2062
2063
|
post: {
|
|
2063
2064
|
runId: string;
|
|
2064
2065
|
requester: string;
|
|
@@ -2070,7 +2071,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2070
2071
|
reaskMs?: number;
|
|
2071
2072
|
},
|
|
2072
2073
|
now: number,
|
|
2073
|
-
): PlaneAskAnswer {
|
|
2074
|
+
): Promise<PlaneAskAnswer> {
|
|
2074
2075
|
let answer!: PlaneAskAnswer;
|
|
2075
2076
|
this.ctx.storage.transactionSync(() => {
|
|
2076
2077
|
if (post.reaskMs !== undefined)
|
|
@@ -2089,7 +2090,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2089
2090
|
this.applyPlaneWrites(decision.writes);
|
|
2090
2091
|
answer = planeAskAnswerOf(decision, post.runId);
|
|
2091
2092
|
});
|
|
2092
|
-
if (answer.kind === "queued")
|
|
2093
|
+
if (answer.kind === "queued") await this.settlePlaneReaskAlarmAfterCommit(now);
|
|
2093
2094
|
console.log(
|
|
2094
2095
|
`[plane/admit] ${post.threadKey} → ${answer.kind}${answer.kind === "queued" ? ` position ${answer.position}` : ""} (run ${post.runId})`,
|
|
2095
2096
|
);
|
|
@@ -2152,7 +2153,10 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2152
2153
|
|
|
2153
2154
|
/** A refusal-by-name the bot met at attach or exec (`POST /plane/observe`,
|
|
2154
2155
|
* record 0064): an admitted run re-enters the queue at its old position. */
|
|
2155
|
-
planeObserve(
|
|
2156
|
+
async planeObserve(
|
|
2157
|
+
post: { runId: string; resident: string; refusal: string },
|
|
2158
|
+
now: number,
|
|
2159
|
+
): Promise<{ reentered: boolean }> {
|
|
2156
2160
|
let reentered = false;
|
|
2157
2161
|
this.ctx.storage.transactionSync(() => {
|
|
2158
2162
|
const decision = decide(this.planeState(), {
|
|
@@ -2165,7 +2169,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2165
2169
|
this.applyPlaneWrites(decision.writes);
|
|
2166
2170
|
reentered = decision.writes.length > 0;
|
|
2167
2171
|
});
|
|
2168
|
-
if (reentered)
|
|
2172
|
+
if (reentered) await this.settlePlaneReaskAlarmAfterCommit(now);
|
|
2169
2173
|
console.log(
|
|
2170
2174
|
`[plane/observe] run ${post.runId} on ${post.resident}: ${post.refusal.slice(0, 60)} — ${reentered ? "re-entered" : "no-op"}`,
|
|
2171
2175
|
);
|
|
@@ -2194,6 +2198,18 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2194
2198
|
if (set === null || set > due) await this.ctx.storage.setAlarm(due);
|
|
2195
2199
|
}
|
|
2196
2200
|
|
|
2201
|
+
/** Settle alarm I/O without contradicting the plane transaction that already
|
|
2202
|
+
* committed. A scheduling failure may delay a re-ask until another wake,
|
|
2203
|
+
* but returning an error would make the caller proceed or retry while the
|
|
2204
|
+
* durable queued row remains eligible for admission. */
|
|
2205
|
+
private async settlePlaneReaskAlarmAfterCommit(now: number): Promise<void> {
|
|
2206
|
+
try {
|
|
2207
|
+
await this.ensurePlaneReaskAlarm(now);
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
console.error("[plane/alarm] re-ask scheduling failed after the plane state committed", err);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2197
2213
|
/** The earliest instant the plane must wake at (record 0064): the
|
|
2198
2214
|
* earliest hosting deadline, lease end or re-ask across its rows. A due
|
|
2199
2215
|
* already past re-offers at the re-ask cadence, never in a hot loop, and
|
|
@@ -2372,21 +2388,24 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2372
2388
|
|
|
2373
2389
|
/** The transport's push (record 0064, "Where it lives"): committed effects
|
|
2374
2390
|
* are pushed to the bot Worker over the service binding, which forwards to
|
|
2375
|
-
* the container.
|
|
2376
|
-
*
|
|
2391
|
+
* the container. The response does not wait for this best-effort push, but
|
|
2392
|
+
* the actor does: waitUntil keeps its I/O inside this request's lifetime so
|
|
2393
|
+
* it cannot contend with an unrelated request after the caller moves on. */
|
|
2377
2394
|
private pushPlaneEffects(effects: PlaneEffect[]): void {
|
|
2378
2395
|
if (effects.length === 0) return;
|
|
2379
2396
|
const bot = this.env.BOT;
|
|
2380
2397
|
if (!bot) return;
|
|
2381
|
-
|
|
2382
|
-
.
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2398
|
+
const delivery = Promise.resolve()
|
|
2399
|
+
.then(() =>
|
|
2400
|
+
bot.fetch("https://bot/plane/effects", {
|
|
2401
|
+
method: "POST",
|
|
2402
|
+
headers: {
|
|
2403
|
+
"content-type": "application/json",
|
|
2404
|
+
authorization: `Bearer ${this.env.MEMORY_TOKEN ?? ""}`,
|
|
2405
|
+
},
|
|
2406
|
+
body: JSON.stringify({ effects }),
|
|
2407
|
+
}),
|
|
2408
|
+
)
|
|
2390
2409
|
.then((r) => {
|
|
2391
2410
|
if (!r.ok)
|
|
2392
2411
|
console.warn(
|
|
@@ -2398,6 +2417,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2398
2417
|
`[plane/push] ${effects.length} effect(s) not delivered: ${err instanceof Error ? err.message : String(err)} — they ride the next heartbeat`,
|
|
2399
2418
|
);
|
|
2400
2419
|
});
|
|
2420
|
+
holdBackgroundTask(this.ctx, `plane effect push (${effects.map((effect) => effect.id).join(", ")})`, delivery);
|
|
2401
2421
|
}
|
|
2402
2422
|
|
|
2403
2423
|
/** The unacknowledged effects, oldest first, at most `PLANE_EFFECTS_PER_ANSWER`
|
|
@@ -5027,11 +5027,32 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5027
5027
|
* deploy's alone. Answers `restarted` when a stop was issued, else why
|
|
5028
5028
|
* not. */
|
|
5029
5029
|
private async reconcileImage(where: "attach" | "refresh" | "deploy", force = false): Promise<ImageReconcileResult> {
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5030
|
+
const active = await this.isRuntimeActive().catch((err) => {
|
|
5031
|
+
console.log(`image-reconcile (${where}): activity probe failed — preserving the pending report: ${errMsg(err)}`);
|
|
5032
|
+
return null;
|
|
5033
|
+
});
|
|
5034
|
+
// A failed probe proves neither state. Preserve the marker and its drain
|
|
5035
|
+
// hold until a later reconcile confirms inactivity or hydrates a fresh
|
|
5036
|
+
// active container. An attach with that marker must fail closed even after
|
|
5037
|
+
// the drain backstop reopens: admitting it could wake a pre-deploy image.
|
|
5038
|
+
if (active === null) {
|
|
5039
|
+
if (
|
|
5040
|
+
where === "attach" &&
|
|
5041
|
+
(await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY)) !== undefined
|
|
5042
|
+
) {
|
|
5043
|
+
console.log(
|
|
5044
|
+
"image-stale (attach): activity could not be verified while a new-image report is pending — the attach is refused",
|
|
5045
|
+
);
|
|
5046
|
+
return "stale";
|
|
5047
|
+
}
|
|
5048
|
+
return "deferred";
|
|
5049
|
+
}
|
|
5050
|
+
if (!active) {
|
|
5051
|
+
// An inactive container's next start is on the deployed image by
|
|
5052
|
+
// construction. Report on every reconcile: the deploy may have marked
|
|
5053
|
+
// it pending after an earlier idle cycle, and the drain itself refuses
|
|
5054
|
+
// the attach that would otherwise wake it and provide another reporter.
|
|
5055
|
+
await this.reportPendingImageCurrent(where);
|
|
5035
5056
|
return "inactive";
|
|
5036
5057
|
}
|
|
5037
5058
|
const last = THREAD_USERS[THREAD_USERS.length - 1];
|
|
@@ -5091,12 +5112,13 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5091
5112
|
/** The report a held drain waits for (issue 1931): when the deploy's
|
|
5092
5113
|
* reconcile could not verify this resident's fresh container on the new
|
|
5093
5114
|
* image, a marker stays in storage and the registry holds the drain. The
|
|
5094
|
-
* fact the report stands on is
|
|
5095
|
-
* the
|
|
5096
|
-
*
|
|
5097
|
-
*
|
|
5098
|
-
* a transient failure keeps it for
|
|
5099
|
-
* `until` is the backstop for a report
|
|
5115
|
+
* fact the report stands on is either an inactive runtime, whose next start
|
|
5116
|
+
* uses the deployed image by construction, or the active replacement's own
|
|
5117
|
+
* completed hydration: the fresh container has restored its snapshot and
|
|
5118
|
+
* reached `warm`. A stop alone does not report (issue 2101). The marker is
|
|
5119
|
+
* deleted only after a successful report: a transient failure keeps it for
|
|
5120
|
+
* the next reconcile, and the drain's `until` is the backstop for a report
|
|
5121
|
+
* that never lands. */
|
|
5100
5122
|
private async reportPendingImageCurrent(where: string): Promise<boolean> {
|
|
5101
5123
|
const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
|
|
5102
5124
|
if (pending === undefined) return true;
|
|
@@ -5125,9 +5147,9 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5125
5147
|
* an active, quiet container is always cycled, but that stop is not a report.
|
|
5126
5148
|
* The marker survives until the replacement hydrates and reaches `warm` on
|
|
5127
5149
|
* the deploy's image (issue 2101: the old probe raced that restore and lost).
|
|
5128
|
-
* `verified` true is the fact the reopen may stand on
|
|
5129
|
-
*
|
|
5130
|
-
* `reportPendingImageCurrent`, never
|
|
5150
|
+
* `verified` true is the fact the reopen may stand on: inactivity reports
|
|
5151
|
+
* at once, while an active container's cycle leaves a hold until its fresh
|
|
5152
|
+
* hydration calls `reportPendingImageCurrent`, never until a timer.
|
|
5131
5153
|
*
|
|
5132
5154
|
* The hold lands BEFORE the marker: the marker is what lets any concurrent
|
|
5133
5155
|
* attach/refresh reconcile report, and a report that reaches the registry
|
|
@@ -5139,10 +5161,9 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5139
5161
|
await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
|
|
5140
5162
|
const result = await this.reconcileImage("deploy", true);
|
|
5141
5163
|
if (result === "deferred") return { result, verified: false };
|
|
5142
|
-
//
|
|
5143
|
-
//
|
|
5144
|
-
//
|
|
5145
|
-
// read rather than an unconditional false.
|
|
5164
|
+
// Inactivity reports synchronously; a successful stop does not. A
|
|
5165
|
+
// concurrent fresh start may also have hydrated while this reconcile
|
|
5166
|
+
// yielded, hence the storage read rather than a verdict from `result`.
|
|
5146
5167
|
const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
|
|
5147
5168
|
return { result, verified };
|
|
5148
5169
|
}
|