Skip to content

Commit cf4d9ab

Browse files
authored
metaFromEvents is gone: nothing rebuilt an agent meta from a whole log (#1039) (#1696)
The export had no production caller — the meta is stored beside the log and refined per appended event — and it anchored updatedAt to startedAt on replay, so any future caller would have inherited a wrong last-touched time. The tests that used it as a fixture now fold their base through a real store, the way a live run does.
1 parent fafdadc commit cf4d9ab

6 files changed

Lines changed: 59 additions & 64 deletions

File tree

packages/framework/src/cloud-session-link.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,26 @@ import { strict as assert } from 'node:assert'
22
import { test } from 'node:test'
33
import { CloudDriver } from './driver/cloud.js'
44
import { createDriverEventHandler, emitSessionStart } from './agent-telemetry.js'
5-
import { metaFromEvents } from './store/index.js'
5+
import { mkdtemp, rm } from 'node:fs/promises'
6+
import { tmpdir } from 'node:os'
7+
import { join } from 'node:path'
8+
import { AgentStore, type AgentMeta } from './store/index.js'
69
import { CLAUDE_CODE_SESSION_LINK } from './session-link.js'
710
import type { FrameworkEvent } from './events.js'
811

12+
/** The meta after `events`, folded the way a live run folds them: appended to a real store. */
13+
async function foldLive(events: readonly FrameworkEvent[], at: string): Promise<AgentMeta> {
14+
const cwd = await mkdtemp(join(tmpdir(), 'tf-session-link-'))
15+
try {
16+
const store = await AgentStore.open(cwd, { now: at, clock: () => at })
17+
for (const event of events) await store.append(event)
18+
await store.close()
19+
return store.snapshot()
20+
} finally {
21+
await rm(cwd, { recursive: true, force: true })
22+
}
23+
}
24+
925
// #1317: a web agent's meta dead-ended on the generic https://claude.ai/code — recorded by the
1026
// opening `session` event before any session existed — even though the CloudDriver had the real
1127
// URL in hand. This wires the real driver through the real telemetry into the real meta fold,
@@ -40,7 +56,7 @@ test('a cloud run meta ends with the real session URL, not the generic entry poi
4056
const session = await driver.start({ cwd: '/repo', onEvent: handler.onDriverEvent })
4157
await session.prompt('go')
4258

43-
const meta = metaFromEvents(events, '2026-01-01T00:00:00.000Z')
59+
const meta = await foldLive(events, '2026-01-01T00:00:00.000Z')
4460
assert.equal(meta.sessionLink, URL, 'the meta must carry the deep link once the hand-off knows it')
4561
// The opening event still honestly says what was known before the session existed.
4662
const opening = events.find(e => e.kind === 'session')

packages/framework/src/store/agent-store.SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The user opens an agent and sees a header: what it was asked for, which coding a
3232

3333
#### Business logic
3434

35-
An agent's every fact arrives as an event appended to its event log, and the agent meta is the running fold of those events. Folding is the same operation whether it happens as an event is appended or by replaying a whole log from scratch, so a record rebuilt after a restart says exactly what the live one said.
35+
An agent's every fact arrives as an event appended to its event log, and the agent meta is the running fold of those events: each appended event refines the record, and the record is stored beside the log rather than rebuilt from it.
3636

3737
The agent meta carries: whether the agent is running, done, stopped or failed; its id and its start and last-update times; the process and host that own it; what it was asked for; which driver and workspace it uses; the wrapped CLI's session id, session link and the agent-chosen session name; the branch its work is on; the ticket it implements, when the framework picked one; the pull request opened for it; whether it signalled ready for merge; what its handoff is armed to do and how that handoff reported back; the gate it is parked on; whether it has settled on the user; for a cloud session, whether the browser bridge holds a question it is parked on, and whether another machine's daemon started the agent — both marked by the daemon on the way to the dashboard, never stored; the port its browser preview listens on; its run target and, for a relayed agent, the device label; whether it started as a build or a direct prompt; the model the current leg runs; and, for a cloud session, the hand-off anchor commit.
3838

packages/framework/src/store/agent-store.test.SPEC.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ What the tests cover: how an agent's record is written, summarized, archived and
1111

1212
**Folding events into the agent meta**
1313

14-
- Rebuilding the agent meta by replaying a whole log yields the same summary as folding events as they are appended.
1514
- A later request refines the seeded one.
1615
- The model is recorded per leg: the latest leg wins, and a leg that reports no model leaves it unknown rather than inheriting.
1716
- Handoff arming is mirrored, including whether merge is armed, without padding fields the arming never stated.

packages/framework/src/store/agent-store.test.ts

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { hostname } from 'node:os'
55
import {
66
AgentStore,
77
applyEventToMeta,
8-
metaFromEvents,
98
listAgents,
109
readLiveMeta,
1110
readLiveMetas,
@@ -73,6 +72,16 @@ const RUN: FrameworkEvent[] = [
7372
{ kind: 'end', ok: true },
7473
]
7574

75+
/** The meta after `events`, folded the way a live run folds them: appended to a real store. */
76+
async function foldLive(events: readonly FrameworkEvent[], at: string): Promise<AgentMeta> {
77+
const store = await AgentStore.open(CWD, { fs: memFs(), now: at, clock: () => at })
78+
for (const event of events) await store.append(event)
79+
return store.snapshot()
80+
}
81+
82+
/** A run three events in: opened and working, nothing decided yet. What the fold tests refine from. */
83+
const BASE = await foldLive(RUN.slice(0, 3), AT)
84+
7685
test('fresh open truncates the log and writes an initial meta snapshot', async () => {
7786
const fs = memFs({ [EVENTS]: 'stale\n' })
7887
const store = await AgentStore.open(CWD, { fs, fresh: true, now: AT })
@@ -155,16 +164,8 @@ test('loadEvents on a never-run workspace yields an empty array', async () => {
155164
assert.deepEqual(await store.loadEvents(), [])
156165
})
157166

158-
test('metaFromEvents reconstructs the same snapshot as live appends', async () => {
159-
const meta = metaFromEvents(RUN, AT)
160-
assert.equal(meta.intent, 'a blog with comments')
161-
assert.equal(meta.status, 'done')
162-
assert.equal(meta.startedAt, AT)
163-
})
164-
165167
test('applyEventToMeta records the model per leg — the latest session event wins, an unrecorded one clears it (#1438)', () => {
166-
const base = metaFromEvents(RUN.slice(0, 3), AT)
167-
const first = applyEventToMeta(base, { kind: 'session', driver: 'claude', workspace: '/w', fake: false, model: 'fable' }, AT)
168+
const first = applyEventToMeta(BASE, { kind: 'session', driver: 'claude', workspace: '/w', fake: false, model: 'fable' }, AT)
168169
assert.equal(first.model, 'fable')
169170
// A continuation leg may run a different model: fold, don't pin.
170171
const second = applyEventToMeta(first, { kind: 'session', driver: 'claude', workspace: '/w', fake: false, model: 'sonnet' }, AT)
@@ -175,32 +176,29 @@ test('applyEventToMeta records the model per leg — the latest session event wi
175176
})
176177

177178
test('applyEventToMeta mirrors the merge arming onto the meta, so a mid-run tab can read it (#1382)', () => {
178-
const base = metaFromEvents(RUN.slice(0, 3), AT)
179-
const armed = applyEventToMeta(base, { kind: 'handoff-armed', push: true, pr: true, merge: true }, AT)
179+
const armed = applyEventToMeta(BASE, { kind: 'handoff-armed', push: true, pr: true, merge: true }, AT)
180180
assert.deepEqual(armed.handoff, { push: true, pr: true, merge: true })
181181
// A pre-#1382 event has no merge field: the mirror stays shaped like the event, not padded.
182-
const old = applyEventToMeta(base, { kind: 'handoff-armed', push: true, pr: false }, AT)
182+
const old = applyEventToMeta(BASE, { kind: 'handoff-armed', push: true, pr: false }, AT)
183183
assert.deepEqual(old.handoff, { push: true, pr: false })
184184
})
185185

186186
test('applyEventToMeta folds the handoff report onto the meta, so list surfaces can tell publishing from done (#1455)', () => {
187-
const base = metaFromEvents(RUN.slice(0, 3), AT)
188-
assert.equal(base.handoffReport, undefined, 'no report until the epilogue speaks')
189-
const done = applyEventToMeta(base, { kind: 'handoff', outcome: 'done', pushed: true }, AT)
187+
assert.equal(BASE.handoffReport, undefined, 'no report until the epilogue speaks')
188+
const done = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'done', pushed: true }, AT)
190189
assert.equal(done.handoffReport, 'done')
191-
const skipped = applyEventToMeta(base, { kind: 'handoff', outcome: 'skipped', reason: 'not-armed' }, AT)
190+
const skipped = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'skipped', reason: 'not-armed' }, AT)
192191
assert.equal(skipped.handoffReport, 'skipped')
193-
const failed = applyEventToMeta(base, { kind: 'handoff', outcome: 'failed', step: 'push', error: 'boom' }, AT)
192+
const failed = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'failed', step: 'push', error: 'boom' }, AT)
194193
assert.equal(failed.handoffReport, 'failed')
195194
})
196195

