diff --git a/AGENT_STATUS_C10_RESULT.md b/AGENT_STATUS_C10_RESULT.md new file mode 100644 index 00000000000..d21077c9886 --- /dev/null +++ b/AGENT_STATUS_C10_RESULT.md @@ -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. diff --git a/src/main/agent-hooks/server-launch-membership.test.ts b/src/main/agent-hooks/server-launch-membership.test.ts new file mode 100644 index 00000000000..339ca4c5f39 --- /dev/null +++ b/src/main/agent-hooks/server-launch-membership.test.ts @@ -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' }) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 3fb3f51f5f1..80ed81a89a3 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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, diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 9f36d676143..25f9e0ef3dd 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -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 } } diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index 70da93b7c3b..fd85ff1a001 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -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. diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 7f2008114f1..d3f6e164e72 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -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: { diff --git a/src/main/agent-hooks/server/server-launch-membership-payload.ts b/src/main/agent-hooks/server/server-launch-membership-payload.ts new file mode 100644 index 00000000000..631117bea03 --- /dev/null +++ b/src/main/agent-hooks/server/server-launch-membership-payload.ts @@ -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 + ) => 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 +} diff --git a/src/main/agent-hooks/server/server-launch-membership.ts b/src/main/agent-hooks/server/server-launch-membership.ts new file mode 100644 index 00000000000..3cf34a23804 --- /dev/null +++ b/src/main/agent-hooks/server/server-launch-membership.ts @@ -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 + ): 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() + 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 + } +} diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index d47fe0cddeb..6afd55bad29 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 1694c4b1b67..7327f58459a 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -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 } } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index fb165ce1c83..d6ecdb93db8 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -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 && diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index b4cf176b176..60876699f89 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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< diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 3dc84906c99..3a03febcd75 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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: () => diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index ec8a3e78941..26a5b8d0c6c 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -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() } diff --git a/src/main/runtime/orca-runtime-finalize-background-terminal.ts b/src/main/runtime/orca-runtime-finalize-background-terminal.ts new file mode 100644 index 00000000000..830c5f97371 --- /dev/null +++ b/src/main/runtime/orca-runtime-finalize-background-terminal.ts @@ -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>> + workspace: Awaited< + ReturnType + > + launchOpts: Awaited< + ReturnType + > + presentation: RuntimeTerminalPresentation | undefined + cwd: string + preAllocatedHandle: string + tabId: string + leafId: string + paneKey: string + launchToken: string | undefined + effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined + reportPtySpawnCommitted: () => void + surfaceOwner?: boolean + } +): Promise { + 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 } : {}) + } +} diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 33b4ace7772..a480e599ef0 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts b/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts index aca67f67ba2..1589ee9cc1b 100644 --- a/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts +++ b/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts @@ -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(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 - surfaceByPtyId: ReturnType - } - >() - 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() const allLivePtyIds = new Set(sessions.map((session) => session.id)) const selectedLivePtyIds = new Set() 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, diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 876de03fe76..38f04ce0833 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index ddff4bc693b..e919fd32471 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -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, diff --git a/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts b/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts new file mode 100644 index 00000000000..1a8015e76ff --- /dev/null +++ b/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts @@ -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]) + }) +}) diff --git a/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts b/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts new file mode 100644 index 00000000000..5a23be1abec --- /dev/null +++ b/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts @@ -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 + knownHostIds: ReadonlySet + 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) + } +} diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts index 67c1b698a1d..fc640300497 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -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 } : {}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts index f9e21d7e323..34a3b97da8e 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -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 + }) }) ) }) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 5ce39d49d6a..533950e6d35 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -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 } : {}) } } } diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index b21fd8bb3ff..5542280ed80 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -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 } } diff --git a/src/main/runtime/runtime-persisted-pty-indexes.ts b/src/main/runtime/runtime-persisted-pty-indexes.ts new file mode 100644 index 00000000000..13da46b5347 --- /dev/null +++ b/src/main/runtime/runtime-persisted-pty-indexes.ts @@ -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 + surfaceByPtyId: ReturnType +} + +export function getPersistedPtyIndexes(args: { + cache: Map + 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 +} diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 227788af7e1..c3ab1492185 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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<{ diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 20c17f9b01a..ee681bc0e58 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -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) diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 984f20e0955..06e972896e7 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -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 } diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 058d378df11..4ab4b5a9504 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -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)) && diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a0a65d54e7a..7c153cd80ce 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -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()) { diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index a4872822252..228d68bcc88 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -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 } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index eaab5bb78ae..ce8d88f7475 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -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 = { diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index b161e51bc04..ede692f153b 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -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, diff --git a/src/renderer/src/store/slices/agent-status-live-entry-launch-context.ts b/src/renderer/src/store/slices/agent-status-live-entry-launch-context.ts new file mode 100644 index 00000000000..114a924d6c2 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-launch-context.ts @@ -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 + 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 + } +} diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 48797d09d8f..9f395cf29ca 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -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 } diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 3bff35272c2..38eb8fe2361 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -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. diff --git a/src/shared/agent-status-launch-membership.ts b/src/shared/agent-status-launch-membership.ts new file mode 100644 index 00000000000..f7add3acb34 --- /dev/null +++ b/src/shared/agent-status-launch-membership.ts @@ -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 { + 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 + ) +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 3a062f7069d..3a77cbf3510 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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 diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index c56bc3a17e6..9d862bf2fce 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -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 = {