Skip to content

Commit 664d147

Browse files
mason: fix smart-note lifecycle edges
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent b629a7a commit 664d147

11 files changed

Lines changed: 303 additions & 30 deletions

File tree

packages/plugin/src/features/magic-context/dreamer/evaluate-smart-notes.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ async function compileNote(
187187
sessionDirectory: args.sessionDirectory,
188188
projectIdentity: args.projectIdentity,
189189
note,
190-
capabilities: createSmartNoteCapabilities({ projectRoot, signal: controller.signal }),
190+
capabilityFactory: (signal) => createSmartNoteCapabilities({ projectRoot, signal }),
191+
signal: controller.signal,
191192
deadline: args.deadline,
192193
model: args.model,
193194
fallbackModels: args.fallbackModels,
@@ -261,7 +262,7 @@ async function runLivenessCheck(
261262
try {
262263
const result = await runCompiledSmartNoteCheck({
263264
compiledCheck: note.compiledCheck,
264-
capabilities: createSmartNoteCapabilities({ projectRoot, signal: controller.signal }),
265+
capabilityFactory: (signal) => createSmartNoteCapabilities({ projectRoot, signal }),
265266
signal: controller.signal,
266267
timeoutMs: 2_000,
267268
});

packages/plugin/src/features/magic-context/smart-notes/capabilities.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface SmartNoteCapabilityApi {
2424
httpGet(url: string): Promise<{ status: number; body: string }>;
2525
}
2626

27+
export type SmartNoteCapabilityFactory = (signal: AbortSignal) => SmartNoteCapabilityApi;
28+
2729
export interface SmartNoteCapabilitiesOptions {
2830
projectRoot: string;
2931
signal: AbortSignal;

packages/plugin/src/features/magic-context/smart-notes/compiler.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { log } from "../../../shared/logger";
88
import { modelBodyField } from "../../../shared/resolve-fallbacks";
99
import type { Database } from "../../../shared/sqlite";
1010
import { recordChildInvocation } from "../subagent-token-capture";
11-
import type { SmartNoteCapabilityApi } from "./capabilities";
11+
import type { SmartNoteCapabilityFactory } from "./capabilities";
1212
import { SMART_NOTE_COMPILER_SYSTEM_PROMPT } from "./compiler-prompt";
1313
import { runCompiledSmartNoteCheck } from "./sandbox-runner";
1414
import type {
@@ -24,7 +24,8 @@ interface CompileSmartNoteArgs {
2424
sessionDirectory: string | undefined;
2525
projectIdentity: string;
2626
note: { id: number; content: string; surfaceCondition: string | null };
27-
capabilities: SmartNoteCapabilityApi;
27+
capabilityFactory: SmartNoteCapabilityFactory;
28+
signal: AbortSignal;
2829
deadline: number;
2930
model?: string;
3031
fallbackModels?: readonly string[];
@@ -149,7 +150,8 @@ Remember: output only the JSON object described by the system prompt.`;
149150
}
150151
const dryRun = await runCompiledSmartNoteCheck({
151152
compiledCheck,
152-
capabilities: args.capabilities,
153+
capabilityFactory: args.capabilityFactory,
154+
signal: args.signal,
153155
timeoutMs: 2_000,
154156
});
155157
if (!dryRun.ok) {

packages/plugin/src/features/magic-context/smart-notes/runner.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ export async function runDueCompiledSmartNoteChecks(
7979
try {
8080
const result = await runCompiledSmartNoteCheck({
8181
compiledCheck: note.compiledCheck,
82-
capabilities: createSmartNoteCapabilities({
83-
projectRoot: args.projectRoot,
84-
signal: controller.signal,
85-
}),
82+
capabilityFactory: (signal) =>
83+
createSmartNoteCapabilities({
84+
projectRoot: args.projectRoot,
85+
signal,
86+
}),
8687
signal: controller.signal,
8788
timeoutMs: Math.min(2_000, remaining),
8889
});

packages/plugin/src/features/magic-context/smart-notes/sandbox-runner.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22

33
import type { SmartNoteCapabilityApi } from "./capabilities";
44
import { runCompiledSmartNoteCheck } from "./sandbox-runner";
5+
import { SmartNoteNetworkError } from "./types";
56

67
const fakeCap: SmartNoteCapabilityApi = {
78
readFile: async (path) => (path === "ready.txt" ? "ready" : null),
@@ -37,6 +38,67 @@ describe("compiled smart-note QuickJS runner", () => {
3738
expect(result.ok).toBe(false);
3839
});
3940

41+
test("aborts host capabilities on the run timeout and frees the shared lock", async () => {
42+
await runCompiledSmartNoteCheck({
43+
compiledCheck: `function check() { return { met: true }; }`,
44+
capabilities: fakeCap,
45+
});
46+
47+
const startedAt = Date.now();
48+
const timedOut = (await Promise.race([
49+
runCompiledSmartNoteCheck({
50+
compiledCheck: `function check(cap) { cap.httpGet("https://example.test/"); return { met: false }; }`,
51+
capabilityFactory: (signal) => ({
52+
...fakeCap,
53+
httpGet: () =>
54+
new Promise((_resolve, reject) => {
55+
const abort = () =>
56+
reject(new SmartNoteNetworkError("SMART_NOTE_NETWORK: aborted"));
57+
if (signal.aborted) {
58+
abort();
59+
return;
60+
}
61+
signal.addEventListener("abort", abort, { once: true });
62+
}),
63+
}),
64+
timeoutMs: 100,
65+
}),
66+
new Promise<never>((_, reject) =>
67+
setTimeout(
68+
() => reject(new Error("sandbox timeout did not abort the host capability")),
69+
1_000,
70+
),
71+
),
72+
])) as Awaited<ReturnType<typeof runCompiledSmartNoteCheck>>;
73+
const elapsed = Date.now() - startedAt;
74+
75+
expect(timedOut.ok).toBe(false);
76+
if (!timedOut.ok) {
77+
expect(timedOut.network).toBe(true);
78+
}
79+
expect(elapsed).toBeGreaterThanOrEqual(50);
80+
expect(elapsed).toBeLessThan(1_000);
81+
82+
const followup = (await Promise.race([
83+
runCompiledSmartNoteCheck({
84+
compiledCheck: `function check() { return { met: true }; }`,
85+
capabilities: fakeCap,
86+
}),
87+
new Promise<never>((_, reject) =>
88+
setTimeout(
89+
() =>
90+
reject(
91+
new Error(
92+
"follow-up sandbox run stayed blocked behind the timed-out host call",
93+
),
94+
),
95+
500,
96+
),
97+
),
98+
])) as Awaited<ReturnType<typeof runCompiledSmartNoteCheck>>;
99+
expect(followup).toEqual({ ok: true, result: { met: true } });
100+
});
101+
40102
test("serializes concurrent checks whose host calls suspend (shared-module asyncify safety)", async () => {
41103
// Regression for QuickJSUseAfterFree: the asyncify module has ONE
42104
// suspension stack; before serialization, two checks suspended in host

packages/plugin/src/features/magic-context/smart-notes/sandbox-runner.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type QuickJSHandle,
1515
} from "quickjs-emscripten";
1616

17-
import type { SmartNoteCapabilityApi } from "./capabilities";
17+
import type { SmartNoteCapabilityApi, SmartNoteCapabilityFactory } from "./capabilities";
1818
import { isSmartNoteNetworkError, type SmartNoteCheckResult } from "./types";
1919

2020
/**
@@ -58,7 +58,8 @@ function withSandboxLock<T>(fn: () => Promise<T>): Promise<T> {
5858

5959
export interface RunCompiledSmartNoteCheckOptions {
6060
compiledCheck: string;
61-
capabilities: SmartNoteCapabilityApi;
61+
capabilities?: SmartNoteCapabilityApi;
62+
capabilityFactory?: SmartNoteCapabilityFactory;
6263
signal?: AbortSignal;
6364
timeoutMs?: number;
6465
heapLimitBytes?: number;
@@ -84,13 +85,36 @@ const DEFAULT_TIMEOUT_MS = 2_000;
8485
const DEFAULT_HEAP_LIMIT_BYTES = 8 * 1024 * 1024;
8586
const DEFAULT_STACK_LIMIT_BYTES = 512 * 1024;
8687

88+
// Host calls can outlive the VM interrupt path, so any capability that touches
89+
// the outside world must listen to this run's controller. Otherwise one tarpit
90+
// request can keep the shared QuickJS module suspended past the sandbox budget
91+
// and block the next caller on the process-wide lock.
92+
function resolveCapabilitiesForRun(
93+
options: RunCompiledSmartNoteCheckOptions,
94+
signal: AbortSignal,
95+
): SmartNoteCapabilityApi {
96+
if (options.capabilityFactory) {
97+
return options.capabilityFactory(signal);
98+
}
99+
if (options.capabilities) {
100+
return options.capabilities;
101+
}
102+
throw new Error("smart-note check requires capabilities");
103+
}
104+
105+
function throwIfRunAborted(signal: AbortSignal): void {
106+
if (signal.aborted) {
107+
throw signal.reason ?? new Error("smart-note check aborted");
108+
}
109+
}
110+
87111
export async function runCompiledSmartNoteCheck(
88112
options: RunCompiledSmartNoteCheckOptions,
89113
): Promise<RunCompiledSmartNoteCheckResult> {
90114
// Serialize the actual sandbox work (see withSandboxLock): only one
91115
// asyncify-suspended eval may exist at a time on the shared module. The
92-
// per-check timeout starts INSIDE the lock so a check queued behind another
93-
// doesn't burn its own budget waiting for the lock.
116+
// per-check timeout and host-capability controller start INSIDE the lock so
117+
// a check queued behind another doesn't burn its own budget waiting.
94118
return withSandboxLock(() => runCompiledSmartNoteCheckLocked(options));
95119
}
96120

@@ -100,22 +124,29 @@ async function runCompiledSmartNoteCheckLocked(
100124
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
101125
const controller = new AbortController();
102126
const externalAbort = () => controller.abort(options.signal?.reason);
103-
options.signal?.addEventListener("abort", externalAbort, { once: true });
127+
if (options.signal?.aborted) {
128+
externalAbort();
129+
} else {
130+
options.signal?.addEventListener("abort", externalAbort, { once: true });
131+
}
104132
const timer = setTimeout(
105133
() => controller.abort(new Error("smart-note check timed out")),
106134
timeoutMs,
107135
);
108136
try {
137+
throwIfRunAborted(controller.signal);
138+
const capabilities = resolveCapabilitiesForRun(options, controller.signal);
109139
const deadline = Date.now() + timeoutMs;
110140
const quickjs = await getAsyncModule();
141+
throwIfRunAborted(controller.signal);
111142
const context = quickjs.newContext();
112143
try {
113144
context.runtime.setMemoryLimit(options.heapLimitBytes ?? DEFAULT_HEAP_LIMIT_BYTES);
114145
context.runtime.setMaxStackSize(options.stackLimitBytes ?? DEFAULT_STACK_LIMIT_BYTES);
115146
context.runtime.setInterruptHandler(
116147
() => controller.signal.aborted || Date.now() > deadline,
117148
);
118-
installCapabilityObject(context, options.capabilities);
149+
installCapabilityObject(context, capabilities);
119150
disableAmbientDynamicCode(context);
120151
const result = await evalCheck(context, options.compiledCheck);
121152
const checkResult = result as { met?: unknown } | null;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { nextSmartNoteCheckDueAt } from "./schedule";
4+
5+
function collectDeltas(
6+
cron: string,
7+
options: { now: number; floorMs: number; ceilingMs: number; hashPrefix: string },
8+
): number[] {
9+
return Array.from(
10+
{ length: 128 },
11+
(_, index) =>
12+
nextSmartNoteCheckDueAt(cron, {
13+
now: options.now,
14+
noteId: index + 1,
15+
hash: `${options.hashPrefix}-${index}`,
16+
floorMs: options.floorMs,
17+
ceilingMs: options.ceilingMs,
18+
}) - options.now,
19+
);
20+
}
21+
22+
describe("nextSmartNoteCheckDueAt", () => {
23+
test("keeps a floor-1ms schedule at or above the floor after jitter", () => {
24+
const floorMs = 60_000;
25+
const ceilingMs = 10 * floorMs;
26+
const deltas = collectDeltas("* * * * *", {
27+
now: Date.UTC(2026, 0, 1, 0, 0, 59, 999),
28+
floorMs,
29+
ceilingMs,
30+
hashPrefix: "floor",
31+
});
32+
33+
expect(Math.min(...deltas)).toBeGreaterThanOrEqual(floorMs);
34+
expect(Math.max(...deltas)).toBeLessThanOrEqual(ceilingMs);
35+
});
36+
37+
test("keeps a ceiling-clamped schedule at or below the ceiling after jitter", () => {
38+
const floorMs = 1_000;
39+
const ceilingMs = 60_000;
40+
const deltas = collectDeltas("0 * * * *", {
41+
now: Date.UTC(2026, 0, 1, 0, 0, 0, 0),
42+
floorMs,
43+
ceilingMs,
44+
hashPrefix: "ceiling",
45+
});
46+
47+
expect(Math.min(...deltas)).toBeGreaterThanOrEqual(floorMs);
48+
expect(Math.max(...deltas)).toBeLessThanOrEqual(ceilingMs);
49+
});
50+
});

packages/plugin/src/features/magic-context/smart-notes/schedule.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export function nextSmartNoteCheckDueAt(
2323
const rawNext = cron?.trim() ? nextDueAtMs(cron, now) : null;
2424
const rawDelta = rawNext ? rawNext - now : SMART_NOTE_CHECK_DEFAULT_INTERVAL_MS;
2525
const clamped = Math.min(ceilingMs, Math.max(floorMs, rawDelta));
26-
return now + clamped + deterministicJitterMs(clamped, options.noteId, options.hash);
26+
const jittered = clamped + deterministicJitterMs(clamped, options.noteId, options.hash);
27+
const bounded = Math.min(ceilingMs, Math.max(floorMs, jittered));
28+
return now + bounded;
2729
}
2830

2931
function deterministicJitterMs(intervalMs: number, noteId?: number, hash?: string | null): number {

packages/plugin/src/features/magic-context/smart-notes/ssrf-guard.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { describe, expect, test } from "bun:test";
1+
import { describe, expect, mock, test } from "bun:test";
22

3-
import { createPinnedLookup, type SmartNoteResolver, validateSmartNoteHttpUrl } from "./ssrf-guard";
3+
import {
4+
createPinnedLookup,
5+
guardedSmartNoteHttpGet,
6+
type SmartNoteResolver,
7+
validateSmartNoteHttpUrl,
8+
} from "./ssrf-guard";
9+
import { SmartNoteNetworkError } from "./types";
410

511
const signal = new AbortController().signal;
612

@@ -84,6 +90,80 @@ describe("smart-note SSRF guard", () => {
8490
"2606:2800:220:1:248:1893:25c8:1946",
8591
]);
8692
});
93+
94+
test("stops after a terminal per-target failure", async () => {
95+
const contacted: string[] = [];
96+
const requestAddress = mock(async (_validation, candidate) => {
97+
contacted.push(candidate.address);
98+
throw new SmartNoteNetworkError("SMART_NOTE_NETWORK: response body too large", {
99+
terminal: true,
100+
});
101+
});
102+
103+
const error = await guardedSmartNoteHttpGet("https://example.test/", {
104+
signal,
105+
resolver: resolver([
106+
{ address: "93.184.216.34", family: 4 },
107+
{ address: "1.1.1.1", family: 4 },
108+
]),
109+
requestAddress,
110+
}).catch((error) => error);
111+
112+
expect(error).toBeInstanceOf(SmartNoteNetworkError);
113+
expect((error as SmartNoteNetworkError).terminal).toBe(true);
114+
expect(contacted).toEqual(["93.184.216.34"]);
115+
expect(requestAddress.mock.calls).toHaveLength(1);
116+
});
117+
118+
test("advances to the next address after a connection-level failure", async () => {
119+
const contacted: string[] = [];
120+
const requestAddress = mock(async (_validation, candidate) => {
121+
contacted.push(candidate.address);
122+
if (candidate.address === "93.184.216.34") {
123+
throw new SmartNoteNetworkError("SMART_NOTE_NETWORK: connect ECONNREFUSED");
124+
}
125+
return { status: 200, body: "ok" };
126+
});
127+
128+
const response = await guardedSmartNoteHttpGet("https://example.test/", {
129+
signal,
130+
resolver: resolver([
131+
{ address: "93.184.216.34", family: 4 },
132+
{ address: "1.1.1.1", family: 4 },
133+
]),
134+
requestAddress,
135+
});
136+
137+
expect(response).toEqual({ status: 200, body: "ok" });
138+
expect(contacted).toEqual(["93.184.216.34", "1.1.1.1"]);
139+
expect(requestAddress.mock.calls).toHaveLength(2);
140+
});
141+
142+
test("caps the validated address fanout", async () => {
143+
const addresses = [
144+
"93.184.216.34",
145+
"1.1.1.1",
146+
"8.8.8.8",
147+
"151.101.1.69",
148+
"13.107.42.14",
149+
"208.67.222.222",
150+
].map((address) => ({ address, family: 4 as const }));
151+
const contacted: string[] = [];
152+
const requestAddress = mock(async (_validation, candidate) => {
153+
contacted.push(candidate.address);
154+
throw new SmartNoteNetworkError("SMART_NOTE_NETWORK: connect ECONNREFUSED");
155+
});
156+
157+
const error = await guardedSmartNoteHttpGet("https://example.test/", {
158+
signal,
159+
resolver: resolver(addresses),
160+
requestAddress,
161+
}).catch((error) => error);
162+
163+
expect(error).toBeInstanceOf(SmartNoteNetworkError);
164+
expect(contacted).toEqual(addresses.slice(0, 4).map((candidate) => candidate.address));
165+
expect(requestAddress.mock.calls).toHaveLength(4);
166+
});
87167
});
88168

89169
describe("createPinnedLookup", () => {

0 commit comments

Comments
 (0)