Skip to content

feat(core): retain finished foreground agents in BackgroundTaskRegistry - #3911

Closed
tanzhenxin wants to merge 1 commit into
feat/inline-agent-treefrom
feat/foreground-task-retention
Closed

feat(core): retain finished foreground agents in BackgroundTaskRegistry#3911
tanzhenxin wants to merge 1 commit into
feat/inline-agent-treefrom
feat/foreground-task-retention

Conversation

@tanzhenxin

Copy link
Copy Markdown
Collaborator

Stacked on #3904. Targets feat/inline-agent-tree; rebase target is main once PR 1 lands.

Summary

  • What changed: Foreground subagents stay in the background-tasks registry after their tool-call returns, transitioned to a terminal status (completed / failed / cancelled) and bounded by a 128-entry cap. The dialog row drops the [in turn] warning prefix once the entry is settled. Only GOAL terminations settle as completed; TIMEOUT, MAX_TURNS, SHUTDOWN, and ERROR all settle as failed and surface the reason in the dialog detail view.
  • Why it changed: Today's behavior is broken in two ways. First, the dialog can never drill into a finished foreground agent — the entry is deleted the moment the tool-call returns, so the post-run summary lives only in scrollback. Second (and worse), the React state subscribers actually do see the entry — but only as a stale "running" snapshot taken right before the delete. So a completed foreground agent appears in the dialog forever as [in turn] ... with no terminal icon, and the pill stays at 1 local agent for the rest of the session. This refactor fixes both issues with the same change. PR 3 of the umbrella refactor depends on this retention to drill into finished foreground agents from the committed scrollback path.
  • Reviewer focus: The cancel-then-settle race (a cancel() call lands first, then the tool-call's finally delivers the authoritative final stats — the settle path needs to attach those stats without overwriting the cancel verdict); the cancelled-not-notified guard in the prune (a background entry that's been cancelled but hasn't yet emitted its terminal task-notification must not be evicted, otherwise the SDK contract drops a notification and the headless wait loop strands); and the cap-enforcement triggering surface (prune now fires from every terminal-transition path, not just foreground settles, so a background-only session also stays bounded).

Validation

  • Commands run:
    npm run typecheck
    npm run build
    npm run bundle
    cd packages/core && npx vitest run src/agents/background-tasks.test.ts src/tools/agent/agent.test.ts
    cd packages/cli && npx vitest run src/ui/components/background-view/BackgroundTasksDialog.test.tsx
  • Prompts / inputs used: Custom subagent definitions for the failure-path E2E (a max_turns: 1 agent to reliably hit MAX_TURNS); standard "summarize README" prompts for the happy-path E2E.
  • Expected result: Unit tests pass; build clean; running a foreground subagent leaves a navigable terminal entry in the background-tasks dialog with the correct icon and stats, and the footer pill transitions to "task done" instead of staying stuck on "local agent".
  • Observed result: All matched expected. 63 registry tests + 60 agent tests + 30 dialog tests + the broader 6999 core suite pass. E2E groups A (completes), B (fails — MAX_TURNS), C (pill transitions), D (drill-in detail) all pass against the local bundle. A separate E2E test summary will follow as a comment.
  • Quickest reviewer verification path: Build the bundle, run node dist/cli.js, ask for a one-shot subagent summary of any file, then press Down → Enter to open the background-tasks dialog. Expect to see the agent listed with a terminal check icon (no [in turn] prefix) and the footer pill reading 1 task done. Press Enter on the row to confirm the detail view shows verb + stats + the activity log + the prompt.

Scope / Risk

  • Main risk or tradeoff: The cap is a deliberate forgetting policy. Long sessions with more than 128 terminal entries will silently evict the oldest by endTime. Mirrors the existing monitor-registry behavior, and the umbrella registry-unification work (feat(cli): background-agent UI — pill, combined dialog, detail view #3488) will revisit it. The [in turn] prefix removal on settled rows is a UX change — reviewers familiar with the prior look should expect settled foreground rows to read identically to settled background rows now.
  • Not covered / not validated: The cancel-then-settle race is exercised by unit tests but not by an E2E test (the timing trick to reliably produce a mid-flight user cancel against a tool-call settle isn't easily scriptable). The 128-cap eviction is similarly unit-tested but not exercised end-to-end.
  • Breaking changes / migration notes: The registry's unregisterForeground method has been renamed to settleForeground(id, status, details) with a different signature. Internal-only — the only call site is the agent tool's foreground finally path, which has been updated. No external callers in this repo or the SDK surface.

Testing Matrix

  • ✅ Unit tests (registry, agent tool, dialog component)
  • ✅ E2E happy path (foreground completes → dialog + pill + drill-in)
  • ✅ E2E failure path (MAX_TURNS triggers → dialog + error reason)
  • ✅ E2E pill transition (during run → after completion)
  • ✅ E2E drill-in (detail view with progress + prompt)
  • ⚠️ E2E cancel-then-settle race (unit-tested only — timing not scriptable in tmux)
  • ⚠️ E2E 128-cap eviction (unit-tested only — would need 129+ agents per session)
  • N/A SDK consumers (foreground entries don't emit task-notifications either before or after this PR)

Foreground subagents used to disappear from the registry the moment their
tool-call returned, which meant the background-tasks dialog could only
ever show a stale "running" snapshot of finished work — the entry was
deleted but the React state was never re-broadcast, so it lingered with
`[in turn]` and no terminal icon for the rest of the session.

Replace `unregisterForeground` with `settleForeground(id, status, details)`,
which transitions the entry to a terminal status, attaches the final
stats from the tool-call's `getExecutionSummary()`, and retains the
entry up to a 128-cap (mirrors `MonitorRegistry`). Eviction is FIFO by
`endTime` and triggers from every terminal-transition path so a
background-only session also stays bounded. Background entries that are
cancelled-but-not-finalized are excluded from prune to protect the
notification contract. The dialog row now drops the `[in turn]` prefix
once status leaves `running` so settled entries read cleanly. Only
`AgentTerminateMode.GOAL` settles as completed; `TIMEOUT` / `MAX_TURNS`
/ `SHUTDOWN` / `ERROR` all settle as failed with the reason on the
entry, so the dialog detail view shows accurate outcomes instead of a
green check on a run that hit a turn limit.
@tanzhenxin

Copy link
Copy Markdown
Collaborator Author

E2E test report

All four groups of the PR 2 test plan ran against node dist/cli.js in interactive tmux mode. Pre-implementation observations come from the dry-run against the global qwen CLI.

Group Pre-impl behavior Post-impl behavior Status
A — Foreground completes + retained Dialog row showed [in turn] <description> with no terminal icon; entry stuck in stale "running" React state; pill stayed at 1 local agent Dialog row shows <description> (no prefix); detail subtitle reads ✔ Completed · 18s · 37k tokens · 1 tool ✅ PASS
B — Foreground fails + retained as failed Failed entries left no trace in the dialog (entry was deleted on tool-return) Detail shows ✖ Failed · 3s · 6.9k tokens · 1 tool plus an Error section reading Agent terminated with mode: MAX_TURNS ✅ PASS
C — Pill transitions to terminal state Pill stuck at 1 local agent indefinitely after completion Pill reads 1 local agent during the run, transitions to 1 task done once the agent settles ✅ PASS
D — Drill into a finished foreground agent Could not — entry was deleted Detail view shows title + terminal subtitle + Progress section with 3 tool activities + Prompt section ✅ PASS

Reproduction sketch (Group A — most representative):

  1. Build the bundle (npm run build && npm run bundle).
  2. Launch the local CLI: node dist/cli.js --approval-mode yolo.
  3. Ask the agent to spawn a foreground subagent — e.g. "Use a subagent to summarize README.md".
  4. Wait for the inline agent box to settle (✓ Agent ... ● Completed).
  5. From the input field: press Down to focus the pill, then Enter to open the dialog.
  6. Expect the dialog list to show the agent description without an [in turn] prefix.
  7. Press Enter on the row to drill in. Expect the detail subtitle to show a ✔ Completed icon, a non-zero token count, and a tool count — and the footer pill to read 1 task done, not 1 local agent.

Group B reproduction note: the failure path needs a deliberately-failing subagent. The test-engineer used a custom user-level definition with runConfig.max_turns: 1 and a mandatory tool-use instruction to reliably hit MAX_TURNS. Reviewers wanting to reproduce the failure path can use the same trick or any subagent that exhausts its turn budget.

Coverage gaps (covered by unit tests, not by E2E):

  • Cancel-then-settle race (final stats attach on top of an already-cancelled entry).
  • 128-cap FIFO eviction across both foreground and background flavors.
  • Cancelled-but-not-finalized background entries are excluded from prune.

Each is exercised by the registry unit tests (63 total: 4 updated foreground tests + 4 new retention tests + the prior 55 covering register / complete / fail / cancel / activity / paths).

@tanzhenxin
tanzhenxin marked this pull request as draft May 7, 2026 11:33

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] BackgroundTaskEntry.flavor JSDoc is outdated — still says "unregistered when the tool-call returns" but the PR changes behavior to retain entries after settling. The file header and settleForeground method JSDoc were correctly updated, but the interface-level JSDoc at background-tasks.ts:132 was missed.

[Critical] Missing agent-level integration tests for ERROR/TIMEOUT/MAX_TURNS/SHUTDOWN terminate modes. The new try/finally block in agent.ts sets terminalStatus = 'failed' with terminalError for four terminate modes, but agent.test.ts only covers GOAL and CANCELLED. The settleForeground(id, 'failed', {error: ...}) path has zero assertion coverage at the integration level. Add foreground subagent tests that mock getTerminateMode() returning each of these modes.

— deepseek-v4-pro via Qwen Code /review

@@ -1567,6 +1573,19 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
returnDisplay: this.currentDisplay!,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] AgentTerminateMode.SHUTDOWN mapped to 'failed' is misleading.

SHUTDOWN is described as "gracefully shut down (e.g., arena/team session ended)" — a normal, expected termination. Mapping it to 'failed' shows a red X in the dialog, misleading users into thinking something went wrong.

Suggested change
returnDisplay: this.currentDisplay!,
if (terminateMode === AgentTerminateMode.GOAL) {
terminalStatus = 'completed';
} else if (terminateMode === AgentTerminateMode.SHUTDOWN) {
terminalStatus = 'cancelled'; // graceful external shutdown
} else {
terminalStatus = 'failed';
terminalError =
finalText || `Agent terminated with mode: ${terminateMode}`;
}

— deepseek-v4-pro via Qwen Code /review

case 'agent': {
const label = buildBackgroundEntryLabel(entry, { includePrefix: false });
return entry.flavor === 'foreground'
const isLive = entry.status === 'running' || entry.status === 'paused';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Variable name isLive is ambiguous.

The core BackgroundTaskRegistry consistently uses "terminal" / "non-terminal" terminology. isLive has no precedent in the codebase and could be misread as "row is currently rendered in the dialog" rather than "execution is active". isActive or isNonTerminal would be clearer.

Suggested change
const isLive = entry.status === 'running' || entry.status === 'paused';
const isActive = entry.status === 'running' || entry.status === 'paused';
return entry.flavor === 'foreground' && isActive

— deepseek-v4-pro via Qwen Code /review

// Tracked across try/finally so the finally can settle the registry
// entry with the right terminal status. Defaults to `'failed'` so an
// unexpected throw inside `runFramed` (which lands in the outer
// catch, AFTER the inner finally has already run) still settles the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] String literal 'Subagent execution failed.' is duplicated in the same try block.

It appears once for terminalError and once for llmContent fallback. If someone updates one but not the other, they'll diverge.

Suggested change
// catch, AFTER the inner finally has already run) still settles the
const FAILED_DEFAULT = 'Subagent execution failed.';
if (terminateMode === AgentTerminateMode.ERROR) {
terminalStatus = 'failed';
terminalError = finalText || FAILED_DEFAULT;
return {
llmContent: finalText || FAILED_DEFAULT,

— deepseek-v4-pro via Qwen Code /review

a.startTime - b.startTime,
);
while (terminal.length > MAX_RETAINED_TERMINAL_BACKGROUND_TASKS) {
const oldest = terminal.shift();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] pruneTerminalEntries() evicts entries silently — no debug log.

When a terminal entry is evicted by the 128-entry cap, there's no debugLogger output. Troubleshooting "why did my agent disappear from the dialog?" becomes a guessing game between "pruned", "never registered", and "bug".

Suggested change
const oldest = terminal.shift();
while (terminal.length > MAX_RETAINED_TERMINAL_BACKGROUND_TASKS) {
const oldest = terminal.shift();
if (oldest) {
debugLogger.info(
`Pruning terminal entry: ${oldest.agentId} (status=${oldest.status})`,
);
this.agents.delete(oldest.agentId);
}
}

— deepseek-v4-pro via Qwen Code /review

// skips prune + emit and avoids triggering a redundant UI refresh.
let mutated = false;

if (entry.status === 'running') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] settleForeground guards on entry.status === 'running' rather than !isTerminalStatus(entry.status).

If a future non-terminal status (e.g., 'pausing') is added to BackgroundTaskStatus, settleForeground would silently skip the transition, leaving the entry stuck indefinitely. Either switch to !isTerminalStatus() or add a comment documenting this constraining assumption.

— deepseek-v4-pro via Qwen Code /review

this.eventEmitter.on(AgentEventType.TOOL_CALL, onFgToolCall);
this.eventEmitter.on(AgentEventType.USAGE_METADATA, onFgUsageMetadata);

// Tracked across try/finally so the finally can settle the registry

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] terminalError stays undefined when runFramed() throws an unexpected exception.

The outer catch has the actual error message, but runs AFTER the inner finally has already called settleForeground with error: undefined. The dialog shows a failed entry with no error reason. Consider capturing the error via a closure variable that the finally block reads.

Suggested change
// Tracked across try/finally so the finally can settle the registry
let settleError: string | undefined;
try {
await runFramed();
// ... existing code ...
} finally {
// ...
registry.settleForeground(hookOpts.agentId, terminalStatus, {
error: terminalError ?? settleError,
});
}
} catch (error) {
settleError = error instanceof Error ? error.message : String(error);

— deepseek-v4-pro via Qwen Code /review

}),
);
expect(mockRegistry.unregisterForeground).toHaveBeenCalledWith(
expect(mockRegistry.settleForeground).toHaveBeenCalledWith(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] GOAL test uses expect.any(Object) for settleForeground details — no concrete stats assertion.

The details object with {totalTokens, toolUses, durationMs} is never verified. If getExecutionSummary() returns the wrong shape or fgLiveToolCallCount is stale, no test catches it.

Suggested change
expect(mockRegistry.settleForeground).toHaveBeenCalledWith(
expect(mockRegistry.settleForeground).toHaveBeenCalledWith(
expect.stringContaining('file-search-'),
'completed',
expect.objectContaining({
error: undefined,
stats: expect.objectContaining({
totalTokens: expect.any(Number),
toolUses: expect.any(Number),
durationMs: expect.any(Number),
}),
}),
);

— deepseek-v4-pro via Qwen Code /review

@tanzhenxin

Copy link
Copy Markdown
Collaborator Author

Closing — not pursuing this approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants