fix: invalidate workspace state on turn_end so milestones list stays current (#2706) (#3266)

The milestones list only refreshed on agent_end events, causing stale
milestone state during multi-turn agent execution. Add turn_end as a
workspace cache invalidation trigger so the UI reflects milestone
changes after each turn boundary.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 15:48:40 -04:00 committed by GitHub
parent 603839d7f8
commit 155df22e9e
3 changed files with 90 additions and 1 deletions

View file

@ -397,10 +397,11 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag
harness.emit({ type: "auto_retry_end", success: false, attempt: 1, finalError: "still failing" });
harness.emit({ type: "auto_compaction_start", reason: "threshold" });
harness.emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
harness.emit({ type: "turn_end" });
const events = await readSseEventsUntil(
response,
(seen) => seen.filter((event) => event.type === "live_state_invalidation").length >= 5,
(seen) => seen.filter((event) => event.type === "live_state_invalidation").length >= 6,
);
const invalidations = events.filter((event) => event.type === "live_state_invalidation");
@ -416,6 +417,7 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag
{ reason: "auto_retry_end", source: "bridge_event", workspaceIndexCacheInvalidated: false },
{ reason: "auto_compaction_start", source: "bridge_event", workspaceIndexCacheInvalidated: false },
{ reason: "auto_compaction_end", source: "bridge_event", workspaceIndexCacheInvalidated: false },
{ reason: "turn_end", source: "bridge_event", workspaceIndexCacheInvalidated: true },
],
"live_state_invalidation reasons/sources should stay inspectable on /api/session/events",
);
@ -424,6 +426,7 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag
assert.deepEqual(invalidations[2].domains, ["auto", "recovery"]);
assert.deepEqual(invalidations[3].domains, ["auto", "recovery"]);
assert.deepEqual(invalidations[4].domains, ["auto", "recovery"]);
assert.deepEqual(invalidations[5].domains, ["workspace"]);
controller.abort();
await waitForMicrotasks();
@ -585,3 +588,79 @@ test("workspace cache only busts on real boundaries and session mutations emit t
unsubscribe();
});
test("turn_end events invalidate workspace so milestones list reflects current state (issue #2706)", async (t) => {
const fixture = makeWorkspaceFixture();
const sessionPath = createSessionFile(
fixture.projectCwd,
fixture.sessionsDir,
"sess-turn",
"Turn Session",
"2026-03-15T03:32:00.000Z",
);
let workspaceIndexCalls = 0;
const harness = createHarness((command, current) => {
if (command.type === "get_state") {
current.emit({
id: command.id,
type: "response",
command: "get_state",
success: true,
data: fakeSessionState("sess-turn", sessionPath),
});
return;
}
assert.fail(`unexpected command: ${command.type}`);
});
setupBridge(harness, fixture, {
indexWorkspace: async () => {
workspaceIndexCalls += 1;
return fakeWorkspaceIndex();
},
});
t.after(async () => {
await bridge.resetBridgeServiceForTests();
onboarding.resetOnboardingServiceForTests();
fixture.cleanup();
});
const service = bridge.getProjectBridgeService();
await service.ensureStarted();
const seenEvents: any[] = [];
const unsubscribe = service.subscribe((event) => {
seenEvents.push(event);
});
// Load workspace once to prime cache
await bridge.collectBootPayload();
assert.equal(workspaceIndexCalls, 1, "initial boot should call indexWorkspace once");
// Emit turn_end — this should invalidate the workspace cache so the
// milestones list picks up state changes that occurred during the turn.
harness.emit({ type: "turn_end" });
await waitForMicrotasks();
// Verify a live_state_invalidation was emitted for turn_end
const invalidations = seenEvents.filter((event) => event.type === "live_state_invalidation");
const turnEndInvalidation = invalidations.find((event) => event.reason === "turn_end");
assert.ok(turnEndInvalidation, "turn_end should emit a live_state_invalidation event");
assert.ok(
turnEndInvalidation.domains.includes("workspace"),
"turn_end invalidation should include the workspace domain",
);
assert.equal(
turnEndInvalidation.workspaceIndexCacheInvalidated,
true,
"turn_end should invalidate the workspace index cache",
);
// Verify workspace cache was actually busted
await bridge.collectBootPayload();
assert.equal(workspaceIndexCalls, 2, "turn_end should bust the workspace index cache so the next fetch re-indexes");
unsubscribe();
});

View file

@ -659,6 +659,7 @@ export type BridgeLiveStateDomain = "auto" | "workspace" | "recovery" | "resumab
export type BridgeLiveStateInvalidationSource = "bridge_event" | "rpc_command" | "session_manage";
export type BridgeLiveStateInvalidationReason =
| "agent_end"
| "turn_end"
| "auto_retry_start"
| "auto_retry_end"
| "auto_compaction_start"
@ -1251,6 +1252,13 @@ function createLiveStateInvalidationFromBridgeEvent(
domains: ["auto", "workspace", "recovery"],
workspaceIndexCacheInvalidated: true,
};
case "turn_end":
return {
reason: "turn_end",
source: "bridge_event",
domains: ["workspace"],
workspaceIndexCacheInvalidated: true,
};
case "auto_retry_start":
return {
reason: "auto_retry_start",
@ -1771,6 +1779,7 @@ export class BridgeService {
const eventType = (event as { type?: string }).type;
if (
eventType === "agent_end" ||
eventType === "turn_end" ||
eventType === "auto_retry_start" ||
eventType === "auto_retry_end" ||
eventType === "auto_compaction_start" ||

View file

@ -349,6 +349,7 @@ export type LiveStateInvalidationDomain = "auto" | "workspace" | "recovery" | "r
export type LiveStateInvalidationSource = "bridge_event" | "rpc_command" | "session_manage"
export type LiveStateInvalidationReason =
| "agent_end"
| "turn_end"
| "auto_retry_start"
| "auto_retry_end"
| "auto_compaction_start"