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