197196
test('applyEventToMeta folds the skip reason onto the meta, so the sweep can free a dead claim (#1583)', () => {
198-
const base = metaFromEvents(RUN.slice(0, 3), AT)
199-
assert.equal(base.handoffSkip, undefined)
200-
const skipped = applyEventToMeta(base, { kind: 'handoff', outcome: 'skipped', reason: 'no-commits' }, AT)
197+
assert.equal(BASE.handoffSkip, undefined)
198+
const skipped = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'skipped', reason: 'no-commits' }, AT)
201199
assert.equal(skipped.handoffSkip, 'no-commits')
202200
// A handoff that ran carries no skip: the field means "why nothing happened", nothing else.
203-
const done = applyEventToMeta(base, { kind: 'handoff', outcome: 'done', pushed: true }, AT)
201+
const done = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'done', pushed: true }, AT)
204202
assert.equal(done.handoffSkip, undefined)
205203
// And a later leg that published clears a stale skip — a resumed run's second handoff can
206204
// publish after its first skipped, and the release must not act on leg one's reason.
@@ -209,34 +207,30 @@ test('applyEventToMeta folds the skip reason onto the meta, so the sweep can fre
209207
})
210208

211209
test('applyEventToMeta folds the merge outcome onto the meta, so the CI watch can find its PRs (#1418)', () => {
212-
const base = metaFromEvents(RUN.slice(0, 3), AT)
213-
assert.equal(base.mergeOutcome, undefined)
214-
const watched = applyEventToMeta(base, { kind: 'handoff', outcome: 'done', pushed: true, merge: { outcome: 'watched' } }, AT)
210+
assert.equal(BASE.mergeOutcome, undefined)
211+
const watched = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'done', pushed: true, merge: { outcome: 'watched' } }, AT)
215212
assert.equal(watched.mergeOutcome, 'watched')
216213
// The already-open skip carries a merge too (#1216): a rerun finding its predecessor's PR.
217-
const armed = applyEventToMeta(base, { kind: 'handoff', outcome: 'skipped', reason: 'already-open', merge: { outcome: 'auto-armed' } }, AT)
214+
const armed = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'skipped', reason: 'already-open', merge: { outcome: 'auto-armed' } }, AT)
218215
assert.equal(armed.mergeOutcome, 'auto-armed')
219-
const noMerge = applyEventToMeta(base, { kind: 'handoff', outcome: 'done', pushed: true }, AT)
216+
const noMerge = applyEventToMeta(BASE, { kind: 'handoff', outcome: 'done', pushed: true }, AT)
220217
assert.equal(noMerge.mergeOutcome, undefined, 'a handoff without a merge half folds nothing')
221218
})
222219

