fix(agent-world): stop interrupted subagent rows pulsing forever (#3990)

This commit is contained in:
Cyrus Gray
2026-06-23 22:53:15 +05:30
committed by GitHub
parent 4c2277070f
commit b572de6f22
2 changed files with 182 additions and 2 deletions
+136
View File
@@ -1,14 +1,47 @@
import { configureStore } from '@reduxjs/toolkit';
import { describe, expect, it } from 'vitest';
import type { AgentRun, AgentRunStatus, PersistedTurnState } from '../types/turnState';
import chatRuntimeReducer, {
clearAllChatRuntime,
clearQueueStatusForThread,
clearRuntimeForThread,
hydrateRuntimeFromRunLedger,
hydrateRuntimeFromSnapshot,
type QueueStatus,
setQueueStatusForThread,
} from './chatRuntimeSlice';
function makeRun(id: string, status: AgentRunStatus): AgentRun {
return {
id,
kind: 'subagent',
status,
agentId: 'tinyplace_agent',
metadata: { displayName: 'Tinyplace Agent' },
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
function makeInterruptedSnapshot(
threadId: string,
toolTimeline: PersistedTurnState['toolTimeline']
): PersistedTurnState {
return {
threadId,
requestId: 'req-1',
lifecycle: 'interrupted',
iteration: 3,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline,
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
function makeStore() {
return configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
}
@@ -78,6 +111,109 @@ describe('chatRuntimeSlice queue status', () => {
});
});
it('settles orphaned running rows when hydrating an interrupted snapshot', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedSnapshot('t1', [
{
id: 't1:subagent:s1:tinyplace_agent',
name: 'subagent:tinyplace_agent',
round: 1,
status: 'running',
subagent: {
taskId: 's1',
agentId: 'tinyplace_agent',
status: 'running',
toolCalls: [],
},
},
{
id: 't1:subagent:s2:tinyplace_agent',
name: 'subagent:tinyplace_agent',
round: 1,
status: 'success',
subagent: {
taskId: 's2',
agentId: 'tinyplace_agent',
status: 'completed',
toolCalls: [],
},
},
{
id: 't1:subagent:s3:tinyplace_agent',
name: 'subagent:tinyplace_agent',
round: 1,
status: 'error',
subagent: { taskId: 's3', agentId: 'tinyplace_agent', status: 'failed', toolCalls: [] },
},
]),
})
);
const timeline = store.getState().chatRuntime.toolTimelineByThread['t1'];
// The dangling 'running' row becomes terminal 'cancelled' (no live driver to settle it)…
expect(timeline[0].status).toBe('cancelled');
expect(timeline[0].subagent?.status).toBe('cancelled');
// …while already-terminal rows are left untouched.
expect(timeline[1].status).toBe('success');
expect(timeline[1].subagent?.status).toBe('completed');
expect(timeline[2].status).toBe('error');
expect(timeline[2].subagent?.status).toBe('failed');
});
it('renders interrupted run-ledger rows as muted (cancelled), reserving error for failed', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromRunLedger({
threadId: 't1',
runs: [
makeRun('sub-interrupted', 'interrupted'),
makeRun('sub-failed', 'failed'),
makeRun('sub-completed', 'completed'),
],
})
);
const byId = Object.fromEntries(
store.getState().chatRuntime.toolTimelineByThread['t1'].map(e => [e.id, e.status])
);
// Orphaned (interrupted) background runs are terminal but NOT user-facing
// errors — muted, not alarming red.
expect(byId['subagent:sub-interrupted']).toBe('cancelled');
// A genuine failure still surfaces as an error.
expect(byId['subagent:sub-failed']).toBe('error');
expect(byId['subagent:sub-completed']).toBe('success');
});
it('settles the parent row but preserves an awaiting_user subagent on interrupt', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedSnapshot('t2', [
{
id: 't2:subagent:s1:researcher',
name: 'subagent:researcher',
round: 1,
// Core keeps the row `running` while the child is paused for the user.
status: 'running',
subagent: {
taskId: 's1',
agentId: 'researcher',
status: 'awaiting_user',
workerThreadId: 'worker-1',
toolCalls: [],
},
},
]),
})
);
const row = store.getState().chatRuntime.toolTimelineByThread['t2'][0];
// The row stops pulsing (status drives agentNameTone)…
expect(row.status).toBe('cancelled');
// …but the truthful "was awaiting user" child state is kept, not clobbered.
expect(row.subagent?.status).toBe('awaiting_user');
expect(row.subagent?.workerThreadId).toBe('worker-1');
});
it('isolates queue status across threads', () => {
const store = makeStore();
store.dispatch(
+46 -2
View File
@@ -401,6 +401,42 @@ function toolTimelineFromPersisted(entry: PersistedToolTimelineEntry): ToolTimel
};
}
/**
* Settle a rehydrated tool/subagent row that has no live event driver.
*
* A turn-state snapshot is a point-in-time mirror: a row left at the
* non-terminal `running` status was still in-flight when the snapshot was
* written. When the owning turn was *interrupted* (the core process that was
* driving it is gone — see `mark_all_interrupted`), no `subagent_done` /
* `chat_done` event will ever arrive to flip it terminal, so the row would
* pulse forever — the agent-name blink is driven by the row `status`
* (`agentNameTone(entry.status)`; `running` pulses, `cancelled` is muted &
* static). Settle the row to `cancelled` — terminal, muted, not pulsing —
* mirroring `markSubagentCancelled`.
*
* `running` is the only non-terminal value the persisted *row* status can carry
* (`PersistedToolStatus` is `running | success | error`), so that single guard
* catches every orphan.
*
* The nested `subagent.status` is a richer enum: a subagent that emitted
* `SubagentAwaitingUser` is persisted with the row `running` but
* `subagent.status = 'awaiting_user'`. Only settle a child that is *itself*
* still `running`; leaving `awaiting_user` (and any other non-running child)
* intact preserves the truthful "was waiting for the user" history — and the
* pulse is already stopped by the row-level `cancelled` above.
*/
function settleOrphanedTimelineEntry(entry: ToolTimelineEntry): ToolTimelineEntry {
if (entry.status !== 'running') return entry;
return {
...entry,
status: 'cancelled',
subagent:
entry.subagent && entry.subagent.status === 'running'
? { ...entry.subagent, status: 'cancelled' }
: entry.subagent,
};
}
function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntryStatus {
switch (status) {
case 'completed':
@@ -408,8 +444,12 @@ function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntrySta
case 'cancelled':
return 'cancelled';
case 'failed':
case 'interrupted':
return 'error';
case 'interrupted':
// Orphaned by a process exit (e.g. a detached subagent the core lost track
// of and settled on next boot) — terminal, but not a user-facing error.
// Render muted/static like `cancelled`, not alarming red.
return 'cancelled';
case 'awaiting_user':
case 'paused':
return 'awaiting_user';
@@ -864,7 +904,11 @@ const chatRuntimeSlice = createSlice({
if (snapshot.lifecycle === 'interrupted') {
delete state.inferenceStatusByThread[threadId];
delete state.streamingAssistantByThread[threadId];
state.toolTimelineByThread[threadId] = snapshot.toolTimeline.map(toolTimelineFromPersisted);
// No live driver remains for this turn — settle any in-flight rows so
// their agent names stop pulsing instead of blinking forever.
state.toolTimelineByThread[threadId] = snapshot.toolTimeline
.map(toolTimelineFromPersisted)
.map(settleOrphanedTimelineEntry);
return;
}