Skip to content

Commit 32a8919

Browse files
committed
fix(daemon): let metadata updates write fresh timestamps on stale-snapshot path
Preserving startedAt/endedAt unconditionally in the stale-snapshot branch broke the retry flow: a metadata update that carries a NEW endedAt (empty events, matching terminal status) was treated as a stale full snapshot, so the web's fresh completion timestamp was discarded and the daemon's was kept (e2e retry-after-stop). Preserve the lifecycle timestamps only when the snapshot OMITS them (the #6396 stale case, where upsertMessage would null started_at/ended_at) and let a snapshot that genuinely provides them write through. Events, content, terminal status, and ownership fields keep the no-regression protection. Adds a regression test for the metadata-update path: a fresh endedAt lands while daemon-owned events and terminal status survive.
1 parent bd173f3 commit 32a8919

2 files changed

Lines changed: 83 additions & 5 deletions

File tree

apps/daemon/src/routes/project/conversations.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro
237237
TERMINAL_RUN_STATUSES.has(stored.runStatus) &&
238238
incomingStatus !== stored.runStatus;
239239
if (!shrinksEvents && !regressesTerminalStatus) return incoming;
240+
const hasStartedAt = Object.prototype.hasOwnProperty.call(incoming, 'startedAt');
241+
const hasEndedAt = Object.prototype.hasOwnProperty.call(incoming, 'endedAt');
240242
return {
241243
...incoming,
242244
role: stored.role,
@@ -245,11 +247,13 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro
245247
content: stored.content ?? '',
246248
lastRunEventId: stored.lastRunEventId,
247249
runStatus: stored.runStatus,
248-
// Daemon-written lifecycle timestamps; a stale snapshot that omits them
249-
// would otherwise null out started_at / ended_at (and endedAt feeds
250-
// persisted-run telemetry).
251-
startedAt: stored.startedAt,
252-
endedAt: stored.endedAt,
250+
// Daemon-written lifecycle timestamps. A stale snapshot that omits them
251+
// would otherwise null out started_at / ended_at (endedAt feeds
252+
// persisted-run telemetry). But a metadata update that GENUINELY carries
253+
// a new timestamp (e.g. the retry flow persisting a fresh endedAt) must
254+
// still land — so only preserve when the snapshot omits the field.
255+
startedAt: hasStartedAt ? incoming.startedAt : stored.startedAt,
256+
endedAt: hasEndedAt ? incoming.endedAt : stored.endedAt,
253257
};
254258
};
255259

apps/daemon/tests/stale-message-snapshot-preserves-daemon-events.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,80 @@ describe('stale web message snapshot does not wipe daemon-owned run events', ()
239239
expect(after?.runId).toBe(before?.runId);
240240
});
241241

242+
it('lets a metadata update write a fresh endedAt while preserving daemon events', async () => {
243+
// The retry flow persists the completion timestamp as a metadata update
244+
// whose events array is empty (the web never carries the daemon's detailed
245+
// events). It must not be treated as a stale full snapshot: the fresh
246+
// endedAt should land while the daemon-owned events/status survive.
247+
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-meta-update-bin-'));
248+
const fakeClaude = await writeCleanClaude(binDir, 'claude-meta-update');
249+
250+
delete process.env.POSTHOG_KEY;
251+
delete process.env.POSTHOG_HOST;
252+
delete process.env.LANGFUSE_PUBLIC_KEY;
253+
delete process.env.LANGFUSE_SECRET_KEY;
254+
delete process.env.LANGFUSE_BASE_URL;
255+
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
256+
257+
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
258+
await putConfig(started.url, {
259+
agentId: 'claude',
260+
agentCliEnv: { claude: { CLAUDE_BIN: fakeClaude } },
261+
telemetry: { metrics: true, content: false, artifactManifest: false },
262+
privacyDecisionAt: Date.now(),
263+
});
264+
265+
const { projectId, conversationId } = await createConversation(started.url);
266+
const { assistantMessageId, status } = await sendRunAndWait(
267+
started.url,
268+
projectId,
269+
conversationId,
270+
);
271+
expect(status.status).toBe('succeeded');
272+
273+
const before = await fetchAssistantMessage(
274+
started.url,
275+
projectId,
276+
conversationId,
277+
assistantMessageId,
278+
);
279+
expect(before?.events?.length).toBeGreaterThan(0);
280+
expect(before?.endedAt).toBeTypeOf('number');
281+
282+
// Metadata-only update: empty events, matching terminal status, a NEW
283+
// endedAt the web observed on completion.
284+
const freshEndedAt = Date.now() + 10_000;
285+
const metadataUpdate = {
286+
id: assistantMessageId,
287+
role: 'assistant',
288+
content: '',
289+
runStatus: 'succeeded',
290+
events: [],
291+
endedAt: freshEndedAt,
292+
};
293+
const putResponse = await fetch(
294+
`${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(assistantMessageId)}`,
295+
{
296+
method: 'PUT',
297+
headers: { 'content-type': 'application/json' },
298+
body: JSON.stringify(metadataUpdate),
299+
},
300+
);
301+
expect(putResponse.status).toBe(200);
302+
303+
const after = await fetchAssistantMessage(
304+
started.url,
305+
projectId,
306+
conversationId,
307+
assistantMessageId,
308+
);
309+
// The web's fresh endedAt lands (metadata write), while daemon-owned
310+
// events and terminal status survive.
311+
expect(after?.endedAt).toBe(freshEndedAt);
312+
expect(after?.events?.length).toBeGreaterThan(0);
313+
expect(after?.runStatus).toBe('succeeded');
314+
});
315+
242316
it('lets a mock-agent flow persist events/runStatus when the daemon never wrote any', async () => {
243317
// e2e Playwright suites mock the run SSE end-to-end, so the daemon never
244318
// persists events for the assistant message — the web client is the only

0 commit comments

Comments
 (0)