mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(native-chat): transfer resumed background tasks to one live owner
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
const agent = {
|
||||
task_id: 'a962f88aa82feb1c1',
|
||||
task_type: 'local_agent',
|
||||
description: 'Long proof writer'
|
||||
}
|
||||
const shell = { task_id: 'bcl6x3ixf', task_type: 'local_bash', description: 'sleep 150' }
|
||||
const sibling = { task_id: 'sibling', task_type: 'local_agent' }
|
||||
|
||||
function system(subtype: string, fields: Record<string, unknown>) {
|
||||
return { type: 'system', subtype, ...fields }
|
||||
}
|
||||
|
||||
describe('Claude background task pause/resume ownership', () => {
|
||||
it('moves a retained child back to live ownership across eviction, outcome, and auto-resume', () => {
|
||||
let now = 100
|
||||
const tracker = new ClaudeBackgroundTaskTracker(() => now)
|
||||
const roster = (tasks: unknown[]) =>
|
||||
tracker.observe(system('background_tasks_changed', { tasks }))
|
||||
roster([agent, sibling, shell])
|
||||
tracker.observe(
|
||||
system('task_progress', { task_id: agent.task_id, usage: { total_tokens: 18000 } })
|
||||
)
|
||||
tracker.observe({ type: 'result' })
|
||||
expect(tracker.state?.tasks).toHaveLength(3)
|
||||
roster([sibling, shell])
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
tracker.observe(
|
||||
system('task_updated', { task_id: agent.task_id, patch: { status: 'completed' } })
|
||||
)
|
||||
tracker.observe(
|
||||
system('task_notification', {
|
||||
task_id: agent.task_id,
|
||||
status: 'completed',
|
||||
usage: { total_tokens: 19003 }
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.settledTasks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: agent.task_id,
|
||||
state: 'done',
|
||||
startedAt: 100,
|
||||
totalTokens: 19003
|
||||
})
|
||||
])
|
||||
now = 150000
|
||||
roster([agent, sibling, shell])
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
expect(tracker.state?.tasks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: agent.task_id,
|
||||
state: 'working',
|
||||
startedAt: 100,
|
||||
totalTokens: 19003
|
||||
}),
|
||||
expect.objectContaining({ id: sibling.task_id }),
|
||||
expect.objectContaining({ id: shell.task_id })
|
||||
])
|
||||
expect(tracker.stoppableTaskIds).toEqual([agent.task_id, sibling.task_id, shell.task_id])
|
||||
roster([sibling, shell])
|
||||
tracker.observe(
|
||||
system('task_notification', {
|
||||
task_id: agent.task_id,
|
||||
status: 'completed',
|
||||
usage: { total_tokens: 21000 }
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.settledTasks).toEqual([
|
||||
expect.objectContaining({ id: agent.task_id, startedAt: 100, totalTokens: 21000 })
|
||||
])
|
||||
roster([])
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('reconciles an edge-only resume without keeping its earlier settled copy', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker(() => 100)
|
||||
for (const task of [agent, sibling]) {
|
||||
tracker.observe(system('task_started', { ...task, is_backgrounded: true }))
|
||||
}
|
||||
tracker.observe(system('task_notification', { task_id: agent.task_id, status: 'completed' }))
|
||||
tracker.observe(
|
||||
system('task_updated', {
|
||||
task_id: agent.task_id,
|
||||
patch: { status: 'running', is_backgrounded: true }
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.tasks).toHaveLength(2)
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -196,8 +196,8 @@ export class ClaudeBackgroundTaskTracker {
|
||||
continue
|
||||
}
|
||||
// An authoritative live roster supersedes an earlier terminal edge.
|
||||
this.retention.forget(id)
|
||||
const existing = prior.get(id)
|
||||
const retained = this.retention.resume(id)
|
||||
const existing = prior.get(id) ?? retained
|
||||
const kind = classifyClaudeBackgroundTaskKind(task.task_type)
|
||||
this.tasks.set(id, {
|
||||
backgrounded: true,
|
||||
@@ -217,20 +217,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
|
||||
private upsert(id: string, task: TrackedClaudeBackgroundTask): void {
|
||||
const existing = this.tasks.get(id)
|
||||
if (existing) {
|
||||
this.tasks.set(id, {
|
||||
backgrounded: existing.backgrounded || task.backgrounded,
|
||||
kind: task.kind !== 'unknown' ? task.kind : existing.kind,
|
||||
description: task.description ?? existing.description,
|
||||
name: task.name ?? existing.name,
|
||||
state: task.state ?? existing.state,
|
||||
startedAt: existing.startedAt,
|
||||
totalTokens: existing.totalTokens
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
let foregroundId: string | undefined
|
||||
for (const [candidateId, candidate] of this.tasks) {
|
||||
if (!candidate.backgrounded) {
|
||||
@@ -243,6 +230,20 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
this.tasks.delete(foregroundId)
|
||||
}
|
||||
const existing = this.tasks.get(id) ?? this.retention.resume(id)
|
||||
this.terminalTaskIds.delete(id)
|
||||
if (existing) {
|
||||
this.tasks.set(id, {
|
||||
backgrounded: existing.backgrounded || task.backgrounded,
|
||||
kind: task.kind !== 'unknown' ? task.kind : existing.kind,
|
||||
description: task.description ?? existing.description,
|
||||
name: task.name ?? existing.name,
|
||||
state: task.state ?? existing.state,
|
||||
startedAt: existing.startedAt,
|
||||
totalTokens: existing.totalTokens
|
||||
})
|
||||
return
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,9 +93,17 @@ export class ClaudeSettledBackgroundTasks {
|
||||
this.recentlyRemoved.delete(id)
|
||||
}
|
||||
|
||||
forget(id: string): void {
|
||||
/** Positive live evidence transfers identity back to the tracker, never the old outcome. */
|
||||
resume(id: string): TrackedClaudeBackgroundTask | undefined {
|
||||
const settled = this.settled.get(id)
|
||||
const removed = this.recentlyRemoved.get(id)
|
||||
this.settled.delete(id)
|
||||
this.recentlyRemoved.delete(id)
|
||||
const source = settled ?? removed
|
||||
if (!source || source.startedAt === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return { ...source, backgrounded: true, state: undefined, startedAt: source.startedAt }
|
||||
}
|
||||
|
||||
get hasSettled(): boolean {
|
||||
|
||||
@@ -200,3 +200,24 @@ describe('resolveBackgroundTaskName', () => {
|
||||
).toBe('Background command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumed tasks from mixed-version hosts', () => {
|
||||
it('renders one live owner per id and counts only the two dispatched agents', () => {
|
||||
const live = agent('resumed', { totalTokens: 20000 })
|
||||
const settled = agent('resumed', { state: 'done', totalTokens: 19003 })
|
||||
const shells = Array.from({ length: 4 }, (_, index) =>
|
||||
agent(`shell-${index}`, { kind: 'command' })
|
||||
)
|
||||
const groups = buildBackgroundTaskGroups(
|
||||
[live, ...shells],
|
||||
[settled, agent('sibling', { state: 'done' })]
|
||||
)
|
||||
expect(
|
||||
groups.flatMap((group) => group.tasks).filter((entry) => entry.task.id === live.id)
|
||||
).toEqual([{ task: live, settled: false, state: 'working', name: 'Background agent' }])
|
||||
expect(backgroundTasksHeaderContent(groups, { narrow: false, now: NOW }).segments).toEqual([
|
||||
'2 agents',
|
||||
'4 shells'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,20 +76,22 @@ export function buildBackgroundTaskGroups(
|
||||
tasks: readonly AgentSessionBackgroundTask[],
|
||||
settledTasks: readonly AgentSessionBackgroundTask[]
|
||||
): BackgroundTaskGroup[] {
|
||||
const entries: BackgroundRosterTask[] = [
|
||||
...tasks.map((task) => ({
|
||||
task,
|
||||
settled: false,
|
||||
state: effectiveState(task, false),
|
||||
name: resolveBackgroundTaskName(task)
|
||||
})),
|
||||
...settledTasks.map((task) => ({
|
||||
task,
|
||||
settled: true,
|
||||
state: effectiveState(task, true),
|
||||
name: resolveBackgroundTaskName(task)
|
||||
}))
|
||||
]
|
||||
// Older hosts can retain a previous turn beside its resumed live task.
|
||||
const owners = new Map<string, BackgroundRosterTask>()
|
||||
for (const [roster, settled] of [
|
||||
[settledTasks, true],
|
||||
[tasks, false]
|
||||
] as const) {
|
||||
for (const task of roster) {
|
||||
owners.set(task.id, {
|
||||
task,
|
||||
settled,
|
||||
state: effectiveState(task, settled),
|
||||
name: resolveBackgroundTaskName(task)
|
||||
})
|
||||
}
|
||||
}
|
||||
const entries = [...owners.values()]
|
||||
entries.sort((left, right) => {
|
||||
const startDelta = (left.task.startedAt ?? 0) - (right.task.startedAt ?? 0)
|
||||
return startDelta !== 0 ? startDelta : left.task.id < right.task.id ? -1 : 1
|
||||
|
||||
Reference in New Issue
Block a user