Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1d9e935
refactor(bash): replace wake outbox with reconciler
ibetitsmike Aug 31, 2026
bb57ed3
test(bash): remove wake outbox coverage
ibetitsmike Aug 31, 2026
3210acf
refactor(bash): add wake reconciler
ibetitsmike Aug 31, 2026
2cca576
refactor(bash): expose monitor wake snapshots
ibetitsmike Aug 31, 2026
10b41c7
refactor(bash): wire workspace wake reconciler
ibetitsmike Aug 31, 2026
d8d4daf
refactor(bash): remove wake outbox
ibetitsmike Aug 31, 2026
eea3182
refactor(bash): preserve wake prompt semantics
ibetitsmike Aug 31, 2026
6f2e11b
test(bash): cover reconciler invariants
ibetitsmike Aug 31, 2026
893c3fe
test(bash): run wake wiring coverage early
ibetitsmike Aug 31, 2026
7d5f601
test(bash): support partial process manager doubles
ibetitsmike Aug 31, 2026
aec06d1
fix(bash): persist runtime monitor failures
ibetitsmike Aug 31, 2026
d3a942b
test(bash): cover runtime monitor failures
ibetitsmike Aug 31, 2026
ac58215
chore(bash): satisfy wake reconciler lint
ibetitsmike Aug 31, 2026
4d9e39b
fix(bash): satisfy reconciler mutex typing
ibetitsmike Aug 31, 2026
209682e
test(bash): match synchronous wake snapshots
ibetitsmike Aug 31, 2026
e9efa7f
fix(bash): retry deferred monitor wakes
ibetitsmike Aug 31, 2026
5dcc022
fix(bash): filter delivered settlement tails
ibetitsmike Aug 31, 2026
8dde79b
test(bash): cover settlement tail frontiers
ibetitsmike Aug 31, 2026
444a7fa
chore(bash): modernize stale wake-store comments
ibetitsmike Aug 31, 2026
f20d1fa
refactor(bash): trim wake reconciler scaffolding
ibetitsmike Aug 31, 2026
b5235c7
fix(bash): guard canceled monitor generations
ibetitsmike Aug 31, 2026
0331652
fix(bash): reconcile retained monitor state
ibetitsmike Aug 31, 2026
1e07d48
test(bash): cover reconciler review regressions
ibetitsmike Aug 31, 2026
2277006
test(bash): accept monitor generation metadata
ibetitsmike Aug 31, 2026
2be0daa
test(bash): harden generation cleanup regression
ibetitsmike Aug 31, 2026
48694d4
test(bash): wire cancellation discard seam
ibetitsmike Aug 31, 2026
48408f1
test(bash): await cancellation persistence chain
ibetitsmike Aug 31, 2026
f08984a
test(bash): avoid unsafe asymmetric matcher
ibetitsmike Aug 31, 2026
a4c7008
fix(bash): close wake delivery races
ibetitsmike Aug 31, 2026
9a3aa91
test(bash): cover round two review races
ibetitsmike Aug 31, 2026
15090b2
fix(bash): recover monitor failure wakes
ibetitsmike Aug 31, 2026
1a8a61a
test(bash): cover round three review defects
ibetitsmike Aug 31, 2026
9679d14
test(bash): isolate non-probing frontier check
ibetitsmike Aug 31, 2026
49840d7
test(bash): isolate frontier probe from monitor loop
ibetitsmike Aug 31, 2026
ef089cd
test(bash): spy on frontier process lookup
ibetitsmike Aug 31, 2026
0a05618
test(bash): freeze frontier probe process state
ibetitsmike Aug 31, 2026
a75438b
test(bash): inject deterministic frontier process
ibetitsmike Aug 31, 2026
4f8abdc
fix(bash): cancel pending failure retries
ibetitsmike Aug 31, 2026
2476195
fix(bash): scope failure-persist tracking to live chains
ibetitsmike Aug 31, 2026
466a878
fix(bash): preserve monitor wake generations
ibetitsmike Aug 31, 2026
b234d73
test(bash): cover round five wake regressions
ibetitsmike Aug 31, 2026
2ca43cf
fix(bash): scope wake cleanup generations
ibetitsmike Sep 1, 2026
433f3c1
test(bash): cover scoped cleanup regressions
ibetitsmike Sep 1, 2026
371331b
fix(bash): preserve pending wake evidence
ibetitsmike Sep 1, 2026
59ff2bd
fix(bash): persist opt-out match frontier
ibetitsmike Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 216 additions & 25 deletions src/node/services/backgroundProcessManager.test.ts

