Skip to content

Commit f99d0af

Browse files
LanNguyenSinguyen-si-ppclaude
authored
fix(agent-memory-sync): reject cron step<=0 to stop scheduler infinite-loop hang (+ coverage) (#61)
* fix(agent-memory-sync): reject cron step<=0 to stop scheduler hang parsePart validated a step token against the field range via parseInteger(stepToken, min, max). For fields with min=0 (minute/hour/dayOfWeek) step=0 was accepted, and fillRange(result, min, max, 0) then looped forever (cursor += 0) — a typo'd operator cron string like '*/0 * * * *' hung the sync daemon unbounded. Validate the step independently as an integer >= 1 (new parseStep), with no upper bound: a step larger than the range simply collapses to the start value in a single terminating fillRange iteration. Adds tests for the rejected '*/0', the now-valid range-independent '*/90', and pins the existing throw for the unsupported lone-value 'a/n' step form. Refs: agent-memory task 124c89e0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(memory-router): assert default embed timeout is wired to fetch The embed-provider suite proved an abort throws but never that the DEFAULT_TIMEOUT_MS (5000) is the value handed to AbortSignal.timeout and that its signal reaches fetch. Spy over the real AbortSignal.timeout (call-through) to pin the 5000ms default and the explicit-override branch of `opts.timeoutMs ?? DEFAULT_TIMEOUT_MS`. Refs: agent-memory task 124c89e0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(memory-digest-cli): exercise the real generate action body The generate suite replaced the action with a spy, so the real body (scan → extract → generate → format → write/print + catch→exit) had ~0% coverage. Add integration tests driving the real registerGenerateCommand action against today-dated temp-dir fixtures: markdown --output write, --json --output write (also parsing non-default --days/--max), stdout print when --output is omitted, and the catch/process.exit(1) branch via an unwritable --output path. Stubs console.log/error and process.exit with save/restore. Tightens the tautological description assertion to the exact string. Refs: agent-memory task 124c89e0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lan Nguyen Si <nguyen-si@publicplan.de> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 80d6072 commit f99d0af

4 files changed

Lines changed: 282 additions & 5 deletions

File tree

packages/agent-memory-sync/src/memory-sync/scheduler.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function parsePart(value: string, min: number, max: number): Set<number> {
6565
const stepMatch = /^(\*|\d+(?:-\d+)?)\/(\d+)$/.exec(segment);
6666
if (stepMatch) {
6767
const [, rangeToken, stepToken] = stepMatch;
68-
const step = parseInteger(stepToken, min, max);
68+
const step = parseStep(stepToken);
6969
if (rangeToken === "*") {
7070
fillRange(result, min, max, step);
7171
} else {
@@ -109,6 +109,20 @@ function parseInteger(value: string, min: number, max: number): number {
109109
return parsed;
110110
}
111111

112+
// A step must be a positive integer, validated independently of the field's
113+
// min/max. Fields with min=0 (minute/hour/dayOfWeek) formerly accepted step=0
114+
// via parseInteger, and fillRange(..., 0) then looped forever (cursor += 0),
115+
// hanging the sync daemon on a typo'd cron string like '*/0 * * * *'. There is
116+
// deliberately no upper bound: a step larger than the range simply collapses to
117+
// the start value, which fillRange handles in a single terminating iteration.
118+
function parseStep(value: string): number {
119+
const parsed = Number(value);
120+
if (!Number.isInteger(parsed) || parsed < 1) {
121+
throw new CliError(`cron step '${value}' must be a positive integer (>= 1).`, 2);
122+
}
123+
return parsed;
124+
}
125+
112126
function fillRange(target: Set<number>, start: number, end: number, step: number): void {
113127
for (let cursor = start; cursor <= end; cursor += step) {
114128
target.add(cursor);

packages/agent-memory-sync/tests/unit/scheduler.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ test("parseCron: invalid — range with start > end throws CliError", () => {
8888
assert.throws(() => validateCronExpression("5-3 * * * *"), /invalid/);
8989
});
9090

91+
test("parseCron: invalid — step */0 is rejected (guards the infinite-loop hang)", () => {
92+
// Regression guard. Formerly '*/0' passed parseInteger for min=0 fields
93+
// (minute/hour/dayOfWeek), then fillRange(result, min, max, 0) looped forever
94+
// (cursor += 0), hanging the sync daemon on a typo'd cron string. The step is
95+
// now validated as an integer >= 1, independent of the field range. If the
96+
// guard is removed this assertion never returns — the call hangs (bounded only
97+
// by the CI job timeout), which still fails the run rather than passing.
98+
assert.throws(() => validateCronExpression("*/0 * * * *"), /must be a positive integer/);
99+
});
100+
101+
test("parseCron: step larger than the field range is valid (step is range-independent)", () => {
102+
// '*/90' on minutes: the step exceeds max=59, so only minute 0 matches — this
103+
// is valid, not an error. Formerly parseInteger(stepToken, 0, 59) rejected any
104+
// step > 59; the fix decouples the step from the field's min/max on purpose.
105+
assert.doesNotThrow(() => validateCronExpression("*/90 * * * *"));
106+
});
107+
108+
test("parseCron: single-value step form 'a/n' (e.g. 5/2) throws — lone-value step unsupported", () => {
109+
// Standard cron reads 5/2 as 5-max/2, but this parser routes the lone range
110+
// token through parseRange, whose missing end token fails parseInteger. Pin the
111+
// current behavior: it throws (rather than silently misbehaving or hanging).
112+
assert.throws(() => validateCronExpression("5/2 * * * *"), /outside the allowed range/);
113+
});
114+
91115
test("parseCron: invalid — non-numeric field value throws CliError", () => {
92116
assert.throws(() => validateCronExpression("* * * * abc"), /outside the allowed range/);
93117
});

packages/memory-digest-cli/tests/generate.test.ts

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import { test } from "node:test";
1111
import assert from "node:assert/strict";
12+
import * as fs from "node:fs/promises";
13+
import * as os from "node:os";
14+
import * as path from "node:path";
1215
import { Command } from "commander";
1316
import { registerGenerateCommand } from "../src/commands/generate.js";
1417

@@ -35,11 +38,11 @@ test("registerGenerateCommand: registers a subcommand named 'generate'", () => {
3538
assert.equal(generateCmd.name(), "generate");
3639
});
3740

38-
test("registerGenerateCommand: subcommand has a non-empty description", () => {
41+
test("registerGenerateCommand: subcommand has the expected description", () => {
3942
const { generateCmd } = buildProgram();
40-
assert.ok(
41-
generateCmd.description().length > 0,
42-
"description should not be empty"
43+
assert.equal(
44+
generateCmd.description(),
45+
"Generate a memory digest from markdown files"
4346
);
4447
});
4548

@@ -154,3 +157,167 @@ test("generate: all non-default flags can be passed together", async () => {
154157
assert.equal(capturedOptions!.recursive, true);
155158
assert.equal(capturedOptions!.json, true);
156159
});
160+
161+
// ─── real-action integration tests ──────────────────────────────────────────
162+
//
163+
// The tests above all replace the action handler with a spy, so the real
164+
// action body in generate.ts (scan → extract → generate → format →
165+
// write/print, plus the catch/process.exit path) is never exercised. The
166+
// tests below run the REAL action registered by registerGenerateCommand
167+
// against real temp-dir fixtures on disk.
168+
//
169+
// The scanner only matches files literally named `YYYY-MM-DD.md` and filters
170+
// by the date encoded in the filename (not mtime), so fixtures must use
171+
// today's date computed at test-run time -- a hardcoded date would drift out
172+
// of the default 7-day window and start failing later.
173+
174+
function todayFileName(): string {
175+
return `${new Date().toISOString().slice(0, 10)}.md`;
176+
}
177+
178+
// Sentinel thrown by the stubbed process.exit so the real action's async
179+
// control flow stops exactly where the real process.exit would have
180+
// terminated the process, without actually killing the test runner.
181+
class ProcessExitSentinel extends Error {
182+
code: number | undefined;
183+
constructor(code: number | undefined) {
184+
super(`process.exit called with code ${code}`);
185+
this.code = code;
186+
}
187+
}
188+
189+
// Runs `fn` with console.log/console.error/process.exit stubbed out, and
190+
// returns what was captured. console.log calls are recorded (the action's
191+
// stdout print branch uses it); console.error is silenced (the action logs
192+
// progress and error messages through it); process.exit is replaced with a
193+
// spy that records the exit code and throws ProcessExitSentinel so the
194+
// action's control flow halts the same way a real process.exit would, then
195+
// swallows that specific sentinel so the test doesn't fail on it.
196+
async function withStubbedIO(
197+
fn: () => Promise<void>
198+
): Promise<{ logs: unknown[][]; exitCode: number | undefined; exitCalled: boolean }> {
199+
const originalLog = console.log;
200+
const originalError = console.error;
201+
const originalExit = process.exit;
202+
203+
const logs: unknown[][] = [];
204+
let exitCode: number | undefined;
205+
let exitCalled = false;
206+
207+
console.log = (...args: unknown[]) => {
208+
logs.push(args);
209+
};
210+
console.error = () => {};
211+
process.exit = ((code?: number) => {
212+
exitCalled = true;
213+
exitCode = code;
214+
throw new ProcessExitSentinel(code);
215+
}) as typeof process.exit;
216+
217+
try {
218+
await fn();
219+
} catch (error) {
220+
if (!(error instanceof ProcessExitSentinel)) {
221+
throw error;
222+
}
223+
} finally {
224+
console.log = originalLog;
225+
console.error = originalError;
226+
process.exit = originalExit;
227+
}
228+
229+
return { logs, exitCode, exitCalled };
230+
}
231+
232+
test("generate: real action writes a markdown digest to --output", async () => {
233+
const { program } = buildProgram();
234+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "generate-real-md-"));
235+
await fs.writeFile(
236+
path.join(tmpDir, todayFileName()),
237+
"# Today\n\n- Decided to adopt the new digest format\n- Fixed a bug in the extractor\n",
238+
"utf-8"
239+
);
240+
const outFile = path.join(tmpDir, "out.md");
241+
242+
const { exitCalled } = await withStubbedIO(async () => {
243+
await program.parseAsync(["generate", "--dir", tmpDir, "--output", outFile], {
244+
from: "user",
245+
});
246+
});
247+
248+
assert.equal(exitCalled, false, "process.exit must not be called on success");
249+
const content = await fs.readFile(outFile, "utf-8");
250+
assert.ok(content.length > 0, "output file should be non-empty");
251+
});
252+
253+
test("generate: real action writes valid JSON to --output with --json (and parses --days/--max)", async () => {
254+
const { program } = buildProgram();
255+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "generate-real-json-"));
256+
await fs.writeFile(
257+
path.join(tmpDir, todayFileName()),
258+
"# Today\n\n- Learned something new\n- Shipped a fix\n",
259+
"utf-8"
260+
);
261+
const outFile = path.join(tmpDir, "out.json");
262+
263+
const { exitCalled } = await withStubbedIO(async () => {
264+
await program.parseAsync(
265+
[
266+
"generate",
267+
"--dir", tmpDir,
268+
"--output", outFile,
269+
"--json",
270+
"--days", "30",
271+
"--max", "5",
272+
],
273+
{ from: "user" }
274+
);
275+
});
276+
277+
assert.equal(exitCalled, false, "process.exit must not be called on success");
278+
const content = await fs.readFile(outFile, "utf-8");
279+
assert.doesNotThrow(() => JSON.parse(content), "written file must be valid JSON");
280+
});
281+
282+
test("generate: real action prints the digest to stdout when --output is omitted", async () => {
283+
const { program } = buildProgram();
284+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "generate-real-stdout-"));
285+
await fs.writeFile(
286+
path.join(tmpDir, todayFileName()),
287+
"# Today\n\n- Did a thing worth remembering\n",
288+
"utf-8"
289+
);
290+
291+
const { logs, exitCalled } = await withStubbedIO(async () => {
292+
await program.parseAsync(["generate", "--dir", tmpDir], { from: "user" });
293+
});
294+
295+
assert.equal(exitCalled, false, "process.exit must not be called on success");
296+
assert.equal(logs.length, 1, "console.log should be called exactly once with the digest");
297+
assert.ok(
298+
typeof logs[0][0] === "string" && logs[0][0].length > 0,
299+
"printed digest should be a non-empty string"
300+
);
301+
});
302+
303+
test("generate: real action calls process.exit(1) when --output cannot be written", async () => {
304+
const { program } = buildProgram();
305+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "generate-real-exit-"));
306+
await fs.writeFile(
307+
path.join(tmpDir, todayFileName()),
308+
"# Today\n\n- A note\n",
309+
"utf-8"
310+
);
311+
// Parent directory of the output path does not exist, so fs.writeFile
312+
// rejects with ENOENT and the action's catch block must run.
313+
const badOutput = path.join(tmpDir, "does-not-exist-subdir", "out.md");
314+
315+
const { exitCode, exitCalled } = await withStubbedIO(async () => {
316+
await program.parseAsync(["generate", "--dir", tmpDir, "--output", badOutput], {
317+
from: "user",
318+
});
319+
});
320+
321+
assert.equal(exitCalled, true, "process.exit should have been called");
322+
assert.equal(exitCode, 1, "process.exit should be called with code 1");
323+
});

packages/memory-router/tests/unit/embed-provider.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,78 @@ test('embedBatch: res.text() failure in non-ok path falls back to "<no body>"',
240240
}
241241
});
242242

