mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* feat(native-chat): a queued card can record a Dispatch's task as its source A chat worker's task is held in the chat's queue like any agent message, so the card needs to say which Dispatch it is from: the sender, run, task and Dispatch ids. An older build reads an unknown kind as the person's card and sends it as written. * feat(orchestration): a chat can be a dispatch worker, like a terminal agent `dispatch --to orca_session_id:<id>` and `worker-start --terminal orca_session_id:<id>` now accept a chat on this host instead of refusing it. - The chat is refused only where mail to it would be: unknown, a provider id, another host, or closed. A chat can't be its own coordinator's worker. - Its task goes through sendAgentTurn as a queued send, as mail notices do: an idle chat starts a turn, a busy one holds a card naming the Dispatch. The operation id is derived from the Dispatch, so a resend replays. - worker-start reads that outcome as the terminal path reads its write; a card held behind a running turn is handed over with its start unobserved. - The Dispatch names the chat by its /clear root, with no pane or process, and records its Orca session id, so its own sub-dispatches nest under it. - Mail to dispatch:<id> reaches the chat; worker-show/list/read read the chat's session records; stop and abandon never close the chat; closing the chat fails its Dispatch as closing a terminal does. - A dispatch preamble for a structured session names the CLI the structured mail lane names. * test(orchestration): a chat worker's reach, and its Dispatch across a /clear At rest is live, a closed chat has exited, and another host or nothing to read is unverifiable. A /clear keeps the chat's Dispatch, and the session that continues the chat reports as it. * fix(orchestration): chat worker review round 1 - dispatch --inject is a keepalive-backed wait, so a chat that takes a while to accept its task no longer reads as a dead runtime at the 30 s idle cut. - An injected task whose delivery is unknown keeps its Dispatch open, as an unknown worker-start does, instead of failing it while the chat may run it. - A busy chat's worker-start receipt says the task waits as a card in the chat's queue, and that worker-abandon does not remove that card. - A close that puts the chat's tab back no longer fails its Dispatch: the closed-chat settle runs after the close's outcome, not at its hide. - worker-start adopting a chat says it gave the task to the chat, instead of claiming it started a terminal agent; the mode value is unchanged. - Test: a chat whose agent has not taken its task reads outcome_unknown. * test(native-chat): a close's hide defers its hidden notice until the close settles The rollback suite pinned the hide's arguments; it now expects the deferred notice, and that the notice is sent once, after a rolled-back tab is back. * fix(orchestration): a chat worker whose successor is unknown is unverifiable, not exited A /clear successor this host has no record of is missing evidence, not proof the chat is gone. Only a closed chat reads exited, so only a closed chat settles its Dispatch; a lost successor reads unverifiable with that reason. * fix(orchestration): chat worker review round 2 - A chat's close notice settles only that chat's Dispatch. A scan of every chat Dispatch read another chat whose close was still in flight (its tab hidden, maybe to be put back) as closed and failed its Dispatch. - Only a dispatch --inject into a chat is a long poll; a terminal inject writes and returns, and keeps its short-RPC slot. - Receipt wording: worker-start says it gave the task to the chat, and a queued task is sent when the chat's queue reaches it. * fix(orchestration): chat worker wording, review round 3 - worker-start's mode sentence for a chat states the placement, which is true whether the task is delivered, queued or refused. - Comments and a test title no longer claim a close notice re-derives every chat worker, or that a queued task waits for the current turn. * fix(orchestration): build a structured worker's task sender from the start's own ids The minted worker's preamble names who its task is from with the run, task and Dispatch ids worker-start already holds, so it reads no Dispatch row. * fix(orchestration): point a chat worker at its held Dispatch mail again A chat worker's coordinator mail lands in its Dispatch's mailbox, but a chat's idle edge re-derived the Dispatch mailbox only for a party with a terminal handle. A pointer lost with the provider then waited for new mail after a restart or a /clear. The re-derivation now looks the Dispatch up by the party's address, which names a worker by its handle and a chat by its root. * fix(native-chat): open a task card's sender by address; a task carries no mail Opening an agent message's sender looked up the mail it carried, which only a mail notice has; with the task kind in the union that read no longer typed. A task's coordinator is found by its address alone.
258 lines
9.3 KiB
TypeScript
258 lines
9.3 KiB
TypeScript
/**
|
|
* The chat tab must survive a close that did not land.
|
|
*
|
|
* `closeStructuredAgentSessionChild` hides the tab BEFORE it issues the close, so every failure
|
|
* shape past that point used to leave the user's chat tab pulled out of the durable restore index
|
|
* for a session that is still running — a destructive operation that refused, and still took
|
|
* something away.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
|
import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
|
|
|
const hostRef: { current: unknown } = { current: null }
|
|
|
|
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
|
|
getStructuredAgentSessionHost: () => hostRef.current
|
|
}))
|
|
|
|
const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close')
|
|
|
|
const SESSION = 'session-1'
|
|
const TAB_ID = 'tab-of-session-1'
|
|
|
|
function record(sessionId: string): AgentSessionRecord {
|
|
return {
|
|
sessionId,
|
|
provider: 'claude',
|
|
location: {
|
|
executionHostId: 'local',
|
|
wslDistro: null,
|
|
workspaceId: 'repo_1::/tmp/wt-a',
|
|
workspaceKind: 'folder'
|
|
},
|
|
lease: {
|
|
sessionId,
|
|
runtimeKind: 'native',
|
|
claimStatus: 'live',
|
|
handoffStage: null,
|
|
runtimeFence: 1,
|
|
deathEvidence: null
|
|
}
|
|
} as unknown as AgentSessionRecord
|
|
}
|
|
|
|
type HostOptions = {
|
|
/** Sessions the host keeps holding through a close, so the post-close observation is `live`. */
|
|
stuck?: boolean
|
|
/** Rejects the close, without the child going. */
|
|
closeThrows?: Error
|
|
/** The child dies and is recorded dead, but the close then fails past that proof. */
|
|
settledThenThrows?: boolean
|
|
/** Rejects the visibility write itself, so the hide never lands. */
|
|
visibilityThrows?: Error
|
|
/** Sessions already in the persisted visible-tab index. */
|
|
visible?: string[]
|
|
/** Blows up the index read, so the rollback cannot prove the tab was ever visible. */
|
|
indexThrows?: boolean
|
|
}
|
|
|
|
/** The hide a close sends; its hidden notice waits for the close's outcome. */
|
|
const HIDE_PENDING_CLOSE = [SESSION, false, undefined, { deferHiddenNotice: true }]
|
|
|
|
function installHost(options: HostOptions = {}) {
|
|
const entry = record(SESSION)
|
|
const held = new Set([SESSION])
|
|
const visible = new Set(options.visible ?? [SESSION])
|
|
const setSessionTabVisibility = vi.fn(async (sessionId: string, isVisible: boolean) => {
|
|
if (options.visibilityThrows) {
|
|
throw options.visibilityThrows
|
|
}
|
|
if (isVisible) {
|
|
visible.add(sessionId)
|
|
} else {
|
|
visible.delete(sessionId)
|
|
}
|
|
})
|
|
const notifySessionTabHidden = vi.fn()
|
|
const close = vi.fn(async (sessionId: string) => {
|
|
if (options.closeThrows) {
|
|
throw options.closeThrows
|
|
}
|
|
if (options.stuck) {
|
|
return
|
|
}
|
|
held.delete(sessionId)
|
|
entry.lease.claimStatus = 'released'
|
|
entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
|
|
if (options.settledThenThrows) {
|
|
throw new Error('the event sink could not be flushed')
|
|
}
|
|
})
|
|
const log = recordingStructuredAgentSessionLogger()
|
|
hostRef.current = {
|
|
deps: {
|
|
logger: log.logger,
|
|
store: {
|
|
getRecord: (id: string) => (id === SESSION ? entry : null),
|
|
getSessionTabId: (id: string) => {
|
|
if (options.indexThrows) {
|
|
throw new Error('visible tab index unreadable')
|
|
}
|
|
return visible.has(id) ? TAB_ID : null
|
|
}
|
|
}
|
|
},
|
|
hasSession: (sessionId: string) => held.has(sessionId),
|
|
setSessionTabVisibility,
|
|
notifySessionTabHidden,
|
|
close
|
|
}
|
|
return { close, setSessionTabVisibility, notifySessionTabHidden, visible, log }
|
|
}
|
|
|
|
describe('closeStructuredAgentSessionChild tab-visibility rollback', () => {
|
|
beforeEach(() => {
|
|
hostRef.current = null
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('retires the tab and reports the close on the success path', async () => {
|
|
const host = installHost()
|
|
const retire = vi.fn(() => true)
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION, {
|
|
runtime: {
|
|
retireStructuredAgentSessionTabFromSnapshot: retire
|
|
} as never
|
|
})
|
|
|
|
expect(outcome).toEqual({ stopped: true, closeAttempted: true })
|
|
expect(host.visible.has(SESSION)).toBe(false)
|
|
expect(retire).toHaveBeenCalledWith(SESSION)
|
|
// The hide is the only visibility write a settled close performs.
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
|
|
})
|
|
|
|
it('restores the tab when the close throws and the child is still there', async () => {
|
|
const host = installHost({ closeThrows: new Error('provider round trip failed') })
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(outcome.stopped).toBe(false)
|
|
expect(outcome.closeAttempted).toBe(true)
|
|
expect(outcome.reason).toBe('provider round trip failed')
|
|
expect(host.visible.has(SESSION)).toBe(true)
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([
|
|
HIDE_PENDING_CLOSE,
|
|
[SESSION, true, TAB_ID]
|
|
])
|
|
// Sent once, after the tab is back: a chat worker's Dispatch reads the chat as open.
|
|
expect(host.notifySessionTabHidden.mock.calls).toEqual([[SESSION]])
|
|
expect(host.notifySessionTabHidden.mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
host.setSessionTabVisibility.mock.invocationCallOrder[1]!
|
|
)
|
|
})
|
|
|
|
it('restores the tab when the post-close observation is not `exited`', async () => {
|
|
const host = installHost({ stuck: true })
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(outcome.stopped).toBe(false)
|
|
expect(outcome.closeAttempted).toBe(true)
|
|
expect(host.visible.has(SESSION)).toBe(true)
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([
|
|
HIDE_PENDING_CLOSE,
|
|
[SESSION, true, TAB_ID]
|
|
])
|
|
})
|
|
|
|
it('leaves the tab retired when a close throws PAST a proven exit', async () => {
|
|
// `closeStructuredSessionsForWorktree` re-observes and counts this session closed; republishing
|
|
// the tab here would resurrect it at the next launch for a workspace that is gone.
|
|
const host = installHost({ settledThenThrows: true })
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(outcome.stopped).toBe(false)
|
|
expect(host.visible.has(SESSION)).toBe(false)
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
|
|
})
|
|
|
|
it('does not put the tab back when the caller is discarding the workspace anyway', async () => {
|
|
// Worktree teardown passes this off for a removal that cannot refuse — force, and the
|
|
// folder-workspace paths. A tab put back there is a durable reference to a workspace that is
|
|
// about to be gone, so it republishes the chat at the next launch pointing at it.
|
|
const host = installHost({ stuck: true })
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION, {
|
|
restoreTabOnUnprovenClose: false
|
|
})
|
|
|
|
expect(outcome.stopped).toBe(false)
|
|
expect(host.visible.has(SESSION)).toBe(false)
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
|
|
})
|
|
|
|
it('does not publish a tab for a session that was already hidden', async () => {
|
|
const host = installHost({ closeThrows: new Error('provider round trip failed'), visible: [] })
|
|
|
|
await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(host.visible.has(SESSION)).toBe(false)
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
|
|
})
|
|
|
|
it('does not roll back a visibility write that never landed', async () => {
|
|
const host = installHost({ visibilityThrows: new Error('visibility write failed') })
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(outcome).toEqual({
|
|
stopped: false,
|
|
closeAttempted: false,
|
|
reason: 'visibility write failed'
|
|
})
|
|
expect(host.close).not.toHaveBeenCalled()
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
|
|
})
|
|
|
|
it('keeps the original failure when the restore itself throws', async () => {
|
|
const host = installHost({ stuck: true })
|
|
const restoreFailure = new Error('agent_session_identity_required')
|
|
host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => {
|
|
if (isVisible) {
|
|
throw restoreFailure
|
|
}
|
|
})
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(outcome.stopped).toBe(false)
|
|
expect(outcome.closeAttempted).toBe(true)
|
|
expect(outcome.reason).not.toContain('agent_session_identity_required')
|
|
expect(host.log.entries.map((entry) => entry.fields)).toEqual([
|
|
{ scope: 'close-tab-restore', sessionId: SESSION, error: restoreFailure }
|
|
])
|
|
})
|
|
|
|
it('claims nothing when the visible-tab index cannot be read', async () => {
|
|
const host = installHost({ indexThrows: true, closeThrows: new Error('boom') })
|
|
|
|
await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
|
|
})
|
|
|
|
it('reports no close attempt when no host is installed', async () => {
|
|
hostRef.current = null
|
|
|
|
const outcome = await closeStructuredAgentSessionChild(SESSION)
|
|
|
|
expect(outcome.stopped).toBe(false)
|
|
expect(outcome.closeAttempted).toBe(false)
|
|
})
|
|
})
|