mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
fix(agent-status): harden single-store lifecycle
This commit is contained in:
@@ -24,11 +24,11 @@ the structured-session mapping and nothing else.
|
||||
An audit on 2026-09-09 found six producers and three consumers, and three
|
||||
separate copies of the same row inside the main process alone:
|
||||
|
||||
| Main-process copy | Keyed by | Owned by | Persisted | Evicted |
|
||||
| -------------------------------------- | --------- | ---------------------------------------------------------- | ------------------- | ----------------------------- |
|
||||
| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate |
|
||||
| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only |
|
||||
| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) |
|
||||
| Main-process copy | Keyed by | Owned by | Persisted | Evicted |
|
||||
| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- |
|
||||
| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate |
|
||||
| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only |
|
||||
| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) |
|
||||
|
||||
The second copy is a duplicate write: the OSC status parsed in main is
|
||||
forwarded to the hook server _and_ retained in the runtime store from the same
|
||||
@@ -92,14 +92,14 @@ The structured feed keeps its job of projecting a session's journal into a
|
||||
summary and streaming it to subscribers. On every publish it additionally
|
||||
ingests the summary into the hook server as a status row:
|
||||
|
||||
| Row field | From |
|
||||
| ----------------- | ------------------------------------------------------------- |
|
||||
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
|
||||
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
|
||||
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
|
||||
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
|
||||
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
|
||||
| prompt, tool, last message, model, provider session | the summary's fields |
|
||||
| Row field | From |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
|
||||
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
|
||||
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
|
||||
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
|
||||
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
|
||||
| prompt, tool, last message, model, provider session | the summary's fields |
|
||||
|
||||
Sessions with no persisted turn (`status === null`) produce no row, matching
|
||||
what the chat shows. When the host revokes live ownership the row is re-set
|
||||
@@ -192,13 +192,13 @@ store is now the only main-process copy of a PTY agent's row.
|
||||
|
||||
### The five call sites
|
||||
|
||||
| Call site | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Call site | Before | After |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `orca-runtime-create-terminal-side-effect-command-code-detector.ts` `retain()` | second write of the OSC payload already sent to the hook server | deleted; the event now carries the pane's `terminalHandle` and the hook ingest keeps the only copy |
|
||||
| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row |
|
||||
| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` |
|
||||
| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only |
|
||||
| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` |
|
||||
| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row |
|
||||
| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` |
|
||||
| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only |
|
||||
| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` |
|
||||
|
||||
Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns
|
||||
`RuntimeAgentRowSnapshot` now that nothing retains one.
|
||||
@@ -235,8 +235,9 @@ for a row whose pane binding was nulled by a controller incarnation change.
|
||||
`retain()` was not only a store: its boolean return was the signal that
|
||||
republished `session.tabs` for a status-only transition, which no title change
|
||||
covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that
|
||||
exact change set plus hook restore provenance, so the replacement was to route
|
||||
the signal off the store rather than build a second comparator.
|
||||
projection change set, including restore provenance and terminal-handle joins,
|
||||
so the replacement was to route the signal off the store rather than build a
|
||||
second comparator.
|
||||
`installHookStatusSessionTabsRepublish` now owns all three arms — enriched
|
||||
status, pane clear, and the status-drop tap a dismissal emits — and both hosts
|
||||
install it.
|
||||
@@ -254,14 +255,11 @@ republish signal, alongside the snapshot and structured sink it already had.
|
||||
A row the user dismisses on the desktop leaves `worktree ps` and the phone at
|
||||
once, instead of lingering until the pty exits. One store means one dismissal.
|
||||
|
||||
### The one consequence that was not intended
|
||||
|
||||
A legacy numeric pane key (`<tabId>:<paneRuntimeId>`, minted for a
|
||||
pre-stable-id `pane:N` leaf) fails `parsePaneKey`, so `ingestTerminalStatus`
|
||||
refuses it. Such a pane already produced no hook row and therefore no sidebar
|
||||
row; the retained store was the last thing still listing it in `worktree ps`
|
||||
and on mobile. Those rows are now absent everywhere rather than present in two
|
||||
surfaces out of four.
|
||||
Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts
|
||||
register aliases to their stable leaf owners; an in-process OSC observation may
|
||||
also retain a numeric key only when the runtime supplies the matching tab, PTY,
|
||||
and terminal handle. HTTP and relay ingress still require a stable key or a
|
||||
registered alias, and numeric rows are never persisted.
|
||||
|
||||
## PR 2: the renderer subscribes
|
||||
|
||||
@@ -271,14 +269,14 @@ unmount cleanup becomes a tab-close signal to the host. The IPC applicator is
|
||||
the single writer for observed status. The 2026-09-09 audit sorted the other
|
||||
writers:
|
||||
|
||||
| Writer | Disposition |
|
||||
| --------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
|
||||
| structured bridge status writes | delete; main now publishes the row |
|
||||
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
|
||||
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
|
||||
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
|
||||
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
|
||||
| Writer | Disposition |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
|
||||
| structured bridge status writes | delete; main now publishes the row |
|
||||
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
|
||||
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
|
||||
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
|
||||
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
|
||||
|
||||
The Command Code done-settle window is renderer policy with no main
|
||||
equivalent. PR 2 either moves it into main's detector or leaves it, and says
|
||||
@@ -302,6 +300,44 @@ call it.
|
||||
- Hydration honesty: a restored non-done row is `restoredUnconfirmed` and is
|
||||
never fresh.
|
||||
|
||||
## PR 1b reliability contract
|
||||
|
||||
- **Invariant (`agent-session.status-host-ownership`):** each execution host has
|
||||
one agent-status store; OSC, hooks, and structured sessions write it, while
|
||||
desktop, `worktree ps`, and mobile only project it. Dismissal, certified PTY
|
||||
exit, and provider-generation replacement remove the same row everywhere;
|
||||
transport loss alone removes nothing.
|
||||
- **Failure source:** the deleted runtime row store duplicated OSC observations,
|
||||
keyed them by a different terminal identity, and outlived a dismissal from the
|
||||
hook store. Relay replay could also make old evidence look fresh when readers
|
||||
used its new delivery timestamp.
|
||||
- **Oracle:** one OSC observation appears through the hook snapshot in
|
||||
`worktree ps` and mobile, and one store dismissal removes it from both without
|
||||
stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin,
|
||||
legacy numeric-pane compatibility, certified-exit and provider-generation
|
||||
cleanup, evidence-age freshness, and exactly-once startup/stop teardown.
|
||||
- **Gate:** `terminal-performance.osc-status-scan-budget` covers the unchanged
|
||||
bounded OSC parser and the runtime projection. There is not yet a dedicated
|
||||
blocking multi-surface status-store gate; the focused suites below are the
|
||||
accepted gap until they accumulate reliability-gate soak evidence.
|
||||
- **Provider/platform coverage:** local and daemon-backed PTYs are covered by
|
||||
runtime tests, and SSH relay loss/replay semantics by relay integration tests.
|
||||
The projection is shared by git worktrees and folder workspaces. WSL uses the
|
||||
same store and admission code but has no live run here; Linux and Windows
|
||||
runtime execution, native mobile clients, and mixed-version paired clients
|
||||
remain validation gaps.
|
||||
- **Performance budget:** publication stays event-driven with no new polling or
|
||||
subprocesses. One mobile projection clones the status snapshot once, builds
|
||||
pane/handle indexes once, and has a deterministic call-count test; lifecycle
|
||||
cleanup is bounded by the existing status and handle inventories, and orcad
|
||||
tests prove listeners clean up once on failed startup and repeated stop.
|
||||
- **Diagnostics:** existing hook-listener errors name the pane and PTY, while
|
||||
status-store tests pin delivery versus evidence clocks. No new telemetry or
|
||||
raw terminal data is emitted.
|
||||
- **Residual gaps:** rendered Electron/mobile behavior, live SSH reconnect, and
|
||||
Linux/Windows/WSL execution require the platform QA pass. The current
|
||||
cross-version gate does not cover `session.tabs` content.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit: ingest a structured summary and read it back through
|
||||
|
||||
@@ -36,6 +36,14 @@ describe('createHookStatusSessionTabsInvalidator', () => {
|
||||
expect(changed(working())).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates when a row acquires a terminal handle', () => {
|
||||
const changed = createHookStatusSessionTabsInvalidator()
|
||||
changed(working())
|
||||
|
||||
expect(changed(working({ terminalHandle: 'term_rejoined' }))).toBe(true)
|
||||
expect(changed(working({ terminalHandle: 'term_rejoined' }))).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['state', { state: 'waiting' as const }],
|
||||
['workingMode', { workingMode: 'monitoring' as const }],
|
||||
|
||||
@@ -5,6 +5,7 @@ type KnownStatus = {
|
||||
connectionId: string | null
|
||||
payload: ParsedAgentStatusPayload
|
||||
restoredUnconfirmed: boolean
|
||||
terminalHandle: string | null
|
||||
}
|
||||
|
||||
/** Reports whether a hook status event changed anything the `session.tabs`
|
||||
@@ -29,7 +30,8 @@ export function createHookStatusSessionTabsInvalidator(): {
|
||||
known.set(event.paneKey, {
|
||||
connectionId: event.connectionId,
|
||||
payload: next,
|
||||
restoredUnconfirmed
|
||||
restoredUnconfirmed,
|
||||
terminalHandle: event.terminalHandle ?? null
|
||||
})
|
||||
return (
|
||||
!previous ||
|
||||
@@ -42,7 +44,8 @@ export function createHookStatusSessionTabsInvalidator(): {
|
||||
(previous.payload.interrupted ?? false) !== (next.interrupted ?? false) ||
|
||||
(previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) ||
|
||||
(previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) ||
|
||||
previous.restoredUnconfirmed !== restoredUnconfirmed
|
||||
previous.restoredUnconfirmed !== restoredUnconfirmed ||
|
||||
previous.terminalHandle !== (event.terminalHandle ?? null)
|
||||
)
|
||||
}
|
||||
// Why: a cleared pane must re-arm, else the memo swallows the first event of the
|
||||
@@ -50,8 +53,8 @@ export function createHookStatusSessionTabsInvalidator(): {
|
||||
invalidator.forgetPane = (paneKey: string): void => {
|
||||
known.delete(paneKey)
|
||||
}
|
||||
// Why: an SSH disconnect clears a whole host's rows at once and names no pane, so
|
||||
// the caller needs the pane list back to republish each affected workspace.
|
||||
// Why: an explicit connection clear names no pane, so the caller needs the pane list
|
||||
// back to republish each affected workspace.
|
||||
invalidator.forgetConnection = (connectionId: string): string[] => {
|
||||
const forgotten: string[] = []
|
||||
for (const [paneKey, status] of known) {
|
||||
|
||||
@@ -28,8 +28,8 @@ export function installHookStatusSessionTabsRepublish(
|
||||
getRuntime()?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null)
|
||||
}
|
||||
})
|
||||
// Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land here.
|
||||
// Without it the live state published above becomes a zombie question card.
|
||||
// Teardown: certified agent exit, pane close, and explicit connection clears land here.
|
||||
// Transport loss alone keeps the last remote observation as unverifiable evidence.
|
||||
const unsubscribeClear = statusStore.subscribePaneStatusClear((clear) => {
|
||||
const clearedPaneKeys =
|
||||
'paneKey' in clear ? [clear.paneKey] : changedSessionTabs.forgetConnection(clear.connectionId)
|
||||
|
||||
@@ -294,6 +294,49 @@ describe('AgentHookServer ingestTerminalStatus', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a runtime-owned legacy pane without opening legacy relay ingress', () => {
|
||||
const server = new AgentHookServer()
|
||||
const event = {
|
||||
paneKey: 'legacy-tab:7',
|
||||
tabId: 'legacy-tab',
|
||||
ptyId: 'legacy-pty',
|
||||
terminalHandle: 'term_legacy',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working' as const, prompt: 'legacy task', agentType: 'codex' as const }
|
||||
}
|
||||
|
||||
server.ingestTerminalStatus(event)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: 'legacy-tab:7',
|
||||
tabId: 'legacy-tab',
|
||||
terminalHandle: 'term_legacy',
|
||||
prompt: 'legacy task'
|
||||
})
|
||||
])
|
||||
server.stop()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PTY id', { ptyId: undefined }],
|
||||
['terminal handle', { terminalHandle: undefined }],
|
||||
['matching tab', { tabId: 'other-tab' }]
|
||||
])('rejects a legacy terminal row without its runtime-owned %s', (_label, overrides) => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: 'legacy-tab:7',
|
||||
tabId: 'legacy-tab',
|
||||
ptyId: 'legacy-pty',
|
||||
terminalHandle: 'term_legacy',
|
||||
payload: { state: 'working', prompt: 'legacy task', agentType: 'codex' },
|
||||
...overrides
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
server.stop()
|
||||
})
|
||||
|
||||
it('suppresses exact duplicate runtime terminal status observations', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
|
||||
@@ -364,6 +364,39 @@ describe('AgentHookServer listener replay', () => {
|
||||
expect(listener).toHaveBeenCalledWith({ paneKey: PANE })
|
||||
})
|
||||
|
||||
it('fans out one pane clear per status evicted by tab teardown', () => {
|
||||
const server = new AgentHookServer()
|
||||
const siblingPane = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222')
|
||||
const otherTabPane = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333')
|
||||
for (const paneKey of [PANE, siblingPane, otherTabPane]) {
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey,
|
||||
payload: { state: 'working', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
}
|
||||
const clearListener = vi.fn()
|
||||
const statusListener = vi.fn()
|
||||
server.subscribePaneStatusClear(clearListener)
|
||||
server.subscribeStatusChanges(statusListener)
|
||||
const evidenceObservedAtByPaneKey = (
|
||||
server as unknown as { evidenceObservedAtByPaneKey: Map<string, number> }
|
||||
).evidenceObservedAtByPaneKey
|
||||
expect(evidenceObservedAtByPaneKey.size).toBe(3)
|
||||
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
|
||||
expect(clearListener.mock.calls.map(([clear]) => clear)).toEqual([
|
||||
{ paneKey: PANE },
|
||||
{ paneKey: siblingPane }
|
||||
])
|
||||
expect(statusListener).toHaveBeenCalledOnce()
|
||||
expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ paneKey: otherTabPane })])
|
||||
expect([...evidenceObservedAtByPaneKey.keys()]).toEqual([otherTabPane])
|
||||
})
|
||||
|
||||
it('batches connection cleanup and retains sibling and local statuses', () => {
|
||||
const server = new AgentHookServer()
|
||||
const paneKeyAt = (prefix: string, index: number): string =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { track } from '../../telemetry/client'
|
||||
import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence'
|
||||
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
|
||||
import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
@@ -8,6 +8,7 @@ import { AgentHookServerIngestNormalization } from './server-ingest-normalizatio
|
||||
|
||||
export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization {
|
||||
ingestTerminalStatus(event: {
|
||||
ptyId?: string
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
@@ -18,23 +19,29 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges
|
||||
const physicalPaneKey = event.paneKey.trim()
|
||||
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
const legacyPaneKey = parseLegacyNumericPaneKey(paneKey)
|
||||
if (paneKey.length === 0) {
|
||||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
return
|
||||
}
|
||||
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
|
||||
return
|
||||
}
|
||||
const reportedTabId =
|
||||
event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined
|
||||
if (
|
||||
paneKey === physicalPaneKey &&
|
||||
reportedTabId !== undefined &&
|
||||
reportedTabId !== parsedPaneKey.tabId
|
||||
) {
|
||||
const runtimeOwnedLegacyPane = Boolean(
|
||||
legacyPaneKey &&
|
||||
event.ptyId?.trim() &&
|
||||
event.terminalHandle?.trim() &&
|
||||
reportedTabId === legacyPaneKey.tabId
|
||||
)
|
||||
// Legacy rows are accepted only from the in-process PTY ingress with both runtime identities;
|
||||
// HTTP and relay paths still require a stable pane key or a registered alias.
|
||||
if (paneKey.length > MAX_PANE_KEY_LEN || (!parsedPaneKey && !runtimeOwnedLegacyPane)) {
|
||||
return
|
||||
}
|
||||
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
|
||||
const paneTabId = parsedPaneKey?.tabId ?? legacyPaneKey?.tabId
|
||||
if (paneKey === physicalPaneKey && reportedTabId !== undefined && reportedTabId !== paneTabId) {
|
||||
return
|
||||
}
|
||||
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId
|
||||
if (this.getAgentStatusDisposition(paneKey) !== 'accept') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt
|
||||
const paneRetired =
|
||||
this.closedAgentStatusPaneKeys.has(paneKey) ||
|
||||
this.closedAgentStatusPaneKeys.has(ownerPaneKey)
|
||||
const tabId = parsePaneKey(ownerPaneKey)?.tabId
|
||||
const tabId =
|
||||
parsePaneKey(ownerPaneKey)?.tabId ?? parseLegacyNumericPaneKey(ownerPaneKey)?.tabId
|
||||
if (tabId && this.closedAgentStatusTabIds.has(tabId)) {
|
||||
return 'suppress'
|
||||
}
|
||||
|
||||
@@ -179,10 +179,13 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
if (!identity.inheritedFromActivePane) {
|
||||
this.maybeTrackAgentPromptSent(effectivePayload, previous)
|
||||
}
|
||||
// Why carried forward: the handle is pane identity, not turn state. Only main's OSC parse
|
||||
// resolves one, so a hook post for the same pane would otherwise erase the row's only join
|
||||
// back to its terminal — and the worktree listing rescues a cleared pane binding with it.
|
||||
const terminalHandle = boundaryAwarePayload.terminalHandle ?? previous?.terminalHandle
|
||||
// Why carried forward only within one host: main's OSC parse resolves the handle, so a later
|
||||
// hook must not erase its terminal join; a connection change must not inherit another host's.
|
||||
const terminalHandle =
|
||||
boundaryAwarePayload.terminalHandle ??
|
||||
(boundaryAwarePayload.connectionId === previous?.connectionId
|
||||
? previous?.terminalHandle
|
||||
: undefined)
|
||||
const enriched = {
|
||||
...this.attachStatusTiming(boundaryAwarePayload, now, observedAt),
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
|
||||
@@ -7,9 +7,11 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
dropStatusEntriesByTabPrefix(tabId: string): void {
|
||||
this.markTabClosedForAgentStatus(tabId)
|
||||
const paneKeysToClear = new Set<string>()
|
||||
const statusPaneKeysToClear = new Set<string>()
|
||||
for (const key of this.state.lastStatusByPaneKey.keys()) {
|
||||
if (paneCacheKeyMatchesTab(key, tabId)) {
|
||||
paneKeysToClear.add(key)
|
||||
statusPaneKeysToClear.add(key)
|
||||
}
|
||||
}
|
||||
for (const key of this.state.lastPromptByPaneKey.keys()) {
|
||||
@@ -72,6 +74,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
|
||||
this.evidenceObservedAtByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
@@ -80,6 +83,10 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
// Why: tab teardown must retire status subscribers' pane-scoped memo state too.
|
||||
for (const paneKey of statusPaneKeysToClear) {
|
||||
this.emitPaneStatusCleared({ paneKey })
|
||||
}
|
||||
}
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
|
||||
@@ -38,6 +38,27 @@ describe('the terminal handle a status row is stamped with', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cross a connection ownership change on a colliding pane key', () => {
|
||||
const server = new AgentHookServer()
|
||||
ingest(server, { connectionId: 'ssh-a' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-handle',
|
||||
worktreeId: 'other-worktree',
|
||||
payload: { state: 'done', prompt: 'other host', agentType: 'codex' }
|
||||
},
|
||||
'ssh-b'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
connectionId: 'ssh-b',
|
||||
worktreeId: 'other-worktree'
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle')
|
||||
})
|
||||
|
||||
it('is never persisted, because it belongs to the runtime that issued it', () => {
|
||||
const server = new AgentHookServer()
|
||||
ingest(server)
|
||||
|
||||
@@ -293,6 +293,17 @@ describe('agentStatus:drop IPC', () => {
|
||||
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
|
||||
})
|
||||
|
||||
it('forwards a runtime-owned legacy numeric row dismissal', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:drop')!
|
||||
handler!({}, 'tab-1:0')
|
||||
|
||||
expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0')
|
||||
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith('tab-1:0')
|
||||
})
|
||||
|
||||
it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
@@ -305,7 +316,6 @@ describe('agentStatus:drop IPC', () => {
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
'tab-1:0', // legacy numeric pane-key suffix
|
||||
'no-colon', // missing colon — rejected by isValidPaneKey
|
||||
':leading', // empty tabId half
|
||||
'trailing:', // empty leafId half
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types'
|
||||
import { parseLegacyNumericPaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
clearMigrationUnsupportedPtysByTabPrefix,
|
||||
clearMigrationUnsupportedPtysForPaneKey
|
||||
@@ -27,7 +28,10 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
|
||||
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
|
||||
|
||||
ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => {
|
||||
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
|
||||
if (
|
||||
typeof paneKey !== 'string' ||
|
||||
(!isValidPaneKey(paneKey) && parseLegacyNumericPaneKey(paneKey) === null)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -16,18 +16,15 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ
|
||||
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
|
||||
import type { ServeReadiness } from '../server/serve-readiness'
|
||||
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
|
||||
import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider'
|
||||
import { resolveOrcadBrowserProvider } from './orcad-browser-provider'
|
||||
import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths'
|
||||
import {
|
||||
describeOrcadBindExposure,
|
||||
OrcadBindAddressError,
|
||||
resolveOrcadBindHost
|
||||
} from './orcad-bind-address'
|
||||
import {
|
||||
acquireOrcadInstanceLock,
|
||||
OrcadInstanceLockError,
|
||||
type OrcadInstanceLock
|
||||
} from './orcad-instance-lock'
|
||||
import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock'
|
||||
import { startOrcadWithLifecycle } from './orcad-lifecycle'
|
||||
|
||||
let runOrcadQuitHandlers = (): void => {}
|
||||
|
||||
@@ -116,22 +113,24 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
|
||||
headless: browserProvider !== null,
|
||||
...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {})
|
||||
})
|
||||
try {
|
||||
return await startOrcadRuntime(options, browserProvider, instanceLock)
|
||||
} catch (error) {
|
||||
await browserProvider?.stop()
|
||||
setRuntimeBrowserCommandsFactory(null)
|
||||
runOrcadQuitHandlers()
|
||||
instanceLock.release()
|
||||
throw error
|
||||
}
|
||||
return startOrcadWithLifecycle(
|
||||
(registerCleanup) => startOrcadRuntime(options, registerCleanup),
|
||||
async () => {
|
||||
try {
|
||||
await browserProvider?.stop()
|
||||
} finally {
|
||||
setRuntimeBrowserCommandsFactory(null)
|
||||
runOrcadQuitHandlers()
|
||||
instanceLock.release()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function startOrcadRuntime(
|
||||
options: OrcadOptions,
|
||||
browserProvider: OrcadBrowserProvider | null,
|
||||
instanceLock: OrcadInstanceLock
|
||||
): Promise<OrcadHandle> {
|
||||
registerCleanup: (cleanup: () => Promise<void>) => void
|
||||
): Promise<Pick<OrcadHandle, 'readiness'>> {
|
||||
const { OrcaRuntimeService } = await import('../runtime/orca-runtime')
|
||||
const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc')
|
||||
const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } =
|
||||
@@ -152,6 +151,25 @@ async function startOrcadRuntime(
|
||||
const { installHookStatusSessionTabsRepublish } =
|
||||
await import('../agent-hooks/hook-status-session-tabs-republish')
|
||||
|
||||
let rpc: InstanceType<typeof OrcaRuntimeRpcServer> | null = null
|
||||
let uninstallHookStatusRepublish = (): void => {}
|
||||
let daemonStarted = false
|
||||
registerCleanup(async () => {
|
||||
try {
|
||||
await rpc?.stop()
|
||||
} finally {
|
||||
try {
|
||||
// Why disconnect and not shut down: the daemon must outlive this process, or an
|
||||
// orcad restart goes back to killing every running terminal.
|
||||
if (daemonStarted) {
|
||||
await stopOrcadDaemon()
|
||||
}
|
||||
} finally {
|
||||
uninstallHookStatusRepublish()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const runtimeUserDataPath = getAppEnvironment().getPath('userData')
|
||||
initOrcaProfilePaths()
|
||||
const profile = ensureActiveOrcaProfile(runtimeUserDataPath)
|
||||
@@ -169,6 +187,7 @@ async function startOrcadRuntime(
|
||||
// adapter as THE local provider, and the registry's contract is that it lands before
|
||||
// registerPtyHandlers so the IPC layer routes through the daemon from the first call.
|
||||
await startOrcadDaemon()
|
||||
daemonStarted = true
|
||||
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
// Why lazy: a daemon swap replaces the provider after construction, so an eager
|
||||
@@ -194,15 +213,20 @@ async function startOrcadRuntime(
|
||||
// so without these a headless host publishes its structured chats nowhere and lists no agents.
|
||||
getAgentStatusSnapshot: () =>
|
||||
agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
|
||||
getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(),
|
||||
getAgentProviderSessionRowsForPane: (paneKey) =>
|
||||
agentHookServer.getStatusSnapshotForPane(paneKey),
|
||||
structuredAgentStatusSink: {
|
||||
publish: (summary) => agentHookServer.ingestStructuredStatus(summary),
|
||||
forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId)
|
||||
}
|
||||
},
|
||||
reconcileAgentStatusForEndedProcess: (paneKeys) =>
|
||||
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys)
|
||||
})
|
||||
|
||||
// Why here too and not only on the desktop: nothing else republishes `session.tabs` when a
|
||||
// pane's status row changes, and orcad's whole job is serving paired clients.
|
||||
const uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish(
|
||||
uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish(
|
||||
agentHookServer,
|
||||
() => runtime
|
||||
)
|
||||
@@ -225,7 +249,7 @@ async function startOrcadRuntime(
|
||||
await runtime.reconcileLegacyWorkerTerminals()
|
||||
|
||||
const bindHost = resolveOrcadBindHost(options.bind)
|
||||
const rpc = new OrcaRuntimeRpcServer({
|
||||
rpc = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath: runtimeUserDataPath,
|
||||
enableWebSocket: true,
|
||||
@@ -283,24 +307,7 @@ async function startOrcadRuntime(
|
||||
mode: options.json ? 'json' : 'human'
|
||||
})
|
||||
|
||||
return {
|
||||
readiness,
|
||||
stop: async () => {
|
||||
try {
|
||||
await rpc.stop()
|
||||
} finally {
|
||||
// Why disconnect and not shut down: the daemon must outlive this process, or an
|
||||
// orcad restart goes back to killing every running terminal. See
|
||||
// orcad-daemon-supervision.ts.
|
||||
await stopOrcadDaemon()
|
||||
await browserProvider?.stop()
|
||||
setRuntimeBrowserCommandsFactory(null)
|
||||
uninstallHookStatusRepublish()
|
||||
runOrcadQuitHandlers()
|
||||
instanceLock.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
return { readiness }
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): OrcadOptions {
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* The two things a supervisor reads off a launch: what the arguments mean, and what an exit
|
||||
* code means. Both are part of the ops contract in docs/reference/orcad-operations.md.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORCAD_EXIT_CONFIGURATION,
|
||||
ORCAD_EXIT_FAILED,
|
||||
parseArgs,
|
||||
resolveOrcadExitCode
|
||||
} from './orcad-entry'
|
||||
import { startOrcadWithLifecycle } from './orcad-lifecycle'
|
||||
import { OrcadBindAddressError } from './orcad-bind-address'
|
||||
import { OrcadInstanceLockError } from './orcad-instance-lock'
|
||||
|
||||
@@ -41,3 +42,36 @@ describe('resolveOrcadExitCode', () => {
|
||||
expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orcad lifecycle cleanup', () => {
|
||||
it('uninstalls registered runtime resources when startup fails', async () => {
|
||||
const cleanupRuntime = vi.fn(async () => {})
|
||||
const cleanupHost = vi.fn(async () => {})
|
||||
|
||||
await expect(
|
||||
startOrcadWithLifecycle(async (registerCleanup) => {
|
||||
registerCleanup(cleanupRuntime)
|
||||
await Promise.resolve()
|
||||
throw new Error('startup failed')
|
||||
}, cleanupHost)
|
||||
).rejects.toThrow('startup failed')
|
||||
|
||||
expect(cleanupRuntime).toHaveBeenCalledOnce()
|
||||
expect(cleanupHost).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces concurrent and repeated normal stops', async () => {
|
||||
const cleanupRuntime = vi.fn(async () => {})
|
||||
const cleanupHost = vi.fn(async () => {})
|
||||
const handle = await startOrcadWithLifecycle(async (registerCleanup) => {
|
||||
registerCleanup(cleanupRuntime)
|
||||
return { readiness: 'ready' }
|
||||
}, cleanupHost)
|
||||
|
||||
await Promise.all([handle.stop(), handle.stop()])
|
||||
await handle.stop()
|
||||
|
||||
expect(cleanupRuntime).toHaveBeenCalledOnce()
|
||||
expect(cleanupHost).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
function createIdempotentOrcadCleanup(cleanup: () => Promise<void>): () => Promise<void> {
|
||||
let completion: Promise<void> | null = null
|
||||
return () => {
|
||||
completion ??= Promise.resolve().then(cleanup)
|
||||
return completion
|
||||
}
|
||||
}
|
||||
|
||||
export async function startOrcadWithLifecycle<T extends object>(
|
||||
start: (registerRuntimeCleanup: (cleanup: () => Promise<void>) => void) => Promise<T>,
|
||||
cleanupHost: () => Promise<void>
|
||||
): Promise<T & { stop(): Promise<void> }> {
|
||||
let cleanupRuntime = async (): Promise<void> => {}
|
||||
const cleanup = createIdempotentOrcadCleanup(async () => {
|
||||
try {
|
||||
await cleanupRuntime()
|
||||
} finally {
|
||||
await cleanupHost()
|
||||
}
|
||||
})
|
||||
try {
|
||||
const handle = await start((nextCleanup) => {
|
||||
cleanupRuntime = nextCleanup
|
||||
})
|
||||
return { ...handle, stop: cleanup }
|
||||
} catch (error) {
|
||||
await cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ export function makeAgentStatusStoreWiring(): {
|
||||
deps: {
|
||||
onTerminalAgentStatus: (event: Parameters<AgentHookServer['ingestTerminalStatus']>[0]) => void
|
||||
getAgentStatusSnapshot: () => ReturnType<AgentHookServer['getStatusSnapshot']>
|
||||
getAgentProviderSessionSnapshot: () => ReturnType<AgentHookServer['getStatusSnapshot']>
|
||||
getAgentProviderSessionRowsForPane: (
|
||||
paneKey: string
|
||||
) => ReturnType<AgentHookServer['getStatusSnapshotForPane']>
|
||||
reconcileAgentStatusForEndedProcess: (
|
||||
paneKeys: Parameters<AgentHookServer['reconcileEndedProcessForPaneKeys']>[0]
|
||||
) => void
|
||||
}
|
||||
/** Call once the runtime exists; returns the republish teardown. */
|
||||
attach: (runtime: WiredRuntime) => () => void
|
||||
@@ -28,7 +35,13 @@ export function makeAgentStatusStoreWiring(): {
|
||||
deps: {
|
||||
onTerminalAgentStatus: (event) => statusStore.ingestTerminalStatus(event),
|
||||
getAgentStatusSnapshot: () =>
|
||||
statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true)
|
||||
statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
|
||||
getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(),
|
||||
getAgentProviderSessionRowsForPane: (paneKey) =>
|
||||
statusStore.getStatusSnapshotForPane(paneKey),
|
||||
reconcileAgentStatusForEndedProcess: (paneKeys) => {
|
||||
statusStore.reconcileEndedProcessForPaneKeys(paneKeys)
|
||||
}
|
||||
},
|
||||
attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,18 @@ describe('mobile/paired projection for a pane pending a human answer', () => {
|
||||
expect(out?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('does not let replay delivery time make old working evidence outrank a newer title', () => {
|
||||
const hookAt = Date.now() - 1_000
|
||||
const replayedAt = Date.now()
|
||||
const out = renewFromPtyTitle()(
|
||||
{ ...claudeStatus('working', replayedAt), evidenceObservedAt: hookAt },
|
||||
parkedOnPromptPty(hookAt),
|
||||
{ preserveQuestionUnderShellTitle: true }
|
||||
)
|
||||
|
||||
expect(out?.state).toBe('done')
|
||||
})
|
||||
|
||||
// Why: an idle title is the ABSENCE of activity evidence, so it cannot outrank the hook.
|
||||
// A `working` title is positive evidence the agent resumed, which does — otherwise a
|
||||
// finished turn's question card would linger into the next working interval (#11761).
|
||||
|
||||
@@ -173,6 +173,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper
|
||||
leaf.waitBlockedAt = null
|
||||
leaf.tailWaitState = undefined
|
||||
}
|
||||
this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId))
|
||||
this.primeWaitBlockedBaselineFromSeededTail(ptyId)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } from './orca-runtime-verify-orchestration-compatibility-caller'
|
||||
import type { OrchestrationCompatibilityTerminalAuthority } from './runtime-terminal-contracts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
isTerminalLeafId,
|
||||
makePaneKey,
|
||||
parseLegacyNumericPaneKey,
|
||||
parsePaneKey
|
||||
} from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer'
|
||||
import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates'
|
||||
@@ -38,6 +43,30 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim
|
||||
return paneKeys
|
||||
}
|
||||
|
||||
/** Status cleanup also owns runtime-admitted legacy OSC rows; orchestration authority does not. */
|
||||
protected collectAgentStatusPaneKeysForPty(ptyId: string): Set<string> {
|
||||
const paneKeys = this.collectPaneKeysForPty(ptyId)
|
||||
const terminalHandles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId))
|
||||
// The provider-session snapshot is the unfiltered store view, so certified exit can also
|
||||
// retire a dismissed row's identity-only remnant after its pane binding moved.
|
||||
for (const row of this.getAgentProviderSessionSnapshotFn?.() ?? []) {
|
||||
if (row.terminalHandle && terminalHandles.has(row.terminalHandle)) {
|
||||
paneKeys.add(row.paneKey)
|
||||
}
|
||||
}
|
||||
const ptyPaneKey = this.ptysById.get(ptyId)?.paneKey
|
||||
if (ptyPaneKey && parseLegacyNumericPaneKey(ptyPaneKey)) {
|
||||
paneKeys.add(ptyPaneKey)
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
const paneKey = this.makeRuntimePaneKey(leaf)
|
||||
if (parseLegacyNumericPaneKey(paneKey)) {
|
||||
paneKeys.add(paneKey)
|
||||
}
|
||||
}
|
||||
return paneKeys
|
||||
}
|
||||
|
||||
getOrchestrationDispatchAuthority(
|
||||
terminalHandle: string
|
||||
): OrchestrationCompatibilityTerminalAuthority | null {
|
||||
|
||||
@@ -96,8 +96,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
|
||||
missingIds: missingRuntimeWorktreeIds,
|
||||
ptysById: this.ptysById,
|
||||
tabs: this.tabs,
|
||||
getTerminalHandleForPty: (ptyId) =>
|
||||
this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId),
|
||||
getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId),
|
||||
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
|
||||
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
|
||||
})
|
||||
|
||||
@@ -67,9 +67,17 @@ async function createRuntimeWithHookRows(
|
||||
? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()]
|
||||
: (): AgentStatusIpcPayload[] => rows
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
...(statusWiring ? { onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus } : {}),
|
||||
...(statusWiring
|
||||
? {
|
||||
onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus,
|
||||
reconcileAgentStatusForEndedProcess:
|
||||
statusWiring.deps.reconcileAgentStatusForEndedProcess,
|
||||
getAgentProviderSessionSnapshot: statusWiring.deps.getAgentProviderSessionSnapshot,
|
||||
getAgentProviderSessionRowsForPane: statusWiring.deps.getAgentProviderSessionRowsForPane
|
||||
}
|
||||
: {}),
|
||||
getAgentStatusSnapshot: readRows,
|
||||
getAgentProviderSessionRowsForPane: readRows
|
||||
...(statusWiring ? {} : { getAgentProviderSessionRowsForPane: readRows })
|
||||
})
|
||||
const internals = runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
|
||||
@@ -327,6 +335,100 @@ describe('headless hook agent-status projection (#11761)', () => {
|
||||
expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt')
|
||||
})
|
||||
|
||||
it('evicts the predecessor row at a certified provider generation reset', async () => {
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = await createRuntimeWithHookRows([], statusWiring)
|
||||
runtime.onPtyData(
|
||||
PTY_ID,
|
||||
'\x1b]9999;{"state":"working","prompt":"predecessor","agentType":"claude"}\x07',
|
||||
1
|
||||
)
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1)
|
||||
|
||||
const internals = runtime as unknown as {
|
||||
resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void
|
||||
}
|
||||
internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID)
|
||||
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
|
||||
statusWiring.statusStore.stop()
|
||||
})
|
||||
|
||||
it('evicts a row joined only through the terminal handle on certified PTY exit', async () => {
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = await createRuntimeWithHookRows([], statusWiring)
|
||||
const terminal = (await runtime.listTerminals()).terminals[0]
|
||||
if (!terminal) {
|
||||
throw new Error('expected a live terminal')
|
||||
}
|
||||
const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID)
|
||||
statusWiring.statusStore.ingestTerminalStatus({
|
||||
paneKey: priorPaneKey,
|
||||
tabId: 'prior-tab',
|
||||
terminalHandle: terminal.handle,
|
||||
payload: { state: 'working', prompt: 'prior pane', agentType: 'claude' }
|
||||
})
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1)
|
||||
|
||||
runtime.onPtyExit(PTY_ID, 0)
|
||||
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
|
||||
statusWiring.statusStore.stop()
|
||||
})
|
||||
|
||||
it('evicts the central status row when a disconnected PTY record is pruned', async () => {
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = await createRuntimeWithHookRows([], statusWiring)
|
||||
runtime.onPtyData(
|
||||
PTY_ID,
|
||||
'\x1b]9999;{"state":"working","prompt":"before prune","agentType":"claude"}\x07',
|
||||
1
|
||||
)
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1)
|
||||
|
||||
const internals = runtime as unknown as {
|
||||
dropDisconnectedPtyRecord: (ptyId: string) => void
|
||||
}
|
||||
internals.dropDisconnectedPtyRecord(PTY_ID)
|
||||
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
|
||||
statusWiring.statusStore.stop()
|
||||
})
|
||||
|
||||
it('evicts a dismissed handle-joined remnant on certified PTY exit', async () => {
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = await createRuntimeWithHookRows([], statusWiring)
|
||||
const terminal = (await runtime.listTerminals()).terminals[0]
|
||||
if (!terminal) {
|
||||
throw new Error('expected a live terminal')
|
||||
}
|
||||
const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID)
|
||||
statusWiring.statusStore.ingestTerminalStatus({
|
||||
paneKey: priorPaneKey,
|
||||
tabId: 'prior-tab',
|
||||
terminalHandle: terminal.handle,
|
||||
payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' }
|
||||
})
|
||||
statusWiring.statusStore.ingestRemote(
|
||||
{
|
||||
paneKey: priorPaneKey,
|
||||
tabId: 'prior-tab',
|
||||
providerSession: PROVIDER_SESSION,
|
||||
payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' }
|
||||
},
|
||||
null
|
||||
)
|
||||
statusWiring.statusStore.dropStatusEntry(priorPaneKey)
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: priorPaneKey, providerSessionOnly: true })
|
||||
])
|
||||
|
||||
runtime.onPtyExit(PTY_ID, 0)
|
||||
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
|
||||
statusWiring.statusStore.stop()
|
||||
})
|
||||
|
||||
it('does not carry a hook question across an identity-only owner title', async () => {
|
||||
const runtime = await createRuntimeWithHookRows([hookRow()])
|
||||
const internals = runtime as unknown as {
|
||||
|
||||
@@ -47,7 +47,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte
|
||||
options.hostExitConfirmed !== true
|
||||
// Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority
|
||||
// receipt a receipt-only pane's key comes from.
|
||||
const exitPaneKeys = this.collectPaneKeysForPty(ptyId)
|
||||
const exitPaneKeys = this.collectAgentStatusPaneKeysForPty(ptyId)
|
||||
if (preservesAbnormalSshSurface) {
|
||||
const prior = this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict
|
||||
this.rememberPtyLivenessVerdict(ptyId, {
|
||||
|
||||
@@ -94,11 +94,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime
|
||||
getLiveBrowserTabs: (worktreeId) => this.getLiveBrowserTabsByPageId(worktreeId),
|
||||
getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey),
|
||||
getProviderSessionSnapshot: () => this.getAgentProviderSessionSnapshotFn?.() ?? [],
|
||||
getStatusSnapshot: () => this.getAgentStatusSnapshotFn?.() ?? [],
|
||||
getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId),
|
||||
findPty: (worktreeId, tab, options) =>
|
||||
this.findPtyForMobileTerminalTab(worktreeId, tab, options),
|
||||
getRetainedStatus: (paneKey, pty, tab) =>
|
||||
this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab),
|
||||
getRetainedStatus: (paneKey, pty, tab, getRows) =>
|
||||
this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows),
|
||||
getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId),
|
||||
issuePtyHandle: (pty) => this.issuePtyHandle(pty),
|
||||
recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state),
|
||||
@@ -129,13 +130,14 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime
|
||||
protected getFreshRetainedAgentStatusForMobileTab(
|
||||
paneKey: string,
|
||||
pty: RuntimePtyWorktreeRecord | null,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
_tab: RuntimeMobileSessionTerminalTab,
|
||||
getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[]
|
||||
): RuntimeAgentRowSnapshot | null {
|
||||
const handlePty = pty ?? (tab.ptyId ? (this.ptysById.get(tab.ptyId) ?? null) : null)
|
||||
const terminalHandle = pty ? this.issuePtyHandle(pty) : null
|
||||
return selectFreshAgentRowForMobileTab({
|
||||
paneKey,
|
||||
terminalHandle: handlePty ? this.issuePtyHandle(handlePty) : null,
|
||||
hookRows: this.getAgentStatusSnapshotFn?.() ?? []
|
||||
terminalHandle,
|
||||
hookRows: getRows(paneKey, terminalHandle)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt
|
||||
|
||||
protected dropDisconnectedPtyRecord(ptyId: string): void {
|
||||
// Why: pruning can remove a PTY without the normal exit callback.
|
||||
this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId))
|
||||
this.advancePtyLifecycleGeneration(ptyId)
|
||||
this.pairedRendererSessionOwnedPtyIds.delete(ptyId)
|
||||
this.ptysById.delete(ptyId)
|
||||
|
||||
@@ -123,14 +123,11 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit
|
||||
}
|
||||
|
||||
protected getTerminalHandlesForPtyId(ptyId: string): string[] {
|
||||
const handles = new Set(
|
||||
this.getLeavesForPty(ptyId)
|
||||
.filter((candidate) => candidate.connected)
|
||||
.map((leaf) => this.issueHandle(leaf))
|
||||
)
|
||||
const runtimeHandle = this.handleByPtyId.get(ptyId)
|
||||
if (runtimeHandle) {
|
||||
handles.add(runtimeHandle)
|
||||
const handles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId))
|
||||
for (const handle of this.getLeavesForPty(ptyId)
|
||||
.filter((candidate) => candidate.connected)
|
||||
.map((leaf) => this.issueHandle(leaf))) {
|
||||
handles.add(handle)
|
||||
}
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||||
if (!pty) {
|
||||
@@ -142,6 +139,23 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit
|
||||
return [...handles].sort()
|
||||
}
|
||||
|
||||
protected getExistingTerminalHandlesForPtyId(ptyId: string): string[] {
|
||||
const handles = new Set(
|
||||
this.getLeavesForPty(ptyId)
|
||||
.map((leaf) => this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)))
|
||||
.filter((handle): handle is string => handle !== undefined)
|
||||
)
|
||||
const runtimeHandle = this.handleByPtyId.get(ptyId)
|
||||
if (runtimeHandle) {
|
||||
handles.add(runtimeHandle)
|
||||
}
|
||||
const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle
|
||||
if (incarnationHandle) {
|
||||
handles.add(incarnationHandle)
|
||||
}
|
||||
return [...handles].sort()
|
||||
}
|
||||
|
||||
protected getRecordedTerminalSleepHandles(
|
||||
ptyIds: Iterable<string>,
|
||||
terminalHandlesByPtyId: Readonly<Record<string, readonly string[]>>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
|
||||
import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
@@ -284,7 +285,11 @@ describe('OrcaRuntimeService', () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
let rows: AgentStatusIpcPayload[] = []
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getAgentStatusSnapshot: () => rows,
|
||||
getAgentProviderSessionRowsForPane: () => []
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
@@ -293,7 +298,27 @@ describe('OrcaRuntimeService', () => {
|
||||
{ id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Unrelated PTY' }
|
||||
]
|
||||
})
|
||||
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
|
||||
tabId: 'other-tab',
|
||||
leafId: '99999999-9999-4999-8999-999999999999'
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
const unrelatedPty = runtime['ptysById'].get('persisted-pty')!
|
||||
const unrelatedHandle = runtime['issuePtyHandle'](unrelatedPty)
|
||||
rows = [
|
||||
{
|
||||
paneKey: 'other-tab:99999999-9999-4999-8999-999999999999',
|
||||
tabId: 'other-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: unrelatedHandle,
|
||||
connectionId: null,
|
||||
state: 'working',
|
||||
prompt: 'unrelated task',
|
||||
agentType: 'codex',
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now()
|
||||
}
|
||||
]
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
@@ -304,6 +329,45 @@ describe('OrcaRuntimeService', () => {
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
expect(listed.tabs[0]).not.toHaveProperty('agentStatus')
|
||||
})
|
||||
|
||||
it('reads and indexes the full agent-status snapshot once per mobile projection', async () => {
|
||||
const tabCount = 20
|
||||
const session = makeWorkspaceSessionWithHeadlessTerminal()
|
||||
const tabs = Array.from({ length: tabCount }, (_, index) => ({
|
||||
...session.tabsByWorktree[TEST_WORKTREE_ID]![0]!,
|
||||
id: `host-tab-${index}`,
|
||||
ptyId: `missing-pty-${index}`
|
||||
}))
|
||||
const terminalLayoutsByTabId = Object.fromEntries(
|
||||
tabs.map((tab, index) => [
|
||||
tab.id,
|
||||
makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: `missing-pty-${index}` })
|
||||
])
|
||||
)
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
|
||||
...session,
|
||||
tabsByWorktree: { [TEST_WORKTREE_ID]: tabs },
|
||||
terminalLayoutsByTabId
|
||||
})
|
||||
const getAgentStatusSnapshot = vi.fn(() => [])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getAgentStatusSnapshot,
|
||||
getAgentProviderSessionRowsForPane: () => []
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listed.tabs).toHaveLength(tabCount)
|
||||
expect(getAgentStatusSnapshot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('kills persisted SSH PTYs when closing hydrated headless tabs before pane metadata is restored', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fi
|
||||
const LEAF_ID = '77777777-7777-4777-8777-777777777777'
|
||||
const PANE_KEY = `tab-dismiss:${LEAF_ID}`
|
||||
|
||||
function wiredRuntime(): {
|
||||
function wiredRuntime(incarnationId?: string): {
|
||||
runtime: OrcaRuntimeService
|
||||
statusWiring: ReturnType<typeof makeAgentStatusStoreWiring>
|
||||
} {
|
||||
@@ -39,6 +39,13 @@ function wiredRuntime(): {
|
||||
}
|
||||
]
|
||||
})
|
||||
if (incarnationId) {
|
||||
runtime.registerPty('dismiss-pty', TEST_WORKTREE_ID, null, {
|
||||
tabId: 'tab-dismiss',
|
||||
leafId: LEAF_ID,
|
||||
incarnationId
|
||||
})
|
||||
}
|
||||
return { runtime, statusWiring }
|
||||
}
|
||||
|
||||
@@ -98,4 +105,101 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
|
||||
republish.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['leaf binding', undefined, false],
|
||||
['controller incarnation', 'incarnation-1', true]
|
||||
] as const)(
|
||||
'rejoins a row through its %s handle after pane ownership clears',
|
||||
async (_, incarnationId, clearLeafBinding) => {
|
||||
const { runtime, statusWiring } = wiredRuntime(incarnationId)
|
||||
emitWorkingStatus(runtime, 1)
|
||||
const row = statusWiring.statusStore.getStatusSnapshot()[0]!
|
||||
const internals = runtime as unknown as {
|
||||
handleByLeafKey: Map<string, string>
|
||||
handleByPtyIncarnation: Map<string, { handle: string }>
|
||||
ptysById: Map<string, { paneKey: string | null; tabId: string | null }>
|
||||
}
|
||||
const pty = internals.ptysById.get('dismiss-pty')!
|
||||
pty.paneKey = null
|
||||
pty.tabId = null
|
||||
if (clearLeafBinding) {
|
||||
expect(internals.handleByPtyIncarnation.get('dismiss-pty')?.handle).toBe(row.terminalHandle)
|
||||
internals.handleByLeafKey.clear()
|
||||
}
|
||||
|
||||
const listed = await runtime.getWorktreePs()
|
||||
|
||||
expect(
|
||||
listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents
|
||||
).toEqual([expect.objectContaining({ prompt: 'ship it' })])
|
||||
statusWiring.statusStore.stop()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps runtime-owned legacy OSC rows in worktree.ps and mobile projections', async () => {
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'legacy-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
activeLeafId: 'pane:7',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'legacy-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:7',
|
||||
paneRuntimeId: 7,
|
||||
ptyId: 'legacy-pty'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'legacy-epoch',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'legacy-tab::pane:7',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'legacy-tab::pane:7',
|
||||
parentTabId: 'legacy-tab',
|
||||
leafId: 'pane:7',
|
||||
ptyId: 'legacy-pty',
|
||||
title: 'Codex',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData(
|
||||
'legacy-pty',
|
||||
'\x1b]9999;{"state":"working","prompt":"legacy task","agentType":"codex"}\x07',
|
||||
1
|
||||
)
|
||||
|
||||
const listed = await runtime.getWorktreePs()
|
||||
const mobile = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(
|
||||
listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents
|
||||
).toEqual([expect.objectContaining({ paneKey: 'legacy-tab:7', prompt: 'legacy task' })])
|
||||
expect(mobile.tabs[0]).toMatchObject({
|
||||
type: 'terminal',
|
||||
agentStatus: { paneKey: 'legacy-tab:7', prompt: 'legacy task' }
|
||||
})
|
||||
runtime.onPtyExit('legacy-pty', 0)
|
||||
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
|
||||
statusWiring.statusStore.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,17 +47,31 @@ describe('selectFreshExplicitAgentStatus', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses restored and stale rows', () => {
|
||||
it('refuses restored, identity-only and stale evidence rows', () => {
|
||||
const args = { handle: HANDLE, paneKey: PANE_KEY }
|
||||
expect(
|
||||
selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ restoredUnconfirmed: true })] })
|
||||
).toBeNull()
|
||||
expect(
|
||||
selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ providerSessionOnly: true })] })
|
||||
).toBeNull()
|
||||
expect(
|
||||
selectFreshExplicitAgentStatus({
|
||||
...args,
|
||||
hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })]
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
selectFreshExplicitAgentStatus({
|
||||
...args,
|
||||
hookRows: [
|
||||
row({
|
||||
receivedAt: Date.now(),
|
||||
evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
|
||||
})
|
||||
]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers a permission row over a working row stamped at the same instant', () => {
|
||||
@@ -120,5 +134,16 @@ describe('selectFreshAgentRowForMobileTab', () => {
|
||||
hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })]
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
selectFreshAgentRowForMobileTab({
|
||||
...args,
|
||||
hookRows: [
|
||||
row({
|
||||
receivedAt: Date.now(),
|
||||
evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
|
||||
})
|
||||
]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export type RuntimeAgentRowSnapshot = {
|
||||
payload: ParsedAgentStatusPayload
|
||||
stateStartedAt: number
|
||||
updatedAt: number
|
||||
evidenceObservedAt?: number
|
||||
}
|
||||
|
||||
function isLiveObservation(row: AgentStatusIpcPayload): boolean {
|
||||
@@ -42,13 +43,15 @@ export function selectFreshExplicitAgentStatus(args: {
|
||||
const consider = (
|
||||
state: AgentStatusEntry['state'] | undefined,
|
||||
updatedAt: number | null | undefined,
|
||||
evidenceObservedAt: number | null | undefined,
|
||||
restoredUnconfirmed = false,
|
||||
providerSessionOnly = false,
|
||||
stateStartedAt?: number | null
|
||||
): void => {
|
||||
if (!state || restoredUnconfirmed || typeof updatedAt !== 'number') {
|
||||
if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') {
|
||||
return
|
||||
}
|
||||
if (now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
return
|
||||
}
|
||||
const status = mapExplicitAgentStateToRuntimeTerminalStatus(state)
|
||||
@@ -62,7 +65,14 @@ export function selectFreshExplicitAgentStatus(args: {
|
||||
if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) {
|
||||
continue
|
||||
}
|
||||
consider(row.state, row.receivedAt, row.restoredUnconfirmed, row.stateStartedAt)
|
||||
consider(
|
||||
row.state,
|
||||
row.receivedAt,
|
||||
row.evidenceObservedAt,
|
||||
row.restoredUnconfirmed,
|
||||
row.providerSessionOnly,
|
||||
row.stateStartedAt
|
||||
)
|
||||
}
|
||||
return bestStatus
|
||||
? {
|
||||
@@ -81,8 +91,12 @@ export function selectFreshAgentRowForMobileTab(args: {
|
||||
hookRows: readonly AgentStatusIpcPayload[]
|
||||
}): RuntimeAgentRowSnapshot | null {
|
||||
let match: AgentStatusIpcPayload | null = null
|
||||
const now = Date.now()
|
||||
for (const row of args.hookRows) {
|
||||
if (!isLiveObservation(row)) {
|
||||
if (
|
||||
!isLiveObservation(row) ||
|
||||
now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (row.paneKey === args.paneKey) {
|
||||
@@ -100,7 +114,7 @@ export function selectFreshAgentRowForMobileTab(args: {
|
||||
match = row
|
||||
}
|
||||
}
|
||||
if (!match || Date.now() - match.receivedAt > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
@@ -110,6 +124,9 @@ export function selectFreshAgentRowForMobileTab(args: {
|
||||
...(match.tabId ? { tabId: match.tabId } : {}),
|
||||
payload: pickParsedAgentStatusPayload(match),
|
||||
stateStartedAt: match.stateStartedAt ?? match.receivedAt,
|
||||
updatedAt: match.receivedAt
|
||||
updatedAt: match.receivedAt,
|
||||
...(match.evidenceObservedAt !== undefined
|
||||
? { evidenceObservedAt: match.evidenceObservedAt }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +33,13 @@ export function buildRuntimeMobileAgentStatus(
|
||||
host: RuntimeMobileAgentStatusHost
|
||||
): { agentStatus: AgentStatusEntry } | Record<string, never> {
|
||||
const paneKey = host.getPaneKey(tab)
|
||||
// Why: neither the OSC-retained row nor a title-derived status can carry a
|
||||
// provider session — only the hook payload does, and headless serve has no
|
||||
// Why: neither the live-status projection nor a title-derived status carries a
|
||||
// provider session — only the full hook payload does, and headless serve has no
|
||||
// renderer to publish `tab.agentStatus`. Without it mobile native chat has no
|
||||
// transcript to address and sits on the empty state forever.
|
||||
const hookRow = selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey))
|
||||
// Why: the hook row is evidence in its own right. Returning early on a missing
|
||||
// PTY status/retained row put this check ahead of the only headless carrier, so
|
||||
// PTY status/projected row put this check ahead of the only headless carrier, so
|
||||
// an agent that reported its session but never emitted a recognized title got no
|
||||
// `agentStatus` at all — exactly the hook-only case the fallback exists for.
|
||||
if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) {
|
||||
@@ -101,6 +101,9 @@ export function buildRuntimeMobileAgentStatus(
|
||||
...liveRow.payload,
|
||||
paneKey,
|
||||
updatedAt: liveRow.updatedAt,
|
||||
...(liveRow.evidenceObservedAt !== undefined
|
||||
? { evidenceObservedAt: liveRow.evidenceObservedAt }
|
||||
: {}),
|
||||
stateStartedAt: liveRow.stateStartedAt,
|
||||
stateHistory: [],
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
agentStatusAuthorityObservedAt,
|
||||
pickParsedAgentStatusPayload,
|
||||
type AgentStatusEntry,
|
||||
type AgentStatusIpcPayload
|
||||
@@ -22,7 +23,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
if (
|
||||
(status.state === 'waiting' || status.state === 'blocked') &&
|
||||
pty.lastAgentStatus === 'idle' &&
|
||||
Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS
|
||||
Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS
|
||||
) {
|
||||
return status
|
||||
}
|
||||
@@ -35,7 +36,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
}
|
||||
const richStatusCanOwnTitleInterval =
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs === null ||
|
||||
status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs
|
||||
agentStatusAuthorityObservedAt(status) > pty.lastAgentStatusRichInvalidatedAtEpochMs
|
||||
const titleEvidenceAt = pty.lastOscTitleEpochMs
|
||||
if (titleEvidenceAt === null) {
|
||||
return richStatusCanOwnTitleInterval ? status : null
|
||||
@@ -63,7 +64,10 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
(pty.lastAgentStatus === 'permission' &&
|
||||
(status.state === 'blocked' || status.state === 'waiting'))
|
||||
if (!titleConfirmsState) {
|
||||
if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) {
|
||||
if (
|
||||
richStatusCanOwnTitleInterval &&
|
||||
agentStatusAuthorityObservedAt(status) >= titleEvidenceAt
|
||||
) {
|
||||
return status
|
||||
}
|
||||
if (pty.lastAgentStatus === null && !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle)) {
|
||||
@@ -82,7 +86,8 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
)
|
||||
}
|
||||
const richStatusOwnsCurrentState =
|
||||
Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS && richStatusCanOwnTitleInterval
|
||||
Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS &&
|
||||
richStatusCanOwnTitleInterval
|
||||
// Fresh explicit evidence from this title interval owns acknowledgement identity.
|
||||
const stateStartedAt = richStatusOwnsCurrentState
|
||||
? status.stateStartedAt
|
||||
@@ -124,7 +129,7 @@ export function selectRuntimeHookAgentRowForPane(
|
||||
entry.agentType &&
|
||||
(entry.providerSessionOnly !== true ||
|
||||
(entry.agentType === 'pi' && entry.providerSession != null)) &&
|
||||
entry.receivedAt >= freshAfter &&
|
||||
(entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter &&
|
||||
(!agent || entry.receivedAt > agent.receivedAt)
|
||||
) {
|
||||
agent = entry
|
||||
@@ -133,7 +138,7 @@ export function selectRuntimeHookAgentRowForPane(
|
||||
entry.providerSessionOnly !== true &&
|
||||
// Restored rows cannot prove liveness because the turn may have ended while offline (#12346).
|
||||
entry.restoredUnconfirmed !== true &&
|
||||
entry.receivedAt >= freshAfter &&
|
||||
(entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter &&
|
||||
(!live || entry.receivedAt > live.receivedAt)
|
||||
) {
|
||||
live = entry
|
||||
@@ -149,6 +154,9 @@ export function selectRuntimeHookAgentRowForPane(
|
||||
? {
|
||||
payload: pickParsedAgentStatusPayload(live),
|
||||
updatedAt: live.receivedAt,
|
||||
...(live.evidenceObservedAt !== undefined
|
||||
? { evidenceObservedAt: live.evidenceObservedAt }
|
||||
: {}),
|
||||
stateStartedAt: live.stateStartedAt ?? live.receivedAt,
|
||||
...(live.worktreeId ? { worktreeId: live.worktreeId } : {})
|
||||
}
|
||||
@@ -167,6 +175,13 @@ export function resolveRuntimeHookLiveAgentRow(
|
||||
if (live.payload.interactivePrompt != null) {
|
||||
return live
|
||||
}
|
||||
// This is the pane's only wall-clock title timestamp comparable to hook `receivedAt`.
|
||||
return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null
|
||||
// This is the pane's only wall-clock title timestamp comparable to when the hook evidence
|
||||
// was observed; replay delivery order must not make old evidence outrank a newer title.
|
||||
return !nonAgentTitle &&
|
||||
agentStatusAuthorityObservedAt({
|
||||
updatedAt: live.updatedAt,
|
||||
evidenceObservedAt: live.evidenceObservedAt
|
||||
}) >= (pty?.lastOscTitleEpochMs ?? 0)
|
||||
? live
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RuntimeMobileSessionProjectionHost = {
|
||||
getLiveBrowserTabs(worktreeId: string): Map<string, BrowserTabInfo>
|
||||
getProviderSessionRows(paneKey: string): AgentStatusIpcPayload[] | undefined
|
||||
getProviderSessionSnapshot(): AgentStatusIpcPayload[]
|
||||
getStatusSnapshot(): AgentStatusIpcPayload[]
|
||||
getLeafKey(tabId: string, leafId: string): string
|
||||
findPty(
|
||||
worktreeId: string,
|
||||
@@ -27,7 +28,8 @@ export type RuntimeMobileSessionProjectionHost = {
|
||||
getRetainedStatus(
|
||||
paneKey: string,
|
||||
pty: RuntimePtyWorktreeRecord | null,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
tab: RuntimeMobileSessionTerminalTab,
|
||||
getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[]
|
||||
): RuntimeAgentRowSnapshot | null
|
||||
getTrackedTitle(ptyId: string | null): string | null
|
||||
issuePtyHandle(pty: RuntimePtyWorktreeRecord): string
|
||||
|
||||
@@ -48,6 +48,42 @@ export function projectRuntimeMobileSessionTabs(
|
||||
hookRowsForPane.set(paneKey, rows)
|
||||
return rows
|
||||
}
|
||||
let statusRowsByPaneKey: Map<string, AgentStatusIpcPayload[]> | null = null
|
||||
let statusRowsByTerminalHandle: Map<string, AgentStatusIpcPayload[]> | null = null
|
||||
const getStatusRows = (
|
||||
paneKey: string,
|
||||
terminalHandle: string | null
|
||||
): AgentStatusIpcPayload[] => {
|
||||
if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) {
|
||||
statusRowsByPaneKey = new Map()
|
||||
statusRowsByTerminalHandle = new Map()
|
||||
for (const row of host.getStatusSnapshot()) {
|
||||
const paneRows = statusRowsByPaneKey.get(row.paneKey)
|
||||
if (paneRows) {
|
||||
paneRows.push(row)
|
||||
} else {
|
||||
statusRowsByPaneKey.set(row.paneKey, [row])
|
||||
}
|
||||
if (row.terminalHandle) {
|
||||
const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle)
|
||||
if (handleRows) {
|
||||
handleRows.push(row)
|
||||
} else {
|
||||
statusRowsByTerminalHandle.set(row.terminalHandle, [row])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const paneRows = statusRowsByPaneKey.get(paneKey) ?? []
|
||||
if (!terminalHandle) {
|
||||
return paneRows
|
||||
}
|
||||
const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? []
|
||||
if (paneRows.length === 0) {
|
||||
return handleRows
|
||||
}
|
||||
return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))]
|
||||
}
|
||||
// Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client.
|
||||
const claimedLivePtyIds = new Set<string>()
|
||||
for (const tab of snapshot.tabs) {
|
||||
@@ -98,11 +134,11 @@ export function projectRuntimeMobileSessionTabs(
|
||||
? makePaneKey(tab.parentTabId, tab.leafId)
|
||||
: `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}`
|
||||
const mobileStatusPty = livePty ?? pty
|
||||
// Why: headless hooks live only in main's retained rows; reuse this lookup
|
||||
// Why: headless hooks live in main's status store; reuse this lookup
|
||||
// for both title ownership and status publication so the two cannot diverge.
|
||||
const retainedAgentStatus = tab.agentStatus
|
||||
? null
|
||||
: host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab)
|
||||
: host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab, getStatusRows)
|
||||
const hookAgentStatus = tab.agentStatus
|
||||
? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey))
|
||||
: null
|
||||
|
||||
@@ -104,7 +104,7 @@ export type RuntimeTerminalAgentStatusEvent = {
|
||||
|
||||
export type HookLiveAgentRow = Pick<
|
||||
RuntimeAgentRowSnapshot,
|
||||
'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId'
|
||||
'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId'
|
||||
>
|
||||
|
||||
export type RuntimePtyDataAdmission = Readonly<{
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('worktree agent source admission', () => {
|
||||
expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0)
|
||||
})
|
||||
|
||||
it('carries the row own working mode and drops restored rows', () => {
|
||||
it('carries the row own working mode and drops non-live rows', () => {
|
||||
const monitoring = collectRuntimeWorktreeAgentSources({
|
||||
...connected,
|
||||
hookSnapshots: [{ ...hookRow, workingMode: 'monitoring' as const }]
|
||||
@@ -77,5 +77,11 @@ describe('worktree agent source admission', () => {
|
||||
hookSnapshots: [{ ...hookRow, restoredUnconfirmed: true as const }]
|
||||
})
|
||||
expect(restored.size).toBe(0)
|
||||
|
||||
const providerSessionOnly = collectRuntimeWorktreeAgentSources({
|
||||
...connected,
|
||||
hookSnapshots: [{ ...hookRow, providerSessionOnly: true }]
|
||||
})
|
||||
expect(providerSessionOnly.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,7 +189,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
|
||||
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
|
||||
tabs: ReadonlyMap<string, RuntimeSyncedTab>
|
||||
/** Non-minting: a listing must not issue handles, only recognise the ones already bound. */
|
||||
getTerminalHandleForPty: (ptyId: string) => string | null
|
||||
getTerminalHandlesForPty: (ptyId: string) => readonly string[]
|
||||
getSummary: SummaryLookup
|
||||
}): {
|
||||
mirroredWorktreeIdByTabId: Map<string, string>
|
||||
@@ -262,8 +262,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
|
||||
if (pty.paneKey) {
|
||||
connectedPtyEvidence.paneKeys.add(pty.paneKey)
|
||||
}
|
||||
const terminalHandle = args.getTerminalHandleForPty(pty.ptyId)
|
||||
if (terminalHandle) {
|
||||
for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) {
|
||||
connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
|
||||
RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload }
|
||||
>()
|
||||
for (const entry of args.hookSnapshots) {
|
||||
if (entry.restoredUnconfirmed === true) {
|
||||
if (entry.restoredUnconfirmed === true || entry.providerSessionOnly === true) {
|
||||
continue
|
||||
}
|
||||
const hookPayload = pickParsedAgentStatusPayload(entry)
|
||||
@@ -49,10 +49,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interrupted: entry.interrupted ?? false,
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
// A structured row's clock is its journal, so a restart's republish does not read as new.
|
||||
updatedAt: entry.structuredHost
|
||||
? (entry.evidenceObservedAt ?? entry.receivedAt)
|
||||
: entry.receivedAt,
|
||||
// A replay advances delivery order, not the age of the evidence shown by worktree.ps.
|
||||
updatedAt: entry.evidenceObservedAt ?? entry.receivedAt,
|
||||
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
|
||||
expect(events).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clears stamped status on reconnect loss but not final shutdown', async () => {
|
||||
it('keeps stamped status unverifiable across reconnect loss and final shutdown', async () => {
|
||||
const initialRelay = createFakeRelay()
|
||||
relay = createFakeRelay()
|
||||
vi.mocked(deployAndLaunchRelay)
|
||||
@@ -436,16 +436,13 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
|
||||
await session.reconnect({} as SshConnection)
|
||||
initialRelay.dispose()
|
||||
|
||||
expect(agentHookServer.getStatusSnapshot()).toEqual([])
|
||||
expect(clearListener).toHaveBeenCalledOnce()
|
||||
expect(clearListener).toHaveBeenCalledWith({
|
||||
transient: true,
|
||||
connectionId: 'conn-clear',
|
||||
clearedAt: expect.any(Number)
|
||||
})
|
||||
expect(agentHookServer.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ connectionId: 'conn-clear', state: 'working' })
|
||||
])
|
||||
expect(clearListener).not.toHaveBeenCalled()
|
||||
session.dispose()
|
||||
session = null
|
||||
expect(clearListener).toHaveBeenCalledOnce()
|
||||
expect(clearListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks the fake relay for cached hook replay after the session wires its listener', async () => {
|
||||
|
||||
@@ -1679,10 +1679,9 @@ export class SshRelaySession {
|
||||
|
||||
if (reason === 'shutdown') {
|
||||
clearPtyOwnershipForConnection(this.targetId)
|
||||
} else {
|
||||
// Why: handlers detached above, so no late event can re-stamp status between this clear and reconnect replay.
|
||||
agentHookServer.clearStatusEntriesForConnection(this.targetId)
|
||||
}
|
||||
// Connection loss makes remote status unverifiable, not exited. Keep the last observation;
|
||||
// replay or certified process teardown will update or remove it on the execution host.
|
||||
|
||||
const ptyProvider = getSshPtyProvider(this.targetId)
|
||||
if (ptyProvider && 'dispose' in ptyProvider) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// ─── The one identity/clock contract the fleet path reads ────────────────────
|
||||
// A hook row carries a pane key, a delivery timestamp and, from newer hosts, an
|
||||
// observation timestamp. Terminal identity lives on the runtime, not on the row.
|
||||
// The fleet matcher needs both, and every fact it needs used to be an OPTIONAL
|
||||
// observation timestamp. A row may carry the runtime handle observed with OSC, but
|
||||
// fleet authority still resolves terminal identity from the runtime. The matcher needs both,
|
||||
// and every fact it needs used to be an OPTIONAL
|
||||
// field on `AgentStatusIpcPayload` — so an unenriched producer published a row the
|
||||
// matcher silently failed to identify (failure table L-1) and a missing observation
|
||||
// clock silently degraded to the delivery clock (W1-14 / RR-W-P1A).
|
||||
@@ -10,8 +11,8 @@
|
||||
// deliberately exposes no `terminalHandle?`, no `evidenceObservedAt?` and no raw
|
||||
// payload, so a consumer cannot read an absent identity or clock by accident.
|
||||
//
|
||||
// This type never crosses IPC or the wire. `AgentStatusIpcPayload` is unchanged and
|
||||
// remains what `agentStatus:set` / `agentStatus:getSnapshot` publish.
|
||||
// This type never crosses IPC or the wire. `AgentStatusIpcPayload` remains what
|
||||
// `agentStatus:set` / `agentStatus:getSnapshot` publish.
|
||||
|
||||
import type { AgentStatusIpcPayload } from './agent-status-ipc-payload'
|
||||
import type { AgentStatusState, AgentType } from './agent-status-types'
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resetWebSessionTabsSnapshotFreshnessForTests,
|
||||
type WebSessionTabsSyncState
|
||||
} from '../../src/renderer/src/runtime/web-session-tabs-sync'
|
||||
import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture'
|
||||
|
||||
vi.mock('../../src/renderer/src/store', () => ({
|
||||
useAppStore: {
|
||||
@@ -689,7 +690,9 @@ describe('real PTY decorative session-tabs fanout', () => {
|
||||
})
|
||||
|
||||
it('renews retained hook status without resetting its state start', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps)
|
||||
const uninstallStatusRepublish = statusWiring.attach(runtime)
|
||||
const ptyId = seedWorktree(runtime, 0)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0]
|
||||
@@ -769,5 +772,7 @@ describe('real PTY decorative session-tabs fanout', () => {
|
||||
true
|
||||
)
|
||||
unsubscribe()
|
||||
uninstallStatusRepublish()
|
||||
statusWiring.statusStore.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
|
||||
import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
@@ -27,7 +28,9 @@ type Harness = {
|
||||
}
|
||||
|
||||
function createHarness(): Harness {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const statusWiring = makeAgentStatusStoreWiring()
|
||||
const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps)
|
||||
const uninstallStatusRepublish = statusWiring.attach(runtime)
|
||||
runtime.registerPty(PTY_ID, WORKTREE_ID)
|
||||
const tab: TerminalTab = {
|
||||
type: 'terminal',
|
||||
@@ -58,7 +61,17 @@ function createHarness(): Harness {
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
|
||||
publications.push(structuredClone(snapshot))
|
||||
})
|
||||
return { internals, publications, runtime, tab, unsubscribe }
|
||||
return {
|
||||
internals,
|
||||
publications,
|
||||
runtime,
|
||||
tab,
|
||||
unsubscribe: () => {
|
||||
unsubscribe()
|
||||
uninstallStatusRepublish()
|
||||
statusWiring.statusStore.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setRichStatus(
|
||||
|
||||
Reference in New Issue
Block a user