243+
test('embedBatch: omitted timeoutMs wires the DEFAULT_TIMEOUT_MS (5000) into AbortSignal.timeout', async () => {
244+
// Not just "an abort throws" — pin that the 5000ms default is the value passed
245+
// to AbortSignal.timeout and that its signal reaches fetch. Spies over the real
246+
// AbortSignal.timeout (call-through so fetch's init stays well-formed) and fetch.
247+
const origFetch = (globalThis as { fetch?: typeof fetch }).fetch;
248+
const origTimeout = AbortSignal.timeout;
249+
let capturedTimeoutArg: number | undefined;
250+
let capturedSignal: AbortSignal | undefined;
251+
252+
try {
253+
(AbortSignal as { timeout: (ms: number) => AbortSignal }).timeout = (ms: number) => {
254+
capturedTimeoutArg = ms;
255+
return origTimeout.call(AbortSignal, ms);
256+
};
257+
(globalThis as { fetch: typeof fetch }).fetch = (async (_url: string, init?: RequestInit) => {
258+
capturedSignal = init?.signal ?? undefined;
259+
return {
260+
ok: true,
261+
status: 200,
262+
statusText: 'OK',
263+
json: async () => ({ data: [] }),
264+
text: async () => '',
265+
} as unknown as Response;
266+
}) as unknown as typeof fetch;
267+
268+
await embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'] }); // timeoutMs omitted
269+
270+
assert.equal(capturedTimeoutArg, 5000, 'default embed timeout must be 5000ms');
271+
assert.ok(capturedSignal instanceof AbortSignal, 'the timeout signal must be wired to fetch');
272+
} finally {
273+
AbortSignal.timeout = origTimeout;
274+
if (origFetch) {
275+
(globalThis as { fetch: typeof fetch }).fetch = origFetch;
276+
} else {
277+
delete (globalThis as { fetch?: typeof fetch }).fetch;
278+
}
279+
}
280+
});
281+
282+
test('embedBatch: explicit timeoutMs overrides the default', async () => {
283+
// Pins both sides of the `opts.timeoutMs ?? DEFAULT_TIMEOUT_MS` branch so a
284+
// mutation collapsing it to a constant fails.
285+
const origFetch = (globalThis as { fetch?: typeof fetch }).fetch;
286+
const origTimeout = AbortSignal.timeout;
287+
let capturedTimeoutArg: number | undefined;
288+
289+
try {
290+
(AbortSignal as { timeout: (ms: number) => AbortSignal }).timeout = (ms: number) => {
291+
capturedTimeoutArg = ms;
292+
return origTimeout.call(AbortSignal, ms);
293+
};
294+
(globalThis as { fetch: typeof fetch }).fetch = (async () => ({
295+
ok: true,
296+
status: 200,
297+
statusText: 'OK',
298+
json: async () => ({ data: [] }),
299+
text: async () => '',
300+
} as unknown as Response)) as unknown as typeof fetch;
301+
302+
await embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'], timeoutMs: 1234 });
303+
304+
assert.equal(capturedTimeoutArg, 1234, 'explicit timeoutMs must win over the default');
305+
} finally {
306+
AbortSignal.timeout = origTimeout;
307+
if (origFetch) {
308+
(globalThis as { fetch: typeof fetch }).fetch = origFetch;
309+
} else {
310+
delete (globalThis as { fetch?: typeof fetch }).fetch;
311+
}
312+
}
313+
});
314+
243315
// ─── resolveProviderConfig ───────────────────────────────────────────────────
244316

245317
test('resolveProviderConfig: OPENAI_API_KEY missing → returns null', () => {

0 commit comments

Comments
 (0)