mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(native-chat): one ownership index with link roles, and a chat refuses to start while a terminal writes its conversation Each chat link now carries its role (head or earlier) and key; Session History names the chat continuing a conversation first, duplicate adoption checks only head links in both the pre-commit check and the store transaction, and terminal resumes stay refused exactly as before. The terminal-writer check now also runs inside acquireOwner before the spawn, refusing with the typed pre-spawn reason conversationOpenInTerminal. * fix(native-chat): hold a terminal start Orca issues until its terminal reports it An in-memory registry of managed terminal starts per conversation, registered in the same step as the resume admission (terminal.send with a resume command, createTerminal with a command), bound to the PTY execution that runs it, and ended only when that terminal's hooks report the conversation or the execution is gone. A chat's agent start refuses while an entry may hold its conversation. * fix(terminal): an automatic pane restore never resumes a conversation an open chat continues Resume recipes built from a pane's saved facts (cold restore, deferred and late reattach, sleeping wake, host mobile materialization) carry resumeOrigin 'saved'. The execution host decides before any command is issued: while an open chat continues the conversation, or a chat may be writing it, the pane starts its plain shell with the started-fresh notice and its saved claim is forgotten; with unreadable records only a saved restore is refused and its record kept. An admitted resume registers its managed start in the same step, so the chat's agent start refuses until the terminal reports it or exits. Clearing the last saved record now reaches disk. * Fix saved terminal resume ownership and effective launch results * Preserve conversation case and clear refused cold restore identity * fix: publish effective terminal launch results on attachment * fix: preserve terminal launch callback and candidate ownership * fix: keep existing terminal attachments observational * fix: consolidate terminal launch imports * test: document snapshot runtime fixture boundary * fix: preserve terminal launch identity types * fix: accept reattach payload policy sessions * fix: preserve terminal result validation contracts * refactor: separate terminal create contract * test: document partial terminal runtime fixtures * test: align sleeping resume store fixture * Fix terminal create type contracts * fix: return the terminal main started to its waiting view * fix: register reattached terminals before publishing their handles * Check unavailable-resume test fixture types * Complete sidebar resume test store fixture * test: bound saved resume ownership and refusal work * Settle refused saved resume claims from actual launch results * Fix saved resume test type contracts * test(e2e): capture exact terminal parking diagnostics * test: verify parked terminal with fresh host inventory * Consolidate agent session imports in remote terminal transport * Clarify terminal conversation refusal guidance * Admit note-only saved conversations without hiding live writers * Retain terminal writer proofs through final admission
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import {
|
|
suppressSavedResumeLaunch,
|
|
captureSavedResumeCleanup,
|
|
chooseSavedResumeFreshLaunch,
|
|
type ResumeSpawnAdmission,
|
|
resumeSpawnRefusal,
|
|
SavedResumeFreshRefusal
|
|
} from '../host-env/saved-resume-admission'
|
|
import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority'
|
|
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
|
|
import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
|
import { ptyIncarnationById } from '../provider/ownership-state'
|
|
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
|
|
import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate'
|
|
import {
|
|
agentSessionOwners,
|
|
assertSpawnReplyWasLive,
|
|
reconcileAgentSessionOwnerListings
|
|
} from '../pane/agent-session-owners'
|
|
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
|
import {
|
|
assertPaneStillExists,
|
|
excludeAbsentOwnerReplay,
|
|
PaneOwnerContentionError
|
|
} from '../pane/pane-spawn-operation'
|
|
import { isRegisteredAgentSessionOwnerLive } from '../provider/liveness'
|
|
import { failPtySpawnExecute } from '../spawn-execute-failure'
|
|
import type { RuntimePtySpawnState } from './spawn-state'
|
|
import { markRuntimeSpawnHiddenBeforeSpawn } from './spawn-hidden-delivery'
|
|
|
|
export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise<void> {
|
|
const args = ctx.args
|
|
const captured: ResumeSpawnAdmission | null =
|
|
args.launchAgent && args.resumeProviderSession
|
|
? {
|
|
verdict: 'admitted',
|
|
refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession }
|
|
}
|
|
: null
|
|
if (args.resumeOrigin === 'saved') {
|
|
await captureSavedResumeCleanup(
|
|
ctx.deps.runtime,
|
|
ctx.deps.store,
|
|
ctx.spawnIdentityPaneKey,
|
|
captured,
|
|
args.worktreeId
|
|
)
|
|
}
|
|
const chooseFresh = async (): Promise<void> => {
|
|
ctx.spawnOptions.env = { ...ctx.spawnOptions.env }
|
|
ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(
|
|
ctx.deps.runtime,
|
|
args,
|
|
ctx.expectedWslDistro,
|
|
ctx.spawnOptions
|
|
)
|
|
if (captured && ctx.resumeAdmission) {
|
|
ctx.resumeAdmission.refused = captured.refused
|
|
ctx.resumeAdmission.sourceRecord = captured.sourceRecord
|
|
}
|
|
if (resumeSpawnRefusal(ctx.resumeAdmission)) {
|
|
ctx.launchCommand = undefined
|
|
ctx.env = ctx.spawnOptions.env
|
|
}
|
|
}
|
|
const runtime = ctx.deps.runtime
|
|
const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn
|
|
ctx.releaseWorktreeSpawn = acquireWorktreeSpawn
|
|
? await acquireWorktreeSpawn.call(
|
|
runtime,
|
|
spawnWorktreeTarget(args.worktreeId, args.connectionId),
|
|
{
|
|
refuseSleptWorktree: args.refuseSleptWorktree,
|
|
// A pre-adopted stable pane attaches to its live owner, not a new terminal.
|
|
reattachSessionId: ctx.preAdoptedStablePane?.owner.ptyId ?? args.sessionId,
|
|
requireAttachOnly: () => {
|
|
ctx.spawnOptions = { ...ctx.spawnOptions, attachOnly: true }
|
|
}
|
|
}
|
|
)
|
|
: undefined
|
|
let awaitingAnotherStart = false
|
|
try {
|
|
if (args.preAllocatedHandle) {
|
|
ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle)
|
|
}
|
|
const stablePaneOwnerCandidate = ctx.preAdoptedStablePane
|
|
? ctx.preAdoptedStablePane.owner
|
|
: args.agentSessionEnsure
|
|
? null
|
|
: excludeAbsentOwnerReplay(
|
|
resolveStablePaneOwner(
|
|
ctx.deps.runtime,
|
|
ctx.deps.store,
|
|
ctx.spawnIdentityPaneKey,
|
|
args.worktreeId,
|
|
args.connectionId
|
|
),
|
|
args.paneSpawnOperation?.absentOwner
|
|
)
|
|
if (stablePaneOwnerCandidate) {
|
|
throw new PaneOwnerContentionError()
|
|
}
|
|
if (args.paneSpawnOperation?.mustExist && args.worktreeId && args.tabId && args.leafId) {
|
|
assertPaneStillExists(
|
|
ctx.deps.store,
|
|
args.worktreeId,
|
|
args.tabId,
|
|
args.leafId,
|
|
args.connectionId
|
|
)
|
|
}
|
|
const expectedPtyId = ctx.effectiveSessionAppId ?? ctx.sessionId
|
|
if (!stablePaneOwnerCandidate) {
|
|
markRuntimeSpawnHiddenBeforeSpawn(ctx)
|
|
}
|
|
if (expectedPtyId) {
|
|
ctx.pendingRegistrationClaim = ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId)
|
|
ctx.pendingRegistrationPtyId = expectedPtyId
|
|
}
|
|
if (ctx.isDaemonHostSpawn && expectedPtyId) {
|
|
ctx.preparedProvisionalExecutionContext =
|
|
ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, {
|
|
resetIncarnation: ctx.isNewDaemonSession && !stablePaneOwnerCandidate,
|
|
preserveExisting: !ctx.isNewDaemonSession || Boolean(stablePaneOwnerCandidate)
|
|
}) ?? false
|
|
}
|
|
const sequenceBeforeProviderSpawn = expectedPtyId
|
|
? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
|
|
: 0
|
|
const assertClientStillConnected = (): void => {
|
|
if (args.signal?.aborted) {
|
|
throw new Error('client_disconnected')
|
|
}
|
|
}
|
|
if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) {
|
|
// Claims can outlive this controller; import authoritative owner listings before deciding absence.
|
|
await reconcileAgentSessionOwnerListings()
|
|
const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim)
|
|
if (recoveredOwner && ctx.pendingRegistrationPtyId !== recoveredOwner.ptyId) {
|
|
if (ctx.pendingRegistrationPtyId) {
|
|
ctx.deps.runtime?.cancelPendingPtyRegistration?.(
|
|
ctx.pendingRegistrationPtyId,
|
|
undefined,
|
|
ctx.pendingRegistrationClaim
|
|
)
|
|
}
|
|
ctx.pendingRegistrationClaim = ctx.deps.runtime?.beginPtyRegistration?.(
|
|
recoveredOwner.ptyId,
|
|
ptyIncarnationById.get(recoveredOwner.ptyId)
|
|
)
|
|
ctx.pendingRegistrationPtyId = recoveredOwner.ptyId
|
|
}
|
|
let providerResult: PtySpawnResult | null = null
|
|
let ensured: AgentSessionClaimedSpawnResult | undefined
|
|
try {
|
|
ensured = await agentSessionOwners.ensure({
|
|
claim: args.agentSessionEnsure.claim,
|
|
surface: args.agentSessionEnsure.surface,
|
|
spawn: async () => {
|
|
assertClientStillConnected()
|
|
await chooseFresh()
|
|
const refusal = resumeSpawnRefusal(ctx.resumeAdmission)
|
|
if (refusal) {
|
|
throw new SavedResumeFreshRefusal(refusal)
|
|
}
|
|
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
|
|
ctx.rejectedRegistrationCandidate = providerResult
|
|
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
|
|
ctx.reportPtySpawnCommitted()
|
|
assertSpawnReplyWasLive(providerResult)
|
|
ctx.deps.runtime?.assertPtyRegistrationAllowed?.(
|
|
providerResult.id,
|
|
providerResult.incarnationId
|
|
)
|
|
if (providerResult.incarnationId) {
|
|
// Why: local providers cannot serialize controller claims, so liveness proof
|
|
// needs the exact incarnation before the registry promotes the new owner.
|
|
ptyIncarnationById.set(providerResult.id, providerResult.incarnationId)
|
|
}
|
|
const providerEnsure = providerResult.agentSessionEnsure
|
|
return {
|
|
ptyId: providerResult.id,
|
|
...(providerEnsure
|
|
? {
|
|
owner: providerEnsure.owner,
|
|
disposition: providerEnsure.disposition
|
|
}
|
|
: {})
|
|
}
|
|
},
|
|
isLive: isRegisteredAgentSessionOwnerLive,
|
|
onJoin: () => {
|
|
awaitingAnotherStart = true
|
|
}
|
|
})
|
|
} catch (error) {
|
|
if (!(error instanceof SavedResumeFreshRefusal)) {
|
|
throw error
|
|
}
|
|
// The claim reservation rejected before spawn; this shell must never become its owner.
|
|
ctx.resumeAdmission = {
|
|
...error.admission,
|
|
refused: captured?.refused ?? error.admission.refused,
|
|
sourceRecord: captured?.sourceRecord
|
|
}
|
|
suppressSavedResumeLaunch(args, ctx.spawnOptions)
|
|
ctx.launchCommand = undefined
|
|
ctx.env = ctx.spawnOptions.env
|
|
delete args.agentSessionEnsure
|
|
ctx.spawnOptions.agentSessionEnsure = undefined
|
|
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
|
|
ctx.reportPtySpawnCommitted()
|
|
ctx.stablePaneOwner = null
|
|
}
|
|
awaitingAnotherStart = false
|
|
if (providerResult) {
|
|
ctx.result = providerResult
|
|
} else if (ensured) {
|
|
ctx.result = {
|
|
id: ensured.owner.ptyId,
|
|
isReattach: true,
|
|
// A listing's incarnation rejects delayed exits from an older process.
|
|
incarnationId: ptyIncarnationById.get(ensured.owner.ptyId)
|
|
}
|
|
} else {
|
|
throw new Error('agent_session_owner_missing')
|
|
}
|
|
if (ensured) {
|
|
ctx.result.agentSessionEnsure = ensured
|
|
}
|
|
} else {
|
|
assertClientStillConnected()
|
|
await chooseFresh()
|
|
ctx.result = await ctx.provider.spawn(ctx.spawnOptions)
|
|
ctx.reportPtySpawnCommitted()
|
|
ctx.stablePaneOwner = null
|
|
ctx.rejectedRegistrationCandidate = ctx.result
|
|
}
|
|
if (ctx.result.isReattach || ctx.result.agentSessionEnsure?.disposition === 'adopted') {
|
|
ctx.result.isReattach = true
|
|
ctx.resumeAdmission = null
|
|
}
|
|
if (ctx.result.isReattach) {
|
|
Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id))
|
|
}
|
|
ctx.rejectedRegistrationCandidate ??= ctx.result
|
|
if (ctx.pendingRegistrationPtyId !== ctx.result.id) {
|
|
if (ctx.pendingRegistrationPtyId) {
|
|
ctx.deps.runtime?.cancelPendingPtyRegistration?.(
|
|
ctx.pendingRegistrationPtyId,
|
|
undefined,
|
|
ctx.pendingRegistrationClaim
|
|
)
|
|
}
|
|
ctx.pendingRegistrationClaim = ctx.deps.runtime?.beginPtyRegistration?.(
|
|
ctx.result.id,
|
|
ctx.result.incarnationId
|
|
)
|
|
ctx.pendingRegistrationPtyId = ctx.result.id
|
|
}
|
|
assertSpawnReplyWasLive(ctx.result)
|
|
// Admit registration before mutating sequence/context or publishing durable state.
|
|
if (
|
|
ctx.pendingRegistrationClaim &&
|
|
!ctx.deps.runtime?.narrowPtyRegistration(
|
|
ctx.result.id,
|
|
ctx.pendingRegistrationClaim,
|
|
ctx.result.incarnationId
|
|
)
|
|
) {
|
|
throw new PaneOwnerContentionError()
|
|
}
|
|
ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId)
|
|
if (ctx.result.providerSequence) {
|
|
const runtimeSequenceBeforeReconcile =
|
|
ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0
|
|
// Preserve the renderer-domain boundary needed to reconcile daemon snapshot kitty flags.
|
|
ctx.reconciledSnapshotSeq =
|
|
ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
|
ctx.result.id,
|
|
ctx.result.providerSequence,
|
|
sequenceBeforeProviderSpawn
|
|
) ?? null
|
|
if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) {
|
|
ctx.snapshotKittyFlagsCoverReconciledSeq = false
|
|
}
|
|
}
|
|
ensureWslHookRelayForReattach(ctx.result)
|
|
const spawnedWslDistro =
|
|
ctx.result.wslDistro === undefined ? ctx.expectedWslDistro : ctx.result.wslDistro
|
|
if (spawnedWslDistro && !args.connectionId) {
|
|
// A local WSL terminal is user-initiated, so background work held for this distro may run.
|
|
noteWslDistrosRunning([spawnedWslDistro])
|
|
}
|
|
ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.result.id, spawnedWslDistro)
|
|
} catch (err) {
|
|
throw failPtySpawnExecute(ctx, err, {
|
|
sessionId: ctx.sessionId,
|
|
ownsNewSession: ctx.isNewDaemonSession,
|
|
telemetry: args.telemetry,
|
|
joinedAnotherStart: awaitingAnotherStart
|
|
})
|
|
} finally {
|
|
if (args.preAllocatedHandle) {
|
|
ctx.deps.trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
|
|
}
|
|
}
|
|
}
|