223220
test('applyEventToMeta marks a thrown run as failed', () => {
224-
const base = metaFromEvents(RUN.slice(0, 3), AT)
225-
const failed = applyEventToMeta(base, { kind: 'end', ok: false, detail: 'boom' }, AT)
221+
const failed = applyEventToMeta(BASE, { kind: 'end', ok: false, detail: 'boom' }, AT)
226222
assert.equal(failed.status, 'failed')
227223
})
228224

229225
test('applyEventToMeta marks a user-stopped run as stopped, not failed (#218)', () => {
230-
const base = metaFromEvents(RUN.slice(0, 3), AT)
231-
const stopped = applyEventToMeta(base, { kind: 'end', ok: false, stopped: true }, AT)
226+
const stopped = applyEventToMeta(BASE, { kind: 'end', ok: false, stopped: true }, AT)
232227
assert.equal(stopped.status, 'stopped')
233228
})
234229

235230
test('applyEventToMeta tracks whether the run is working or parked on the user (#785)', () => {
236-
const base = metaFromEvents(RUN.slice(0, 3), AT)
237-
assert.equal(base.settledAt, undefined, 'a working run is not parked')
231+
assert.equal(BASE.settledAt, undefined, 'a working run is not parked')
238232

239-
const parked = applyEventToMeta(base, { kind: 'settled' }, AT)
233+
const parked = applyEventToMeta(BASE, { kind: 'settled' }, AT)
240234
assert.equal(parked.settledAt, AT)
241235
assert.equal(parked.status, 'running', 'still live: it holds the project and takes messages')
242236

@@ -245,26 +239,24 @@ test('applyEventToMeta tracks whether the run is working or parked on the user (
245239
assert.equal(working.settledAt, undefined)
246240

247241
// An agent that has ended is not waiting on anyone.
248-
const ended = applyEventToMeta(applyEventToMeta(base, { kind: 'settled' }, AT), { kind: 'end', ok: true }, AT)
242+
const ended = applyEventToMeta(applyEventToMeta(BASE, { kind: 'settled' }, AT), { kind: 'end', ok: true }, AT)
249243
assert.equal(ended.settledAt, undefined)
250244
assert.equal(ended.status, 'done')
251245
})
252246

253247
test('applyEventToMeta records the session name + ready-for-merge lifecycle signals (#326)', () => {
254-
const base = metaFromEvents(RUN.slice(0, 3), AT)
255-
assert.equal(base.sessionName, undefined)
256-
assert.equal(base.readyForMerge, undefined)
257-
const named = applyEventToMeta(base, { kind: 'session-name', name: 'add-comments' }, AT)
248+
assert.equal(BASE.sessionName, undefined)
249+
assert.equal(BASE.readyForMerge, undefined)
250+
const named = applyEventToMeta(BASE, { kind: 'session-name', name: 'add-comments' }, AT)
258251
assert.equal(named.sessionName, 'add-comments')
259252
const ready = applyEventToMeta(named, { kind: 'ready-for-merge' }, AT)
260253
assert.equal(ready.readyForMerge, true)
261254
assert.equal(ready.sessionName, 'add-comments') // ready doesn't clobber the name
262255
})
263256

264257
test('applyEventToMeta records the ticket a run is implementing (#1117)', () => {
265-
const base = metaFromEvents(RUN.slice(0, 3), AT)
266-
assert.equal(base.ticket, undefined, 'a run nobody linked to a ticket says nothing')
267-
const on = applyEventToMeta(base, { kind: 'ticket', path: 'tickets/2026-07-25_login.md' }, AT)
258+
assert.equal(BASE.ticket, undefined, 'a run nobody linked to a ticket says nothing')
259+
const on = applyEventToMeta(BASE, { kind: 'ticket', path: 'tickets/2026-07-25_login.md' }, AT)
268260
assert.equal(on.ticket, 'tickets/2026-07-25_login.md')
269261
// It is a fact about why the agent exists, so it outlives the work: a reader looking at a finished
270262
// run still gets to see which ticket it was.
@@ -273,9 +265,8 @@ test('applyEventToMeta records the ticket a run is implementing (#1117)', () =>
273265
})
274266

275267
test('applyEventToMeta tracks the pending choice gate a run is parked on (#636)', () => {
276-
const base = metaFromEvents(RUN.slice(0, 3), AT)
277-
assert.equal(base.pendingChoice, undefined)
278-
const asked = applyEventToMeta(base, { kind: 'choice', id: 'g1', title: 'Cache the auth store?', options: [{ id: 'y', label: 'Yes' }] }, AT)
268+
assert.equal(BASE.pendingChoice, undefined)
269+
const asked = applyEventToMeta(BASE, { kind: 'choice', id: 'g1', title: 'Cache the auth store?', options: [{ id: 'y', label: 'Yes' }] }, AT)
279270
assert.deepEqual(asked.pendingChoice, { id: 'g1', title: 'Cache the auth store?' })
280271
// A resolve for a different gate id leaves it parked; the matching resolve clears it.
281272
const other = applyEventToMeta(asked, { kind: 'choice-resolved', id: 'other', picked: 'y', by: 'user' }, AT)
@@ -285,8 +276,7 @@ test('applyEventToMeta tracks the pending choice gate a run is parked on (#636)'
285276
})
286277

287278
test('applyEventToMeta clears a pending choice when the run ends (#636)', () => {
288-
const base = metaFromEvents(RUN.slice(0, 3), AT)
289-
const asked = applyEventToMeta(base, { kind: 'choice', id: 'g1', title: 'q?', options: [{ id: 'y', label: 'Yes' }] }, AT)
279+
const asked = applyEventToMeta(BASE, { kind: 'choice', id: 'g1', title: 'q?', options: [{ id: 'y', label: 'Yes' }] }, AT)
290280
const ended = applyEventToMeta(asked, { kind: 'end', ok: true }, AT)
291281
assert.equal(ended.pendingChoice, undefined)
292282
})
@@ -891,17 +881,15 @@ test('startedAtFromAgentId inverts agentIdFromStartedAt, and refuses foreign ids
891881
test('applyEventToMeta records the pull request a session opened (E6)', () => {
892882
// The number is a fact about the agent, so the agent writes it down — rather than every later
893883
// surface re-deriving it from branch names and creation times.
894-
const base = metaFromEvents(RUN.slice(0, 3), AT)
895-
assert.equal(base.pr, undefined, 'a session with no PR says nothing')
896-
const on = applyEventToMeta(base, { kind: 'pull-request', number: 42, url: 'https://x/pull/42' }, AT)
884+
assert.equal(BASE.pr, undefined, 'a session with no PR says nothing')
885+
const on = applyEventToMeta(BASE, { kind: 'pull-request', number: 42, url: 'https://x/pull/42' }, AT)
897886
assert.deepEqual(on.pr, { number: 42, url: 'https://x/pull/42' })
898887
// It must outlive the agent: every read of it happens after the session has ended.
899888
assert.deepEqual(applyEventToMeta(on, { kind: 'end', ok: true }, AT).pr, { number: 42, url: 'https://x/pull/42' })
900889
})
901890

902891
test('applyEventToMeta records the branch a branch event names (#1277)', () => {
903-
const base = metaFromEvents(RUN.slice(0, 3), AT)
904-
const on = applyEventToMeta(base, { kind: 'branch', branch: 'tf-agent-r1' }, AT)
892+
const on = applyEventToMeta(BASE, { kind: 'branch', branch: 'tf-agent-r1' }, AT)
905893
assert.equal(on.branch, 'tf-agent-r1')
906894
// A rename mid-run replaces it: the meta always names the branch the work is on now.
907895
const renamed = applyEventToMeta(on, { kind: 'branch', branch: 'tf-cool-name' }, AT)

packages/framework/src/store/agent-store.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,6 @@ async function stopAndArchiveLive(fs: StoreFs, dir: string, meta: AgentMeta): Pr
541541
return stopped
542542
}
543543

544-
/** Rebuild {@link AgentMeta} from a full event log (used when resuming). */
545-
export function metaFromEvents(events: readonly FrameworkEvent[], startedAt: string): AgentMeta {
546-
let meta = freshMeta(startedAt)
547-
for (const event of events) meta = applyEventToMeta(meta, event, startedAt)
548-
return meta
549-
}
550-
551544
/**
552545
* Durable, append-only store for a single agent's orchestration events, plus a
553546
* derived {@link AgentMeta} snapshot. Writes are serialized through one tail

packages/framework/src/store/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export {
22
AgentStore,
33
nodeStoreFs,
44
applyEventToMeta,
5-
metaFromEvents,
65
listAgents,
76
readAllAgents as readAllAgents,
87
findAgent,

0 commit comments

Comments
 (0)