mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into aug23-sta5269-pty-owner-identity
# Conflicts: # src/main/daemon/daemon-pty-adapter.ts # src/main/daemon/daemon-server.ts
This commit is contained in:
@@ -21,21 +21,16 @@ inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/memory/collector.ts
|
||||
@@ -87,9 +82,6 @@ inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
|
||||
@@ -8,13 +8,17 @@
|
||||
* the host Node floor at 18 instead of 22.5+.
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, copyFileSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { arch, platform } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const OUT_DIR = join(ROOT, 'out', 'orcad')
|
||||
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
|
||||
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
|
||||
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
|
||||
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
|
||||
|
||||
// Native addons must exist on the host; they cannot be bundled.
|
||||
// `electron` is external so a residual import fails loudly at require() time rather
|
||||
@@ -48,6 +52,10 @@ const externalNativeAddons = {
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
|
||||
}
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
@@ -78,12 +86,34 @@ for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const sqliteImporters = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
const specifier = imported.original ?? imported.path
|
||||
if (specifier === 'node:sqlite') {
|
||||
sqliteImporters.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphErrors = []
|
||||
if (electronImporters.size > 0) {
|
||||
console.error(
|
||||
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
|
||||
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
|
||||
graphErrors.push(
|
||||
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
if (sqliteImporters.size > 0) {
|
||||
graphErrors.push(
|
||||
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (graphErrors.length > 0) {
|
||||
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
|
||||
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
|
||||
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
|
||||
// install the PTY controller. Once orcad ships, it should become a ratchet entry
|
||||
@@ -91,6 +121,6 @@ ${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
* - a paired client can list worktrees and create a terminal,
|
||||
* - a command run in that terminal produces its output,
|
||||
* - the server exits when asked.
|
||||
* Optional `--browser` assertions:
|
||||
* - create, navigate, evaluate, and screenshot through the selected host provider.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
|
||||
@@ -274,6 +277,50 @@ async function main() {
|
||||
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
|
||||
}
|
||||
log(`server resolves ${shown.id}`)
|
||||
if (process.argv.includes('--browser')) {
|
||||
const status = orca(pairingCode, ['status'])
|
||||
if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
|
||||
throw new Error(
|
||||
`status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
|
||||
)
|
||||
}
|
||||
const fixturePath = join(userDataDir, 'browser-smoke.html')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
'<!doctype html><title>Orcad Browser Smoke</title><main>browser-ready</main>'
|
||||
)
|
||||
const browserPageId = orca(pairingCode, [
|
||||
'tab',
|
||||
'create',
|
||||
'--worktree',
|
||||
created.id,
|
||||
'--url',
|
||||
'about:blank'
|
||||
])?.browserPageId
|
||||
if (!browserPageId) {
|
||||
throw new Error('browser.tabCreate returned no browser page id')
|
||||
}
|
||||
const targetFlags = ['--worktree', created.id, '--page', browserPageId]
|
||||
const targetUrl = pathToFileURL(fixturePath).href
|
||||
const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
|
||||
if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
|
||||
throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
|
||||
}
|
||||
const evaluated = orca(pairingCode, [
|
||||
'eval',
|
||||
...targetFlags,
|
||||
'--expression',
|
||||
'document.querySelector("main")?.textContent'
|
||||
])
|
||||
if (evaluated?.result !== 'browser-ready') {
|
||||
throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
|
||||
}
|
||||
const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
|
||||
if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
|
||||
throw new Error('browser.screenshot returned no PNG data')
|
||||
}
|
||||
log('browser navigate/evaluate/screenshot round trip OK')
|
||||
}
|
||||
|
||||
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
|
||||
if (!terminal?.handle) {
|
||||
|
||||
@@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => {
|
||||
)
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a typed close code when transport error precedes close', () => {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 4409 })
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' }))
|
||||
})
|
||||
|
||||
it('classifies an opaque close after transport error as 1006', () => {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 0 })
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
|
||||
})
|
||||
|
||||
it('bounds an error when the platform never emits close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
|
||||
link.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the missing-close timer when explicitly closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onerror?.()
|
||||
link.close()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
// Native WebSockets normally emit close immediately after error; bound the
|
||||
// missing-close case so a dead socket cannot leave recovery pending forever.
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
export class RelayOuterError extends Error {
|
||||
constructor(readonly code: number) {
|
||||
super(`relay_outer_${code}`)
|
||||
@@ -32,6 +36,7 @@ export class MobileRelayE2eeLink {
|
||||
private readonly channel: MobileE2EEV2PhysicalChannel
|
||||
private outerReady = false
|
||||
private closed = false
|
||||
private transportErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private inboundChain: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(options: MobileRelayE2eeLinkOptions) {
|
||||
@@ -70,6 +75,10 @@ export class MobileRelayE2eeLink {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.channel.dispose()
|
||||
this.socket.close()
|
||||
}
|
||||
@@ -103,8 +112,21 @@ export class MobileRelayE2eeLink {
|
||||
})
|
||||
.catch((error: unknown) => this.fail(asError(error)))
|
||||
}
|
||||
this.socket.onerror = () => this.fail(new Error('relay transport error'))
|
||||
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
|
||||
// `error` is often delivered just before `close`; wait for close so a
|
||||
// typed relay code is not replaced by a generic transport error.
|
||||
this.socket.onerror = () => {
|
||||
this.transportErrorTimer ??= setTimeout(() => {
|
||||
this.transportErrorTimer = null
|
||||
this.fail(new RelayOuterError(1006))
|
||||
}, RELAY_ERROR_CLOSE_GRACE_MS)
|
||||
}
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.fail(new RelayOuterError(event.code || 1006))
|
||||
}
|
||||
}
|
||||
|
||||
private acceptHello(raw: unknown): void {
|
||||
@@ -137,6 +159,10 @@ export class MobileRelayE2eeLink {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.channel.dispose()
|
||||
this.options.onError(error)
|
||||
this.socket.close()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
|
||||
import {
|
||||
RelayDirectorMoveNotNewerError,
|
||||
resolvePairingInviteThroughDirector
|
||||
} from './mobile-relay-invite-director'
|
||||
|
||||
class FakeSocket {
|
||||
sent: string[] = []
|
||||
@@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects same/older epochs and untrusted extra fields', async () => {
|
||||
it('rejects malformed moves with untrusted extra fields', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const resolving = resolvePairingInviteThroughDirector({
|
||||
relay,
|
||||
@@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(resolving).rejects.toThrow(/not strictly newer/)
|
||||
await expect(resolving).rejects.toThrow(/invalid relay director move/)
|
||||
})
|
||||
|
||||
it('describes a same-epoch move without accepting it', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const resolving = resolvePairingInviteThroughDirector({
|
||||
relay,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'relay-moved',
|
||||
v: 1,
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
|
||||
const error = await resolving.catch((value: unknown) => value)
|
||||
expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError)
|
||||
expect(error).toMatchObject({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
|
||||
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
|
||||
|
||||
export class RelayDirectorMoveNotNewerError extends Error {
|
||||
readonly cellUrl: string
|
||||
readonly assignmentEpoch: number
|
||||
readonly currentCellUrl: string
|
||||
readonly currentAssignmentEpoch: number
|
||||
|
||||
constructor(args: {
|
||||
cellUrl: string
|
||||
assignmentEpoch: number
|
||||
currentCellUrl: string
|
||||
currentAssignmentEpoch: number
|
||||
}) {
|
||||
super('relay director move was not strictly newer')
|
||||
this.name = 'RelayDirectorMoveNotNewerError'
|
||||
this.cellUrl = args.cellUrl
|
||||
this.assignmentEpoch = args.assignmentEpoch
|
||||
this.currentCellUrl = args.currentCellUrl
|
||||
this.currentAssignmentEpoch = args.currentAssignmentEpoch
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePairingInviteThroughDirector(args: {
|
||||
relay: PairingRelay
|
||||
timeoutMs?: number
|
||||
@@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: {
|
||||
return
|
||||
}
|
||||
const moved = RelayMovedSchema.safeParse(value)
|
||||
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
|
||||
finish(new Error('relay director move was not strictly newer'))
|
||||
if (!moved.success) {
|
||||
finish(new Error('invalid relay director move'))
|
||||
return
|
||||
}
|
||||
if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
|
||||
finish(
|
||||
new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: moved.data.cellUrl,
|
||||
assignmentEpoch: moved.data.assignmentEpoch,
|
||||
currentCellUrl: args.relay.cellUrl,
|
||||
currentAssignmentEpoch: args.relay.assignmentEpoch
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
|
||||
@@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => {
|
||||
expect(fakes.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the typed close code when transport error precedes close', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 4409 })
|
||||
|
||||
await expect(status).rejects.toEqual(new RelayOuterError(4409))
|
||||
})
|
||||
|
||||
it('classifies an opaque close after transport error as 1006', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 0 })
|
||||
|
||||
await expect(status).rejects.toEqual(new RelayOuterError(1006))
|
||||
})
|
||||
|
||||
it('settles after an error when the platform never emits close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006))
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
|
||||
@@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
export { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
@@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: {
|
||||
let requestCounter = 0
|
||||
let closed = false
|
||||
let intentionallyClosed = false
|
||||
let transportErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let outerReady = false
|
||||
let authenticated = false
|
||||
let resolveAuthenticated!: () => void
|
||||
@@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: {
|
||||
})
|
||||
.catch((error: unknown) => fail(asError(error)))
|
||||
}
|
||||
socket.onerror = () => fail(new Error('relay transport error'))
|
||||
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
|
||||
// WebSocket implementations commonly emit `error` immediately before
|
||||
// `close`; the bounded fallback represents an opaque 1006 close.
|
||||
socket.onerror = () => {
|
||||
transportErrorTimer ??= setTimeout(() => {
|
||||
transportErrorTimer = null
|
||||
fail(new RelayOuterError(1006))
|
||||
}, RELAY_ERROR_CLOSE_GRACE_MS)
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (transportErrorTimer) {
|
||||
clearTimeout(transportErrorTimer)
|
||||
transportErrorTimer = null
|
||||
}
|
||||
fail(new RelayOuterError(event.code || 1006))
|
||||
}
|
||||
|
||||
function acceptRelayHello(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
@@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
if (transportErrorTimer) {
|
||||
clearTimeout(transportErrorTimer)
|
||||
transportErrorTimer = null
|
||||
}
|
||||
if (intentionallyClosed) {
|
||||
log('info', 'Relay: pairing socket closed', cellHost)
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PairingCandidateClient } from './mobile-relay-physical-client'
|
||||
import { RelayOuterError } from './mobile-relay-physical-client'
|
||||
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
|
||||
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
@@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => {
|
||||
expect(stale.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries the authoritative cell when the director confirms the same assignment', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const target = client(Promise.resolve(success()))
|
||||
const persistMove = vi.fn()
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: (relay) => {
|
||||
expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl)
|
||||
expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch)
|
||||
return connects++ === 0 ? stale : target
|
||||
},
|
||||
resolveDirector: async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
},
|
||||
persistMove,
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {}
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
|
||||
expect(connects).toBe(2)
|
||||
expect(persistMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps recovery bounded when the authoritative-cell constructor throws', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const target = client(Promise.resolve(success()))
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: () => {
|
||||
connects++
|
||||
if (connects === 1) {
|
||||
return stale
|
||||
}
|
||||
if (connects === 2) {
|
||||
throw new Error('relay constructor failed')
|
||||
}
|
||||
return target
|
||||
},
|
||||
resolveDirector: async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
},
|
||||
persistMove: vi.fn(),
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
onLog: (entry) => entries.push(entry)
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
|
||||
expect(connects).toBe(3)
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: 'warn',
|
||||
message: 'Relay: recovery attempt 1 failed',
|
||||
detail: 'Error: relay constructor failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry a same-epoch move to a different cell', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const resolveDirector = vi.fn(async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: 'https://relay-c2.onorca.dev',
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: () => {
|
||||
connects++
|
||||
return stale
|
||||
},
|
||||
resolveDirector,
|
||||
persistMove: vi.fn(),
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {}
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/)
|
||||
expect(resolveDirector).toHaveBeenCalledTimes(3)
|
||||
expect(connects).toBe(1)
|
||||
})
|
||||
|
||||
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
|
||||
const offline = client(Promise.reject(new RelayOuterError(4404)))
|
||||
const resolveDirector = vi.fn()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
|
||||
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
|
||||
import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogSink } from './types'
|
||||
@@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: {
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (isCurrentAssignmentMove(error, relay)) {
|
||||
log(
|
||||
'info',
|
||||
'Relay: director kept current assignment',
|
||||
redactSocketEndpoint(relay.cellUrl)
|
||||
)
|
||||
client.close()
|
||||
await backOff(attempt)
|
||||
if (closed) {
|
||||
throw new Error('relay pairing client closed')
|
||||
}
|
||||
try {
|
||||
client = args.connect(relay, args.onLog)
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (retryError) {
|
||||
lastError = retryError
|
||||
log(
|
||||
'warn',
|
||||
`Relay: recovery attempt ${attempt + 1} failed`,
|
||||
pairingRelayErrorDetail(retryError)
|
||||
)
|
||||
if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) {
|
||||
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
|
||||
throw retryError
|
||||
}
|
||||
await backOff(attempt)
|
||||
continue
|
||||
}
|
||||
}
|
||||
log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error))
|
||||
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
|
||||
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
|
||||
@@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean {
|
||||
return (
|
||||
error instanceof RelayDirectorMoveNotNewerError &&
|
||||
error.assignmentEpoch === relay.assignmentEpoch &&
|
||||
error.cellUrl === relay.cellUrl &&
|
||||
error.currentAssignmentEpoch === relay.assignmentEpoch &&
|
||||
error.currentCellUrl === relay.cellUrl
|
||||
)
|
||||
}
|
||||
|
||||
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
|
||||
return {
|
||||
...journal.metadata.relay,
|
||||
|
||||
@@ -172,7 +172,8 @@ export class RuntimeClient {
|
||||
...(response.result.remoteUpdateSupport
|
||||
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
|
||||
: {}),
|
||||
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {})
|
||||
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}),
|
||||
...(response.result.degradations ? { degradations: response.result.degradations } : {})
|
||||
},
|
||||
graph: {
|
||||
state: graphState
|
||||
|
||||
@@ -47,7 +47,14 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0
|
||||
liveTabCount: 0,
|
||||
degradations: [
|
||||
{
|
||||
code: 'browser_unavailable',
|
||||
capability: 'browser.headless.v1',
|
||||
message: 'Browser automation is unavailable.'
|
||||
}
|
||||
]
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-legacy' }
|
||||
})}\n`
|
||||
@@ -72,7 +79,8 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
|
||||
expect(status.result.runtime).toMatchObject({
|
||||
reachable: true,
|
||||
runtimeId: 'runtime-legacy',
|
||||
state: 'ready'
|
||||
state: 'ready',
|
||||
degradations: [expect.objectContaining({ code: 'browser_unavailable' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,7 +56,8 @@ export async function getCliStatus(
|
||||
...(response.result.remoteUpdateSupport
|
||||
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
|
||||
: {}),
|
||||
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {})
|
||||
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}),
|
||||
...(response.result.degradations ? { degradations: response.result.degradations } : {})
|
||||
},
|
||||
graph: {
|
||||
state: graphState
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import {
|
||||
decrypt,
|
||||
deriveSharedKey,
|
||||
@@ -90,7 +91,14 @@ describe('CLI remote WebSocket transport', () => {
|
||||
automatic: false,
|
||||
reason: 'manual-service-update-required'
|
||||
},
|
||||
capabilities: ['updater.remote-control.v1']
|
||||
capabilities: ['updater.remote-control.v1'],
|
||||
degradations: [
|
||||
{
|
||||
code: 'browser_unavailable',
|
||||
capability: 'browser.headless.v1',
|
||||
message: 'Browser automation is unavailable.'
|
||||
}
|
||||
]
|
||||
})
|
||||
servers.push(runtime)
|
||||
const offer: PairingOffer = {
|
||||
@@ -113,7 +121,8 @@ describe('CLI remote WebSocket transport', () => {
|
||||
expect(status.result.runtime).toMatchObject({
|
||||
appVersion: '1.5.0',
|
||||
remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' },
|
||||
capabilities: ['updater.remote-control.v1']
|
||||
capabilities: ['updater.remote-control.v1'],
|
||||
degradations: [expect.objectContaining({ code: 'browser_unavailable' })]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -243,6 +252,7 @@ async function startTestRuntime(
|
||||
reason: 'manual-service-update-required'
|
||||
}
|
||||
capabilities?: string[]
|
||||
degradations?: RuntimeStatus['degradations']
|
||||
} = {}
|
||||
): Promise<TestRuntime> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
@@ -314,7 +324,8 @@ async function startTestRuntime(
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
appVersion: statusOverrides.appVersion,
|
||||
remoteUpdateSupport: statusOverrides.remoteUpdateSupport,
|
||||
capabilities: statusOverrides.capabilities
|
||||
capabilities: statusOverrides.capabilities,
|
||||
degradations: statusOverrides.degradations
|
||||
},
|
||||
_meta: { runtimeId }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import { createNdjsonParser, encodeNdjson } from './ndjson'
|
||||
import type { DaemonRequest, HelloMessage } from './types'
|
||||
|
||||
export type ConnectedDaemonClient = {
|
||||
clientId: string
|
||||
controlSocket: Socket
|
||||
streamSocket: Socket | null
|
||||
authenticatedPairEstablished: boolean
|
||||
}
|
||||
|
||||
type DaemonClientConnectionOptions = {
|
||||
token: string
|
||||
protocolVersion: number
|
||||
identity: {
|
||||
launchNonce: string | null
|
||||
startedAtMs: number | null
|
||||
entryPath: string | null
|
||||
appVersion: string | null
|
||||
spawnerExecPath: string | null
|
||||
}
|
||||
log: DaemonFileLog
|
||||
streamDataBatcher: DaemonStreamDataBatcher
|
||||
isAcceptingWork: () => boolean
|
||||
onTransportChanged: () => void
|
||||
onConnectionAccepted: () => void
|
||||
onAuthenticatedPair: () => void
|
||||
onLastAuthenticatedClientDisconnected: () => void
|
||||
onControlRequest: (socket: Socket, clientId: string, request: DaemonRequest) => void
|
||||
onControlReplaced: (clientId: string) => void
|
||||
onClientDisconnected: (clientId: string) => void
|
||||
onStreamDisconnected: (clientId: string) => void
|
||||
}
|
||||
|
||||
export class DaemonClientConnections {
|
||||
private readonly clients = new Map<string, ConnectedDaemonClient>()
|
||||
private readonly transportSockets = new Set<Socket>()
|
||||
|
||||
constructor(private readonly options: DaemonClientConnectionOptions) {}
|
||||
|
||||
accept(socket: Socket): void {
|
||||
this.options.onConnectionAccepted()
|
||||
this.transportSockets.add(socket)
|
||||
socket.once('close', () => {
|
||||
this.transportSockets.delete(socket)
|
||||
this.options.onTransportChanged()
|
||||
})
|
||||
socket.on('error', () => socket.destroy())
|
||||
|
||||
if (!this.options.isAcceptingWork()) {
|
||||
socket.end(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
ok: false,
|
||||
error: 'Daemon temporarily unavailable; reconnect',
|
||||
retryable: true
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
const decoder = new StringDecoder('utf8')
|
||||
const parser = createNdjsonParser(
|
||||
(message) => this.handleFirstMessage(socket, message),
|
||||
() => socket.destroy()
|
||||
)
|
||||
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
|
||||
}
|
||||
|
||||
get(clientId: string): ConnectedDaemonClient | undefined {
|
||||
return this.clients.get(clientId)
|
||||
}
|
||||
|
||||
values(): IterableIterator<ConnectedDaemonClient> {
|
||||
return this.clients.values()
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.clients.size
|
||||
}
|
||||
|
||||
get transportCount(): number {
|
||||
return this.transportSockets.size
|
||||
}
|
||||
|
||||
hasOnlyTransportsFor(client: ConnectedDaemonClient): boolean {
|
||||
return [...this.transportSockets].every(
|
||||
(transport) => transport === client.controlSocket || transport === client.streamSocket
|
||||
)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.controlSocket.destroy()
|
||||
client.streamSocket?.destroy()
|
||||
}
|
||||
this.clients.clear()
|
||||
for (const socket of this.transportSockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.transportSockets.clear()
|
||||
}
|
||||
|
||||
private handleFirstMessage(socket: Socket, message: unknown): void {
|
||||
const hello = message as HelloMessage
|
||||
if (hello.type !== 'hello') {
|
||||
this.options.log.log('client-hello-rejected', { reason: 'expected-hello' })
|
||||
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' }))
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
if (hello.version !== this.options.protocolVersion) {
|
||||
this.options.log.log('client-hello-rejected', {
|
||||
reason: 'protocol-mismatch',
|
||||
clientVersion: hello.version
|
||||
})
|
||||
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' }))
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
if (hello.token !== this.options.token) {
|
||||
this.options.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role })
|
||||
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' }))
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
if (hello.role !== 'control' && hello.role !== 'stream') {
|
||||
this.options.log.log('client-hello-rejected', {
|
||||
reason: 'invalid-role',
|
||||
role: hello.role
|
||||
})
|
||||
socket.end(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid role' }))
|
||||
return
|
||||
}
|
||||
|
||||
this.options.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId })
|
||||
const identity = this.options.identity
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
ok: true,
|
||||
...(identity.launchNonce && identity.startedAtMs
|
||||
? {
|
||||
daemonIdentity: {
|
||||
pid: process.pid,
|
||||
startedAtMs: identity.startedAtMs,
|
||||
launchNonce: identity.launchNonce,
|
||||
...(identity.entryPath ? { entryPath: identity.entryPath } : {}),
|
||||
...(identity.appVersion ? { appVersion: identity.appVersion } : {}),
|
||||
...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
)
|
||||
|
||||
if (hello.role === 'control') {
|
||||
this.installControlSocket(socket, hello.clientId)
|
||||
return
|
||||
}
|
||||
if (hello.role === 'stream') {
|
||||
const client = this.clients.get(hello.clientId)
|
||||
if (!client) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
this.installStreamSocket(socket, client)
|
||||
client.authenticatedPairEstablished = true
|
||||
this.options.onAuthenticatedPair()
|
||||
return
|
||||
}
|
||||
// Parsed wire data is not made safe by the HelloMessage assertion above.
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
private installControlSocket(socket: Socket, clientId: string): void {
|
||||
const previous = this.clients.get(clientId)
|
||||
const client: ConnectedDaemonClient = {
|
||||
clientId,
|
||||
controlSocket: socket,
|
||||
streamSocket: null,
|
||||
authenticatedPairEstablished: false
|
||||
}
|
||||
this.clients.set(clientId, client)
|
||||
this.setupControlParser(socket, clientId)
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
this.options.onControlReplaced(clientId)
|
||||
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
|
||||
previous.streamSocket?.destroy()
|
||||
previous.controlSocket.destroy()
|
||||
}
|
||||
|
||||
private setupControlParser(socket: Socket, clientId: string): void {
|
||||
const decoder = new StringDecoder('utf8')
|
||||
const parser = createNdjsonParser(
|
||||
(message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest),
|
||||
() => {}
|
||||
)
|
||||
socket.removeAllListeners('data')
|
||||
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
|
||||
socket.on('close', () => {
|
||||
const client = this.clients.get(clientId)
|
||||
if (client?.controlSocket !== socket) {
|
||||
return
|
||||
}
|
||||
this.options.onClientDisconnected(clientId)
|
||||
const wasFullyAuthenticated = client.authenticatedPairEstablished
|
||||
client.streamSocket?.destroy()
|
||||
this.clients.delete(clientId)
|
||||
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
|
||||
this.options.onTransportChanged()
|
||||
})
|
||||
}
|
||||
|
||||
private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void {
|
||||
if (
|
||||
wasFullyAuthenticated &&
|
||||
![...this.clients.values()].some((client) => client.authenticatedPairEstablished) &&
|
||||
this.options.isAcceptingWork()
|
||||
) {
|
||||
this.options.onLastAuthenticatedClientDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
private installStreamSocket(socket: Socket, client: ConnectedDaemonClient): void {
|
||||
const previous = client.streamSocket
|
||||
socket.removeAllListeners('data')
|
||||
client.streamSocket = socket
|
||||
socket.on('drain', () => this.options.streamDataBatcher.flush(client.clientId))
|
||||
const cleanup = (): void => {
|
||||
socket.removeListener('close', cleanup)
|
||||
socket.removeListener('error', cleanup)
|
||||
if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) {
|
||||
return
|
||||
}
|
||||
this.options.onStreamDisconnected(client.clientId)
|
||||
client.streamSocket = null
|
||||
}
|
||||
socket.on('close', cleanup)
|
||||
socket.on('error', cleanup)
|
||||
if (previous && previous !== socket) {
|
||||
previous.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Socket } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPendingRequests } from './daemon-client-pending-requests'
|
||||
import { requestDaemonRpc } from './daemon-client-rpc-request'
|
||||
import { isDaemonGoneError } from './daemon-pty-adapter'
|
||||
import { isDaemonGoneError } from './daemon-endpoint-errors'
|
||||
import {
|
||||
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
|
||||
DaemonConnectionLostError,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
|
||||
import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types'
|
||||
|
||||
/**
|
||||
* Narrow on purpose: only the daemon's own reply for a request type it does not implement.
|
||||
* A transient failure must stay unproven rather than be mistaken for a missing capability.
|
||||
* The server throws `Unknown request type: <type>`; the client rejects with that text, which
|
||||
* `addNodePtyRecoveryHint` only ever prepends to.
|
||||
*/
|
||||
export function isUnknownRequestTypeError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes('Unknown request type')
|
||||
}
|
||||
|
||||
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
|
||||
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
|
||||
export function isDaemonGoneError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') {
|
||||
return true
|
||||
}
|
||||
const msg = err.message
|
||||
return (
|
||||
msg === 'Connection lost' ||
|
||||
msg === 'Not connected' ||
|
||||
msg === 'Hello response timed out' ||
|
||||
msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE ||
|
||||
msg === DAEMON_ENDPOINT_LOST_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
export function isMissingWindowsNamedPipeError(err: unknown): boolean {
|
||||
if (process.platform !== 'win32' || !(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
return errno.code === 'ENOENT' && errno.syscall === 'connect'
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence'
|
||||
import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
|
||||
export function sameEndpointIdentity(
|
||||
left: DaemonEndpointIdentity,
|
||||
right: DaemonEndpointIdentity
|
||||
): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.startedAtMs === right.startedAtMs &&
|
||||
left.launchNonce === right.launchNonce
|
||||
)
|
||||
}
|
||||
|
||||
export function exactDaemonIncarnationForPidRecord(
|
||||
identity: DaemonEndpointIdentity,
|
||||
pidRecord: ParsedDaemonPid | null
|
||||
): ExactDaemonIncarnation {
|
||||
return {
|
||||
identity: { ...identity },
|
||||
...(process.platform === 'linux' &&
|
||||
pidRecord?.pid === identity.pid &&
|
||||
pidRecord.startedAtMs === identity.startedAtMs &&
|
||||
pidRecord.launchNonce === identity.launchNonce &&
|
||||
pidRecord.linuxStartTicks &&
|
||||
pidRecord.bootId
|
||||
? {
|
||||
linuxStartTicks: pidRecord.linuxStartTicks,
|
||||
bootId: pidRecord.bootId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null {
|
||||
if (!pidPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { chmodSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import {
|
||||
DaemonEndpointUnavailableError,
|
||||
getDaemonSocketBindPath,
|
||||
publishDaemonEndpoint,
|
||||
readDaemonEndpointOwnershipState,
|
||||
type DaemonEndpointOwnershipState,
|
||||
type DaemonSocketIdentity
|
||||
} from './daemon-endpoint-ownership'
|
||||
import { probeSocketConnect } from './daemon-endpoint-probe'
|
||||
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
|
||||
|
||||
type DaemonEndpointLifecycleOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath: string | null
|
||||
launchNonce: string | null
|
||||
token: string
|
||||
publishEndpointOwnership: () => void
|
||||
log: DaemonFileLog
|
||||
isServing: () => boolean
|
||||
onOwnershipLost: () => void
|
||||
}
|
||||
|
||||
export class DaemonEndpointLifecycle {
|
||||
private static readonly OWNERSHIP_POLL_MS = 30 * 1000
|
||||
private static readonly LOSS_CONFIRMATIONS = 2
|
||||
private ownedSocketIdentity: DaemonSocketIdentity | null = null
|
||||
private ownershipTimer: ReturnType<typeof setInterval> | null = null
|
||||
private ownershipLossStreak = 0
|
||||
private ownershipLost = false
|
||||
|
||||
constructor(private readonly options: DaemonEndpointLifecycleOptions) {}
|
||||
|
||||
bindPath(): string {
|
||||
return process.platform === 'win32'
|
||||
? this.options.socketPath
|
||||
: getDaemonSocketBindPath(this.options.socketPath)
|
||||
}
|
||||
|
||||
secureBindPath(bindPath: string): void {
|
||||
try {
|
||||
chmodSync(bindPath, 0o600)
|
||||
} catch {
|
||||
// Best-effort on platforms that support it.
|
||||
}
|
||||
}
|
||||
|
||||
abandonBindPath(bindPath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
unlinkSync(bindPath)
|
||||
} catch {
|
||||
// Already consumed by publication, or never created.
|
||||
}
|
||||
}
|
||||
|
||||
async publish(bindPath: string): Promise<void> {
|
||||
const outcome = await publishDaemonEndpoint(
|
||||
bindPath,
|
||||
this.options.socketPath,
|
||||
probeSocketConnect
|
||||
)
|
||||
if (outcome.status !== 'published') {
|
||||
this.options.log.log('endpoint-publish-declined', { reason: outcome.status })
|
||||
console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`)
|
||||
throw new DaemonEndpointUnavailableError(outcome.status)
|
||||
}
|
||||
this.ownedSocketIdentity = outcome.identity
|
||||
let publishedOwnership = false
|
||||
try {
|
||||
this.options.publishEndpointOwnership()
|
||||
publishedOwnership = true
|
||||
writeFileSync(this.options.tokenPath, this.options.token, { mode: 0o600 })
|
||||
} catch (error) {
|
||||
if (publishedOwnership && this.options.pidPath && this.options.launchNonce) {
|
||||
unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce)
|
||||
}
|
||||
this.ownedSocketIdentity = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
retireUnstarted(): void {
|
||||
this.stopOwnershipWatch()
|
||||
this.unlinkOwnedArtifacts()
|
||||
}
|
||||
|
||||
unlinkOwnedArtifacts(): void {
|
||||
unlinkOwnedDaemonTokenFile(this.options.tokenPath, this.options.token)
|
||||
if (this.options.pidPath && this.options.launchNonce) {
|
||||
unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce)
|
||||
}
|
||||
// The canonical endpoint stays: removing it could delete a replacement's name.
|
||||
this.ownedSocketIdentity = null
|
||||
}
|
||||
|
||||
stopOwnershipWatch(): void {
|
||||
if (this.ownershipTimer === null) {
|
||||
return
|
||||
}
|
||||
clearInterval(this.ownershipTimer)
|
||||
this.ownershipTimer = null
|
||||
}
|
||||
|
||||
hasLostOwnership(): boolean {
|
||||
if (this.ownershipLost) {
|
||||
return true
|
||||
}
|
||||
return this.observeOwnership() === 'lost'
|
||||
}
|
||||
|
||||
get lost(): boolean {
|
||||
return this.ownershipLost
|
||||
}
|
||||
|
||||
requestRetirementForLoss(): void {
|
||||
const alreadyLost = this.ownershipLost
|
||||
this.ownershipLost = true
|
||||
this.ownedSocketIdentity = null
|
||||
if (!alreadyLost) {
|
||||
this.options.log.log('endpoint-ownership-lost', {
|
||||
socketPath: this.options.socketPath
|
||||
})
|
||||
console.warn(
|
||||
'[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end'
|
||||
)
|
||||
}
|
||||
this.stopOwnershipWatch()
|
||||
this.options.onOwnershipLost()
|
||||
}
|
||||
|
||||
startOwnershipWatch(): void {
|
||||
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
|
||||
return
|
||||
}
|
||||
this.ownershipTimer = setInterval(
|
||||
() => this.checkOwnership(),
|
||||
DaemonEndpointLifecycle.OWNERSHIP_POLL_MS
|
||||
)
|
||||
this.ownershipTimer.unref()
|
||||
}
|
||||
|
||||
private checkOwnership(): void {
|
||||
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
|
||||
return
|
||||
}
|
||||
if (this.observeOwnership() !== 'lost') {
|
||||
return
|
||||
}
|
||||
this.ownershipLossStreak++
|
||||
if (this.ownershipLossStreak >= DaemonEndpointLifecycle.LOSS_CONFIRMATIONS) {
|
||||
this.requestRetirementForLoss()
|
||||
}
|
||||
}
|
||||
|
||||
private observeOwnership(): DaemonEndpointOwnershipState {
|
||||
if (process.platform === 'win32' || !this.ownedSocketIdentity || !this.options.isServing()) {
|
||||
return 'indeterminate'
|
||||
}
|
||||
const state = readDaemonEndpointOwnershipState(
|
||||
this.options.socketPath,
|
||||
this.ownedSocketIdentity
|
||||
)
|
||||
if (state !== 'lost') {
|
||||
this.ownershipLossStreak = 0
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -205,13 +205,13 @@ describe('daemon endpoint ownership publication', () => {
|
||||
await server.start()
|
||||
|
||||
const daemon = server as unknown as {
|
||||
checkEndpointOwnership: () => void
|
||||
retirementRequested: boolean
|
||||
endpoint: { checkOwnership: () => void }
|
||||
lifecycle: { retirementRequested: boolean }
|
||||
}
|
||||
// An inconclusive or matching probe must never retire a healthy daemon, however often it runs.
|
||||
daemon.checkEndpointOwnership()
|
||||
daemon.checkEndpointOwnership()
|
||||
expect(daemon.retirementRequested).toBe(false)
|
||||
daemon.endpoint.checkOwnership()
|
||||
daemon.endpoint.checkOwnership()
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(false)
|
||||
|
||||
// Another daemon takes the endpoint name.
|
||||
unlinkSync(socketPath)
|
||||
@@ -224,13 +224,13 @@ describe('daemon endpoint ownership publication', () => {
|
||||
try {
|
||||
// A single observation can land inside a replacement's unlink-then-link gap, so the
|
||||
// first one must not retire anything.
|
||||
daemon.checkEndpointOwnership()
|
||||
expect(daemon.retirementRequested).toBe(false)
|
||||
daemon.endpoint.checkOwnership()
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(false)
|
||||
|
||||
daemon.checkEndpointOwnership()
|
||||
daemon.endpoint.checkOwnership()
|
||||
// Why: retirement drains rather than kills — an orphaned daemon stops being a
|
||||
// permanent unreachable host without tearing live sessions out from under the user.
|
||||
expect(daemon.retirementRequested).toBe(true)
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(true)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => usurper.close(() => resolve()))
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HistoryRecoveryContext } from './daemon-pty-runtime-state'
|
||||
import type { HistoryRecoveryFreeze } from './history-manager'
|
||||
|
||||
// Taking and clearing together prevents any recovery branch from retaining a consumed freeze.
|
||||
export function takeHistoryRecoveryFreeze(
|
||||
historyRecovery: HistoryRecoveryContext,
|
||||
sessionId: string
|
||||
): HistoryRecoveryFreeze | undefined {
|
||||
const freeze =
|
||||
historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined
|
||||
historyRecovery.freeze = null
|
||||
return freeze
|
||||
}
|
||||
@@ -58,6 +58,22 @@ class ManualIdleClock {
|
||||
return this.timers.size
|
||||
}
|
||||
}
|
||||
type DaemonServerInternals = {
|
||||
connections: {
|
||||
clients: Map<string, { controlSocket: Socket }>
|
||||
accept(socket: Socket): void
|
||||
}
|
||||
admission: { createOrAttachInFlight: number }
|
||||
lifecycle: {
|
||||
retirementRequested: boolean
|
||||
state: string
|
||||
beginIdleShutdown(): void
|
||||
}
|
||||
requestRouter: {
|
||||
route(clientId: string, request: unknown): Promise<unknown>
|
||||
}
|
||||
host: { dispose: () => Promise<void> }
|
||||
}
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } {
|
||||
let onExit: ((code: number) => void) | null = null
|
||||
@@ -222,11 +238,8 @@ describe('current daemon lifecycle retirement', () => {
|
||||
await startServer()
|
||||
const client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const daemon = server as unknown as {
|
||||
clients: Map<string, { controlSocket: Socket }>
|
||||
host: { dispose: () => Promise<void> }
|
||||
}
|
||||
const controlSocket = [...daemon.clients.values()][0].controlSocket
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
|
||||
const originalWrite = controlSocket.write.bind(controlSocket)
|
||||
let replyFlushed: (() => void) | undefined
|
||||
vi.spyOn(controlSocket, 'write').mockImplementation(((
|
||||
@@ -252,11 +265,8 @@ describe('current daemon lifecycle retirement', () => {
|
||||
await startServer()
|
||||
const client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const daemon = server as unknown as {
|
||||
clients: Map<string, { controlSocket: Socket }>
|
||||
host: { dispose: () => Promise<void> }
|
||||
}
|
||||
const controlSocket = [...daemon.clients.values()][0].controlSocket
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
|
||||
const originalWrite = controlSocket.write.bind(controlSocket)
|
||||
vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) =>
|
||||
originalWrite(chunk)) as unknown as Socket['write'])
|
||||
@@ -301,8 +311,8 @@ describe('current daemon lifecycle retirement', () => {
|
||||
await startServer()
|
||||
const client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const daemon = server as unknown as { createOrAttachInFlight: number }
|
||||
daemon.createOrAttachInFlight = 1
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
daemon.admission.createOrAttachInFlight = 1
|
||||
|
||||
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
|
||||
retiring: false
|
||||
@@ -395,13 +405,13 @@ describe('current daemon lifecycle retirement', () => {
|
||||
await first.ensureConnected()
|
||||
await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 })
|
||||
first.disconnect()
|
||||
const daemon = server as unknown as { retirementRequested: boolean }
|
||||
await waitFor(() => daemon.retirementRequested)
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await waitFor(() => daemon.lifecycle.retirementRequested)
|
||||
expect(onIdleShutdown).not.toHaveBeenCalled()
|
||||
|
||||
const second = new DaemonClient({ socketPath, tokenPath })
|
||||
await second.ensureConnected()
|
||||
await waitFor(() => !daemon.retirementRequested)
|
||||
await waitFor(() => !daemon.lifecycle.retirementRequested)
|
||||
subprocess.exit(0)
|
||||
expect(onIdleShutdown).not.toHaveBeenCalled()
|
||||
|
||||
@@ -444,8 +454,8 @@ describe('current daemon lifecycle retirement', () => {
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
const daemon = server as unknown as { clients: Map<string, unknown> }
|
||||
await waitFor(() => daemon.clients.has('startup-health-control'))
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await waitFor(() => daemon.connections.clients.has('startup-health-control'))
|
||||
clock.advanceBy(1_000)
|
||||
healthControl.destroy()
|
||||
|
||||
@@ -465,18 +475,15 @@ describe('current daemon lifecycle retirement', () => {
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
const daemon = server as unknown as {
|
||||
clients: Map<string, unknown>
|
||||
retirementRequested: boolean
|
||||
}
|
||||
await waitFor(() => daemon.clients.has('startup-control-create'))
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await waitFor(() => daemon.connections.clients.has('startup-control-create'))
|
||||
const response = await requestOnRawSocket(control, {
|
||||
id: 'control-only-create',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
|
||||
})
|
||||
expect(response.error).toContain('connection is incomplete')
|
||||
expect(daemon.retirementRequested).toBe(false)
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(false)
|
||||
expect(clock.pendingCount).toBe(0)
|
||||
|
||||
clock.advanceBy(1_000)
|
||||
@@ -501,14 +508,11 @@ describe('current daemon lifecycle retirement', () => {
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
const daemon = server as unknown as {
|
||||
clients: Map<string, unknown>
|
||||
retirementRequested: boolean
|
||||
}
|
||||
await waitFor(() => daemon.clients.has('control-only-overlap'))
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await waitFor(() => daemon.connections.clients.has('control-only-overlap'))
|
||||
|
||||
paired.disconnect()
|
||||
await waitFor(() => daemon.retirementRequested)
|
||||
await waitFor(() => daemon.lifecycle.retirementRequested)
|
||||
expect(clock.pendingCount).toBe(0)
|
||||
const response = await requestOnRawSocket(incomplete, {
|
||||
id: 'overlap-control-create',
|
||||
@@ -516,7 +520,7 @@ describe('current daemon lifecycle retirement', () => {
|
||||
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
|
||||
})
|
||||
expect(response.error).toContain('connection is incomplete')
|
||||
expect(daemon.retirementRequested).toBe(true)
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(true)
|
||||
|
||||
incomplete.destroy()
|
||||
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
|
||||
@@ -538,10 +542,8 @@ describe('current daemon lifecycle retirement', () => {
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
const daemon = server as unknown as {
|
||||
retirementRequested: boolean
|
||||
}
|
||||
await waitFor(() => daemon.retirementRequested)
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await waitFor(() => daemon.lifecycle.retirementRequested)
|
||||
expect(clock.pendingCount).toBe(0)
|
||||
|
||||
replacementControl.destroy()
|
||||
@@ -554,8 +556,8 @@ describe('current daemon lifecycle retirement', () => {
|
||||
await client.ensureConnected()
|
||||
await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 })
|
||||
client.disconnect()
|
||||
const daemon = server as unknown as { retirementRequested: boolean }
|
||||
await waitFor(() => daemon.retirementRequested)
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await waitFor(() => daemon.lifecycle.retirementRequested)
|
||||
|
||||
expect(onIdleShutdown).not.toHaveBeenCalled()
|
||||
expect(clock.pendingCount).toBe(0)
|
||||
@@ -612,14 +614,11 @@ describe('current daemon lifecycle retirement', () => {
|
||||
|
||||
it('rejects create or attach once the idle admission fence is pending', async () => {
|
||||
await startServer()
|
||||
const daemon = server as unknown as {
|
||||
idleShutdownState: string
|
||||
routeRequest(clientId: string, request: unknown): Promise<unknown>
|
||||
}
|
||||
daemon.idleShutdownState = 'idle-shutdown-pending'
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
daemon.lifecycle.state = 'idle-shutdown-pending'
|
||||
|
||||
await expect(
|
||||
daemon.routeRequest('late-client', {
|
||||
daemon.requestRouter.route('late-client', {
|
||||
id: 'late-create',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'late', cols: 80, rows: 24 }
|
||||
@@ -629,30 +628,23 @@ describe('current daemon lifecycle retirement', () => {
|
||||
|
||||
it('aborts the pending shutdown when create or attach started before the fence', async () => {
|
||||
await startServer()
|
||||
const daemon = server as unknown as {
|
||||
createOrAttachInFlight: number
|
||||
idleShutdownState: string
|
||||
beginIdleShutdown(): void
|
||||
}
|
||||
daemon.createOrAttachInFlight = 1
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
daemon.admission.createOrAttachInFlight = 1
|
||||
|
||||
daemon.beginIdleShutdown()
|
||||
daemon.lifecycle.beginIdleShutdown()
|
||||
|
||||
expect(daemon.idleShutdownState).toBe('running')
|
||||
expect(daemon.lifecycle.state).toBe('running')
|
||||
expect(onIdleShutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('explicitly marks a post-fence accepted transport as retryable', async () => {
|
||||
await startServer()
|
||||
const daemon = server as unknown as {
|
||||
idleShutdownState: string
|
||||
handleConnection(socket: Socket): void
|
||||
}
|
||||
daemon.idleShutdownState = 'idle-shutdown-pending'
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
daemon.lifecycle.state = 'idle-shutdown-pending'
|
||||
const socket = new EventEmitter() as Socket
|
||||
socket.end = vi.fn() as unknown as Socket['end']
|
||||
|
||||
daemon.handleConnection(socket)
|
||||
daemon.connections.accept(socket)
|
||||
|
||||
const payload = vi.mocked(socket.end).mock.calls[0]?.[0]
|
||||
expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true })
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
type DaemonProcessHandle
|
||||
} from './daemon-spawner'
|
||||
import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership'
|
||||
import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import { DaemonClient } from './client'
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function removeDaemonListener<T>(listeners: T[], listener: T): void {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyDaemonAuditListeners<T>(
|
||||
listeners: readonly ((value: T) => void)[],
|
||||
value: T
|
||||
): void {
|
||||
for (const listener of listeners.slice()) {
|
||||
try {
|
||||
listener(value)
|
||||
} catch {
|
||||
// Audit observers cannot affect daemon operations.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DaemonServer, type DaemonServerOptions } from './daemon-server'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import type { DaemonServerOptions } from './daemon-server-options'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
|
||||
export type DaemonStartOptions = {
|
||||
|
||||
@@ -10,8 +10,8 @@ import { PROTOCOL_VERSION } from './types'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
|
||||
type DaemonServerPrivate = {
|
||||
pendingPtySpawnPreparations: Map<string, Set<unknown>>
|
||||
clients: Map<string, { streamSocket: Socket | null }>
|
||||
preparations: { pending: Map<string, Set<unknown>> }
|
||||
connections: { clients: Map<string, { streamSocket: Socket | null }> }
|
||||
}
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
@@ -96,7 +96,7 @@ describe('daemon preflight client replacement', () => {
|
||||
await replacement.ensureConnected()
|
||||
finishPreparation()
|
||||
await vi.waitFor(() =>
|
||||
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
|
||||
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
|
||||
)
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
|
||||
@@ -139,12 +139,13 @@ describe('daemon preflight client replacement', () => {
|
||||
disconnected.stream.destroy()
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
(server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket
|
||||
(server as unknown as DaemonServerPrivate).connections.clients.get(disconnectedClientId)
|
||||
?.streamSocket
|
||||
).toBeNull()
|
||||
)
|
||||
finishPreparation()
|
||||
await vi.waitFor(() =>
|
||||
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
|
||||
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
|
||||
)
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control'
|
||||
import {
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
|
||||
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
|
||||
import { DaemonPtySessionControl } from './daemon-pty-session-control'
|
||||
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
|
||||
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
|
||||
import type { GetSnapshotResult } from './types'
|
||||
import type { PtyProviderBufferSnapshot } from '../providers/types'
|
||||
|
||||
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
|
||||
|
||||
export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
|
||||
async getBufferSnapshot(
|
||||
id: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
if (!this.supportsAuthoritativeBufferSnapshots) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows)
|
||||
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
|
||||
sessionId: id,
|
||||
...(scrollbackRows !== undefined ? { scrollbackRows } : {})
|
||||
})
|
||||
const snapshot = result.snapshot
|
||||
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
|
||||
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
|
||||
return null
|
||||
}
|
||||
const restored =
|
||||
this.historyManager &&
|
||||
this.historyReader &&
|
||||
(scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS)
|
||||
? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows)
|
||||
: snapshot
|
||||
return this.toProviderBufferSnapshot(restored)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected toProviderBufferSnapshot(
|
||||
snapshot: NonNullable<GetSnapshotResult['snapshot']>
|
||||
): PtyProviderBufferSnapshot | null {
|
||||
if (typeof snapshot.outputSequence !== 'number') {
|
||||
return null
|
||||
}
|
||||
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
|
||||
return {
|
||||
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
frameRestoreAnsi: snapshot.frameRestoreAnsi,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
cwd: snapshot.cwd,
|
||||
lastTitle: snapshot.lastTitle,
|
||||
seq: snapshot.outputSequence,
|
||||
source: 'headless',
|
||||
oscLinks: snapshot.oscLinks,
|
||||
alternateScreen: snapshot.modes.alternateScreen,
|
||||
// Why known `0` is carried too: it proves the app negotiated nothing at
|
||||
// this boundary, which is a different fact from a source that cannot say.
|
||||
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
protected async overlayDurableRestoreSnapshot(
|
||||
sessionId: string,
|
||||
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
|
||||
scrollbackRows?: number
|
||||
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
|
||||
if (!this.historyManager || !this.historyReader) {
|
||||
return liveSnapshot
|
||||
}
|
||||
// Why turn the caller away instead of queueing: this session already has a
|
||||
// compact in flight whose result a third one would only duplicate, and an
|
||||
// unbounded queue is how a stalled history filesystem grows without limit.
|
||||
if (this.checkpointQueue.isSaturated(sessionId)) {
|
||||
return liveSnapshot
|
||||
}
|
||||
// Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts.
|
||||
// Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work.
|
||||
if (!this.tryAdmitNonFinalCheckpoint(sessionId)) {
|
||||
return liveSnapshot
|
||||
}
|
||||
// Why per session with a deadline: a reattach is a user click, so it must wait
|
||||
// on this session's own compact and nothing else. A blown deadline does not
|
||||
// cancel that compact — it keeps running and still commits — so the fallback
|
||||
// costs restore depth for this one reattach, never durable history (STA-4173).
|
||||
return await this.checkpointQueue.runWithDeadline(
|
||||
sessionId,
|
||||
async () => {
|
||||
try {
|
||||
return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows)
|
||||
} finally {
|
||||
this.releaseNonFinalCheckpointAdmission(sessionId)
|
||||
this.overlayDeadlineWarnedSessionIds.delete(sessionId)
|
||||
}
|
||||
},
|
||||
DURABLE_HISTORY_OVERLAY_DEADLINE_MS,
|
||||
liveSnapshot,
|
||||
{
|
||||
onDeadline: () => {
|
||||
if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) {
|
||||
this.overlayDeadlineWarnedSessionIds.add(sessionId)
|
||||
console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean {
|
||||
if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) {
|
||||
if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) {
|
||||
this.nonFinalAdmissionDeniedSessionIds.add(sessionId)
|
||||
console.warn('[history] non-final checkpoint already in flight:', sessionId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (
|
||||
this.nonFinalCheckpointAdmissionSessionIds.size >=
|
||||
(this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS
|
||||
) {
|
||||
this.reportNonFinalGlobalAdmissionDenial(sessionId)
|
||||
return false
|
||||
}
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
|
||||
this.nonFinalCheckpointAdmissionSessionIds.add(sessionId)
|
||||
return true
|
||||
}
|
||||
|
||||
protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void {
|
||||
if (!this.nonFinalGlobalAdmissionWarningActive) {
|
||||
this.nonFinalGlobalAdmissionWarningActive = true
|
||||
console.warn('[history] non-final checkpoint global admission limit reached:', sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
protected releaseNonFinalCheckpointAdmission(sessionId: string): void {
|
||||
if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) {
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
|
||||
this.nonFinalGlobalAdmissionWarningActive = false
|
||||
}
|
||||
}
|
||||
|
||||
protected async compactDurableRestoreSnapshot(
|
||||
sessionId: string,
|
||||
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
|
||||
scrollbackRows?: number
|
||||
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
|
||||
if (!this.historyReader) {
|
||||
return liveSnapshot
|
||||
}
|
||||
try {
|
||||
// Why compact before reading: an independent take/append races the 5s tick, can
|
||||
// seq-gap the log, and would remount a stale checkpoint over the live window.
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
|
||||
teardown: false,
|
||||
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
|
||||
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
|
||||
})
|
||||
if (checkpoint.checkpoint === 'committed') {
|
||||
this.sessionsNeedingFullCheckpoint.delete(sessionId)
|
||||
}
|
||||
if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) {
|
||||
return checkpoint.snapshot ?? liveSnapshot
|
||||
}
|
||||
if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) {
|
||||
return checkpoint.snapshot
|
||||
}
|
||||
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
|
||||
ignoreCleanEnd: true,
|
||||
wslDistro: this.wslDistrosBySessionId.get(sessionId)
|
||||
})
|
||||
if (!restoreInfo) {
|
||||
return liveSnapshot
|
||||
}
|
||||
return await buildDurableCheckpointSnapshot({
|
||||
liveSnapshot: checkpoint.snapshot,
|
||||
restoreInfo,
|
||||
scrollbackRows
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[history] durable snapshot overlay failed:', sessionId, error)
|
||||
return liveSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
async clearBuffer(id: string): Promise<void> {
|
||||
await this.client.request('clearScrollback', { sessionId: id })
|
||||
this.markSessionDirty(id)
|
||||
}
|
||||
|
||||
acknowledgeDataEvent(_id: string, _charCount: number): void {
|
||||
// No flow control for daemon-backed terminals
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
|
||||
import { DaemonPtyCheckpointScheduler } from './daemon-pty-checkpoint-scheduler'
|
||||
import type { SnapshotCheckpointResult } from './daemon-pty-runtime-state'
|
||||
import type { GetSnapshotResult, TakePendingOutputResult } from './types'
|
||||
|
||||
export abstract class DaemonPtyCheckpointPersistence extends DaemonPtyCheckpointScheduler {
|
||||
// Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry;
|
||||
// skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole.
|
||||
protected async writeSessionCheckpoint(
|
||||
sessionId: string,
|
||||
opts: { final: boolean; teardown: boolean }
|
||||
): Promise<'done' | 'deferred'> {
|
||||
if (!this.supportsIncrementalCheckpoints) {
|
||||
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
|
||||
if (result.snapshot && this.historyManager) {
|
||||
const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot)
|
||||
return checkpoint === 'retryable' ? 'deferred' : 'done'
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) {
|
||||
if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) {
|
||||
return 'deferred'
|
||||
}
|
||||
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
|
||||
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
|
||||
teardown: opts.teardown,
|
||||
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
|
||||
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
|
||||
})
|
||||
if (checkpoint.checkpoint === 'retryable') {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
this.sessionsNeedingFullCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
|
||||
return 'done'
|
||||
}
|
||||
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
|
||||
sessionId
|
||||
})
|
||||
if (!take) {
|
||||
return 'done'
|
||||
}
|
||||
if (take.overflowed) {
|
||||
// Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it.
|
||||
if (this.isFullCheckpointCoolingDown(sessionId)) {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
|
||||
teardown: false,
|
||||
forceLiveSnapshot: true
|
||||
})
|
||||
if (checkpoint.checkpoint === 'retryable') {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
if (take.records.length === 0) {
|
||||
return 'done'
|
||||
}
|
||||
if (!this.historyManager) {
|
||||
return 'done'
|
||||
}
|
||||
const appendResult = await this.historyManager.appendIncrements(
|
||||
sessionId,
|
||||
take.seq,
|
||||
take.records
|
||||
)
|
||||
if (appendResult === 'needs-checkpoint') {
|
||||
// Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them.
|
||||
if (this.isFullCheckpointCoolingDown(sessionId)) {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
|
||||
teardown: false,
|
||||
forceLiveSnapshot: true
|
||||
})
|
||||
if (checkpoint.checkpoint === 'retryable') {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
|
||||
protected async takeSnapshotAndCheckpoint(
|
||||
sessionId: string,
|
||||
opts: {
|
||||
teardown: boolean
|
||||
forceLiveSnapshot?: boolean
|
||||
requireContinuityProof?: boolean
|
||||
}
|
||||
): Promise<SnapshotCheckpointResult> {
|
||||
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
|
||||
sessionId,
|
||||
includeSnapshot: true,
|
||||
teardownSnapshot: opts.teardown
|
||||
})
|
||||
if (take?.snapshot && this.historyManager) {
|
||||
// Why require drainedRecords: an older daemon still empties the pending
|
||||
// queue on includeSnapshot but omits the field. Treating absence as []
|
||||
// would compact stale disk history and reset the log.
|
||||
const snapshot =
|
||||
take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed
|
||||
? take.snapshot
|
||||
: await this.buildDurableHistorySnapshot(
|
||||
sessionId,
|
||||
take.snapshot,
|
||||
[...take.drainedRecords, ...take.records],
|
||||
{
|
||||
pendingRecordsAreComplete: take.seq === 1,
|
||||
...(opts.requireContinuityProof === true
|
||||
? { requiredPreviousPendingOutputSeq: take.seq - 1 }
|
||||
: {})
|
||||
}
|
||||
)
|
||||
const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, {
|
||||
pendingOutputSeq: take.seq
|
||||
})
|
||||
if (checkpoint === 'retryable') {
|
||||
// Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that
|
||||
// failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and
|
||||
// defeat the log's seq-gap detection. A stale prefix beats an undetectable hole.
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
this.sessionsNeedingLiveCheckpoint.add(sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
|
||||
this.markSessionDirty(sessionId)
|
||||
return { checkpoint, snapshot: take.snapshot }
|
||||
}
|
||||
if (checkpoint === 'unavailable') {
|
||||
this.sessionsNeedingFullCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
|
||||
return { checkpoint, snapshot: take.snapshot }
|
||||
}
|
||||
this.lastFullCheckpointAt.set(sessionId, Date.now())
|
||||
if (take.records.length > 0 && snapshot === take.snapshot) {
|
||||
// Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail.
|
||||
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
|
||||
}
|
||||
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
|
||||
return { checkpoint: 'committed', snapshot }
|
||||
}
|
||||
this.sessionsNeedingFullCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
|
||||
return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null }
|
||||
}
|
||||
|
||||
protected async buildDurableHistorySnapshot(
|
||||
sessionId: string,
|
||||
liveSnapshot: NonNullable<TakePendingOutputResult['snapshot']>,
|
||||
pendingRecords: TakePendingOutputResult['records'],
|
||||
opts: {
|
||||
pendingRecordsAreComplete: boolean
|
||||
requiredPreviousPendingOutputSeq?: number
|
||||
}
|
||||
): Promise<NonNullable<TakePendingOutputResult['snapshot']>> {
|
||||
if (!this.historyReader) {
|
||||
return liveSnapshot
|
||||
}
|
||||
try {
|
||||
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
|
||||
ignoreCleanEnd: true,
|
||||
wslDistro: this.wslDistrosBySessionId.get(sessionId)
|
||||
})
|
||||
if (
|
||||
(!restoreInfo && !opts.pendingRecordsAreComplete) ||
|
||||
(opts.requiredPreviousPendingOutputSeq !== undefined &&
|
||||
restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq)
|
||||
) {
|
||||
console.warn('[history] durable continuity unproven; using live snapshot:', sessionId)
|
||||
return liveSnapshot
|
||||
}
|
||||
return await buildDurableCheckpointSnapshot({
|
||||
liveSnapshot,
|
||||
restoreInfo,
|
||||
pendingRecords
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[history] durable history rebuild failed:', sessionId, error)
|
||||
return liveSnapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { awaitDaemonWorkWithinCallerDeadline } from './daemon-request-deadline'
|
||||
import { DaemonPtyConnectionLifecycle } from './daemon-pty-connection-lifecycle'
|
||||
|
||||
export abstract class DaemonPtyCheckpointScheduler extends DaemonPtyConnectionLifecycle {
|
||||
protected stopCheckpointTimer(): void {
|
||||
if (!this.checkpointTimer) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.checkpointTimer)
|
||||
this.checkpointTimer = null
|
||||
}
|
||||
|
||||
protected stopCheckpointTimerIfIdle(): void {
|
||||
if (this.dirtySessionVersions.size === 0) {
|
||||
this.stopCheckpointTimer()
|
||||
}
|
||||
}
|
||||
|
||||
protected scheduleCheckpointTimer(): void {
|
||||
if (
|
||||
this.checkpointTimer ||
|
||||
!this.historyManager ||
|
||||
!this.supportsCheckpoints ||
|
||||
this.dirtySessionVersions.size === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write.
|
||||
this.checkpointTimer = setTimeout(
|
||||
() => {
|
||||
this.checkpointTimer = null
|
||||
// Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes.
|
||||
if (this.checkpointInFlight) {
|
||||
this.scheduleCheckpointTimer()
|
||||
return
|
||||
}
|
||||
const checkpoint = this.checkpointDirtySessions()
|
||||
this.checkpointInFlight = checkpoint
|
||||
void checkpoint
|
||||
.finally(() => {
|
||||
if (this.checkpointInFlight === checkpoint) {
|
||||
this.checkpointInFlight = null
|
||||
this.scheduleCheckpointTimer()
|
||||
}
|
||||
})
|
||||
// Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here.
|
||||
.catch(() => {})
|
||||
},
|
||||
(this.constructor as typeof DaemonPtyCheckpointScheduler).CHECKPOINT_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
|
||||
protected markSessionDirty(sessionId: string): void {
|
||||
if (!this.activeSessionIds.has(sessionId)) {
|
||||
return
|
||||
}
|
||||
this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1)
|
||||
this.scheduleCheckpointTimer()
|
||||
}
|
||||
|
||||
protected async checkpointDirtySessions(): Promise<void> {
|
||||
if (!this.historyManager || this.dirtySessionVersions.size === 0) {
|
||||
return
|
||||
}
|
||||
// Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces)
|
||||
// while not dropping writes that arrive mid-checkpoint.
|
||||
const versions = new Map(
|
||||
[...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId))
|
||||
)
|
||||
if (versions.size === 0) {
|
||||
this.dirtySessionVersions.clear()
|
||||
this.stopCheckpointTimer()
|
||||
return
|
||||
}
|
||||
const completed = await this.checkpointSessions(versions.keys())
|
||||
for (const [sessionId, version] of versions) {
|
||||
if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) {
|
||||
this.dirtySessionVersions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
this.stopCheckpointTimerIfIdle()
|
||||
}
|
||||
|
||||
/** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */
|
||||
protected async runExclusiveCheckpoint(
|
||||
operation: () => Promise<void>,
|
||||
options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {}
|
||||
): Promise<boolean> {
|
||||
this.stopCheckpointTimer()
|
||||
// Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together.
|
||||
const previous = this.checkpointInFlight ?? Promise.resolve()
|
||||
const checkpoint = previous.catch(() => {}).then(operation)
|
||||
this.checkpointInFlight = checkpoint
|
||||
// Why the release rides the checkpoint instead of the caller's await: a caller that walks away
|
||||
// at its deadline must leave this checkpoint as the tail, so the durable write still runs to
|
||||
// completion, still commits, and the next waiter still queues behind it (STA-4228).
|
||||
const settled = checkpoint.then(
|
||||
() => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty),
|
||||
(err: unknown) => {
|
||||
this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty)
|
||||
throw err
|
||||
}
|
||||
)
|
||||
if (options.callerDeadlineMs === undefined) {
|
||||
await settled
|
||||
return true
|
||||
}
|
||||
return await awaitDaemonWorkWithinCallerDeadline(settled, options.callerDeadlineMs)
|
||||
}
|
||||
|
||||
protected releaseExclusiveCheckpoint(
|
||||
checkpoint: Promise<void>,
|
||||
rescheduleDirty: boolean | undefined
|
||||
): void {
|
||||
if (this.checkpointInFlight === checkpoint) {
|
||||
this.checkpointInFlight = null
|
||||
}
|
||||
this.stopCheckpointTimer()
|
||||
if (rescheduleDirty !== false) {
|
||||
this.scheduleCheckpointTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the
|
||||
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
|
||||
protected async checkpointAllSessions(): Promise<void> {
|
||||
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
|
||||
for (const sessionId of completed) {
|
||||
this.dirtySessionVersions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
protected async checkpointSessions(
|
||||
sessionIds: Iterable<string>,
|
||||
opts?: { final?: boolean; teardown?: boolean }
|
||||
): Promise<Set<string>> {
|
||||
const completed = new Set<string>()
|
||||
if (!this.historyManager) {
|
||||
return completed
|
||||
}
|
||||
const ids = Array.from(sessionIds)
|
||||
let nextIndex = 0
|
||||
|
||||
const checkpointNext = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
// No worker in this pass awaits abandoned work, so a full admission set cannot open here.
|
||||
if (
|
||||
opts?.final !== true &&
|
||||
this.nonFinalCheckpointAdmissionSessionIds.size >=
|
||||
(this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS
|
||||
) {
|
||||
const deferredSessionId = ids[nextIndex]
|
||||
if (deferredSessionId !== undefined) {
|
||||
this.reportNonFinalGlobalAdmissionDenial(deferredSessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
const index = nextIndex
|
||||
nextIndex++
|
||||
if (index >= ids.length) {
|
||||
return
|
||||
}
|
||||
const sessionId = ids[index]
|
||||
await this.checkpointSession(sessionId, {
|
||||
final: opts?.final === true,
|
||||
teardown: opts?.teardown === true
|
||||
})
|
||||
.then((result) => {
|
||||
// Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires.
|
||||
if (result === 'done') {
|
||||
completed.add(sessionId)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
|
||||
}
|
||||
}
|
||||
// Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once.
|
||||
const workers = Array.from(
|
||||
{
|
||||
length: Math.min(
|
||||
(this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS,
|
||||
ids.length
|
||||
)
|
||||
},
|
||||
() => checkpointNext()
|
||||
)
|
||||
await Promise.all(workers)
|
||||
return completed
|
||||
}
|
||||
|
||||
// Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately.
|
||||
protected isFullCheckpointCoolingDown(sessionId: string): boolean {
|
||||
const last = this.lastFullCheckpointAt.get(sessionId)
|
||||
if (last === undefined) {
|
||||
return false
|
||||
}
|
||||
const elapsed = Date.now() - last
|
||||
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window.
|
||||
return (
|
||||
elapsed >= 0 &&
|
||||
elapsed <
|
||||
(this.constructor as typeof DaemonPtyCheckpointScheduler).FULL_CHECKPOINT_COOLDOWN_MS
|
||||
)
|
||||
}
|
||||
|
||||
protected async checkpointSession(
|
||||
sessionId: string,
|
||||
opts: { final: boolean; teardown: boolean }
|
||||
): Promise<'done' | 'deferred'> {
|
||||
// Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and
|
||||
// deferring there would silently drop what the user left on screen rather than delay it.
|
||||
if (opts.final) {
|
||||
return await this.checkpointQueue.run(sessionId, () =>
|
||||
this.writeSessionCheckpoint(sessionId, opts)
|
||||
)
|
||||
}
|
||||
// Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue
|
||||
// still commits and the next tick retries this one. Nothing on disk is discarded.
|
||||
if (
|
||||
this.checkpointQueue.isSaturated(sessionId) ||
|
||||
!this.tryAdmitNonFinalCheckpoint(sessionId)
|
||||
) {
|
||||
return 'deferred'
|
||||
}
|
||||
const run = async (): Promise<'done' | 'deferred'> => {
|
||||
try {
|
||||
return await this.writeSessionCheckpoint(sessionId, opts)
|
||||
} finally {
|
||||
this.releaseNonFinalCheckpointAdmission(sessionId)
|
||||
this.periodicDeadlineWarnedSessionIds.delete(sessionId)
|
||||
}
|
||||
}
|
||||
return await this.checkpointQueue.runWithDeadline(
|
||||
sessionId,
|
||||
run,
|
||||
(this.constructor as typeof DaemonPtyCheckpointScheduler).PERIODIC_CHECKPOINT_DEADLINE_MS,
|
||||
'deferred',
|
||||
{
|
||||
onDeadline: () => {
|
||||
if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) {
|
||||
this.periodicDeadlineWarnedSessionIds.add(sessionId)
|
||||
console.warn('[history] periodic checkpoint deadline exceeded:', sessionId)
|
||||
}
|
||||
},
|
||||
onAbandonedRejection: (error) => {
|
||||
console.warn('[history] checkpoint failed:', sessionId, error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
classifyDaemonAuditFailure,
|
||||
type DaemonAuditObservation,
|
||||
type DaemonAuditTrigger
|
||||
} from './daemon-audit-classifier'
|
||||
import {
|
||||
exactDaemonIncarnationForPidRecord,
|
||||
sameEndpointIdentity
|
||||
} from './daemon-endpoint-incarnation'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
|
||||
import { notifyDaemonAuditListeners } from './daemon-listener-registry'
|
||||
import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions'
|
||||
import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
|
||||
export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions {
|
||||
protected async ensureConnected(deadlineMs?: number): Promise<void> {
|
||||
try {
|
||||
// Why: destructive teardown bounds the handshake by its deadline so a wedged
|
||||
// connect fails fast; undefined keeps the default connect behavior.
|
||||
await (deadlineMs !== undefined
|
||||
? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
|
||||
: this.client.ensureConnected())
|
||||
} finally {
|
||||
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
}
|
||||
this.recordAuthenticatedIdentity()
|
||||
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
|
||||
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
|
||||
const isFreshConnection = this.removeEventListener === null
|
||||
this.setupEventRouting()
|
||||
this.scheduleCheckpointTimer()
|
||||
this.flushOwedProducerResumes()
|
||||
if (isFreshConnection) {
|
||||
this.resyncBackgroundedSessions()
|
||||
}
|
||||
}
|
||||
|
||||
protected recordAuthenticatedIdentity(): void {
|
||||
const current = this.client.getDaemonIdentity()
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const previous = this.lastAuthenticatedIdentity
|
||||
if (previous && sameEndpointIdentity(previous, current)) {
|
||||
return
|
||||
}
|
||||
this.lastAuthenticatedIdentity = { ...current }
|
||||
this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord)
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
const event = { previous: { ...previous }, current: { ...current } }
|
||||
notifyDaemonAuditListeners(this.identityChangeListeners, event)
|
||||
}
|
||||
|
||||
protected async resolveExactDaemonIncarnation(
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
): Promise<ExactDaemonIncarnation | null> {
|
||||
if (
|
||||
!exactIncarnation ||
|
||||
process.platform !== 'linux' ||
|
||||
(exactIncarnation.linuxStartTicks && exactIncarnation.bootId)
|
||||
) {
|
||||
return exactIncarnation
|
||||
}
|
||||
const cachedIncarnation = exactDaemonIncarnationForPidRecord(
|
||||
exactIncarnation.identity,
|
||||
this.pidRecord
|
||||
)
|
||||
if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) {
|
||||
return cachedIncarnation
|
||||
}
|
||||
const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity)
|
||||
this.pidRecord = pidRecord ?? this.pidRecord
|
||||
return pidRecord?.linuxStartTicks && pidRecord.bootId
|
||||
? {
|
||||
identity: { ...exactIncarnation.identity },
|
||||
linuxStartTicks: pidRecord.linuxStartTicks,
|
||||
bootId: pidRecord.bootId
|
||||
}
|
||||
: exactIncarnation
|
||||
}
|
||||
|
||||
protected cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void {
|
||||
if (
|
||||
exactIncarnation &&
|
||||
this.lastAuthenticatedIdentity &&
|
||||
sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity)
|
||||
) {
|
||||
this.exactDaemonIncarnation = exactIncarnation
|
||||
}
|
||||
}
|
||||
|
||||
protected async readMatchingPidRecord(
|
||||
identity: DaemonEndpointIdentity
|
||||
): Promise<ParsedDaemonPid | null> {
|
||||
if (!this.pidPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8'))
|
||||
return parsed?.pid === identity.pid &&
|
||||
parsed.startedAtMs === identity.startedAtMs &&
|
||||
parsed.launchNonce === identity.launchNonce
|
||||
? parsed
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected observeAuditFailure(
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
|
||||
exactIncarnation = this.exactDaemonIncarnation,
|
||||
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
): void {
|
||||
void this.resolveExactDaemonIncarnation(exactIncarnation)
|
||||
.then((resolvedIncarnation) => {
|
||||
this.cacheExactDaemonIncarnation(resolvedIncarnation)
|
||||
return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, {
|
||||
additionalEvidenceSources,
|
||||
endpointGoneProof
|
||||
})
|
||||
})
|
||||
.then((observation) => this.publishAuditObservation(observation))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
protected publishAuditObservation(observation: DaemonAuditObservation): void {
|
||||
this.lastAuditObservation = observation
|
||||
this.trackAuditEligibility(observation)
|
||||
notifyDaemonAuditListeners(this.auditObservationListeners, observation)
|
||||
}
|
||||
|
||||
protected resyncBackgroundedSessions(): void {
|
||||
for (const id of this.backgroundedSessionIds) {
|
||||
// Harmless no-op for sessions the daemon doesn't know (yet).
|
||||
this.client.notify('setSessionBackground', { sessionId: id, background: true })
|
||||
}
|
||||
}
|
||||
|
||||
protected flushOwedProducerResumes(): void {
|
||||
if (this.producerResumesOwedOnReconnect.size === 0) {
|
||||
return
|
||||
}
|
||||
for (const id of this.producerResumesOwedOnReconnect) {
|
||||
// Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency.
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { getMacDaemonSystemResolverHealth } from './daemon-health'
|
||||
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
|
||||
import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness'
|
||||
import { isDaemonGoneError } from './daemon-endpoint-errors'
|
||||
import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence'
|
||||
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
|
||||
import type { ListSessionsResult } from './types'
|
||||
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
||||
|
||||
export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence {
|
||||
// Why: the token read no longer throws, so audit its absence directly after an authenticated drop.
|
||||
protected isRetiredEndpointTokenMissing(): boolean {
|
||||
return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath)
|
||||
}
|
||||
|
||||
protected async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing()
|
||||
if (missingRetiredEndpointToken) {
|
||||
this.observeAuditFailure(
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
this.exactDaemonIncarnation,
|
||||
['token_file']
|
||||
)
|
||||
}
|
||||
if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) {
|
||||
throw err
|
||||
}
|
||||
if (!this.respawnPromise) {
|
||||
this.respawnPromise = this.doRespawn().finally(() => {
|
||||
this.respawnPromise = null
|
||||
})
|
||||
}
|
||||
await this.respawnPromise
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
// Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn).
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected reconnectAfterWriteFailure(): void {
|
||||
if (
|
||||
this.writeRecoveryPromise ||
|
||||
this.writeRecoveryAttempted ||
|
||||
this.respawnAdoptionClosed ||
|
||||
!this.respawnFn
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.writeRecoveryAttempted = true
|
||||
// Why: the dead endpoint took down every session on this daemon. Signal all
|
||||
// active panes now — while they are still in activeSessionIds, so the
|
||||
// renderer's liveness gate still reads them live — so background panes
|
||||
// remount + re-attach alongside the one that was written, instead of being
|
||||
// left frozen with silently dropped input until each is typed into.
|
||||
this.notifyActiveSessionsWriteUnavailable()
|
||||
const recovery = this.withDaemonRetry(() => this.ensureConnected())
|
||||
.catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error))
|
||||
.finally(() => {
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
if (this.writeRecoveryPromise === recovery) {
|
||||
this.writeRecoveryPromise = null
|
||||
}
|
||||
})
|
||||
this.writeRecoveryPromise = recovery
|
||||
}
|
||||
|
||||
protected notifyActiveSessionsWriteUnavailable(): void {
|
||||
// Snapshot first: a listener that kills a pane would mutate activeSessionIds
|
||||
// mid-iteration and silently skip the sibling this fan-out exists to reach.
|
||||
const ids = [...this.activeSessionIds]
|
||||
for (const id of ids) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.emitWriteUnavailable(id)
|
||||
}
|
||||
}
|
||||
|
||||
protected clearSessionAwaitingDaemonRecovery(sessionId: string): void {
|
||||
this.sessionsAwaitingDaemonRecovery.delete(sessionId)
|
||||
if (this.sessionsAwaitingDaemonRecovery.size === 0) {
|
||||
this.writeRecoveryAttempted = false
|
||||
}
|
||||
}
|
||||
|
||||
protected async withHistorySpawnLock<T>(
|
||||
sessionId: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (!this.historyManager) {
|
||||
return await operation()
|
||||
}
|
||||
const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const tail = previous.then(
|
||||
() => current,
|
||||
() => current
|
||||
)
|
||||
this.historySpawnLocks.set(sessionId, tail)
|
||||
await previous.catch(() => {})
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
release()
|
||||
if (this.historySpawnLocks.get(sessionId) === tail) {
|
||||
this.historySpawnLocks.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void> {
|
||||
if (!this.respawnFn) {
|
||||
return
|
||||
}
|
||||
|
||||
const health = await getMacDaemonSystemResolverHealth(
|
||||
this.socketPath,
|
||||
this.tokenPath,
|
||||
this.protocolVersion
|
||||
)
|
||||
if (health !== 'unhealthy') {
|
||||
return
|
||||
}
|
||||
|
||||
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
|
||||
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
|
||||
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
|
||||
console.warn(
|
||||
daemonLiveSessionCount === null
|
||||
? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified'
|
||||
: `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs.
|
||||
this.fanoutSyntheticExits(-1)
|
||||
if (!this.respawnPromise) {
|
||||
this.respawnPromise = this.doRespawn(
|
||||
'[daemon] macOS system resolver unavailable - respawning daemon',
|
||||
'unhealthy_resolver'
|
||||
).finally(() => {
|
||||
this.respawnPromise = null
|
||||
})
|
||||
}
|
||||
await this.respawnPromise
|
||||
}
|
||||
|
||||
/** Replace a stale packaged daemon only after its live sessions drain. */
|
||||
protected async replaceStaleBundleDaemonBeforeNewPty(): Promise<void> {
|
||||
if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) {
|
||||
return
|
||||
}
|
||||
if (!this.staleBundleReplacementPromise) {
|
||||
this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce(
|
||||
this.runtimeDir,
|
||||
this.packagedAppVersion
|
||||
).finally(() => {
|
||||
this.staleBundleReplacementPromise = null
|
||||
})
|
||||
}
|
||||
await this.staleBundleReplacementPromise
|
||||
}
|
||||
|
||||
protected async replaceStaleBundleDaemonOnce(
|
||||
runtimeDir: string,
|
||||
packagedAppVersion: string
|
||||
): Promise<void> {
|
||||
const stale = await isDaemonStaleForCurrentBundle(
|
||||
runtimeDir,
|
||||
this.socketPath,
|
||||
this.tokenPath,
|
||||
packagedAppVersion,
|
||||
this.protocolVersion
|
||||
)
|
||||
if (!stale) {
|
||||
return
|
||||
}
|
||||
|
||||
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
|
||||
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
|
||||
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
|
||||
console.warn(
|
||||
daemonLiveSessionCount === null
|
||||
? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified'
|
||||
: `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.fanoutSyntheticExits(-1)
|
||||
if (!this.respawnPromise) {
|
||||
this.respawnPromise = this.doRespawn(
|
||||
'[daemon] Packaged daemon is stale - respawning from the current app bundle',
|
||||
'stale_bundle'
|
||||
).finally(() => {
|
||||
this.respawnPromise = null
|
||||
})
|
||||
}
|
||||
await this.respawnPromise
|
||||
}
|
||||
|
||||
/** Replace a TCC-severed daemon only after its live sessions drain. */
|
||||
protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void> {
|
||||
// Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS.
|
||||
if (!this.respawnFn || !this.runtimeDir) {
|
||||
return
|
||||
}
|
||||
|
||||
const health = await getMacDaemonTccAttributionHealth(
|
||||
this.runtimeDir,
|
||||
this.socketPath,
|
||||
this.tokenPath,
|
||||
this.protocolVersion
|
||||
)
|
||||
if (health !== 'severed') {
|
||||
return
|
||||
}
|
||||
|
||||
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
|
||||
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
|
||||
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
|
||||
console.warn(
|
||||
daemonLiveSessionCount === null
|
||||
? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified'
|
||||
: `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.fanoutSyntheticExits(-1)
|
||||
if (!this.respawnPromise) {
|
||||
this.respawnPromise = this.doRespawn(
|
||||
'[daemon] macOS TCC attribution severed - respawning daemon under the current app binary',
|
||||
'severed_tcc_attribution'
|
||||
).finally(() => {
|
||||
this.respawnPromise = null
|
||||
})
|
||||
}
|
||||
await this.respawnPromise
|
||||
}
|
||||
|
||||
protected async getDaemonLiveSessionCount(): Promise<number | null> {
|
||||
try {
|
||||
await this.client.ensureConnected()
|
||||
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
|
||||
return result.sessions.filter((session) => session.isAlive).length
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void {
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.backgroundStreamListeners]) {
|
||||
listener(payload)
|
||||
}
|
||||
}
|
||||
|
||||
protected async doRespawn(
|
||||
message = '[daemon] Daemon died — respawning',
|
||||
reason: DaemonRespawnReason = 'daemon_died'
|
||||
): Promise<void> {
|
||||
console.warn(message)
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
this.client.disconnect()
|
||||
const releaseAdoptionLease = await this.respawnFn!(reason)
|
||||
if (this.respawnAdoptionClosed) {
|
||||
// Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns.
|
||||
releaseAdoptionLease?.()
|
||||
throw new Error('Daemon adapter closed during respawn')
|
||||
}
|
||||
this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null
|
||||
}
|
||||
|
||||
protected releasePendingRespawnAdoptionLease(): void {
|
||||
const release = this.pendingRespawnAdoptionRelease
|
||||
this.pendingRespawnAdoptionRelease = null
|
||||
release?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { DaemonPtySessionInventory } from './daemon-pty-session-inventory'
|
||||
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
|
||||
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
||||
|
||||
export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory {
|
||||
onData(
|
||||
callback: (payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}) => void
|
||||
): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.dataListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.dataListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
this.backgroundStreamListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.backgroundStreamListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.backgroundStreamListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onExit(
|
||||
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
|
||||
): () => void {
|
||||
this.exitListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.exitListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.exitListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
|
||||
this.writeUnavailableListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.writeUnavailableListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.writeUnavailableListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected emitWriteUnavailable(id: string): void {
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.writeUnavailableListeners]) {
|
||||
listener({ id })
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.respawnAdoptionClosed = true
|
||||
this.sessionsAwaitingDaemonRecovery.clear()
|
||||
this.writeRecoveryAttempted = false
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
this.stopCheckpointTimer()
|
||||
this.dirtySessionVersions.clear()
|
||||
this.lastFullCheckpointAt.clear()
|
||||
this.sessionsNeedingFullCheckpoint.clear()
|
||||
this.sessionsNeedingLiveCheckpoint.clear()
|
||||
this.sessionsNeedingContinuityCheckpoint.clear()
|
||||
this.overlayDeadlineWarnedSessionIds.clear()
|
||||
this.periodicDeadlineWarnedSessionIds.clear()
|
||||
this.nonFinalAdmissionDeniedSessionIds.clear()
|
||||
this.coldRestoreCache.clear()
|
||||
this.wslDistrosBySessionId.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.auditObservationListeners.length = 0
|
||||
this.identityChangeListeners.length = 0
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
|
||||
// cleanly ended so they don't trigger false cold restores.
|
||||
if (this.historyManager) {
|
||||
void this.historyManager
|
||||
.dispose()
|
||||
.catch((err) => console.warn('[history] dispose failed:', err))
|
||||
}
|
||||
this.client.disconnect()
|
||||
}
|
||||
|
||||
async establishLifecycleLease(): Promise<void> {
|
||||
if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
|
||||
return
|
||||
}
|
||||
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
|
||||
await this.client.ensureConnected()
|
||||
this.recordAuthenticatedIdentity()
|
||||
}
|
||||
|
||||
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
|
||||
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
|
||||
async disconnectOnly(): Promise<void> {
|
||||
if (!this.disconnectOnlyPromise) {
|
||||
this.respawnAdoptionClosed = true
|
||||
this.sessionsAwaitingDaemonRecovery.clear()
|
||||
this.writeRecoveryAttempted = false
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns])
|
||||
}
|
||||
await this.disconnectOnlyPromise
|
||||
}
|
||||
|
||||
protected async finishDisconnectOnly(keepHistoryShutdowns: Promise<void>[]): Promise<void> {
|
||||
// Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations.
|
||||
await Promise.allSettled(keepHistoryShutdowns)
|
||||
this.respawnAdoptionClosed = true
|
||||
// Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon
|
||||
// later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs.
|
||||
await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), {
|
||||
rescheduleDirty: false
|
||||
})
|
||||
this.dirtySessionVersions.clear()
|
||||
this.lastFullCheckpointAt.clear()
|
||||
this.coldRestoreCache.clear()
|
||||
this.wslDistrosBySessionId.clear()
|
||||
// Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window.
|
||||
for (const id of this.pausedProducerSessionIds) {
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
|
||||
try {
|
||||
// Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path.
|
||||
const deadlineMs = Date.now() + 250
|
||||
if (!this.client.isConnected()) {
|
||||
await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
|
||||
}
|
||||
await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now()))
|
||||
} catch {
|
||||
// An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty.
|
||||
}
|
||||
}
|
||||
this.client.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class FinalCheckpointWaitExpiredError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`)
|
||||
this.name = 'FinalCheckpointWaitExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export class TerminalKilledError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Session "${sessionId}" was explicitly killed`)
|
||||
this.name = 'TerminalKilledError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import {
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
type ListSessionsResult,
|
||||
type SessionInfo
|
||||
} from './types'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
|
||||
export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots {
|
||||
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
|
||||
protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean {
|
||||
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
|
||||
}
|
||||
|
||||
async hasChildProcesses(id: string): Promise<boolean> {
|
||||
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
|
||||
return true
|
||||
}
|
||||
return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id))
|
||||
}
|
||||
|
||||
async inspectProcess(id: string): Promise<PtyProcessInspection> {
|
||||
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
|
||||
return { foregroundProcess: null, hasChildProcesses: true, unavailable: true }
|
||||
}
|
||||
if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) {
|
||||
// Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the
|
||||
// one call they do support instead of throwing, or completion detection stays dead until recreate.
|
||||
// Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than
|
||||
// reading as an idle foreground and dispatching a false completion.
|
||||
const { foregroundProcess } = await this.client.request<{
|
||||
foregroundProcess: string | null
|
||||
}>('getForegroundProcess', { sessionId: id })
|
||||
return {
|
||||
foregroundProcess,
|
||||
hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess)
|
||||
}
|
||||
}
|
||||
return this.client.request<{
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
}>('inspectProcess', { sessionId: id })
|
||||
}
|
||||
|
||||
async getForegroundProcess(id: string): Promise<string | null> {
|
||||
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await this.client.request<{ foregroundProcess: string | null }>(
|
||||
'getForegroundProcess',
|
||||
{ sessionId: id }
|
||||
)
|
||||
return result.foregroundProcess
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(id: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await this.client.request<{ foregroundProcess: string | null }>(
|
||||
'confirmForegroundProcess',
|
||||
{ sessionId: id }
|
||||
)
|
||||
return result.foregroundProcess
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async serialize(ids: string[]): Promise<string> {
|
||||
const sessions: Record<string, { initialCwd?: string }> = {}
|
||||
for (const id of ids) {
|
||||
sessions[id] = { initialCwd: this.initialCwds.get(id) }
|
||||
}
|
||||
return JSON.stringify(sessions)
|
||||
}
|
||||
|
||||
async revive(_state: string): Promise<void> {
|
||||
// Sessions already live in the daemon — no revival needed
|
||||
}
|
||||
|
||||
/** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId
|
||||
* no longer exists, and caches alive session IDs.
|
||||
*
|
||||
* IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed
|
||||
* worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live
|
||||
* worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false
|
||||
* orphans. No production caller yet; wire the alias in when it gains one. */
|
||||
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
|
||||
alive: string[]
|
||||
killed: string[]
|
||||
}> {
|
||||
await this.ensureConnected()
|
||||
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
|
||||
|
||||
const alive: string[] = []
|
||||
const killed: string[] = []
|
||||
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
// Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan.
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
|
||||
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
|
||||
try {
|
||||
await this.client.request('kill', { sessionId: session.sessionId })
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
killed.push(session.sessionId)
|
||||
} else {
|
||||
alive.push(session.sessionId)
|
||||
// Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data.
|
||||
this.activeSessionIds.add(session.sessionId)
|
||||
await this.reconcileLiveSessionHistory(session).catch((err) =>
|
||||
console.warn('[history] live-session reconciliation failed:', session.sessionId, err)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { alive, killed }
|
||||
}
|
||||
|
||||
protected async reconcileLiveSessionHistory(session: SessionInfo): Promise<void> {
|
||||
const historyManager = this.historyManager
|
||||
const historyReader = this.historyReader
|
||||
if (!historyManager || !historyReader) {
|
||||
return
|
||||
}
|
||||
await this.withHistorySpawnLock(session.sessionId, async () => {
|
||||
if (historyManager.hasWriter(session.sessionId)) {
|
||||
return
|
||||
}
|
||||
const probe = historyReader.probeRestorableHistory(session.sessionId)
|
||||
if (probe.status === 'unreadable') {
|
||||
return
|
||||
}
|
||||
if (probe.status === 'none') {
|
||||
await historyManager.openSession(session.sessionId, {
|
||||
cwd: session.cwd ?? '',
|
||||
cols: session.cols,
|
||||
rows: session.rows
|
||||
})
|
||||
} else {
|
||||
const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId)
|
||||
try {
|
||||
const detection = await historyReader.detectColdRestoreState(session.sessionId, {
|
||||
wslDistro: session.wslDistro ?? undefined
|
||||
})
|
||||
if (
|
||||
detection.status === 'unreadable' ||
|
||||
(detection.status === 'restored' && detection.hasUnreadableRecovery)
|
||||
) {
|
||||
historyManager.suspendSession(session.sessionId, recoveryFreeze)
|
||||
return
|
||||
}
|
||||
historyManager.reopenSession(session.sessionId, recoveryFreeze)
|
||||
} finally {
|
||||
historyManager.abandonRecoveryFreeze(recoveryFreeze)
|
||||
}
|
||||
}
|
||||
if (historyManager.hasWriter(session.sessionId)) {
|
||||
this.sessionsNeedingFullCheckpoint.add(session.sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.add(session.sessionId)
|
||||
this.lastFullCheckpointAt.delete(session.sessionId)
|
||||
this.markSessionDirty(session.sessionId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CreateOrAttachResult } from './types'
|
||||
import type { PtySpawnResult } from '../providers/types'
|
||||
|
||||
export function providerSequenceFromCreateOrAttach(
|
||||
result: CreateOrAttachResult
|
||||
): PtySpawnResult['providerSequence'] {
|
||||
if (result.isNew) {
|
||||
return { value: 0, generation: 'reset' }
|
||||
}
|
||||
return typeof result.snapshot?.outputSequence === 'number'
|
||||
? { value: result.snapshot.outputSequence, generation: 'continued' }
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { DaemonClient } from './client'
|
||||
import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event'
|
||||
import type {
|
||||
DaemonAuditContext,
|
||||
DaemonAuditObservation,
|
||||
DaemonAuditTrigger
|
||||
} from './daemon-audit-classifier'
|
||||
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
|
||||
import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
|
||||
import { readDaemonPidRecord, sameEndpointIdentity } from './daemon-endpoint-incarnation'
|
||||
import { removeDaemonListener } from './daemon-listener-registry'
|
||||
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
import {
|
||||
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
|
||||
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
|
||||
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact,
|
||||
supportsPtyStartupIngress,
|
||||
type ListSessionsResult,
|
||||
type TakePendingOutputResult
|
||||
} from './types'
|
||||
import { ColdRestorePayloadCache } from './cold-restore-payload-cache'
|
||||
import {
|
||||
HistoryManager,
|
||||
type HistoryCheckpointResult,
|
||||
type HistoryRecoveryFreeze
|
||||
} from './history-manager'
|
||||
import { HistoryReader } from './history-reader'
|
||||
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
|
||||
import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
|
||||
|
||||
export type PendingDaemonSpawnOperation = {
|
||||
exitsBySessionId: Map<string, { incarnationId?: string }[]>
|
||||
ignoredExitIncarnationIds: Set<string>
|
||||
ignoreNextExit: boolean
|
||||
acceptsUnroutedExit: boolean
|
||||
}
|
||||
|
||||
export type HistoryRecoveryContext = {
|
||||
freeze: HistoryRecoveryFreeze | null
|
||||
unreadableSessionId: string | null
|
||||
identityChanged: boolean
|
||||
}
|
||||
|
||||
export type SnapshotCheckpointResult = {
|
||||
checkpoint: HistoryCheckpointResult
|
||||
snapshot: NonNullable<TakePendingOutputResult['snapshot']> | null
|
||||
}
|
||||
|
||||
export type DaemonPtyAdapterOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
profileScope?: string
|
||||
protocolVersion?: number
|
||||
historyPath?: string
|
||||
runtimeDir?: string
|
||||
packagedAppVersion?: string | null
|
||||
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type DaemonRespawnReason =
|
||||
| 'daemon_died'
|
||||
| 'unhealthy_resolver'
|
||||
| 'stale_bundle'
|
||||
| 'severed_tcc_attribution'
|
||||
|
||||
export type DaemonIdentityChangeEvent = {
|
||||
previous: DaemonEndpointIdentity
|
||||
current: DaemonEndpointIdentity
|
||||
}
|
||||
|
||||
export abstract class DaemonPtyRuntimeState {
|
||||
readonly protocolVersion: number
|
||||
protected socketPath: string
|
||||
protected tokenPath: string
|
||||
protected pidPath: string | null
|
||||
protected pidRecord: ParsedDaemonPid | null
|
||||
protected client: DaemonClient
|
||||
protected auditContext: DaemonAuditContext
|
||||
protected lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
|
||||
protected exactDaemonIncarnation: ExactDaemonIncarnation | null = null
|
||||
protected lastAuditObservation: DaemonAuditObservation | null = null
|
||||
protected readonly trackAuditEligibility = createDaemonAuditEligibilityTracker()
|
||||
protected auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
|
||||
protected identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
|
||||
protected historyManager: HistoryManager | null
|
||||
protected historyReader: HistoryReader | null
|
||||
protected respawnFn: DaemonPtyAdapterOptions['respawn'] | null
|
||||
protected runtimeDir: string | null
|
||||
protected packagedAppVersion: string | null
|
||||
protected readonly executionHostId: ExecutionHostId
|
||||
protected readonly ownerEndpointRef: string
|
||||
protected pendingRespawnAdoptionRelease: (() => void) | null = null
|
||||
protected respawnAdoptionClosed = false
|
||||
protected respawnPromise: Promise<void> | null = null
|
||||
protected staleBundleReplacementPromise: Promise<void> | null = null
|
||||
protected writeRecoveryPromise: Promise<void> | null = null
|
||||
protected writeRecoveryAttempted = false
|
||||
protected dataListeners: ((payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}) => void)[] = []
|
||||
protected exitListeners: ((payload: {
|
||||
id: string
|
||||
code: number
|
||||
incarnationId?: PtyIncarnationId
|
||||
cause?: TerminalExitCause
|
||||
}) => void)[] = []
|
||||
protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
|
||||
protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
|
||||
protected removeEventListener: (() => void) | null = null
|
||||
protected initialCwds = new Map<string, string>()
|
||||
protected wslDistrosBySessionId = new Map<string, string>()
|
||||
protected killedSessionTombstones = new Map<string, number>()
|
||||
protected sleepRestoreSessionIds = new Set<string>()
|
||||
protected coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => {
|
||||
this.sleepRestoreSessionIds.delete(sessionId)
|
||||
})
|
||||
protected activeSessionIds = new Set<string>()
|
||||
protected getSizeUnsupported = false
|
||||
protected sessionsAwaitingDaemonRecovery = new Set<string>()
|
||||
protected sessionIncarnations = new Map<string, string>()
|
||||
protected sessionOwnerIdentities = new Map<string, DaemonEndpointIdentity>()
|
||||
protected livenessInventoryInFlight: Promise<ListSessionsResult> | null = null
|
||||
protected pendingSpawnOperationsBySessionId = new Map<string, Set<PendingDaemonSpawnOperation>>()
|
||||
protected pendingClaimSpawnOperations = new Set<PendingDaemonSpawnOperation>()
|
||||
protected historySpawnLocks = new Map<string, Promise<void>>()
|
||||
protected dirtySessionVersions = new Map<string, number>()
|
||||
protected sessionsNeedingFullCheckpoint = new Set<string>()
|
||||
protected sessionsNeedingLiveCheckpoint = new Set<string>()
|
||||
protected sessionsNeedingContinuityCheckpoint = new Set<string>()
|
||||
protected checkpointTimer: ReturnType<typeof setTimeout> | null = null
|
||||
protected checkpointInFlight: Promise<void> | null = null
|
||||
protected checkpointQueue = new CheckpointSessionQueue()
|
||||
protected nonFinalCheckpointAdmissionSessionIds = new Set<string>()
|
||||
protected nonFinalAdmissionDeniedSessionIds = new Set<string>()
|
||||
protected nonFinalGlobalAdmissionWarningActive = false
|
||||
protected overlayDeadlineWarnedSessionIds = new Set<string>()
|
||||
protected periodicDeadlineWarnedSessionIds = new Set<string>()
|
||||
protected keepHistoryShutdowns = new Set<Promise<void>>()
|
||||
protected disconnectOnlyPromise: Promise<void> | null = null
|
||||
protected supportsCheckpoints: boolean
|
||||
protected supportsIncrementalCheckpoints: boolean
|
||||
protected supportsProducerFlowControl: boolean
|
||||
protected supportsAuthoritativeBufferSnapshots: boolean
|
||||
protected supportsStartupIngress: boolean
|
||||
protected pausedProducerSessionIds = new Set<string>()
|
||||
protected backgroundedSessionIds = new Set<string>()
|
||||
protected producerResumesOwedOnReconnect = new Set<string>()
|
||||
protected static CHECKPOINT_INTERVAL_MS = 5_000
|
||||
protected static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000
|
||||
protected static MAX_CONCURRENT_CHECKPOINTS = 4
|
||||
protected static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
|
||||
protected lastFullCheckpointAt = new Map<string, number>()
|
||||
|
||||
protected abstract observeAuditFailure(
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
|
||||
exactIncarnation?: ExactDaemonIncarnation | null,
|
||||
additionalEvidenceSources?: readonly DaemonEvidenceSource[],
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
): void
|
||||
|
||||
constructor(opts: DaemonPtyAdapterOptions) {
|
||||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||
this.socketPath = opts.socketPath
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.pidPath = opts.pidPath ?? null
|
||||
this.pidRecord = readDaemonPidRecord(this.pidPath)
|
||||
this.auditContext = {
|
||||
protocolGeneration: this.protocolVersion,
|
||||
provider: 'local-daemon',
|
||||
endpoint: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket',
|
||||
profileScope: opts.profileScope ?? ''
|
||||
}
|
||||
this.client = new DaemonClient({
|
||||
socketPath: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
protocolVersion: opts.protocolVersion
|
||||
})
|
||||
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
|
||||
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
|
||||
this.respawnFn = opts.respawn ?? null
|
||||
this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
|
||||
this.packagedAppVersion = opts.packagedAppVersion ?? null
|
||||
this.executionHostId = opts.executionHostId ?? 'local'
|
||||
this.ownerEndpointRef = `daemon-v${this.protocolVersion}`
|
||||
this.supportsCheckpoints = this.protocolVersion >= 4
|
||||
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
|
||||
this.supportsProducerFlowControl = this.protocolVersion >= 19
|
||||
this.supportsAuthoritativeBufferSnapshots =
|
||||
this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION
|
||||
this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion)
|
||||
this.client.onDisconnected(() => {
|
||||
if (!this.respawnAdoptionClosed) {
|
||||
this.writeRecoveryAttempted = false
|
||||
for (const id of this.activeSessionIds) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
}
|
||||
}
|
||||
for (const id of this.pausedProducerSessionIds) {
|
||||
this.producerResumesOwedOnReconnect.add(id)
|
||||
}
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.observeAuditFailure('transport_closed')
|
||||
})
|
||||
}
|
||||
|
||||
supportsGitCredentialGuardHost(): boolean {
|
||||
return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
canProvideAuthoritativeBufferSnapshot(_id: string): boolean {
|
||||
return this.supportsAuthoritativeBufferSnapshots
|
||||
}
|
||||
|
||||
protected get canDelegateBackgroundToDaemon(): boolean {
|
||||
return (
|
||||
this.supportsAuthoritativeBufferSnapshots &&
|
||||
supportsMode2031UnsubscribeFact(this.protocolVersion)
|
||||
)
|
||||
}
|
||||
|
||||
getHistoryManager(): HistoryManager | null {
|
||||
return this.historyManager
|
||||
}
|
||||
|
||||
getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null {
|
||||
return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null
|
||||
}
|
||||
|
||||
protected ownerIdentity(
|
||||
sessionIncarnationId?: PtyIncarnationId
|
||||
): TerminalOwnerIdentity | undefined {
|
||||
const daemon = this.lastAuthenticatedIdentity
|
||||
if (!daemon || !sessionIncarnationId) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
executionHostId: this.executionHostId,
|
||||
ownerKind: 'daemon',
|
||||
ownerIncarnationId: daemon.launchNonce,
|
||||
sessionIncarnationId,
|
||||
protocolVersion: this.protocolVersion,
|
||||
endpointRef: this.ownerEndpointRef
|
||||
}
|
||||
}
|
||||
|
||||
getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null {
|
||||
if (!this.hasExactSessionAuthority(id)) {
|
||||
return null
|
||||
}
|
||||
return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null
|
||||
}
|
||||
|
||||
protected recordSessionAuthority(id: string, incarnationId?: PtyIncarnationId): void {
|
||||
if (!incarnationId || !this.lastAuthenticatedIdentity) {
|
||||
return
|
||||
}
|
||||
this.sessionIncarnations.set(id, incarnationId)
|
||||
this.sessionOwnerIdentities.set(id, { ...this.lastAuthenticatedIdentity })
|
||||
}
|
||||
|
||||
protected hasExactSessionAuthority(
|
||||
id: string,
|
||||
expectedIncarnationId?: PtyIncarnationId
|
||||
): boolean {
|
||||
const sessionIncarnationId = this.sessionIncarnations.get(id)
|
||||
const ownerIdentity = this.sessionOwnerIdentities.get(id)
|
||||
const currentIdentity = this.lastAuthenticatedIdentity
|
||||
return Boolean(
|
||||
sessionIncarnationId &&
|
||||
ownerIdentity &&
|
||||
currentIdentity &&
|
||||
sameEndpointIdentity(ownerIdentity, currentIdentity) &&
|
||||
(expectedIncarnationId === undefined || expectedIncarnationId === sessionIncarnationId)
|
||||
)
|
||||
}
|
||||
|
||||
getLastAuditObservation(): DaemonAuditObservation | null {
|
||||
return this.lastAuditObservation
|
||||
}
|
||||
|
||||
onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void {
|
||||
this.identityChangeListeners.push(listener)
|
||||
return () => removeDaemonListener(this.identityChangeListeners, listener)
|
||||
}
|
||||
|
||||
onAuditEligibilityObservation(
|
||||
listener: (observation: DaemonAuditObservation) => void
|
||||
): () => void {
|
||||
this.auditObservationListeners.push(listener)
|
||||
return () => removeDaemonListener(this.auditObservationListeners, listener)
|
||||
}
|
||||
|
||||
supportsAgentSessionClaims(): boolean {
|
||||
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
providesAgentSessionOwnerListings(_ptyId: string): boolean {
|
||||
return this.supportsAgentSessionClaims()
|
||||
}
|
||||
|
||||
supportsAgentSessionCreateOperations(): boolean {
|
||||
return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { isUnknownRequestTypeError } from './daemon-endpoint-errors'
|
||||
import { sameEndpointIdentity } from './daemon-endpoint-incarnation'
|
||||
import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn'
|
||||
import type { ListSessionsResult } from './types'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
|
||||
|
||||
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
|
||||
|
||||
export abstract class DaemonPtySessionAuthority extends DaemonPtySessionSpawn {
|
||||
private listSessionsForLiveness(): Promise<ListSessionsResult> {
|
||||
if (this.livenessInventoryInFlight) {
|
||||
return this.livenessInventoryInFlight
|
||||
}
|
||||
const request = this.client.request<ListSessionsResult>(
|
||||
'listSessions',
|
||||
undefined,
|
||||
LIVENESS_PROBE_TIMEOUT_MS
|
||||
)
|
||||
this.livenessInventoryInFlight = request
|
||||
void request.then(
|
||||
() => {
|
||||
if (this.livenessInventoryInFlight === request) {
|
||||
this.livenessInventoryInFlight = null
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (this.livenessInventoryInFlight === request) {
|
||||
this.livenessInventoryInFlight = null
|
||||
}
|
||||
}
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
async probePtyLiveness(
|
||||
id: string,
|
||||
expectedIncarnationId?: PtyIncarnationId,
|
||||
expectedOwnerIdentity?: TerminalOwnerIdentity
|
||||
): Promise<boolean | null> {
|
||||
try {
|
||||
await this.ensureConnected(Date.now() + LIVENESS_PROBE_TIMEOUT_MS)
|
||||
const ownerIdentityAtProbe = this.lastAuthenticatedIdentity
|
||||
? { ...this.lastAuthenticatedIdentity }
|
||||
: null
|
||||
const canConfirmAbsence =
|
||||
this.hasExactSessionAuthority(id, expectedIncarnationId) &&
|
||||
(expectedOwnerIdentity === undefined ||
|
||||
this.ownerIdentity(expectedIncarnationId ?? this.sessionIncarnations.get(id))
|
||||
?.ownerIncarnationId === expectedOwnerIdentity.ownerIncarnationId)
|
||||
if (expectedOwnerIdentity !== undefined && !canConfirmAbsence) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
(expectedIncarnationId === undefined || canConfirmAbsence) &&
|
||||
!this.getSizeUnsupported &&
|
||||
this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION
|
||||
) {
|
||||
try {
|
||||
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
|
||||
'getSize',
|
||||
{ sessionId: id },
|
||||
LIVENESS_PROBE_TIMEOUT_MS
|
||||
)
|
||||
if (
|
||||
!ownerIdentityAtProbe ||
|
||||
!this.lastAuthenticatedIdentity ||
|
||||
!sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return result.size !== null ? true : canConfirmAbsence ? false : null
|
||||
} catch (error) {
|
||||
if (!isUnknownRequestTypeError(error)) {
|
||||
throw error
|
||||
}
|
||||
this.getSizeUnsupported = true
|
||||
}
|
||||
}
|
||||
const { sessions } = await this.listSessionsForLiveness()
|
||||
if (
|
||||
!ownerIdentityAtProbe ||
|
||||
!this.lastAuthenticatedIdentity ||
|
||||
!sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const liveSession = sessions.find((session) => session.sessionId === id && session.isAlive)
|
||||
if (liveSession) {
|
||||
this.recordSessionAuthority(id, liveSession.incarnationId)
|
||||
if (
|
||||
expectedIncarnationId !== undefined &&
|
||||
liveSession.incarnationId !== expectedIncarnationId
|
||||
) {
|
||||
return canConfirmAbsence ? false : null
|
||||
}
|
||||
return true
|
||||
}
|
||||
return canConfirmAbsence ? false : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { ColdRestorePayload } from './cold-restore-payload-cache'
|
||||
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
|
||||
import { DaemonPtySessionAuthority } from './daemon-pty-session-authority'
|
||||
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
|
||||
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
import { SessionNotFoundError, type CreateOrAttachResult } from './types'
|
||||
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import type { PtySpawnResult } from '../providers/types'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
export { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-authority'
|
||||
|
||||
const MAX_TOMBSTONES = 1000
|
||||
|
||||
export abstract class DaemonPtySessionControl extends DaemonPtySessionAuthority {
|
||||
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence'> | void> {
|
||||
await this.ensureConnected()
|
||||
if (!this.canDelegateBackgroundToDaemon) {
|
||||
this.setPtyBackgrounded(id, false)
|
||||
}
|
||||
|
||||
// Why size-first: attach must ride the session's own geometry — a fixed
|
||||
// 80×24 here could resize a live agent's TUI — and a null size means the
|
||||
// daemon cannot prove the session, so refuse rather than risk a create.
|
||||
const size = await this.getAppliedSize(id)
|
||||
if (!size) {
|
||||
throw new SessionNotFoundError(id)
|
||||
}
|
||||
const result = await this.client.request<CreateOrAttachResult>('createOrAttach', {
|
||||
sessionId: id,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
attachOnly: true
|
||||
})
|
||||
if (result.isNew) {
|
||||
// Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn
|
||||
// instead of publishing a fresh shell as an attach.
|
||||
await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => {
|
||||
// Why surface, not swallow: a failed retire leaves an untracked orphan shell.
|
||||
console.warn('[daemon] attach-only retire of accidental legacy spawn failed', {
|
||||
sessionId: id,
|
||||
error
|
||||
})
|
||||
})
|
||||
throw new SessionNotFoundError(id)
|
||||
}
|
||||
this.clearSessionAwaitingDaemonRecovery(id)
|
||||
const providerSequence = providerSequenceFromCreateOrAttach(result)
|
||||
return providerSequence ? { providerSequence } : undefined
|
||||
}
|
||||
|
||||
hasPty(id: string): boolean {
|
||||
return this.activeSessionIds.has(id)
|
||||
}
|
||||
|
||||
write(id: string, data: string): boolean {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(
|
||||
id,
|
||||
this.client.notify('write', {
|
||||
sessionId: id,
|
||||
data,
|
||||
...(this.sessionIncarnations.get(id)
|
||||
? { expectedIncarnationId: this.sessionIncarnations.get(id) }
|
||||
: {})
|
||||
}),
|
||||
recoverable
|
||||
)
|
||||
}
|
||||
|
||||
async writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(
|
||||
id,
|
||||
await this.client.notifyWithSettlement('write', {
|
||||
sessionId: id,
|
||||
data,
|
||||
...(this.sessionIncarnations.get(id)
|
||||
? { expectedIncarnationId: this.sessionIncarnations.get(id) }
|
||||
: {})
|
||||
}),
|
||||
recoverable
|
||||
)
|
||||
}
|
||||
|
||||
protected prepareWrite(id: string): boolean {
|
||||
this.markSessionDirty(id)
|
||||
// Why recoverable and not just active: rejecting a write asks the pane to remount,
|
||||
// which only helps if this endpoint can come back. A legacy adapter has no respawn,
|
||||
// so its reattach fails and the pane rebuilds empty — losing scrollback the user
|
||||
// could still read. Keep the pre-existing silent drop for those.
|
||||
const recoverable =
|
||||
this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn)
|
||||
if (
|
||||
recoverable &&
|
||||
(this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected())
|
||||
) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return recoverable
|
||||
}
|
||||
|
||||
protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean {
|
||||
if (!delivered && recoverable) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
this.markSessionDirty(id)
|
||||
this.client.notify('resize', {
|
||||
sessionId: id,
|
||||
cols,
|
||||
rows,
|
||||
...(this.sessionIncarnations.get(id)
|
||||
? { expectedIncarnationId: this.sessionIncarnations.get(id) }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
pauseProducer(id: string): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.add(id)
|
||||
this.client.notify('pausePty', { sessionId: id })
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.producerResumesOwedOnReconnect.delete(id)
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.delete(id)
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
|
||||
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
|
||||
setPtyBackgrounded(id: string, background: boolean): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
// Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream.
|
||||
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
|
||||
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
|
||||
// retract it, so a TUI exiting while hidden would strand the subscription and the
|
||||
// next theme flip would inject CSI 997 into its replacement shell. Declining to
|
||||
// background keeps main's scanner — which emits both facts — authoritative.
|
||||
const safeBackground = this.canDelegateBackgroundToDaemon && background
|
||||
if (safeBackground) {
|
||||
this.backgroundedSessionIds.add(id)
|
||||
} else {
|
||||
this.backgroundedSessionIds.delete(id)
|
||||
}
|
||||
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
|
||||
}
|
||||
|
||||
async shutdown(
|
||||
id: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<void> {
|
||||
if (opts.keepHistory && this.disconnectOnlyPromise) {
|
||||
throw new Error('Cannot keep history after daemon disconnect has started')
|
||||
}
|
||||
const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts))
|
||||
if (!opts.keepHistory) {
|
||||
await shutdown
|
||||
return
|
||||
}
|
||||
this.keepHistoryShutdowns.add(shutdown)
|
||||
try {
|
||||
await shutdown
|
||||
} finally {
|
||||
this.keepHistoryShutdowns.delete(shutdown)
|
||||
}
|
||||
}
|
||||
|
||||
protected async shutdownWithHistoryLock(
|
||||
id: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<void> {
|
||||
// Why: shutdown can be the first lazy-client operation after restart; connect
|
||||
// before killing so a healthy daemon session is not orphaned (#7742). Connect,
|
||||
// the final-checkpoint wait, and kill all share the caller's one absolute
|
||||
// deadline, so neither a wedged handshake nor a stalled history write can burn
|
||||
// the whole teardown budget before the kill even starts. Only the waits are
|
||||
// bounded — the checkpoint itself stays deadline-free and lossless (STA-4228).
|
||||
await this.ensureConnected(opts.deadlineMs)
|
||||
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
|
||||
// Force a final snapshot so wake can restore the pane users left.
|
||||
if (opts.keepHistory) {
|
||||
const committed = await this.runExclusiveCheckpoint(
|
||||
async () => {
|
||||
await this.checkpointSessions([id], { final: true, teardown: true })
|
||||
},
|
||||
{ callerDeadlineMs: opts.deadlineMs }
|
||||
)
|
||||
// Why throw instead of killing anyway: the snapshot the caller asked us to prove is still
|
||||
// being written. Killing here would race the wake-time restore source to disk, so report the
|
||||
// pty unverified and leave it alive — worktree sleep declines to commit it and retries.
|
||||
if (!committed) {
|
||||
throw new FinalCheckpointWaitExpiredError(id)
|
||||
}
|
||||
const wslDistro = this.wslDistrosBySessionId.get(id)
|
||||
const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro })
|
||||
const detected = detection?.status === 'restored' ? detection.restoreInfo : null
|
||||
const restoreInfo = detected
|
||||
? {
|
||||
...detected,
|
||||
cwd:
|
||||
normalizeWslColdRestoreCwd({
|
||||
recoveredCwd: detected.cwd,
|
||||
requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(),
|
||||
wslDistro
|
||||
}) ?? ''
|
||||
}
|
||||
: null
|
||||
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(id, coldRestore)
|
||||
if (this.coldRestoreCache.has(id)) {
|
||||
this.sleepRestoreSessionIds.add(id)
|
||||
}
|
||||
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
|
||||
this.historyManager?.suspendSession(id)
|
||||
} else if (
|
||||
detection?.status === 'unreadable' ||
|
||||
(detection?.status === 'restored' && detection.hasUnreadableRecovery)
|
||||
) {
|
||||
this.historyManager?.suspendSession(id)
|
||||
}
|
||||
}
|
||||
await this.client.request(
|
||||
'kill',
|
||||
{ sessionId: id, immediate: opts.immediate ?? false },
|
||||
remainingDaemonRequestTimeoutMs(opts.deadlineMs)
|
||||
)
|
||||
this.activeSessionIds.delete(id)
|
||||
this.sessionIncarnations.delete(id)
|
||||
this.sessionOwnerIdentities.delete(id)
|
||||
this.clearSessionAwaitingDaemonRecovery(id)
|
||||
this.dirtySessionVersions.delete(id)
|
||||
if (!opts.keepHistory) {
|
||||
this.coldRestoreCache.delete(id)
|
||||
this.sleepRestoreSessionIds.delete(id)
|
||||
}
|
||||
// Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory).
|
||||
this.sessionsNeedingFullCheckpoint.delete(id)
|
||||
this.sessionsNeedingLiveCheckpoint.delete(id)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(id)
|
||||
this.overlayDeadlineWarnedSessionIds.delete(id)
|
||||
this.periodicDeadlineWarnedSessionIds.delete(id)
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(id)
|
||||
this.lastFullCheckpointAt.delete(id)
|
||||
this.stopCheckpointTimerIfIdle()
|
||||
this.initialCwds.delete(id)
|
||||
this.wslDistrosBySessionId.delete(id)
|
||||
// Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory).
|
||||
if (this.historyManager && !opts.keepHistory) {
|
||||
await this.historyManager
|
||||
.removeSession(id)
|
||||
.catch((err) => console.warn('[history] removeSession failed:', id, err))
|
||||
}
|
||||
|
||||
// Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory.
|
||||
if (!opts.keepHistory) {
|
||||
this.killedSessionTombstones.delete(id)
|
||||
this.killedSessionTombstones.set(id, Date.now())
|
||||
if (this.killedSessionTombstones.size > MAX_TOMBSTONES) {
|
||||
const oldest = this.killedSessionTombstones.keys().next().value
|
||||
if (oldest) {
|
||||
this.killedSessionTombstones.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ackColdRestore(sessionId: string): void {
|
||||
this.coldRestoreCache.delete(sessionId)
|
||||
this.sleepRestoreSessionIds.delete(sessionId)
|
||||
}
|
||||
|
||||
clearTombstone(sessionId: string): void {
|
||||
this.killedSessionTombstones.delete(sessionId)
|
||||
}
|
||||
|
||||
protected buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
|
||||
// Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake.
|
||||
const scrollback = restoreInfo.modes.alternateScreen
|
||||
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
|
||||
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
|
||||
if (!scrollback) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
scrollback,
|
||||
cwd: restoreInfo.cwd,
|
||||
cols: restoreInfo.cols,
|
||||
rows: restoreInfo.rows,
|
||||
oscLinks: restoreInfo.oscLinks,
|
||||
...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async sendSignal(id: string, signal: string): Promise<void> {
|
||||
await this.client.request('signal', { sessionId: id, signal })
|
||||
}
|
||||
|
||||
async getCwd(id: string): Promise<string> {
|
||||
try {
|
||||
const result = await this.client.request<{ cwd: string | null }>('getCwd', {
|
||||
sessionId: id
|
||||
})
|
||||
return result.cwd ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async getInitialCwd(id: string): Promise<string> {
|
||||
return this.initialCwds.get(id) ?? ''
|
||||
}
|
||||
|
||||
// Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert.
|
||||
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
|
||||
try {
|
||||
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
|
||||
'getSize',
|
||||
{ sessionId: id }
|
||||
)
|
||||
return result.size ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { basename } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import {
|
||||
isAgentSessionOwnerBinding,
|
||||
type AgentSessionOwnerBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner'
|
||||
import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot'
|
||||
import { recordAuthenticatedInventory } from './daemon-audit-classifier'
|
||||
import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors'
|
||||
import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection'
|
||||
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import type { ListSessionsResult, SessionInfo } from './types'
|
||||
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
|
||||
import type { PtyProcessInfo } from '../providers/types'
|
||||
|
||||
export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection {
|
||||
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
|
||||
// Why: snapshotted before the request so ids spawned mid-flight can never
|
||||
// be reconciled away below.
|
||||
const preRequestActiveIds = new Set(this.activeSessionIds)
|
||||
try {
|
||||
// Why: connect + listSessions share the caller's one absolute deadline so a
|
||||
// wedged handshake cannot burn the whole teardown budget before the list issues.
|
||||
await this.ensureConnected(opts?.deadlineMs)
|
||||
const result = await this.client.request<ListSessionsResult>(
|
||||
'listSessions',
|
||||
undefined,
|
||||
remainingDaemonRequestTimeoutMs(opts?.deadlineMs)
|
||||
)
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const processes: PtyProcessInfo[] = []
|
||||
const aliveSessionIds = new Set<string>()
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
aliveSessionIds.add(session.sessionId)
|
||||
this.recordSessionAuthority(session.sessionId, session.incarnationId)
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
processes.push(
|
||||
admission.admit({
|
||||
id: session.sessionId,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
...(this.ownerIdentity(session.incarnationId)
|
||||
? { ownerIdentity: this.ownerIdentity(session.incarnationId) }
|
||||
: {}),
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
})
|
||||
)
|
||||
}
|
||||
// Why: hasPty reads activeSessionIds, and an exit missed while the socket
|
||||
// was disconnected otherwise survives an authoritative inventory forever —
|
||||
// defeating every absence proof built on the cache.
|
||||
for (const id of preRequestActiveIds) {
|
||||
if (!aliveSessionIds.has(id)) {
|
||||
this.activeSessionIds.delete(id)
|
||||
this.sessionIncarnations.delete(id)
|
||||
this.sessionOwnerIdentities.delete(id)
|
||||
}
|
||||
}
|
||||
this.publishAuditObservation(
|
||||
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
|
||||
)
|
||||
return processes
|
||||
} catch (error) {
|
||||
const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing()
|
||||
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
|
||||
this.observeAuditFailure(
|
||||
missingAuthenticatedToken
|
||||
? 'token_missing_after_authenticated_disconnect'
|
||||
: 'inventory_failed',
|
||||
this.exactDaemonIncarnation,
|
||||
[
|
||||
...(missingAuthenticatedToken ? (['token_file'] as const) : []),
|
||||
...(missingNamedPipe ? (['windows_named_pipe'] as const) : [])
|
||||
],
|
||||
missingNamedPipe ? 'windows_named_pipe_missing' : undefined
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected validatedAgentSessionOwners(
|
||||
owners: unknown
|
||||
): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record<string, never> {
|
||||
if (owners === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (
|
||||
!Array.isArray(owners) ||
|
||||
owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES ||
|
||||
!owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live')
|
||||
) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return owners.length > 0
|
||||
? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) }
|
||||
: {}
|
||||
}
|
||||
|
||||
// Why: the Manage Sessions panel needs the full SessionInfo (pid, state,
|
||||
// createdAt) per session for display; listProcesses drops that detail for
|
||||
// the IPtyProvider contract. Keep both in parallel rather than widening
|
||||
// the provider surface.
|
||||
async listSessions(): Promise<SessionInfo[]> {
|
||||
await this.ensureConnected()
|
||||
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
|
||||
return result.sessions
|
||||
.filter((s) => s.isAlive)
|
||||
.map((session) => ({
|
||||
...session,
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
}))
|
||||
}
|
||||
|
||||
getActiveSessionIds(): string[] {
|
||||
return [...this.activeSessionIds]
|
||||
}
|
||||
|
||||
// Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit
|
||||
// for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever.
|
||||
fanoutSyntheticExits(code: number): void {
|
||||
const ids = [...this.activeSessionIds]
|
||||
this.activeSessionIds.clear()
|
||||
this.sessionsAwaitingDaemonRecovery.clear()
|
||||
this.writeRecoveryAttempted = false
|
||||
this.dirtySessionVersions.clear()
|
||||
this.lastFullCheckpointAt.clear()
|
||||
this.sessionsNeedingFullCheckpoint.clear()
|
||||
this.sessionsNeedingLiveCheckpoint.clear()
|
||||
this.sessionsNeedingContinuityCheckpoint.clear()
|
||||
this.overlayDeadlineWarnedSessionIds.clear()
|
||||
this.periodicDeadlineWarnedSessionIds.clear()
|
||||
this.nonFinalAdmissionDeniedSessionIds.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.stopCheckpointTimer()
|
||||
for (const id of ids) {
|
||||
this.coldRestoreCache.delete(id)
|
||||
// Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics.
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.exitListeners]) {
|
||||
listener({
|
||||
id,
|
||||
code,
|
||||
...(this.sessionIncarnations.get(id)
|
||||
? { incarnationId: this.sessionIncarnations.get(id) }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
this.sessionIncarnations.delete(id)
|
||||
this.sessionOwnerIdentities.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.COMSPEC || 'powershell.exe'
|
||||
}
|
||||
return process.env.SHELL || '/bin/zsh'
|
||||
}
|
||||
|
||||
async getProfiles(): Promise<{ name: string; path: string }[]> {
|
||||
if (process.platform === 'win32') {
|
||||
return [
|
||||
{ name: 'PowerShell', path: 'powershell.exe' },
|
||||
{ name: 'Command Prompt', path: 'cmd.exe' }
|
||||
]
|
||||
}
|
||||
const shells = ['/bin/zsh', '/bin/bash', '/bin/sh']
|
||||
return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import type {
|
||||
HistoryRecoveryContext,
|
||||
PendingDaemonSpawnOperation
|
||||
} from './daemon-pty-runtime-state'
|
||||
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
|
||||
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
|
||||
import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { mintPtySessionId } from './pty-session-id'
|
||||
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
|
||||
import { supportsPtyStartupBarrier } from './shell-ready'
|
||||
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
|
||||
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import { resolveUnixShellPath } from '../providers/local-pty-utils'
|
||||
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
|
||||
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
const spawnOpts = this.withHistoryIsolation(opts)
|
||||
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
|
||||
const operation = {
|
||||
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
|
||||
ignoredExitIncarnationIds: new Set<string>(),
|
||||
ignoreNextExit: false,
|
||||
acceptsUnroutedExit: false
|
||||
}
|
||||
const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set()
|
||||
operations.add(operation)
|
||||
this.pendingSpawnOperationsBySessionId.set(sessionId, operations)
|
||||
if (opts.agentSessionEnsure) {
|
||||
this.pendingClaimSpawnOperations.add(operation)
|
||||
}
|
||||
const historyRecovery: HistoryRecoveryContext = {
|
||||
freeze: null,
|
||||
unreadableSessionId: null,
|
||||
identityChanged: false
|
||||
}
|
||||
try {
|
||||
return await this.withHistorySpawnLock(sessionId, () =>
|
||||
this.withDaemonRetry(() =>
|
||||
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
if (historyRecovery.freeze) {
|
||||
this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze)
|
||||
}
|
||||
this.pendingClaimSpawnOperations.delete(operation)
|
||||
operations.delete(operation)
|
||||
if (operations.size === 0) {
|
||||
this.pendingSpawnOperationsBySessionId.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions {
|
||||
const wslContext = resolveWslSessionContext({
|
||||
cwd: opts.cwd,
|
||||
sessionId: opts.sessionId,
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
|
||||
})
|
||||
if (
|
||||
opts.attachOnly === true ||
|
||||
(opts.sessionId !== undefined && opts.isNewSession !== true) ||
|
||||
!opts.worktreeId ||
|
||||
opts.historyIsolationEnabled !== true ||
|
||||
(process.platform === 'win32' && !wslContext)
|
||||
) {
|
||||
return opts
|
||||
}
|
||||
const env = { ...opts.env }
|
||||
const preferredShell = wslContext
|
||||
? 'bash'
|
||||
: opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh'
|
||||
const shellPath = resolveUnixShellPath(preferredShell)
|
||||
const historyArgs = [
|
||||
env,
|
||||
opts.worktreeId,
|
||||
shellPath,
|
||||
opts.cwd ?? resolveSafePtyDefaultCwd()
|
||||
] as const
|
||||
const result = wslContext
|
||||
? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro })
|
||||
: injectHistoryEnv(...historyArgs)
|
||||
if (wslContext) {
|
||||
injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro)
|
||||
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
|
||||
}
|
||||
logHistoryInjection(opts.worktreeId, result)
|
||||
return { ...opts, env }
|
||||
}
|
||||
|
||||
protected async doSpawn(
|
||||
opts: PtySpawnOptions,
|
||||
operation: PendingDaemonSpawnOperation,
|
||||
historyRecovery: HistoryRecoveryContext
|
||||
): Promise<PtySpawnResult> {
|
||||
if (
|
||||
opts.agentSessionEnsure &&
|
||||
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
|
||||
) {
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
const requestedSessionId = opts.sessionId!
|
||||
// Why: v30 daemons survive upgrades; reject their accidental create result before publication.
|
||||
const attachOnly = opts.attachOnly === true
|
||||
const emulateLegacyAttachOnly =
|
||||
attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
|
||||
let sessionId = requestedSessionId
|
||||
let wslDistro = resolveWslSessionContext({
|
||||
cwd: opts.cwd,
|
||||
sessionId,
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
|
||||
})?.distro
|
||||
let activeSpawnContext: DaemonPtySpawnContext | null = null
|
||||
const freezeHistory = async (): Promise<void> => {
|
||||
if (!this.historyManager) {
|
||||
return
|
||||
}
|
||||
const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId
|
||||
if (historyRecovery.freeze?.sessionId === recoverySessionId) {
|
||||
return
|
||||
}
|
||||
if (historyRecovery.freeze) {
|
||||
this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze)
|
||||
}
|
||||
historyRecovery.freeze = await this.historyManager.freezeForRecovery(recoverySessionId)
|
||||
historyRecovery.unreadableSessionId = null
|
||||
}
|
||||
const detectColdRestore = async (options?: {
|
||||
ignoreCleanEnd?: boolean
|
||||
}): Promise<ColdRestoreInfo | null> => {
|
||||
if (!this.historyReader) {
|
||||
return null
|
||||
}
|
||||
await freezeHistory()
|
||||
const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId
|
||||
const recoveryWslDistro = activeSpawnContext?.wslDistro ?? wslDistro
|
||||
const detection = await this.historyReader.detectColdRestoreState(recoverySessionId, {
|
||||
...options,
|
||||
wslDistro: recoveryWslDistro
|
||||
})
|
||||
if (detection.status === 'unreadable') {
|
||||
historyRecovery.unreadableSessionId = detection.sessionId
|
||||
return null
|
||||
}
|
||||
const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null
|
||||
if (detection.status === 'restored' && detection.hasUnreadableRecovery) {
|
||||
historyRecovery.unreadableSessionId = detection.sessionId
|
||||
}
|
||||
if (!restoreInfo) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...restoreInfo,
|
||||
cwd:
|
||||
normalizeWslColdRestoreCwd({
|
||||
recoveredCwd: restoreInfo.cwd,
|
||||
requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(),
|
||||
wslDistro: recoveryWslDistro
|
||||
}) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
if (this.killedSessionTombstones.has(sessionId)) {
|
||||
throw new TerminalKilledError(sessionId)
|
||||
}
|
||||
|
||||
if (opts.isNewSession) {
|
||||
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
|
||||
await this.replaceStaleBundleDaemonBeforeNewPty()
|
||||
await this.replaceSeveredMacTccDaemonBeforeNewPty()
|
||||
}
|
||||
|
||||
await this.ensureConnected()
|
||||
// Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from
|
||||
// a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop
|
||||
// handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached.
|
||||
if (!this.canDelegateBackgroundToDaemon) {
|
||||
this.setPtyBackgrounded(sessionId, false)
|
||||
}
|
||||
|
||||
// Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request.
|
||||
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
|
||||
let restoreInfo: ColdRestoreInfo | null = null
|
||||
let restoreSkippedForLiveSession = false
|
||||
const historyProbe = opts.attachOnly
|
||||
? undefined
|
||||
: this.historyReader?.probeRestorableHistory(sessionId)
|
||||
if (historyProbe && historyProbe.status !== 'none') {
|
||||
if ((await this.getAppliedSize(sessionId)) !== null) {
|
||||
restoreSkippedForLiveSession = true
|
||||
if (this.historyManager && !this.historyManager.hasWriter(sessionId)) {
|
||||
await detectColdRestore()
|
||||
restoreInfo = null
|
||||
}
|
||||
} else {
|
||||
restoreInfo = await detectColdRestore()
|
||||
}
|
||||
}
|
||||
let effectiveCwd = restoreInfo?.cwd ?? opts.cwd
|
||||
let effectiveCols = restoreInfo?.cols ?? opts.cols
|
||||
let effectiveRows = restoreInfo?.rows ?? opts.rows
|
||||
|
||||
const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false
|
||||
const isCodexStartupCommand =
|
||||
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex'
|
||||
const shouldWaitForShellReady =
|
||||
isCodexStartupCommand &&
|
||||
shouldUseShellReadyStartupDelivery({
|
||||
command: opts.command,
|
||||
startupCommandDelivery: opts.startupCommandDelivery
|
||||
})
|
||||
const shellReadyTimeoutMs =
|
||||
shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady
|
||||
? CODEX_SHELL_READY_TIMEOUT_MS
|
||||
: undefined
|
||||
|
||||
const context: DaemonPtySpawnContext = {
|
||||
opts,
|
||||
operation,
|
||||
historyRecovery,
|
||||
requestedSessionId,
|
||||
attachOnly,
|
||||
emulateLegacyAttachOnly,
|
||||
sessionId,
|
||||
wslDistro,
|
||||
restoreInfo,
|
||||
restoreSkippedForLiveSession,
|
||||
effectiveCwd,
|
||||
effectiveCols,
|
||||
effectiveRows,
|
||||
shellReadySupported,
|
||||
shellReadyTimeoutMs,
|
||||
historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null,
|
||||
detectColdRestore
|
||||
}
|
||||
activeSpawnContext = context
|
||||
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
|
||||
return this.finishSpawn(context, result)
|
||||
}
|
||||
|
||||
protected resultForExitBeforeSpawnReply(
|
||||
sessionId: string,
|
||||
result: CreateOrAttachResult,
|
||||
operation: PendingDaemonSpawnOperation
|
||||
): PtySpawnResult | null {
|
||||
const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some(
|
||||
(exit) =>
|
||||
!(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) &&
|
||||
(!exit.incarnationId ||
|
||||
!result.incarnationId ||
|
||||
exit.incarnationId === result.incarnationId)
|
||||
)
|
||||
if (!matchingExit) {
|
||||
return null
|
||||
}
|
||||
// Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state.
|
||||
const exitedResult: PtySpawnResult = {
|
||||
id: sessionId,
|
||||
exitedBeforeSpawnReply: true,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
|
||||
...(!result.isNew ? { isReattach: true } : {})
|
||||
}
|
||||
return exitedResult
|
||||
}
|
||||
|
||||
didExitBeforeSpawnReply(result: PtySpawnResult): boolean {
|
||||
return result.exitedBeforeSpawnReply === true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
|
||||
import type { PtySpawnResult } from '../providers/types'
|
||||
import type { CreateOrAttachResult } from './types'
|
||||
|
||||
export function daemonPtySpawnIdentityFields(
|
||||
result: Pick<CreateOrAttachResult, 'incarnationId'>,
|
||||
ownerIdentity: TerminalOwnerIdentity | undefined
|
||||
): Pick<PtySpawnResult, 'incarnationId' | 'ownerIdentity'> {
|
||||
return {
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(ownerIdentity ? { ownerIdentity } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { TerminalAttachCanceledError } from './types'
|
||||
|
||||
export type PendingPtySpawnPreparation = {
|
||||
canceled: boolean
|
||||
// Why: `canceled` is only polled between spawn steps; the signal is what
|
||||
// actually interrupts an in-progress cwd probe on a dead share.
|
||||
readonly controller: AbortController
|
||||
cancelTimer?: ReturnType<typeof setTimeout>
|
||||
// Why: preparations are keyed by sessionId, but a control-socket close must
|
||||
// cancel only the disconnecting client's preps, not another client's (F4).
|
||||
clientId: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export class DaemonPtySpawnPreparations {
|
||||
private readonly pending = new Map<string, Set<PendingPtySpawnPreparation>>()
|
||||
private readonly cancellationByPreparation = new WeakMap<
|
||||
PendingPtySpawnPreparation,
|
||||
{ promise: Promise<void>; resolve: () => void }
|
||||
>()
|
||||
|
||||
constructor(private readonly preparePtySpawn: () => Promise<void>) {}
|
||||
|
||||
register(
|
||||
sessionId: string,
|
||||
clientId: string,
|
||||
requestId: string,
|
||||
cancelAfterMs: unknown
|
||||
): PendingPtySpawnPreparation {
|
||||
const preparation: PendingPtySpawnPreparation = {
|
||||
canceled: false,
|
||||
controller: new AbortController(),
|
||||
clientId,
|
||||
requestId
|
||||
}
|
||||
this.cancellationByPreparation.set(preparation, Promise.withResolvers<void>())
|
||||
if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) {
|
||||
preparation.cancelTimer = setTimeout(
|
||||
() => this.cancelPreparation(preparation),
|
||||
Math.min(Number(cancelAfterMs), 300_000)
|
||||
)
|
||||
preparation.cancelTimer.unref()
|
||||
}
|
||||
const pendingForSession = this.pending.get(sessionId) ?? new Set()
|
||||
pendingForSession.add(preparation)
|
||||
this.pending.set(sessionId, pendingForSession)
|
||||
return preparation
|
||||
}
|
||||
|
||||
async prepareUnlessCanceled(
|
||||
sessionId: string,
|
||||
preparation: PendingPtySpawnPreparation
|
||||
): Promise<void> {
|
||||
// Race cancellation against preflight so shutdown/disconnect can return the
|
||||
// protocol cancellation error before tearing down the client transport.
|
||||
const cancellation = this.cancellationByPreparation.get(preparation)
|
||||
const preparationTask = cancellation
|
||||
? Promise.race([this.preparePtySpawn(), cancellation.promise])
|
||||
: this.preparePtySpawn()
|
||||
await preparationTask
|
||||
if (preparation.canceled) {
|
||||
throw new TerminalAttachCanceledError(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
finish(sessionId: string, preparation: PendingPtySpawnPreparation): void {
|
||||
if (preparation.cancelTimer) {
|
||||
clearTimeout(preparation.cancelTimer)
|
||||
}
|
||||
this.cancellationByPreparation.delete(preparation)
|
||||
const pendingForSession = this.pending.get(sessionId)
|
||||
pendingForSession?.delete(preparation)
|
||||
if (pendingForSession?.size === 0) {
|
||||
this.pending.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
cancel(sessionId: string, request?: { clientId: string; requestId?: string }): boolean {
|
||||
const pendingForSession = this.pending.get(sessionId)
|
||||
if (!pendingForSession) {
|
||||
return false
|
||||
}
|
||||
let canceled = false
|
||||
for (const preparation of pendingForSession) {
|
||||
if (
|
||||
request &&
|
||||
(preparation.clientId !== request.clientId ||
|
||||
(request.requestId !== undefined && preparation.requestId !== request.requestId))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.cancelPreparation(preparation)
|
||||
canceled = true
|
||||
}
|
||||
return canceled
|
||||
}
|
||||
|
||||
cancelForClient(clientId: string): void {
|
||||
for (const pendingForSession of this.pending.values()) {
|
||||
for (const preparation of pendingForSession) {
|
||||
if (preparation.clientId === clientId) {
|
||||
this.cancelPreparation(preparation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private cancelPreparation(preparation: PendingPtySpawnPreparation): void {
|
||||
preparation.canceled = true
|
||||
preparation.controller.abort()
|
||||
this.cancellationByPreparation.get(preparation)?.resolve()
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const sessionId of this.pending.keys()) {
|
||||
this.cancel(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ColdRestorePayload } from './cold-restore-payload-cache'
|
||||
import type { DaemonAuditObservation } from './daemon-audit-classifier'
|
||||
import {
|
||||
DaemonPtyRuntimeState,
|
||||
type HistoryRecoveryContext,
|
||||
type PendingDaemonSpawnOperation,
|
||||
type SnapshotCheckpointResult
|
||||
} from './daemon-pty-runtime-state'
|
||||
import { isDaemonGoneError } from './daemon-endpoint-errors'
|
||||
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { NdjsonLineTooLongError } from './ndjson'
|
||||
import {
|
||||
iterateTerminalHistorySeedChunks,
|
||||
measureTerminalHistorySeed,
|
||||
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
|
||||
} from './terminal-history-seed-chunks'
|
||||
import type { CreateOrAttachResult, GetSnapshotResult } from './types'
|
||||
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
|
||||
export type DaemonPtySpawnContext = {
|
||||
opts: PtySpawnOptions
|
||||
operation: PendingDaemonSpawnOperation
|
||||
historyRecovery: HistoryRecoveryContext
|
||||
requestedSessionId: string
|
||||
attachOnly: boolean
|
||||
emulateLegacyAttachOnly: boolean
|
||||
sessionId: string
|
||||
wslDistro: string | undefined
|
||||
restoreInfo: ColdRestoreInfo | null
|
||||
restoreSkippedForLiveSession: boolean
|
||||
effectiveCwd: string | undefined
|
||||
effectiveCols: number
|
||||
effectiveRows: number
|
||||
shellReadySupported: boolean
|
||||
shellReadyTimeoutMs: number | undefined
|
||||
historySeedSegments: readonly string[] | null
|
||||
detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise<ColdRestoreInfo | null>
|
||||
}
|
||||
|
||||
export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
|
||||
protected abstract takeSnapshotAndCheckpoint(
|
||||
sessionId: string,
|
||||
opts: {
|
||||
teardown: boolean
|
||||
forceLiveSnapshot?: boolean
|
||||
requireContinuityProof?: boolean
|
||||
}
|
||||
): Promise<SnapshotCheckpointResult>
|
||||
protected abstract markSessionDirty(sessionId: string): void
|
||||
protected abstract writeSessionCheckpoint(
|
||||
sessionId: string,
|
||||
opts: { final: boolean; teardown: boolean }
|
||||
): Promise<'done' | 'deferred'>
|
||||
protected abstract releasePendingRespawnAdoptionLease(): void
|
||||
protected abstract setupEventRouting(): void
|
||||
protected abstract scheduleCheckpointTimer(): void
|
||||
protected abstract stopCheckpointTimer(): void
|
||||
protected abstract stopCheckpointTimerIfIdle(): void
|
||||
protected abstract recordAuthenticatedIdentity(): void
|
||||
protected abstract runExclusiveCheckpoint(
|
||||
operation: () => Promise<void>,
|
||||
options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number }
|
||||
): Promise<boolean>
|
||||
protected abstract checkpointAllSessions(): Promise<void>
|
||||
protected abstract ensureConnected(deadlineMs?: number): Promise<void>
|
||||
protected abstract withHistorySpawnLock<T>(
|
||||
sessionId: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T>
|
||||
protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void
|
||||
protected abstract reconnectAfterWriteFailure(): void
|
||||
protected abstract checkpointSessions(
|
||||
sessionIds: Iterable<string>,
|
||||
opts?: { final?: boolean; teardown?: boolean }
|
||||
): Promise<Set<string>>
|
||||
protected abstract publishAuditObservation(observation: DaemonAuditObservation): void
|
||||
protected abstract isRetiredEndpointTokenMissing(): boolean
|
||||
protected abstract withDaemonRetry<T>(fn: () => Promise<T>): Promise<T>
|
||||
protected abstract replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void>
|
||||
protected abstract replaceStaleBundleDaemonBeforeNewPty(): Promise<void>
|
||||
protected abstract replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void>
|
||||
abstract setPtyBackgrounded(id: string, background: boolean): void
|
||||
abstract getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null>
|
||||
protected abstract resultForExitBeforeSpawnReply(
|
||||
sessionId: string,
|
||||
result: CreateOrAttachResult,
|
||||
operation: PendingDaemonSpawnOperation
|
||||
): PtySpawnResult | null
|
||||
protected abstract buildColdRestorePayload(
|
||||
restoreInfo: ColdRestoreInfo
|
||||
): ColdRestorePayload | null
|
||||
protected abstract overlayDurableRestoreSnapshot(
|
||||
sessionId: string,
|
||||
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
|
||||
scrollbackRows?: number
|
||||
): Promise<NonNullable<GetSnapshotResult['snapshot']>>
|
||||
|
||||
protected async createOrAttachSpawn(
|
||||
context: DaemonPtySpawnContext,
|
||||
historySeedSegments: readonly string[] | null
|
||||
): Promise<CreateOrAttachResult> {
|
||||
const requestCreateOrAttach = async (
|
||||
historySeed: string | undefined,
|
||||
historySeedTransferId: string | undefined
|
||||
) => {
|
||||
const { opts } = context
|
||||
if (opts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const payload = {
|
||||
sessionId: context.sessionId,
|
||||
cols: context.effectiveCols,
|
||||
rows: context.effectiveRows,
|
||||
cwd: context.attachOnly ? undefined : context.effectiveCwd,
|
||||
env: context.attachOnly ? undefined : opts.env,
|
||||
envToDelete: context.attachOnly ? undefined : opts.envToDelete,
|
||||
command: context.attachOnly ? undefined : opts.command,
|
||||
startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery,
|
||||
launchAgent: context.attachOnly ? undefined : opts.launchAgent,
|
||||
...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}),
|
||||
...(context.attachOnly &&
|
||||
opts.expectedIncarnationIsAuthoritative === true &&
|
||||
opts.expectedIncarnationId !== undefined
|
||||
? { expectedIncarnationId: opts.expectedIncarnationId }
|
||||
: {}),
|
||||
shellOverride: context.attachOnly ? undefined : opts.shellOverride,
|
||||
terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: context.attachOnly
|
||||
? undefined
|
||||
: opts.terminalWindowsPowerShellImplementation,
|
||||
shellReadySupported: context.attachOnly ? false : context.shellReadySupported,
|
||||
...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined
|
||||
? { shellReadyTimeoutMs: context.shellReadyTimeoutMs }
|
||||
: {}),
|
||||
...(historySeed ? { historySeed } : {}),
|
||||
...(historySeedTransferId ? { historySeedTransferId } : {}),
|
||||
...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress
|
||||
? { startupIngress: opts.startupIngress }
|
||||
: {}),
|
||||
...(!context.attachOnly && opts.agentSessionEnsure
|
||||
? { agentSessionEnsure: opts.agentSessionEnsure }
|
||||
: {})
|
||||
}
|
||||
context.operation.acceptsUnroutedExit = !context.attachOnly
|
||||
try {
|
||||
return await (opts.signal
|
||||
? this.client.request<CreateOrAttachResult>(
|
||||
'createOrAttach',
|
||||
payload,
|
||||
undefined,
|
||||
opts.signal
|
||||
)
|
||||
: this.client.request<CreateOrAttachResult>('createOrAttach', payload))
|
||||
} finally {
|
||||
context.operation.acceptsUnroutedExit = false
|
||||
}
|
||||
}
|
||||
|
||||
let historySeedUnavailable = false
|
||||
const deliverSeedAndCreate = async (): Promise<CreateOrAttachResult> => {
|
||||
if (!historySeedSegments || historySeedSegments.length === 0) {
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
const metrics = measureTerminalHistorySeed(historySeedSegments)
|
||||
if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) {
|
||||
try {
|
||||
return await requestCreateOrAttach(historySeedSegments.join(''), undefined)
|
||||
} catch (error) {
|
||||
if (!(error instanceof NdjsonLineTooLongError)) {
|
||||
throw error
|
||||
}
|
||||
historySeedUnavailable = true
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
}
|
||||
if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) {
|
||||
historySeedUnavailable = true
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
|
||||
let transferId: string | undefined
|
||||
try {
|
||||
const started = await this.client.request<{ transferId: string }>(
|
||||
'startHistorySeedTransfer',
|
||||
metrics
|
||||
)
|
||||
transferId = started.transferId
|
||||
let index = 0
|
||||
for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) {
|
||||
await this.client.request('appendHistorySeedTransfer', { transferId, index, data })
|
||||
index += 1
|
||||
}
|
||||
await this.client.request('finishHistorySeedTransfer', { transferId })
|
||||
} catch (error) {
|
||||
if (transferId) {
|
||||
await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {})
|
||||
}
|
||||
if (isDaemonGoneError(error)) {
|
||||
throw error
|
||||
}
|
||||
historySeedUnavailable = true
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
return requestCreateOrAttach(undefined, transferId)
|
||||
}
|
||||
const result = await deliverSeedAndCreate()
|
||||
return historySeedUnavailable && result.historySeeded === undefined
|
||||
? { ...result, historySeeded: false }
|
||||
: result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
|
||||
import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request'
|
||||
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
|
||||
import { TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze'
|
||||
import { daemonPtySpawnIdentityFields } from './daemon-pty-spawn-identity-fields'
|
||||
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
|
||||
import { SessionNotFoundError, type CreateOrAttachResult } from './types'
|
||||
import type { PtySpawnResult } from '../providers/types'
|
||||
|
||||
export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
|
||||
protected async finishSpawn(
|
||||
context: DaemonPtySpawnContext,
|
||||
initialResult: CreateOrAttachResult
|
||||
): Promise<PtySpawnResult> {
|
||||
const {
|
||||
opts,
|
||||
operation,
|
||||
historyRecovery,
|
||||
requestedSessionId,
|
||||
emulateLegacyAttachOnly,
|
||||
restoreSkippedForLiveSession,
|
||||
detectColdRestore
|
||||
} = context
|
||||
let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context
|
||||
const createOrAttach = (historySeedSegments: readonly string[] | null) => {
|
||||
Object.assign(context, {
|
||||
sessionId,
|
||||
effectiveCwd,
|
||||
effectiveCols,
|
||||
effectiveRows
|
||||
})
|
||||
return this.createOrAttachSpawn(context, historySeedSegments)
|
||||
}
|
||||
let result = initialResult
|
||||
let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
|
||||
const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise<void> => {
|
||||
const requestedSessionId = sessionId
|
||||
if (
|
||||
opts.agentSessionEnsure &&
|
||||
!isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure)
|
||||
) {
|
||||
// Why: a claim-incapable owner may already have spawned before returning
|
||||
// a malformed response; retire only this requested session before failing closed.
|
||||
await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {})
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId
|
||||
context.sessionId = sessionId
|
||||
if (requestedSessionId === sessionId) {
|
||||
return
|
||||
}
|
||||
if (historyRecovery.freeze) {
|
||||
this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze)
|
||||
historyRecovery.freeze = null
|
||||
}
|
||||
historyRecovery.unreadableSessionId = null
|
||||
historyRecovery.identityChanged = true
|
||||
restoreInfo = null
|
||||
historySeedSegments = null
|
||||
}
|
||||
if (emulateLegacyAttachOnly && result.isNew) {
|
||||
operation.ignoreNextExit = true
|
||||
await this.client.request('kill', { sessionId: requestedSessionId, immediate: true })
|
||||
throw new SessionNotFoundError(requestedSessionId)
|
||||
}
|
||||
await adoptSpawnResultSession(result)
|
||||
// Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner.
|
||||
this.clearSessionAwaitingDaemonRecovery(requestedSessionId)
|
||||
this.clearSessionAwaitingDaemonRecovery(sessionId)
|
||||
const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
|
||||
if (exitedResult) {
|
||||
return exitedResult
|
||||
}
|
||||
if (
|
||||
opts.attachOnly &&
|
||||
opts.expectedIncarnationIsAuthoritative &&
|
||||
(!result.incarnationId || result.incarnationId !== opts.expectedIncarnationId)
|
||||
) {
|
||||
throw new TerminalSessionOwnerUnverifiedError(sessionId)
|
||||
}
|
||||
this.recordSessionAuthority(sessionId, result.incarnationId)
|
||||
const claimResult = (): Pick<PtySpawnResult, 'agentSessionEnsure'> | Record<string, never> =>
|
||||
result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}
|
||||
let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
|
||||
// Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons.
|
||||
wslDistro = providerWslDistro ?? undefined
|
||||
context.wslDistro = wslDistro
|
||||
if (wslDistro) {
|
||||
this.wslDistrosBySessionId.set(sessionId, wslDistro)
|
||||
} else if (providerWslDistro === null || result.isNew) {
|
||||
this.wslDistrosBySessionId.delete(sessionId)
|
||||
}
|
||||
const launchIdentity = (): { launchAgent?: NonNullable<typeof result.launchAgent> } =>
|
||||
result.launchAgent ? { launchAgent: result.launchAgent } : {}
|
||||
|
||||
if (effectiveCwd) {
|
||||
this.initialCwds.set(sessionId, effectiveCwd)
|
||||
}
|
||||
|
||||
// Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor.
|
||||
let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
|
||||
|
||||
// Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data.
|
||||
const cachedRestore = this.coldRestoreCache.get(sessionId)
|
||||
if (cachedRestore) {
|
||||
// Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal.
|
||||
this.activeSessionIds.add(sessionId)
|
||||
if (this.historyManager && !historyRecovery.identityChanged) {
|
||||
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
|
||||
if (historyRecovery.unreadableSessionId === sessionId) {
|
||||
this.historyManager.suspendSession(sessionId, recoveryFreeze)
|
||||
} else {
|
||||
this.historyManager.reopenSession(sessionId, recoveryFreeze)
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
|
||||
pid,
|
||||
...claimResult(),
|
||||
...launchIdentity(),
|
||||
coldRestore: cachedRestore,
|
||||
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
|
||||
...(!result.isNew ? { isReattach: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
|
||||
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
|
||||
if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) {
|
||||
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
|
||||
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
|
||||
if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) {
|
||||
// Why: the aliveness probe raced with session death, so the first
|
||||
// create lacked recovery bytes. Replace it before exposing the PTY.
|
||||
if (result.incarnationId) {
|
||||
operation.ignoredExitIncarnationIds.add(result.incarnationId)
|
||||
}
|
||||
operation.ignoreNextExit = true
|
||||
await this.client.request('kill', { sessionId, immediate: true })
|
||||
effectiveCwd = restoreInfo.cwd
|
||||
effectiveCols = restoreInfo.cols
|
||||
effectiveRows = restoreInfo.rows
|
||||
result = await createOrAttach(historySeedSegments)
|
||||
await adoptSpawnResultSession(result)
|
||||
const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
|
||||
if (exitedRetryResult) {
|
||||
return exitedRetryResult
|
||||
}
|
||||
this.recordSessionAuthority(sessionId, result.incarnationId)
|
||||
providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
|
||||
wslDistro = providerWslDistro ?? undefined
|
||||
context.wslDistro = wslDistro
|
||||
if (wslDistro) {
|
||||
this.wslDistrosBySessionId.set(sessionId, wslDistro)
|
||||
} else if (providerWslDistro === null || result.isNew) {
|
||||
this.wslDistrosBySessionId.delete(sessionId)
|
||||
}
|
||||
pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
|
||||
this.initialCwds.set(sessionId, effectiveCwd)
|
||||
}
|
||||
} else if (
|
||||
!historyRecovery.identityChanged &&
|
||||
!result.isNew &&
|
||||
result.historySeeded === false
|
||||
) {
|
||||
restoreInfo = await detectColdRestore()
|
||||
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
|
||||
}
|
||||
|
||||
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
|
||||
this.activeSessionIds.add(sessionId)
|
||||
const providerSequence = providerSequenceFromCreateOrAttach(result)
|
||||
|
||||
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
|
||||
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
|
||||
const coldRestore = this.buildColdRestorePayload(restoreInfo)
|
||||
const canReanchorHistory =
|
||||
!historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true
|
||||
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
|
||||
if (this.historyManager && !historyRecovery.identityChanged) {
|
||||
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
|
||||
if (historyRecovery.unreadableSessionId === sessionId) {
|
||||
await this.historyManager.openSession(sessionId, {
|
||||
cwd: effectiveCwd ?? '',
|
||||
cols: effectiveCols,
|
||||
rows: effectiveRows,
|
||||
...(recoveryFreeze ? { recoveryFreeze } : {}),
|
||||
quarantineUnreadableRecovery: true
|
||||
})
|
||||
if (this.historyManager.hasWriter(sessionId)) {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
this.sessionsNeedingLiveCheckpoint.add(sessionId)
|
||||
this.lastFullCheckpointAt.delete(sessionId)
|
||||
}
|
||||
} else if (canReanchorHistory) {
|
||||
this.historyManager.registerWriter(sessionId, recoveryFreeze)
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
// Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor.
|
||||
this.lastFullCheckpointAt.delete(sessionId)
|
||||
} else {
|
||||
// Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent.
|
||||
this.historyManager.suspendSession(sessionId, recoveryFreeze)
|
||||
}
|
||||
}
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(sessionId, coldRestore)
|
||||
return {
|
||||
id: sessionId,
|
||||
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
|
||||
pid,
|
||||
...claimResult(),
|
||||
...launchIdentity(),
|
||||
coldRestore,
|
||||
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
|
||||
...(providerSequence ? { providerSequence } : {}),
|
||||
...(!result.isNew ? { isReattach: true } : {})
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
|
||||
pid,
|
||||
...claimResult(),
|
||||
...launchIdentity(),
|
||||
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
|
||||
...(providerSequence ? { providerSequence } : {})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.historyManager && !historyRecovery.identityChanged && result.isNew) {
|
||||
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
|
||||
await this.historyManager.openSession(sessionId, {
|
||||
cwd: effectiveCwd ?? '',
|
||||
cols: effectiveCols,
|
||||
rows: effectiveRows,
|
||||
...(recoveryFreeze ? { recoveryFreeze } : {}),
|
||||
...(historyRecovery.unreadableSessionId === sessionId
|
||||
? { quarantineUnreadableRecovery: true }
|
||||
: {})
|
||||
})
|
||||
} else if (
|
||||
this.historyManager &&
|
||||
!historyRecovery.identityChanged &&
|
||||
(result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId)
|
||||
) {
|
||||
// Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart.
|
||||
this.historyManager.suspendSession(
|
||||
sessionId,
|
||||
takeHistoryRecoveryFreeze(historyRecovery, sessionId)
|
||||
)
|
||||
} else if (this.historyManager && !historyRecovery.identityChanged) {
|
||||
// Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint.
|
||||
this.historyManager.registerWriter(
|
||||
sessionId,
|
||||
takeHistoryRecoveryFreeze(historyRecovery, sessionId)
|
||||
)
|
||||
if (!wasAlreadyManaged) {
|
||||
// Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity.
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.add(sessionId)
|
||||
this.lastFullCheckpointAt.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const isReattach = !result.isNew
|
||||
if (!isReattach || !result.snapshot) {
|
||||
return {
|
||||
id: sessionId,
|
||||
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
|
||||
pid,
|
||||
...claimResult(),
|
||||
...launchIdentity(),
|
||||
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
|
||||
...(providerSequence ? { providerSequence } : {}),
|
||||
...(isReattach ? { isReattach: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot)
|
||||
const reattachProviderSequence =
|
||||
typeof reattachSnapshot.outputSequence === 'number'
|
||||
? { value: reattachSnapshot.outputSequence, generation: 'continued' as const }
|
||||
: providerSequence
|
||||
const isAltScreen = reattachSnapshot.modes.alternateScreen
|
||||
const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences
|
||||
const snapshotFrame = reattachSnapshot.snapshotAnsi
|
||||
const snapshotPayload = snapshotPrefix + snapshotFrame
|
||||
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
|
||||
// Why known `0` is no longer dropped: the pane tracker must be able to tell
|
||||
// "the app negotiated nothing" from "this reattach proved nothing".
|
||||
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(
|
||||
reattachSnapshot.modes.kittyKeyboardFlags
|
||||
)
|
||||
return {
|
||||
id: sessionId,
|
||||
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
|
||||
pid,
|
||||
...claimResult(),
|
||||
...launchIdentity(),
|
||||
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
|
||||
snapshot: snapshotPayload,
|
||||
snapshotCols: reattachSnapshot.cols,
|
||||
snapshotRows: reattachSnapshot.rows,
|
||||
// Why only for an alt frame: normal history remains safe to replay at its capture grid.
|
||||
...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi
|
||||
? {
|
||||
snapshotPrefixAnsi: snapshotPrefix,
|
||||
snapshotFrameAnsi: snapshotFrame,
|
||||
snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi
|
||||
}
|
||||
: {}),
|
||||
...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}),
|
||||
...(kittyKeyboardFlags !== undefined
|
||||
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
|
||||
: {}),
|
||||
isReattach: true,
|
||||
isAlternateScreen: isAltScreen,
|
||||
// Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch.
|
||||
...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}),
|
||||
// Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329).
|
||||
...(reattachSnapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function remainingDaemonRequestTimeoutMs(
|
||||
deadlineMs: number | undefined
|
||||
): number | undefined {
|
||||
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
|
||||
}
|
||||
|
||||
// The caller's wait is bounded without cancelling durable work, which keeps running and committing.
|
||||
export async function awaitDaemonWorkWithinCallerDeadline(
|
||||
work: Promise<void>,
|
||||
deadlineMs: number
|
||||
): Promise<boolean> {
|
||||
void work.catch(() => {})
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
work.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now()))
|
||||
timer.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations'
|
||||
import type { DaemonServerLifecycle } from './daemon-server-lifecycle'
|
||||
import type { DaemonSessionAttachments } from './daemon-session-attachments'
|
||||
import type { DaemonSessionBackgroundRouting } from './daemon-session-background-routing'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
|
||||
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
||||
import type { TerminalHost } from './terminal-host'
|
||||
import { SessionNotFoundError, type DaemonRequest } from './types'
|
||||
|
||||
type DaemonRequestRouterOptions = {
|
||||
host: TerminalHost
|
||||
connections: DaemonClientConnections
|
||||
lifecycle: DaemonServerLifecycle
|
||||
admission: DaemonTerminalAdmission
|
||||
preparations: DaemonPtySpawnPreparations
|
||||
attachments: DaemonSessionAttachments
|
||||
historySeedTransfers: TerminalHistorySeedTransferRegistry
|
||||
sessionBackgroundRouting: DaemonSessionBackgroundRouting
|
||||
streamDataBatcher: DaemonStreamDataBatcher
|
||||
ptySpawnHealthCheck: () => Promise<void>
|
||||
log: DaemonFileLog
|
||||
}
|
||||
|
||||
export class DaemonRequestRouter {
|
||||
constructor(private readonly options: DaemonRequestRouterOptions) {}
|
||||
|
||||
async route(clientId: string, request: DaemonRequest): Promise<unknown> {
|
||||
const client = this.options.connections.get(clientId)
|
||||
switch (request.type) {
|
||||
case 'startHistorySeedTransfer': {
|
||||
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
|
||||
throw new Error('Daemon client connection is incomplete; reconnect')
|
||||
}
|
||||
return {
|
||||
transferId: this.options.historySeedTransfers.start(clientId, request.payload)
|
||||
}
|
||||
}
|
||||
case 'appendHistorySeedTransfer':
|
||||
this.options.historySeedTransfers.append(
|
||||
clientId,
|
||||
request.payload.transferId,
|
||||
request.payload.index,
|
||||
request.payload.data
|
||||
)
|
||||
return {}
|
||||
case 'finishHistorySeedTransfer':
|
||||
this.options.historySeedTransfers.finish(clientId, request.payload.transferId)
|
||||
return {}
|
||||
case 'abortHistorySeedTransfer':
|
||||
this.options.historySeedTransfers.abort(clientId, request.payload.transferId)
|
||||
return {}
|
||||
case 'createOrAttach':
|
||||
return this.options.admission.createOrAttach(clientId, request)
|
||||
case 'cancelCreateOrAttach':
|
||||
return {
|
||||
canceled: this.options.preparations.cancel(request.payload.sessionId, {
|
||||
clientId,
|
||||
...(typeof request.payload.requestId === 'string'
|
||||
? { requestId: request.payload.requestId }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
case 'closeStartupQueryAuthority':
|
||||
return {
|
||||
appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId)
|
||||
}
|
||||
case 'write':
|
||||
return this.write(
|
||||
client,
|
||||
request.payload.sessionId,
|
||||
request.payload.data,
|
||||
request.payload.expectedIncarnationId
|
||||
)
|
||||
case 'resize':
|
||||
return this.resize(
|
||||
client,
|
||||
request.payload.sessionId,
|
||||
request.payload.cols,
|
||||
request.payload.rows,
|
||||
request.payload.expectedIncarnationId
|
||||
)
|
||||
case 'pausePty':
|
||||
this.options.host.pauseProducer(request.payload.sessionId)
|
||||
return {}
|
||||
case 'resumePty':
|
||||
this.options.host.resumeProducer(request.payload.sessionId)
|
||||
return {}
|
||||
case 'setSessionBackground':
|
||||
return this.options.sessionBackgroundRouting.setBackground(
|
||||
request.payload.sessionId,
|
||||
request.payload.background === true
|
||||
)
|
||||
case 'kill':
|
||||
return this.kill(clientId, request.payload.sessionId, request.payload.immediate)
|
||||
case 'signal':
|
||||
this.options.host.signal(request.payload.sessionId, request.payload.signal)
|
||||
return {}
|
||||
case 'detach':
|
||||
this.options.attachments.detachSessionForClient(request.payload.sessionId, clientId)
|
||||
this.options.log.log('session-detached', { sessionId: request.payload.sessionId })
|
||||
return {}
|
||||
case 'getCwd':
|
||||
return { cwd: await this.options.host.getCwd(request.payload.sessionId) }
|
||||
case 'getForegroundProcess':
|
||||
return {
|
||||
foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId)
|
||||
}
|
||||
case 'inspectProcess':
|
||||
return this.options.host.inspectProcess(request.payload.sessionId)
|
||||
case 'confirmForegroundProcess':
|
||||
return {
|
||||
foregroundProcess: await this.options.host.confirmForegroundProcess(
|
||||
request.payload.sessionId
|
||||
)
|
||||
}
|
||||
case 'clearScrollback':
|
||||
this.options.host.clearScrollback(request.payload.sessionId)
|
||||
return {}
|
||||
case 'listSessions':
|
||||
return { sessions: this.options.host.listSessions() }
|
||||
case 'shutdownIfIdle':
|
||||
return this.shutdownIfIdle(clientId, request.id)
|
||||
case 'getSnapshot':
|
||||
return this.getSnapshot(request.payload.sessionId, request.payload.scrollbackRows)
|
||||
case 'getSize':
|
||||
return { size: this.options.host.getAppliedSize(request.payload.sessionId) }
|
||||
case 'takePendingOutput':
|
||||
return this.options.host.takePendingOutput(
|
||||
request.payload.sessionId,
|
||||
request.payload.includeSnapshot === true,
|
||||
{ teardownSnapshot: request.payload.teardownSnapshot === true }
|
||||
)
|
||||
case 'ping':
|
||||
return { pong: true }
|
||||
case 'systemResolverHealth':
|
||||
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
||||
case 'ptySpawnHealth':
|
||||
await this.options.ptySpawnHealthCheck()
|
||||
return { healthy: true }
|
||||
case 'shutdown':
|
||||
return this.shutdown(clientId, request.id, request.payload.killSessions)
|
||||
}
|
||||
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
|
||||
}
|
||||
|
||||
private write(
|
||||
client: ConnectedDaemonClient | undefined,
|
||||
sessionId: string,
|
||||
data: string,
|
||||
expectedIncarnationId?: string
|
||||
): Record<string, never> {
|
||||
try {
|
||||
this.options.attachments.recordInput(sessionId)
|
||||
this.options.host.write(sessionId, data, expectedIncarnationId)
|
||||
} catch (error) {
|
||||
this.options.attachments.clearInput(sessionId)
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
this.sendExitEvent(client, sessionId, -1, expectedIncarnationId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
private resize(
|
||||
client: ConnectedDaemonClient | undefined,
|
||||
sessionId: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
expectedIncarnationId?: string
|
||||
): Record<string, never> {
|
||||
try {
|
||||
this.options.host.resize(sessionId, cols, rows, expectedIncarnationId)
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
this.sendExitEvent(client, sessionId, -1, expectedIncarnationId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
private async kill(
|
||||
clientId: string,
|
||||
sessionId: string,
|
||||
immediate: boolean | undefined
|
||||
): Promise<Record<string, never>> {
|
||||
const canceledPendingSpawn = this.options.preparations.cancel(sessionId)
|
||||
this.options.attachments.clearInput(sessionId)
|
||||
const attribution = { sessionId, immediate: immediate === true, clientId }
|
||||
try {
|
||||
await this.options.host.kill(sessionId, { immediate })
|
||||
} catch (error) {
|
||||
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
|
||||
this.options.log.log('session-kill-failed', attribution)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.options.log.log('session-killed', attribution)
|
||||
return {}
|
||||
}
|
||||
|
||||
private shutdownIfIdle(clientId: string, requestId: string): { retiring: boolean } {
|
||||
const client = this.options.connections.get(clientId)
|
||||
const retiring =
|
||||
client !== undefined &&
|
||||
client.streamSocket !== null &&
|
||||
this.options.connections.size === 1 &&
|
||||
this.options.admission.inFlight === 0 &&
|
||||
this.options.host.listSessions().length === 0 &&
|
||||
this.options.connections.hasOnlyTransportsFor(client)
|
||||
if (retiring) {
|
||||
this.options.lifecycle.retireAfterIdleReply(clientId, requestId, client.controlSocket)
|
||||
}
|
||||
return { retiring }
|
||||
}
|
||||
|
||||
private getSnapshot(sessionId: string, requestedRows: unknown): unknown {
|
||||
const startedAt = performance.now()
|
||||
const scrollbackRows =
|
||||
typeof requestedRows === 'number' && Number.isFinite(requestedRows)
|
||||
? Math.max(0, Math.min(50_000, Math.floor(requestedRows)))
|
||||
: undefined
|
||||
const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows })
|
||||
const snapshotMs = performance.now() - startedAt
|
||||
if (snapshotMs >= 25) {
|
||||
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
|
||||
sessionIdSuffix: sessionId.slice(-10),
|
||||
snapshotMs: Math.round(snapshotMs)
|
||||
})
|
||||
}
|
||||
return { snapshot }
|
||||
}
|
||||
|
||||
private async shutdown(
|
||||
clientId: string,
|
||||
requestId: string,
|
||||
killSessions: boolean
|
||||
): Promise<Record<string, never>> {
|
||||
this.options.log.log('shutdown', {
|
||||
reason: 'rpc',
|
||||
killSessions: killSessions === true
|
||||
})
|
||||
const serverClose = this.options.lifecycle.beginOrdinaryShutdownFence()
|
||||
if (killSessions) {
|
||||
try {
|
||||
await this.options.host.dispose()
|
||||
} catch (error) {
|
||||
this.options.log.log('shutdown-dispose-failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
const controlSocket = this.options.connections.get(clientId)?.controlSocket
|
||||
if (controlSocket) {
|
||||
this.options.lifecycle.deferRpcShutdownUntilReply(
|
||||
clientId,
|
||||
requestId,
|
||||
controlSocket,
|
||||
serverClose
|
||||
)
|
||||
} else {
|
||||
this.options.lifecycle.finishRpcShutdownWithoutReply(serverClose)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
private sendExitEvent(
|
||||
client: ConnectedDaemonClient | undefined,
|
||||
sessionId: string,
|
||||
code: number,
|
||||
incarnationId?: string
|
||||
): void {
|
||||
if (!client?.streamSocket) {
|
||||
return
|
||||
}
|
||||
this.options.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId,
|
||||
payload: { code, ...(incarnationId ? { incarnationId } : {}) }
|
||||
})
|
||||
this.options.streamDataBatcher.flush(client.clientId)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { DaemonClient } from './client'
|
||||
import { createMockSubprocess } from './daemon-pty-adapter-test-harness'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
type DaemonServerInternals = {
|
||||
preparations: {
|
||||
pending: Map<string, Set<{ canceled: boolean }>>
|
||||
}
|
||||
host: { listSessions: () => unknown[] }
|
||||
}
|
||||
|
||||
describe('daemon async spawn cancellation', () => {
|
||||
let directory: string
|
||||
@@ -82,14 +88,11 @@ describe('daemon async spawn cancellation', () => {
|
||||
await spawnStarted
|
||||
|
||||
client.disconnect()
|
||||
const daemon = server as unknown as {
|
||||
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean }>>
|
||||
host: { listSessions: () => unknown[] }
|
||||
}
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
[...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled
|
||||
).toBe(true)
|
||||
expect([...(daemon.preparations.pending.get('disconnected-spawn') ?? [])][0]?.canceled).toBe(
|
||||
true
|
||||
)
|
||||
)
|
||||
releaseSpawn()
|
||||
await create
|
||||
@@ -145,14 +148,10 @@ describe('daemon async spawn cancellation', () => {
|
||||
30_000,
|
||||
abort.signal
|
||||
)
|
||||
const daemon = server as unknown as {
|
||||
pendingPtySpawnPreparations: Map<string, Set<unknown>>
|
||||
}
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
// Attach-only used to register nothing, so the daemon could not match the
|
||||
// cancel and the client dropped its only timeout.
|
||||
await vi.waitFor(() =>
|
||||
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
|
||||
)
|
||||
await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2))
|
||||
|
||||
abort.abort()
|
||||
await expect(attach).rejects.toThrow('client_disconnected')
|
||||
@@ -175,12 +174,8 @@ describe('daemon async spawn cancellation', () => {
|
||||
abort.signal
|
||||
)
|
||||
await spawnStarted
|
||||
const daemon = server as unknown as {
|
||||
pendingPtySpawnPreparations: Map<string, Set<unknown>>
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
|
||||
)
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2))
|
||||
|
||||
abort.abort()
|
||||
await expect(second).rejects.toThrow('client_disconnected')
|
||||
|
||||
@@ -35,9 +35,13 @@ type DaemonAttachmentPrivate = {
|
||||
createOrAttach: (options: CreateOrAttachOptions) => Promise<CreateOrAttachResult>
|
||||
sessions: Map<string, { hasAttachedClients: boolean }>
|
||||
}
|
||||
clients: Map<string, { streamSocket: Socket | null }>
|
||||
streamClientIdBySessionId: Map<string, string>
|
||||
attachTokenBySessionId: Map<string, symbol>
|
||||
connections: {
|
||||
clients: Map<string, { streamSocket: Socket | null }>
|
||||
}
|
||||
attachments: {
|
||||
clientIdBySessionId: Map<string, string>
|
||||
tokenBySessionId: Map<string, symbol>
|
||||
}
|
||||
}
|
||||
|
||||
describe('DaemonServer attachment lifecycle', () => {
|
||||
@@ -73,7 +77,7 @@ describe('DaemonServer attachment lifecycle', () => {
|
||||
await createSession('transport-owned-session')
|
||||
const daemon = server as unknown as DaemonAttachmentPrivate
|
||||
const detachClients = vi.spyOn(daemon.host, 'detachClients')
|
||||
const streamSocket = [...daemon.clients.values()][0]?.streamSocket
|
||||
const streamSocket = [...daemon.connections.clients.values()][0]?.streamSocket
|
||||
|
||||
streamSocket?.destroy()
|
||||
|
||||
@@ -82,8 +86,8 @@ describe('DaemonServer attachment lifecycle', () => {
|
||||
{ sessionId: 'transport-owned-session', token: expect.any(Symbol) }
|
||||
])
|
||||
expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false)
|
||||
expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false)
|
||||
expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false)
|
||||
expect(daemon.attachments.clientIdBySessionId.has('transport-owned-session')).toBe(false)
|
||||
expect(daemon.attachments.tokenBySessionId.has('transport-owned-session')).toBe(false)
|
||||
})
|
||||
|
||||
it('parks only the requesting client attachment on explicit detach', async () => {
|
||||
@@ -95,8 +99,8 @@ describe('DaemonServer attachment lifecycle', () => {
|
||||
|
||||
expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol))
|
||||
expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false)
|
||||
expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false)
|
||||
expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false)
|
||||
expect(daemon.attachments.clientIdBySessionId.has('explicitly-detached-session')).toBe(false)
|
||||
expect(daemon.attachments.tokenBySessionId.has('explicitly-detached-session')).toBe(false)
|
||||
})
|
||||
|
||||
it('parks an attachment completed after its transport already closed', async () => {
|
||||
@@ -122,7 +126,7 @@ describe('DaemonServer attachment lifecycle', () => {
|
||||
await created
|
||||
|
||||
client.disconnect()
|
||||
await vi.waitFor(() => expect(daemon.clients.size).toBe(0))
|
||||
await vi.waitFor(() => expect(daemon.connections.clients.size).toBe(0))
|
||||
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true)
|
||||
finishRequest()
|
||||
|
||||
@@ -130,7 +134,7 @@ describe('DaemonServer attachment lifecycle', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false)
|
||||
)
|
||||
expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false)
|
||||
expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false)
|
||||
expect(daemon.attachments.clientIdBySessionId.has('close-race-session')).toBe(false)
|
||||
expect(daemon.attachments.tokenBySessionId.has('close-race-session')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmodSync, linkSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { chmodSync, linkSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Server } from 'node:net'
|
||||
import { connect, createServer, type Server } from 'node:net'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import type { ConnectedDaemonClient } from './daemon-client-connections'
|
||||
import { DaemonClient } from './client'
|
||||
import { isDaemonGoneError } from './daemon-pty-adapter'
|
||||
import { isDaemonGoneError } from './daemon-endpoint-errors'
|
||||
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness'
|
||||
|
||||
// A killed process must actually report its exit: teardown waits
|
||||
@@ -32,6 +35,21 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
dispose() {}
|
||||
}
|
||||
}
|
||||
type DaemonServerInternals = {
|
||||
endpoint: {
|
||||
hasLostOwnership(): boolean
|
||||
requestRetirementForLoss(): void
|
||||
checkOwnership(): void
|
||||
ownershipLossStreak: number
|
||||
ownershipLost: boolean
|
||||
}
|
||||
lifecycle: {
|
||||
retirementRequested: boolean
|
||||
state: string
|
||||
server: Server | null
|
||||
}
|
||||
connections: { clients: Map<string, ConnectedDaemonClient> }
|
||||
}
|
||||
|
||||
describe('daemon server error handling', () => {
|
||||
let dir: string
|
||||
@@ -88,8 +106,8 @@ describe('daemon server error handling', () => {
|
||||
).rejects.toThrow(/no longer owns its endpoint/)
|
||||
|
||||
// And it stands down rather than lingering as an unreachable host.
|
||||
const daemon = server as unknown as { retirementRequested: boolean }
|
||||
expect(daemon.retirementRequested).toBe(true)
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(true)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => usurper.close(() => resolve()))
|
||||
}
|
||||
@@ -108,17 +126,14 @@ describe('daemon server error handling', () => {
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
const daemon = server as unknown as {
|
||||
hasLostEndpointOwnership: () => boolean
|
||||
retirementRequested: boolean
|
||||
}
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
|
||||
try {
|
||||
// Drop search permission on the directory so the ownership stat fails EACCES.
|
||||
chmodSync(dir, 0o600)
|
||||
|
||||
expect(daemon.hasLostEndpointOwnership()).toBe(false)
|
||||
expect(daemon.retirementRequested).toBe(false)
|
||||
expect(daemon.endpoint.hasLostOwnership()).toBe(false)
|
||||
expect(daemon.lifecycle.retirementRequested).toBe(false)
|
||||
} finally {
|
||||
chmodSync(dir, 0o700)
|
||||
}
|
||||
@@ -150,8 +165,8 @@ describe('daemon server error handling', () => {
|
||||
linkSync(usurperBind, socketPath)
|
||||
|
||||
// The guard is still armed here — no creation has been refused yet.
|
||||
const daemon = server as unknown as { hasLostEndpointOwnership: () => boolean }
|
||||
expect(daemon.hasLostEndpointOwnership()).toBe(true)
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
expect(daemon.endpoint.hasLostOwnership()).toBe(true)
|
||||
|
||||
await expect(
|
||||
client.request('createOrAttach', {
|
||||
@@ -198,14 +213,11 @@ describe('daemon server error handling', () => {
|
||||
).rejects.toThrow(/no longer owns its endpoint/)
|
||||
|
||||
// Simulate a late client completing its handshake and clearing pending retirement.
|
||||
const daemon = server as unknown as {
|
||||
retirementRequested: boolean
|
||||
hasLostEndpointOwnership: () => boolean
|
||||
}
|
||||
daemon.retirementRequested = false
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
daemon.lifecycle.retirementRequested = false
|
||||
|
||||
// The loss must still be remembered, so creation stays closed.
|
||||
expect(daemon.hasLostEndpointOwnership()).toBe(true)
|
||||
expect(daemon.endpoint.hasLostOwnership()).toBe(true)
|
||||
await expect(
|
||||
client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 })
|
||||
).rejects.toThrow(/no longer owns its endpoint/)
|
||||
@@ -248,16 +260,13 @@ describe('daemon server error handling', () => {
|
||||
|
||||
// Losing the endpoint with nothing left to drain must stand the daemon down, even
|
||||
// though this client is still connected.
|
||||
const daemon = server as unknown as {
|
||||
requestRetirementForLostEndpoint: () => void
|
||||
idleShutdownState: string
|
||||
}
|
||||
expect(daemon.idleShutdownState).toBe('running')
|
||||
daemon.requestRetirementForLostEndpoint()
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
expect(daemon.lifecycle.state).toBe('running')
|
||||
daemon.endpoint.requestRetirementForLoss()
|
||||
|
||||
// Drained and unreachable: it must begin standing down rather than wait for this
|
||||
// client, which can never make it routable again.
|
||||
expect(daemon.idleShutdownState).not.toBe('running')
|
||||
expect(daemon.lifecycle.state).not.toBe('running')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => usurper.close(() => resolve()))
|
||||
}
|
||||
@@ -320,19 +329,15 @@ describe('daemon server error handling', () => {
|
||||
unlinkSync(socketPath)
|
||||
linkSync(usurperBind, socketPath)
|
||||
|
||||
const daemon = server as unknown as {
|
||||
retirementRequested: boolean
|
||||
endpointOwnershipLost: boolean
|
||||
checkEndpointOwnership: () => void
|
||||
}
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
// Retirement is already pending for an unrelated reason.
|
||||
daemon.retirementRequested = true
|
||||
daemon.lifecycle.retirementRequested = true
|
||||
|
||||
daemon.checkEndpointOwnership()
|
||||
daemon.checkEndpointOwnership()
|
||||
daemon.endpoint.checkOwnership()
|
||||
daemon.endpoint.checkOwnership()
|
||||
|
||||
// The loss must be recorded regardless, so a later hello cannot undo it.
|
||||
expect(daemon.endpointOwnershipLost).toBe(true)
|
||||
expect(daemon.endpoint.ownershipLost).toBe(true)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => usurper.close(() => resolve()))
|
||||
}
|
||||
@@ -352,12 +357,7 @@ describe('daemon server error handling', () => {
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
const daemon = server as unknown as {
|
||||
checkEndpointOwnership: () => void
|
||||
hasLostEndpointOwnership: () => boolean
|
||||
endpointOwnershipLost: boolean
|
||||
endpointOwnershipLossStreak: number
|
||||
}
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
// A second name for our own socket inode, so ownership can be handed back.
|
||||
const ourAlias = join(dir, '.ours')
|
||||
linkSync(socketPath, ourAlias)
|
||||
@@ -369,21 +369,21 @@ describe('daemon server error handling', () => {
|
||||
// First loss.
|
||||
unlinkSync(socketPath)
|
||||
linkSync(usurperBind, socketPath)
|
||||
daemon.checkEndpointOwnership()
|
||||
expect(daemon.endpointOwnershipLossStreak).toBe(1)
|
||||
expect(daemon.endpointOwnershipLost).toBe(false)
|
||||
daemon.endpoint.checkOwnership()
|
||||
expect(daemon.endpoint.ownershipLossStreak).toBe(1)
|
||||
expect(daemon.endpoint.ownershipLost).toBe(false)
|
||||
|
||||
// Ownership demonstrably returns, observed through the admission path.
|
||||
unlinkSync(socketPath)
|
||||
linkSync(ourAlias, socketPath)
|
||||
expect(daemon.hasLostEndpointOwnership()).toBe(false)
|
||||
expect(daemon.endpointOwnershipLossStreak).toBe(0)
|
||||
expect(daemon.endpoint.hasLostOwnership()).toBe(false)
|
||||
expect(daemon.endpoint.ownershipLossStreak).toBe(0)
|
||||
|
||||
// A later isolated loss is confirmation #1, not #2, so it must not retire.
|
||||
unlinkSync(socketPath)
|
||||
linkSync(usurperBind, socketPath)
|
||||
daemon.checkEndpointOwnership()
|
||||
expect(daemon.endpointOwnershipLost).toBe(false)
|
||||
daemon.endpoint.checkOwnership()
|
||||
expect(daemon.endpoint.ownershipLost).toBe(false)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => usurper.close(() => resolve()))
|
||||
}
|
||||
@@ -400,19 +400,21 @@ describe('daemon server error handling', () => {
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
const daemon = server as unknown as { server: Server | null }
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
|
||||
expect(daemon.server?.listenerCount('error')).toBe(1)
|
||||
expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
// Twice on purpose: a one-shot listener survives the first error and dies on the second,
|
||||
// so a single emit cannot tell a permanent handler from `once`.
|
||||
expect(() => daemon.server?.emit('error', new Error('EMFILE: accept failed'))).not.toThrow()
|
||||
expect(() =>
|
||||
daemon.server?.emit('error', new Error('EMFILE: accept failed again'))
|
||||
daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed'))
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed again'))
|
||||
).not.toThrow()
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
expect(daemon.server?.listenerCount('error')).toBe(1)
|
||||
expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
@@ -422,6 +424,44 @@ describe('daemon server error handling', () => {
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unsupported hello roles without replacing an authenticated stream', async () => {
|
||||
const onAuthenticatedClientPair = vi.fn()
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
onAuthenticatedClientPair,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
const connectedClient = [...daemon.connections.clients.values()][0]
|
||||
const originalStream = connectedClient.streamSocket
|
||||
|
||||
const unsupported = connect(socketPath)
|
||||
unsupported.on('error', () => {})
|
||||
await new Promise<void>((resolve) => unsupported.once('connect', resolve))
|
||||
const response = new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||
unsupported.once('data', (data) => resolve(JSON.parse(data.toString())))
|
||||
})
|
||||
unsupported.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
token: readFileSync(tokenPath, 'utf8'),
|
||||
clientId: connectedClient.clientId,
|
||||
role: 'observer'
|
||||
})
|
||||
)
|
||||
await expect(response).resolves.toMatchObject({ ok: false, error: 'Invalid role' })
|
||||
unsupported.destroy()
|
||||
|
||||
expect(connectedClient.streamSocket).toBe(originalStream)
|
||||
expect(connectedClient.authenticatedPairEstablished).toBe(true)
|
||||
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'stands down instead of serving when the server errors while publication is in flight',
|
||||
async () => {
|
||||
@@ -443,14 +483,14 @@ describe('daemon server error handling', () => {
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
const started = server.start()
|
||||
const daemon = server as unknown as { server: Server | null }
|
||||
await vi.waitFor(() => expect(daemon.server?.listening).toBe(true))
|
||||
daemon.server?.emit('error', new Error('injected accept failure'))
|
||||
const daemon = server as unknown as DaemonServerInternals
|
||||
await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true))
|
||||
daemon.lifecycle.server?.emit('error', new Error('injected accept failure'))
|
||||
|
||||
await expect(started).rejects.toThrow('injected accept failure')
|
||||
// Why wait: start() rejects the moment the error lands, while publication is still in
|
||||
// flight. Reporting failure must end with the daemon actually not serving.
|
||||
await vi.waitFor(() => expect(daemon.server).toBeNull())
|
||||
await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull())
|
||||
await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,8 +7,12 @@ import { SessionNotFoundError, type DaemonRequest } from './types'
|
||||
|
||||
type DaemonServerPrivate = {
|
||||
host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void> }
|
||||
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean; clientId: string }>>
|
||||
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
preparations: {
|
||||
pending: Map<string, Set<{ canceled: boolean; clientId: string }>>
|
||||
}
|
||||
requestRouter: {
|
||||
route(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
describe('daemon kill attribution', () => {
|
||||
@@ -38,7 +42,7 @@ describe('daemon kill attribution', () => {
|
||||
})
|
||||
vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished)
|
||||
|
||||
const request = daemon.routeRequest('control-42', {
|
||||
const request = daemon.requestRouter.route('control-42', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
@@ -71,7 +75,7 @@ describe('daemon kill attribution', () => {
|
||||
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused'))
|
||||
|
||||
await expect(
|
||||
daemon.routeRequest('control-42', {
|
||||
daemon.requestRouter.route('control-42', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
@@ -103,10 +107,10 @@ describe('daemon kill attribution', () => {
|
||||
controller: new AbortController(),
|
||||
clientId: 'control-42'
|
||||
}
|
||||
daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation]))
|
||||
daemon.preparations.pending.set('agent-session', new Set([pendingPreparation]))
|
||||
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session'))
|
||||
|
||||
await daemon.routeRequest('control-42', {
|
||||
await daemon.requestRouter.route('control-42', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { createServer, type Server, type Socket } from 'node:net'
|
||||
import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
|
||||
|
||||
type PendingShutdownReply = { start: () => void }
|
||||
|
||||
type DaemonServerLifecycleOptions = {
|
||||
protocolVersion: number
|
||||
initialAdoptionTimeoutMs: number
|
||||
clock: {
|
||||
setTimeout(callback: () => void, delayMs: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
now(): number
|
||||
}
|
||||
endpoint: DaemonEndpointLifecycle
|
||||
log: DaemonFileLog
|
||||
isIdle: () => boolean
|
||||
disposeResources: () => Promise<void>
|
||||
onIdleShutdown: () => void
|
||||
onRpcShutdown: () => void
|
||||
}
|
||||
|
||||
export class DaemonServerLifecycle {
|
||||
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
|
||||
private server: Server | null = null
|
||||
private startupFailure: Error | null = null
|
||||
private state: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running'
|
||||
private initialAdoptionTimer: unknown = null
|
||||
private initialAdoptionDeadlineMs: number | null = null
|
||||
private retirementRequested = false
|
||||
private shutdownPromise: Promise<void> | null = null
|
||||
private ordinaryShutdownServerClose: Promise<void> | null = null
|
||||
private readonly pendingShutdownReplies = new Map<string, PendingShutdownReply>()
|
||||
|
||||
constructor(private readonly options: DaemonServerLifecycleOptions) {}
|
||||
|
||||
isAcceptingWork(): boolean {
|
||||
return this.state === 'running'
|
||||
}
|
||||
|
||||
async start(handleConnection: (socket: Socket) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = createServer(handleConnection)
|
||||
let startupSettled = false
|
||||
const onServerError = (error: Error): void => {
|
||||
if (startupSettled) {
|
||||
this.options.log.log('server-error', { message: error.message })
|
||||
console.warn(`[daemon] Socket server error: ${error.message}`)
|
||||
return
|
||||
}
|
||||
startupSettled = true
|
||||
this.startupFailure = error
|
||||
reject(error)
|
||||
}
|
||||
this.server.on('error', onServerError)
|
||||
const bindPath = this.options.endpoint.bindPath()
|
||||
this.server.listen(bindPath, () => {
|
||||
this.options.endpoint.secureBindPath(bindPath)
|
||||
const abandonStartup = (error: unknown): void => {
|
||||
startupSettled = true
|
||||
const server = this.server
|
||||
this.server = null
|
||||
reject(error)
|
||||
server?.close()
|
||||
this.options.endpoint.abandonBindPath(bindPath)
|
||||
}
|
||||
void this.options.endpoint.publish(bindPath).then(() => {
|
||||
if (this.startupFailure) {
|
||||
this.options.endpoint.retireUnstarted()
|
||||
this.cancelInitialAdoptionTimer()
|
||||
abandonStartup(this.startupFailure)
|
||||
return
|
||||
}
|
||||
if (this.options.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
|
||||
this.armInitialAdoptionTimeout()
|
||||
}
|
||||
this.options.endpoint.startOwnershipWatch()
|
||||
startupSettled = true
|
||||
resolve()
|
||||
}, abandonStartup)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
if (!this.shutdownPromise) {
|
||||
const serverClose = this.beginOrdinaryShutdownFence()
|
||||
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
|
||||
}
|
||||
return this.shutdownPromise
|
||||
}
|
||||
|
||||
onConnectionAccepted(): void {
|
||||
this.cancelInitialAdoptionTimer()
|
||||
}
|
||||
|
||||
onAuthenticatedPair(): void {
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.cancelInitialAdoptionTimer()
|
||||
if (!this.options.endpoint.lost) {
|
||||
this.retirementRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
onLastAuthenticatedClientDisconnected(): void {
|
||||
if (this.state === 'running') {
|
||||
this.retirementRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
onEndpointOwnershipLost(): void {
|
||||
this.retirementRequested = true
|
||||
this.reevaluateIdleShutdown()
|
||||
}
|
||||
|
||||
reevaluateIdleShutdown(): void {
|
||||
if (this.state !== 'running') {
|
||||
return
|
||||
}
|
||||
if (this.retirementRequested) {
|
||||
this.cancelInitialAdoptionTimer()
|
||||
if (this.options.isIdle()) {
|
||||
this.beginIdleShutdown()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!this.options.isIdle() || this.initialAdoptionDeadlineMs === null) {
|
||||
this.cancelInitialAdoptionTimer()
|
||||
return
|
||||
}
|
||||
if (this.initialAdoptionTimer !== null) {
|
||||
return
|
||||
}
|
||||
const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.options.clock.now())
|
||||
if (remainingMs === 0) {
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.retirementRequested = true
|
||||
this.beginIdleShutdown()
|
||||
return
|
||||
}
|
||||
this.initialAdoptionTimer = this.options.clock.setTimeout(() => {
|
||||
this.initialAdoptionTimer = null
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.retirementRequested = true
|
||||
this.beginIdleShutdown()
|
||||
}, remainingMs)
|
||||
}
|
||||
|
||||
beginOrdinaryShutdownFence(): Promise<void> {
|
||||
this.state = 'shutting-down'
|
||||
this.cancelInitialAdoptionTimer()
|
||||
this.ordinaryShutdownServerClose ??= this.beginServerClose()
|
||||
return this.ordinaryShutdownServerClose
|
||||
}
|
||||
|
||||
deferRpcShutdownUntilReply(
|
||||
clientId: string,
|
||||
requestId: string,
|
||||
socket: Socket,
|
||||
serverClose: Promise<void>
|
||||
): void {
|
||||
this.deferShutdownUntilReply(clientId, requestId, socket, () =>
|
||||
this.finishRpcShutdown(serverClose)
|
||||
)
|
||||
}
|
||||
finishRpcShutdownWithoutReply(serverClose: Promise<void>): void {
|
||||
this.shutdownPromise ??= this.finishRpcShutdown(serverClose)
|
||||
}
|
||||
|
||||
retireAfterIdleReply(clientId: string, requestId: string, socket: Socket): void {
|
||||
this.state = 'shutting-down'
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.retirementRequested = false
|
||||
this.cancelInitialAdoptionTimer()
|
||||
const serverClose = this.beginServerClose()
|
||||
this.deferShutdownUntilReply(clientId, requestId, socket, () =>
|
||||
this.finishIdleShutdown(serverClose)
|
||||
)
|
||||
}
|
||||
|
||||
startPendingShutdownReply(clientId: string, requestId: string): void {
|
||||
this.pendingShutdownReplies.get(this.shutdownReplyKey(clientId, requestId))?.start()
|
||||
}
|
||||
|
||||
private armInitialAdoptionTimeout(): void {
|
||||
this.initialAdoptionDeadlineMs =
|
||||
this.options.clock.now() + this.options.initialAdoptionTimeoutMs
|
||||
this.reevaluateIdleShutdown()
|
||||
}
|
||||
|
||||
private cancelInitialAdoptionTimer(): void {
|
||||
if (this.initialAdoptionTimer === null) {
|
||||
return
|
||||
}
|
||||
this.options.clock.clearTimeout(this.initialAdoptionTimer)
|
||||
this.initialAdoptionTimer = null
|
||||
}
|
||||
|
||||
private beginIdleShutdown(): void {
|
||||
this.initialAdoptionTimer = null
|
||||
if (this.state !== 'running') {
|
||||
return
|
||||
}
|
||||
this.state = 'idle-shutdown-pending'
|
||||
if (!this.options.isIdle()) {
|
||||
this.state = 'running'
|
||||
this.reevaluateIdleShutdown()
|
||||
return
|
||||
}
|
||||
this.state = 'shutting-down'
|
||||
const serverClose = this.beginServerClose()
|
||||
this.shutdownPromise = this.finishIdleShutdown(serverClose)
|
||||
}
|
||||
|
||||
private async finishOrdinaryShutdown(serverClose: Promise<void>): Promise<void> {
|
||||
this.options.endpoint.unlinkOwnedArtifacts()
|
||||
await this.disposeResources()
|
||||
await serverClose
|
||||
}
|
||||
|
||||
private async finishRpcShutdown(serverClose: Promise<void>): Promise<void> {
|
||||
await this.finishOrdinaryShutdown(serverClose)
|
||||
this.options.onRpcShutdown()
|
||||
}
|
||||
|
||||
private async finishIdleShutdown(serverClose: Promise<void>): Promise<void> {
|
||||
this.options.endpoint.unlinkOwnedArtifacts()
|
||||
await this.disposeResources()
|
||||
await serverClose
|
||||
this.options.onIdleShutdown()
|
||||
}
|
||||
|
||||
private async disposeResources(): Promise<void> {
|
||||
await this.options.disposeResources()
|
||||
this.pendingShutdownReplies.clear()
|
||||
}
|
||||
|
||||
private beginServerClose(): Promise<void> {
|
||||
const server = this.server
|
||||
this.server = null
|
||||
if (!server) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
|
||||
private deferShutdownUntilReply(
|
||||
clientId: string,
|
||||
requestId: string,
|
||||
socket: Socket,
|
||||
finish: () => Promise<void>
|
||||
): void {
|
||||
const key = this.shutdownReplyKey(clientId, requestId)
|
||||
let started = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const start = (): void => {
|
||||
if (started) {
|
||||
return
|
||||
}
|
||||
started = true
|
||||
clearTimeout(timer)
|
||||
socket.off('close', start)
|
||||
socket.off('error', start)
|
||||
this.pendingShutdownReplies.delete(key)
|
||||
this.shutdownPromise ??= finish()
|
||||
}
|
||||
timer = setTimeout(start, DaemonServerLifecycle.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
|
||||
timer.unref()
|
||||
socket.once('close', start)
|
||||
socket.once('error', start)
|
||||
this.pendingShutdownReplies.set(key, { start })
|
||||
}
|
||||
|
||||
private shutdownReplyKey(clientId: string, requestId: string): string {
|
||||
return `${clientId}\u0000${requestId}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
|
||||
export type DaemonServerOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
startedAtMs?: number
|
||||
publishEndpointOwnership?: () => void
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
protocolVersion?: number
|
||||
onIdleShutdown?: () => void
|
||||
onRpcShutdown?: () => void
|
||||
initialAdoptionTestConfig?: {
|
||||
timeoutMs: number
|
||||
clock: {
|
||||
setTimeout(callback: () => void, delayMs: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
now(): number
|
||||
}
|
||||
}
|
||||
ptySpawnHealthCheck?: () => Promise<void>
|
||||
preparePtySpawn?: () => Promise<void>
|
||||
onPtySessionExit?: (sessionId: string) => void
|
||||
onAuthenticatedClientPair?: () => void
|
||||
log?: DaemonFileLog
|
||||
spawnSubprocess: (opts: {
|
||||
sessionId: string
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
command?: string
|
||||
shellOverride?: string
|
||||
isCanceled?: () => boolean
|
||||
}) => SubprocessHandle | Promise<SubprocessHandle>
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import type { ConnectedDaemonClient } from './daemon-client-connections'
|
||||
import { DaemonClient } from './client'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { PROTOCOL_VERSION, type DaemonRequest } from './types'
|
||||
@@ -50,21 +51,16 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
}
|
||||
|
||||
type DaemonServerPrivate = {
|
||||
server: Server | null
|
||||
pendingPtySpawnPreparations: Map<string, Set<unknown>>
|
||||
lifecycle: { server: Server | null }
|
||||
preparations: { pending: Map<string, Set<unknown>> }
|
||||
host: {
|
||||
kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void>
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
connections: { clients: Map<string, ConnectedDaemonClient> }
|
||||
requestRouter: {
|
||||
route(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
}
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
clientId: string
|
||||
controlSocket: Socket
|
||||
streamSocket: Socket | null
|
||||
authenticatedPairEstablished: boolean
|
||||
}
|
||||
>
|
||||
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
describe('DaemonServer', () => {
|
||||
@@ -345,11 +341,11 @@ describe('DaemonServer', () => {
|
||||
// Wait for the server to process the close (and cancel the prep) before
|
||||
// releasing the preflight, else the resumed spawn races ahead of cancellation.
|
||||
await vi.waitFor(() =>
|
||||
expect((server as unknown as DaemonServerPrivate).clients.size).toBe(0)
|
||||
expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0)
|
||||
)
|
||||
finishPreparation()
|
||||
await vi.waitFor(() =>
|
||||
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
|
||||
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
|
||||
)
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -486,8 +482,8 @@ describe('DaemonServer', () => {
|
||||
const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown)
|
||||
|
||||
let acknowledged = false
|
||||
const routed = daemon
|
||||
.routeRequest('client-1', {
|
||||
const routed = daemon.requestRouter
|
||||
.route('client-1', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
@@ -592,14 +588,14 @@ describe('DaemonServer', () => {
|
||||
write: vi.fn()
|
||||
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
|
||||
|
||||
daemon.clients.set('client-1', {
|
||||
daemon.connections.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket,
|
||||
streamSocket,
|
||||
authenticatedPairEstablished: true
|
||||
})
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'req-1',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||||
@@ -612,7 +608,7 @@ describe('DaemonServer', () => {
|
||||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"')
|
||||
|
||||
streamSocket.write.mockClear()
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'req-2',
|
||||
type: 'write',
|
||||
payload: { sessionId: 'test-session', data: 'x' }
|
||||
@@ -649,14 +645,14 @@ describe('DaemonServer', () => {
|
||||
write: vi.fn()
|
||||
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
|
||||
|
||||
daemon.clients.set('client-1', {
|
||||
daemon.connections.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket,
|
||||
streamSocket,
|
||||
authenticatedPairEstablished: true
|
||||
})
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'req-1',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||||
@@ -707,13 +703,13 @@ describe('DaemonServer', () => {
|
||||
writableLength: number
|
||||
}
|
||||
|
||||
daemon.clients.set('client-1', {
|
||||
daemon.connections.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket,
|
||||
streamSocket,
|
||||
authenticatedPairEstablished: true
|
||||
})
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'req-1',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||||
@@ -780,11 +776,11 @@ describe('DaemonServer', () => {
|
||||
const control = await connectRawHello('control', 'raw-client')
|
||||
const stream = await connectRawHello('stream', 'raw-client')
|
||||
|
||||
expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy()
|
||||
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
|
||||
|
||||
stream.destroy()
|
||||
|
||||
await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null)
|
||||
await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null)
|
||||
control.destroy()
|
||||
})
|
||||
|
||||
@@ -801,7 +797,7 @@ describe('DaemonServer', () => {
|
||||
const secondStream = await connectRawHello('stream', 'raw-client')
|
||||
|
||||
await waitFor(() => firstClosed)
|
||||
expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy()
|
||||
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
|
||||
secondStream.destroy()
|
||||
control.destroy()
|
||||
})
|
||||
@@ -823,8 +819,8 @@ describe('DaemonServer', () => {
|
||||
const secondControl = await connectRawHello('control', 'raw-client')
|
||||
|
||||
await waitFor(() => firstControlClosed && firstStreamClosed)
|
||||
expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy()
|
||||
expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull()
|
||||
expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy()
|
||||
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull()
|
||||
secondControl.destroy()
|
||||
})
|
||||
|
||||
@@ -838,7 +834,7 @@ describe('DaemonServer', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => closed || stream.destroyed)
|
||||
expect(daemon.clients.has('missing-client')).toBe(false)
|
||||
expect(daemon.connections.clients.has('missing-client')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -850,7 +846,7 @@ describe('DaemonServer', () => {
|
||||
const daemon = server as unknown as DaemonServerPrivate & {
|
||||
host: { dispose: () => Promise<void> }
|
||||
}
|
||||
const controlSocket = [...daemon.clients.values()][0].controlSocket
|
||||
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
|
||||
const originalWrite = controlSocket.write.bind(controlSocket)
|
||||
let replyFlushed: (() => void) | undefined
|
||||
vi.spyOn(controlSocket, 'write').mockImplementation(((
|
||||
@@ -932,7 +928,7 @@ describe('DaemonServer', () => {
|
||||
const c = await connectClient()
|
||||
await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({})
|
||||
|
||||
await waitFor(() => daemon.server === null)
|
||||
await waitFor(() => daemon.lifecycle.server === null)
|
||||
// Why not existsSync: the dead entry remains for the next publisher to replace.
|
||||
expect(await waitForEndpointUnreachable(socketPath)).toBe(true)
|
||||
const late = new DaemonClient({ socketPath, tokenPath })
|
||||
|
||||
+204
-1463
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { TerminalHost } from './terminal-host'
|
||||
|
||||
export class DaemonSessionAttachments {
|
||||
private readonly clientIdBySessionId = new Map<string, string>()
|
||||
private readonly tokenBySessionId = new Map<string, symbol>()
|
||||
private readonly lastInputAtBySessionId = new Map<string, number>()
|
||||
|
||||
constructor(private readonly host: TerminalHost) {}
|
||||
|
||||
attach(sessionId: string, clientId: string, token: symbol): void {
|
||||
this.clientIdBySessionId.set(sessionId, clientId)
|
||||
this.tokenBySessionId.set(sessionId, token)
|
||||
}
|
||||
|
||||
clientIdForSession(sessionId: string): string | undefined {
|
||||
return this.clientIdBySessionId.get(sessionId)
|
||||
}
|
||||
|
||||
recordInput(sessionId: string): void {
|
||||
this.lastInputAtBySessionId.set(sessionId, performance.now())
|
||||
}
|
||||
|
||||
lastInputAt(sessionId: string): number | undefined {
|
||||
return this.lastInputAtBySessionId.get(sessionId)
|
||||
}
|
||||
|
||||
clearInput(sessionId: string): void {
|
||||
this.lastInputAtBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
release(sessionId: string): void {
|
||||
this.clientIdBySessionId.delete(sessionId)
|
||||
this.tokenBySessionId.delete(sessionId)
|
||||
this.lastInputAtBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
detachSessionForClient(sessionId: string, clientId: string): void {
|
||||
if (this.clientIdBySessionId.get(sessionId) !== clientId) {
|
||||
return
|
||||
}
|
||||
const token = this.tokenBySessionId.get(sessionId)
|
||||
if (token) {
|
||||
this.host.detach(sessionId, token)
|
||||
}
|
||||
this.clientIdBySessionId.delete(sessionId)
|
||||
this.tokenBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
detachClientSessions(clientId: string): void {
|
||||
const attachments: { sessionId: string; token: symbol }[] = []
|
||||
for (const [sessionId, attachedClientId] of this.clientIdBySessionId) {
|
||||
if (attachedClientId !== clientId) {
|
||||
continue
|
||||
}
|
||||
const token = this.tokenBySessionId.get(sessionId)
|
||||
if (token) {
|
||||
attachments.push({ sessionId, token })
|
||||
}
|
||||
this.clientIdBySessionId.delete(sessionId)
|
||||
this.tokenBySessionId.delete(sessionId)
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
this.host.detachClients(attachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
|
||||
import type { DaemonSessionAttachments } from './daemon-session-attachments'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import type { TerminalHost } from './terminal-host'
|
||||
|
||||
type DaemonSessionBackgroundRoutingOptions = {
|
||||
host: TerminalHost
|
||||
attachments: DaemonSessionAttachments
|
||||
transientFactRelay: BackgroundTransientFactRelay
|
||||
streamDataBatcher: DaemonStreamDataBatcher
|
||||
}
|
||||
|
||||
export class DaemonSessionBackgroundRouting {
|
||||
constructor(private readonly options: DaemonSessionBackgroundRoutingOptions) {}
|
||||
|
||||
setBackground(sessionId: string, background: boolean): Record<string, never> {
|
||||
recordDaemonStreamBacklogEvent('setSessionBackground', {
|
||||
sessionIdSuffix: sessionId.slice(-10),
|
||||
background
|
||||
})
|
||||
const changed = this.options.transientFactRelay.setSessionBackground(sessionId, background)
|
||||
this.options.streamDataBatcher.refreshSessionDroppability(sessionId)
|
||||
if (!changed) {
|
||||
return {}
|
||||
}
|
||||
if (background) {
|
||||
this.options.transientFactRelay.seedSessionScanState(
|
||||
sessionId,
|
||||
this.options.host.getPartialEscapeTailAnsi(sessionId)
|
||||
)
|
||||
}
|
||||
const streamClientId = this.options.attachments.clientIdForSession(sessionId)
|
||||
if (!streamClientId) {
|
||||
return {}
|
||||
}
|
||||
const mode2031State = this.options.transientFactRelay.getMode2031ReplyScanState(sessionId)
|
||||
const scanSeedAnsi = background
|
||||
? ''
|
||||
: mode2031State.pendingSubscribe
|
||||
? mode2031State.tail
|
||||
: this.options.host.getPartialEscapeTailAnsi(sessionId)
|
||||
this.options.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId,
|
||||
payload: {
|
||||
background,
|
||||
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}),
|
||||
...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {})
|
||||
}
|
||||
})
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ 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 { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import type { ConnectedDaemonClient } from './daemon-client-connections'
|
||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
|
||||
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
@@ -16,22 +17,14 @@ type MockSubprocess = SubprocessHandle & {
|
||||
}
|
||||
|
||||
type DaemonLifecyclePrivate = {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
clientId: string
|
||||
controlSocket: Socket
|
||||
streamSocket: Socket | null
|
||||
authenticatedPairEstablished: boolean
|
||||
}
|
||||
>
|
||||
host: {
|
||||
getPartialEscapeTailAnsi(sessionId: string): string
|
||||
connections: { clients: Map<string, ConnectedDaemonClient> }
|
||||
host: { getPartialEscapeTailAnsi(sessionId: string): string }
|
||||
requestRouter: {
|
||||
route(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
}
|
||||
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
streamDataBatcher: DaemonStreamDataBatcher
|
||||
transientFactRelay: BackgroundTransientFactRelay
|
||||
streamClientIdBySessionId: Map<string, string>
|
||||
attachments: { clientIdBySessionId: Map<string, string> }
|
||||
}
|
||||
|
||||
function createMockSubprocess(): MockSubprocess {
|
||||
@@ -95,7 +88,7 @@ function addClient(
|
||||
write: ReturnType<typeof vi.fn>
|
||||
writableLength: number
|
||||
}
|
||||
daemon.clients.set('client-1', {
|
||||
daemon.connections.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket,
|
||||
streamSocket,
|
||||
@@ -134,7 +127,7 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
server = harness.server
|
||||
const { daemon } = harness
|
||||
addClient(daemon)
|
||||
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
|
||||
daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1')
|
||||
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('')
|
||||
const lifecycle: string[] = []
|
||||
vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation(
|
||||
@@ -152,7 +145,7 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
}
|
||||
)
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'background',
|
||||
type: 'setSessionBackground',
|
||||
payload: { sessionId: 'session-toggle', background: true }
|
||||
@@ -160,7 +153,7 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
expect(lifecycle).toEqual(['refresh:true', 'marker:true'])
|
||||
|
||||
lifecycle.length = 0
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'duplicate-background',
|
||||
type: 'setSessionBackground',
|
||||
payload: { sessionId: 'session-toggle', background: true }
|
||||
@@ -168,7 +161,7 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
expect(lifecycle).toEqual(['refresh:true'])
|
||||
|
||||
lifecycle.length = 0
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'foreground',
|
||||
type: 'setSessionBackground',
|
||||
payload: { sessionId: 'session-toggle', background: false }
|
||||
@@ -181,13 +174,13 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
server = harness.server
|
||||
const { daemon } = harness
|
||||
addClient(daemon)
|
||||
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
|
||||
daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1')
|
||||
daemon.transientFactRelay.setSessionBackground('session-toggle', true)
|
||||
daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?')
|
||||
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?')
|
||||
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'foreground',
|
||||
type: 'setSessionBackground',
|
||||
payload: { sessionId: 'session-toggle', background: false }
|
||||
@@ -216,7 +209,9 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
const lifecycle: string[] = []
|
||||
vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation(
|
||||
(sessionId) => {
|
||||
lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`)
|
||||
lifecycle.push(
|
||||
`refresh:${daemon.attachments.clientIdBySessionId.get(sessionId) ?? 'unrouted'}`
|
||||
)
|
||||
}
|
||||
)
|
||||
vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation(
|
||||
@@ -225,7 +220,7 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
}
|
||||
)
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'attach',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'session-attach', cols: 80, rows: 24 }
|
||||
@@ -241,7 +236,7 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
addClient(daemon, 128 * 1024)
|
||||
daemon.transientFactRelay.setSessionBackground('session-exit', true)
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
await daemon.requestRouter.route('client-1', {
|
||||
id: 'attach',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'session-exit', cols: 80, rows: 24 }
|
||||
@@ -262,6 +257,6 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
payload: { code: 42 }
|
||||
})
|
||||
expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false)
|
||||
expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false)
|
||||
expect(daemon.attachments.clientIdBySessionId.has('session-exit')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
|
||||
import type { DaemonClientConnections } from './daemon-client-connections'
|
||||
import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle'
|
||||
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type {
|
||||
DaemonPtySpawnPreparations,
|
||||
PendingPtySpawnPreparation
|
||||
} from './daemon-pty-spawn-preparations'
|
||||
import type { DaemonSessionAttachments } from './daemon-session-attachments'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
||||
import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
|
||||
import {
|
||||
isAgentSessionExecutionClaim,
|
||||
isAgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import { type DaemonRequest, TerminalAttachCanceledError } from './types'
|
||||
|
||||
type CreateOrAttachRequest = Extract<DaemonRequest, { type: 'createOrAttach' }>
|
||||
|
||||
type DaemonTerminalAdmissionOptions = {
|
||||
host: TerminalHost
|
||||
connections: DaemonClientConnections
|
||||
endpoint: DaemonEndpointLifecycle
|
||||
preparations: DaemonPtySpawnPreparations
|
||||
attachments: DaemonSessionAttachments
|
||||
historySeedTransfers: TerminalHistorySeedTransferRegistry
|
||||
transientFactRelay: BackgroundTransientFactRelay
|
||||
streamDataBatcher: DaemonStreamDataBatcher
|
||||
log: DaemonFileLog
|
||||
isAcceptingWork: () => boolean
|
||||
requestEndpointRetirement: () => void
|
||||
reevaluateIdleShutdown: () => void
|
||||
}
|
||||
|
||||
export class DaemonTerminalAdmission {
|
||||
private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100
|
||||
private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024
|
||||
private createOrAttachInFlight = 0
|
||||
|
||||
constructor(private readonly options: DaemonTerminalAdmissionOptions) {}
|
||||
|
||||
get inFlight(): number {
|
||||
return this.createOrAttachInFlight
|
||||
}
|
||||
|
||||
async createOrAttach(clientId: string, request: CreateOrAttachRequest): Promise<unknown> {
|
||||
const client = this.options.connections.get(clientId)
|
||||
if (!this.options.isAcceptingWork()) {
|
||||
throw new Error('Daemon temporarily unavailable; reconnect')
|
||||
}
|
||||
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
|
||||
throw new Error('Daemon client connection is incomplete; reconnect')
|
||||
}
|
||||
const payload = request.payload
|
||||
const attachOnly = payload.attachOnly === true
|
||||
if (!attachOnly && this.options.endpoint.hasLostOwnership()) {
|
||||
this.options.requestEndpointRetirement()
|
||||
throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE)
|
||||
}
|
||||
this.createOrAttachInFlight++
|
||||
let routedSessionId = payload.sessionId
|
||||
let result: CreateOrAttachResult
|
||||
let spawnPreparation: PendingPtySpawnPreparation | null = null
|
||||
try {
|
||||
if (
|
||||
payload.agentSessionEnsure !== undefined &&
|
||||
(!isAgentSessionExecutionClaim(payload.agentSessionEnsure.claim) ||
|
||||
!isAgentSessionSurfaceBinding(payload.agentSessionEnsure.surface))
|
||||
) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
spawnPreparation = this.options.preparations.register(
|
||||
payload.sessionId,
|
||||
clientId,
|
||||
request.id,
|
||||
payload.cancelAfterMs
|
||||
)
|
||||
if (!attachOnly) {
|
||||
await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation)
|
||||
}
|
||||
if (payload.historySeed !== undefined && payload.historySeedTransferId !== undefined) {
|
||||
throw new Error('Multiple terminal history seed sources')
|
||||
}
|
||||
const historySeedChunks =
|
||||
payload.historySeedTransferId !== undefined
|
||||
? this.options.historySeedTransfers.take(clientId, payload.historySeedTransferId)
|
||||
: payload.historySeed !== undefined
|
||||
? [payload.historySeed]
|
||||
: undefined
|
||||
result = await this.options.host.createOrAttach({
|
||||
sessionId: payload.sessionId,
|
||||
cols: payload.cols,
|
||||
rows: payload.rows,
|
||||
cwd: payload.cwd,
|
||||
env: payload.env,
|
||||
envToDelete: payload.envToDelete,
|
||||
command: payload.command,
|
||||
startupCommandDelivery: payload.startupCommandDelivery,
|
||||
...(attachOnly ? { attachOnly: true } : {}),
|
||||
...(payload.expectedIncarnationId !== undefined
|
||||
? { expectedIncarnationId: payload.expectedIncarnationId }
|
||||
: {}),
|
||||
...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}),
|
||||
shellOverride: payload.shellOverride,
|
||||
terminalWindowsWslDistro: payload.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation,
|
||||
shellReadySupported: payload.shellReadySupported,
|
||||
historySeedChunks,
|
||||
startupIngress: parsePtyStartupIngressIntent(payload.startupIngress),
|
||||
...(payload.shellReadyTimeoutMs !== undefined
|
||||
? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs }
|
||||
: {}),
|
||||
...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}),
|
||||
isCanceled: () => spawnPreparation?.canceled === true,
|
||||
cancelSignal: spawnPreparation.controller.signal,
|
||||
onSessionResolved: (sessionId) => {
|
||||
routedSessionId = sessionId
|
||||
},
|
||||
streamClient: this.createStreamClient(clientId, () => routedSessionId)
|
||||
})
|
||||
} finally {
|
||||
if (spawnPreparation) {
|
||||
this.options.preparations.finish(payload.sessionId, spawnPreparation)
|
||||
}
|
||||
this.createOrAttachInFlight--
|
||||
this.options.reevaluateIdleShutdown()
|
||||
}
|
||||
|
||||
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId
|
||||
if (
|
||||
this.options.connections.get(clientId) !== client ||
|
||||
!client.authenticatedPairEstablished ||
|
||||
client.streamSocket === null
|
||||
) {
|
||||
this.options.host.detach(routedSessionId, result.attachToken)
|
||||
throw new TerminalAttachCanceledError(routedSessionId)
|
||||
}
|
||||
this.options.attachments.attach(routedSessionId, clientId, result.attachToken)
|
||||
this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId)
|
||||
if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) {
|
||||
this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId: routedSessionId,
|
||||
payload: { background: true }
|
||||
})
|
||||
}
|
||||
this.options.log.log(result.isNew ? 'session-created' : 'session-attached', {
|
||||
sessionId: routedSessionId,
|
||||
pid: result.pid
|
||||
})
|
||||
return {
|
||||
isNew: result.isNew,
|
||||
snapshot: result.snapshot,
|
||||
pid: result.pid,
|
||||
shellState: result.shellState,
|
||||
incarnationId: result.incarnationId,
|
||||
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
|
||||
wslDistro: result.wslDistro,
|
||||
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
|
||||
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private createStreamClient(
|
||||
clientId: string,
|
||||
sessionId: () => string
|
||||
): CreateOrAttachOptions['streamClient'] {
|
||||
return {
|
||||
onData: (data, rawLength = data.length, transformed = false, seq) => {
|
||||
const routedSessionId = sessionId()
|
||||
this.options.transientFactRelay.onSessionData(routedSessionId, data)
|
||||
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
|
||||
const isInteractiveOutput =
|
||||
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
|
||||
lastInputAt !== undefined &&
|
||||
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
|
||||
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
|
||||
flushImmediately: isInteractiveOutput,
|
||||
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
|
||||
rawLength,
|
||||
transformed,
|
||||
seq
|
||||
})
|
||||
},
|
||||
onExit: (code, incarnationId, cause) => {
|
||||
const routedSessionId = sessionId()
|
||||
this.options.log.log('session-exited', {
|
||||
sessionId: routedSessionId,
|
||||
code,
|
||||
cause: cause?.kind
|
||||
})
|
||||
this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId: routedSessionId,
|
||||
payload: { code, incarnationId, ...(cause ? { cause } : {}) }
|
||||
})
|
||||
this.options.streamDataBatcher.flush(clientId)
|
||||
recordDaemonStreamBacklogEvent('sessionExit', {
|
||||
sessionIdSuffix: routedSessionId.slice(-10)
|
||||
})
|
||||
this.options.transientFactRelay.onSessionExit(routedSessionId)
|
||||
this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId)
|
||||
this.options.attachments.release(routedSessionId)
|
||||
this.options.reevaluateIdleShutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ const ORCA_SHELL_WRAPPER_ENV = [
|
||||
'ORCA_MIMOCODE_HOME',
|
||||
'ORCA_PI_CODING_AGENT_DIR',
|
||||
'ORCA_OMP_CODING_AGENT_DIR',
|
||||
'ORCA_CODEX_HOME'
|
||||
'ORCA_OMP_STATUS_EXTENSION',
|
||||
'ORCA_CODEX_HOME',
|
||||
'ORCA_AGENT_TEAMS_SHIM_DIR',
|
||||
'ORCA_REMOTE_CLI_BIN_DIR'
|
||||
] as const
|
||||
export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
|
||||
|
||||
|
||||
+49
-1355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
import type * as pty from 'node-pty'
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
|
||||
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
|
||||
import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess,
|
||||
type RecognizedAgentProcess
|
||||
} from '../../../shared/agent-process-recognition'
|
||||
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
|
||||
import { isShellProcess } from '../../../shared/shell-process-detection'
|
||||
import { parsePtySessionId } from '../pty-session-id'
|
||||
|
||||
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
|
||||
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
|
||||
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
|
||||
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
|
||||
|
||||
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
|
||||
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
|
||||
if (!trimmed || trimmed === 'xterm-256color') {
|
||||
return null
|
||||
}
|
||||
return trimmed.split(/[\\/]/).pop() || null
|
||||
}
|
||||
|
||||
function resolveFallbackForegroundProcess(
|
||||
processName: string | null | undefined,
|
||||
shellPath: string
|
||||
): string | null {
|
||||
const normalized = normalizeForegroundProcessName(processName)
|
||||
if (normalized || process.platform !== 'win32') {
|
||||
return normalized
|
||||
}
|
||||
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
|
||||
}
|
||||
|
||||
function shouldInspectOuterWrapperFallback(processName: string | null): boolean {
|
||||
const recognized = recognizeAgentProcess(processName)
|
||||
return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)
|
||||
}
|
||||
|
||||
export type PtyForegroundProcessTracker = {
|
||||
recordOutput(data: string): void
|
||||
markDead(): void
|
||||
getForegroundProcess(): string | null
|
||||
confirmForegroundProcess(): Promise<string | null>
|
||||
}
|
||||
|
||||
export function createPtyForegroundProcessTracker(args: {
|
||||
process: pty.IPty
|
||||
shellPath: string
|
||||
cwd?: string
|
||||
sessionId: string
|
||||
startupAgentRecognition: RecognizedAgentProcess | null
|
||||
isDead: () => boolean
|
||||
}): PtyForegroundProcessTracker {
|
||||
const proc = args.process
|
||||
let lastOutputAt = 0
|
||||
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
|
||||
const contextPaths = getAgentForegroundContextPaths({
|
||||
cwd: args.cwd,
|
||||
worktreeId: parsePtySessionId(args.sessionId).worktreeId
|
||||
})
|
||||
let startupAgentForeground: { processName: string; expiresAt: number } | null =
|
||||
args.startupAgentRecognition
|
||||
? {
|
||||
processName: args.startupAgentRecognition.processName,
|
||||
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
|
||||
}
|
||||
: null
|
||||
let foregroundRefreshInFlight = false
|
||||
let lastForegroundRefreshStartedAt = 0
|
||||
const getFallbackProcess = (): string | null =>
|
||||
resolveFallbackForegroundProcess(proc.process, args.shellPath)
|
||||
const getActiveStartupAgent = (
|
||||
now = Date.now()
|
||||
): { processName: string; expiresAt: number } | null => {
|
||||
if (!startupAgentForeground) {
|
||||
return null
|
||||
}
|
||||
if (now > startupAgentForeground.expiresAt) {
|
||||
startupAgentForeground = null
|
||||
return null
|
||||
}
|
||||
return startupAgentForeground
|
||||
}
|
||||
const shouldInspectFallback = (fallbackProcess: string | null): boolean =>
|
||||
fallbackProcess !== null &&
|
||||
(isShellProcess(fallbackProcess) ||
|
||||
isAgentForegroundWrapperProcess(fallbackProcess) ||
|
||||
shouldInspectOuterWrapperFallback(fallbackProcess) ||
|
||||
process.platform !== 'win32')
|
||||
|
||||
const scheduleRefresh = (fallbackProcess: string | null): void => {
|
||||
if (args.isDead() || !proc.pid) {
|
||||
return
|
||||
}
|
||||
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
(fallbackRecognition !== null &&
|
||||
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
|
||||
!shouldInspectFallback(fallbackProcess)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const idleNoEvidenceShell =
|
||||
fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground
|
||||
const retryMs = !idleNoEvidenceShell
|
||||
? FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
: process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
|
||||
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
: SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
|
||||
return
|
||||
}
|
||||
foregroundRefreshInFlight = true
|
||||
lastForegroundRefreshStartedAt = now
|
||||
const retireStaleForegroundIdentity = (): void => {
|
||||
const currentFallbackProcess = getFallbackProcess()
|
||||
if (
|
||||
fallbackIsShell &&
|
||||
!getActiveStartupAgent() &&
|
||||
currentFallbackProcess !== null &&
|
||||
isShellProcess(currentFallbackProcess)
|
||||
) {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
} else if (
|
||||
cachedAgentForeground !== null &&
|
||||
Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS &&
|
||||
currentFallbackProcess !== null &&
|
||||
isAgentForegroundWrapperProcess(currentFallbackProcess)
|
||||
) {
|
||||
cachedAgentForeground = null
|
||||
}
|
||||
}
|
||||
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
|
||||
contextPaths
|
||||
})
|
||||
.then<string | void>(({ processName, available }) => {
|
||||
if (args.isDead() || !available) {
|
||||
return
|
||||
}
|
||||
if (!processName || !recognizeAgentProcess(processName)) {
|
||||
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
|
||||
return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => {
|
||||
if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) {
|
||||
return
|
||||
}
|
||||
retireStaleForegroundIdentity()
|
||||
})
|
||||
}
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
cachedAgentForeground = { processName, refreshedAt: Date.now() }
|
||||
startupAgentForeground = null
|
||||
return processName
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort only: foreground enrichment must never affect PTY health.
|
||||
})
|
||||
.finally(() => {
|
||||
foregroundRefreshInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
recordOutput: (data) => {
|
||||
if (data.length > 0) {
|
||||
lastOutputAt = Date.now()
|
||||
}
|
||||
},
|
||||
markDead: () => {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
},
|
||||
getForegroundProcess: () => {
|
||||
if (args.isDead()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const fallbackProcess = getFallbackProcess()
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
const inspectOuterWrapper =
|
||||
fallbackRecognition !== null &&
|
||||
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
|
||||
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
|
||||
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
|
||||
startupAgentForeground = null
|
||||
return fallbackProcess
|
||||
}
|
||||
scheduleRefresh(fallbackProcess)
|
||||
const now = Date.now()
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
fallbackProcess !== null &&
|
||||
(isAgentForegroundWrapperProcess(fallbackProcess) ||
|
||||
inspectOuterWrapper ||
|
||||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
const activeStartupAgentForeground = getActiveStartupAgent(now)
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
|
||||
return activeStartupAgentForeground.processName
|
||||
}
|
||||
return fallbackProcess
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
confirmForegroundProcess: async () => {
|
||||
if (args.isDead() || !proc.pid) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const fallbackProcess = getFallbackProcess()
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
(fallbackRecognition !== null &&
|
||||
process.platform !== 'win32' &&
|
||||
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
|
||||
(process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess))
|
||||
) {
|
||||
return fallbackProcess
|
||||
}
|
||||
const resolution = await resolveAgentForegroundProcessWithAvailability(
|
||||
proc.pid,
|
||||
fallbackProcess,
|
||||
{
|
||||
contextPaths,
|
||||
fresh: true,
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
forceProcessScan: true,
|
||||
readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
)
|
||||
if (args.isDead() || !resolution.available) {
|
||||
return null
|
||||
}
|
||||
const recognized = recognizeAgentProcess(resolution.processName)
|
||||
if (recognized) {
|
||||
cachedAgentForeground = {
|
||||
processName: recognized.processName,
|
||||
refreshedAt: Date.now()
|
||||
}
|
||||
startupAgentForeground = null
|
||||
return recognized.processName
|
||||
}
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
return resolution.processName
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as pty from 'node-pty'
|
||||
import {
|
||||
hostReportsChildExitStatus,
|
||||
wrapShellSpawnForMacosTccAttribution
|
||||
} from '../../providers/macos-tcc-login-shell'
|
||||
import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain'
|
||||
import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job'
|
||||
|
||||
export type SpawnedDaemonPty = {
|
||||
process: pty.IPty
|
||||
shellPath: string
|
||||
spawnCwd: string
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
/** False when a wrapper owns the reported status, so no exit code may be read from it. */
|
||||
reportsChildExitStatus: boolean
|
||||
}
|
||||
|
||||
/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */
|
||||
export function spawnNativeDaemonPty(args: {
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
spawnCwd: string
|
||||
env: Record<string, string>
|
||||
cols: number
|
||||
rows: number
|
||||
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
|
||||
onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void
|
||||
}): SpawnedDaemonPty {
|
||||
let reportsChildExitStatus = true
|
||||
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => {
|
||||
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env)
|
||||
// Why: children inherit job membership, so the host job must exist before the first Windows PTY.
|
||||
if (process.platform === 'win32') {
|
||||
assignHostProcessToKillOnCloseJob()
|
||||
}
|
||||
const proc = pty.spawn(wrapped.file, wrapped.args, {
|
||||
name: args.env.TERM ?? 'xterm-256color',
|
||||
cols: args.cols,
|
||||
rows: args.rows,
|
||||
cwd,
|
||||
env: args.env,
|
||||
// Why: bundled ConPTY has the wrap-marker behavior xterm expects.
|
||||
...(process.platform === 'win32' ? { useConptyDll: true } : {})
|
||||
})
|
||||
reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file)
|
||||
args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped')
|
||||
return proc
|
||||
}
|
||||
|
||||
try {
|
||||
const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd)
|
||||
return {
|
||||
process: process_,
|
||||
shellPath: args.shellPath,
|
||||
spawnCwd: args.spawnCwd,
|
||||
reportsChildExitStatus
|
||||
}
|
||||
} catch (primaryErr) {
|
||||
if (process.platform !== 'win32') {
|
||||
throw primaryErr
|
||||
}
|
||||
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
|
||||
try {
|
||||
const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
|
||||
const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr)
|
||||
console.warn(
|
||||
`[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"`
|
||||
)
|
||||
return {
|
||||
process,
|
||||
shellPath: attempt.shellPath,
|
||||
spawnCwd: attempt.effectiveCwd,
|
||||
startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs,
|
||||
reportsChildExitStatus
|
||||
}
|
||||
} catch {
|
||||
// This fallback shell also failed -- try the next link in the chain.
|
||||
}
|
||||
}
|
||||
throw primaryErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
resolveProcessExitCause,
|
||||
type TerminalExitCause
|
||||
} from '../../../shared/terminal-exit-cause'
|
||||
|
||||
const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024
|
||||
|
||||
type DataListener = (data: string) => void
|
||||
type ExitListener = (code: number, cause?: TerminalExitCause) => void
|
||||
|
||||
/** Preserves spawn-time output and exit ordering until Session installs its listeners. */
|
||||
export class PtyPreListenerEvents {
|
||||
private onDataCb: DataListener | null = null
|
||||
private onExitCb: ExitListener | null = null
|
||||
private pendingData: string[] = []
|
||||
private pendingDataChars = 0
|
||||
private pendingExitCode: number | null = null
|
||||
private pendingExitCause: TerminalExitCause | null = null
|
||||
|
||||
acceptData(data: string): void {
|
||||
if (this.onDataCb) {
|
||||
this.onDataCb(data)
|
||||
return
|
||||
}
|
||||
this.pendingData.push(data)
|
||||
this.pendingDataChars += data.length
|
||||
while (this.pendingDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) {
|
||||
const removed = this.pendingData.shift()
|
||||
if (removed === undefined) {
|
||||
this.pendingDataChars = 0
|
||||
return
|
||||
}
|
||||
this.pendingDataChars -= removed.length
|
||||
}
|
||||
}
|
||||
|
||||
acceptExit(args: {
|
||||
exitCode: number
|
||||
signal?: number
|
||||
hostReportsChildExitStatus: boolean
|
||||
}): void {
|
||||
const cause = resolveProcessExitCause(args)
|
||||
if (this.onExitCb) {
|
||||
this.flushData()
|
||||
this.onExitCb(args.exitCode, cause)
|
||||
} else {
|
||||
this.pendingExitCode = args.exitCode
|
||||
this.pendingExitCause = cause
|
||||
}
|
||||
}
|
||||
|
||||
onData(cb: DataListener): void {
|
||||
this.onDataCb = cb
|
||||
this.flushData()
|
||||
}
|
||||
|
||||
onExit(cb: ExitListener): void {
|
||||
this.onExitCb = cb
|
||||
if (this.pendingExitCode === null) {
|
||||
return
|
||||
}
|
||||
const code = this.pendingExitCode
|
||||
const cause = this.pendingExitCause ?? resolveProcessExitCause({ exitCode: code })
|
||||
this.pendingExitCode = null
|
||||
this.pendingExitCause = null
|
||||
this.flushData()
|
||||
cb(code, cause)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.onDataCb = null
|
||||
this.onExitCb = null
|
||||
this.pendingData = []
|
||||
this.pendingDataChars = 0
|
||||
this.pendingExitCode = null
|
||||
this.pendingExitCause = null
|
||||
}
|
||||
|
||||
private flushData(): void {
|
||||
if (!this.onDataCb || this.pendingData.length === 0) {
|
||||
return
|
||||
}
|
||||
const pending = this.pendingData
|
||||
this.pendingData = []
|
||||
this.pendingDataChars = 0
|
||||
for (const data of pending) {
|
||||
this.onDataCb(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash'
|
||||
import { isPwshAvailable } from '../../pwsh'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env'
|
||||
import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env'
|
||||
import {
|
||||
POWERLEVEL10K_WIZARD_DISABLE_ENV,
|
||||
seedPowerlevel10kWizardEnv
|
||||
} from '../../pty/powerlevel10k-wizard-env'
|
||||
import {
|
||||
resolveSafePtyDefaultCwd,
|
||||
assertSafeAgentStartupCwd
|
||||
} from '../../providers/pty-default-cwd'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
type WindowsPowerShellShellFamily
|
||||
} from '../../providers/windows-powershell'
|
||||
import {
|
||||
buildWindowsPowerShellSpawnAttempts,
|
||||
type WindowsShellSpawnAttempt
|
||||
} from '../../providers/windows-shell-fallback-chain'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from '../../providers/windows-shell-args'
|
||||
import { resolveUnixShellPath } from '../../providers/local-pty-utils'
|
||||
import { selectShellStartupFeatures } from '../../shell-startup-features'
|
||||
import { parseWslPath } from '../../wsl'
|
||||
import { addWslEnvKeys } from '../../wsl-env'
|
||||
import {
|
||||
recognizeAgentProcessFromCommandLine,
|
||||
type RecognizedAgentProcess
|
||||
} from '../../../shared/agent-process-recognition'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
|
||||
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
|
||||
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
|
||||
import { resolveWslSessionContext } from '../wsl-session-context'
|
||||
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
|
||||
import type { PtySubprocessOptions } from '../pty-subprocess'
|
||||
|
||||
export type PtyShellLaunchPlan = {
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
spawnCwd: string
|
||||
validationCwd: string
|
||||
startupCommandDeliveredInShellArgs: boolean
|
||||
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
|
||||
startupAgentRecognition: RecognizedAgentProcess | null
|
||||
}
|
||||
|
||||
export function createPtyShellLaunchPlan(
|
||||
opts: PtySubprocessOptions,
|
||||
env: Record<string, string>
|
||||
): PtyShellLaunchPlan {
|
||||
const resolvedWslContext = resolveWslSessionContext(opts)
|
||||
let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
|
||||
let shellArgs: string[]
|
||||
let startupCommandDeliveredInShellArgs = false
|
||||
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
|
||||
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
|
||||
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
|
||||
const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd()
|
||||
if (opts.command && startupAgentRecognition) {
|
||||
assertSafeAgentStartupCwd(requestedCwd, opts.command)
|
||||
}
|
||||
let spawnCwd = requestedCwd
|
||||
let validationCwd = spawnCwd
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase()
|
||||
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath)
|
||||
const resolvedShellFamily: WindowsPowerShellShellFamily =
|
||||
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
|
||||
? normalizedShellFamily
|
||||
: normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe'
|
||||
? normalizedShellFamily
|
||||
: undefined
|
||||
const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({
|
||||
shellFamily: resolvedShellFamily,
|
||||
implementation: opts.terminalWindowsPowerShellImplementation
|
||||
})
|
||||
const shouldResolvePowerShellFamily =
|
||||
opts.terminalWindowsPowerShellImplementation !== undefined ||
|
||||
pathWin32.basename(shellPath) === shellPath
|
||||
if (resolvedGitBashPath) {
|
||||
shellPath = resolvedGitBashPath
|
||||
} else if (shellPath === WINDOWS_GIT_BASH_SHELL) {
|
||||
shellPath = 'powershell.exe'
|
||||
} else {
|
||||
shellPath = shouldResolvePowerShellFamily
|
||||
? (resolveEffectiveWindowsPowerShell({
|
||||
shellFamily: resolvedShellFamily,
|
||||
implementation: opts.terminalWindowsPowerShellImplementation,
|
||||
pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false
|
||||
}) ?? shellPath)
|
||||
: shellPath
|
||||
}
|
||||
if (
|
||||
pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' &&
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
) {
|
||||
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
|
||||
shellPath,
|
||||
cwd: spawnCwd,
|
||||
defaultCwd: resolveSafePtyDefaultCwd(),
|
||||
wslContext: resolvedWslContext,
|
||||
startupCommand: opts.command
|
||||
})
|
||||
const primaryAttempt = windowsFallbackAttempts[0]
|
||||
if (primaryAttempt) {
|
||||
shellPath = primaryAttempt.shellPath
|
||||
shellArgs = primaryAttempt.shellArgs
|
||||
spawnCwd = primaryAttempt.effectiveCwd
|
||||
validationCwd = primaryAttempt.validationCwd
|
||||
startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs
|
||||
} else {
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
spawnCwd,
|
||||
resolveSafePtyDefaultCwd(),
|
||||
resolvedWslContext,
|
||||
opts.command,
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
validationCwd = resolved.validationCwd
|
||||
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
|
||||
}
|
||||
if (isWindowsGitBashShellPath(shellPath)) {
|
||||
env.CHERE_INVOKING ??= '1'
|
||||
}
|
||||
const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null
|
||||
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
|
||||
if (codexHomeWslInfo) {
|
||||
const launchWslDistro = resolvedWslContext?.distro
|
||||
if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) {
|
||||
delete env.CODEX_HOME
|
||||
delete env.ORCA_CODEX_HOME
|
||||
} else {
|
||||
env.CODEX_HOME = codexHomeWslInfo.linuxPath
|
||||
env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath
|
||||
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
|
||||
if (!launchWslDistro) {
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
requestedCwd,
|
||||
resolveSafePtyDefaultCwd(),
|
||||
{ distro: codexHomeWslInfo.distro },
|
||||
opts.command,
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
validationCwd = resolved.validationCwd
|
||||
startupCommandDeliveredInShellArgs =
|
||||
resolved.startupCommandDeliveredInShellArgs === true
|
||||
}
|
||||
}
|
||||
} else if (isHostCodexHomeForWsl(env.CODEX_HOME)) {
|
||||
delete env.CODEX_HOME
|
||||
delete env.ORCA_CODEX_HOME
|
||||
} else if (env.CODEX_HOME) {
|
||||
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
|
||||
}
|
||||
if (env.CLAUDE_CONFIG_DIR) {
|
||||
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
|
||||
}
|
||||
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
|
||||
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
|
||||
}
|
||||
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
|
||||
delete env.CODEX_HOME
|
||||
delete env.ORCA_CODEX_HOME
|
||||
}
|
||||
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
|
||||
addOrcaWslInteropEnv(env)
|
||||
}
|
||||
} else {
|
||||
rescrubDaemonPtyEnvironment(env, opts)
|
||||
const preferredShellPath = shellPath
|
||||
shellPath = resolveUnixShellPath(shellPath)
|
||||
if (shellPath !== preferredShellPath) {
|
||||
env.SHELL = shellPath
|
||||
console.warn(
|
||||
`[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"`
|
||||
)
|
||||
}
|
||||
const waitsForShellReady =
|
||||
Boolean(opts.command) &&
|
||||
(!isCodexStartupCommand ||
|
||||
shouldUseShellReadyStartupDelivery({
|
||||
command: opts.command as string,
|
||||
startupCommandDelivery: opts.startupCommandDelivery
|
||||
}))
|
||||
delete env.ORCA_SHELL_FEATURES
|
||||
const shellLaunch = getShellLaunchConfig(
|
||||
shellPath,
|
||||
selectShellStartupFeatures({
|
||||
shellPath,
|
||||
env,
|
||||
hasStartupCommand: Boolean(opts.command),
|
||||
waitsForShellReady,
|
||||
emitsStartupIdentity: waitsForShellReady
|
||||
})
|
||||
)
|
||||
Object.assign(env, shellLaunch.env)
|
||||
shellArgs = shellLaunch.args ?? ['-l']
|
||||
}
|
||||
|
||||
seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete })
|
||||
if (
|
||||
env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined &&
|
||||
process.platform === 'win32' &&
|
||||
pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
|
||||
) {
|
||||
addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
|
||||
}
|
||||
finalizeDaemonPtyEnvironment(env, opts.env)
|
||||
|
||||
return {
|
||||
shellPath,
|
||||
shellArgs,
|
||||
spawnCwd,
|
||||
validationCwd,
|
||||
startupCommandDeliveredInShellArgs,
|
||||
windowsFallbackAttempts,
|
||||
startupAgentRecognition
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { delimiter } from 'node:path'
|
||||
import { dropInheritedOrcaFishHistory } from '../../fish-history-session'
|
||||
import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env'
|
||||
import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env'
|
||||
import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env'
|
||||
import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir'
|
||||
import { removeInheritedNoColor } from '../../pty/terminal-color-env'
|
||||
import { resolvePathEnvKey } from '../../pty/windows-environment-path'
|
||||
import { dropInheritedOrcaHistFile } from '../../worktree-history-file-path'
|
||||
import {
|
||||
gitCredentialPromptGuardEnv,
|
||||
mergeGitConfigEnvProtocol
|
||||
} from '../../../shared/git-credential-prompt-env'
|
||||
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../../shared/terminal-git-credential-guard'
|
||||
import {
|
||||
expandWindowsEnvironmentVariables,
|
||||
expandWindowsPathEnvironmentVariables
|
||||
} from '../../../shared/windows-environment-expansion'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { PtySubprocessOptions } from '../pty-subprocess'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_TAB_ID',
|
||||
'ORCA_WORKTREE_ID',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN'
|
||||
] as const
|
||||
const WINDOWS_PATH_ENV_KEY_RE = /^path$/i
|
||||
|
||||
function composeGuardedDaemonGitConfigEnv(
|
||||
env: Record<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined,
|
||||
launchAgent: TuiAgent | undefined
|
||||
): void {
|
||||
const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
|
||||
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
|
||||
if (policy !== 'guard' && launchAgent === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard.
|
||||
Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform))
|
||||
}
|
||||
|
||||
function deleteRequestedDaemonEnvKeys(
|
||||
env: Record<string, string>,
|
||||
keys: readonly string[] | undefined
|
||||
): void {
|
||||
// Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it.
|
||||
const deleteOrcaOwnedCodexHome =
|
||||
keys?.includes('ORCA_CODEX_HOME') === true &&
|
||||
env.ORCA_CODEX_HOME !== undefined &&
|
||||
env.CODEX_HOME === env.ORCA_CODEX_HOME
|
||||
for (const key of keys ?? []) {
|
||||
delete env[key]
|
||||
}
|
||||
if (deleteOrcaOwnedCodexHome) {
|
||||
delete env.CODEX_HOME
|
||||
}
|
||||
}
|
||||
|
||||
function removeUnspecifiedPaneIdentityEnv(
|
||||
env: Record<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
for (const key of PANE_IDENTITY_ENV_KEYS) {
|
||||
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes the second PATH key only when the daemon's env merge created it. */
|
||||
function collapseWindowsPathEnvKeys(
|
||||
env: Record<string, string>,
|
||||
requestedEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key))
|
||||
if (pathKeys.length < 2) {
|
||||
return
|
||||
}
|
||||
// Why: a one-key main patch is authoritative; zero or two keys came from inherited state.
|
||||
const requestedKeys = requestedEnv
|
||||
? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key))
|
||||
: []
|
||||
if (requestedKeys.length !== 1) {
|
||||
return
|
||||
}
|
||||
const survivingKey = requestedKeys[0]
|
||||
if (!survivingKey || env[survivingKey] === undefined) {
|
||||
return
|
||||
}
|
||||
for (const key of pathKeys) {
|
||||
if (key !== survivingKey) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Promotes the agent-teams shim path ahead of inherited PATH entries. */
|
||||
function promoteAgentTeamsShimPath(
|
||||
env: Record<string, string>,
|
||||
requestedPath: string | undefined
|
||||
): void {
|
||||
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
|
||||
return
|
||||
}
|
||||
const normalizedRequestedPath =
|
||||
process.platform === 'win32'
|
||||
? expandWindowsEnvironmentVariables(requestedPath, env)
|
||||
: requestedPath
|
||||
const pathDelimiter = process.platform === 'win32' ? ';' : delimiter
|
||||
const shimDir = normalizedRequestedPath.split(pathDelimiter)[0]
|
||||
if (!shimDir) {
|
||||
return
|
||||
}
|
||||
const pathKey = resolvePathEnvKey(env, process.platform)
|
||||
const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? []
|
||||
env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter)
|
||||
}
|
||||
|
||||
function removeInheritedDevAgentHookEndpoint(
|
||||
env: Record<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) {
|
||||
// Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec.
|
||||
delete env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
}
|
||||
}
|
||||
|
||||
export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'Orca',
|
||||
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
|
||||
FORCE_HYPERLINK: '1'
|
||||
} as Record<string, string>
|
||||
stripLegacyTerminalShimEnv(env, process.platform)
|
||||
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
|
||||
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
|
||||
if (opts.env?.TERM) {
|
||||
env.TERM = opts.env.TERM
|
||||
}
|
||||
removeUnspecifiedPaneIdentityEnv(env, opts.env)
|
||||
if (opts.env?.fish_history === undefined) {
|
||||
dropInheritedOrcaFishHistory(env)
|
||||
}
|
||||
if (opts.env?.HISTFILE === undefined) {
|
||||
dropInheritedOrcaHistFile(env)
|
||||
}
|
||||
if (opts.env?.ORCA_HISTFILE === undefined) {
|
||||
delete env.ORCA_HISTFILE
|
||||
}
|
||||
removeInheritedDevAgentHookEndpoint(env, opts.env)
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
removeAppImageRuntimeEnv(env)
|
||||
removeInheritedNoColor(env)
|
||||
env.LANG ??= 'en_US.UTF-8'
|
||||
return env
|
||||
}
|
||||
|
||||
export function rescrubDaemonPtyEnvironment(
|
||||
env: Record<string, string>,
|
||||
opts: PtySubprocessOptions
|
||||
): void {
|
||||
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
|
||||
if (opts.env?.TERM) {
|
||||
env.TERM = opts.env.TERM
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeDaemonPtyEnvironment(
|
||||
env: Record<string, string>,
|
||||
requestedEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
expandWindowsPathEnvironmentVariables(env)
|
||||
collapseWindowsPathEnvKeys(env, requestedEnv)
|
||||
const requestedPath = requestedEnv
|
||||
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
|
||||
: undefined
|
||||
promoteAgentTeamsShimPath(env, requestedPath)
|
||||
stripLegacyTerminalShimEnv(env, process.platform)
|
||||
dropIncoherentCondaActivationEnv(env, process.platform)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import * as pty from 'node-pty'
|
||||
import { statSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import {
|
||||
ensureNodePtySpawnHelperExecutable,
|
||||
getNodePtySpawnHelperCandidates,
|
||||
validateWorkingDirectoryAsync,
|
||||
WorkingDirectoryValidationAbortedError
|
||||
} from '../../providers/local-pty-utils'
|
||||
import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd'
|
||||
import { TerminalAttachCanceledError } from '../daemon-errors'
|
||||
import { DaemonProtocolError } from '../types'
|
||||
|
||||
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
|
||||
|
||||
function daemonEnvironmentDiagSuffix(): string {
|
||||
const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev'
|
||||
const systemVersion =
|
||||
(process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ||
|
||||
release()
|
||||
return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})`
|
||||
}
|
||||
|
||||
function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError {
|
||||
const detailName = kind === 'helper' ? 'helper' : 'cwd'
|
||||
const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd'
|
||||
const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory'
|
||||
return new DaemonProtocolError(
|
||||
`Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}`
|
||||
)
|
||||
}
|
||||
|
||||
function isExistingDirectory(path: string | undefined): path is string {
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return statSync(path).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function repairDaemonCwd(): string | null {
|
||||
const candidates = [process.env.ORCA_USER_DATA_PATH]
|
||||
try {
|
||||
candidates.push(resolveSafePtyDefaultCwd())
|
||||
} catch {
|
||||
// Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
|
||||
}
|
||||
candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
|
||||
for (const candidate of candidates) {
|
||||
if (isExistingDirectory(candidate)) {
|
||||
try {
|
||||
process.chdir(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// Try the next stable cwd candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function preflightDaemonCwd(): void {
|
||||
let daemonCwd = '<unavailable>'
|
||||
try {
|
||||
daemonCwd = process.cwd()
|
||||
if (isExistingDirectory(daemonCwd)) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Recover below; process.cwd() throws after the original cwd is deleted.
|
||||
}
|
||||
if (repairDaemonCwd()) {
|
||||
return
|
||||
}
|
||||
throw formatMissingDaemonPathError('cwd', daemonCwd)
|
||||
}
|
||||
|
||||
function preflightMacNodePtySpawnEnvironment(): void {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
let candidates: string[]
|
||||
try {
|
||||
candidates = getNodePtySpawnHelperCandidates()
|
||||
} catch {
|
||||
throw formatMissingDaemonPathError('helper', '<unresolved>')
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (statSync(candidate).isFile()) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Try the next node-pty native location.
|
||||
}
|
||||
}
|
||||
throw formatMissingDaemonPathError('helper', candidates[0] ?? '<unresolved>')
|
||||
}
|
||||
|
||||
function preflightUnixPtySpawnEnvironment(): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
// Why: detached daemons can outlive their launch cwd; repair before every spawn.
|
||||
preflightDaemonCwd()
|
||||
preflightMacNodePtySpawnEnvironment()
|
||||
}
|
||||
|
||||
function isNativeWindowsPath(path: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
export async function preflightPtySpawn(args: {
|
||||
validationCwd: string
|
||||
cwdWasExplicit: boolean
|
||||
sessionId: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<void> {
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
preflightUnixPtySpawnEnvironment()
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) {
|
||||
await validateWorkingDirectoryAsync(
|
||||
args.validationCwd,
|
||||
args.signal ? { signal: args.signal } : {}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await validateWorkingDirectoryAsync(
|
||||
args.validationCwd,
|
||||
args.signal ? { signal: args.signal } : {}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorkingDirectoryValidationAbortedError) {
|
||||
throw new TerminalAttachCanceledError(args.sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const formatted = new DaemonProtocolError(
|
||||
`Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${daemonEnvironmentDiagSuffix()}`
|
||||
)
|
||||
if (err instanceof Error && err.stack) {
|
||||
formatted.stack = err.stack
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function runPtySpawnHealthProbe(): Promise<void> {
|
||||
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
|
||||
? process.env.ORCA_USER_DATA_PATH
|
||||
: resolveSafePtyDefaultCwd()
|
||||
let proc: pty.IPty
|
||||
try {
|
||||
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cwd,
|
||||
env: { ...process.env, TERM: 'xterm-256color' }
|
||||
})
|
||||
} catch (err) {
|
||||
throw formatPtySpawnError(err, '/bin/sh', cwd)
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
let exitDisposable: { dispose(): void } | undefined
|
||||
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
exitDisposable?.dispose()
|
||||
if (opts?.kill) {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// Best-effort cleanup for a short-lived health probe.
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
|
||||
kill: true
|
||||
})
|
||||
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
|
||||
exitDisposable = proc.onExit(({ exitCode }) => {
|
||||
if (exitCode === 0) {
|
||||
finish()
|
||||
} else {
|
||||
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function preflightPtySpawnHealth(): boolean {
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
}
|
||||
preflightUnixPtySpawnEnvironment()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type * as pty from 'node-pty'
|
||||
import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition'
|
||||
import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo'
|
||||
import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups'
|
||||
import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group'
|
||||
import { readPtsName } from '../../pty/node-pty-pts-name'
|
||||
import { terminatePtyJob } from '../../windows/windows-pty-job'
|
||||
import { isValidPtySize } from '../daemon-pty-size'
|
||||
import type { SubprocessHandle } from '../session-subprocess-handle'
|
||||
import { createPtyForegroundProcessTracker } from './foreground-process-tracker'
|
||||
import { PtyPreListenerEvents } from './pre-listener-events'
|
||||
|
||||
type DisposableNativePty = pty.IPty & { destroy?: () => void }
|
||||
|
||||
export function createDaemonPtySubprocessHandle(args: {
|
||||
process: pty.IPty
|
||||
shellPath: string
|
||||
spawnCwd: string
|
||||
env: Record<string, string>
|
||||
startupCommandDeliveredInShellArgs: boolean
|
||||
reportsChildExitStatus: boolean
|
||||
requestedCwd?: string
|
||||
sessionId: string
|
||||
startupAgentRecognition: RecognizedAgentProcess | null
|
||||
}): SubprocessHandle {
|
||||
const proc = args.process
|
||||
// node-pty exposes destroy at runtime but omits it from IPty.
|
||||
const nativeProc = proc as DisposableNativePty
|
||||
const events = new PtyPreListenerEvents()
|
||||
let dead = false
|
||||
let disposed = false
|
||||
let nodePtyKillIssued = false
|
||||
const foreground = createPtyForegroundProcessTracker({
|
||||
process: proc,
|
||||
shellPath: args.shellPath,
|
||||
cwd: args.requestedCwd,
|
||||
sessionId: args.sessionId,
|
||||
startupAgentRecognition: args.startupAgentRecognition,
|
||||
isDead: () => dead
|
||||
})
|
||||
|
||||
proc.onData((data) => {
|
||||
foreground.recordOutput(data)
|
||||
events.acceptData(data)
|
||||
})
|
||||
proc.onExit(({ exitCode, signal }) => {
|
||||
events.acceptExit({
|
||||
exitCode,
|
||||
signal,
|
||||
hostReportsChildExitStatus: args.reportsChildExitStatus
|
||||
})
|
||||
})
|
||||
proc.onExit(() => {
|
||||
dead = true
|
||||
foreground.markDead()
|
||||
// Why: neutralize kill synchronously so a later async socket-close SIGHUP cannot hit a recycled pid.
|
||||
if (process.platform !== 'win32') {
|
||||
nativeProc.kill = () => {}
|
||||
}
|
||||
})
|
||||
|
||||
const slavePath = readPtySlavePath(proc)
|
||||
return {
|
||||
pid: proc.pid,
|
||||
shellPath: args.shellPath,
|
||||
shellCwd: args.spawnCwd,
|
||||
shellPathEnv: args.env.PATH,
|
||||
...(slavePath ? { slavePath } : {}),
|
||||
...(args.startupCommandDeliveredInShellArgs
|
||||
? { startupCommandDeliveredInShellArgs: true }
|
||||
: {}),
|
||||
getForegroundProcess: foreground.getForegroundProcess,
|
||||
confirmForegroundProcess: foreground.confirmForegroundProcess,
|
||||
write: (data) => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.write(data)
|
||||
} catch {
|
||||
dead = true
|
||||
}
|
||||
},
|
||||
resize: (cols, rows) => {
|
||||
if (dead || !isValidPtySize(cols, rows)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.resize(cols, rows)
|
||||
} catch {
|
||||
dead = true
|
||||
}
|
||||
},
|
||||
// WindowsTerminal also wires _socket to the ConPTY conout pipe, so pausing backpressures the child.
|
||||
pause: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.pause()
|
||||
} catch {
|
||||
// Native handle already torn down; flow control is best-effort.
|
||||
}
|
||||
},
|
||||
resume: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.resume()
|
||||
} catch {
|
||||
// Native handle already torn down; flow control is best-effort.
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.clear()
|
||||
} catch {
|
||||
// A clear on a just-exited PTY is best-effort.
|
||||
}
|
||||
},
|
||||
kill: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
nodePtyKillIssued = true
|
||||
try {
|
||||
proc.kill()
|
||||
} catch (error) {
|
||||
// A rejected native kill is not proof of exit; keep the wrapper live for a retry.
|
||||
nodePtyKillIssued = false
|
||||
throw error
|
||||
}
|
||||
},
|
||||
terminateOwnedTree: () => terminatePtyJob(proc),
|
||||
forceKill: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
// Escalate a ConPTY kill through the job without double-closing node-pty's shell handle.
|
||||
if (process.platform === 'win32' && nodePtyKillIssued) {
|
||||
terminatePtyJob(proc)
|
||||
return
|
||||
}
|
||||
try {
|
||||
forceKillPosixPtyProcessGroups(proc.pid, () => {
|
||||
process.kill(proc.pid, 'SIGKILL')
|
||||
})
|
||||
} catch (signalError) {
|
||||
try {
|
||||
proc.kill()
|
||||
nodePtyKillIssued = true
|
||||
} catch {
|
||||
nodePtyKillIssued = false
|
||||
throw signalError
|
||||
}
|
||||
}
|
||||
},
|
||||
signal: (sig) => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
const signalRootPid = (): void => {
|
||||
try {
|
||||
process.kill(proc.pid, sig)
|
||||
} catch {
|
||||
// Process may already be dead.
|
||||
}
|
||||
}
|
||||
// SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target.
|
||||
if (sig === 'SIGWINCH') {
|
||||
signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid)
|
||||
return
|
||||
}
|
||||
signalRootPid()
|
||||
},
|
||||
onData: (cb) => events.onData(cb),
|
||||
onExit: (cb) => events.onExit(cb),
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
dead = true
|
||||
events.clear()
|
||||
// POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY.
|
||||
if (process.platform !== 'win32') {
|
||||
nativeProc.kill = () => {}
|
||||
} else if (nodePtyKillIssued) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
nativeProc.destroy?.()
|
||||
} catch {
|
||||
// Native handle was already torn down.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,20 +60,24 @@ import { githubProjectHost } from '../../shared/github/project-identity'
|
||||
// Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only.
|
||||
export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals'
|
||||
export { classifyProjectError } from './project-view/project-error-classification'
|
||||
export { updateIssueBySlug } from './project-view/mutations'
|
||||
export {
|
||||
updateProjectItemFieldValue,
|
||||
clearProjectItemFieldValue,
|
||||
updateIssueBySlug,
|
||||
updatePullRequestBySlug,
|
||||
clearProjectItemFieldValue
|
||||
} from './project-view/project-field-mutations'
|
||||
export { updatePullRequestBySlug } from './project-view/pull-request-mutation'
|
||||
export {
|
||||
addIssueCommentBySlug,
|
||||
updateIssueCommentBySlug,
|
||||
deleteIssueCommentBySlug,
|
||||
deleteIssueCommentBySlug
|
||||
} from './project-view/issue-comment-mutations'
|
||||
export {
|
||||
listLabelsBySlug,
|
||||
listAssignableUsersBySlug,
|
||||
listIssueTypesBySlug,
|
||||
updateIssueTypeBySlug,
|
||||
getWorkItemDetailsBySlug
|
||||
} from './project-view/mutations'
|
||||
listIssueTypesBySlug
|
||||
} from './project-view/repository-field-options'
|
||||
export { updateIssueTypeBySlug } from './project-view/issue-type-mutation'
|
||||
export { getWorkItemDetailsBySlug } from './project-view/work-item-details'
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals'
|
||||
import type { PRComment } from '../../../shared/github/comment-types'
|
||||
import type {
|
||||
GitHubProjectCommentMutationResult,
|
||||
GitHubProjectMutationResult
|
||||
} from '../../../shared/github/project-result-types'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
DeleteIssueCommentBySlugArgs,
|
||||
UpdateIssueCommentBySlugArgs
|
||||
} from '../../../shared/github/project-request-types'
|
||||
|
||||
type RawIssueCommentResponse = {
|
||||
id?: number
|
||||
user?: { login?: string; avatar_url?: string; type?: string } | null
|
||||
body?: string
|
||||
created_at?: string
|
||||
html_url?: string
|
||||
}
|
||||
|
||||
function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment {
|
||||
return {
|
||||
id: data.id ?? Date.now(),
|
||||
author: data.user?.login ?? 'You',
|
||||
authorAvatarUrl: data.user?.avatar_url ?? '',
|
||||
body: data.body ?? fallbackBody,
|
||||
createdAt: data.created_at ?? new Date().toISOString(),
|
||||
url: data.html_url ?? '',
|
||||
isBot: data.user?.type === 'Bot'
|
||||
}
|
||||
}
|
||||
|
||||
export async function addIssueCommentBySlug(
|
||||
args: AddIssueCommentBySlugArgs
|
||||
): Promise<GitHubProjectCommentMutationResult> {
|
||||
const slug = validateSlugArgs(args.owner, args.repo)
|
||||
if (!slug.ok) {
|
||||
return slug
|
||||
}
|
||||
const number = assertPositiveInt(args.number, 'number')
|
||||
if (!number.ok) {
|
||||
return { ok: false, error: number.error }
|
||||
}
|
||||
if (typeof args.body !== 'string' || !args.body.trim()) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
||||
}
|
||||
const result = await runRest<RawIssueCommentResponse>(
|
||||
[
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${args.owner}/${args.repo}/issues/${args.number}/comments`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
],
|
||||
undefined,
|
||||
'core',
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
return result.ok
|
||||
? { ok: true, comment: mapIssueComment(result.data, args.body) }
|
||||
: { ok: false, error: result.error }
|
||||
}
|
||||
|
||||
export async function updateIssueCommentBySlug(
|
||||
args: UpdateIssueCommentBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const slug = validateSlugArgs(args.owner, args.repo)
|
||||
if (!slug.ok) {
|
||||
return slug
|
||||
}
|
||||
const number = assertPositiveInt(args.commentId, 'commentId')
|
||||
if (!number.ok) {
|
||||
return { ok: false, error: number.error }
|
||||
}
|
||||
if (typeof args.body !== 'string' || !args.body.trim()) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
||||
}
|
||||
const result = await runRest<unknown>(
|
||||
[
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
],
|
||||
undefined,
|
||||
'core',
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error }
|
||||
}
|
||||
|
||||
export async function deleteIssueCommentBySlug(
|
||||
args: DeleteIssueCommentBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const slug = validateSlugArgs(args.owner, args.repo)
|
||||
if (!slug.ok) {
|
||||
return slug
|
||||
}
|
||||
const number = assertPositiveInt(args.commentId, 'commentId')
|
||||
if (!number.ok) {
|
||||
return { ok: false, error: number.error }
|
||||
}
|
||||
const result = await runRest<unknown>(
|
||||
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
|
||||
undefined,
|
||||
'core',
|
||||
{ expectEmpty: true, ...projectGhExecOptions(args.host) }
|
||||
)
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
assertPositiveInt,
|
||||
projectGhExecOptions,
|
||||
runGraphql,
|
||||
validateSlugArgs,
|
||||
type GraphqlVars
|
||||
} from './internals'
|
||||
import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types'
|
||||
import type { UpdateIssueTypeBySlugArgs } from '../../../shared/github/project-request-types'
|
||||
|
||||
export async function updateIssueTypeBySlug(
|
||||
args: UpdateIssueTypeBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const validation = validateSlugArgs(args.owner, args.repo)
|
||||
if (!validation.ok) {
|
||||
return validation
|
||||
}
|
||||
const number = assertPositiveInt(args.number, 'number')
|
||||
if (!number.ok) {
|
||||
return { ok: false, error: number.error }
|
||||
}
|
||||
const lookup = await runGraphql<{
|
||||
repository?: { issue?: { id?: string } | null } | null
|
||||
}>(
|
||||
`query($owner:String!, $repo:String!, $num:Int!) {
|
||||
repository(owner:$owner, name:$repo) { issue(number:$num) { id } }
|
||||
}`,
|
||||
{ owner: args.owner, repo: args.repo, num: args.number },
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
if (!lookup.ok) {
|
||||
return { ok: false, error: lookup.error }
|
||||
}
|
||||
const issueId = lookup.data.repository?.issue?.id
|
||||
if (!issueId) {
|
||||
return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } }
|
||||
}
|
||||
const query = args.issueTypeId
|
||||
? `mutation($issueId:ID!, $issueTypeId:ID!) {
|
||||
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) {
|
||||
issue { id }
|
||||
}
|
||||
}`
|
||||
: `mutation($issueId:ID!) {
|
||||
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) {
|
||||
issue { id }
|
||||
}
|
||||
}`
|
||||
const vars: GraphqlVars = args.issueTypeId
|
||||
? { issueId, issueTypeId: args.issueTypeId }
|
||||
: { issueId }
|
||||
const result = await runGraphql<unknown>(query, vars, projectGhExecOptions(args.host))
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ vi.mock('./internals', () => ({
|
||||
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
|
||||
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock,
|
||||
projectHostAuthenticationError: projectHostAuthenticationErrorMock,
|
||||
projectGhExecOptions: (host?: string) => (host ? { host } : {}),
|
||||
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
|
||||
classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }),
|
||||
rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }),
|
||||
runGraphql: runGraphqlMock,
|
||||
@@ -46,7 +46,8 @@ vi.mock('./internals', () => ({
|
||||
: { ok: false, error: { type: 'validation_error', message: `${name} invalid` } }
|
||||
}))
|
||||
|
||||
import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations'
|
||||
import { updateIssueBySlug } from './mutations'
|
||||
import { getWorkItemDetailsBySlug } from './work-item-details'
|
||||
|
||||
describe('updateIssueBySlug', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { runGraphqlMock } = vi.hoisted(() => ({ runGraphqlMock: vi.fn() }))
|
||||
|
||||
vi.mock('./internals', () => ({
|
||||
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
|
||||
runGraphql: runGraphqlMock
|
||||
}))
|
||||
|
||||
import { clearProjectItemFieldValue, updateProjectItemFieldValue } from './project-field-mutations'
|
||||
|
||||
describe('project field mutations', () => {
|
||||
beforeEach(() => {
|
||||
runGraphqlMock.mockReset().mockResolvedValue({ ok: true, data: {} })
|
||||
})
|
||||
|
||||
it('maps each field value kind to a host-pinned GraphQL variable', async () => {
|
||||
await updateProjectItemFieldValue({
|
||||
projectId: 'PVT_1',
|
||||
itemId: 'PVTI_1',
|
||||
fieldId: 'PVTF_1',
|
||||
value: { kind: 'number', number: 12.5 },
|
||||
host: 'github.corp.example'
|
||||
})
|
||||
|
||||
expect(runGraphqlMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('$value:Float!'),
|
||||
{ projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1', value: 12.5 },
|
||||
{ host: 'github.corp.example' }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed field kinds without dispatching GraphQL', async () => {
|
||||
const result = await updateProjectItemFieldValue({
|
||||
projectId: 'PVT_1',
|
||||
itemId: 'PVTI_1',
|
||||
fieldId: 'PVTF_1',
|
||||
value: { kind: 'future-kind' } as never
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { type: 'validation_error' } })
|
||||
expect(runGraphqlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps clear mutations host-scoped', async () => {
|
||||
await clearProjectItemFieldValue({
|
||||
projectId: 'PVT_1',
|
||||
itemId: 'PVTI_1',
|
||||
fieldId: 'PVTF_1',
|
||||
host: 'github.corp.example'
|
||||
})
|
||||
|
||||
expect(runGraphqlMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('clearProjectV2ItemFieldValue'),
|
||||
{ projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1' },
|
||||
{ host: 'github.corp.example' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals'
|
||||
import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types'
|
||||
import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types'
|
||||
import type {
|
||||
ClearProjectItemFieldArgs,
|
||||
UpdateProjectItemFieldArgs
|
||||
} from '../../../shared/github/project-request-types'
|
||||
|
||||
class UnknownFieldMutationKindError extends Error {
|
||||
constructor(kind: string) {
|
||||
super(`Unknown project field mutation kind: ${kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string {
|
||||
switch (value.kind) {
|
||||
case 'single-select':
|
||||
return 'singleSelectOptionId: $value'
|
||||
case 'iteration':
|
||||
return 'iterationId: $value'
|
||||
case 'text':
|
||||
return 'text: $value'
|
||||
case 'number':
|
||||
return 'number: $value'
|
||||
case 'date':
|
||||
return 'date: $value'
|
||||
}
|
||||
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
|
||||
}
|
||||
|
||||
function mutationValueVar(value: GitHubProjectFieldMutationValue): {
|
||||
type: string
|
||||
val: string | number
|
||||
} {
|
||||
switch (value.kind) {
|
||||
case 'single-select':
|
||||
return { type: 'String!', val: value.optionId }
|
||||
case 'iteration':
|
||||
return { type: 'String!', val: value.iterationId }
|
||||
case 'text':
|
||||
return { type: 'String!', val: value.text }
|
||||
case 'number':
|
||||
return { type: 'Float!', val: value.number }
|
||||
case 'date':
|
||||
return { type: 'Date!', val: value.date }
|
||||
}
|
||||
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
|
||||
}
|
||||
|
||||
export async function updateProjectItemFieldValue(
|
||||
args: UpdateProjectItemFieldArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
if (!args.projectId || !args.itemId || !args.fieldId) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
|
||||
}
|
||||
let valueFragment: string
|
||||
let valueVariable: { type: string; val: string | number }
|
||||
try {
|
||||
valueFragment = graphqlValueForFieldMutation(args.value)
|
||||
valueVariable = mutationValueVar(args.value)
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownFieldMutationKindError) {
|
||||
return { ok: false, error: { type: 'validation_error', message: error.message } }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const query = `
|
||||
mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valueVariable.type}) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { ${valueFragment} }
|
||||
}) { projectV2Item { id } }
|
||||
}
|
||||
`
|
||||
const vars: GraphqlVars = {
|
||||
projectId: args.projectId,
|
||||
itemId: args.itemId,
|
||||
fieldId: args.fieldId,
|
||||
value: valueVariable.val
|
||||
}
|
||||
const result = await runGraphql<unknown>(query, vars, projectGhExecOptions(args.host))
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error }
|
||||
}
|
||||
|
||||
export async function clearProjectItemFieldValue(
|
||||
args: ClearProjectItemFieldArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
if (!args.projectId || !args.itemId || !args.fieldId) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
|
||||
}
|
||||
const result = await runGraphql<unknown>(
|
||||
`mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) {
|
||||
clearProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId itemId: $itemId fieldId: $fieldId
|
||||
}) { projectV2Item { id } }
|
||||
}`,
|
||||
{ projectId: args.projectId, itemId: args.itemId, fieldId: args.fieldId },
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals'
|
||||
import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types'
|
||||
import type { UpdatePullRequestBySlugArgs } from '../../../shared/github/project-request-types'
|
||||
|
||||
export async function updatePullRequestBySlug(
|
||||
args: UpdatePullRequestBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const slug = validateSlugArgs(args.owner, args.repo)
|
||||
if (!slug.ok) {
|
||||
return slug
|
||||
}
|
||||
const number = assertPositiveInt(args.number, 'number')
|
||||
if (!number.ok) {
|
||||
return { ok: false, error: number.error }
|
||||
}
|
||||
if (!args.updates || typeof args.updates !== 'object') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
|
||||
}
|
||||
const patchArgs: string[] = [
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${args.owner}/${args.repo}/pulls/${args.number}`
|
||||
]
|
||||
let fieldCount = 0
|
||||
if (args.updates.title !== undefined) {
|
||||
patchArgs.push('--raw-field', `title=${args.updates.title}`)
|
||||
fieldCount++
|
||||
}
|
||||
if (args.updates.body !== undefined) {
|
||||
patchArgs.push('--raw-field', `body=${args.updates.body}`)
|
||||
fieldCount++
|
||||
}
|
||||
if (args.updates.state !== undefined) {
|
||||
patchArgs.push('--raw-field', `state=${args.updates.state}`)
|
||||
fieldCount++
|
||||
}
|
||||
if (fieldCount === 0) {
|
||||
return { ok: true }
|
||||
}
|
||||
const result = await runRest<unknown>(
|
||||
patchArgs,
|
||||
undefined,
|
||||
'core',
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { acquireMock, ghExecFileAsyncMock, noteRepositoryRateLimitSpendMock, releaseMock } =
|
||||
vi.hoisted(() => ({
|
||||
acquireMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
noteRepositoryRateLimitSpendMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./internals', () => ({
|
||||
acquire: acquireMock,
|
||||
release: releaseMock,
|
||||
extractExecError: vi.fn(),
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock,
|
||||
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
|
||||
projectHostAuthenticationError: vi.fn().mockResolvedValue(null),
|
||||
repositoryRateLimitGuard: vi.fn().mockReturnValue({ blocked: false }),
|
||||
runGraphql: vi.fn(),
|
||||
validateSlugArgs: () => ({ ok: true })
|
||||
}))
|
||||
|
||||
vi.mock('./project-error-classification', () => ({
|
||||
classifyProjectError: vi.fn(),
|
||||
rateLimitedError: vi.fn()
|
||||
}))
|
||||
|
||||
import { listAssignableUsersBySlug, listLabelsBySlug } from './repository-field-options'
|
||||
|
||||
describe('repository field options', () => {
|
||||
beforeEach(() => {
|
||||
acquireMock.mockReset().mockResolvedValue(undefined)
|
||||
releaseMock.mockReset()
|
||||
noteRepositoryRateLimitSpendMock.mockReset()
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('keeps label discovery paginated and host-scoped', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: 'bug\nenhancement\n', stderr: '' })
|
||||
|
||||
await expect(
|
||||
listLabelsBySlug({ owner: 'acme', repo: 'widgets', host: 'github.corp.example' })
|
||||
).resolves.toEqual({ ok: true, labels: ['bug', 'enhancement'] })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', '--paginate', 'repos/acme/widgets/labels', '--jq', '.[].name'],
|
||||
{ encoding: 'utf-8', host: 'github.corp.example' }
|
||||
)
|
||||
expect(releaseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('merges seed assignees after paginated provider results', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: '{"login":"octo","name":null,"avatarUrl":"octo.png"}\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
const result = await listAssignableUsersBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
seedLogins: ['octo', 'hubot']
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
users: [
|
||||
{ login: 'octo', name: null, avatarUrl: 'octo.png' },
|
||||
{ login: 'hubot', name: null, avatarUrl: '' }
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
acquire,
|
||||
extractExecError,
|
||||
ghExecFileAsync,
|
||||
noteRepositoryRateLimitSpend,
|
||||
projectGhExecOptions,
|
||||
projectHostAuthenticationError,
|
||||
release,
|
||||
repositoryRateLimitGuard,
|
||||
runGraphql,
|
||||
validateSlugArgs
|
||||
} from './internals'
|
||||
import { classifyProjectError, rateLimitedError } from './project-error-classification'
|
||||
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
|
||||
import type {
|
||||
ListAssignableUsersBySlugResult,
|
||||
ListIssueTypesBySlugResult,
|
||||
ListLabelsBySlugResult
|
||||
} from '../../../shared/github/project-result-types'
|
||||
import type {
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListIssueTypesBySlugArgs,
|
||||
ListLabelsBySlugArgs
|
||||
} from '../../../shared/github/project-request-types'
|
||||
|
||||
export async function listLabelsBySlug(
|
||||
args: ListLabelsBySlugArgs
|
||||
): Promise<ListLabelsBySlugResult> {
|
||||
const validation = validateSlugArgs(args.owner, args.repo)
|
||||
if (!validation.ok) {
|
||||
return validation
|
||||
}
|
||||
const authError = await projectHostAuthenticationError(args.host)
|
||||
if (authError) {
|
||||
return { ok: false, error: authError }
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(args, 'core')
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
await acquire()
|
||||
noteRepositoryRateLimitSpend(args, 'core')
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'],
|
||||
{ encoding: 'utf-8', ...projectGhExecOptions(args.host) }
|
||||
)
|
||||
return { ok: true, labels: stdout.trim().split('\n').filter(Boolean) }
|
||||
} catch (error) {
|
||||
const { stderr, stdout } = extractExecError(error)
|
||||
return { ok: false, error: classifyProjectError(stderr, stdout, args.host) }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAssignableUsersBySlug(
|
||||
args: ListAssignableUsersBySlugArgs
|
||||
): Promise<ListAssignableUsersBySlugResult> {
|
||||
const validation = validateSlugArgs(args.owner, args.repo)
|
||||
if (!validation.ok) {
|
||||
return validation
|
||||
}
|
||||
const authError = await projectHostAuthenticationError(args.host)
|
||||
if (authError) {
|
||||
return { ok: false, error: authError }
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(args, 'core')
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
const users: GitHubAssignableUser[] = []
|
||||
await acquire()
|
||||
noteRepositoryRateLimitSpend(args, 'core')
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--paginate',
|
||||
`repos/${args.owner}/${args.repo}/assignees`,
|
||||
'--jq',
|
||||
'.[] | {login: .login, name: null, avatarUrl: .avatar_url}'
|
||||
],
|
||||
{ encoding: 'utf-8', ...projectGhExecOptions(args.host) }
|
||||
)
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
try {
|
||||
const user = JSON.parse(line) as {
|
||||
login?: string
|
||||
avatarUrl?: string
|
||||
name?: string | null
|
||||
}
|
||||
if (typeof user.login === 'string') {
|
||||
users.push({
|
||||
login: user.login,
|
||||
name: user.name ?? null,
|
||||
avatarUrl: user.avatarUrl ?? ''
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed jq output.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const { stderr } = extractExecError(error)
|
||||
return { ok: false, error: classifyProjectError(stderr, '', args.host) }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
const seen = new Set(users.map((user) => user.login))
|
||||
for (const login of args.seedLogins ?? []) {
|
||||
if (typeof login === 'string' && !seen.has(login)) {
|
||||
users.push({ login, name: null, avatarUrl: '' })
|
||||
seen.add(login)
|
||||
}
|
||||
}
|
||||
return { ok: true, users }
|
||||
}
|
||||
|
||||
export async function listIssueTypesBySlug(
|
||||
args: ListIssueTypesBySlugArgs
|
||||
): Promise<ListIssueTypesBySlugResult> {
|
||||
const validation = validateSlugArgs(args.owner, args.repo)
|
||||
if (!validation.ok) {
|
||||
return validation
|
||||
}
|
||||
const result = await runGraphql<{
|
||||
repository?: {
|
||||
issueTypes?: {
|
||||
nodes?: ({
|
||||
id?: string
|
||||
name?: string
|
||||
color?: string | null
|
||||
description?: string | null
|
||||
} | null)[]
|
||||
} | null
|
||||
} | null
|
||||
}>(
|
||||
`query($owner:String!, $repo:String!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
issueTypes(first:50) { nodes { id name color description } }
|
||||
}
|
||||
}`,
|
||||
{ owner: args.owner, repo: args.repo },
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
if (!result.ok) {
|
||||
if (result.error.type === 'schema_drift' || result.error.type === 'validation_error') {
|
||||
return { ok: true, types: [] }
|
||||
}
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
const types = (result.data.repository?.issueTypes?.nodes ?? [])
|
||||
.filter(
|
||||
(node): node is NonNullable<typeof node> =>
|
||||
node !== null && typeof node.id === 'string' && typeof node.name === 'string'
|
||||
)
|
||||
.map((node) => ({
|
||||
id: node.id as string,
|
||||
name: node.name as string,
|
||||
color: typeof node.color === 'string' ? node.color : null,
|
||||
description: typeof node.description === 'string' ? node.description : null
|
||||
}))
|
||||
return { ok: true, types }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { assertPositiveInt, projectGhExecOptions, runGraphql, validateSlugArgs } from './internals'
|
||||
import type { PRComment } from '../../../shared/github/comment-types'
|
||||
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types'
|
||||
import type { ProjectWorkItemDetailsBySlugResult } from '../../../shared/github/project-result-types'
|
||||
import type { ProjectWorkItemDetailsBySlugArgs } from '../../../shared/github/project-request-types'
|
||||
|
||||
type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null }
|
||||
type RawLabel = { name?: string; color?: string }
|
||||
type RawComment = {
|
||||
databaseId?: number
|
||||
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
||||
body?: string
|
||||
createdAt?: string
|
||||
url?: string
|
||||
}
|
||||
type RawWorkItemContent = {
|
||||
id?: string
|
||||
number?: number
|
||||
title?: string
|
||||
url?: string
|
||||
state?: string
|
||||
stateReason?: string | null
|
||||
isDraft?: boolean
|
||||
updatedAt?: string
|
||||
body?: string
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
author?: { login?: string } | null
|
||||
labels?: { nodes?: RawLabel[] }
|
||||
assignees?: { nodes?: RawUser[] }
|
||||
participants?: { nodes?: RawUser[] }
|
||||
comments?: { nodes?: (RawComment | null)[] }
|
||||
}
|
||||
|
||||
export async function getWorkItemDetailsBySlug(
|
||||
args: ProjectWorkItemDetailsBySlugArgs
|
||||
): Promise<ProjectWorkItemDetailsBySlugResult> {
|
||||
const validation = validateSlugArgs(args.owner, args.repo)
|
||||
if (!validation.ok) {
|
||||
return validation
|
||||
}
|
||||
const number = assertPositiveInt(args.number, 'number')
|
||||
if (!number.ok) {
|
||||
return { ok: false, error: number.error }
|
||||
}
|
||||
if (args.type !== 'issue' && args.type !== 'pr') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } }
|
||||
}
|
||||
const contentFragment = args.type === 'issue' ? ISSUE_FRAGMENT : PULL_REQUEST_FRAGMENT
|
||||
const result = await runGraphql<{
|
||||
repository?: {
|
||||
issue?: RawWorkItemContent | null
|
||||
pullRequest?: RawWorkItemContent | null
|
||||
} | null
|
||||
}>(
|
||||
`query($owner:String!, $repo:String!, $num:Int!) {
|
||||
repository(owner:$owner, name:$repo) { ${contentFragment} }
|
||||
}`,
|
||||
{ owner: args.owner, repo: args.repo, num: args.number },
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
const raw =
|
||||
args.type === 'issue' ? result.data.repository?.issue : result.data.repository?.pullRequest
|
||||
if (!raw) {
|
||||
return { ok: false, error: { type: 'not_found', message: 'Item not found.' } }
|
||||
}
|
||||
return { ok: true, details: mapWorkItemDetails(raw, args) }
|
||||
}
|
||||
|
||||
const ISSUE_FRAGMENT = `issue(number:$num) {
|
||||
id number title url state stateReason updatedAt body author { login }
|
||||
labels(first:50) { nodes { name } }
|
||||
assignees(first:50) { nodes { login } }
|
||||
participants(first:50) { nodes { login name avatarUrl } }
|
||||
comments(first:100) {
|
||||
nodes { databaseId author { login avatarUrl __typename } body createdAt url }
|
||||
}
|
||||
}`
|
||||
|
||||
const PULL_REQUEST_FRAGMENT = `pullRequest(number:$num) {
|
||||
id number title url state isDraft updatedAt headRefName baseRefName body author { login }
|
||||
labels(first:50) { nodes { name } }
|
||||
assignees(first:50) { nodes { login } }
|
||||
participants(first:50) { nodes { login name avatarUrl } }
|
||||
comments(first:100) {
|
||||
nodes { databaseId author { login avatarUrl __typename } body createdAt url }
|
||||
}
|
||||
}`
|
||||
|
||||
function mapWorkItemDetails(
|
||||
raw: RawWorkItemContent,
|
||||
args: ProjectWorkItemDetailsBySlugArgs
|
||||
): GitHubWorkItemDetails {
|
||||
const labels = (raw.labels?.nodes ?? [])
|
||||
.map((label) => label?.name)
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
const assignees = (raw.assignees?.nodes ?? [])
|
||||
.map((assignee) => assignee?.login)
|
||||
.filter((login): login is string => typeof login === 'string')
|
||||
const comments: PRComment[] = []
|
||||
for (const comment of raw.comments?.nodes ?? []) {
|
||||
if (!comment || typeof comment.body !== 'string') {
|
||||
continue
|
||||
}
|
||||
comments.push({
|
||||
id: typeof comment.databaseId === 'number' ? comment.databaseId : Date.now(),
|
||||
author: comment.author?.login ?? '',
|
||||
authorAvatarUrl: comment.author?.avatarUrl ?? '',
|
||||
body: comment.body,
|
||||
createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : '',
|
||||
url: typeof comment.url === 'string' ? comment.url : '',
|
||||
isBot: comment.author?.__typename === 'Bot'
|
||||
})
|
||||
}
|
||||
const participants: GitHubAssignableUser[] = []
|
||||
for (const participant of raw.participants?.nodes ?? []) {
|
||||
if (participant && typeof participant.login === 'string') {
|
||||
participants.push({
|
||||
login: participant.login,
|
||||
name: participant.name ?? null,
|
||||
avatarUrl: participant.avatarUrl ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
const state: 'open' | 'closed' | 'merged' | 'draft' =
|
||||
args.type === 'pr'
|
||||
? raw.isDraft
|
||||
? 'draft'
|
||||
: raw.state === 'MERGED'
|
||||
? 'merged'
|
||||
: raw.state === 'CLOSED'
|
||||
? 'closed'
|
||||
: 'open'
|
||||
: raw.state === 'CLOSED'
|
||||
? 'closed'
|
||||
: 'open'
|
||||
return {
|
||||
item: {
|
||||
id: typeof raw.id === 'string' ? raw.id : '',
|
||||
type: args.type,
|
||||
number: typeof raw.number === 'number' ? raw.number : args.number,
|
||||
title: typeof raw.title === 'string' ? raw.title : '',
|
||||
state,
|
||||
url: typeof raw.url === 'string' ? raw.url : '',
|
||||
labels,
|
||||
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '',
|
||||
author: typeof raw.author?.login === 'string' ? raw.author.login : null,
|
||||
branchName:
|
||||
args.type === 'pr' && typeof raw.headRefName === 'string' ? raw.headRefName : undefined,
|
||||
baseRefName:
|
||||
args.type === 'pr' && typeof raw.baseRefName === 'string' ? raw.baseRefName : undefined
|
||||
},
|
||||
body: typeof raw.body === 'string' ? raw.body : '',
|
||||
comments,
|
||||
participants,
|
||||
assignees
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ describe('registerCrashReportingHandlers', () => {
|
||||
_resetRendererErrorReportDedupeForTests()
|
||||
})
|
||||
|
||||
it('copies the latest pending diagnostic text to the clipboard', async () => {
|
||||
it('copies the requested captured report to the clipboard', async () => {
|
||||
const latest = report()
|
||||
registerCrashReportingHandlers({
|
||||
getById: vi.fn(async () => latest),
|
||||
@@ -151,6 +151,7 @@ describe('registerCrashReportingHandlers', () => {
|
||||
} as never)
|
||||
|
||||
const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
|
||||
reportId: latest.id,
|
||||
notes: 'extra /Users/alice/project'
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks'
|
||||
import { isENOENT } from '../filesystem-path-containment'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
|
||||
export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void {
|
||||
const { store } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:check',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo) {
|
||||
const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId)
|
||||
// Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free.
|
||||
return {
|
||||
status: args.hostId || repoIdExists ? 'error' : 'ok',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
|
||||
if (repo.connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: !result.isBinary,
|
||||
hooks: result.isBinary ? null : parseOrcaYaml(result.content),
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: isENOENT(error) ? 'ok' : 'error',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = has ? loadHooks(repo.path) : null
|
||||
// Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed".
|
||||
const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path)
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: has,
|
||||
hooks,
|
||||
mayNeedUpdate
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import { isENOENT } from '../filesystem-path-containment'
|
||||
import { parseOrcaYaml } from '../../hooks'
|
||||
import { readIssueCommand, writeIssueCommand } from '../../issue-command-file'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
|
||||
export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void {
|
||||
const { store } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:readIssueCommand',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return {
|
||||
status: 'ok',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: '',
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return {
|
||||
status: 'error',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: issueCommandPath,
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
|
||||
let status: 'ok' | 'error' = 'ok'
|
||||
let localContent: string | null = null
|
||||
let sharedContent: string | null = null
|
||||
try {
|
||||
const result = await fsProvider.readFile(issueCommandPath)
|
||||
localContent = result.isBinary ? null : result.content.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
sharedContent = result.isBinary
|
||||
? null
|
||||
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
const effectiveContent = localContent ?? sharedContent
|
||||
return {
|
||||
status: localContent ? 'ok' : status,
|
||||
localContent,
|
||||
sharedContent,
|
||||
effectiveContent,
|
||||
localFilePath: issueCommandPath,
|
||||
source: localContent
|
||||
? ('local' as const)
|
||||
: sharedContent
|
||||
? ('shared' as const)
|
||||
: ('none' as const)
|
||||
}
|
||||
}
|
||||
return readIssueCommand(repo.path)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:writeIssueCommand',
|
||||
async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
throw new Error(
|
||||
'Remote filesystem unavailable. Reconnect the SSH target before retrying.'
|
||||
)
|
||||
}
|
||||
const trimmed = args.content.trim()
|
||||
if (!trimmed) {
|
||||
await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca'))
|
||||
const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore')
|
||||
try {
|
||||
const result = await fsProvider.readFile(gitignorePath)
|
||||
if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) {
|
||||
const separator = result.content.endsWith('\n') ? '' : '\n'
|
||||
await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
await fsProvider.writeFile(gitignorePath, '.orca\n')
|
||||
}
|
||||
await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`)
|
||||
return
|
||||
}
|
||||
writeIssueCommand(repo.path, args.content)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { isENOENT } from '../filesystem-path-containment'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
|
||||
export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContext): void {
|
||||
const { store } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:inspectSetupScriptImports',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return inspectSetupScriptImportCandidates(
|
||||
async (relativePath) => {
|
||||
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
|
||||
if (repo.connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(filePath)
|
||||
return result.isBinary ? null : result.content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
console.warn('[hooks] Failed to inspect setup script import candidate:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
fileExists: async (relativePath) => {
|
||||
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
|
||||
if (repo.connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const fileStat = await fsProvider.stat(filePath)
|
||||
return fileStat.type !== 'directory'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
return !fileStat.isDirectory()
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
console.warn('[hooks] Failed to stat setup script import candidate:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
createIssueCommandRunnerScript,
|
||||
resolveSetupRunnerShell
|
||||
} from '../../worktree-runner-script'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
|
||||
export function registerWorktreeHookRunnerHandler(context: WorktreeIpcContext): void {
|
||||
const { store } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:createIssueCommandRunner',
|
||||
(_event, args: { repoId: string; worktreePath: string; command: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
|
||||
return createIssueCommandRunnerScript(
|
||||
repo,
|
||||
args.worktreePath,
|
||||
args.command,
|
||||
getLocalProjectWorktreeGitOptions(store, repo),
|
||||
resolveSetupRunnerShell(store.getSettings())
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref'
|
||||
import {
|
||||
handleMock,
|
||||
removeHandlerMock,
|
||||
listWorktreesMock,
|
||||
getPullRequestPushTargetMock,
|
||||
@@ -14,6 +15,35 @@ import {
|
||||
} from './worktrees-test-fixtures'
|
||||
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
|
||||
|
||||
const WORKTREE_HANDLER_CHANNELS = [
|
||||
'worktrees:listAll',
|
||||
'worktrees:list',
|
||||
'worktrees:listRetiredNames',
|
||||
'worktrees:listDetected',
|
||||
'worktrees:listKnownForExecutionHost',
|
||||
'worktrees:forgetRemovedForExecutionHost',
|
||||
'worktrees:cancelListDetected',
|
||||
'worktrees:create',
|
||||
'worktrees:adoptProvisionedRoot',
|
||||
'worktrees:prefetchCreateBase',
|
||||
'worktrees:resolvePrBase',
|
||||
'worktrees:resolveMrBase',
|
||||
'worktrees:remove',
|
||||
'worktrees:forgetLocal',
|
||||
'worktrees:forceDeletePreservedBranch',
|
||||
'worktrees:updateMeta',
|
||||
'worktrees:listLineage',
|
||||
'worktrees:listLineageForHost',
|
||||
'worktrees:updateLineage',
|
||||
'worktrees:persistSortOrder',
|
||||
'worktrees:getBranchRenameFailureOutput',
|
||||
'hooks:check',
|
||||
'hooks:inspectSetupScriptImports',
|
||||
'hooks:createIssueCommandRunner',
|
||||
'hooks:readIssueCommand',
|
||||
'hooks:writeIssueCommand'
|
||||
] as const
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
)
|
||||
@@ -113,6 +143,20 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined()
|
||||
})
|
||||
|
||||
it('removes exactly the installed channel set before installing the first handler', () => {
|
||||
const removedChannels = removeHandlerMock.mock.calls.map(([channel]) => channel)
|
||||
const installedChannels = handleMock.mock.calls.map(([channel]) => channel)
|
||||
|
||||
expect(new Set(removedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS))
|
||||
expect(new Set(removedChannels)).toEqual(new Set(installedChannels))
|
||||
expect(new Set(installedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS))
|
||||
expect(removedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length)
|
||||
expect(installedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length)
|
||||
expect(Math.max(...removeHandlerMock.mock.invocationCallOrder)).toBeLessThan(
|
||||
Math.min(...handleMock.mock.invocationCallOrder)
|
||||
)
|
||||
})
|
||||
|
||||
it('persistSortOrder only reorders existing worktrees and never mints meta for a stale id', () => {
|
||||
const liveId = 'repo-1::/workspace/repo'
|
||||
const staleId = 'removed-repo::/workspace/gone'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolve } from 'node:path'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import { notifyWorktreesChanged } from './worktree-remote'
|
||||
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
|
||||
import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees'
|
||||
import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees/listing/detected-worktree-scan-cache'
|
||||
import { setPlatform, listWorktreesMock } from './worktrees-test-module-mocks'
|
||||
import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { makeWorktreeMeta } from './worktrees-test-fixtures'
|
||||
|
||||
@@ -156,6 +156,22 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('purges metadata after a rejected best-effort PTY sweep', async () => {
|
||||
const worktreeId = 'repo-1::/workspace/feature-wt'
|
||||
killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable'))
|
||||
|
||||
await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({})
|
||||
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.webContents.send.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sweeps a connected SSH owner through its PTY provider', async () => {
|
||||
const worktreeId = 'repo-gone::/workspace/feature-wt'
|
||||
const sshProvider = {} as never
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees'
|
||||
import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing'
|
||||
import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority'
|
||||
import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks'
|
||||
import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
|
||||
@@ -137,6 +137,35 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => {
|
||||
const worktreeId = 'repo-1::/workspace/feature-wt'
|
||||
const localRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = {
|
||||
...localRepo,
|
||||
displayName: 'ssh',
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' }))
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' })
|
||||
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
|
||||
expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
@@ -484,6 +513,29 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('purges a folder workspace after a rejected best-effort PTY sweep', async () => {
|
||||
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable'))
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId })
|
||||
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-folder'
|
||||
})
|
||||
expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.webContents.send.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
// Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:<uuid>`
|
||||
// — reusable across hosts — so the sweep must name the owning connection.
|
||||
it('fences an SSH folder workspace PTY sweep to the owning connection', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees'
|
||||
import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers'
|
||||
import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority'
|
||||
import { getSshGitProviderMock } from './worktrees-test-module-mocks'
|
||||
import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote'
|
||||
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
||||
import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees'
|
||||
import { registerWorktreeHandlers } from './worktrees'
|
||||
import { __resetDetectedWorktreeScanCacheForTests } from './worktrees/listing/detected-worktree-scan-cache'
|
||||
import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories'
|
||||
import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement'
|
||||
import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
|
||||
|
||||
+73
-3663
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection'
|
||||
import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
|
||||
import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model'
|
||||
|
||||
export function createFolderWorkspace(
|
||||
args: CreateWorktreeArgsWithSystemProvenance,
|
||||
repo: Repo,
|
||||
store: Store
|
||||
): CreateWorktreeResult {
|
||||
const now = Date.now()
|
||||
const instanceId = randomUUID()
|
||||
const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const meta = store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
...(store.getProjectHostSetups
|
||||
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||||
: {}),
|
||||
displayName: args.displayName || args.name,
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
orcaCreatedAt: now,
|
||||
orcaCreationSource: 'desktop',
|
||||
creatorProvenance: { kind: 'host' },
|
||||
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
|
||||
...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}),
|
||||
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
|
||||
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
|
||||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||||
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
|
||||
...(args.linkedLinearIssueWorkspaceId !== undefined
|
||||
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
|
||||
: {}),
|
||||
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
|
||||
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
|
||||
: {}),
|
||||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}),
|
||||
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}),
|
||||
...(args.linkedAzureDevOpsPR !== undefined
|
||||
? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR }
|
||||
: {}),
|
||||
...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}),
|
||||
...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}),
|
||||
...(args.linkedTaskSourceContext !== undefined
|
||||
? { linkedTaskSourceContext: args.linkedTaskSourceContext }
|
||||
: {})
|
||||
})
|
||||
return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { gitExecFileAsync } from '../../../git/runner'
|
||||
import {
|
||||
getLocalProjectGitExecOptions,
|
||||
getLocalProjectWorktreeGitOptions
|
||||
} from '../../../project-runtime-git-options'
|
||||
import { getSshGitProvider } from '../../../providers/ssh-git-dispatch'
|
||||
import {
|
||||
fetchPrHeadTrackingRef,
|
||||
fetchGitHubPullRequestHeadRef
|
||||
} from '../../../github/pr-head-tracking-ref'
|
||||
import { resolveGitHubPrStartPoint } from '../../../github/pr-start-point'
|
||||
import { resolveGitHubReviewHeadRemote } from '../../../github/review-head-remote'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
|
||||
export function registerReviewBaseHandlers(context: WorktreeIpcContext): void {
|
||||
const { store, runtime } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:resolvePrBase',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
): Promise<GitHubPrStartPoint | { error: string }> => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
return { error: 'Repo not found' }
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
return { error: 'Folder mode does not support creating worktrees.' }
|
||||
}
|
||||
const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (!repo.connectionId) {
|
||||
return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo))
|
||||
}
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(
|
||||
'SSH Git provider is not available. Reconnect to this target and try again.'
|
||||
)
|
||||
}
|
||||
return provider.exec(args, repo.path)
|
||||
}
|
||||
// Why: SSH review-head fetches require narrow write-capable RPCs.
|
||||
const fetchRemoteTrackingRef = (remote: string, branch: string): Promise<void> =>
|
||||
fetchPrHeadTrackingRef(
|
||||
repo,
|
||||
repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined,
|
||||
remote,
|
||||
branch,
|
||||
{ localGitExecOptions: getLocalProjectGitExecOptions(store, repo) }
|
||||
)
|
||||
const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise<string> =>
|
||||
fetchGitHubPullRequestHeadRef(
|
||||
repo,
|
||||
repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined,
|
||||
remote,
|
||||
prNumber,
|
||||
{ localGitExecOptions: getLocalProjectGitExecOptions(store, repo) }
|
||||
)
|
||||
|
||||
return resolveGitHubPrStartPoint({
|
||||
repoPath: repo.path,
|
||||
prNumber: args.prNumber,
|
||||
headRefName: args.headRefName,
|
||||
baseRefName: args.baseRefName,
|
||||
isCrossRepository: args.isCrossRepository,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
localGitOptions: getLocalProjectWorktreeGitOptions(store, repo),
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
fetchPullRequestHeadRef,
|
||||
// Why: one resolver keeps source preference and hosting identity aligned
|
||||
// across local, WSL, and SSH worktree creation.
|
||||
resolveRemote: () =>
|
||||
resolveGitHubReviewHeadRemote({
|
||||
repoPath: repo.path,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
localGitOptions: getLocalProjectWorktreeGitOptions(store, repo),
|
||||
gitExec
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:resolveMrBase',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoId: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
targetBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
): Promise<
|
||||
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
|
||||
| { error: string }
|
||||
> => {
|
||||
return runtime.resolveManagedMrBase({
|
||||
repoSelector: `id:${args.repoId}`,
|
||||
mrIid: args.mrIid,
|
||||
sourceBranch: args.sourceBranch,
|
||||
targetBranch: args.targetBranch,
|
||||
isCrossRepository: args.isCrossRepository
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ipcMain, app } from 'electron'
|
||||
import type {
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
AdoptProvisionedRootArgs
|
||||
} from '../../../../shared/worktree/create-types'
|
||||
import { withWorktreeSpan } from '../../../observability/instrumentation'
|
||||
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
|
||||
import type { WorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import {
|
||||
resolveAutomationWorkspaceProvenance,
|
||||
releaseAutomationWorkspaceProvenanceRequest,
|
||||
finishAutomationWorkspaceProvenanceRequest
|
||||
} from '../../../automations/workspace-provenance'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
createRemoteWorktree,
|
||||
createLocalWorktree,
|
||||
notifyWorktreesChanged
|
||||
} from '../../worktree-remote'
|
||||
import { track } from '../../../telemetry/client'
|
||||
import { classifyWorkspaceCreateError } from '../../workspace-create-error-classifier'
|
||||
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
|
||||
import { adoptProvisionedRootSshCheckout } from '../../../provisioned-root-ssh-adoption'
|
||||
import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas'
|
||||
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
|
||||
import { createFolderWorkspace } from './folder-workspace-creation'
|
||||
import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
|
||||
export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void {
|
||||
const { mainWindow, store, runtime, options } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:create',
|
||||
async (_event, rawArgs: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
|
||||
const args = normalizeLinkedWorkItemFields(rawArgs)
|
||||
// Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator.
|
||||
return withWorktreeSpan({ stage: 'create' }, async () => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
|
||||
const automationProvenance = resolveAutomationWorkspaceProvenance({
|
||||
authority: runtime,
|
||||
repoSelector: args.repoId,
|
||||
repo,
|
||||
request: args.automationProvenanceRequest
|
||||
})
|
||||
const createArgs: CreateWorktreeArgsWithSystemProvenance = {
|
||||
...args,
|
||||
automationProvenance
|
||||
}
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
// Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks.
|
||||
result = isFolderRepo(repo)
|
||||
? createFolderWorkspace(createArgs, repo, store)
|
||||
: repo.connectionId
|
||||
? await createRemoteWorktree(createArgs, repo, store, mainWindow)
|
||||
: await createLocalWorktree(createArgs, repo, store, mainWindow, runtime)
|
||||
} catch (error) {
|
||||
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
track('workspace_create_failed', {
|
||||
source,
|
||||
error_class: classifyWorkspaceCreateError(error),
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
|
||||
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
|
||||
track('workspace_created', {
|
||||
source,
|
||||
from_existing_branch:
|
||||
!isFolderRepo(repo) &&
|
||||
typeof args.baseBranch === 'string' &&
|
||||
args.baseBranch.length > 0,
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
|
||||
if (isFolderRepo(repo)) {
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
}
|
||||
|
||||
options?.onWorktreeLifecycle?.({
|
||||
kind: 'created',
|
||||
worktreeId: result.worktree.id,
|
||||
path: result.worktree.path,
|
||||
branch: result.worktree.branch
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:adoptProvisionedRoot',
|
||||
async (_event, rawArgs: AdoptProvisionedRootArgs): Promise<CreateWorktreeResult> => {
|
||||
const args = normalizeLinkedWorkItemFields(rawArgs)
|
||||
return withWorktreeSpan({ stage: 'create' }, async () => {
|
||||
const repo = findExactRepoOwner(store, args.repoId, args.executionHostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
throw new Error('Provisioned-root repository ownership is missing or ambiguous.')
|
||||
}
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
const automationProvenance = resolveAutomationWorkspaceProvenance({
|
||||
authority: runtime,
|
||||
repoSelector: args.repoId,
|
||||
repo,
|
||||
request: args.automationProvenanceRequest
|
||||
})
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
result = await adoptProvisionedRootSshCheckout({
|
||||
userDataPath: app.getPath('userData'),
|
||||
request: { ...args, automationProvenance },
|
||||
repo,
|
||||
store,
|
||||
isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId)
|
||||
})
|
||||
} catch (error) {
|
||||
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
track('workspace_create_failed', {
|
||||
source,
|
||||
error_class: classifyWorkspaceCreateError(error),
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
track('workspace_created', {
|
||||
source,
|
||||
from_existing_branch: false,
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
options?.onWorktreeLifecycle?.({
|
||||
kind: 'created',
|
||||
worktreeId: result.worktree.id,
|
||||
path: result.worktree.path,
|
||||
branch: result.worktree.branch
|
||||
})
|
||||
return result
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
|
||||
export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void {
|
||||
const { store, runtime } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:prefetchCreateBase',
|
||||
async (_event, args: { repoId: string; baseBranch?: string }): Promise<void> => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime })
|
||||
} catch {
|
||||
// Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there.
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
|
||||
export function getFolderWorkspaceRootId(repo: Repo): string {
|
||||
return `${repo.id}::${repo.path}`
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
|
||||
return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
|
||||
const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
|
||||
}
|
||||
|
||||
export function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
const rootId = getFolderWorkspaceRootId(repo)
|
||||
return (
|
||||
worktreeId === rootId ||
|
||||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
|
||||
return {
|
||||
id: worktreeId,
|
||||
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId: repo.id,
|
||||
...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
||||
...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}),
|
||||
...(meta.projectHostSetupId !== undefined
|
||||
? { projectHostSetupId: meta.projectHostSetupId }
|
||||
: {}),
|
||||
path: repo.path,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo),
|
||||
displayName: meta.displayName || repo.displayName,
|
||||
comment: meta.comment || '',
|
||||
linkedIssue: meta.linkedIssue ?? null,
|
||||
linkedPR: meta.linkedPR ?? null,
|
||||
linkedLinearIssue: meta.linkedLinearIssue ?? null,
|
||||
linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null,
|
||||
linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null,
|
||||
linkedGitLabMR: meta.linkedGitLabMR ?? null,
|
||||
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
|
||||
linkedBitbucketPR: meta.linkedBitbucketPR ?? null,
|
||||
linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null,
|
||||
linkedGiteaPR: meta.linkedGiteaPR ?? null,
|
||||
linkedWorkItem: meta.linkedWorkItem ?? null,
|
||||
linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null,
|
||||
isArchived: meta.isArchived ?? false,
|
||||
isUnread: meta.isUnread ?? false,
|
||||
isPinned: meta.isPinned ?? false,
|
||||
sortOrder: meta.sortOrder ?? 0,
|
||||
...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
|
||||
lastActivityAt: meta.lastActivityAt ?? 0,
|
||||
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
|
||||
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
|
||||
...(meta.automationProvenance !== undefined
|
||||
? { automationProvenance: meta.automationProvenance }
|
||||
: {}),
|
||||
...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}),
|
||||
...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}),
|
||||
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
|
||||
diffComments: meta.diffComments,
|
||||
mobileDiffReview: meta.mobileDiffReview
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user