Large diffs are not rendered by default.

330 changes: 260 additions & 70 deletions src/node/services/backgroundProcessManager.ts

Large diffs are not rendered by default.

158 changes: 122 additions & 36 deletions src/node/services/bashMonitorRegistryStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,26 @@ describe("BashMonitorRegistryStore", () => {
script: "echo hi",
});

await store.remove("owner-1", "proc-1");
await store.remove("owner-1", "proc-1", "2026-01-01T00:00:00.000Z");
expect((await store.listAll("owner-1")).map((record) => record.processId)).toEqual(["proc-2"]);

// remove is idempotent for already-deleted records
await store.remove("owner-1", "proc-1");
await store.remove("owner-1", "proc-1", "2026-01-01T00:00:00.000Z");
});

test("registry directory owns records with mismatched embedded owners", async () => {
const config = makeConfig(rootDir);
const store = new BashMonitorRegistryStore(config);
await store.upsert(armedPayload());
const file = path.join(config.sessionsDir, "owner-1", BASH_MONITOR_REGISTRY_DIR, "proc-1.json");
const record = JSON.parse(await fsPromises.readFile(file, "utf-8")) as Record<string, unknown>;
await fsPromises.writeFile(
file,
JSON.stringify({ ...record, ownerWorkspaceId: "other-owner" }),
"utf-8"
);

expect((await store.listAll("owner-1"))[0].ownerWorkspaceId).toBe("owner-1");
});

test("upsert replaces an existing record for the same process", async () => {
Expand All @@ -70,6 +85,50 @@ describe("BashMonitorRegistryStore", () => {
expect(records[0].filter).toBe("READY");
});

test("remove preserves a newer generation for the same process ID", async () => {
const store = new BashMonitorRegistryStore(makeConfig(rootDir));
const oldCreatedAt = "2026-08-31T12:00:00.000Z";
const newCreatedAt = "2026-08-31T12:01:00.000Z";
await store.upsert(armedPayload({ createdAt: oldCreatedAt }));
await store.upsert(armedPayload({ createdAt: newCreatedAt, filter: "NEW" }));

await store.remove("owner-1", "proc-1", oldCreatedAt);

expect(await store.listAll("owner-1")).toMatchObject([
{ processId: "proc-1", createdAt: newCreatedAt, filter: "NEW" },
]);
});

test("terminal and lost writes preserve a re-armed generation", async () => {
const store = new BashMonitorRegistryStore(makeConfig(rootDir));
const oldCreatedAt = "2026-08-31T12:00:00.000Z";
const newCreatedAt = "2026-08-31T12:01:00.000Z";
await store.upsert(armedPayload({ createdAt: oldCreatedAt }));
await store.upsert(armedPayload({ createdAt: newCreatedAt, filter: "NEW" }));

await store.recordTerminal("owner-1", "proc-1", oldCreatedAt, {
status: "exited",
exitCode: 1,
settledAt: "2026-08-31T12:02:00.000Z",
wakeOnExit: true,
terminalStatusShown: false,
});
await store.recordLost("owner-1", "proc-1", oldCreatedAt, {
reason: "runtime-failure",
failedAt: "2026-08-31T12:02:00.000Z",
});

const records = await store.listAll("owner-1");
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
processId: "proc-1",
createdAt: newCreatedAt,
filter: "NEW",
});
expect(records[0].terminal).toBeUndefined();
expect(records[0].lost).toBeUndefined();
});

