Skip to content

Commit d42ec00

Browse files
committed
refactor: carry session lifetimes through close and script finalization
1 parent 431d814 commit d42ec00

23 files changed

Lines changed: 321 additions & 112 deletions

‎src/__tests__/test-utils/store-factory.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore {
1111
export function makeStoredSessionRef(session: SessionState, address = session.name): SessionRef {
1212
return makeSessionStore().publish(address, session);
1313
}
14+
15+
export function storeSessionForTest(store: SessionStore, session: SessionState): SessionRef {
16+
const ref = store.lookup(session.name);
17+
if (!ref) return store.publish(session.name, session);
18+
if (ref.session !== session) throw new Error('A different test session occupies this address');
19+
return ref;
20+
}

‎src/daemon/__tests__/filesystem-boundary-faults.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts';
12
import assert from 'node:assert/strict';
23
import crypto from 'node:crypto';
34
import path from 'node:path';
@@ -158,7 +159,7 @@ function createSessionStoreFixture(root: string): FilesystemBoundaryFixture {
158159

159160
return {
160161
targetPath,
161-
run: async () => store.finalizeRepairTeardown(session),
162+
run: async () => store.finalizeRepairTeardown(storeSessionForTest(store, session)),
162163
expected: 'return',
163164
verifyReturn: (_value, errno) => {
164165
const tombstone = store.readRepairTombstone(session.name);

‎src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
12
/**
23
* ADR 0012 decision 6 acceptance test: a healed sibling `.ad` produced by the
34
* repair loop must replay end-to-end in a FRESH session, with every selector
@@ -140,7 +141,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope
140141
// repair-armed write on the same explicit finalize signal `close
141142
// --save-script` sets). ---
142143
markRepairTransactionComplete(session);
143-
sessionStore.writeSessionLog(session);
144+
sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
144145
const healedPath = path.join(root, 'flow.healed.ad');
145146
expect(fs.existsSync(healedPath)).toBe(true);
146147
const healedScript = fs.readFileSync(healedPath, 'utf8');

‎src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
12
/**
23
* ADR 0012 decision 6, R2/R3, extended per #1262: behaviors introduced
34
* alongside the `resume.from` / `repairHint` agreement fix
@@ -188,7 +189,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a
188189
// --- Commit: the transaction is COMPLETE, so the healed script actually
189190
// publishes — the corrective press survives, "click" (never recorded) does
190191
// not. Proves the empty-tail resume did not lead to a discarded repair. ---
191-
const writeResult = sessionStore.writeSessionLog(session);
192+
const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
192193
expect(writeResult.written).toBe(true);
193194
const healedPath = path.join(root, 'flow.healed.ad');
194195
expect(fs.existsSync(healedPath)).toBe(true);
@@ -312,7 +313,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes
312313
// since a `manual` divergence never dispatched it) does not. Proves the
313314
// empty-tail resume did not lead to a discarded repair (the #1260
314315
// discard-at-close trap, now also closed for `manual`). ---
315-
const writeResult = sessionStore.writeSessionLog(session);
316+
const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
316317
expect(writeResult.written).toBe(true);
317318
const healedPath = path.join(root, 'flow.healed.ad');
318319
expect(fs.existsSync(healedPath)).toBe(true);
@@ -436,7 +437,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an
436437

437438
// --- Commit: COMPLETE, so the healed script publishes the corrective
438439
// press; the pre-action "click" (never dispatched) does not appear. ---
439-
const writeResult = sessionStore.writeSessionLog(session);
440+
const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
440441
expect(writeResult.written).toBe(true);
441442
const healedPath = path.join(root, 'flow.healed.ad');
442443
expect(fs.existsSync(healedPath)).toBe(true);

‎src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
12
/**
23
* ADR 0012 decision 6 repair-transaction close-ordering guarantees (BLOCKER 2/3 sequencing): the
34
* platform close must run and succeed BEFORE the healed `.ad` commits (never claim a successful
@@ -244,7 +245,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact
244245

245246
// Writer 1 commits a complete artifact at the default healed path.
246247
const first = makeCompleteRepairSession(sessionStore, `${sessionName}-1`, root);
247-
const r1 = sessionStore.writeSessionLog(first);
248+
const r1 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, first));
248249
expect(r1.written).toBe(true);
249250
const committed = fs.readFileSync(healedPath, 'utf8');
250251
expect(committed).toContain(HEAL_COMPLETE_SENTINEL);
@@ -261,7 +262,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact
261262
result: { selectorChain: ['id="different"'] },
262263
targetEvidence: freshEvidence('different', 'Different'),
263264
};
264-
const r2 = sessionStore.writeSessionLog(second);
265+
const r2 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, second));
265266
expect(r2.written).toBe(false);
266267
expect(r2.written === false && r2.error?.message).toMatch(/already exists/);
267268
// The first writer's complete artifact is byte-for-byte intact.

‎src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
12
/**
23
* ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c):
34
* proves the WHOLE chain end to end, at the layer these fixes actually live —
@@ -304,7 +305,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale
304305
// Idle-reap tears the still-incomplete repair session down: the writer commits
305306
// nothing (not complete) and a tombstone is left behind (the exact teardown
306307
// step daemon-runtime.ts's teardownDaemonSession runs).
307-
sessionStore.finalizeRepairTeardown(session);
308+
sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session));
308309
sessionStore.delete(sessionName);
309310
expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(false);
310311

@@ -353,7 +354,7 @@ test('C5a/BLOCKER 3: teardown of a COMPLETE repair auto-commits a self-contained
353354

354355
// Teardown (e.g. the client tearing down the ephemeral daemon after a clean
355356
// repair) auto-commits the completed transaction and leaves no tombstone.
356-
sessionStore.finalizeRepairTeardown(session);
357+
sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session));
357358
expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(true);
358359
const healedScript = fs.readFileSync(path.join(root, 'flow.healed.ad'), 'utf8');
359360
expect(healedScript).toContain(HEAL_COMPLETE_SENTINEL);
@@ -398,7 +399,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F
398399
const digest = leg1Divergence.resume.planDigest;
399400

400401
// Idle-reap tears the incomplete repair down, leaving a tombstone.
401-
sessionStore.finalizeRepairTeardown(sessionStore.get(sessionName)!);
402+
sessionStore.finalizeRepairTeardown(sessionStore.lookup(sessionName)!);
402403
sessionStore.delete(sessionName);
403404
expect(sessionStore.readRepairTombstone(sessionName)).toBeDefined();
404405

‎src/daemon/__tests__/request-router-idle-expired.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,7 @@ test('an expired marker that has aged out stops explaining the absence', async (
9595

9696
test('an abandoned repair transaction outranks the idle-expiry marker', async () => {
9797
const { sessionStore, handler } = makeHandler('agent-device-router-idle-vs-repair-');
98-
writeIdleMarker(sessionStore, 'repair-x');
99-
sessionStore.writeRepairTombstone({
98+
const ref = sessionStore.publish('repair-x', {
10099
name: 'repair-x',
101100
device: { platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', booted: true },
102101
createdAt: Date.now(),
@@ -110,6 +109,10 @@ test('an abandoned repair transaction outranks the idle-expiry marker', async ()
110109
},
111110
});
112111

112+
sessionStore.writeRepairTombstone(ref);
113+
sessionStore.retire(ref);
114+
writeIdleMarker(sessionStore, 'repair-x');
115+
113116
const response = await handler(closeRequest('repair-x'));
114117

115118
expect(response.ok).toBe(false);

‎src/daemon/__tests__/request-router-repair-expired.test.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ test('a command that finds no session but hits a live repair tombstone gets REPA
6262
const { sessionStore, handler } = makeHandler('agent-device-router-repair-expired-');
6363
// The repair session was reaped (idle-reap) leaving a tombstone; the store
6464
// has no live session by that name.
65-
sessionStore.writeRepairTombstone(tombstonedSession('repair-x'));
65+
const ref = sessionStore.publish('repair-x', tombstonedSession('repair-x'));
66+
sessionStore.writeRepairTombstone(ref);
67+
sessionStore.retire(ref);
6668

6769
const response = await handler(closeRequest('repair-x'));
6870

@@ -89,10 +91,12 @@ test('without a tombstone, a missing session still returns a plain SESSION_NOT_F
8991
// never completed at all.
9092
test('a command hitting a commit-failure tombstone gets REPAIR_COMMIT_FAILED with the real cause, not a generic REPAIR_SESSION_EXPIRED', async () => {
9193
const { sessionStore, handler } = makeHandler('agent-device-router-commit-failed-');
92-
sessionStore.writeRepairTombstone(tombstonedSession('repair-commit-fail'), undefined, {
94+
const ref = sessionStore.publish('repair-commit-fail', tombstonedSession('repair-commit-fail'));
95+
sessionStore.writeRepairTombstone(ref, undefined, {
9396
code: 'COMMAND_FAILED',
9497
message: 'A prior healed script already exists at /flows/login.healed.ad; ...',
9598
});
99+
sessionStore.retire(ref);
96100

97101
const response = await handler(closeRequest('repair-commit-fail'));
98102

@@ -108,7 +112,9 @@ test('a command hitting a commit-failure tombstone gets REPAIR_COMMIT_FAILED wit
108112
test('an expired tombstone does not shadow a missing session', async () => {
109113
const { sessionStore, handler } = makeHandler('agent-device-router-expired-tombstone-');
110114
// TTL 0 => already stale.
111-
sessionStore.writeRepairTombstone(tombstonedSession('repair-y'), 0);
115+
const ref = sessionStore.publish('repair-y', tombstonedSession('repair-y'));
116+
sessionStore.writeRepairTombstone(ref, 0);
117+
sessionStore.retire(ref);
112118

113119
const response = await handler(closeRequest('repair-y'));
114120

@@ -144,7 +150,9 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO
144150
}).planDigest;
145151

146152
// The repair session was reaped, leaving a tombstone; no live session exists.
147-
sessionStore.writeRepairTombstone(tombstonedSession('repair-from'));
153+
const ref = sessionStore.publish('repair-from', tombstonedSession('repair-from'));
154+
sessionStore.writeRepairTombstone(ref);
155+
sessionStore.retire(ref);
148156

149157
const response = await handler({
150158
token: 'test-token',

‎src/daemon/__tests__/request-save-script-transports.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts';
12
import { isSessionRecording } from '../session-script-publication-capability.ts';
23
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
34
/**
@@ -204,7 +205,9 @@ for (const [transport, send] of TRANSPORTS) {
204205
expect(isSessionRecording(session)).toBe(false);
205206
expect(session.scriptPublication).toBe(undefined);
206207
// No artifact: the write a later close/teardown would attempt publishes nothing.
207-
expect(sessionStore.writeSessionLog(session)).toEqual({ written: false });
208+
expect(sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session))).toEqual({
209+
written: false,
210+
});
208211
expect(listAdArtifacts(root)).toEqual([]);
209212
expect(fs.existsSync(path.join(root, 'forged.ad'))).toBe(false);
210213

@@ -295,7 +298,7 @@ test('an owner-armed session still records its target and publishes its script',
295298
expect(isSessionRecording(session)).toBe(true);
296299
expect(scriptTargetPath(session.scriptPublication ?? NO_SCRIPT_PUBLICATION)).toBe(target);
297300

298-
const result = sessionStore.writeSessionLog(session);
301+
const result = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
299302
expect(result).toEqual({ written: true, path: target, actionCount: 1 });
300303
expect(fs.readFileSync(target, 'utf8')).toMatch(/^open /m);
301304
});

‎src/daemon/__tests__/session-store-lifetime.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
23
import { test } from 'vitest';
34
import { AppError } from '@agent-device/kernel/errors';
4-
import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
5+
import {
6+
makeSession,
7+
makeRepairCompleteSession,
8+
makeRepairArmedSession,
9+
authoringPublication,
10+
} from '../../__tests__/test-utils/session-factories.ts';
511
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
612

713
const ADDRESS = 'cwd:worktree:default';
@@ -121,3 +127,47 @@ test('a ref from another store has no authority over the same address', () => {
121127
assert.equal(target.retire(foreign), false);
122128
assert.equal(target.requireCurrent(local), foreign.session);
123129
});
130+
131+
test('script writes use the latest matching record and refuse a retired lifetime', () => {
132+
const store = makeSessionStore();
133+
const ref = store.publish(ADDRESS, makeSession('default'));
134+
store.update(ref, {
135+
scriptPublication: authoringPublication('armed'),
136+
actions: [{ ts: 1, command: 'click', positionals: ['id="late-action"'], flags: {} }],
137+
});
138+
const result = store.writeSessionLog(ref);
139+
assert.equal(result.written, true);
140+
if (result.written) assert.match(fs.readFileSync(result.path, 'utf8'), /late-action/);
141+
store.retire(ref);
142+
const successor = store.publish(ADDRESS, makeRepairCompleteSession('default'));
143+
assert.throws(() => store.writeSessionLog(ref), ended);
144+
store.finalizeRepairTeardown(ref);
145+
const state = store.requireCurrent(successor).scriptPublication;
146+
assert.equal(state?.kind, 'repair');
147+
if (state?.kind === 'repair') assert.equal(state.status, 'complete');
148+
assert.equal(successor.session.actions.length, 0);
149+
});
150+
151+
test('repair tombstones follow the scoped address and cannot be written by a retired ref', () => {
152+
const store = makeSessionStore();
153+
const ref = store.publish(ADDRESS, makeRepairArmedSession('default'));
154+
store.update(ref, {
155+
scriptPublication: {
156+
kind: 'repair',
157+
status: 'armed',
158+
boundary: 0,
159+
target: { kind: 'default', force: false },
160+
sourcePath: '/latest.ad',
161+
},
162+
});
163+
store.writeRepairTombstone(ref);
164+
assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS);
165+
assert.equal(store.readRepairTombstone(ADDRESS)?.sourcePath, '/latest.ad');
166+
assert.equal(store.readRepairTombstone('default'), undefined);
167+
store.retire(ref);
168+
store.clearRepairTombstone(ADDRESS);
169+
const successor = store.publish(ADDRESS, makeRepairArmedSession('default'));
170+
store.writeRepairTombstone(ref);
171+
assert.equal(store.readRepairTombstone(ADDRESS), undefined);
172+
assert.equal(store.requireCurrent(successor), successor.session);
173+
});

0 commit comments

Comments
 (0)