Skip to content

Commit 3aa2088

Browse files
authored
Merge pull request #494 from lukstafi/ludics/task-304a02a6-s1/root
fix(mag): briefing queues /compact after feedback-digest (task-304a02a6)
2 parents 778d7c5 + 790769c commit 3aa2088

2 files changed

Lines changed: 101 additions & 15 deletions

File tree

src/mag-auto-compact.test.ts

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2-
import { mkdirSync, readFileSync } from "fs";
1+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
33
import { join } from "path";
44
import { withSyntheticHarness } from "./test-utils.ts";
55

@@ -27,16 +27,91 @@ function readQueue(): Record<string, unknown>[] {
2727
}
2828

2929
describe("magBriefing auto-compact follow-up", () => {
30-
test("enqueues /compact directly behind the briefing entry", async () => {
30+
test("enqueues /compact as the final item, with feedback-digest between briefing and /compact", async () => {
31+
// Harness condition: clean state — no pre-existing pending feedback-digest
32+
// in queue.jsonl, no cooldown state file. The synthetic harness creates a
33+
// fresh tmp dir each test, so both gates open and tryQueueFeedbackDigest
34+
// actually enqueues. If that condition stops holding, items[1] would not be
35+
// "feedback-digest" and the middle-slot assertion would fail loudly.
3136
const { magBriefing } = await import("./mag.ts");
3237
magBriefing(false);
3338

3439
const items = readQueue();
35-
// Must be at least: briefing, /compact (feedback-digest may follow).
36-
expect(items.length).toBeGreaterThanOrEqual(2);
40+
// briefing → feedback-digest → /compact. /compact must always land last
41+
// (the AC's invariant); feedback-digest in the middle is the ungated path.
42+
expect(items).toHaveLength(3);
3743
expect(items[0]!.action).toBe("briefing");
38-
expect(items[1]!.action).toBe("message");
39-
expect(items[1]!.content).toBe("/compact");
44+
expect(items[1]!.action).toBe("feedback-digest");
45+
expect(items[items.length - 1]!.action).toBe("message");
46+
expect(items[items.length - 1]!.content).toBe("/compact");
47+
});
48+
49+
test("when feedback-digest is gated, queue is briefing → /compact (length 2)", async () => {
50+
// Harness condition: pre-seed queue.jsonl with a pending feedback-digest
51+
// entry for "ludics" so queueHasPendingFeedbackDigest() returns true and
52+
// tryQueueFeedbackDigest short-circuits with { queued: false }. Without
53+
// this seed, digest would fire and the length-2 assertion would fail.
54+
const qf = join(getTmpDir(), "mag", "queue.jsonl");
55+
writeFileSync(
56+
qf,
57+
JSON.stringify({ id: "seed", action: "feedback-digest", repo: "ludics" }) + "\n",
58+
);
59+
60+
const { magBriefing } = await import("./mag.ts");
61+
magBriefing(false);
62+
63+
const items = readQueue();
64+
// Drop the pre-seeded sentinel; only assert on what magBriefing wrote.
65+
const written = items.slice(1);
66+
expect(written).toHaveLength(2);
67+
expect(written[0]!.action).toBe("briefing");
68+
expect(written[1]!.action).toBe("message");
69+
expect(written[1]!.content).toBe("/compact");
70+
// /compact must be last regardless of digest gating.
71+
expect(items[items.length - 1]!.action).toBe("message");
72+
expect(items[items.length - 1]!.content).toBe("/compact");
73+
});
74+
75+
test("/compact still enqueues when feedback-digest enqueue throws", async () => {
76+
// Harness condition: spy on queue.queueRequest to throw on the
77+
// feedback-digest action only (simulating a queue-lock timeout or
78+
// state-file write failure inside tryQueueFeedbackDigest). Without the
79+
// try/catch around the digest call, the throw would propagate out of
80+
// magBriefing before /compact is enqueued, leaving the queue with only
81+
// the briefing entry. Mutation: removing the try/catch makes this test
82+
// fail because /compact never lands and `errSpy` never sees the warning.
83+
const queueMod = await import("./queue.ts");
84+
const origQueueRequest = queueMod.queueRequest;
85+
let calls = 0;
86+
const requestSpy = spyOn(queueMod, "queueRequest").mockImplementation(
87+
((req: Parameters<typeof origQueueRequest>[0]) => {
88+
calls++;
89+
if (req.action === "feedback-digest") {
90+
throw new Error("simulated queue-lock timeout");
91+
}
92+
return origQueueRequest(req);
93+
}) as typeof origQueueRequest,
94+
);
95+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
96+
97+
try {
98+
const { magBriefing } = await import("./mag.ts");
99+
magBriefing(false);
100+
101+
const items = readQueue();
102+
// briefing was queued (call 1), feedback-digest threw (call 2),
103+
// /compact still queued (call 3). On disk: briefing + /compact only.
104+
expect(calls).toBeGreaterThanOrEqual(3);
105+
expect(items).toHaveLength(2);
106+
expect(items[0]!.action).toBe("briefing");
107+
expect(items[1]!.action).toBe("message");
108+
expect(items[1]!.content).toBe("/compact");
109+
const errLines = errSpy.mock.calls.map((c) => String(c[0] ?? ""));
110+
expect(errLines.some((l) => l.includes("feedback-digest enqueue failed"))).toBe(true);
111+
} finally {
112+
requestSpy.mockRestore();
113+
errSpy.mockRestore();
114+
}
40115
});
41116
});
42117

src/mag.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3314,17 +3314,28 @@ export function magBriefing(wait: boolean = true, timeout: number = 300): void {
33143314
const requestId = queueRequest({ action: "briefing" });
33153315
console.log(`Queued briefing request: ${requestId}`);
33163316

3317-
// Auto-compact after briefing — checkpoint compaction (task-a00fc0d9 /
3318-
// docs/proposals/auto-compact-after-checkpoints.md). Enqueued directly
3319-
// behind the briefing so it lands before any later automated enqueues.
3320-
queueRequest({ action: "message", content: "/compact" });
3321-
33223317
// Auto-queue feedback-digest once daily alongside the briefing trigger.
3323-
const fdResult = tryQueueFeedbackDigest("ludics");
3324-
if (fdResult.queued) {
3325-
console.error("ludics: briefing queued feedback-digest for ludics");
3318+
// Lands before /compact so digest can consume the briefing's in-flight
3319+
// context (task-304a02a6 / docs/proposals/task-304a02a6-reorder-briefing-auto-queue.md).
3320+
// Wrapped so a digest enqueue failure (queue-lock timeout, state-file write
3321+
// error) cannot suppress the unconditional /compact below — the auto-compact
3322+
// contract (task-a00fc0d9) requires /compact after every briefing.
3323+
try {
3324+
const fdResult = tryQueueFeedbackDigest("ludics");
3325+
if (fdResult.queued) {
3326+
console.error("ludics: briefing queued feedback-digest for ludics");
3327+
}
3328+
} catch (err) {
3329+
const msg = err instanceof Error ? err.message : String(err);
3330+
console.error(`ludics: feedback-digest enqueue failed: ${msg}`);
33263331
}
33273332

3333+
// Auto-compact after briefing — checkpoint compaction (task-a00fc0d9 /
3334+
// docs/proposals/auto-compact-after-checkpoints.md). Enqueued unconditionally
3335+
// as the LAST follow-up so the next session starts fresh, after
3336+
// feedback-digest has had a chance to consume the briefing context.
3337+
queueRequest({ action: "message", content: "/compact" });
3338+
33283339
if (!wait) {
33293340
console.log("Mag will process when ready");
33303341
return;

0 commit comments

Comments
 (0)