test("skips malformed records when listing", async () => {
const config = makeConfig(rootDir);
const store = new BashMonitorRegistryStore(config);
Expand All @@ -86,12 +145,25 @@ describe("BashMonitorRegistryStore", () => {
expect(records.map((record) => record.processId)).toEqual(["proc-1"]);
});

test("listAll propagates transient record read failures", async () => {
const config = makeConfig(rootDir);
const store = new BashMonitorRegistryStore(config);
await store.upsert(armedPayload());
await fsPromises.mkdir(
path.join(config.sessionsDir, "owner-1", BASH_MONITOR_REGISTRY_DIR, "unreadable.json")
);

const result = await store.listAll("owner-1").catch((error: unknown) => error);

expect(result).toMatchObject({ code: "EISDIR" });
});

test("listOwnerWorkspaceIds returns only owners with records", async () => {
const config = makeConfig(rootDir);
const store = new BashMonitorRegistryStore(config);
await store.upsert(armedPayload({ workspaceId: "owner-b" }));
await store.upsert(armedPayload({ workspaceId: "owner-a" }));
await store.remove("owner-b", "proc-1");
await store.remove("owner-b", "proc-1", "2026-01-01T00:00:00.000Z");
// Session dir without a registry dir must be skipped, not crash the walk.
await fsPromises.mkdir(path.join(config.sessionsDir, "owner-empty"), { recursive: true });

Expand All @@ -116,43 +188,57 @@ describe("BashMonitorRegistryStore", () => {
});
});

test("consumeIfArmedBefore takes stale records but preserves live replacements", async () => {
test("keeps terminal disposition until delivery removes the row", async () => {
const store = new BashMonitorRegistryStore(makeConfig(rootDir));
const cutoffMs = Date.parse("2026-06-01T00:00:00.000Z");

// Stale record (armed before cutoff) is consumed and returned.
await store.upsert(armedPayload({ createdAt: "2026-01-01T00:00:00.000Z" }));
const consumed = await store.consumeIfArmedBefore("owner-1", "proc-1", cutoffMs);
expect(consumed?.processId).toBe("proc-1");
expect(await store.listAll("owner-1")).toHaveLength(0);

// Live record (re-armed at/after cutoff, e.g. by a workspace resumed during recovery)
// must survive and yield null so no false monitor-lost wake is enqueued for it.
await store.upsert(armedPayload({ createdAt: "2026-06-01T00:00:00.000Z" }));
expect(await store.consumeIfArmedBefore("owner-1", "proc-1", cutoffMs)).toBeNull();
expect(await store.listAll("owner-1")).toHaveLength(1);

// Missing record yields null.
expect(await store.consumeIfArmedBefore("owner-1", "proc-missing", cutoffMs)).toBeNull();
await store.upsert(armedPayload());

await store.recordTerminal("owner-1", "proc-1", "2026-01-01T00:00:00.000Z", {
status: "exited",
exitCode: 0,
settledAt: "2026-01-01T00:00:01.000Z",
wakeOnExit: true,
terminalStatusShown: false,
});

expect((await store.listAll("owner-1"))[0].terminal).toEqual({
status: "exited",
exitCode: 0,
settledAt: "2026-01-01T00:00:01.000Z",
wakeOnExit: true,
terminalStatusShown: false,
});
});

test("keeps a stale record when the pre-remove callback fails", async () => {
test("persists bounded runtime failure evidence until delivery", async () => {
const store = new BashMonitorRegistryStore(makeConfig(rootDir));
const cutoffMs = Date.parse("2026-06-01T00:00:00.000Z");
await store.upsert(armedPayload({ createdAt: "2026-01-01T00:00:00.000Z" }));

let rejection: unknown;
try {
await store.consumeIfArmedBefore("owner-1", "proc-1", cutoffMs, () =>
Promise.reject(new Error("wake persistence failed"))
);
} catch (error) {
rejection = error;
}
expect(rejection).toBeInstanceOf(Error);
if (!(rejection instanceof Error)) throw new Error("expected callback rejection");
expect(rejection.message).toBe("wake persistence failed");
expect(await store.listAll("owner-1")).toHaveLength(1);
await store.upsert(armedPayload());
await store.recordLost("owner-1", "proc-1", "2026-01-01T00:00:00.000Z", {
reason: "runtime-failure",
failureMessage: "\u001b[31mtransport unavailable\u001b[0m",
failedOperations: ["readOutput", "getExitCode"],
failedMatch: {
lines: Array.from({ length: 60 }, (_, index) => `line-${index}`),
totalMatches: 60,
droppedLines: 2,
matchedThroughOffset: 120,
},
failedAt: "2026-01-01T00:00:02.000Z",
});

const lost = (await store.listAll("owner-1"))[0].lost;
expect(lost).toMatchObject({
reason: "runtime-failure",
failureMessage: "transport unavailable",
failedOperations: ["readOutput", "getExitCode"],
failedAt: "2026-01-01T00:00:02.000Z",
failedMatch: {
totalMatches: 60,
droppedLines: 12,
matchedThroughOffset: 120,
},
});
expect(lost?.failedMatch?.lines).toHaveLength(50);
expect(lost?.failedMatch?.lines[0]).toBe("line-10");
});

test("bounds persisted script length", async () => {
Expand Down
Loading
Loading