feat(agent-status): publish committed launch membership

This commit is contained in:
Brennan Benson
2026-09-14 19:02:36 -07:00
parent 1d3783e091
commit 2d715e87cb
40 changed files with 1558 additions and 259 deletions
+54
View File
@@ -0,0 +1,54 @@
# C10 implementation result
## Completed cases and evidence
- **Committed/adopted launch membership:** Added the host-owned launch-membership facet and admission path. `PtySpawnResult.agentSessionEnsure.owner.statusBinding` is the only source of `runId` and `attachment.executionId`; admission runs after PTY registration and early-exit validation, using the owner's final canonical surface. The existing owner result is used directly; no second reservation or C5 callback was added.
- **Bare create-operation replay:** A result containing only `{ id, incarnationId }` is treated as deduplication evidence. It cannot seed a pane row or invoke membership publication. Regression coverage is in `terminal-creation-and-readiness-part-02.spec.ts`.
- **Silent startup:** A committed or adopted owner publishes one compatibility row before provider output. The legacy state remains `done` with `sessionBoundary: true`; `launchMembership` is the authoritative presence facet, so launch does not become Working, waiting, completion, unread, or power-save activity.
- **Adoption and repeated requests:** Created and adopted owner results retain the same binding and canonical surface. The runtime callback is emitted after registration for both dispositions; the focused OMP resume test verifies the shared binding is forwarded unchanged.
- **Host inventory recovery:** Persisted membership hydrates as `unconfirmed`. Complete owner inventories re-admit matching live owners, restore canonical tab/leaf/handle metadata, clear stale hydration state, and retire only rows in the covered host scope that are absent. Incomplete inventories never retire absence; local and SSH scopes are reconciled independently.
- **Lifecycle cleanup:** Failed/exited settlement removes membership without making persistence a user-action gate. Dismissal retains provider resume identity only and drops launch membership.
- **Reader projections:** The facet is carried through agent-status IPC, renderer state, mobile/session-tab projections, `worktree ps`, and worktree activity rollups. Committed membership keeps a row visible while contributing neither Working nor permission status. Connected-PTY gating is bypassed only for a committed launch facet.
The C5 dependency is the shared owner contract in `1d3783e091` (tree-equivalent to `22f80ab284`), with the accompanying handoff at `f167c637f0`. The implementation consumes `owner.statusBinding` and does not mint a competing run or attachment identity.
## Judgments
### Architecture fit
**Fit: verified for the C10 boundary.** Admission is attached to the existing claimed-owner transaction and consumes its committed/adopted result. Membership is an orthogonal host fact, not a turn reducer, readiness claim, provider journal, or second reservation registry. Complete inventory coverage is required before absence retires a row, and bookkeeping errors do not block terminal creation or dismissal.
### Functional correctness
**Correctness: verified for the covered cases.** Focused tests cover silent admission, bare replay rejection, provider transition carry-forward, adoption, failed settlement, dismissal, persistence/hydration, complete versus incomplete inventory, host-scoped retirement, and the worktree rollup. The aggregate runtime suite covers the new post-registration callback and existing create/adopt paths. Remote contact loss remains uncertainty rather than process death.
### Concrete precedent and deviations
The inspected implementations consistently create a pre-execution membership record from the owning lifecycle, keep it separate from turn activity, and retire it from authoritative host inventory. Orca differs materially where it must: PTY agents can be on an SSH execution host, so client contact or a partial inventory cannot prove exit; and mixed-version readers receive a legacy `done`/boundary projection plus an optional membership facet instead of a new wire state. Those differences are deliberate and remain subject to the host replication and compatibility work owned by C7.
### Validation
- `pnpm tc:node`
- `pnpm tc:web`
- Focused launch/projection tests: 17 passed across 3 files; additional status/runtime regression set: 87 passed across 8 files.
- Aggregate `src/main/runtime/orca-runtime.test.ts`: **1,268 passed, 1 skipped**.
- `node config/scripts/check-changed-code-quality.mjs`: 0 new findings in all gates.
- `git diff --check`: passed.
## Remaining gaps
- Provider hook delivery and readiness/receipt behavior for Cursor/OpenCode on Windows/WSL are not fixed by membership; those remain C4/C3 work.
- Emitter attribution, subject-keyed status storage, manual/nested discovery, and full provider-alias reconciliation remain C5 work beyond the consumed owner binding.
- Host composition, replica stream epochs/cursors, mixed-version/session-tab negotiation, and final reader cutover remain C7 work. This batch adds optional fields but does not claim the cross-version transport contract is complete.
- Canonical turn/outcome reduction and lost completion/child recovery remain C1/C2 work; exact attachment execution observation remains C6 work.
- A complete end-to-end daemon/SSH process fixture with a real provider is not included here; the owner inventory and runtime seams are covered with bounded unit fixtures. Remote absence remains `unverifiable` until the owning host answers.
## Required sibling commits
- C5 owner binding: `22f80ab284` (and handoff `f167c637f0`; consumed tree is `1d3783e091`).
- C7 host-domain composition and negotiated replica publication before relying on the new facet across paired clients.
- C3 readiness/command-receipt evidence, C4 provider delivery adapters, C6 exact-attachment observation, and C1/C2 shared lifecycle/recovery reducers for their respective follow-on cases.
## Commit
Pending local implementation commit.
@@ -0,0 +1,354 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, _internals } from './server'
import { PANE } from './server.test-fixtures'
import { collectRuntimeWorktreePtyAgentSources } from '../runtime/runtime-worktree-pty-agent-sources'
import { attachRuntimeWorktreeAgentRows } from '../runtime/runtime-worktree-agent-rows'
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import type { AgentStatusLaunchBinding } from '../../shared/agent-status-launch-membership'
import { makePaneKey } from '../../shared/stable-pane-id'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
const binding: AgentStatusLaunchBinding = {
runId: 'run-1',
attachment: { executionId: 'execution-1' },
role: 'root'
}
const remotePane = makePaneKey('tab-remote', '22222222-2222-4222-8222-222222222222')
const remoteBinding: AgentStatusLaunchBinding = {
runId: 'run-remote',
attachment: { executionId: 'execution-remote' },
role: 'root'
}
const owner = {
claim: {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'a'.repeat(43),
worktreeScopeDigest: 'b'.repeat(43),
agent: 'codex' as const
},
generation: 'generation-1',
phase: 'live' as const,
ptyId: 'pty-owner',
surface: {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: `term_${'a'.repeat(32)}`
},
statusBinding: binding
}
beforeEach(() => {
_internals.resetCachesForTests()
getCohortAtEmitMock.mockReturnValue({})
trackMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('host-owned launch membership', () => {
it('ignores a bare create-operation result without an owner status binding', () => {
const server = new AgentHookServer()
expect(
server.admitAgentSessionOwner({
owner: { id: 'pty-only', incarnationId: 'incarnation-only' },
paneKey: PANE,
connectionId: null,
terminalHandle: 'term-only',
agentType: 'codex',
disposition: 'created'
})
).toBeNull()
expect(server.getStatusSnapshot()).toEqual([])
})
it('publishes a compatibility row before the first provider observation', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.subscribeEnrichedStatus(listener)
const admitted = server.admitAgentLaunch({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
terminalHandle: 'term_launch',
agentType: 'codex',
launchToken: 'launch-secret',
binding,
disposition: 'created',
committedAt: 100
})
expect(admitted).toMatchObject({
paneKey: PANE,
payload: { state: 'done', sessionBoundary: true, agentType: 'codex' },
launchMembership: { binding, phase: 'committed', disposition: 'created' }
})
expect(listener).toHaveBeenCalledOnce()
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
sessionBoundary: true,
launchMembership: { binding, phase: 'committed' }
})
})
it('carries membership through a provider event without treating it as turn state', () => {
const server = new AgentHookServer()
server.admitAgentLaunch({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: 'conn-1',
terminalHandle: 'term_launch',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: 100
})
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'hello', agentType: 'codex' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
launchMembership: { binding, phase: 'committed' }
})
})
it('retires failed launches and permits dismissal even when persistence fails', () => {
const server = new AgentHookServer()
server.admitAgentLaunch({
paneKey: PANE,
connectionId: null,
terminalHandle: 'term_launch',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: 100
})
expect(server.settleAgentLaunch(PANE, 'failed')).toBe(true)
expect(server.getStatusSnapshot()).toEqual([])
})
it('does not retire rows for an incomplete owner census', () => {
const server = new AgentHookServer()
server.admitAgentLaunch({
paneKey: PANE,
connectionId: null,
terminalHandle: 'term_launch',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: 100
})
expect(server.reconcileAgentLaunchMembership([], { complete: false })).toEqual({
reAdmitted: 0,
retired: 0
})
expect(server.getStatusSnapshot()).toHaveLength(1)
expect(server.getStatusSnapshot()[0]?.launchMembership?.phase).toBe('committed')
})
it('reconciles only the execution host covered by a targeted inventory', () => {
const server = new AgentHookServer()
server.admitAgentLaunch({
paneKey: PANE,
connectionId: null,
terminalHandle: 'term-local',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: 100
})
server.admitAgentLaunch({
paneKey: remotePane,
connectionId: 'conn-remote',
terminalHandle: 'term-remote',
agentType: 'codex',
binding: remoteBinding,
disposition: 'created',
committedAt: 100
})
expect(
server.reconcileAgentLaunchMembership([], { complete: true, connectionId: 'conn-remote' })
).toEqual({ reAdmitted: 0, retired: 1 })
expect(server.getStatusSnapshot().map((entry) => entry.paneKey)).toEqual([PANE])
expect(
server.reconcileAgentLaunchMembership([], { complete: true, connectionId: null })
).toEqual({ reAdmitted: 0, retired: 1 })
expect(server.getStatusSnapshot()).toEqual([])
})
it('drops launch membership when dismissal retains only provider resume identity', () => {
const server = new AgentHookServer()
server.admitAgentLaunch({
paneKey: PANE,
connectionId: null,
terminalHandle: 'term-launch',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: 100
})
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'resume-me' },
payload: { state: 'working', prompt: 'continue', agentType: 'codex' }
},
null
)
server.dropStatusEntry(PANE)
expect(server.getStatusSnapshot()[0]).toMatchObject({
providerSessionOnly: true,
providerSession: { key: 'session_id', id: 'resume-me' }
})
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('launchMembership')
})
it('marks a hydrated launch unconfirmed and re-admits only matching owners', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-launch-membership-'))
try {
const first = new AgentHookServer()
await first.start({ userDataPath, env: 'production' })
first.admitAgentLaunch({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
terminalHandle: 'term_launch',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: Date.now()
})
first.flushStatusPersistSync()
first.stop()
const second = new AgentHookServer()
await second.start({ userDataPath, env: 'production' })
expect(second.getStatusSnapshot()[0]?.launchMembership?.phase).toBe('unconfirmed')
expect(second.reconcileAgentLaunchMembership([owner])).toEqual({
reAdmitted: 1,
retired: 0
})
expect(second.getStatusSnapshot()[0]?.launchMembership?.phase).toBe('committed')
expect(second.getStatusSnapshot()[0]).toMatchObject({
terminalHandle: owner.surface.terminalHandle,
tabId: owner.surface.tabId,
worktreeId: owner.surface.worktreeId
})
second.stop()
const third = new AgentHookServer()
await third.start({ userDataPath, env: 'production' })
expect(third.reconcileAgentLaunchMembership([], { complete: true })).toEqual({
reAdmitted: 0,
retired: 1
})
expect(third.getStatusSnapshot()).toEqual([])
third.stop()
} finally {
rmSync(userDataPath, { recursive: true, force: true })
}
})
})
describe('launch membership worktree projection', () => {
it('shows host activity without permission or working rollups', () => {
const server = new AgentHookServer()
server.admitAgentLaunch({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
terminalHandle: 'term_launch',
agentType: 'codex',
binding,
disposition: 'created',
committedAt: Date.now()
})
const sources = collectRuntimeWorktreePtyAgentSources({
hookSnapshots: server.getStatusSnapshot(),
mirroredWorktreeIdByTabId: new Map([['tab-1', 'wt-1']]),
connectedPtyEvidence: {
tabIds: new Set(),
paneKeys: new Set(),
ptyIdByTerminalHandle: new Map()
}
})
const summary: RuntimeWorktreePsSummary = {
worktreeId: 'wt-1',
repoId: 'repo-1',
repo: 'repo',
path: '/tmp/repo',
branch: 'main',
isArchived: false,
isMainWorktree: true,
hasHostSidebarActivity: false,
parentWorktreeId: null,
childWorktreeIds: [],
displayName: 'repo',
workspaceStatus: 'clean',
sortOrder: 0,
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
comment: '',
isPinned: false,
isActive: false,
unread: false,
liveTerminalCount: 0,
hasAttachedPty: false,
lastOutputAt: null,
preview: '',
status: 'inactive',
agents: []
}
attachRuntimeWorktreeAgentRows({
summaries: new Map([['wt-1', summary]]),
pathIndex: {
platformByRepoId: new Map(),
posixAbsolute: new Map(),
posixRelative: new Map(),
windows: new Map(),
windowsAbsolute: new Map()
},
missingWorktreeIds: new Set(),
rowSources: new Map(sources.map((source) => [source.paneKey, source])),
workingTerminalEvidenceByWorktreeId: new Map(),
orchestrationByPaneKey: undefined,
getSummary: (summaries, _pathIndex, _missing, worktreeId) => summaries.get(worktreeId) ?? null
})
expect(summary.hasHostSidebarActivity).toBe(true)
expect(summary.status).toBe('inactive')
expect(summary.agents[0]?.launchMembership).toMatchObject({ phase: 'committed' })
})
})
+1
View File
@@ -16,6 +16,7 @@ export type {
AgentHookStatusFreshnessObservation,
EnrichedAgentHookEventPayload
} from './server/server-types'
export type { AgentLaunchAdmission, AgentLaunchSettlement } from './server/server-launch-membership'
export type { AgentHookSource }
export {
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
@@ -17,7 +17,11 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
) {
return null
}
const { launchToken: _launchToken, ...resumeIdentity } = entry
const {
launchToken: _launchToken,
launchMembership: _launchMembership,
...resumeIdentity
} = entry
return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true }
}
@@ -100,6 +100,12 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping {
// Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth.
entry.restoredUnconfirmed = true
}
if (entry.launchMembership) {
// A persisted launch proves only that this host committed an execution in
// the prior runtime. The owner inventory must re-admit it before it is
// considered live again.
entry.launchMembership = { ...entry.launchMembership, phase: 'unconfirmed' }
}
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
if (entry.connectionId) {
// Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
@@ -18,9 +18,9 @@ import { launchTokenHash } from '../../../shared/agent-hook-spool'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import { isValidPiProviderSessionOnly } from './server-status-identity'
import { AgentHookServerIngestStructured } from './server-ingest-structured'
import { AgentHookServerLaunchMembership } from './server-launch-membership'
export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructured {
export abstract class AgentHookServerIngestRemote extends AgentHookServerLaunchMembership {
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
ingestRemote(
envelope: {
@@ -0,0 +1,44 @@
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation'
import type { EnrichedAgentHookEventPayload } from './server-types'
/**
* Keep launch membership host-owned while carrying it across a verified terminal owner.
*/
export function prepareLaunchMembershipPayload(args: {
payload: AgentHookEventPayload
origin: AgentStatusObservationOrigin
previous?: EnrichedAgentHookEventPayload
sameTerminalOwner: (
previous: EnrichedAgentHookEventPayload,
incoming: Pick<AgentHookEventPayload, 'connectionId' | 'worktreeId'>
) => boolean
}): AgentHookEventPayload {
const { payload, origin, previous, sameTerminalOwner } = args
const { launchMembership: incomingLaunchMembership, ...payloadWithoutMembership } = payload
const launchOwnedPayload: AgentHookEventPayload =
origin === 'launch' && incomingLaunchMembership
? { ...payloadWithoutMembership, launchMembership: incomingLaunchMembership }
: payloadWithoutMembership
const terminalHandle =
launchOwnedPayload.terminalHandle ??
(previous?.terminalHandle && sameTerminalOwner(previous, launchOwnedPayload)
? previous.terminalHandle
: undefined)
const terminalOwnedPayload =
terminalHandle === launchOwnedPayload.terminalHandle
? launchOwnedPayload
: { ...launchOwnedPayload, terminalHandle }
const carriedLaunchMembership =
terminalOwnedPayload.launchMembership ??
(previous &&
previous.launchMembership &&
previous.terminalHandle &&
terminalHandle === previous.terminalHandle &&
sameTerminalOwner(previous, terminalOwnedPayload)
? previous.launchMembership
: undefined)
return carriedLaunchMembership
? { ...terminalOwnedPayload, launchMembership: carriedLaunchMembership }
: terminalOwnedPayload
}
@@ -0,0 +1,293 @@
import type { AgentType } from '../../../shared/agent-status-types'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import {
isAgentSessionOwnerBinding,
type AgentSessionOwnerBinding
} from '../../../shared/agent-session-host-authority'
import {
launchMembershipsEqual,
parseAgentStatusLaunchBinding,
type AgentStatusLaunchBinding,
type AgentStatusLaunchMembership
} from '../../../shared/agent-status-launch-membership'
import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerIngestStructured } from './server-ingest-structured'
export type AgentLaunchAdmission = {
paneKey: string
tabId?: string
worktreeId?: string
connectionId: string | null
terminalHandle?: string
agentType: AgentType
launchToken?: string
prompt?: string
binding: AgentStatusLaunchBinding
disposition: 'created' | 'adopted'
committedAt?: number
}
export type AgentLaunchSettlement = 'committed' | 'unconfirmed' | 'failed' | 'exited'
function ownerStatusBinding(value: unknown): AgentStatusLaunchBinding | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null
}
return parseAgentStatusLaunchBinding(Reflect.get(value, 'statusBinding'))
}
function launchMembershipKey(binding: AgentStatusLaunchBinding): string {
return `${binding.runId}\u0000${binding.attachment.executionId}`
}
function readEnrichedStatus(
value: AgentHookEventPayload | undefined
): EnrichedAgentHookEventPayload | undefined {
if (
!value ||
typeof value !== 'object' ||
!('receivedAt' in value) ||
typeof value.receivedAt !== 'number' ||
!('stateStartedAt' in value) ||
typeof value.stateStartedAt !== 'number'
) {
return undefined
}
return { ...value, receivedAt: value.receivedAt, stateStartedAt: value.stateStartedAt }
}
export abstract class AgentHookServerLaunchMembership extends AgentHookServerIngestStructured {
private sameLaunchTerminalOwner(
existing: EnrichedAgentHookEventPayload,
incoming: Pick<AgentHookEventPayload, 'connectionId' | 'worktreeId' | 'terminalHandle'>
): boolean {
return (
existing.terminalHandle !== undefined &&
incoming.terminalHandle !== undefined &&
existing.terminalHandle === incoming.terminalHandle &&
this.sameTerminalOwner(existing, incoming)
)
}
/**
* Admit a committed or adopted owner before provider output is available.
* The legacy `done + sessionBoundary` payload is a compatibility projection;
* consumers must use `launchMembership` to distinguish it from completion.
*/
admitAgentLaunch(args: AgentLaunchAdmission): EnrichedAgentHookEventPayload | null {
if (!parsePaneKey(args.paneKey) || !parseAgentStatusLaunchBinding(args.binding)) {
return null
}
const committedAt = args.committedAt ?? Date.now()
if (!Number.isFinite(committedAt) || committedAt <= 0) {
return null
}
const membership: AgentStatusLaunchMembership = {
binding: args.binding,
disposition: args.disposition,
phase: 'committed',
committedAt
}
const previous = readEnrichedStatus(this.state.lastStatusByPaneKey.get(args.paneKey))
if (previous?.launchMembership) {
if (
!launchMembershipsEqual(previous.launchMembership, membership) ||
!this.sameLaunchTerminalOwner(previous, args)
) {
return null
}
return this.replaceLaunchMembership(previous, membership, args.launchToken)
}
if (previous && !this.sameLaunchTerminalOwner(previous, args)) {
// A provider row with no terminal join is not safe to re-key from a launch.
// The host may still admit the owner later once the canonical surface is known.
return null
}
const event: AgentHookEventPayload = {
paneKey: args.paneKey,
tabId: args.tabId,
worktreeId: args.worktreeId,
connectionId: args.connectionId,
terminalHandle: args.terminalHandle,
launchToken: args.launchToken,
launchMembership: membership,
payload: {
state: 'done',
sessionBoundary: true,
prompt: args.prompt ?? '',
agentType: args.agentType
}
}
return this.applyNormalizedStatus(event, undefined, 'launch', committedAt)
}
/** Convenience seam for owner registries: C5's binding is the only identity source. */
admitAgentSessionOwner(args: {
owner: unknown
paneKey: string
tabId?: string
worktreeId?: string
connectionId: string | null
terminalHandle?: string
agentType: AgentType
launchToken?: string
prompt?: string
disposition: 'created' | 'adopted'
committedAt?: number
}): EnrichedAgentHookEventPayload | null {
if (!isAgentSessionOwnerBinding(args.owner)) {
return null
}
const binding = ownerStatusBinding(args.owner)
if (!binding) {
return null
}
return this.admitAgentLaunch({ ...args, binding })
}
/** Settle launch bookkeeping without making persistence success a user-action gate. */
settleAgentLaunch(paneKey: string, settlement: AgentLaunchSettlement): boolean {
const existing = readEnrichedStatus(this.state.lastStatusByPaneKey.get(paneKey))
if (!existing?.launchMembership) {
return false
}
if (settlement === 'failed' || settlement === 'exited') {
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (!deleted) {
return false
}
this.commitStatusRowMutation(deleted, undefined)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitStatusDropped(deleted.paneKey)
return true
}
const phase = settlement === 'committed' ? 'committed' : 'unconfirmed'
if (existing.launchMembership.phase === phase) {
return false
}
this.replaceLaunchMembership(existing, { ...existing.launchMembership, phase })
return true
}
/**
* Reconcile hydrated launch rows against owner inventory. Positive owner
* matches re-admit immediately; an incomplete inventory cannot retire absence.
*/
reconcileAgentLaunchMembership(
owners: readonly unknown[],
options: { complete: boolean; connectionId?: string | null } = { complete: true }
): { reAdmitted: number; retired: number } {
const admitted = new Map<string, AgentSessionOwnerBinding>()
for (const candidate of owners) {
const binding = ownerStatusBinding(candidate)
if (binding && isAgentSessionOwnerBinding(candidate)) {
admitted.set(launchMembershipKey(binding), candidate)
}
}
let reAdmitted = 0
let retired = 0
let changed = false
for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) {
const entry = readEnrichedStatus(rawEntry)
if (!entry) {
continue
}
const membership = entry.launchMembership
if (!membership) {
continue
}
const inScope =
options.connectionId === undefined || (entry.connectionId ?? null) === options.connectionId
if (!inScope) {
continue
}
const owner = admitted.get(launchMembershipKey(membership.binding))
if (owner !== undefined) {
const canonicalPaneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId)
let currentPaneKey = paneKey
let currentEntry = entry
if (canonicalPaneKey !== paneKey) {
if (this.state.lastStatusByPaneKey.has(canonicalPaneKey)) {
// A complete owner inventory proves the surface, but cannot authorize
// overwriting another row already occupying it.
continue
}
this.transferPaneAuthority(paneKey, canonicalPaneKey, owner.ptyId, Date.now(), {
authorityVerified: true
})
currentPaneKey = canonicalPaneKey
const movedEntry = readEnrichedStatus(
this.state.lastStatusByPaneKey.get(canonicalPaneKey)
)
if (!movedEntry) {
continue
}
currentEntry = movedEntry
}
const surfaceChanged =
currentEntry.terminalHandle !== owner.surface.terminalHandle ||
currentEntry.tabId !== owner.surface.tabId ||
currentEntry.worktreeId !== owner.surface.worktreeId
if (
membership.phase !== 'committed' ||
surfaceChanged ||
currentEntry.restoredUnconfirmed
) {
const { restoredUnconfirmed: _restoredUnconfirmed, ...currentEntryWithoutRestore } =
currentEntry
const updated = {
...currentEntryWithoutRestore,
paneKey: currentPaneKey,
tabId: owner.surface.tabId,
worktreeId: owner.surface.worktreeId,
terminalHandle: owner.surface.terminalHandle,
launchMembership: { ...membership, phase: 'committed' as const }
}
this.state.lastStatusByPaneKey.set(currentPaneKey, updated)
this.commitStatusRowMutation(currentEntry, updated)
this.emitEnrichedStatus(updated)
if (membership.phase !== 'committed') {
reAdmitted += 1
}
changed = true
}
continue
}
if (!options.complete) {
continue
}
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (deleted) {
this.commitStatusRowMutation(deleted, undefined, false)
this.emitStatusDropped(paneKey)
retired += 1
changed = true
}
}
if (changed) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
return { reAdmitted, retired }
}
private replaceLaunchMembership(
existing: EnrichedAgentHookEventPayload,
launchMembership: AgentStatusLaunchMembership,
launchToken?: string
): EnrichedAgentHookEventPayload {
const updated: EnrichedAgentHookEventPayload = {
...existing,
...(launchToken ? { launchToken } : {}),
launchMembership
}
this.state.lastStatusByPaneKey.set(existing.paneKey, updated)
this.commitStatusRowMutation(existing, updated)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitEnrichedStatus(updated)
return updated
}
}
@@ -13,6 +13,7 @@ import {
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentHookAuthorityEvidence, EnrichedAgentHookEventPayload } from './server-types'
import { isValidPaneKey, isValidPiProviderSessionOnly } from './server-status-identity'
import { parseAgentStatusLaunchMembership } from '../../../shared/agent-status-launch-membership'
export function dropHydratedIdleClaudeSubagents(
payload: ParsedAgentStatusPayload
@@ -91,6 +92,13 @@ export function sanitizeHydratedEntry(
const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined
const providerSessionOnly = record.providerSessionOnly === true
const retainedForLiveness = record.retainedForLiveness === true
const launchMembership =
record.launchMembership === undefined
? undefined
: parseAgentStatusLaunchMembership(record.launchMembership)
if (record.launchMembership !== undefined && !launchMembership) {
return null
}
const validRetainedIdentity = Boolean(
retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown'
)
@@ -131,6 +139,7 @@ export function sanitizeHydratedEntry(
providerSession,
providerSessionOnly: providerSessionOnly ? true : undefined,
retainedForLiveness: retainedForLiveness ? true : undefined,
...(launchMembership ? { launchMembership } : {}),
payload,
receivedAt,
stateStartedAt
@@ -70,6 +70,7 @@ export function toAgentStatusIpcPayload(
...(entry.observation ? { observation: entry.observation } : {}),
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}),
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}),
...(entry.launchMembership ? { launchMembership: entry.launchMembership } : {}),
...entry.payload
}
}
@@ -19,6 +19,7 @@ import {
import { isStaleGrokTurnEnd } from './server-grok-status-rules'
import { isToolProgressWorkingAfterInterrupt } from './server-status-identity'
import { AgentHookServerStatusApplication } from './server-status-application'
import { prepareLaunchMembershipPayload } from './server-launch-membership-payload'
export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication {
protected applyNormalizedStatus(
@@ -36,20 +37,19 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
| EnrichedAgentHookEventPayload
| undefined
const rowBefore = mutationBefore ?? previous
const terminalHandle =
payload.terminalHandle ??
(previous?.terminalHandle && this.sameTerminalOwner(previous, payload)
? previous.terminalHandle
: undefined)
const terminalOwnedPayload =
terminalHandle === payload.terminalHandle ? payload : { ...payload, terminalHandle }
if (previous && isStaleGrokTurnEnd(previous, terminalOwnedPayload)) {
const membershipOwnedPayload = prepareLaunchMembershipPayload({
payload,
origin,
previous,
sameTerminalOwner: (existing, incoming) => this.sameTerminalOwner(existing, incoming)
})
if (previous && isStaleGrokTurnEnd(previous, membershipOwnedPayload)) {
// Why: Grok turn-end hooks may arrive after the next prompt, including across relay restart.
this.commitStatusRowMutation(rowBefore, previous)
return previous
}
const connectionClearWatermark = terminalOwnedPayload.connectionId
? this.connectionTimestampWatermarkById.get(terminalOwnedPayload.connectionId)
const connectionClearWatermark = membershipOwnedPayload.connectionId
? this.connectionTimestampWatermarkById.get(membershipOwnedPayload.connectionId)
: undefined
// Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback.
const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined
@@ -58,15 +58,15 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
(connectionClearWatermark ?? -1) + 1,
(restoredStatusWatermark ?? -1) + 1
)
if (terminalOwnedPayload.connectionId) {
this.connectionTimestampWatermarkById.set(terminalOwnedPayload.connectionId, now)
if (membershipOwnedPayload.connectionId) {
this.connectionTimestampWatermarkById.set(membershipOwnedPayload.connectionId, now)
}
if (terminalOwnedPayload.providerSessionOnly) {
if (membershipOwnedPayload.providerSessionOnly) {
// Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status.
onAccepted?.()
const enriched = {
...this.attachStatusTiming(terminalOwnedPayload, now),
observation: this.stampObservation(terminalOwnedPayload, origin, now)
...this.attachStatusTiming(membershipOwnedPayload, now),
observation: this.stampObservation(membershipOwnedPayload, origin, now)
}
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
@@ -78,21 +78,21 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
return enriched
}
const stateReconciledPayload =
terminalOwnedPayload.connectionId &&
terminalOwnedPayload.payload.agentType === 'codex' &&
terminalOwnedPayload.hookEventName
membershipOwnedPayload.connectionId &&
membershipOwnedPayload.payload.agentType === 'codex' &&
membershipOwnedPayload.hookEventName
? {
...terminalOwnedPayload,
...membershipOwnedPayload,
payload: reconcileRemoteCodexState(
this.state,
terminalOwnedPayload.paneKey,
terminalOwnedPayload.hookEventName,
terminalOwnedPayload.toolAgentId,
terminalOwnedPayload.payload,
membershipOwnedPayload.paneKey,
membershipOwnedPayload.hookEventName,
membershipOwnedPayload.toolAgentId,
membershipOwnedPayload.payload,
previous?.payload
)
}
: terminalOwnedPayload
: membershipOwnedPayload
const previousCodexRoot =
stateReconciledPayload.payload.agentType === 'codex' &&
stateReconciledPayload.toolAgentId &&
@@ -7,6 +7,7 @@ import type {
import type { AgentStatusObservation } from '../../../shared/agent-status-observation'
import type { AgentKind } from '../../../shared/telemetry-events'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { AgentStatusLaunchMembership } from '../../../shared/agent-status-launch-membership'
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
@@ -25,6 +26,8 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
retainedForLiveness?: true
/** Persisted proof that a lead boundary was held working only by child agents. */
claudeLeadBoundaryChildOnly?: true
/** Host-owned launch/adoption membership. This facet is not a turn or readiness claim. */
launchMembership?: AgentStatusLaunchMembership
}
export type PersistedAgentHookEventPayload = Omit<
+21
View File
@@ -225,6 +225,27 @@ async function startOrcadRuntime(
// PTY agent on this host, and the store is the only place `worktree.ps` and the mobile
// projection read from — unwired, orcad lists no PTY agents at all.
onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event),
onAgentSessionCommitted: (commit) => {
agentHookServer.admitAgentSessionOwner({
owner: commit.result.owner,
paneKey: commit.paneKey,
tabId: commit.tabId,
worktreeId: commit.worktreeId,
connectionId: commit.connectionId,
terminalHandle: commit.result.owner.surface.terminalHandle,
agentType: commit.agentType ?? commit.result.owner.claim.agent,
launchToken: commit.launchToken,
disposition: commit.result.disposition
})
},
onAgentSessionInventoryReconciled: (reconciliation) => {
agentHookServer.reconcileAgentLaunchMembership(reconciliation.owners, {
complete: reconciliation.complete,
...(reconciliation.connectionId !== undefined
? { connectionId: reconciliation.connectionId }
: {})
})
},
// Why here too and not only on the desktop: orcad serves `worktree.ps` and `agentSession.*`,
// so without these a headless host publishes its structured chats nowhere and lists no agents.
getAgentStatusSnapshot: () =>
+13 -114
View File
@@ -4,6 +4,7 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies'
import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop'
import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan'
import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit'
import { finalizeBackgroundTerminalCreate } from './orca-runtime-finalize-background-terminal'
export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication {
async createTerminal(
@@ -175,123 +176,21 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
} finally {
releaseStablePaneCreate?.()
}
if (!result.stablePaneOwner) {
reportPtySpawnCommitted()
}
const adoptedStablePane = Boolean(result.stablePaneOwner)
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
} else if (result.stablePaneOwner) {
preAllocatedHandle = result.stablePaneOwner.handle
tabId = result.stablePaneOwner.tabId
leafId = result.stablePaneOwner.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
}
throw error
}
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId, {
return finalizeBackgroundTerminalCreate(this, {
result,
workspace,
launchOpts,
presentation,
cwd,
preAllocatedHandle,
tabId,
leafId,
terminalHandle: preAllocatedHandle,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
if (launchOpts.structuredAgentSessionId) {
dependencies.agentSessionPtyWriteGate.bindPty(
result.id,
launchOpts.structuredAgentSessionId
)
}
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
pty.runtimeSessionOwned = true
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
}
pty.launchConfig = effectiveLaunchConfig
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
pty.launchAgent = launchOpts.launchAgent ?? null
}
pty.tabId = tabId
pty.paneKey = paneKey
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
})
}
let surface: dependencies.RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
try {
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...dependencies.ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = dependencies.createTerminalRevealWarning(handle, err)
}
} else if (presentation !== 'background') {
warning = dependencies.createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.pid ? { processId: result.pid } : {}),
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(adoptedStablePane ? { isReattach: true as const } : {}),
...(warning ? { warning } : {})
}
launchToken,
effectiveLaunchConfig,
reportPtySpawnCommitted,
surfaceOwner: opts.surfaceOwner
})
} finally {
releaseStablePaneCreate()
}
@@ -0,0 +1,172 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by characterization tests.
import * as dependencies from './orca-runtime-create-terminal-dependencies'
import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
import type { RuntimeTerminalPresentation } from '../../shared/runtime-types'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
export async function finalizeBackgroundTerminalCreate(
runtime: OrcaRuntimeWithCreateTerminal,
args: {
result: Awaited<ReturnType<NonNullable<dependencies.RuntimePtyController['spawn']>>>
workspace: Awaited<
ReturnType<OrcaRuntimeWithCreateTerminal['resolveTerminalWorkspaceLaunchScope']>
>
launchOpts: Awaited<
ReturnType<OrcaRuntimeWithCreateTerminal['resolveAgentTerminalCreateOptions']>
>
presentation: RuntimeTerminalPresentation | undefined
cwd: string
preAllocatedHandle: string
tabId: string
leafId: string
paneKey: string
launchToken: string | undefined
effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined
reportPtySpawnCommitted: () => void
surfaceOwner?: boolean
}
): Promise<dependencies.RuntimeTerminalCreate> {
const {
result,
workspace,
launchOpts,
presentation,
cwd,
launchToken,
effectiveLaunchConfig,
reportPtySpawnCommitted,
surfaceOwner
} = args
let { preAllocatedHandle, tabId, leafId, paneKey } = args
if (!result.stablePaneOwner) {
reportPtySpawnCommitted()
}
const adoptedStablePane = Boolean(result.stablePaneOwner)
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
} else if (result.stablePaneOwner) {
preAllocatedHandle = result.stablePaneOwner.handle
tabId = result.stablePaneOwner.tabId
leafId = result.stablePaneOwner.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
}
try {
runtime.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
runtime.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
}
throw error
}
runtime.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
runtime.preparePtyExecutionContext(result.id, result.wslDistro)
}
runtime.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId,
leafId,
terminalHandle: preAllocatedHandle,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
if (result.agentSessionEnsure && runtime.onAgentSessionCommitted) {
try {
runtime.onAgentSessionCommitted({
result: result.agentSessionEnsure,
paneKey,
tabId,
leafId,
worktreeId: workspace.id,
connectionId: workspace.connectionId,
launchToken,
agentType: launchOpts.launchAgent ?? result.agentSessionEnsure.owner.claim.agent
})
} catch (error) {
// Membership is bookkeeping. A failed status publication must never turn a committed execution into a failed terminal create.
console.warn('[terminal-create] launch membership publication failed:', error)
}
}
if (launchOpts.structuredAgentSessionId) {
dependencies.agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId)
}
const pty = runtime.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
pty.runtimeSessionOwned = true
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = runtime.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
runtime.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
}
pty.launchConfig = effectiveLaunchConfig
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
pty.launchAgent = launchOpts.launchAgent ?? null
}
pty.tabId = tabId
pty.paneKey = paneKey
}
const handle = pty ? runtime.issuePtyHandle(pty) : preAllocatedHandle
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
runtime.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
})
}
let surface: dependencies.RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && runtime.notifier?.revealTerminalSession) {
try {
await runtime.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...dependencies.ownerSurfacing(surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = dependencies.createTerminalRevealWarning(handle, err)
}
} else if (presentation !== 'background') {
warning = dependencies.createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
...runtime.getPtyExecutionHostMetadata(result.id),
surface,
...(result.pid ? { processId: result.pid } : {}),
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(adoptedStablePane ? { isReattach: true as const } : {}),
...(warning ? { warning } : {})
}
}
@@ -6,6 +6,8 @@ import type {
AgentSessionCreateOperation,
OrchestrationCompatibilitySshAttachmentAuthority,
RestoredOrchestrationAuthorityReceipt,
RuntimeAgentSessionCommit,
RuntimeAgentSessionInventoryReconciliation,
RuntimeTerminalAgentStatusEvent
} from './runtime-terminal-contracts'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
@@ -59,6 +61,14 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
| ((event: RuntimeTerminalAgentStatusEvent) => void)
| null
/** C10 admission callback; bookkeeping failures never gate terminal creation. */
protected readonly onAgentSessionCommitted: ((commit: RuntimeAgentSessionCommit) => void) | null
/** Complete owner inventories are the only restart-retirement authority. */
protected readonly onAgentSessionInventoryReconciled:
| ((reconciliation: RuntimeAgentSessionInventoryReconciliation) => void)
| null
protected readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null
protected terminalSideEffectLocalConsumerAvailable = false
@@ -3,11 +3,7 @@ import { OrcaRuntimeWithRefreshPtyWorktreeRecordsFromController } from './orca-r
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toSshExecutionHostId
} from '../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import {
PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS,
PTY_CONTROLLER_LIST_TIMEOUT_MS
@@ -21,14 +17,15 @@ import {
inferWorktreeIdFromPtyId,
runtimeWorktreeIdsEqual
} from './runtime-worktree-path-identity'
import {
indexPersistedPtySurfaceBindings,
indexPersistedPtyWorktreeBindings
} from './runtime-worktree-binding-index'
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
import { NO_OBSERVING_PROVIDER_REASON } from '../../shared/pty-liveness-verdict'
import { buildControllerTerminalIdentities } from './orca-runtime-build-controller-terminal-identities'
import { retireOrchestrationAuthorityAbsentFromInventory } from './runtime-restored-orchestration-authority-sweep'
import { reconcileRuntimeAgentSessionInventory } from './runtime-agent-session-inventory-reconciliation'
import {
getPersistedPtyIndexes,
type RuntimePersistedPtyIndexes
} from './runtime-persisted-pty-indexes'
export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory extends OrcaRuntimeWithRefreshPtyWorktreeRecordsFromController {
protected async refreshPtyWorktreeRecordsWithControllerInventory(
@@ -117,36 +114,16 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext
}
const sessions = sessionsResult.value.processes
const queriedHostIds = new Set<ExecutionHostId>(sessionsResult.value.hostIds)
if (connectionId === undefined) {
for (const session of sessions) {
const hostId = getPtyExecutionHost(session.id)
if (hostId && hostId !== 'foreign' && parseExecutionHostId(hostId)?.kind === 'ssh') {
queriedHostIds.add(hostId)
}
}
}
reconcileRuntimeAgentSessionInventory({
sessions,
connectionId,
queriedHostIds,
knownHostIds: this.listKnownExecutionHostIds(),
onReconciled: this.onAgentSessionInventoryReconciled ?? undefined
})
const { controllerIdentityByPtyId } = buildControllerTerminalIdentities(sessions)
const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees)
const persistedIndexesByHostId = new Map<
ExecutionHostId,
{
worktreeIdByPtyId: ReadonlyMap<string, string>
surfaceByPtyId: ReturnType<typeof indexPersistedPtySurfaceBindings>
}
>()
const getPersistedIndexes = (hostId: ExecutionHostId) => {
const existing = persistedIndexesByHostId.get(hostId)
if (existing) {
return existing
}
const persistedSession = this.store?.getWorkspaceSession?.(hostId)
const indexes = {
worktreeIdByPtyId: indexPersistedPtyWorktreeBindings(persistedSession),
surfaceByPtyId: indexPersistedPtySurfaceBindings(persistedSession)
}
persistedIndexesByHostId.set(hostId, indexes)
return indexes
}
const persistedIndexesByHostId = new Map<ExecutionHostId, RuntimePersistedPtyIndexes>()
const allLivePtyIds = new Set(sessions.map((session) => session.id))
const selectedLivePtyIds = new Set<string>()
for (const session of sessions) {
@@ -156,9 +133,14 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext
const sessionConnectionId =
parseAppSshPtyId(session.id)?.connectionId ??
(typeof connectionId === 'string' ? connectionId : null)
const persistedIndexes = getPersistedIndexes(
sessionConnectionId ? toSshExecutionHostId(sessionConnectionId) : LOCAL_EXECUTION_HOST_ID
)
const persistedHostId = sessionConnectionId
? toSshExecutionHostId(sessionConnectionId)
: LOCAL_EXECUTION_HOST_ID
const persistedIndexes = getPersistedPtyIndexes({
cache: persistedIndexesByHostId,
store: this.store,
hostId: persistedHostId
})
const controllerIdentity = controllerIdentityByPtyId.get(session.id)
const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id)
const providerWorktree = session.worktreeId
@@ -303,7 +285,7 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext
}
}
}
// Why: runs after the hasPty rescue so a still-addressable pane keeps its receipt.
// Runs after the hasPty rescue so addressable panes keep their receipt.
retireOrchestrationAuthorityAbsentFromInventory(this.restoredOrchestrationAuthorityByPtyId, {
queriedHostIds,
allLivePtyIds,
+11 -1
View File
@@ -3,7 +3,11 @@ import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands'
import type { RuntimeStore } from './runtime-store-contract'
import type { StatsCollector } from '../stats/collector'
import type { IPtyProvider } from '../providers/types'
import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts'
import type {
RuntimeAgentSessionCommit,
RuntimeAgentSessionInventoryReconciliation,
RuntimeTerminalAgentStatusEvent
} from './runtime-terminal-contracts'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
@@ -45,6 +49,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
getSshProvider?: (connectionId: string) => IPtyProvider | undefined
onPtyStopped?: (ptyId: string) => void
onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void
onAgentSessionCommitted?: (commit: RuntimeAgentSessionCommit) => void
onAgentSessionInventoryReconciled?: (
reconciliation: RuntimeAgentSessionInventoryReconciliation
) => void
onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void
// Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC
// terminal output. worktree.ps reads this at query time so mobile shows the
@@ -226,6 +234,8 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
this.getSshProviderFn = deps?.getSshProvider ?? null
this.onPtyStopped = deps?.onPtyStopped ?? null
this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null
this.onAgentSessionCommitted = deps?.onAgentSessionCommitted ?? null
this.onAgentSessionInventoryReconciled = deps?.onAgentSessionInventoryReconciled ?? null
this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null
this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable')
this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null
@@ -242,7 +242,36 @@ describe('OrcaRuntimeService', () => {
expect(internals.ptysById.has('pty-exited-during-start')).toBe(false)
})
it('does not publish launch membership from a bare create-operation spawn result', async () => {
const onAgentSessionCommitted = vi.fn()
const operationId = `${Date.now()}-${'ab'.repeat(16)}`
const runtime = new OrcaRuntimeService(store, undefined, { onAgentSessionCommitted })
const spawn = vi.fn().mockResolvedValue({
id: 'pty-bare-operation-replay',
incarnationId: 'incarnation-bare-operation-replay'
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
launchAgent: 'codex',
presentation: 'background',
agentSessionCreateOperationId: operationId
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({ agentSessionCreateOperationId: operationId })
)
expect(onAgentSessionCommitted).not.toHaveBeenCalled()
})
it('adopts repeated structured OMP resumes while preserving the exact file locator', async () => {
const onAgentSessionCommitted = vi.fn()
let canonicalOwner:
| {
claim: AgentSessionExecutionClaim
@@ -276,7 +305,7 @@ describe('OrcaRuntimeService', () => {
}
}
})
const runtime = new OrcaRuntimeService(store)
const runtime = new OrcaRuntimeService(store, undefined, { onAgentSessionCommitted })
runtime.setPtyController({
spawn,
write: () => true,
@@ -296,6 +325,13 @@ describe('OrcaRuntimeService', () => {
expect(first.disposition).toBe('created')
expect(second.disposition).toBe('adopted')
expect(onAgentSessionCommitted).toHaveBeenCalledTimes(2)
expect(onAgentSessionCommitted.mock.calls[0]?.[0]).toMatchObject({
result: { disposition: 'created', owner: { statusBinding: canonicalOwner?.statusBinding } }
})
expect(onAgentSessionCommitted.mock.calls[1]?.[0]).toMatchObject({
result: { disposition: 'adopted', owner: { statusBinding: canonicalOwner?.statusBinding } }
})
expect(second.terminal).toMatchObject({
handle: first.terminal.handle,
tabId: first.terminal.tabId,
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentSessionOwners } from '../ipc/pty/pane/agent-session-owners'
import { reconcileRuntimeAgentSessionInventory } from './runtime-agent-session-inventory-reconciliation'
const owner = {
claim: {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'a'.repeat(43),
worktreeScopeDigest: 'b'.repeat(43),
agent: 'codex' as const
},
generation: 'generation-1',
phase: 'live' as const,
ptyId: 'local-pty',
surface: {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: `term_${'a'.repeat(32)}`
},
statusBinding: {
runId: 'run-1',
attachment: { executionId: 'execution-1' },
role: 'root' as const
}
}
afterEach(() => {
agentSessionOwners.release(owner.ptyId)
})
describe('runtime launch-owner inventory reconciliation', () => {
it('includes the current-runtime local owner when the local provider omits owner metadata', () => {
agentSessionOwners.register(owner)
const onReconciled = vi.fn()
reconcileRuntimeAgentSessionInventory({
sessions: [{ id: owner.ptyId, cwd: '/workspace', title: 'shell' }],
connectionId: null,
queriedHostIds: new Set(['local']),
knownHostIds: new Set(['local']),
onReconciled
})
expect(onReconciled).toHaveBeenCalledWith(
expect.objectContaining({ complete: true, connectionId: null })
)
expect(onReconciled.mock.calls[0]?.[0].owners).toEqual([owner])
})
})
@@ -0,0 +1,45 @@
import type { PtyProcessInfo } from '../providers/types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { getPtyExecutionHost } from '../../shared/terminal-execution-host'
import { parseExecutionHostId } from '../../shared/execution-host'
import type { RuntimeAgentSessionInventoryReconciliation } from './runtime-terminal-contracts'
import { agentSessionOwners } from '../ipc/pty/pane/agent-session-owners'
/** Add host ids discovered in an aggregate inventory and publish its coverage. */
export function reconcileRuntimeAgentSessionInventory(args: {
sessions: readonly PtyProcessInfo[]
connectionId?: string | null
queriedHostIds: Set<ExecutionHostId>
knownHostIds: ReadonlySet<ExecutionHostId>
onReconciled?: (reconciliation: RuntimeAgentSessionInventoryReconciliation) => void
}): void {
if (args.connectionId === undefined) {
for (const session of args.sessions) {
const hostId = getPtyExecutionHost(session.id)
if (hostId && hostId !== 'foreign' && parseExecutionHostId(hostId)?.kind === 'ssh') {
args.queriedHostIds.add(hostId)
}
}
}
if (!args.onReconciled) {
return
}
const owners = [
...args.sessions.flatMap((session) => session.agentSessionOwners ?? []),
...(args.connectionId === undefined || args.connectionId === null
? agentSessionOwners.list().filter((owner) => getPtyExecutionHost(owner.ptyId) === null)
: [])
]
const complete =
args.connectionId !== undefined ||
[...args.knownHostIds].every((hostId) => {
const kind = parseExecutionHostId(hostId)?.kind
return kind === 'runtime' || args.queriedHostIds.has(hostId)
})
try {
args.onReconciled({ owners, complete, connectionId: args.connectionId })
} catch (error) {
// Inventory-derived bookkeeping must not make a terminal listing fail.
console.warn('[runtime] launch membership reconciliation failed:', error)
}
}
@@ -6,6 +6,7 @@ import {
type ParsedAgentStatusPayload
} from '../../shared/agent-status-types'
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { AgentStatusLaunchMembership } from '../../shared/agent-status-launch-membership'
import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types'
import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection'
@@ -20,6 +21,7 @@ export type RuntimeAgentRowSnapshot = {
updatedAt: number
evidenceObservedAt?: number
providerSession?: AgentProviderSessionMetadata
launchMembership?: AgentStatusLaunchMembership
}
function isLiveObservation(row: AgentStatusIpcPayload): boolean {
@@ -128,6 +130,7 @@ export function selectFreshAgentRowForMobileTab(args: {
stateStartedAt: match.stateStartedAt ?? match.receivedAt,
updatedAt: match.receivedAt,
...(match.providerSession ? { providerSession: match.providerSession } : {}),
...(match.launchMembership ? { launchMembership: match.launchMembership } : {}),
...(match.evidenceObservedAt !== undefined
? { evidenceObservedAt: match.evidenceObservedAt }
: {})
@@ -4,6 +4,16 @@ import type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection
import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder'
const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' }
const LAUNCH_MEMBERSHIP = {
binding: {
runId: 'run-1',
attachment: { executionId: 'execution-1' },
role: 'root' as const
},
disposition: 'created' as const,
phase: 'committed' as const,
committedAt: 10
}
const TAB: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: 'tab::leaf',
@@ -21,7 +31,8 @@ describe('mobile agent status builder', () => {
payload: { state: 'working', prompt: 'ship it', agentType: 'codex' },
stateStartedAt: 10,
updatedAt: 10,
providerSession: PROVIDER_SESSION
providerSession: PROVIDER_SESSION,
launchMembership: LAUNCH_MEMBERSHIP
}
const result = buildRuntimeMobileAgentStatus(null, TAB, 'term-1', retained, () => [], {
@@ -32,7 +43,10 @@ describe('mobile agent status builder', () => {
expect(result).toEqual(
expect.objectContaining({
agentStatus: expect.objectContaining({ providerSession: PROVIDER_SESSION })
agentStatus: expect.objectContaining({
providerSession: PROVIDER_SESSION,
launchMembership: LAUNCH_MEMBERSHIP
})
})
)
})
@@ -114,7 +114,8 @@ export function buildRuntimeMobileAgentStatus(
: {}),
tabId: tab.parentTabId,
terminalTitle,
...providerSession
...providerSession,
...(liveRow.launchMembership ? { launchMembership: liveRow.launchMembership } : {})
},
ownerAgent,
ownerOptions
@@ -153,7 +154,8 @@ export function buildRuntimeMobileAgentStatus(
tabId: tab.parentTabId,
terminalTitle,
stateHistory: [],
...providerSession
...providerSession,
...(hookRow.launchMembership ? { launchMembership: hookRow.launchMembership } : {})
}
}
}
@@ -6,6 +6,7 @@ import {
type AgentStatusIpcPayload
} from '../../shared/agent-status-types'
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { AgentStatusLaunchMembership } from '../../shared/agent-status-launch-membership'
import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-status-projection'
import type { HookLiveAgentRow } from './runtime-terminal-contracts'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
@@ -57,7 +58,8 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
...(status.worktreeId ? { worktreeId: status.worktreeId } : {}),
...(status.tabId ? { tabId: status.tabId } : {}),
...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}),
...(status.providerSession ? { providerSession: status.providerSession } : {})
...(status.providerSession ? { providerSession: status.providerSession } : {}),
...(status.launchMembership ? { launchMembership: status.launchMembership } : {})
})
const titleConfirmsState =
(pty.lastAgentStatus === 'working' && status.state === 'working') ||
@@ -108,6 +110,7 @@ export type RuntimeHookAgentRowLookup = {
providerSession: AgentProviderSessionMetadata | null
providerSessionAgentType: string | null
providerSessionReceivedAt: number | null
launchMembership: AgentStatusLaunchMembership | null
agentType: string | null
agentIsLive: boolean
live: HookLiveAgentRow | null
@@ -158,9 +161,12 @@ export function selectRuntimeHookAgentRowForPane(
? { evidenceObservedAt: live.evidenceObservedAt }
: {}),
stateStartedAt: live.stateStartedAt ?? live.receivedAt,
...(live.worktreeId ? { worktreeId: live.worktreeId } : {})
...(live.worktreeId ? { worktreeId: live.worktreeId } : {}),
...(live.launchMembership ? { launchMembership: live.launchMembership } : {})
}
: null
: null,
launchMembership:
live?.launchMembership ?? agent?.launchMembership ?? session?.launchMembership ?? null
}
}
@@ -0,0 +1,29 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type { RuntimeStore } from './runtime-store-contract'
import {
indexPersistedPtySurfaceBindings,
indexPersistedPtyWorktreeBindings
} from './runtime-worktree-binding-index'
export type RuntimePersistedPtyIndexes = {
worktreeIdByPtyId: ReadonlyMap<string, string>
surfaceByPtyId: ReturnType<typeof indexPersistedPtySurfaceBindings>
}
export function getPersistedPtyIndexes(args: {
cache: Map<ExecutionHostId, RuntimePersistedPtyIndexes>
store: RuntimeStore | null
hostId: ExecutionHostId
}): RuntimePersistedPtyIndexes {
const existing = args.cache.get(args.hostId)
if (existing) {
return existing
}
const persistedSession = args.store?.getWorkspaceSession?.(args.hostId)
const indexes = {
worktreeIdByPtyId: indexPersistedPtyWorktreeBindings(persistedSession),
surfaceByPtyId: indexPersistedPtySurfaceBindings(persistedSession)
}
args.cache.set(args.hostId, indexes)
return indexes
}
+26 -1
View File
@@ -1,6 +1,7 @@
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
import type {
AgentLaunchPreferences,
AgentSessionClaimedSpawnResult,
AgentSessionExecutionClaim,
RuntimeCreateAgentSessionResult
} from '../../shared/agent-session-host-authority'
@@ -56,6 +57,25 @@ export type TerminalCreateOptions = {
deferMobileSessionPublish?: boolean
}
/** Notification emitted after the execution host commits or adopts a claimed owner. */
export type RuntimeAgentSessionCommit = {
result: AgentSessionClaimedSpawnResult
paneKey: string
tabId: string
leafId: string
worktreeId: string
connectionId: string | null
launchToken?: string
agentType?: TuiAgent
}
export type RuntimeAgentSessionInventoryReconciliation = {
owners: readonly unknown[]
complete: boolean
/** `undefined` is an aggregate census; null is the local host; a string is one SSH host. */
connectionId?: string | null
}
/** Identity a fenced spawn can be re-found by in the execution host's own inventory. */
export type AgentSessionCreateReclaimIdentity = {
worktreeId: string
@@ -104,7 +124,12 @@ export type RuntimeTerminalAgentStatusEvent = {
export type HookLiveAgentRow = Pick<
RuntimeAgentRowSnapshot,
'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId'
| 'payload'
| 'updatedAt'
| 'evidenceObservedAt'
| 'stateStartedAt'
| 'worktreeId'
| 'launchMembership'
>
export type RuntimePtyDataAdmission = Readonly<{
@@ -62,7 +62,8 @@ export function attachRuntimeWorktreeAgentRows(args: {
interrupted: source.interrupted,
stateStartedAt: source.stateStartedAt,
updatedAt: source.updatedAt,
...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {})
...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}),
...(source.launchMembership ? { launchMembership: source.launchMembership } : {})
}
const rows = rowsByWorktree.get(summary.worktreeId)
if (rows) {
@@ -81,10 +82,16 @@ export function attachRuntimeWorktreeAgentRows(args: {
let hasForegroundWorkingAgent = false
const monitoringSources: RuntimeWorktreeAgentSource[] = []
for (const row of rows) {
if (!isFreshNonDoneAgentStatus(row, now)) {
const committedLaunch = row.launchMembership?.phase === 'committed'
if (!committedLaunch && !isFreshNonDoneAgentStatus(row, now)) {
continue
}
summary.hasHostSidebarActivity = true
if (committedLaunch) {
// Membership means the host committed an execution; it is neither a
// turn nor a pending interaction and must not make the worktree busy.
continue
}
if (row.state === 'working') {
if (row.workingMode === 'monitoring') {
const source = rowSources.get(row.paneKey)
@@ -1,5 +1,6 @@
import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event'
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
import type { AgentStatusLaunchMembership } from '../../shared/agent-status-launch-membership'
export type RuntimeWorktreeAgentSource = {
paneKey: string
@@ -19,4 +20,6 @@ export type RuntimeWorktreeAgentSource = {
updatedAt: number
/** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */
structuredHost?: StructuredHostStatus
/** Presence proof from the execution host, independent of turn state. */
launchMembership?: AgentStatusLaunchMembership
}
@@ -51,7 +51,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
stateStartedAt: entry.stateStartedAt,
// 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 } : {})
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}),
...(entry.launchMembership ? { launchMembership: entry.launchMembership } : {})
})
}
const sources: RuntimeWorktreeAgentSource[] = []
@@ -65,6 +66,7 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
// holds the session drops the row itself on close, so its presence is the liveness evidence.
if (
source.structuredHost === undefined &&
source.launchMembership?.phase !== 'committed' &&
tabId !== undefined &&
mirroredWorktreeId === undefined &&
(source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) &&
@@ -79,6 +79,27 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
getSshProvider: (connectionId) => getSshPtyProvider(connectionId),
onPtyStopped: clearProviderPtyState,
onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event),
onAgentSessionCommitted: (commit) => {
agentHookServer.admitAgentSessionOwner({
owner: commit.result.owner,
paneKey: commit.paneKey,
tabId: commit.tabId,
worktreeId: commit.worktreeId,
connectionId: commit.connectionId,
terminalHandle: commit.result.owner.surface.terminalHandle,
agentType: commit.agentType ?? commit.result.owner.claim.agent,
launchToken: commit.launchToken,
disposition: commit.result.disposition
})
},
onAgentSessionInventoryReconciled: (reconciliation) => {
agentHookServer.reconcileAgentLaunchMembership(reconciliation.owners, {
complete: reconciliation.complete,
...(reconciliation.connectionId !== undefined
? { connectionId: reconciliation.connectionId }
: {})
})
},
// Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer.
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
@@ -240,10 +240,11 @@ export function createAgentStatusEventApplicator(args: {
...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {})
},
metadata:
data.providerSession || data.launchToken
data.providerSession || data.launchToken || data.launchMembership
? {
...(data.providerSession ? { providerSession: data.providerSession } : {}),
...(data.launchToken ? { launchToken: data.launchToken } : {})
...(data.launchToken ? { launchToken: data.launchToken } : {}),
...(data.launchMembership ? { launchMembership: data.launchMembership } : {})
}
: undefined
}
@@ -8,6 +8,7 @@ import type {
ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import type { AgentStatusObservation } from '../../../../shared/agent-status-observation'
import type { AgentStatusLaunchMembership } from '../../../../shared/agent-status-launch-membership'
import type {
AgentProviderSessionMetadata,
ResumableTuiAgent,
@@ -115,6 +116,7 @@ export type AgentStatusMetadata = {
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
terminalResumeEligible?: false
launchMembership?: AgentStatusLaunchMembership
}
export type AgentStatusUpdate = {
@@ -6,13 +6,10 @@ import {
type AgentStateHistoryEntry,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import {
agentProviderSessionsEqual,
getAgentResumeArgv,
isResumableTuiAgent,
type AgentProviderSessionMetadata,
type SleepingAgentLaunchConfig,
type SleepingAgentSessionRecord
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig,
SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import {
resolveAgentStatusIdentity,
@@ -25,10 +22,10 @@ import type {
AgentStatusRouting,
AgentStatusTiming
} from './agent-status-contract'
import { registryEntryMatchesStatus } from './agent-status-launch-config'
import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding'
import { findAgentPaneWorktreeId } from './agent-status-pane-key-tab-binding'
import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context'
import { deriveAgentStatusLiveFacts } from './agent-status-live-facts'
import { deriveAgentStatusLiveEntryLaunchContext } from './agent-status-live-entry-launch-context'
export type AgentStatusLiveEntryBuild = {
entry: AgentStatusEntry
@@ -162,61 +159,26 @@ export function buildAgentStatusLiveEntry(
payloadMergedOrchestration ??
runtimeMergedOrchestration ??
(payload.state === 'done' ? existing?.orchestration : undefined)
const canReuseExistingProviderSession =
existing?.agentType === identity.agentType &&
(existing.state !== 'done' || payload.state === 'done')
const providerSession =
metadata?.providerSession ??
(canReuseExistingProviderSession ? existing.providerSession : undefined)
const existingProviderSession = canReuseExistingProviderSession
? existing.providerSession
: undefined
const providerSessionChanged =
Boolean(metadata?.providerSession && existingProviderSession) &&
!agentProviderSessionsEqual(
identity.agentType,
metadata?.providerSession,
existingProviderSession
)
const statusTabId = routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined
const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle
const registryEntry = state.agentLaunchConfigByPaneKey[paneKey]
const registryMatched = registryEntryMatchesStatus({
entry: registryEntry,
const launchContext = deriveAgentStatusLiveEntryLaunchContext({
state,
paneKey,
agentType: identity.agentType,
tabId: statusTabId,
terminalHandle: statusTerminalHandle,
launchToken: metadata?.launchToken,
providerSession,
existingProviderSession,
providerSessionChanged
payload,
existing,
identity,
routing,
metadata
})
const matchedRegistryLaunchConfig = registryMatched ? registryEntry?.launchConfig : undefined
const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey]
const retainsResumableRecoveryIdentity =
payload.state === 'done' &&
isResumableTuiAgent(identity.agentType) &&
providerSession !== undefined &&
getAgentResumeArgv(identity.agentType, providerSession) !== null
const matchedSleepingLaunchConfig =
(payload.state !== 'done' || retainsResumableRecoveryIdentity) &&
existingSleepingRecord?.launchConfig &&
existingSleepingRecord.agent === identity.agentType &&
providerSession &&
agentProviderSessionsEqual(
identity.agentType,
existingSleepingRecord.providerSession,
providerSession
)
? existingSleepingRecord.launchConfig
: undefined
const launchConfigSource =
(payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken
? metadata?.launchConfig
: undefined) ??
matchedRegistryLaunchConfig ??
matchedSleepingLaunchConfig
const {
statusTabId,
statusTerminalHandle,
launchMembership,
registryEntry,
registryMatched,
providerSession,
providerSessionChanged,
retainsResumableRecoveryIdentity,
launchConfigSource
} = launchContext
const entry: AgentStatusEntry = {
state: payload.state,
workingMode: payload.workingMode,
@@ -264,6 +226,7 @@ export function buildAgentStatusLiveEntry(
...(metadata?.terminalResumeEligible === false
? { terminalResumeEligible: false as const }
: {}),
...(launchMembership ? { launchMembership } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1,
@@ -0,0 +1,110 @@
import type { AppState } from '../types'
import {
agentProviderSessionsEqual,
getAgentResumeArgv,
isResumableTuiAgent,
type AgentProviderSessionMetadata,
type SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { AgentStatusEntry, AgentStatusPayload } from './agent-status-contract'
import type { AgentStatusMetadata, AgentStatusRouting } from './agent-status-contract'
import type { resolveAgentStatusIdentity } from '../../../../shared/agent-status-identity'
import { registryEntryMatchesStatus } from './agent-status-launch-config'
import { getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding'
export type AgentStatusLiveEntryLaunchContext = {
statusTabId: string | undefined
statusTerminalHandle: string | undefined
launchMembership: AgentStatusMetadata['launchMembership']
registryEntry: AppState['agentLaunchConfigByPaneKey'][string] | undefined
registryMatched: boolean
providerSession: AgentProviderSessionMetadata | undefined
providerSessionChanged: boolean
retainsResumableRecoveryIdentity: boolean
launchConfigSource: SleepingAgentLaunchConfig | undefined
}
export function deriveAgentStatusLiveEntryLaunchContext(args: {
state: AppState
paneKey: string
payload: AgentStatusPayload
existing: AgentStatusEntry | undefined
identity: ReturnType<typeof resolveAgentStatusIdentity>
routing?: AgentStatusRouting
metadata?: AgentStatusMetadata
}): AgentStatusLiveEntryLaunchContext {
const { state, paneKey, payload, existing, identity, routing, metadata } = args
const statusTabId = routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined
const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle
const launchMembership =
metadata?.launchMembership ??
(existing?.launchMembership &&
routing?.terminalHandle !== undefined &&
routing.terminalHandle === existing.terminalHandle
? existing.launchMembership
: undefined)
const registryEntry = state.agentLaunchConfigByPaneKey[paneKey]
const canReuseExistingProviderSession =
existing?.agentType === identity.agentType &&
(existing.state !== 'done' || payload.state === 'done')
const providerSession =
metadata?.providerSession ??
(canReuseExistingProviderSession ? existing.providerSession : undefined)
const existingProviderSession = canReuseExistingProviderSession
? existing.providerSession
: undefined
const providerSessionChanged =
Boolean(metadata?.providerSession && existingProviderSession) &&
!agentProviderSessionsEqual(
identity.agentType,
metadata?.providerSession,
existingProviderSession
)
const registryMatched = registryEntryMatchesStatus({
entry: registryEntry,
paneKey,
agentType: identity.agentType,
tabId: statusTabId,
terminalHandle: statusTerminalHandle,
launchToken: metadata?.launchToken,
providerSession,
existingProviderSession,
providerSessionChanged
})
const matchedRegistryLaunchConfig = registryMatched ? registryEntry?.launchConfig : undefined
const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey]
const retainsResumableRecoveryIdentity =
payload.state === 'done' &&
isResumableTuiAgent(identity.agentType) &&
providerSession !== undefined &&
getAgentResumeArgv(identity.agentType, providerSession) !== null
const matchedSleepingLaunchConfig =
(payload.state !== 'done' || retainsResumableRecoveryIdentity) &&
existingSleepingRecord?.launchConfig &&
existingSleepingRecord.agent === identity.agentType &&
providerSession &&
agentProviderSessionsEqual(
identity.agentType,
existingSleepingRecord.providerSession,
providerSession
)
? existingSleepingRecord.launchConfig
: undefined
const launchConfigSource =
(payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken
? metadata?.launchConfig
: undefined) ??
matchedRegistryLaunchConfig ??
matchedSleepingLaunchConfig
return {
statusTabId,
statusTerminalHandle,
launchMembership,
registryEntry,
registryMatched,
providerSession,
providerSessionChanged,
retainsResumableRecoveryIdentity,
launchConfigSource
}
}
@@ -1,6 +1,7 @@
import type { ParsedAgentStatusPayload } from '../agent-status-types'
import type { AgentHookSource } from '../agent-hook-relay'
import type { AgentProviderSessionMetadata } from '../agent-session-resume'
import type { AgentStatusLaunchMembership } from '../agent-status-launch-membership'
export type AgentHookEventPayload = {
paneKey: string
@@ -50,6 +51,8 @@ export type AgentHookEventPayload = {
* Lets a reader rejoin the row to its terminal after the pane key moved. Never persisted:
* a handle belongs to the runtime that issued it. */
terminalHandle?: string
/** Host-internal launch admission facet. Provider transports cannot author this field. */
launchMembership?: AgentStatusLaunchMembership
payload: ParsedAgentStatusPayload
}
+3
View File
@@ -15,6 +15,7 @@ import type {
AgentStatusOrchestrationContext,
ParsedAgentStatusPayload
} from './agent-status-types'
import type { AgentStatusLaunchMembership } from './agent-status-launch-membership'
/** A PTY the pane-key migration could not move, reported for operator triage. */
export type MigrationUnsupportedPtyEntry = {
@@ -39,6 +40,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
paneKey: string
launchToken?: string
terminalHandle?: string
/** Host-owned committed/adopted execution membership, independent of turn state. */
launchMembership?: AgentStatusLaunchMembership
tabId?: string
worktreeId?: string
/** Identifies the SSH connection the event arrived on, or null for local.
@@ -0,0 +1,104 @@
/**
* Host-owned membership for an execution that Orca committed or adopted.
*
* Membership is deliberately separate from the legacy status state. A row can
* therefore be present before a provider has emitted a turn observation without
* manufacturing `working`, `waiting`, or completion evidence.
*/
import type { AgentStatusExecutionBinding } from './agent-status-run'
/** C5's canonical binding; C10 stores and projects it but never mints one. */
export type AgentStatusLaunchBinding = AgentStatusExecutionBinding
export type AgentStatusLaunchMembership = {
binding: AgentStatusLaunchBinding
disposition: 'created' | 'adopted'
phase: 'committed' | 'unconfirmed'
committedAt: number
}
const MAX_ID_LENGTH = 128
function isBoundedId(value: unknown): value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_ID_LENGTH ||
value !== value.trim()
) {
return false
}
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x1f || code === 0x7f) {
return false
}
}
return true
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseAgentStatusLaunchBinding(value: unknown): AgentStatusLaunchBinding | null {
if (!isRecord(value)) {
return null
}
const attachment = value.attachment
if (
!isRecord(attachment) ||
!isBoundedId(value.runId) ||
!isBoundedId(attachment.executionId) ||
(value.role !== 'root' && value.role !== 'child')
) {
return null
}
if (value.continuityOf !== undefined && !isBoundedId(value.continuityOf)) {
return null
}
return {
runId: value.runId,
attachment: { executionId: attachment.executionId },
role: value.role,
...(value.continuityOf !== undefined ? { continuityOf: value.continuityOf } : {})
}
}
export function parseAgentStatusLaunchMembership(
value: unknown
): AgentStatusLaunchMembership | null {
if (!isRecord(value)) {
return null
}
const binding = parseAgentStatusLaunchBinding(value.binding)
if (
!binding ||
(value.disposition !== 'created' && value.disposition !== 'adopted') ||
(value.phase !== 'committed' && value.phase !== 'unconfirmed') ||
typeof value.committedAt !== 'number' ||
!Number.isFinite(value.committedAt) ||
value.committedAt <= 0
) {
return null
}
return {
binding,
disposition: value.disposition,
phase: value.phase,
committedAt: value.committedAt
}
}
export function launchMembershipsEqual(
left: AgentStatusLaunchMembership,
right: AgentStatusLaunchMembership
): boolean {
return (
left.binding.runId === right.binding.runId &&
left.binding.attachment.executionId === right.binding.attachment.executionId &&
left.binding.role === right.binding.role &&
left.binding.continuityOf === right.binding.continuityOf
)
}
+3
View File
@@ -5,6 +5,7 @@
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { OrchestrationFleetAttention } from './orchestration-fleet-attention'
import type { AgentStatusRowFacets } from './agent-status-observation'
import type { AgentStatusLaunchMembership } from './agent-status-launch-membership'
import type { TuiAgent } from './tui-agent'
import {
normalizeInteractivePromptField,
@@ -141,6 +142,8 @@ export type AgentStatusEntry = {
interrupted?: boolean
/** True when this `done` is a session boundary, not a completed turn. See AgentStatusPayload. */
sessionBoundary?: boolean
/** Host-owned committed/adopted execution membership; not completion evidence. */
launchMembership?: AgentStatusLaunchMembership
/** Orchestration dispatch context for panes spawned by another agent.
* Why: parent/child hierarchy is pane-level state, not worktree lineage — workers often share the coordinator's worktree. */
orchestration?: AgentStatusOrchestrationContext
+3
View File
@@ -1,4 +1,5 @@
import type { AgentStatusState, AgentType, AgentWorkingMode } from './agent-status-types'
import type { AgentStatusLaunchMembership } from './agent-status-launch-membership'
import type { BaseRefSearchResult, Repo } from './repo-types'
import type { CreateWorktreeResult, RemoveWorktreeResult } from './worktree/create-types'
import type {
@@ -28,6 +29,8 @@ export type RuntimeWorktreeAgentRow = {
/** The structured session host still runs this row's provider child, so it is fresh regardless
* of age. Optional on the wire: old hosts never send it. */
structuredHostOwned?: true
/** Host-owned committed/adopted execution membership; not completion evidence. */
launchMembership?: AgentStatusLaunchMembership
}
export type RuntimeWorktreePsSummary = {