Merge origin/main into ssh restore-required fix

This commit is contained in:
Brennan Benson
2026-08-17 00:20:53 -07:00
264 changed files with 19821 additions and 8978 deletions
+6
View File
@@ -270,7 +270,10 @@ jobs:
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
@@ -309,7 +312,10 @@ jobs:
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
-1
View File
@@ -65,7 +65,6 @@ inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/orchestration/db.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/runtime-rpc.ts
@@ -15,6 +15,7 @@ const shellContractFiles = [
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
@@ -4944,7 +4944,9 @@ export default function SessionScreen() {
ref={liveInputRef}
style={styles.liveInputCapture}
value={liveInputCapture}
onChangeText={handleLiveInputChange}
// Why onChange, not onChangeText: only the raw native event carries the
// marked-text report that says whether this text is still preedit.
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
placeholder=""
+3
View File
@@ -85,6 +85,9 @@
"pnpm": {
"overrides": {
"xcode>uuid": "11.1.1"
},
"patchedDependencies": {
"react-native@0.83.9": "patches/react-native@0.83.9.patch"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
index d02e003a439e410ddab597b2c036256c80db0395..3caff21b0d00bfbc6dea3f8470e812b8343ac3a2 100644
--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
@@ -703,6 +703,7 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
{
return {
.text = RCTStringFromNSString(_backedTextInputView.attributedText.string),
+ .isComposing = _backedTextInputView.markedTextRange != nil,
.selectionRange = [self _selectionRange],
.eventCount = static_cast<int>(_mostRecentEventCount),
.contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset),
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
index a9bc219f8bf729111e907423ca61b991fd712b46..60e9c6f54edb295295d6e48d1d554499106b7e57 100644
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
@@ -24,6 +24,10 @@ static jsi::Value textInputMetricsPayload(
payload.setProperty(runtime, "eventCount", textInputMetrics.eventCount);
+ if (textInputMetrics.isComposing.has_value()) {
+ payload.setProperty(runtime, "isComposing", *textInputMetrics.isComposing);
+ }
+
if (includeSelectionState) {
auto selection = jsi::Object(runtime);
selection.setProperty(
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebfbe94a54d 100644
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
@@ -7,6 +7,8 @@
#pragma once
+#include <optional>
+
#include <react/renderer/attributedstring/AttributedString.h>
#include <react/renderer/components/view/ViewEventEmitter.h>
@@ -18,6 +20,7 @@ class TextInputEventEmitter : public ViewEventEmitter {
struct Metrics {
std::string text;
+ std::optional<bool> isComposing;
AttributedString::Range selectionRange;
// ScrollView-like metrics
Size contentSize;
+5
View File
@@ -7,6 +7,11 @@ settings:
overrides:
xcode>uuid: 11.1.1
patchedDependencies:
react-native@0.83.9:
hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
path: patches/react-native@0.83.9.patch
importers:
.:
@@ -12,6 +12,13 @@ describe('terminal iOS IME keyboard', () => {
expect(sessionRouteSource).not.toContain('"ascii-capable"')
})
it('subscribes live capture to onChange so the marked-text report survives', () => {
// onChangeText hands over only a string, discarding the preedit report that
// decides whether the text may reach the PTY at all.
expect(sessionRouteSource).toContain('onChange={handleLiveInputChange}')
expect(sessionRouteSource).not.toContain('onChangeText={handleLiveInputChange}')
})
it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
expect(sessionRouteSource).not.toContain('textContentType="none"')
expect(sessionRouteSource).toContain('autoComplete="off"')
@@ -1,178 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
isTerminalLiveHangulCodePoint,
type TerminalLiveMirrorStep
} from './terminal-live-hangul-mirror'
type MirrorRun = {
readonly payloads: readonly string[]
readonly sentText: string
readonly heldText: string
}
function runMirrorSequence(
fieldStates: readonly string[],
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
): MirrorRun {
const payloads: string[] = []
let sentText = ''
let heldText = ''
for (const fieldText of fieldStates) {
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
if (options.commitAtEnd) {
const lastField = sentText + heldText
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
return { payloads, sentText, heldText }
}
describe('terminal live hangul mirror', () => {
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
expect(run.heldText).toBe('')
})
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한', '글'])
expect(run.sentText).toBe('한글')
})
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
// Given / When
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['가', '나'])
expect(run.sentText).toBe('가나')
})
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
// Given: '하' was committed by the settle timer
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
expect(commit.nextSentText).toBe('하')
// When: user keeps composing '하' → '한'
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
commitHeld: false
})
// Then: one DEL erases the stale syllable; the new one is held again
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
expect(correction.nextSentText).toBe('')
expect(correction.heldText).toBe('한')
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
})
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
// Given / When
const run = runMirrorSequence(['a', 'ab', 'abc'])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
// Given: '한글' typed, '한' streamed, '글' held
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
expect(beforeSpace.sentText).toBe('한')
expect(beforeSpace.heldText).toBe('글')
// When
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
commitHeld: false
})
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
expect(step.heldText).toBe('')
expect(step.nextSentText).toBe('한글 ')
})
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
// Given
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
expect(held.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
expect(step.heldText).toBe('')
})
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
// Then
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 3,
appendText: '',
nextSentText: '',
heldText: ''
})
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
})
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 0,
appendText: '',
nextSentText: '',
heldText: ''
})
})
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
// Given / When
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
expect(chinese.heldText).toBe('')
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
expect(vietnamese.heldText).toBe('')
})
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
})
})
@@ -1,61 +0,0 @@
// Why: paused composition should still reach the PTY quickly; corrections make
// a premature commit safe, so this can be short without leaking jamo forever.
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
const TERMINAL_DEL_BYTE = '\x7f'
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export type TerminalLiveMirrorStep = {
readonly eraseCount: number
readonly appendText: string
readonly nextSentText: string
readonly heldText: string
}
// Why: React Native exposes no composition events, but Hangul composition only
// mutates the trailing syllable. Holding just that code point keeps the PTY
// echo live while preedit jamo never leak; DEL corrections repair any commit
// that later turns out to be premature.
export function computeTerminalLiveMirrorStep(
sentText: string,
fieldText: string,
options: { readonly commitHeld: boolean }
): TerminalLiveMirrorStep {
const fieldCodePoints = Array.from(fieldText)
const lastCodePoint = fieldCodePoints.at(-1)
const holdLast =
!options.commitHeld &&
lastCodePoint !== undefined &&
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
const sentCodePoints = Array.from(sentText)
let commonPrefixLength = 0
while (
commonPrefixLength < sentCodePoints.length &&
commonPrefixLength < targetCodePoints.length &&
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
) {
commonPrefixLength += 1
}
return {
eraseCount: sentCodePoints.length - commonPrefixLength,
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
nextSentText: targetCodePoints.join(''),
heldText
}
}
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
}
@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
type TerminalLiveMirrorStep
} from './terminal-live-preedit-mirror'
type MirrorRun = {
readonly payloads: readonly string[]
readonly sentText: string
readonly heldText: string
}
type MirrorFrame = { readonly text: string; readonly composing?: boolean }
function runMirrorSequence(
fieldStates: readonly (string | MirrorFrame)[],
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
): MirrorRun {
const payloads: string[] = []
let sentText = ''
let heldText = ''
for (const frame of fieldStates) {
const { text, composing } = typeof frame === 'string' ? { text: frame } : frame
const step = computeTerminalLiveMirrorStep(sentText, text, { commitHeld: false, composing })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
if (options.commitAtEnd) {
const lastField = sentText + heldText
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
return { payloads, sentText, heldText }
}
describe('terminal live preedit mirror with a reported marked-text range', () => {
it('Given Japanese romaji composition When steps run Then no reading reaches the PTY and no DEL repairs it', () => {
// Given / When: `sa` composes to さ, then `sakura` converts to 桜
const run = runMirrorSequence(
[
{ text: 's', composing: true },
{ text: 'さ', composing: true },
{ text: 'さく', composing: true },
{ text: 'さくら', composing: true },
{ text: '桜', composing: false }
],
{ commitAtEnd: true }
)
// Then
expect(run.payloads).toEqual(['桜'])
expect(run.sentText).toBe('桜')
expect(run.heldText).toBe('')
})
it('Given ASCII Chinese pinyin preedit When steps run Then the reading never leaks, which no code-point rule could achieve', () => {
// Given / When
const run = runMirrorSequence(
[
{ text: 'n', composing: true },
{ text: 'ni', composing: true },
{ text: 'niha', composing: true },
{ text: 'nihao', composing: true },
{ text: '你好', composing: false }
],
{ commitAtEnd: true }
)
// Then
expect(run.payloads).toEqual(['你好'])
expect(run.sentText).toBe('你好')
})
it('Given Hangul composition When the range is reported Then the syllable lands once with no DEL correction', () => {
// Given / When
const run = runMirrorSequence(
[
{ text: 'ㅎ', composing: true },
{ text: '하', composing: true },
{ text: '한', composing: true },
{ text: '한', composing: false }
],
{ commitAtEnd: true }
)
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
})
it('Given ASCII typing When the range is reported empty Then every character mirrors immediately', () => {
// Given / When
const run = runMirrorSequence([
{ text: 'a', composing: false },
{ text: 'ab', composing: false },
{ text: 'abc', composing: false }
])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given committed text When a new composition starts Then the commit stays and only the preedit is held', () => {
// Given
const committed = computeTerminalLiveMirrorStep('', 'ls ', {
commitHeld: false,
composing: false
})
expect(committed.nextSentText).toBe('ls ')
// When
const step = computeTerminalLiveMirrorStep('ls ', 'ls さ', {
commitHeld: false,
composing: true
})
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step.nextSentText).toBe('ls ')
expect(step.heldText).toBe('さ')
})
it('Given a composition that rewrites already-sent text When the step runs Then DEL erases only the difference', () => {
// Given / When: the input method reclaimed a code point the PTY already has
const step = computeTerminalLiveMirrorStep('abc', 'abX', { commitHeld: false, composing: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f')
expect(step.nextSentText).toBe('ab')
expect(step.heldText).toBe('X')
})
})
describe('terminal live preedit mirror with no marked-text report', () => {
it('Given Japanese romaji When nothing is reported Then the whole multi-code-point reading is held as one unit', () => {
// Given / When: きょう is three code points, so holding one trailing code point would leak きょ
const run = runMirrorSequence(['k', 'ky', 'きょ', 'きょう'], { commitAtEnd: true })
// The ASCII part of the reading still leaks and is DEL-repaired — only a
// marked-text range can see an ASCII preedit — but no partial kana reaches the
// PTY, which a trailing-single-code-point hold could not achieve.
expect(run.payloads).toEqual(['k', 'y', '\x7f\x7f', 'きょう'])
expect(run.sentText).toBe('きょう')
})
it('Given Hangul composition When nothing is reported Then no jamo leaks and only the final syllable commits', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
expect(run.heldText).toBe('')
})
it('Given multi-syllable Hangul When nothing is reported Then the whole run stays held until commit', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한글'])
expect(run.sentText).toBe('한글')
})
it('Given dubeolsik resplit 간→가나 When nothing is reported Then no intermediate syllable is ever sent', () => {
// Given / When
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['가나'])
expect(run.sentText).toBe('가나')
})
it('Given a timer-committed hold When composition continues Then DEL corrections repair the premature commit', () => {
// Given: '하' was committed by the settle timer
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
// When: user keeps composing '하' → '한'
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
commitHeld: false
})
// Then
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
expect(correction.nextSentText).toBe('')
expect(correction.heldText).toBe('한')
})
it('Given pure ASCII typing When nothing is reported Then mirroring stays immediate with no held text', () => {
// Given / When
const run = runMirrorSequence(['a', 'ab', 'abc'])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given a trailing space after held text When the step runs Then the space commits the hold', () => {
// Given
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한'])
expect(beforeSpace.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한 ', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한 ')
expect(step.heldText).toBe('')
})
it('Given a trailing ASCII letter after held text When the step runs Then the hold commits with the letter', () => {
// Given
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
expect(held.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
expect(step.heldText).toBe('')
})
it('Given Vietnamese telex whose commit ends in ASCII When the step runs Then it mirrors immediately', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('tiếng')
expect(step.heldText).toBe('')
})
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
// Then
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 3,
appendText: '',
nextSentText: '',
heldText: ''
})
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
})
// A settle-timer commit promotes the held run to sent text. The next keystroke must not reach
// back over it: the fallback has no composing signal, so without a bound it re-holds everything
// already delivered and the caller erases it with DEL. Measured at 120 DELs for this word.
it('Given a non-composing script on the fallback path When the settle timer commits between keystrokes Then nothing already sent is erased', () => {
// Given
const word = Array.from('приветмир')
let sentText = ''
let field = ''
const payloads: string[] = []
// When — every keystroke is followed by a settle commit, the Android shape
for (const codePoint of word) {
field += codePoint
const live = computeTerminalLiveMirrorStep(sentText, field, { commitHeld: false })
const livePayload = buildTerminalLiveMirrorPayload(live)
if (livePayload.length > 0) {
payloads.push(livePayload)
}
const settled = computeTerminalLiveMirrorStep(live.nextSentText, field, { commitHeld: true })
const settledPayload = buildTerminalLiveMirrorPayload(settled)
if (settledPayload.length > 0) {
payloads.push(settledPayload)
}
sentText = settled.nextSentText
}
// Then
const wire = payloads.join('')
expect(wire, 'a DEL erases a character the pty already received').not.toContain('\x7f')
expect(wire).toBe(word.join(''))
})
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 0,
appendText: '',
nextSentText: '',
heldText: ''
})
})
})
@@ -0,0 +1,83 @@
// Why: an unreported preedit that pauses should still reach the PTY quickly;
// corrections make a premature commit safe. Only armed when the platform
// reports nothing — a reported preedit is not text yet and must never time out.
export const TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS = 300
const TERMINAL_DEL_BYTE = '\x7f'
const LAST_ASCII_CODE_POINT = 0x7f
export type TerminalLiveMirrorStep = {
readonly eraseCount: number
readonly appendText: string
readonly nextSentText: string
readonly heldText: string
}
/**
* How much of the field the text system can still rewrite, in code points.
*
* Preedit is a fact about the field, never about the script — Chinese pinyin
* preedit is plain ASCII — so the marked-text range decides and no code point is
* ever classified. That is what makes this hold for input methods nobody tested.
*
* `composing` is undefined only where the platform reports no range at all —
* today React Native Android. The fallback holds the trailing non-ASCII run: a
* conversion IME always leaves one and ASCII keeps its zero-latency echo. It
* enumerates nothing, and it cannot see an ASCII preedit — only a report can.
*/
function heldPreeditLength(
fieldCodePoints: readonly string[],
stableLength: number,
composing: boolean | undefined
): number {
if (composing !== undefined) {
return composing ? fieldCodePoints.length - stableLength : 0
}
let held = 0
while (
held < fieldCodePoints.length &&
(fieldCodePoints[fieldCodePoints.length - 1 - held]?.codePointAt(0) ?? 0) >
LAST_ASCII_CODE_POINT
) {
held += 1
}
// Why the bound: the run can reach back over code points already delivered to the pty, and
// holding those makes the caller erase them with DEL and retype them. The reported branch above
// subtracts `stableLength` for the same reason; without it a settle-timer commit followed by
// another keystroke costs a DEL per already-sent character.
return Math.min(held, fieldCodePoints.length - stableLength)
}
function commonPrefixLength(left: readonly string[], right: readonly string[]): number {
let length = 0
while (length < left.length && length < right.length && left[length] === right[length]) {
length += 1
}
return length
}
export function computeTerminalLiveMirrorStep(
sentText: string,
fieldText: string,
options: { readonly commitHeld: boolean; readonly composing?: boolean }
): TerminalLiveMirrorStep {
const fieldCodePoints = Array.from(fieldText)
const sentCodePoints = Array.from(sentText)
const stableLength = commonPrefixLength(sentCodePoints, fieldCodePoints)
const heldLength = options.commitHeld
? 0
: heldPreeditLength(fieldCodePoints, stableLength, options.composing)
const targetCodePoints = fieldCodePoints.slice(0, fieldCodePoints.length - heldLength)
const keptLength = Math.min(stableLength, targetCodePoints.length)
return {
eraseCount: sentCodePoints.length - keptLength,
appendText: targetCodePoints.slice(keptLength).join(''),
nextSentText: targetCodePoints.join(''),
heldText: fieldCodePoints.slice(fieldCodePoints.length - heldLength).join('')
}
}
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
}
@@ -3,12 +3,23 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
import { TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS } from './terminal-live-preedit-mirror'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHandlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
/** `isComposing` omitted models a platform that reports no marked-text range. */
function changeLiveInput(
handlers: TerminalLiveInputCommitHandlers,
text: string,
isComposing?: boolean
): void {
handlers.handleLiveInputChange({ nativeEvent: { text, isComposing } })
}
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly handlers: TerminalLiveInputCommitHandlers
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly setConnected: (next: boolean) => void
@@ -46,7 +57,7 @@ function createTerminalLiveInputCommitHarness({
// Refs never re-render; only these variables re-run the hook's clear effects.
let currentActiveSessionTabType: string | undefined = 'terminal'
let currentConnected = true
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let handlers: TerminalLiveInputCommitHandlers | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
@@ -105,29 +116,59 @@ describe('terminal live input commit hook', () => {
vi.useRealTimers()
})
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
handlers.handleLiveInputChange(fieldText)
changeLiveInput(handlers, fieldText)
await vi.advanceTimersByTimeAsync(50)
}
// Then: only the stable prefix went out; the trailing syllable is held
await vi.waitFor(() => expect(sent).toEqual(['한']))
// Then: the settle timer commits the run intact; no jamo and no DEL repair
await vi.waitFor(() => expect(sent).toEqual(['한글']))
})
it('Given Japanese romaji When the marked-text range is reported Then no reading reaches the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When: `sa` reads as さ, converts to 桜, and only then commits
for (const fieldText of ['s', 'さ', 'さく', 'さくら']) {
changeLiveInput(handlers, fieldText, true)
await vi.advanceTimersByTimeAsync(50)
}
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 4)
changeLiveInput(handlers, '桜', false)
// Then: the reading never reached the PTY, so nothing had to be erased
await vi.waitFor(() => expect(sent).toEqual(['桜']))
})
it('Given a reported preedit that goes idle When the settle delay passes Then nothing is committed on a timer', async () => {
// Given: preedit is not text yet, so no timer may promote it
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
changeLiveInput(handlers, 'nihao', true)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 10)
// Then
expect(sent).toEqual([])
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['한']))
@@ -137,13 +178,13 @@ describe('terminal live input commit hook', () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('하')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
changeLiveInput(handlers, '하')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
await vi.waitFor(() => expect(sent).toEqual(['하']))
// When
handlers.handleLiveInputChange('한')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
changeLiveInput(handlers, '한')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
@@ -152,7 +193,7 @@ describe('terminal live input commit hook', () => {
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputSubmit()
@@ -175,7 +216,7 @@ describe('terminal live input commit hook', () => {
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputSubmit()
@@ -191,8 +232,8 @@ describe('terminal live input commit hook', () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('a')
handlers.handleLiveInputChange('ab')
changeLiveInput(handlers, 'a')
changeLiveInput(handlers, 'ab')
// Then
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
@@ -203,7 +244,7 @@ describe('terminal live input commit hook', () => {
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
handlers.handleLiveInputChange('a–')
changeLiveInput(handlers, 'a–')
// Then: writing "a--" back into the controlled value would kill an active
// iOS dictation/IME session, so the capture must keep what iOS produced
@@ -216,8 +257,8 @@ describe('terminal live input commit hook', () => {
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS dictation replaces its hypothesis as recognition refines
handlers.handleLiveInputChange('high')
handlers.handleLiveInputChange('hi there')
changeLiveInput(handlers, 'high')
changeLiveInput(handlers, 'hi there')
// Then: captures only echo the field; the mirror repairs the PTY with DELs
expect(captures).toEqual(['high', 'hi there'])
@@ -227,10 +268,10 @@ describe('terminal live input commit hook', () => {
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputChange('한 ')
changeLiveInput(handlers, '한 ')
// Then
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
@@ -239,7 +280,7 @@ describe('terminal live input commit hook', () => {
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
@@ -252,7 +293,7 @@ describe('terminal live input commit hook', () => {
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
@@ -267,7 +308,7 @@ describe('terminal live input commit hook', () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
changeLiveInput(handlers, '你好')
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好']))
@@ -277,7 +318,7 @@ describe('terminal live input commit hook', () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
unmount()
@@ -290,7 +331,7 @@ describe('terminal live input commit hook', () => {
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
@@ -302,7 +343,7 @@ describe('terminal live input commit hook', () => {
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
@@ -314,7 +355,7 @@ describe('terminal live input commit hook', () => {
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When: the mobile tab list momentarily yields no active tab object
setActiveSessionTabType(undefined)
@@ -327,7 +368,7 @@ describe('terminal live input commit hook', () => {
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When: the active tab actually becomes a non-terminal (chat) tab
setActiveSessionTabType('chat')
@@ -341,7 +382,7 @@ describe('terminal live input commit hook', () => {
// Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect)
const { captures, handlers, sent, setConnected, setSendResult } =
createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('XYZZY')
changeLiveInput(handlers, 'XYZZY')
await vi.waitFor(() => expect(sent).toEqual(['XYZZY']))
// When: the outage is finally detected, then the link recovers
@@ -353,7 +394,7 @@ describe('terminal live input commit hook', () => {
// 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived
expect(captures.at(-1)).toBe('')
const sentBeforeRecovery = sent.length
handlers.handleLiveInputChange('echo CLEANLINE')
changeLiveInput(handlers, 'echo CLEANLINE')
await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE']))
})
@@ -363,11 +404,11 @@ describe('terminal live input commit hook', () => {
const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({
sendResult: false
})
handlers.handleLiveInputChange('한')
changeLiveInput(handlers, '한')
// When
setConnected(false)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
// Then: the outage cleared the held text before the timer could send it
expect(sent).toEqual([])
@@ -17,6 +17,16 @@ type TerminalLiveInputKeyPressEvent = {
}
}
/** `isComposing` is the text system's marked-text range, forwarded by the pinned
* react-native patch on iOS; `onChangeText` would drop the payload entirely.
* Absent means the platform reports no range — not "not composing". */
type TerminalLiveInputChangeEvent = {
readonly nativeEvent: {
readonly text: string
readonly isComposing?: boolean
}
}
type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
@@ -36,7 +46,7 @@ type TerminalLiveInputCommitHandlers = {
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (text: string) => void
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
}
@@ -113,7 +123,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
)
const handleLiveInputChange = useCallback(
(text: string) => {
({ nativeEvent }: TerminalLiveInputChangeEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
clearPendingLiveInputCommit()
return
@@ -121,8 +131,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(text)
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
setLiveInputCapture(nativeEvent.text)
applyLiveInputMirror(
activeHandle,
normalizeTerminalTextInput(nativeEvent.text),
nativeEvent.isComposing
)
},
[
activeHandle,
@@ -4,8 +4,8 @@ import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS
} from './terminal-live-preedit-mirror'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
@@ -22,8 +22,15 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
readonly setLiveInputCapture: (text: string) => void
}
type RunTerminalLiveMirrorStep = (
handle: string,
fieldText: string,
commitHeld: boolean,
composing?: boolean
) => Promise<boolean>
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
@@ -45,9 +52,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
>(async () => false)
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
const clearHeldCommitTimer = useCallback(() => {
if (heldCommitTimerRef.current) {
@@ -80,8 +85,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
const runMirrorStep = useCallback<RunTerminalLiveMirrorStep>(
async (handle, fieldText, commitHeld, composing) => {
if (
handle !== activeHandleRef.current ||
(activeSessionTabTypeRef.current != null &&
@@ -96,7 +101,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
commitHeld
commitHeld,
composing
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
@@ -104,12 +110,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
clearHeldCommitTimer()
if (step.heldText.length > 0) {
// Why: text the platform positively marked as preedit is not text yet, so
// no idle timer may commit it. Only an unreported hold is a guess that has
// to settle on its own.
if (step.heldText.length > 0 && composing === undefined) {
heldCommitTimerRef.current = setTimeout(() => {
heldCommitTimerRef.current = null
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
void runMirrorStepRef.current(handle, heldField, true)
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
}, TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
}
const payload = buildTerminalLiveMirrorPayload(step)
@@ -140,8 +149,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [runMirrorStep])
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string): void => {
void runMirrorStep(handle, fieldText, false)
(handle: string, fieldText: string, composing?: boolean): void => {
void runMirrorStep(handle, fieldText, false, composing)
},
[runMirrorStep]
)
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatTerminalFocus } from './terminal-format'
import { formatTerminalClose, formatTerminalFocus } from './terminal-format'
describe('formatTerminalFocus', () => {
it('distinguishes superseded navigation from a winning focus', () => {
@@ -22,3 +22,40 @@ describe('formatTerminalFocus', () => {
).toBe('Focused terminal term_winner (tab tab-winner).')
})
})
describe('formatTerminalClose', () => {
it('prints "PTY killed." only for a confirmed kill', () => {
expect(
formatTerminalClose({ close: { handle: 'term_local', tabId: 'tab-1', ptyKilled: true } })
).toBe('Closed terminal term_local. PTY killed.')
})
it('says the remote process was not confirmed stopped instead of claiming a kill', () => {
expect(
formatTerminalClose({
close: {
handle: 'term_remote',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'its SSH provider is no longer registered'
}
})
).toBe(
'Closed terminal term_remote. The PTY was not confirmed stopped: its SSH provider is no longer registered.'
)
})
it('names a PTY known to be live', () => {
expect(
formatTerminalClose({
close: {
handle: 'term_live',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'live'
}
})
).toBe('Closed terminal term_live. The PTY is live.')
})
})
+16 -2
View File
@@ -1,3 +1,4 @@
import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict'
import type {
RuntimeTerminalClose,
RuntimeTerminalCreate,
@@ -185,12 +186,25 @@ export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): st
return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).`
}
/** "PTY killed." is a claim of observed death, so only a confirmed kill earns it. */
function describePtyStop(close: RuntimeTerminalClose): string {
if (close.ptyKilled) {
return ' PTY killed.'
}
if (close.ptyStopVerdict === 'live') {
return ` ${PTY_LIVE_NOTE}`
}
if (close.ptyStopVerdict === 'unverifiable') {
return ` ${describeUnconfirmedStop(close.ptyStopReason ?? 'its host could not be reached')}`
}
return ''
}
export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string {
if (result.close.closeMode === 'tab') {
return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).`
}
const ptyNote = result.close.ptyKilled ? ' PTY killed.' : ''
return `Closed terminal ${result.close.handle}.${ptyNote}`
return `Closed terminal ${result.close.handle}.${describePtyStop(result.close)}`
}
export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
@@ -85,12 +85,7 @@ describe('recordProcessGoneCrash killed/1 ordering', () => {
recordProcessGoneCrash({ record } as never, gpuKill, dedupe)
recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe)
recordProcessGoneCrash(
{ record, attachDetails } as never,
rendererKill,
dedupe,
noMinidump
)
recordProcessGoneCrash({ record, attachDetails } as never, rendererKill, dedupe, noMinidump)
expect(record).toHaveBeenCalledOnce()
// Timing proximity is evidence, not authority to discard an ambiguous report.
@@ -439,7 +439,9 @@ describe('recordProcessGoneCrash', () => {
// Why child kills: the decode gate is source-agnostic and reads
// process.platform synchronously at record time, so the platform stub is
// still in force when it runs.
const nonRecoverableChildKill = (overrides: Partial<ProcessGoneCrashEvent>): ProcessGoneCrashEvent =>
const nonRecoverableChildKill = (
overrides: Partial<ProcessGoneCrashEvent>
): ProcessGoneCrashEvent =>
event({
source: 'child',
processType: 'Utility',
+46 -2
View File
@@ -66,6 +66,9 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
import { addWslEnvKeys } from '../wsl-env'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
@@ -425,7 +428,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
@@ -444,7 +448,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
try {
return await this.withHistorySpawnLock(sessionId, () =>
this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery))
this.withDaemonRetry(() =>
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
)
)
} finally {
if (historyRecovery.freeze) {
@@ -458,6 +464,44 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
private 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 }
}
private async doSpawn(
opts: PtySpawnOptions,
operation: PendingDaemonSpawnOperation,
@@ -1,6 +1,10 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock } from '../shell-templates'
import {
getZshFinalZdotdirRestoreBlock,
getZshStartupFileSourceBlock,
ZSH_HISTFILE_RESTORE_BLOCK
} from '../shell-templates'
export function getDaemonZshShellReadyRcfileContent(): string {
return `# Orca daemon zsh shell-ready wrapper
@@ -23,6 +27,7 @@ if [[ ! -o login ]]; then
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${ZSH_HISTFILE_RESTORE_BLOCK}
${getPosixCodexShellLaunchPreflight()}
fi
__orca_osc133_precmd() {
+3 -1
View File
@@ -16,7 +16,8 @@ import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
getZshStartupFileSourceBlock
getZshStartupFileSourceBlock,
ZSH_HISTFILE_RESTORE_BLOCK
} from '../shell-templates'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
@@ -121,6 +122,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${ZSH_HISTFILE_RESTORE_BLOCK}
${getPosixCodexShellLaunchPreflight()}
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
${getZshFinalZdotdirRestoreBlock()}
+238
View File
@@ -0,0 +1,238 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
deleteFishHistoryFile,
fishHistorySessionName,
isSafeFishHistorySession,
relayFishHistorySessionName,
resolveFishHistoryDir,
sweepOrphanedFishHistoryFiles
} from './fish-history-session'
const HASH_A = 'a1b2c3d4e5f60718'
const HASH_B = '00112233445566ff'
const SESSION_A = fishHistorySessionName(HASH_A)
describe('fish history session naming', () => {
it('mints a session name that is a valid fish variable value', () => {
// fish falls back to the shared default for anything that is not a valid
// variable name, which would silently un-isolate the worktree.
expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718')
expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/)
})
it.each([
['orca_deadbeef', true],
['orca_', false],
['fish_history', false],
['orca_../../etc/passwd', false],
['orca_DEADBEEF', false],
['', false],
[undefined, false],
[42, false]
])('accepts %s as a safe session name: %s', (value, expected) => {
expect(isSafeFishHistorySession(value)).toBe(expected)
})
})
describe('fish history directory resolution', () => {
// Why join rather than literals: the separator is platform-specific, and on
// Windows `isAbsolute('/data')` is also true, so a hardcoded '/data/fish'
// fails there for a reason that has nothing to do with the behavior tested.
it('follows XDG_DATA_HOME when it is absolute', () => {
expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe(
join('/data', 'fish')
)
})
it('ignores a relative XDG_DATA_HOME the way fish does', () => {
expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe(
join('/home/me', '.local', 'share', 'fish')
)
})
it('falls back to HOME when XDG_DATA_HOME is unset', () => {
expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe(
join('/home/me', '.local', 'share', 'fish')
)
})
})
describe('fish history deletion', () => {
let root: string
let fishDir: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-fish-history-'))
fishDir = join(root, 'fish')
mkdirSync(fishDir, { recursive: true })
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
const historyFile = (session: string): string => join(fishDir, `${session}_history`)
it('deletes the session file rather than truncating it', () => {
writeFileSync(historyFile(SESSION_A), '- cmd: ls\n')
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true)
expect(existsSync(historyFile(SESSION_A))).toBe(false)
})
it('tries every candidate directory, since the PTY may not share this env', () => {
const other = join(root, 'other-xdg', 'fish')
mkdirSync(other, { recursive: true })
writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n')
expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true)
expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false)
})
it('refuses a session name it did not mint', () => {
const foreign = join(fishDir, 'fish_history')
writeFileSync(foreign, 'the user’s real history\n')
expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false)
expect(existsSync(foreign)).toBe(true)
})
// Why skipped on Windows: symlinkSync needs Developer Mode or elevation there.
it.skipIf(process.platform === 'win32')(
'refuses to follow a symlink out of the fish data dir',
() => {
const outside = join(root, 'precious.txt')
writeFileSync(outside, 'keep me\n')
symlinkSync(outside, historyFile(SESSION_A))
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
expect(existsSync(outside)).toBe(true)
}
)
it('reports false when there is nothing to delete', () => {
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
})
})
describe('orphaned fish history sweep', () => {
let root: string
let fishDir: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-fish-sweep-'))
fishDir = join(root, 'fish')
mkdirSync(fishDir, { recursive: true })
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
it('removes files for dead worktrees and keeps live ones', () => {
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`), 'live\n')
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(1)
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true)
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false)
})
it("never touches the user's own history files", () => {
// The whole safety of the sweep rests on the orca_<hex>_ prefix.
const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history']
for (const name of untouched) {
writeFileSync(join(fishDir, name), 'mine\n')
}
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0)
for (const name of untouched) {
expect(existsSync(join(fishDir, name))).toBe(true)
}
})
it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => {
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0)
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true)
})
it('spares a file younger than the age guard, since the live set is a snapshot', () => {
// The reachable race: a worktree created after the snapshot but before the
// sweep looks dead while it is actively writing the history it owns.
const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
writeFileSync(fresh, 'just created\n')
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0)
expect(existsSync(fresh)).toBe(true)
})
it('sweeps a file older than the age guard', () => {
const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
writeFileSync(stale, 'long dead\n')
const wellPast = Date.now() + 60 * 60 * 1000
expect(
sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast)
).toBe(1)
expect(existsSync(stale)).toBe(false)
})
it('tolerates a directory that does not exist', () => {
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0)
})
it('sweeps every candidate directory once', () => {
const other = join(root, 'other', 'fish')
mkdirSync(other, { recursive: true })
for (const dir of [fishDir, other]) {
writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
}
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2)
})
})
/**
* A relay host keyed by its CLIENT's worktree ids shares one fish data dir with
* any desktop Orca on the same machine, whose live set knows nothing of those
* ids. The name is the only thing that keeps that sweep off remote history.
*/
describe('relay fish history naming', () => {
let relayRoot: string
beforeEach(() => {
relayRoot = mkdtempSync(join(tmpdir(), 'orca-fish-relay-'))
})
afterEach(() => {
rmSync(relayRoot, { recursive: true, force: true })
})
it('is namespaced apart from desktop session names', () => {
expect(relayFishHistorySessionName(HASH_A)).not.toBe(fishHistorySessionName(HASH_A))
})
it('is still a safe session name, so the relay can delete it by name', () => {
expect(isSafeFishHistorySession(relayFishHistorySessionName(HASH_A))).toBe(true)
})
it('survives a desktop sweep that knows none of the relay worktree ids', () => {
const dir = relayRoot
const relayFile = join(dir, `${relayFishHistorySessionName(HASH_A)}_history`)
const desktopFile = join(dir, `${fishHistorySessionName(HASH_B)}_history`)
writeFileSync(relayFile, 'relay')
writeFileSync(desktopFile, 'desktop')
// A live set with neither hash in it: everything attributable is orphaned.
const removed = sweepOrphanedFishHistoryFiles(new Set(['deadbeef']), [dir])
expect(removed).toBe(1)
expect(existsSync(desktopFile)).toBe(false)
expect(existsSync(relayFile)).toBe(true)
})
})
+139
View File
@@ -0,0 +1,139 @@
import { lstatSync, readdirSync, rmSync } from 'node:fs'
import { homedir } from 'node:os'
import { isAbsolute, join } from 'node:path'
/**
* Per-worktree fish history.
*
* fish ignores HISTFILE entirely — history lives at `<data dir>/<name>_history`
* where `<name>` comes only from the `fish_history` variable, and there is no
* custom-directory knob. So the session NAME is the only isolation lever, and
* the file necessarily lands in the user's fish data dir rather than inside
* Orca's history tree. That is why deletion needs its own path here instead of
* riding the tree tombstone the way bash/zsh history does.
*
* `XDG_DATA_HOME` is deliberately not redirected: that would move every other
* tool's data for the session, not just fish's history.
*/
const SESSION_PREFIX = 'orca_'
const RELAY_SESSION_PREFIX = 'orca_relay_'
const SAFE_SESSION_NAME = /^orca_(?:relay_)?[0-9a-f]{1,64}$/
/** Deliberately does NOT match the relay prefix. A relay host keyed by its
* CLIENT's worktree ids shares the one fish data dir with any desktop Orca on
* the same machine, whose live set knows nothing of those ids — so an
* attributable name is the only thing keeping that sweep off remote history.
* Relay files are removed by exact name when their worktree goes away. */
const ORCA_HISTORY_FILE = /^orca_([0-9a-f]{1,64})_history$/
export function isSafeFishHistorySession(session: unknown): session is string {
return typeof session === 'string' && SAFE_SESSION_NAME.test(session)
}
export function fishHistorySessionName(worktreeHash: string): string {
return `${SESSION_PREFIX}${worktreeHash}`
}
/** Relay-owned counterpart, namespaced out of the desktop sweep's reach. */
export function relayFishHistorySessionName(worktreeHash: string): string {
return `${RELAY_SESSION_PREFIX}${worktreeHash}`
}
/** The directory fish will write history into, for a given environment.
* Resolve this from the PTY's SPAWN env: Orca can be launched with a different
* `XDG_DATA_HOME`/`HOME` than the shells it spawns, and fish follows its own. */
export function resolveFishHistoryDir(env: NodeJS.ProcessEnv = process.env): string {
const xdg = env.XDG_DATA_HOME?.trim()
const dataHome =
xdg && isAbsolute(xdg) ? xdg : join(env.HOME?.trim() || homedir(), '.local', 'share')
return join(dataHome, 'fish')
}
/** Removes one history file, refusing anything that is not a regular file.
* Why lstat: a symlink here would take `rmSync` out of the fish data dir. */
function removeHistoryFile(path: string): boolean {
try {
if (!lstatSync(path).isFile()) {
return false
}
rmSync(path)
return true
} catch {
return false
}
}
/** Delete one worktree's fish history file, trying every directory it could be in.
* Callers pass the directory recorded at spawn time plus this process's own, since
* the two disagree whenever Orca's environment differs from the PTY's. */
export function deleteFishHistoryFile(session: string, dirs: Iterable<string>): boolean {
if (!isSafeFishHistorySession(session)) {
return false
}
let removed = false
for (const dir of new Set(dirs)) {
removed = removeHistoryFile(join(dir, `${session}_history`)) || removed
}
return removed
}
/**
* Delete every Orca-minted fish history file whose worktree is gone.
*
* Why a sweep and not just per-worktree deletion: the history file outlives the
* directory that names it. A crash between tombstone and removal, a hand-deleted
* history dir, or a file written by an older build all orphan one, and only a
* scan of the data dir can find those. Matching on Orca's own `orca_<hex>_`
* prefix means the user's real `fish_history` is never a candidate.
*
* `minAgeMs` mirrors the age guard the history-tree GC already applies: the
* live-worktree set is a snapshot, so a worktree created between the snapshot
* and this sweep would otherwise look dead and lose the history it is actively
* writing. A live session's file keeps a recent mtime, so this doubles as a
* second check on the set itself.
*
* Refuses to run at all on an empty live set: that cannot be told apart from a
* store that failed to hydrate, and unlike the tree GC this deletes files
* outside Orca's own directory.
*/
export function sweepOrphanedFishHistoryFiles(
liveWorktreeHashes: ReadonlySet<string>,
dirs: Iterable<string>,
minAgeMs = 0,
now = Date.now()
): number {
if (liveWorktreeHashes.size === 0) {
return 0
}
let removed = 0
for (const dir of new Set(dirs)) {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
continue
}
for (const entry of entries) {
const hash = ORCA_HISTORY_FILE.exec(entry)?.[1]
if (!hash || liveWorktreeHashes.has(hash)) {
continue
}
const path = join(dir, entry)
if (minAgeMs > 0 && !isOlderThan(path, minAgeMs, now)) {
continue
}
if (removeHistoryFile(path)) {
removed += 1
}
}
}
return removed
}
/** Treats an unreadable file as too young: never delete on a failed stat. */
function isOlderThan(path: string, minAgeMs: number, now: number): boolean {
try {
return now - lstatSync(path).mtimeMs >= minAgeMs
} catch {
return false
}
}
+9
View File
@@ -97,6 +97,15 @@ describe('remote URL probe', () => {
await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).resolves.toBeUndefined()
})
it('keeps a failed SSH command unverifiable even without a transport error string', async () => {
const failure = new Error('relay request failed')
getSshGitProviderMock.mockReturnValue({ exec: vi.fn().mockRejectedValue(failure) })
await expect(
assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' })
).rejects.toBe(failure)
})
it('does not turn a missing SSH provider into a false readable result', async () => {
getSshGitProviderMock.mockReturnValue(null)
+7 -6
View File
@@ -3,6 +3,7 @@ import {
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { gitExecFileAsync } from './runner'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
/**
* The `git remote get-url` probe every forge integration runs to decide whether
@@ -91,10 +92,9 @@ export function isTransientGitProbeError(error: unknown): boolean {
}
/**
* Throws when the remote could not be read because the probe was wedged or its
* transport was gone, and returns normally for every answer — including a repo
* with no remote at all. Lets a caller that treats "nothing found" as a cacheable
* answer tell that apart from a lookup that never got to ask.
* Throws when the remote could not be read, and returns normally for every
* answer — including a repo with no remote at all. Lets a caller that treats
* "nothing found" as cacheable tell that apart from a lookup that never got to ask.
*/
export async function assertRemoteUrlReadable(
context: RemoteUrlProbeContext,
@@ -106,8 +106,9 @@ export async function assertRemoteUrlReadable(
try {
await readRemoteUrl(context, remoteName)
} catch (error) {
if (isTransientGitProbeError(error)) {
throw error
if (isStableMissingGitRemoteError(error)) {
return
}
throw error
}
}
@@ -85,6 +85,7 @@ vi.mock('./github-enterprise-repository', () => ({
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: vi.fn(() => 0),
getSshGitProvider: vi.fn()
}))
@@ -0,0 +1,266 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GithubApiRepositoryModule from './github-api-repository'
import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository'
const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./client-test-mocks')
return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks }
})
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
vi.mock('./github-enterprise-repository', async (importOriginal) =>
moduleMocks.githubEnterpriseRepositoryModuleMock(
await importOriginal<typeof GitHubEnterpriseRepositoryModule>()
)
)
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks))
vi.mock('./github-api-repository', async (importOriginal) =>
moduleMocks.githubApiRepositoryModuleMock(
clientMocks,
await importOriginal<typeof GithubApiRepositoryModule>()
)
)
import { getPRForBranch, getPRForBranchOutcome } from './client'
import { resetPRForBranchMocks } from './client-test-harness'
const {
ghExecFileAsyncMock,
getOwnerRepoMock,
gitExecFileAsyncMock,
getSshGitProviderMock,
resolvePRRepositoryCandidatesMock
} = clientMocks
const MERGED_BRANCH = 'fix-tab-strip-layout-test'
const MERGED_HEAD_OID = 'current-head-oid'
function mockMergedBranchPRLookup(): void {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 5875,
title: 'Merged current branch PR',
state: 'closed',
merged_at: '2026-06-20T04:53:05Z',
html_url: 'https://github.com/acme/widgets/pull/5875',
updated_at: '2026-06-20T04:53:05Z',
draft: false,
mergeable_state: 'clean',
head: { ref: MERGED_BRANCH, sha: MERGED_HEAD_OID },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5875,
title: 'Merged current branch PR',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/5875',
statusCheckRollup: [],
updatedAt: '2026-06-20T04:53:05Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: MERGED_BRANCH,
baseRefOid: 'base-oid',
headRefOid: MERGED_HEAD_OID
})
})
}
function mockUpstreamOnlyPRLookup(): void {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 78,
title: 'Upstream branch PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/78',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 78,
title: 'Hydrated upstream branch PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/78',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'upstream-head-oid'
})
})
}
// The strict execution boundary: for an SSH-hosted repoPath, an unregistered
// provider means "we could not ask" — it must never degrade into a client-side
// git run, which on a same-named local path answers for the wrong repository.
describe('getPRForBranch SSH execution boundary', () => {
beforeEach(() => {
resetPRForBranchMocks(clientMocks)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
})
it('does not rev-parse HEAD locally when the SSH provider is unregistered', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
mockMergedBranchPRLookup()
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
})
it('reports an upstream error when SSH candidate discovery is unverifiable', async () => {
resolvePRRepositoryCandidatesMock.mockRejectedValue(
new Error('Remote repository identity is unverifiable.')
)
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
})
it('keeps a verified empty SSH candidate set as no PR', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [], headRepo: null })
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({ kind: 'no-pr' })
})
it('rev-parses HEAD through the SSH provider when it is registered', async () => {
const sshGitProvider = {
exec: vi.fn(async (args: string[]) =>
args[0] === 'rev-parse' && args[1] === 'HEAD'
? { stdout: `${MERGED_HEAD_OID}\n`, stderr: '' }
: { stdout: '', stderr: '' }
)
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
mockMergedBranchPRLookup()
const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledWith(['rev-parse', 'HEAD'], '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(pr).toMatchObject({ number: 5875, state: 'merged', headSha: MERGED_HEAD_OID })
})
it('rev-parses HEAD through the local runtime for a WSL repository', async () => {
mockMergedBranchPRLookup()
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
const pr = await getPRForBranch('/repo-root', MERGED_BRANCH, null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], {
cwd: '/repo-root',
wslDistro: 'Ubuntu'
})
expect(pr).toMatchObject({ number: 5875, state: 'merged', headSha: MERGED_HEAD_OID })
})
it('does not read tracked upstreams locally when the SSH provider is unregistered', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
mockUpstreamOnlyPRLookup()
gitExecFileAsyncMock.mockResolvedValue({
stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`,
stderr: ''
})
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('reports an upstream error when the SSH HEAD probe loses its provider mid-flight', async () => {
getSshGitProviderMock.mockReturnValue({
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
})
mockMergedBranchPRLookup()
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
})
it('reports an upstream error when the SSH upstream probe loses its provider mid-flight', async () => {
getSshGitProviderMock.mockReturnValue({
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
})
mockUpstreamOnlyPRLookup()
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
})
it('reads tracked upstreams through the SSH provider when it is registered', async () => {
const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
const sshGitProvider = {
exec: vi.fn(async (args: string[]) =>
args[0] === 'for-each-ref'
? { stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`, stderr: '' }
: { stdout: '', stderr: '' }
)
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
mockUpstreamOnlyPRLookup()
const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledWith(forEachRefArgs, '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR' })
})
it('reads tracked upstreams through the local runtime for a WSL repository', async () => {
const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
mockUpstreamOnlyPRLookup()
gitExecFileAsyncMock.mockResolvedValue({
stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`,
stderr: ''
})
const pr = await getPRForBranch('/repo-root', MERGED_BRANCH, null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(forEachRefArgs, {
cwd: '/repo-root',
wslDistro: 'Ubuntu'
})
expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR' })
})
})
+10 -2
View File
@@ -131,10 +131,18 @@ export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMo
}
}
export type SshGitDispatchModuleMock = { getSshGitProvider: Mock }
export type SshGitDispatchModuleMock = {
getSshGitProviderGeneration: Mock
getSshGitProvider: Mock
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string
}
export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock {
return { getSshGitProvider: mocks.getSshGitProviderMock }
return {
getSshGitProviderGeneration: vi.fn(() => 0),
getSshGitProvider: mocks.getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
}
}
export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock }
+31 -15
View File
@@ -73,7 +73,10 @@ import {
isCommitPartOfMergedPR,
type MergedPRCommitMembership
} from './merged-pr-commit-membership'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import {
hasHostedReviewLocalGitOptions,
getHostedReviewLocalGitOptions,
@@ -2315,14 +2318,19 @@ async function getCurrentHeadOid(
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<string | null> {
const provider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
if (provider) {
const result = await provider.exec(['rev-parse', 'HEAD'], repoPath)
return result.stdout.trim() || null
}
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
? await provider.exec(['rev-parse', 'HEAD'], repoPath)
: await gitExecFileAsync(['rev-parse', 'HEAD'], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
const result = await gitExecFileAsync(['rev-parse', 'HEAD'], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return result.stdout.trim() || null
} catch {
return null
@@ -2814,14 +2822,22 @@ async function probeTrackedUpstreamBranches(
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}> {
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
const provider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
if (provider) {
const result = await provider.exec(args, repoPath)
return {
probeFailed: false,
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
}
}
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
? await provider.exec(args, repoPath)
: await gitExecFileAsync(args, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
const result = await gitExecFileAsync(args, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return {
probeFailed: false,
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
+1
View File
@@ -26,6 +26,7 @@ export {
} from './github-repository-identity'
export type {
GitHubRemoteIdentity,
GitHubRemoteIdentityProbeOptions,
GitHubRepoContext,
LocalGitExecOptions,
OwnerRepo
@@ -0,0 +1,25 @@
import type { LocalGitExecOptions } from './github-repository-identity'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
export function githubApiRepositoryProbeCacheKey(
repoPath: string,
remoteName: string,
connectionId: string | null | undefined,
localGitOptions: LocalGitExecOptions,
requireVerifiedSshProbe: boolean
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return `${runtimeKey}\0${repoPath}\0${remoteName}\0${requireVerifiedSshProbe ? 'verified' : 'tolerant'}`
}
export function resolveGitHubApiRepositoryProbe<T>(
value: T | undefined,
requireVerifiedSshProbe: boolean
): T | null {
if (value === undefined && requireVerifiedSshProbe) {
throw new Error('GitHub repository identity is unverifiable.')
}
return value ?? null
}
@@ -0,0 +1,20 @@
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
export type GitHubApiRepositoryResolution =
| GitHubOwnerRepo
| null
| undefined
| (() => Promise<GitHubOwnerRepo | null>)
// Why: renderer/RPC overrides reach authenticated REST paths.
const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
return (
OWNER_SLUG_RE.test(repository.owner) &&
REPOSITORY_SLUG_RE.test(repository.repo) &&
repository.repo !== '.' &&
repository.repo !== '..'
)
}
@@ -1,19 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitHubEnterpriseRepository from './github-enterprise-repository'
import type * as GhUtils from './gh-utils'
import type * as SshGitDispatch from '../providers/ssh-git-dispatch'
const {
getEnterpriseGitHubRepoSlugMock,
getOwnerRepoMock,
getOwnerRepoForRemoteMock,
getSshGitProviderGenerationMock,
isGitHubHostAuthenticatedMock
} = vi.hoisted(() => ({
getEnterpriseGitHubRepoSlugMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getOwnerRepoForRemoteMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
isGitHubHostAuthenticatedMock: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({
...(await importOriginal<typeof SshGitDispatch>()),
getSshGitProviderGeneration: getSshGitProviderGenerationMock
}))
vi.mock('./gh-utils', async (importOriginal) => ({
...(await importOriginal<typeof GhUtils>()),
getOwnerRepo: getOwnerRepoMock,
@@ -29,6 +37,7 @@ vi.mock('./github-enterprise-repository', async (importOriginal) => ({
import {
_resetOriginGitHubApiRepositoryCache,
getGitHubApiRepositoryForRemote,
getOriginGitHubApiRepository,
githubHostExecOptions,
resolveGitHubApiRepository,
@@ -40,6 +49,7 @@ beforeEach(() => {
getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null)
getOwnerRepoMock.mockReset().mockResolvedValue(null)
getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null)
getSshGitProviderGenerationMock.mockReset().mockReturnValue(0)
isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false)
})
@@ -182,6 +192,62 @@ describe('resolveGitHubRepoExecution', () => {
})
describe('origin repository cache', () => {
it('isolates Enterprise identity across SSH provider generations', async () => {
const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }
const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' }
getEnterpriseGitHubRepoSlugMock
.mockResolvedValueOnce(beforeReconnect)
.mockResolvedValueOnce(afterReconnect)
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
beforeReconnect
)
getSshGitProviderGenerationMock.mockReturnValue(1)
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
afterReconnect
)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
})
it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => {
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined)
await expect(
getGitHubApiRepositoryForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).rejects.toThrow('GitHub repository identity is unverifiable.')
})
it('does not reuse a tolerant SSH miss for verified candidate discovery', async () => {
const enterprise = {
owner: 'acme',
repo: 'widgets',
host: 'github.acme-corp.com'
}
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(enterprise)
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toBeNull()
await expect(
getGitHubApiRepositoryForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).resolves.toEqual(enterprise)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
})
it('does not cache an indeterminate Enterprise auth probe', async () => {
const enterprise = {
owner: 'acme',
+50 -65
View File
@@ -8,6 +8,7 @@ import {
getOwnerRepoForRemote,
ghRepoExecOptions,
githubRepoContext,
type GitHubRemoteIdentityProbeOptions,
type LocalGitExecOptions
} from './gh-utils'
import {
@@ -15,7 +16,21 @@ import {
getEnterpriseGitHubRepoSlugForRemote,
isGitHubHostAuthenticated
} from './github-enterprise-repository'
import { githubHostExecOptions } from './github-repository-host'
import {
isValidGitHubApiRepository,
type GitHubApiRepositoryResolution
} from './github-api-repository-validation'
import {
githubApiRepositoryProbeCacheKey,
resolveGitHubApiRepositoryProbe
} from './github-api-repository-probe'
export {
githubHostExecOptions,
githubRepositorySlugArg,
githubRepositoryWebHost
} from './github-repository-host'
export type GitHubApiRepository = GitHubOwnerRepo
export type GitHubRepoExecOptions = ReturnType<typeof ghRepoExecOptions> & { host?: string }
export type GitHubRepoExecution = {
@@ -23,43 +38,12 @@ export type GitHubRepoExecution = {
ghOptions: GitHubRepoExecOptions
}
type GitHubApiRepositoryResolution =
| GitHubApiRepository
| null
| undefined
| (() => Promise<GitHubApiRepository | null>)
// Why: renderer/RPC repository overrides are interpolated into REST paths.
// Reject path syntax before an authenticated gh process can target it.
const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/
function isValidGitHubApiRepository(repository: GitHubApiRepository): boolean {
return (
GITHUB_OWNER_SLUG_RE.test(repository.owner) &&
GITHUB_REPO_SLUG_RE.test(repository.repo) &&
repository.repo !== '.' &&
repository.repo !== '..'
)
}
// Why: the enterprise branch spawns an uncached `git remote get-url` (an SSH
// round trip on connection-backed repos) — hot paths like per-file contents
// and viewed-state toggles resolve per call, so cache like ownerRepoCache does.
// Why: cache the uncached Enterprise remote probe used by hot paths.
const ORIGIN_REPO_CACHE_TTL_MS = 30_000
const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512
const originRepoCache = new Map<string, { value: GitHubApiRepository | null; expiresAt: number }>()
const originRepoInFlight = new Map<string, Promise<GitHubApiRepository | null>>()
function originRepoCacheKey(
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): string {
return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}`
}
/** @internal - exposed for tests only */
export function _resetOriginGitHubApiRepositoryCache(): void {
originRepoCache.clear()
@@ -90,15 +74,30 @@ export async function getGitHubApiRepositoryForRemote(
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
probeOptions: GitHubRemoteIdentityProbeOptions = {}
): Promise<GitHubApiRepository | null> {
// Why: generic PR resolution prefers upstream, but this API represents the
// caller-selected remote exactly (#7331).
const ownerRepo = await getOwnerRepoForRemote(repoPath, remoteName, connectionId, localGitOptions)
const requireVerifiedSshProbe = probeOptions.requireVerifiedSshProbe === true
const verifiedIdentityArgs = requireVerifiedSshProbe ? ([probeOptions] as const) : []
const ownerRepo = await getOwnerRepoForRemote(
repoPath,
remoteName,
connectionId,
localGitOptions,
...verifiedIdentityArgs
)
if (ownerRepo) {
return { ...ownerRepo, host: 'github.com' }
}
const cacheKey = originRepoCacheKey(repoPath, remoteName, connectionId, localGitOptions)
const cacheKey = githubApiRepositoryProbeCacheKey(
repoPath,
remoteName,
connectionId,
localGitOptions,
requireVerifiedSshProbe
)
const now = Date.now()
pruneOriginRepoCache(now)
const cached = originRepoCache.get(cacheKey)
@@ -112,14 +111,21 @@ export async function getGitHubApiRepositoryForRemote(
const probe = (async () => {
const enterpriseOptions =
Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}
const verifiedEnterpriseArgs = requireVerifiedSshProbe ? ([true] as const) : []
const slug =
remoteName === 'origin'
? await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions)
? await getEnterpriseGitHubRepoSlug(
repoPath,
connectionId,
enterpriseOptions,
...verifiedEnterpriseArgs
)
: await getEnterpriseGitHubRepoSlugForRemote(
repoPath,
remoteName,
connectionId,
enterpriseOptions
enterpriseOptions,
...verifiedEnterpriseArgs
)
// Why: undefined means the gh auth inventory could not be read. Caching it
// as a negative would turn a transient spawn failure into a 30-second miss.
@@ -130,7 +136,7 @@ export async function getGitHubApiRepositoryForRemote(
})
pruneOriginRepoCache(Date.now())
}
return slug ?? null
return resolveGitHubApiRepositoryProbe(slug, requireVerifiedSshProbe)
})()
originRepoInFlight.set(cacheKey, probe)
try {
@@ -180,8 +186,12 @@ export async function resolveGitHubApiRepositoryCandidates(
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepositoryCandidates> {
const [upstream, origin] = await Promise.all([
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions),
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
requireVerifiedSshProbe: true
}),
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions, {
requireVerifiedSshProbe: true
})
])
const seen = new Set<string>()
const candidates: GitHubApiRepository[] = []
@@ -292,15 +302,6 @@ export async function resolveGitHubApiRepository(
return null
}
// Why: the gh runner host-qualifies argv from `options.host`, so every known
// host must be carried through. Pinning github.com prevents a process-level
// GH_HOST from silently redirecting an otherwise unambiguous API request.
export function githubHostExecOptions(repository: GitHubApiRepository | null | undefined): {
host?: string
} {
return repository?.host ? { host: repository.host } : {}
}
export async function resolveGitHubRepoExecution(
repoPath: string,
repository?: GitHubApiRepositoryResolution,
@@ -329,19 +330,3 @@ export async function resolveGitHubRepoExecution(
}
}
}
export function githubRepositoryWebHost(repository: GitHubApiRepository): string {
return repository.host ?? 'github.com'
}
/**
* Positional `HOST/OWNER/REPO` argv value (e.g. `gh repo view <slug>`).
* Positional slugs bypass the runner's `--repo` qualifier, so they must be
* qualified here whenever the host is known.
*/
export function githubRepositorySlugArg(repository: GitHubApiRepository): string {
const slug = `${repository.owner}/${repository.repo}`
// Why: github.com must be explicit too; otherwise process-level GH_HOST can
// redirect positional `gh repo view OWNER/REPO` calls to an Enterprise host.
return repository.host ? `${repository.host}/${slug}` : slug
}
@@ -18,6 +18,11 @@ import {
} from './github-remote-identity-parsing'
import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution'
import { parseWslPath } from '../wsl'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error'
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
@@ -214,16 +219,26 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
repoPath: string,
remoteName: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
options: HostedReviewExecutionOptions = {},
requireVerifiedSshProbe = false
): Promise<GitHubEnterpriseRepoSlug | null | undefined> {
const localGitOptions = getHostedReviewLocalGitOptions(options)
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
if (requireVerifiedSshProbe && connectionId && !getSshGitProvider(connectionId)) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
let remoteUrl: string | null
try {
remoteUrl = await getRemoteUrlForRepo(context, remoteName)
} catch {
} catch (error) {
if (requireVerifiedSshProbe && connectionId && !isStableMissingGitRemoteError(error)) {
throw error
}
return null
}
if (requireVerifiedSshProbe && connectionId && !remoteUrl && !getSshGitProvider(connectionId)) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
if (!identity) {
return null
@@ -234,6 +249,9 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
if (aliasHost) {
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
if (!resolved || !hostname) {
if (requireVerifiedSshProbe && connectionId) {
throw new Error('Remote repository identity is unverifiable.')
}
const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost(
identity.host,
repoPath,
@@ -266,7 +284,14 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
export async function getEnterpriseGitHubRepoSlug(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
options: HostedReviewExecutionOptions = {},
requireVerifiedSshProbe = false
): Promise<GitHubEnterpriseRepoSlug | null | undefined> {
return getEnterpriseGitHubRepoSlugForRemote(repoPath, 'origin', connectionId, options)
return getEnterpriseGitHubRepoSlugForRemote(
repoPath,
'origin',
connectionId,
options,
requireVerifiedSshProbe
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
export function githubHostExecOptions(repository: GitHubOwnerRepo | null | undefined): {
host?: string
} {
return repository?.host ? { host: repository.host } : {}
}
export function githubRepositoryWebHost(repository: GitHubOwnerRepo): string {
return repository.host ?? 'github.com'
}
/** Host-qualified positional slug for commands that bypass the runner's `--repo`. */
export function githubRepositorySlugArg(repository: GitHubOwnerRepo): string {
const slug = `${repository.owner}/${repository.repo}`
// Why: pin dotcom too so process-level GH_HOST cannot redirect the request.
return repository.host ? `${repository.host}/${slug}` : slug
}
@@ -25,7 +25,8 @@ vi.mock('../git/runner', async (importOriginal) => ({
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: getSshGitProviderGenerationMock
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
}))
vi.mock('./local-git-config-signature', () => ({
@@ -174,6 +175,70 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => {
expect(resolveWithSshGMock).not.toHaveBeenCalled()
})
it('does not read an SSH repoPath locally when its provider is missing', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
await expect(
getOwnerRepoForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).rejects.toThrow('Remote connection dropped.')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('preserves a one-shot SSH remote failure through candidate discovery', async () => {
const failure = new Error('relay request failed')
const provider = {
exec: vi
.fn()
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce({ stdout: 'git@github.com:team/orca.git\n', stderr: '' })
}
getSshGitProviderMock.mockReturnValue(provider)
await expect(
getOwnerRepoForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).rejects.toBe(failure)
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(provider.exec).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('keeps failed SSH alias classification unverifiable', async () => {
const provider = sshProvider('')
getSshGitProviderMock.mockReturnValue(provider)
await expect(
getOwnerRepoForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).rejects.toThrow('Remote repository identity is unverifiable.')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('isolates the same alias across native and WSL runtimes', async () => {
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
commandExecFileAsyncMock.mockResolvedValueOnce({
+42 -5
View File
@@ -1,7 +1,11 @@
import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe'
import { readRemoteUrl } from '../git/remote-url-probe'
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import {
getSshGitProvider,
getSshGitProviderGeneration,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { readLocalGitConfigSignature } from './local-git-config-signature'
import {
parseGitHubOwnerRepo,
@@ -26,6 +30,10 @@ export type LocalGitExecOptions = {
wslDistro?: string
}
export type GitHubRemoteIdentityProbeOptions = {
requireVerifiedSshProbe?: boolean
}
export function githubRepoContext(
repoPath: string,
connectionId?: string | null,
@@ -108,9 +116,17 @@ export async function getOwnerRepoForRemote(
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
probeOptions: GitHubRemoteIdentityProbeOptions = {}
): Promise<OwnerRepo | null> {
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
if (
probeOptions.requireVerifiedSshProbe &&
context.connectionId &&
!getSshGitProvider(context.connectionId)
) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
const runtimeKey = context.connectionId
? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}`
: `local:${context.wslDistro ?? 'host'}`
@@ -145,8 +161,15 @@ export async function getOwnerRepoForRemote(
// for the same repo concurrently. Coalesce missing-remote probes — but only
// onto one young enough to still answer, so a wedged probe cannot pin the
// repo's identity for the life of the process (P1-D).
return runCoalescedProbe(ownerRepoInFlight, cacheKey, () =>
resolveOwnerRepoForRemote(context, remoteName, cacheKey, nextConfigSignature)
const inFlightKey = `${cacheKey}\0${probeOptions.requireVerifiedSshProbe ? 'verified' : 'tolerant'}`
return runCoalescedProbe(ownerRepoInFlight, inFlightKey, () =>
resolveOwnerRepoForRemote(
context,
remoteName,
cacheKey,
nextConfigSignature,
probeOptions.requireVerifiedSshProbe === true
)
)
}
@@ -154,12 +177,20 @@ async function resolveOwnerRepoForRemote(
context: GitHubRepoContext,
remoteName: string,
cacheKey: string,
configSignature?: string
configSignature: string | undefined,
requireVerifiedSshProbe: boolean
): Promise<OwnerRepo | null> {
const now = Date.now()
try {
const remoteUrl = await getRemoteUrlForRepo(context, remoteName)
if (!remoteUrl) {
if (
requireVerifiedSshProbe &&
context.connectionId &&
!getSshGitProvider(context.connectionId)
) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
// Empty remote URL is stable until git config changes.
ownerRepoCache.set(cacheKey, {
value: null,
@@ -181,6 +212,9 @@ async function resolveOwnerRepoForRemote(
}
if (classification.kind === 'indeterminate') {
// Why: a failed ssh -G probe is not a stable "not GitHub" result.
if (requireVerifiedSshProbe && context.connectionId) {
throw new Error('Remote repository identity is unverifiable.')
}
return null
}
const stableConfigSignature = classification.cacheWithGitConfigSignature
@@ -197,6 +231,9 @@ async function resolveOwnerRepoForRemote(
// Why: only stable "no such remote" misses are safe to hold for minutes.
// Transient git lock/IO failures must retry on the next lookup.
if (!isStableMissingGitRemoteError(error)) {
if (requireVerifiedSshProbe && context.connectionId) {
throw error
}
return null
}
}
@@ -153,7 +153,7 @@ describe('registerPtyHandlers', () => {
})
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('does not accept an incarnation-less exit as proof that the current PTY stopped', async () => {
it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => {
const exitListeners = new Set<
(payload: { id: string; code: number; incarnationId?: string }) => void
>()
@@ -203,7 +203,7 @@ describe('registerPtyHandlers', () => {
await controller.spawn({ cols: 80, rows: 24 })
await expect(controller.stopAndWait('local-incarnated')).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', -1, 'incarnation-live')
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', 0, 'incarnation-live')
})
it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => {
const localShutdown = vi.fn()
@@ -319,7 +319,9 @@ describe('registerPtyHandlers', () => {
kill: (ptyId: string) => boolean
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
// The lease is tombstoned, but nothing observed the detached relay PTY
// stop, so the stop itself is reported as unconfirmed.
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(false)
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
@@ -347,7 +349,7 @@ describe('registerPtyHandlers', () => {
kill: (ptyId: string) => boolean
}
expect(controller.kill('remote-pty')).toBe(true)
expect(controller.kill('remote-pty')).toBe(false)
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
@@ -356,14 +358,15 @@ describe('registerPtyHandlers', () => {
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
it('keeps a rejected SSH PTY unverifiable after kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
onPtyExit: vi.fn(),
markPtyLivenessUnverifiable: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
@@ -399,7 +402,7 @@ describe('registerPtyHandlers', () => {
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
retireRejectedPty: (ptyId: string) => void
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
}
try {
@@ -410,19 +413,23 @@ describe('registerPtyHandlers', () => {
'remote-pty',
'terminated'
)
controller.retireRejectedPty('remote-pty')
controller.retireRejectedPty('remote-pty', false)
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'remote-pty',
'a follow-up stop was issued but its outcome could not be verified'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
@@ -615,7 +615,7 @@ describe('registerPtyHandlers', () => {
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('splits the teardown budget so the liveness RPC gets only what shutdown left', async () => {
// Why: sequential RPCs must share one absolute deadline; otherwise both get
+23 -2
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { spawnMock } from './pty-ipc-mock-registry'
import { makeDeferred } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -102,11 +103,16 @@ describe('registerPtyHandlers', () => {
})
registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never)
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
const runtime = { setPtyController: vi.fn() }
setPtyOwnership('ssh-b-pty', 'ssh-b')
const runtime = {
setPtyController: vi.fn(),
markPtyLivenessUnverifiable: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
listProcesses(connectionId?: string | null): Promise<{ id: string }[]>
listProcessesWithHostScope(): Promise<{ processes: { id: string }[]; hostIds: string[] }>
}
await expect(controller.listProcesses(null)).resolves.toEqual([
@@ -120,7 +126,22 @@ describe('registerPtyHandlers', () => {
expect(sshAList).toHaveBeenCalledOnce()
expect(sshBList).not.toHaveBeenCalled()
await expect(controller.listProcesses()).rejects.toThrow('ssh-b unavailable')
await expect(controller.listProcesses()).resolves.toEqual([
{ id: 'local-pty', title: 'Local', cwd: '/local' },
{ id: 'ssh-a-pty' }
])
expect(sshBList).toHaveBeenCalledOnce()
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'ssh-b-pty',
'ssh-b unavailable'
)
await expect(controller.listProcessesWithHostScope()).resolves.toEqual({
processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }, { id: 'ssh-a-pty' }],
hostIds: [LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId('ssh-a')]
})
expect(sshBList).toHaveBeenCalledTimes(2)
deletePtyOwnership('ssh-b-pty')
})
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
registerSshPtyProvider('ssh-1', {} as never)
+297
View File
@@ -0,0 +1,297 @@
import { describe, expect, it, vi } from 'vitest'
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import {
registerPtyHandlers,
deletePtyOwnership,
setPtyOwnership,
getLocalPtyProvider,
registerSshPtyProvider,
unregisterSshPtyProvider
} from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
// A detached relay PTY is designed to outlive the provider that addressed it, so
// "the SSH provider is gone" is never evidence that the remote process stopped.
describe('stopping a PTY whose SSH provider is unregistered', () => {
const { handlers, mainWindow, installObservableDaemonTestProvider } = setupPtyIpcSuite()
function installController(): {
controller: {
kill: (ptyId: string) => boolean
listProcesses: (connectionId?: string | null) => Promise<{ id: string }[]>
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
stopAndWait: (ptyId: string, opts?: { deadlineMs?: number }) => Promise<boolean>
}
runtime: {
setPtyController: ReturnType<typeof vi.fn>
onPtyExit: ReturnType<typeof vi.fn>
markPtyLivenessUnverifiable: ReturnType<typeof vi.fn>
markPtyLivenessLive: ReturnType<typeof vi.fn>
}
} {
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn(),
markPtyLivenessUnverifiable: vi.fn(),
markPtyLivenessLive: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
return {
controller: runtime.setPtyController.mock.calls[0]?.[0] as never,
runtime
}
}
it('reports an unconfirmed stop instead of a fabricated kill', () => {
setPtyOwnership('ssh-detached-pty', 'ssh-dropped')
const { controller, runtime } = installController()
expect(controller.kill('ssh-detached-pty')).toBe(false)
// The local lease is still tombstoned so reconnect cannot revive the pane.
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-pty', -1, undefined)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'ssh-detached-pty',
expect.stringContaining('SSH')
)
deletePtyOwnership('ssh-detached-pty')
})
it('reports an unconfirmed exact stop instead of a fabricated teardown', async () => {
setPtyOwnership('ssh-detached-stop', 'ssh-dropped')
const { controller, runtime } = installController()
await expect(controller.stopAndWait('ssh-detached-stop')).resolves.toBe(false)
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-stop', -1, undefined)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'ssh-detached-stop',
expect.stringContaining('SSH')
)
deletePtyOwnership('ssh-detached-stop')
})
it('preserves lost-contact evidence for renderer IPC teardown', async () => {
const ptyId = 'ssh-renderer-detached'
setPtyOwnership(ptyId, 'ssh-dropped')
const { runtime } = installController()
try {
await handlers.get('pty:kill')!(null, { id: ptyId })
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
SSH_PROVIDER_UNREGISTERED_REASON
)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
} finally {
deletePtyOwnership(ptyId)
}
})
it('retires a rejected split without asserting an unconfirmed exit', () => {
const ptyId = 'ssh-rejected-split'
setPtyOwnership(ptyId, 'ssh-dropped')
const { controller, runtime } = installController()
try {
controller.retireRejectedPty(ptyId, false)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
'a follow-up stop was issued but its outcome could not be verified'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalledWith(ptyId, 0, expect.anything())
} finally {
deletePtyOwnership(ptyId)
}
})
it('still confirms a stop the owning provider actually performed', async () => {
const daemon = installObservableDaemonTestProvider()
vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockResolvedValue([])
const { controller, runtime } = installController()
await expect(controller.stopAndWait('wt-1@@local-session')).resolves.toBe(true)
expect(daemon.shutdown).toHaveBeenCalledWith(
'wt-1@@local-session',
expect.objectContaining({ immediate: true })
)
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
})
it('records provider-confirmed absence when the exit event was missed', async () => {
const connectionId = 'ssh-confirmed-absent'
const ptyId = 'ssh-confirmed-absent-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {}),
listProcesses: vi.fn(async () => [])
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, connectionId)
try {
const { controller, runtime } = installController()
await expect(controller.stopAndWait(ptyId)).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, 0, undefined)
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('reports lost contact when a registered provider drops during the stop', async () => {
const connectionId = 'ssh-mid-stop-drop'
const ptyId = 'ssh-mid-stop-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {
throw new Error('relay disconnected during stop')
})
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, connectionId)
try {
const { controller, runtime } = installController()
await expect(controller.stopAndWait(ptyId)).resolves.toBe(false)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
'relay disconnected during stop'
)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('preserves lost-contact evidence when fire-and-forget shutdown rejects', async () => {
const connectionId = 'ssh-async-kill-drop'
const ptyId = 'ssh-async-kill-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {
throw new Error('relay disconnected during kill')
})
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, connectionId)
try {
const { controller, runtime } = installController()
expect(controller.kill(ptyId)).toBe(true)
await vi.waitFor(() =>
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
'relay disconnected during kill'
)
)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('isolates a failed SSH inventory from healthy providers', async () => {
const failedConnectionId = 'ssh-inventory-failed'
const healthyConnectionId = 'ssh-inventory-healthy'
const failedPtyId = 'ssh-inventory-failed-pty'
const healthyPtyId = 'ssh-inventory-healthy-pty'
registerSshPtyProvider(failedConnectionId, {
listProcesses: vi.fn(async () => {
throw new Error('inventory transport failed')
})
} as never)
registerSshPtyProvider(healthyConnectionId, {
listProcesses: vi.fn(async () => [{ id: healthyPtyId }])
} as never)
setPtyOwnership(failedPtyId, failedConnectionId)
setPtyOwnership(healthyPtyId, healthyConnectionId)
try {
const { controller, runtime } = installController()
await expect(controller.listProcesses()).resolves.toEqual(
expect.arrayContaining([{ id: healthyPtyId }])
)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
failedPtyId,
'inventory transport failed'
)
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalledWith(
healthyPtyId,
expect.anything()
)
} finally {
deletePtyOwnership(failedPtyId)
deletePtyOwnership(healthyPtyId)
unregisterSshPtyProvider(failedConnectionId)
unregisterSshPtyProvider(healthyConnectionId)
}
})
it('reports a provider-observed survivor as live', async () => {
const connectionId = 'ssh-still-live'
const ptyId = 'ssh-still-live-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {}),
listProcesses: vi.fn(async () => [{ id: ptyId }])
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, connectionId)
try {
const { controller, runtime } = installController()
await expect(controller.stopAndWait(ptyId)).resolves.toBe(false)
expect(runtime.markPtyLivenessLive).toHaveBeenCalledWith(ptyId)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
})
+103 -22
View File
@@ -15,6 +15,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { PtyBindingSourceExpectation, Store } from '../persistence'
import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement'
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { TuiAgent } from '../../shared/tui-agent'
import {
@@ -266,19 +267,36 @@ function registeredPtyProviders(): RegisteredPtyProvider[] {
]
}
async function listRegisteredPtyProcessesWithHostScope(): Promise<{
async function listRegisteredPtyProcessesWithHostScope(
onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void
): Promise<{
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}> {
const providers = registeredPtyProviders()
const providerSessions = await Promise.all(
providers.map(({ provider }) => provider.listProcesses())
providers.map(async ({ provider, connectionId }) => {
try {
const hostId: ExecutionHostId = connectionId
? toSshExecutionHostId(connectionId)
: LOCAL_EXECUTION_HOST_ID
return {
processes: await provider.listProcesses(),
hostId
}
} catch (error) {
if (!connectionId) {
throw error
}
onSshInventoryUnavailable?.(connectionId, error)
return null
}
})
)
const respondingSessions = providerSessions.filter((session) => session !== null)
return {
processes: providerSessions.flat(),
hostIds: providers.map(({ connectionId }) =>
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
)
processes: respondingSessions.flatMap((session) => session.processes),
hostIds: respondingSessions.map((session) => session.hostId)
}
}
@@ -3932,7 +3950,9 @@ export function registerPtyHandlers(
return release
},
finalizeExit: (event) => {
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, {
hostExitConfirmed: true
})
finalizePtyExitForRenderer(event)
},
pauseProvider: (generation, id) => {
@@ -4467,6 +4487,15 @@ export function registerPtyHandlers(
}
}
const markSshInventoryUnverifiable = (connectionId: string, error: unknown): void => {
const reason = error instanceof Error ? error.message : String(error)
for (const [ptyId, ownerConnectionId] of ptyOwnership) {
if (ownerConnectionId === connectionId) {
runtime?.markPtyLivenessUnverifiable?.(ptyId, reason)
}
}
}
// Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them.
runtime?.setPtyController({
claimStablePaneCreate: (args) => {
@@ -4764,7 +4793,8 @@ export function registerPtyHandlers(
rows: args.rows,
cwd,
env,
...(isNewDaemonSession ? { isNewSession: true } : {})
...(isNewDaemonSession ? { isNewSession: true } : {}),
historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true
}
if (!args.connectionId && !isDaemonHostSpawn) {
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
@@ -5552,12 +5582,15 @@ export function registerPtyHandlers(
if (connectionId) {
// Why: runtime/CLI close can target a detached SSH PTY after its
// provider was unregistered. Tombstone the lease so reconnect does
// not revive a terminal the user explicitly closed.
// not revive a terminal the user explicitly closed — but a detached
// relay PTY outlives its provider, so report an unconfirmed stop
// rather than a kill nobody performed.
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON)
return false
}
return false
}
@@ -5589,6 +5622,12 @@ export function registerPtyHandlers(
// Why: close runtime tails without clearing provider ownership, so
// a retry can still target a PTY that survived the failed shutdown.
if (!retired) {
if (connectionId) {
runtime?.markPtyLivenessUnverifiable?.(
ptyId,
err instanceof Error ? err.message : String(err)
)
}
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
@@ -5602,6 +5641,12 @@ export function registerPtyHandlers(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
if (!retiredRejectedPtyIds.has(ptyId)) {
if (connectionId) {
runtime?.markPtyLivenessUnverifiable?.(
ptyId,
err instanceof Error ? err.message : String(err)
)
}
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
@@ -5609,11 +5654,23 @@ export function registerPtyHandlers(
}
return killWithCurrentProvider()
},
retireRejectedPty: (ptyId) => {
retireRejectedPty: (ptyId, stopConfirmed) => {
rememberRetiredRejectedPty(ptyId)
if (!stopConfirmed) {
runtime?.markPtyLivenessUnverifiable?.(
ptyId,
'a follow-up stop was issued but its outcome could not be verified'
)
if (!ptyOwnership.has(ptyId)) {
return
}
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return
}
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
// renderer; repeating that double-fires the exit IPC.
if (!ptyOwnership.has(ptyId)) {
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
return
@@ -5682,13 +5739,14 @@ export function registerPtyHandlers(
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: an absent SSH provider means there is no live target left to
// await, but the relay lease must still be tombstoned.
// Why: the relay lease must still be tombstoned, but an absent SSH
// provider is lost contact — the remote PTY is designed to survive it,
// so nothing here observed an exit to report as a confirmed stop.
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON)
}
return false
}
@@ -5701,6 +5759,12 @@ export function registerPtyHandlers(
})
} catch (err) {
if (!isPtyAlreadyGoneError(err)) {
if (connectionId) {
runtime?.markPtyLivenessUnverifiable?.(
ptyId,
err instanceof Error ? err.message : String(err)
)
}
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
@@ -5709,9 +5773,16 @@ export function registerPtyHandlers(
}
try {
if (!(await verifyPtyStopped(provider, ptyId, opts))) {
runtime?.markPtyLivenessLive?.(ptyId)
return false
}
} catch (err) {
if (connectionId) {
runtime?.markPtyLivenessUnverifiable?.(
ptyId,
err instanceof Error ? err.message : String(err)
)
}
console.warn(
`[pty] Failed to verify PTY ${ptyId} stopped: ${
err instanceof Error ? err.message : String(err)
@@ -5721,9 +5792,11 @@ export function registerPtyHandlers(
}
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
// The owning provider's fresh inventory observed absence, so this is a
// death certificate even when its exit event was missed.
runtime?.onPtyExit(ptyId, 0, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
sendPtyExitToRenderer({ id: ptyId, code: 0 })
}
return true
},
@@ -5780,11 +5853,17 @@ export function registerPtyHandlers(
return localProvider.listProcesses()
}
if (connectionId !== undefined) {
return getProvider(connectionId).listProcesses()
try {
return await getProvider(connectionId).listProcesses()
} catch (error) {
markSshInventoryUnverifiable(connectionId, error)
throw error
}
}
return (await listRegisteredPtyProcessesWithHostScope()).processes
return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable)).processes
},
listProcessesWithHostScope: listRegisteredPtyProcessesWithHostScope,
listProcessesWithHostScope: () =>
listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable),
serializeBuffer: (ptyId, opts) => {
// Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly.
return requestSerializedBuffer(ptyId, opts)
@@ -6493,7 +6572,8 @@ export function registerPtyHandlers(
cwd,
...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}),
env: spawnEnv,
...(isMintedSessionId ? { isNewSession: true } : {})
...(isMintedSessionId ? { isNewSession: true } : {}),
historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true
}
if (!args.connectionId && !isDaemonHostSpawn) {
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
@@ -7628,6 +7708,7 @@ export function registerPtyHandlers(
// provider is unregistered; hydrated app-scoped ids can also arrive
// before ownership is rebuilt. Tombstone instead of falling back local.
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON)
runtime?.onPtyExit(args.id, -1, incarnationId)
rememberSyntheticKillExit(args.id)
sendPtyExitToRenderer({ id: args.id, code: -1 })
+25 -3
View File
@@ -265,6 +265,7 @@ import {
removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval,
recoverLocalWindowsWorktreeRemoval
} from '../local-worktree-removal-recovery'
import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup'
const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable()
const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable()
@@ -2493,15 +2494,15 @@ export function registerWorktreeHandlers(
'Cannot delete the project root workspace. Remove the folder project instead.'
)
}
const ownerHost = parseExecutionHostId(removalHostId)
const sshPtyProvider =
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
// Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata.
await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => {
// Folder projects can be SSH-backed, so fence the sweep to the owning host exactly
// like the git paths — the local inventory must never reach a remote workspace's id.
// The resolved repo is authoritative here: path-derived metadata is shared by
// same-id host copies and can describe a different owner's workspace.
const ownerHost = parseExecutionHostId(removalHostId)
const sshPtyProvider =
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime'
await killAllProcessesForWorktree(args.worktreeId, {
runtime,
@@ -2524,6 +2525,7 @@ export function registerWorktreeHandlers(
})
})
await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => {
await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId)
removeWorktreeMetadataAndTransientState(
store,
args.worktreeId,
@@ -2612,6 +2614,14 @@ export function registerWorktreeHandlers(
} finally {
await removalGate.finish(removalCompleted)
}
// Why history first: the worktree is already gone from git and
// disk by here, so a rejecting push-target cleanup must not be
// able to skip history removal and leave the user's commands on
// the remote host.
await deleteRemoteWorktreeHistory(
getSshPtyProvider(repo.connectionId),
args.worktreeId
)
await cleanupUnusedWorktreePushTargetRemoteSsh(
provider!,
repo.path,
@@ -2720,6 +2730,14 @@ export function registerWorktreeHandlers(
}
// Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed.
if (repo.connectionId) {
// Why history first: the worktree is already gone from git and
// disk by here, so a rejecting push-target cleanup must not be
// able to skip history removal and leave the user's commands on
// the remote host.
await deleteRemoteWorktreeHistory(
getSshPtyProvider(repo.connectionId),
args.worktreeId
)
await cleanupUnusedWorktreePushTargetRemoteSsh(
provider!,
repo.path,
@@ -2898,6 +2916,10 @@ export function registerWorktreeHandlers(
removedPushTarget,
store
)
await deleteRemoteWorktreeHistory(
getSshPtyProvider(remoteConnectionId),
args.worktreeId
)
rememberPreservedBranchCleanupTarget(
args.worktreeId,
removalHostId,
+15 -4
View File
@@ -19,8 +19,10 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
import {
injectHistoryEnv,
updateHistFileForFallback,
logHistoryInjection
injectWslFishHistoryEnv,
updateHistoryEnvForFallback,
logHistoryInjection,
type HistoryInjectionResult
} from '../terminal-history'
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import {
@@ -872,7 +874,15 @@ export class LocalPtyProvider implements IPtyProvider {
historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, {
wslDistro: launchWslDistro
})
if (isWslTerminal && launchWslDistro) {
injectWslFishHistoryEnv(finalEnv, worktreeId, launchWslDistro)
addWslEnvKeys(finalEnv, ['HISTFILE', 'fish_history'])
}
logHistoryInjection(worktreeId, historyResult)
} else {
// Why: injectHistoryEnv is what normally clears it, so when history is off
// an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146.
delete finalEnv.ORCA_HISTFILE
}
await prepareLocalPtySpawn(id)
@@ -895,8 +905,9 @@ export class LocalPtyProvider implements IPtyProvider {
ptySpawn: pty.spawn,
getShellReadyConfig: getFallbackShellReadyConfig,
// Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8).
onBeforeFallbackSpawn: historyResult?.histFile
? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell)
onBeforeFallbackSpawn: historyResult?.historyDir
? (env, fallbackShell) =>
updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult)
: undefined,
windowsFallbackAttempts
})
@@ -11,7 +11,8 @@ import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
getZshStartupFileSourceBlock
getZshStartupFileSourceBlock,
ZSH_HISTFILE_RESTORE_BLOCK
} from '../shell-templates'
import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile'
import {
@@ -44,6 +45,7 @@ if [[ ! -o login ]]; then
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${ZSH_HISTFILE_RESTORE_BLOCK}
${getPosixCodexShellLaunchPreflight()}
fi
__orca_osc133_precmd() {
@@ -96,6 +98,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${ZSH_HISTFILE_RESTORE_BLOCK}
${getPosixCodexShellLaunchPreflight()}
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
${getZshFinalZdotdirRestoreBlock()}
@@ -70,6 +70,8 @@ export type PtySpawnOptions = {
* Existing-session attach paths must stay false so recovery checks do not
* replace the daemon out from under a still-live PTY. */
isNewSession?: boolean
/** Host setting forwarded additively to the process owner; old owners ignore it. */
historyIsolationEnabled?: boolean
/** Attach the named session atomically or fail without creating a process. */
attachOnly?: boolean
/** Exact persisted owner expected by an attach-only routing decision. */
@@ -119,6 +121,8 @@ export type IPtyProvider = {
/** Re-probes a degraded durable host before main commits to fallback spawn semantics. */
recoverFreshSpawnRouting?: () => Promise<boolean>
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
/** Process-owner cleanup for history stored outside the workspace tree. */
deleteWorktreeHistory?: (worktreeId: string) => Promise<void>
/** Whether this spawn target can append the Git guard after its final env merge. */
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean
/** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */
+7 -3
View File
@@ -370,14 +370,16 @@ describe('SshPtyProvider', () => {
})
})
it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
it('forwards shell, WSL, and scoped-history options to the relay mux', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
shellOverride: 'powershell.exe',
terminalWindowsWslDistro: 'Ubuntu'
terminalWindowsWslDistro: 'Ubuntu',
worktreeId: 'repo-1::/remote/wt',
historyIsolationEnabled: true
})
expectRequest(mux.request, 'pty.spawn', {
@@ -386,7 +388,9 @@ describe('SshPtyProvider', () => {
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
shellOverride: 'powershell.exe',
terminalWindowsWslDistro: 'Ubuntu'
terminalWindowsWslDistro: 'Ubuntu',
worktreeId: 'repo-1::/remote/wt',
historyIsolationEnabled: true
})
})
+7 -8
View File
@@ -27,6 +27,7 @@ import type { SshPtyLiveEvidence } from './ssh-pty-liveness-state'
import { listSshPtyProcessesWithLiveEvidence } from './ssh-pty-process-list-liveness'
import { attachSshPtyWithLiveEvidence } from './ssh-pty-attach-liveness'
import { shutdownSshPty } from './ssh-pty-shutdown'
import { requestSshPtyDefaultShell, requestSshPtyProfiles } from './ssh-pty-shell-metadata'
/** Remote PTY provider that proxies IPtyProvider operations through the relay. */
export class SshPtyProvider implements IPtyProvider {
@@ -136,6 +137,10 @@ export class SshPtyProvider implements IPtyProvider {
})
}
async deleteWorktreeHistory(worktreeId: string): Promise<void> {
await this.mux.request('pty.deleteWorktreeHistory', { worktreeId })
}
async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
return await this.agentSessionCapabilities.supportsClaims(options)
}
@@ -316,15 +321,9 @@ export class SshPtyProvider implements IPtyProvider {
this.acceptExitedPtyLiveness(id)
}
async getDefaultShell(): Promise<string> {
const result = await this.mux.request('pty.getDefaultShell')
return result as string
}
getDefaultShell = (): Promise<string> => requestSshPtyDefaultShell(this.mux)
async getProfiles(): Promise<{ name: string; path: string }[]> {
const result = await this.mux.request('pty.getProfiles')
return result as { name: string; path: string }[]
}
getProfiles = (): Promise<{ name: string; path: string }[]> => requestSshPtyProfiles(this.mux)
onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback)
onRejectedData = (callback: SshPtyDataCallback): (() => void) =>
@@ -0,0 +1,11 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
export async function requestSshPtyDefaultShell(mux: SshChannelMultiplexer): Promise<string> {
return (await mux.request('pty.getDefaultShell')) as string
}
export async function requestSshPtyProfiles(
mux: SshChannelMultiplexer
): Promise<{ name: string; path: string }[]> {
return (await mux.request('pty.getProfiles')) as { name: string; path: string }[]
}
@@ -22,6 +22,10 @@ export function buildSshPtySpawnRequest(args: {
// Why: the relay needs launch identity for plugin env overlays and provider-side delivery.
...(options.command ? { command: options.command } : {}),
...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
...(options.worktreeId ? { worktreeId: options.worktreeId } : {}),
...(options.historyIsolationEnabled !== undefined
? { historyIsolationEnabled: options.historyIsolationEnabled }
: {}),
...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}),
...(options.terminalWindowsWslDistro !== undefined
? { terminalWindowsWslDistro: options.terminalWindowsWslDistro }
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { deleteRemoteWorktreeHistory } from './remote-worktree-history-cleanup'
describe('deleteRemoteWorktreeHistory', () => {
it('repeats idempotent cleanup through the PTY owner', async () => {
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
const provider = { deleteWorktreeHistory } as never
await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
expect(deleteWorktreeHistory).toHaveBeenCalledTimes(2)
})
it('degrades safely when an old relay does not expose cleanup', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const provider = {
deleteWorktreeHistory: vi.fn().mockRejectedValue(new Error('unknown request method'))
} as never
try {
await expect(
deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
).resolves.toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remote cleanup unavailable'))
} finally {
warn.mockRestore()
}
})
})
@@ -0,0 +1,15 @@
import type { IPtyProvider } from './providers/pty-provider-contract'
/** Best-effort cleanup through the PTY owner; older relays may not expose the method. */
export async function deleteRemoteWorktreeHistory(
provider: IPtyProvider | undefined,
worktreeId: string
): Promise<void> {
try {
await provider?.deleteWorktreeHistory?.(worktreeId)
} catch (error) {
console.warn(
`[pty:history] Remote cleanup unavailable: ${error instanceof Error ? error.message : String(error)}`
)
}
}
@@ -0,0 +1,683 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection'
import { OrcaRuntimeService } from './orca-runtime'
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
const WORKTREE_PATH = '/tmp/worktree-a'
vi.mock('../git/worktree', () => ({
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/prompt-verification',
isBare: false,
isMainWorktree: false
}
]),
listWorktreesStrict: vi.fn().mockResolvedValue([
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/prompt-verification',
isBare: false,
isMainWorktree: false
}
])
}))
async function createPromptRuntime(
onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void
): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> {
const runtime = new OrcaRuntimeService(makeStore() as never)
const writes: string[] = []
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
write: (_ptyId, data) => {
writes.push(data)
onWrite(runtime, data, writes.length)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, {
launchAgent: 'aider'
})
return { runtime, handle: terminal.handle, writes }
}
describe('agent prompt submission runtime', () => {
afterEach(() => vi.useRealTimers())
it('submits exactly once after an observed lifecycle transition', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('accepts a working-to-idle cycle completed before the first poll', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('reports redraw-only activity as stalled without retrying Enter', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now())
} else if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('reports a neutral title transition as stalled without retrying Enter', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;plain shell\x07', Date.now())
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
}
})
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('does not send Enter after a permission state appears', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes).not.toContain('\r')
})
it('does not paste into an existing permission prompt', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('does not paste into an output-only permission prompt', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
vi.setSystemTime(2_000)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('does not paste into a coalesced live permission title', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"working","agentType":"aider"}\x07' +
'\x1b]0;Codex waiting for permission\x07',
Date.now()
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('does not paste when split status stripping completes a permission title', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
runtime.onPtyData(
'pty-prompt',
'\x1b]0;Codex waiting for permission\x1b]9999;{"state":"working","agentType":"aider"',
Date.now()
)
runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now())
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes).toEqual([])
})
it('preserves hook permission after an earlier live idle title', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"waiting","agentType":"aider"}\x07',
Date.now()
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('does not block on permission text restored only as history', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
runtime.seedTerminalRestoreTail('pty-prompt', {
text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n'
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('does not send Enter after output-only permission appears during settlement', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
vi.setSystemTime(2_000)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
}
})
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes).not.toContain('\r')
})
it.each([
'\x1b]0;Codex waiting for permission\x07\x1b]0;Codex idle\x07',
'\x1b]9999;{"state":"working","agentType":"aider"}\x07' +
'\x1b]0;Codex waiting for permission\x07',
'\x1b]0;Codex waiting for permission\x07' +
'\x1b]9999;{"state":"working","agentType":"aider"}\x07'
])('does not send Enter after coalesced permission activity', async (output) => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
runtime.onPtyData('pty-prompt', output, Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes).not.toContain('\r')
})
it('stops a chunked paste when permission appears between chunks', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
}
})
await expect(submission).rejects.toThrow('agent_prompt_blocked')
expect(writes).toHaveLength(2)
expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).not.toContain('\r')
})
it('stops a chunked paste after transient output-only permission', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData('pty-prompt', 'initial output\n', Date.now())
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
}
})
await expect(submission).rejects.toThrow('agent_prompt_blocked')
expect(writes).toHaveLength(2)
expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).not.toContain('\r')
})
it('prefers a later permission title over an earlier explicit idle status', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"done","agentType":"aider"}\x07',
Date.now()
)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('prefers later explicit idle evidence over stale permission output', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
let handle = ''
const writes: string[] = []
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'prompt-pane',
terminalHandle: handle,
state: 'done',
prompt: '',
agentType: 'aider',
connectionId: null,
receivedAt: Date.now(),
stateStartedAt: Date.now()
}
]
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
write: (_ptyId, data) => {
writes.push(data)
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
handle = (await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' }))
.handle
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n' +
'\x1b]0;Codex waiting for permission\x07',
Date.now()
)
vi.setSystemTime(2_000)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('prefers a later working title over an earlier explicit idle status', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"done","agentType":"aider"}\x07',
Date.now()
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('does not treat an unchanged newer working status as submission evidence', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"working","agentType":"aider"}\x07',
Date.now()
)
}
})
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
vi.setSystemTime(2_000)
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"working","agentType":"aider"}\x07',
Date.now()
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('does not write Enter after the PTY generation changes during settlement', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('terminal_handle_stale')
await vi.advanceTimersByTimeAsync(0)
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-prompt')
)
await vi.runAllTimersAsync()
await rejected
expect(writes).not.toContain('\r')
})
it('does not reuse explicit permission status across a provider generation reset', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"waiting","agentType":"aider"}\x07',
Date.now()
)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
0
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(0)
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
controller.abort()
await vi.runAllTimersAsync()
await rejected
})
it('does not reuse output-only permission across a provider generation reset', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
sequenceAtSpawnStart
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('fails closed when new bytes race a reset after old permission output', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now())
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
sequenceAtSpawnStart
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('reports permission reached after the first Enter as blocked', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('serializes concurrent prompt submissions within one PTY generation', async () => {
vi.useFakeTimers()
let enterCount = 0
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
enterCount += 1
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt')
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt')
await vi.runAllTimersAsync()
await Promise.all([first, second])
const firstPaste = writes.findIndex((data) => data.includes('first prompt'))
const firstEnter = writes.indexOf('\r', firstPaste + 1)
const secondPaste = writes.findIndex((data) => data.includes('second prompt'))
const secondEnter = writes.indexOf('\r', secondPaste + 1)
expect(firstPaste).toBeGreaterThanOrEqual(0)
expect(firstEnter).toBeGreaterThan(firstPaste)
expect(secondPaste).toBeGreaterThan(firstEnter)
expect(secondEnter).toBeGreaterThan(secondPaste)
expect(enterCount).toBe(2)
})
it('does not queue a replacement generation behind an obsolete submission', async () => {
vi.useFakeTimers()
let releaseFirst!: () => void
let firstWriteReached!: () => void
const firstWrite = new Promise<void>((resolve) => {
firstWriteReached = resolve
})
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', {
beforeWrite: async () => {
firstWriteReached()
await firstGate
}
})
await firstWrite
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
0
)
const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt')
await vi.runAllTimersAsync()
await expect(replacement).resolves.toMatchObject({ accepted: true })
expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true)
releaseFirst()
await expect(first).rejects.toThrow('terminal_handle_stale')
})
it('does not close a partial paste after the PTY generation changes', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-prompt')
)
}
}
})
await expect(submission).rejects.toThrow('terminal_handle_stale')
expect(writes).toHaveLength(1)
expect(writes[0]).not.toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
})
it('does not send delayed Enter after cancellation during settlement', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(0)
})
it('does not send another Enter after cancellation during verification', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(500)
controller.abort()
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
})
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROMPT_EFFECT_TIMEOUT_MS,
type AgentPromptActivity,
verifyAgentPromptSubmission
} from './agent-prompt-submission-verification'
function activity(overrides: Partial<AgentPromptActivity> = {}): AgentPromptActivity {
return {
generation: 1,
permissionSequence: 2,
workingSequence: 4,
status: 'idle',
...overrides
}
}
describe('agent prompt submission verification', () => {
afterEach(() => vi.useRealTimers())
it('accepts an observed working transition', async () => {
vi.useFakeTimers()
let current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
current = activity({ workingSequence: 5, status: 'working' })
await vi.advanceTimersByTimeAsync(50)
await expect(verification).resolves.toBeUndefined()
})
it('accepts a completed lifecycle transition between polls', async () => {
vi.useFakeTimers()
let current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
current = activity({ workingSequence: 5 })
await vi.advanceTimersByTimeAsync(50)
await expect(verification).resolves.toBeUndefined()
})
it('does not accept an unrelated transition to a neutral title', async () => {
vi.useFakeTimers()
let current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled')
current = activity({ status: null })
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
await rejected
})
it('reports stalled when no lifecycle transition occurs', async () => {
vi.useFakeTimers()
const current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
await rejected
})
it('blocks when permission appears after submit', async () => {
vi.useFakeTimers()
let current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked')
current = activity({ status: 'permission' })
await vi.advanceTimersByTimeAsync(50)
await rejected
})
it('blocks when permission appears and clears between polls', async () => {
vi.useFakeTimers()
let current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked')
current = activity({ permissionSequence: 3 })
await vi.advanceTimersByTimeAsync(50)
await rejected
})
it('rejects an existing permission state', async () => {
const current = activity({ status: 'permission' })
await expect(
verifyAgentPromptSubmission({ baseline: current, readActivity: () => current })
).rejects.toThrow('agent_prompt_blocked')
})
it('does not accept an unchanged working baseline', async () => {
vi.useFakeTimers()
const current = activity({ status: 'working' })
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current
})
const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
await rejected
})
it('rejects a replaced terminal generation', async () => {
const baseline = activity()
await expect(
verifyAgentPromptSubmission({
baseline,
readActivity: () => activity({ generation: 2 })
})
).rejects.toThrow('terminal_handle_stale')
})
it('cancels while waiting for activity', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const current = activity()
const verification = verifyAgentPromptSubmission({
baseline: current,
readActivity: () => current,
signal: controller.signal
})
controller.abort()
await expect(verification).rejects.toThrow('request_aborted')
})
})
@@ -0,0 +1,90 @@
export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000
const AGENT_PROMPT_EFFECT_POLL_MS = 50
export type AgentPromptActivity = Readonly<{
generation: number
permissionSequence: number
workingSequence: number
status: 'working' | 'permission' | 'idle' | null
}>
type AgentPromptVerificationOptions = {
baseline: AgentPromptActivity
readActivity: () => AgentPromptActivity
signal?: AbortSignal
}
export async function verifyAgentPromptSubmission(
options: AgentPromptVerificationOptions
): Promise<void> {
throwIfAgentPromptAborted(options.signal)
assertPromptNotBlocked(options.baseline, options.baseline)
const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS
while (Date.now() < deadline) {
const current = options.readActivity()
assertSamePromptGeneration(options.baseline, current)
assertPromptNotBlocked(options.baseline, current)
if (agentPromptLifecycleChanged(options.baseline, current)) {
return
}
await waitForAgentPromptPoll(options.signal)
}
const current = options.readActivity()
assertSamePromptGeneration(options.baseline, current)
assertPromptNotBlocked(options.baseline, current)
if (agentPromptLifecycleChanged(options.baseline, current)) {
return
}
throw new Error('agent_prompt_stalled')
}
function agentPromptLifecycleChanged(
baseline: AgentPromptActivity,
current: AgentPromptActivity
): boolean {
return current.workingSequence > baseline.workingSequence
}
function assertSamePromptGeneration(
baseline: AgentPromptActivity,
current: AgentPromptActivity
): void {
if (current.generation !== baseline.generation) {
throw new Error('terminal_handle_stale')
}
}
function assertPromptNotBlocked(baseline: AgentPromptActivity, current: AgentPromptActivity): void {
if (current.status === 'permission' || current.permissionSequence > baseline.permissionSequence) {
throw new Error('agent_prompt_blocked')
}
}
function throwIfAgentPromptAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('request_aborted')
}
}
async function waitForAgentPromptPoll(signal?: AbortSignal): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_EFFECT_POLL_MS))
return
}
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(new Error('request_aborted'))
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, AGENT_PROMPT_EFFECT_POLL_MS)
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
}
})
}
@@ -189,7 +189,7 @@ describe('ClaudeAgentTeamsService', () => {
expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-2')
})
it('keeps the placeholder handle when the respawn split fails', async () => {
it('removes the pane when replacement split fails after a confirmed placeholder stop', async () => {
const { service, teamId, token, leaderPane, api } = createServiceWithLeader()
const request = (argv: string[], envPane = leaderPane) =>
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
@@ -212,10 +212,59 @@ describe('ClaudeAgentTeamsService', () => {
request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a'])
).resolves.toMatchObject({ ok: false, exitCode: 1 })
// the placeholder terminal is left intact and the fake pane id still resolves.
expect(api.closeTerminal).not.toHaveBeenCalled()
await request(['kill-pane', '-t', '%2'])
expect(api.closeTerminal).toHaveBeenCalledWith('teammate-1')
await expect(
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
).resolves.toMatchObject({ stdout: '%1\n' })
})
it('keeps a pane registered when its process stop is unconfirmed', async () => {
const { service, teamId, token, leaderPane, api } = createServiceWithLeader()
const request = (argv: string[], envPane = leaderPane) =>
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
vi.mocked(api.closeTerminal).mockResolvedValueOnce({
handle: 'teammate-1',
tabId: 'tab-1',
ptyKilled: false
})
await expect(request(['kill-pane', '-t', '%2'])).resolves.toMatchObject({
ok: false,
exitCode: 1
})
await expect(
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
).resolves.toMatchObject({ stdout: '%1\n%2\n' })
})
it('does not launch a replacement when the placeholder stop is unconfirmed', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[], envPane = leaderPane) =>
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}', 'cat'])
vi.mocked(api.closeTerminal).mockResolvedValueOnce({
handle: 'teammate-1',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'live'
})
await expect(
request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a'])
).resolves.toMatchObject({ ok: false, exitCode: 1 })
expect(api.closeTerminal).toHaveBeenNthCalledWith(1, 'teammate-1')
expect(splitCalls).toHaveLength(1)
await expect(
request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a'])
).resolves.toMatchObject({ ok: false, exitCode: 1 })
expect(splitCalls).toHaveLength(1)
await request(['kill-pane', '-t', '%2'])
expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-1')
})
it('refuses to respawn the leader pane', async () => {
@@ -4,6 +4,7 @@ import {
tmuxSendKeysText,
tmuxValue
} from '../../shared/claude-agent-teams-tmux-compat'
import { describeUnconfirmedAgentStop } from '../../shared/pty-liveness-verdict'
import {
formatContext,
paneEnv,
@@ -157,27 +158,31 @@ export class ClaudeAgentTeamsTmuxDispatcher {
if (!command) {
return ''
}
if (pane.respawnBlockedReason) {
throw new Error(pane.respawnBlockedReason)
}
const origin =
(pane.splitFromPane ? team.panes.get(pane.splitFromPane) : undefined) ??
team.panes.get(team.leaderPane)!
// Why: create the replacement before destroying the placeholder so a failed
// split leaves the fake pane id pointing at a still-live terminal; on cleanup
// failure, discard the new split and keep the placeholder registered.
const previousHandle = pane.handle
const split = await api.splitTerminal(origin.handle, {
direction: pane.splitDirection ?? 'horizontal',
command,
env: paneEnv(team, pane.fakePaneId),
envToDelete: ['TERM_PROGRAM'],
activate: false
})
const close = await api.closeTerminal(previousHandle)
if (!close.ptyKilled) {
pane.respawnBlockedReason = describeUnconfirmedAgentStop(close)
throw new Error(pane.respawnBlockedReason)
}
try {
await api.closeTerminal(previousHandle)
const split = await api.splitTerminal(origin.handle, {
direction: pane.splitDirection ?? 'horizontal',
command,
env: paneEnv(team, pane.fakePaneId),
envToDelete: ['TERM_PROGRAM'],
activate: false
})
pane.handle = split.handle
} catch (error) {
await api.closeTerminal(split.handle).catch(() => {})
this.removePane(team, pane)
throw error
}
pane.handle = split.handle
return ''
}
@@ -266,14 +271,21 @@ export class ClaudeAgentTeamsTmuxDispatcher {
if (pane.fakePaneId === team.leaderPane) {
throw new Error('refusing to kill leader pane')
}
await api.closeTerminal(pane.handle)
const close = await api.closeTerminal(pane.handle)
if (!close.ptyKilled) {
throw new Error(describeUnconfirmedAgentStop(close))
}
this.removePane(team, pane)
return ''
}
private removePane(team: AgentTeam, pane: TeamPane): void {
team.panes.delete(pane.fakePaneId)
team.paneOrder = team.paneOrder.filter((id) => id !== pane.fakePaneId)
if (team.mainVertical?.lastColumnPane === pane.fakePaneId) {
team.mainVertical.lastColumnPane =
[...team.paneOrder].toReversed().find((id) => id !== team.leaderPane) ?? null
}
return ''
}
private async lastPane(
@@ -59,6 +59,7 @@ export type TeamPane = {
// respawn can recreate it in the same slot while preserving its fake pane id.
splitFromPane?: string
splitDirection?: 'horizontal' | 'vertical'
respawnBlockedReason?: string
}
export type AgentTeam = {
@@ -35,12 +35,12 @@ describe('terminal process incarnation liveness', () => {
).resolves.toBe('live')
await expect(
runtime.inspectTerminalProcessIncarnationLiveness('remote:ssh-1:pty-1:inc-old', SSH_SCOPE)
).resolves.toBe('dead')
).resolves.toBe('exited')
expect(listProcesses).toHaveBeenNthCalledWith(1, 'ssh-1')
expect(listProcesses).toHaveBeenNthCalledWith(2, 'ssh-1')
})
it('keeps missing or malformed identity and unavailable inventory unknown', async () => {
it('keeps missing or malformed identity and unavailable inventory unverifiable', async () => {
const listProcesses = vi
.fn()
.mockResolvedValueOnce([{ id: 'remote:ssh-1:pty-1', cwd: '', title: 'worker' }])
@@ -52,13 +52,13 @@ describe('terminal process incarnation liveness', () => {
await expect(
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE)
).resolves.toBe('unknown')
).resolves.toBe('unverifiable')
await expect(
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE)
).resolves.toBe('unknown')
).resolves.toBe('unverifiable')
await expect(
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE)
).resolves.toBe('unknown')
).resolves.toBe('unverifiable')
})
it('does not inspect an unproven host scope', async () => {
@@ -67,10 +67,10 @@ describe('terminal process incarnation liveness', () => {
await expect(
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, null)
).resolves.toBe('unknown')
).resolves.toBe('unverifiable')
await expect(
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, '{"kind":"ssh"}')
).resolves.toBe('unknown')
).resolves.toBe('unverifiable')
expect(listProcesses).not.toHaveBeenCalled()
})
@@ -83,7 +83,7 @@ describe('terminal process incarnation liveness', () => {
await expect(
runtime.inspectTerminalProcessIncarnationLiveness('local-pty:inc-1', JSON.stringify(scope))
).resolves.toBe('dead')
).resolves.toBe('exited')
expect(listProcesses).toHaveBeenCalledWith(connectionId)
})
})
@@ -311,7 +311,7 @@ describe('terminal close and handle incarnation continuity', () => {
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: true })
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
expect(harness.closeTerminal).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
@@ -328,7 +328,7 @@ describe('terminal close and handle incarnation continuity', () => {
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('kills every live tab PTY after retirement when the renderer graph is stale', async () => {
it('requests a stop for every live tab PTY after retirement when the renderer graph is stale', async () => {
const harness = createHarness()
harness.syncSplitFixtureGraph()
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
@@ -342,7 +342,7 @@ describe('terminal close and handle incarnation continuity', () => {
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
await expect(closing).resolves.toMatchObject({ ptyKilled: false })
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
expect(harness.kill).toHaveBeenCalledWith(SIBLING_PTY_ID)
})
@@ -365,7 +365,64 @@ describe('terminal close and handle incarnation continuity', () => {
expect(harness.kill).not.toHaveBeenCalled()
})
it('falls back to kill when verified teardown rejects after retirement', async () => {
it('reports an unconfirmed stop on the close receipt rather than a bare uncertain false', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
// Mirrors pty.ts when the SSH provider is gone: the lease is tombstoned, the
// stop reports false, and the PTY is marked as contact we lost — not a kill.
harness.kill.mockReturnValue(false)
harness.setVerifiedStopResult(false)
harness.runtime.markPtyLivenessUnverifiable(PTY_ID, 'its SSH provider is no longer registered')
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({
ptyKilled: false,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'its SSH provider is no longer registered'
})
expect(harness.kill).not.toHaveBeenCalled()
})
it('downgrades a live verdict after issuing an unverified follow-up stop', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.setVerifiedStopResult(false)
harness.runtime.markPtyLivenessLive(PTY_ID)
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({
ptyKilled: false,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'a follow-up stop was issued but its outcome could not be verified'
})
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
})
it('leaves a confirmed kill receipt free of any stop verdict', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.setVerifiedStopResult(true)
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
const close = await closing
expect(close.ptyKilled).toBe(true)
expect(close.ptyStopVerdict).toBeUndefined()
expect(close.ptyStopReason).toBeUndefined()
})
it('reports an unconfirmed stop when verified teardown rejects after retirement', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.setVerifiedStopResult(new Error('provider_unavailable'))
@@ -375,7 +432,11 @@ describe('terminal close and handle incarnation continuity', () => {
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
await expect(closing).resolves.toMatchObject({
ptyKilled: false,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'provider_unavailable'
})
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
deadlineMs: expect.any(Number)
})
@@ -398,7 +459,7 @@ describe('terminal close and handle incarnation continuity', () => {
harness.makeSessionUnavailable()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: true })
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
expect(harness.closeTerminal).toHaveBeenCalledWith(TAB_ID)
expect(harness.stopAndWait).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID, {
deadlineMs: expect.any(Number)
@@ -348,7 +348,7 @@ describe('remote runtime terminal split authority', () => {
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true)
})
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
@@ -374,7 +374,7 @@ describe('remote runtime terminal split authority', () => {
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
})
it('preserves the split error when kill and retirement throw', async () => {
@@ -398,6 +398,6 @@ describe('remote runtime terminal split authority', () => {
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
})
})
+164 -3
View File
@@ -166,6 +166,16 @@ function resetPlatform(): void {
}
}
function acknowledgeAgentPromptSubmit(
runtime: OrcaRuntimeService,
ptyId: string,
data: string
): void {
if (data === '\r') {
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now())
}
}
const electronMocks = vi.hoisted(() => {
type Listener = (...args: unknown[]) => void
const listeners = new Map<string, Set<Listener>>()
@@ -6455,7 +6465,8 @@ describe('OrcaRuntimeService', () => {
worktreeId: `${TEST_REPO_ID}::/remote/feature`
}
]),
shutdown: vi.fn().mockResolvedValue(undefined)
shutdown: vi.fn().mockResolvedValue(undefined),
deleteWorktreeHistory: vi.fn().mockResolvedValue(undefined)
}
const runtime = new OrcaRuntimeService(remoteStore as never, undefined, {
getSshProvider: () => ptyProvider as never
@@ -6472,6 +6483,9 @@ describe('OrcaRuntimeService', () => {
'pty-remote',
expect.objectContaining({ immediate: true })
)
expect(ptyProvider.deleteWorktreeHistory).toHaveBeenCalledWith(
`${TEST_REPO_ID}::/remote/feature`
)
expect(ptyProvider.shutdown.mock.invocationCallOrder[0]).toBeLessThan(
gitProvider.removeWorktree.mock.invocationCallOrder[0]
)
@@ -16762,6 +16776,7 @@ describe('OrcaRuntimeService', () => {
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -16828,6 +16843,7 @@ describe('OrcaRuntimeService', () => {
} else {
prematureEnters += 1
}
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
}
return true
},
@@ -16876,6 +16892,7 @@ describe('OrcaRuntimeService', () => {
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -16913,6 +16930,7 @@ describe('OrcaRuntimeService', () => {
runtime.onPtyData('pty-bg', '\x1b[?25hcomposer rendered', Date.now())
}, 1_200)
}
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -16945,6 +16963,7 @@ describe('OrcaRuntimeService', () => {
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -16983,6 +17002,7 @@ describe('OrcaRuntimeService', () => {
runtime.onPtyData('pty-bg', 'final slow composer frame', Date.now())
}, 8_100)
}
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -17024,6 +17044,7 @@ describe('OrcaRuntimeService', () => {
)
}
}
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -17054,6 +17075,7 @@ describe('OrcaRuntimeService', () => {
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
@@ -33140,7 +33162,7 @@ describe('OrcaRuntimeService', () => {
expect(spawn).not.toHaveBeenCalled()
})
it('uses POSIX quoting for mobile agent launch commands in WSL project runtimes', async () => {
it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => {
await withPlatform('win32', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
const runtime = new OrcaRuntimeService({
@@ -33178,7 +33200,7 @@ describe('OrcaRuntimeService', () => {
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
command: "command-code --profile mobile '--note' 'can'\\''t'",
command: `command-code --profile mobile '--note' 'can'"'"'t'`,
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID
})
@@ -48417,6 +48439,145 @@ describe('OrcaRuntimeService', () => {
}
})
it('routes already-missing SSH runtime history cleanup through the PTY owner', async () => {
const repo = {
id: 'repo-runtime-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
const worktreeId = `${repo.id}::/remote/already-deleted`
const metaById: Record<string, WorktreeMeta> = {
[worktreeId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', orcaCreationSource: 'ssh' })
}
const removeWorktreeMeta = vi.fn((id: string) => {
delete metaById[id]
})
const runtimeStore = {
...store,
getRepos: () => [repo],
getRepo: (id: string) => (id === repo.id ? repo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (id: string) => metaById[id],
removeWorktreeMeta
}
const gitProvider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: repo.path,
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
}
const fsProvider = {
stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
}
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
const ptyProvider = { deleteWorktreeHistory } as never
registerSshGitProvider(repo.connectionId, gitProvider as never)
registerSshFilesystemProvider(repo.connectionId, fsProvider as never)
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getSshProvider: () => ptyProvider
})
try {
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`)).resolves.toEqual({})
} finally {
unregisterSshGitProvider(repo.connectionId)
unregisterSshFilesystemProvider(repo.connectionId)
}
expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId)
expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan(
removeWorktreeMeta.mock.invocationCallOrder[0]
)
expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-1')
})
it('routes SSH runtime orphan-directory history cleanup through the PTY owner', async () => {
const repo = {
id: 'repo-runtime-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
const worktreePath = '/remote/orphan'
const worktreeId = `${repo.id}::${worktreePath}`
const metaById: Record<string, WorktreeMeta> = {
[worktreeId]: makeWorktreeMeta({
hostId: 'ssh:ssh-1',
orcaCreatedAt: Date.now(),
orcaCreationSource: 'ssh'
})
}
const removeWorktreeMeta = vi.fn((id: string) => {
delete metaById[id]
})
const runtimeStore = {
...store,
getRepos: () => [repo],
getRepo: (id: string) => (id === repo.id ? repo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (id: string) => metaById[id],
removeWorktreeMeta
}
const gitProvider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: repo.path,
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
}
const fsProvider = {
lstat: vi.fn(async (path: string) => ({
type: path === `${worktreePath}/.git` ? 'file' : 'directory'
})),
readFile: vi.fn(async (path: string) => ({
isBinary: false,
content:
path === `${worktreePath}/.git`
? `gitdir: ${repo.path}/.git/worktrees/orphan\n`
: `${worktreePath}/.git\n`
})),
deletePath: vi.fn().mockResolvedValue(undefined)
}
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
const ptyProvider = {
listProcesses: vi.fn().mockResolvedValue([]),
shutdown: vi.fn().mockResolvedValue(undefined),
deleteWorktreeHistory
}
registerSshGitProvider(repo.connectionId, gitProvider as never)
registerSshFilesystemProvider(repo.connectionId, fsProvider as never)
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getSshProvider: () => ptyProvider as never
})
try {
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({})
} finally {
unregisterSshGitProvider(repo.connectionId)
unregisterSshFilesystemProvider(repo.connectionId)
}
expect(fsProvider.deletePath).toHaveBeenCalledWith(worktreePath, true)
expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId)
expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan(
removeWorktreeMeta.mock.invocationCallOrder[0]
)
})
it('force-removes a legacy Orca-created runtime orphaned worktree directory after Git tracking is gone', async () => {
const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-'))
const repoPath = join(parentDir, 'repo')
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
import { attachCoordinatorRunStore } from './coordinator-runs/coordinator-run-store'
import { attachDecisionGateStore } from './decision-gates/decision-gate-store'
import { attachDispatchCapability } from './dispatch-context/dispatch-capability'
import { attachDispatchCompletion } from './dispatch-context/dispatch-completion'
import { attachDispatchContextStore } from './dispatch-context/dispatch-context-store'
import { attachDispatchLookup } from './dispatch-context/dispatch-lookup'
import { attachWorkerReportSettlement } from './dispatch-context/worker-report-settlement'
import { attachFederatedDispatchStore } from './federation/federated-dispatch-store'
import { attachFederationRelayAck } from './federation/federation-relay-ack'
import { attachFederationRelayEnqueue } from './federation/federation-relay-enqueue'
import { attachFederationRelayImport } from './federation/federation-relay-import'
import { attachFederationRelayItem } from './federation/federation-relay-item'
import { attachRemoteDispatchAttachmentAuthority } from './federation/remote-dispatch-attachment-authority'
import { attachRemoteDispatchAttachmentCreate } from './federation/remote-dispatch-attachment-create'
import { attachRemoteDispatchAttachmentStop } from './federation/remote-dispatch-attachment-stop'
import { attachRemoteQuestionStore } from './federation/remote-question-store'
import { attachLegacyAskOperation } from './legacy/legacy-ask-operation'
import { attachLegacyCompatibilityCandidates } from './legacy/legacy-compatibility-candidates'
import { attachLegacyCompatibilityPrincipals } from './legacy/legacy-compatibility-principals'
import { attachLegacyLifecycleOperation } from './legacy/legacy-lifecycle-operation'
import { attachLegacyMailAcknowledge } from './legacy/legacy-mail-acknowledge'
import { attachLegacyQuestionAcknowledge } from './legacy/legacy-question-acknowledge'
import { attachLegacyQuestionLookup } from './legacy/legacy-question-lookup'
import { attachLegacyRecoveryCohort } from './legacy/legacy-recovery-cohort'
import { attachLegacyReplyOperation } from './legacy/legacy-reply-operation'
import { attachLegacyWorkerCompletion } from './legacy/legacy-worker-completion'
import { attachDirectMailboxRouting } from './messages/direct-mailbox-routing'
import { attachForeignDirectMailboxRouting } from './messages/foreign-direct-mailbox-routing'
import { attachMessageInbox } from './messages/message-inbox'
import { attachMessageInsert } from './messages/message-insert'
import { attachMutationReceiptStore } from './mutation-receipts/mutation-receipt-store'
import { attachQuestionThreads } from './questions/question-threads'
import { attachOrchestrationReset } from './reset/orchestration-reset'
import { attachRunBinding } from './runs/run-binding'
import { attachRunCoordinatorMailRouting } from './runs/run-coordinator-mail-routing'
import { attachRunCreate } from './runs/run-create'
import { attachRunDelivery } from './runs/run-delivery'
import { attachRunLookup } from './runs/run-lookup'
import { attachLegacyCoordinatorMailTakeover } from './runs/legacy-coordinator-mail-takeover'
import { attachAdoptLegacyRun } from './schema/adopt-legacy-run'
import { attachBackfillLegacyQuestionThreads } from './schema/backfill-legacy-question-threads'
import { attachCreateTables } from './schema/create-tables'
import { attachMigrateLegacyContractStorage } from './schema/migrate-legacy-contract-storage'
import { attachSchemaMigrate } from './schema/migrate'
import { attachSchemaColumnProbes } from './schema/schema-column-probes'
import { attachTaskStore } from './tasks/task-store'
import { attachFederatedWorkerStartReconcile } from './worker-dispatch/federated-worker-start-reconcile'
import { attachWorkerDispatchAbandon } from './worker-dispatch/worker-dispatch-abandon'
import { attachWorkerDispatchAuthority } from './worker-dispatch/worker-dispatch-authority'
import { attachWorkerDispatchOutcome } from './worker-dispatch/worker-dispatch-outcome'
import { attachWorkerDispatchStage } from './worker-dispatch/worker-dispatch-stage'
import { attachWorkerDispatchStart } from './worker-dispatch/worker-dispatch-start'
import { attachWorkerDispatchStop } from './worker-dispatch/worker-dispatch-stop'
import { attachWorkerTerminalRecovery } from './worker-dispatch/worker-terminal-recovery'
import { attachWorkerTerminalArchive } from './worker-terminal/worker-terminal-archive'
import { attachWorkerTerminalListing } from './worker-terminal/worker-terminal-listing'
import { attachWorkerTerminalRelease } from './worker-terminal/worker-terminal-release'
import { attachWorkerTerminalResourceStore } from './worker-terminal/worker-terminal-resource-store'
import { attachWorkerTerminalTransfer } from './worker-terminal/worker-terminal-transfer'
export function attachOrchestrationDbMethods(ctor: { prototype: object }): void {
attachCreateTables(ctor)
attachSchemaMigrate(ctor)
attachSchemaColumnProbes(ctor)
attachMigrateLegacyContractStorage(ctor)
attachBackfillLegacyQuestionThreads(ctor)
attachAdoptLegacyRun(ctor)
attachMutationReceiptStore(ctor)
attachLegacyCompatibilityPrincipals(ctor)
attachLegacyCompatibilityCandidates(ctor)
attachLegacyWorkerCompletion(ctor)
attachLegacyRecoveryCohort(ctor)
attachLegacyMailAcknowledge(ctor)
attachLegacyQuestionAcknowledge(ctor)
attachLegacyLifecycleOperation(ctor)
attachLegacyAskOperation(ctor)
attachLegacyQuestionLookup(ctor)
attachLegacyReplyOperation(ctor)
attachRunCreate(ctor)
attachRunBinding(ctor)
attachRunLookup(ctor)
attachRunCoordinatorMailRouting(ctor)
attachLegacyCoordinatorMailTakeover(ctor)
attachRunDelivery(ctor)
attachMessageInsert(ctor)
attachMessageInbox(ctor)
attachDirectMailboxRouting(ctor)
attachForeignDirectMailboxRouting(ctor)
attachQuestionThreads(ctor)
attachTaskStore(ctor)
attachWorkerDispatchStart(ctor)
attachWorkerDispatchStage(ctor)
attachWorkerDispatchAuthority(ctor)
attachWorkerDispatchOutcome(ctor)
attachFederatedWorkerStartReconcile(ctor)
attachWorkerTerminalRecovery(ctor)
attachWorkerDispatchStop(ctor)
attachWorkerDispatchAbandon(ctor)
attachFederatedDispatchStore(ctor)
attachRemoteDispatchAttachmentCreate(ctor)
attachRemoteDispatchAttachmentAuthority(ctor)
attachRemoteDispatchAttachmentStop(ctor)
attachFederationRelayEnqueue(ctor)
attachFederationRelayAck(ctor)
attachFederationRelayImport(ctor)
attachRemoteQuestionStore(ctor)
attachFederationRelayItem(ctor)
attachWorkerTerminalResourceStore(ctor)
attachWorkerTerminalTransfer(ctor)
attachWorkerTerminalRelease(ctor)
attachWorkerTerminalArchive(ctor)
attachWorkerTerminalListing(ctor)
attachDispatchContextStore(ctor)
attachDispatchCapability(ctor)
attachDispatchLookup(ctor)
attachDispatchCompletion(ctor)
attachWorkerReportSettlement(ctor)
attachDecisionGateStore(ctor)
attachCoordinatorRunStore(ctor)
attachOrchestrationReset(ctor)
}
@@ -0,0 +1,10 @@
import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID
export const LEGACY_CONTRACT_VERSION = 0
export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity.
export const SCHEMA_VERSION = 28
@@ -0,0 +1,91 @@
import type { CoordinatorStatus, CoordinatorRun } from '../../types'
import { generateId } from '../generated-id'
import type { OrchestrationDb } from '../orchestration-db'
// ── Coordinator Runs ──
export function createCoordinatorRun(
this: OrchestrationDb,
run: {
spec: string
coordinatorHandle: string
pollIntervalMs?: number
}
): CoordinatorRun {
const id = generateId('run')
this.db
.prepare(
"INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)"
)
.run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000)
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun
}
export function getCoordinatorRun(this: OrchestrationDb, id: string): CoordinatorRun | undefined {
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as
| CoordinatorRun
| undefined
}
export function updateCoordinatorRun(
this: OrchestrationDb,
id: string,
status: CoordinatorStatus
): CoordinatorRun | undefined {
const completedAt =
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
this.db
.prepare(
'UPDATE coordinator_runs SET status = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?'
)
.run(status, completedAt, id)
return this.getCoordinatorRun(id)
}
export function getActiveCoordinatorRun(this: OrchestrationDb): CoordinatorRun | undefined {
return this.db
.prepare(
"SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1"
)
.get() as CoordinatorRun | undefined
}
// ── Queries for Coordinator ──
export function getIdleTerminals(this: OrchestrationDb, excludeHandles: string[] = []): string[] {
const active = this.db
.prepare(
"SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')"
)
.all() as { assignee_handle: string }[]
const busyHandles = new Set(active.map((r) => r.assignee_handle))
for (const h of excludeHandles) {
busyHandles.add(h)
}
// Return handles from message history that aren't busy
const allHandles = this.db
.prepare(
// Why: alias the UNION column — otherwise the row shape depends on the first branch's column name.
'SELECT DISTINCT to_handle AS handle FROM messages UNION SELECT DISTINCT from_handle FROM messages'
)
.all() as { handle: string }[]
return [...new Set(allHandles.map((r) => r.handle))].filter((h) => !busyHandles.has(h))
}
export type CoordinatorRunStoreMethods = {
createCoordinatorRun: typeof createCoordinatorRun
getCoordinatorRun: typeof getCoordinatorRun
updateCoordinatorRun: typeof updateCoordinatorRun
getActiveCoordinatorRun: typeof getActiveCoordinatorRun
getIdleTerminals: typeof getIdleTerminals
}
export function attachCoordinatorRunStore(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
createCoordinatorRun,
getCoordinatorRun,
updateCoordinatorRun,
getActiveCoordinatorRun,
getIdleTerminals
})
}
@@ -0,0 +1,17 @@
import { chmodSync, existsSync } from 'node:fs'
export function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void {
if (dbPath === ':memory:' || process.platform === 'win32') {
// Why: Windows protects these files through Orca's current-user-only userData DACL; POSIX mode bits are inert there.
return
}
for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
try {
if (existsSync(path)) {
chmodSync(path, 0o600)
}
} catch {
// Why: best-effort — a mount that rejects chmod (SSHFS, some network shares) must not fail DB startup.
}
}
}
@@ -0,0 +1,116 @@
import type { GateStatus, DecisionGateRow } from '../../types'
import { LEGACY_RUN_ID } from '../contract-constants'
import { generateId } from '../generated-id'
import type { OrchestrationDb } from '../orchestration-db'
// ── Decision Gates ──
export function createGate(
this: OrchestrationDb,
gate: { taskId: string; question: string; options?: string[] }
): DecisionGateRow {
const id = generateId('gate')
const optionsJson = JSON.stringify(gate.options ?? [])
this.db
.prepare(
'INSERT INTO decision_gates (id, run_id, task_id, question, options) VALUES (?, ?, ?, ?, ?)'
)
.run(
id,
this.getTask(gate.taskId)?.run_id ?? LEGACY_RUN_ID,
gate.taskId,
gate.question,
optionsJson
)
this.completeActiveDispatchForTask(gate.taskId)
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow
}
export function resolveGate(
this: OrchestrationDb,
gateId: string,
resolution: string
): DecisionGateRow | undefined {
const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
| DecisionGateRow
| undefined
if (!gate) {
return undefined
}
this.db
.prepare(
"UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?"
)
.run(resolution, gateId)
// Why: set to 'ready' (not the previous status) so the coordinator re-dispatches the worker with the resolution context.
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
| DecisionGateRow
| undefined
}
export function timeoutGate(this: OrchestrationDb, gateId: string): DecisionGateRow | undefined {
this.db
.prepare(
// Why: without the status guard a late timeout overwrites a gate the user already resolved.
"UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ? AND status = 'pending'"
)
.run(gateId)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
| DecisionGateRow
| undefined
}
export function listGates(
this: OrchestrationDb,
filter?: { taskId?: string; status?: GateStatus }
): DecisionGateRow[] {
if (filter?.taskId && filter?.status) {
return this.db
.prepare('SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at')
.all(filter.taskId, filter.status) as DecisionGateRow[]
}
if (filter?.taskId) {
return this.db
.prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at')
.all(filter.taskId) as DecisionGateRow[]
}
if (filter?.status) {
return this.db
.prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at')
.all(filter.status) as DecisionGateRow[]
}
return this.db
.prepare('SELECT * FROM decision_gates ORDER BY created_at')
.all() as DecisionGateRow[]
}
export function getGate(this: OrchestrationDb, id: string): DecisionGateRow | undefined {
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as
| DecisionGateRow
| undefined
}
export type DecisionGateStoreMethods = {
createGate: typeof createGate
resolveGate: typeof resolveGate
timeoutGate: typeof timeoutGate
listGates: typeof listGates
getGate: typeof getGate
}
export function attachDecisionGateStore(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
createGate,
resolveGate,
timeoutGate,
listGates,
getGate
})
}
@@ -0,0 +1,5 @@
import { createHash } from 'node:crypto'
export function hashDispatchCapability(capability: string): string {
return createHash('sha256').update(capability).digest('hex')
}
@@ -0,0 +1,105 @@
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { OrchestrationError } from '../../orchestration-error'
import { hashDispatchCapability } from '../dispatch-capability-hash'
import { isEquivalentPaneKey } from '../pane-key-match'
import type { OrchestrationDb } from '../orchestration-db'
export function mintDispatchCapability(
this: OrchestrationDb,
params: {
dispatchId: string
paneKey: string
processIncarnation: string
}
): string {
const dispatch = this.getDispatchContextById(params.dispatchId)
if (!dispatch || (dispatch.status !== 'pending' && dispatch.status !== 'dispatched')) {
throw new OrchestrationError(
'dispatch_inactive',
`Dispatch ${params.dispatchId} is not active.`
)
}
const capability = `dcap_${randomBytes(32).toString('base64url')}`
this.db
.prepare(
`UPDATE dispatch_contexts
SET capability_hash = ?, assignee_pane_key = ?, process_incarnation = ?,
capability_revoked_at = NULL
WHERE id = ?`
)
.run(
hashDispatchCapability(capability),
params.paneKey,
params.processIncarnation,
params.dispatchId
)
return capability
}
export function verifyDispatchCapability(
this: OrchestrationDb,
params: {
dispatchId: string
capability: string | undefined
paneKey: string | undefined
processIncarnation: string | undefined
}
): { valid: true } | { valid: false; reason: string } {
const dispatch = this.getDispatchContextById(params.dispatchId)
if (!dispatch) {
return { valid: false, reason: `Dispatch ${params.dispatchId} was not found.` }
}
if (!dispatch.capability_hash) {
return { valid: false, reason: `Dispatch ${params.dispatchId} has no lifecycle capability.` }
}
if (dispatch.capability_revoked_at) {
return { valid: false, reason: `Dispatch ${params.dispatchId} capability is revoked.` }
}
if (!params.capability) {
return { valid: false, reason: 'The Dispatch capability is missing.' }
}
const expected = Buffer.from(dispatch.capability_hash, 'hex')
const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex')
if (expected.length !== observed.length || !timingSafeEqual(expected, observed)) {
return { valid: false, reason: 'The Dispatch capability is invalid.' }
}
if (
!dispatch.assignee_pane_key ||
!params.paneKey ||
!isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey)
) {
return { valid: false, reason: 'The caller is not the Dispatch pane.' }
}
if (
!dispatch.process_incarnation ||
!params.processIncarnation ||
dispatch.process_incarnation !== params.processIncarnation
) {
return { valid: false, reason: 'The Dispatch process incarnation changed.' }
}
return { valid: true }
}
export function revokeDispatchCapability(this: OrchestrationDb, dispatchId: string): void {
this.db
.prepare(
`UPDATE dispatch_contexts
SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
WHERE id = ?`
)
.run(dispatchId)
}
export type DispatchCapabilityMethods = {
mintDispatchCapability: typeof mintDispatchCapability
verifyDispatchCapability: typeof verifyDispatchCapability
revokeDispatchCapability: typeof revokeDispatchCapability
}
export function attachDispatchCapability(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
mintDispatchCapability,
verifyDispatchCapability,
revokeDispatchCapability
})
}
@@ -0,0 +1,2 @@
// Why: shared so every dispatch failure path breaks the circuit at the same accumulated failure_count.
export const DISPATCH_CIRCUIT_BREAK_FAILURES = 3
@@ -0,0 +1,134 @@
import type { TaskStatus, DispatchStatus, DispatchContextRow } from '../../types'
import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './dispatch-circuit-breaker'
import type { OrchestrationDb } from '../orchestration-db'
const FAIL_DISPATCH_SAVEPOINT = 'fail_dispatch'
export function completeDispatch(this: OrchestrationDb, ctxId: string): void {
this.db
.prepare(
// Why: the status guard keeps a late completion from reviving a dispatch already failed or circuit-broken.
"UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now'), capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) WHERE id = ? AND status IN ('pending', 'dispatched')"
)
.run(ctxId)
}
export function completeActiveDispatchForTask(this: OrchestrationDb, taskId: string): void {
const active = this.db
.prepare(
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
)
.get(taskId) as DispatchContextRow | undefined
if (active) {
this.completeDispatch(active.id)
}
}
export function failActiveDispatchForTask(
this: OrchestrationDb,
taskId: string,
error: string
): DispatchContextRow | undefined {
const active = this.db
.prepare(
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
)
.get(taskId) as DispatchContextRow | undefined
return active ? this.failDispatch(active.id, error) : undefined
}
// Why: only bump status='dispatched' — a zombie heartbeat from a finished dispatch would mask a hung retry from the stale detector (§5.3.4).
export function recordHeartbeat(this: OrchestrationDb, dispatchId: string, at: string): void {
this.db
.prepare(
"UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'"
)
.run(at, dispatchId)
}
// Why: dispatched_at grace skips workers still within their first heartbeat interval; julianday() vs raw-TEXT compare avoids misflagging space-format timestamps as stale (#8452).
export function getStaleDispatches(
this: OrchestrationDb,
thresholdIso: string
): DispatchContextRow[] {
return this.db
.prepare(
`SELECT * FROM dispatch_contexts
WHERE status = 'dispatched'
AND dispatched_at IS NOT NULL
AND julianday(dispatched_at) < julianday(?)
AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))`
)
.all(thresholdIso, thresholdIso) as DispatchContextRow[]
}
export function failDispatch(
this: OrchestrationDb,
ctxId: string,
error: string
): DispatchContextRow | undefined {
this.db.exec(`SAVEPOINT ${FAIL_DISPATCH_SAVEPOINT}`)
try {
const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
| DispatchContextRow
| undefined
if (!ctx || (ctx.status !== 'pending' && ctx.status !== 'dispatched')) {
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
return ctx
}
const newFailureCount = ctx.failure_count + 1
const newStatus: DispatchStatus =
newFailureCount >= DISPATCH_CIRCUIT_BREAK_FAILURES ? 'circuit_broken' : 'failed'
const result = this.db
.prepare(
`UPDATE dispatch_contexts
SET status = ?, failure_count = ?, last_failure = ?,
completed_at = COALESCE(completed_at, datetime('now')),
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
WHERE id = ? AND status IN ('pending', 'dispatched')`
)
.run(newStatus, newFailureCount, error, ctxId)
if (result.changes !== 1) {
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
| DispatchContextRow
| undefined
}
// Why: back to 'ready' not 'pending' — 'pending' would strand it since promoteReadyTasks only runs when a dep completes.
const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready'
// Why: the status guard keeps a late failure from reopening a task that already completed or was retried elsewhere.
this.db
.prepare("UPDATE tasks SET status = ? WHERE id = ? AND status IN ('dispatched', 'blocked')")
.run(taskStatus, ctx.task_id)
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
| DispatchContextRow
| undefined
} catch (cause) {
this.db.exec(`ROLLBACK TO ${FAIL_DISPATCH_SAVEPOINT}`)
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
throw cause
}
}
export type DispatchCompletionMethods = {
completeDispatch: typeof completeDispatch
completeActiveDispatchForTask: typeof completeActiveDispatchForTask
failActiveDispatchForTask: typeof failActiveDispatchForTask
recordHeartbeat: typeof recordHeartbeat
getStaleDispatches: typeof getStaleDispatches
failDispatch: typeof failDispatch
}
export function attachDispatchCompletion(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
completeDispatch,
completeActiveDispatchForTask,
failActiveDispatchForTask,
recordHeartbeat,
getStaleDispatches,
failDispatch
})
}
@@ -0,0 +1,131 @@
import type { DispatchContextRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { CURRENT_CONTRACT_VERSION } from '../contract-constants'
import { generateId } from '../generated-id'
import type { OrchestrationDb } from '../orchestration-db'
export function createDispatchContext(
this: OrchestrationDb,
taskId: string,
assigneeHandle: string,
// Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue.
assigneePaneKey?: string,
launchTokenHash?: string,
processIncarnation?: string
): DispatchContextRow {
const task = this.getTask(taskId)
if (!task) {
throw new Error(`Task not found: ${taskId}`)
}
if (task.status !== 'ready') {
throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`)
}
// Why: lock on pane identity too, so a reminted handle can't open a second concurrent dispatch on the same pane.
const existing = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey)
if (existing) {
throw new Error(
`Terminal ${assigneeHandle} already has an active dispatch (${existing.id} for task ${existing.task_id})`
)
}
// Carry forward failure_count so the circuit breaker accumulates across retries for the same task.
const prior = this.db
.prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?')
.get(taskId) as { max_failures: number | null } | undefined
const priorFailures = prior?.max_failures ?? 0
const id = generateId('ctx')
this.db
.prepare(
`INSERT INTO dispatch_contexts (
id, run_id, task_id, contract_version, launch_token_hash,
assignee_handle, assignee_pane_key, process_incarnation,
status, failure_count, dispatched_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))`
)
.run(
id,
task.run_id,
taskId,
CURRENT_CONTRACT_VERSION,
launchTokenHash ?? null,
assigneeHandle,
assigneePaneKey ?? null,
processIncarnation ?? null,
priorFailures
)
this.hasAnyDispatchContextsCache = true
this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId)
return this.db
.prepare('SELECT * FROM dispatch_contexts WHERE id = ?')
.get(id) as DispatchContextRow
}
export function getDispatchContext(
this: OrchestrationDb,
taskId: string
): DispatchContextRow | undefined {
return this.db
.prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1')
.get(taskId) as DispatchContextRow | undefined
}
export function getDispatchContextById(
this: OrchestrationDb,
dispatchId: string
): DispatchContextRow | undefined {
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(dispatchId) as
| DispatchContextRow
| undefined
}
export function commitDispatchLaunchTokenHash(
this: OrchestrationDb,
dispatchId: string,
launchTokenHash: string
): DispatchContextRow {
const dispatch = this.getDispatchContextById(dispatchId)
if (!dispatch) {
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
}
if (dispatch.contract_version !== CURRENT_CONTRACT_VERSION) {
throw new OrchestrationError(
'request_mismatch',
`Dispatch ${dispatchId} does not use the current contract.`
)
}
if (dispatch.launch_token_hash && dispatch.launch_token_hash !== launchTokenHash) {
throw new OrchestrationError(
'request_mismatch',
`Dispatch ${dispatchId} already has a different launch-token commitment.`
)
}
this.db
.prepare(
`UPDATE dispatch_contexts
SET launch_token_hash = COALESCE(launch_token_hash, ?)
WHERE id = ?`
)
.run(launchTokenHash, dispatchId)
return this.getDispatchContextById(dispatchId) as DispatchContextRow
}
export type DispatchContextStoreMethods = {
createDispatchContext: typeof createDispatchContext
getDispatchContext: typeof getDispatchContext
getDispatchContextById: typeof getDispatchContextById
commitDispatchLaunchTokenHash: typeof commitDispatchLaunchTokenHash
}
export function attachDispatchContextStore(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
createDispatchContext,
getDispatchContext,
getDispatchContextById,
commitDispatchLaunchTokenHash
})
}
@@ -0,0 +1,108 @@
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
import type { DispatchContextRow } from '../../types'
import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match'
import type { OrchestrationDb } from '../orchestration-db'
export function getActiveDispatchForTerminal(
this: OrchestrationDb,
handle: string
): DispatchContextRow | undefined {
return this.findActiveDispatchForAssignee(handle)
}
/**
* Cheap "are there any dispatch rows at all" probe. When false, no terminal
* can have an active or recent-completed dispatch, so orchestration-context
* builders can skip their per-terminal query fan-out entirely. Cached after
* the first probe; createDispatchContext marks it true, resets clear it.
*/
export function hasAnyDispatchContexts(this: OrchestrationDb): boolean {
if (this.hasAnyDispatchContextsCache === undefined) {
const row = this.db.prepare('SELECT 1 FROM dispatch_contexts LIMIT 1').get()
this.hasAnyDispatchContextsCache = row !== undefined
}
return this.hasAnyDispatchContextsCache
}
export function getActiveDispatchForIdentity(
this: OrchestrationDb,
handle: string,
paneKey?: string
): DispatchContextRow | undefined {
return this.findActiveDispatchForAssignee(handle, paneKey)
}
export function findActiveDispatchForAssignee(
this: OrchestrationDb,
assigneeHandle: string,
assigneePaneKey?: string
): DispatchContextRow | undefined {
const byHandle = this.db
.prepare(
// Why: newest-first like the pane lookups below — an unordered LIMIT 1 could pin a stale row if a handle ever has two active dispatches.
`SELECT * FROM dispatch_contexts
WHERE assignee_handle = ? AND status IN ('pending', 'dispatched')
ORDER BY rowid DESC LIMIT 1`
)
.get(assigneeHandle) as DispatchContextRow | undefined
if (byHandle) {
return byHandle
}
if (!assigneePaneKey) {
return undefined
}
const exactPane = this.db
.prepare(
`SELECT * FROM dispatch_contexts
WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched')
ORDER BY rowid DESC LIMIT 1`
)
.get(assigneePaneKey) as DispatchContextRow | undefined
if (exactPane) {
return exactPane
}
if (!parsePaneKey(assigneePaneKey)) {
return undefined
}
return this.db
.prepare(
`SELECT * FROM dispatch_contexts
WHERE assignee_pane_key IS NOT NULL
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
ORDER BY rowid DESC LIMIT 1`
)
.get(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow | undefined
}
export function getLatestDispatchForTerminal(
this: OrchestrationDb,
handle: string
): DispatchContextRow | undefined {
return this.db
.prepare(
'SELECT * FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1'
)
.get(handle) as DispatchContextRow | undefined
}
export type DispatchLookupMethods = {
getActiveDispatchForTerminal: typeof getActiveDispatchForTerminal
hasAnyDispatchContexts: typeof hasAnyDispatchContexts
getActiveDispatchForIdentity: typeof getActiveDispatchForIdentity
findActiveDispatchForAssignee: typeof findActiveDispatchForAssignee
getLatestDispatchForTerminal: typeof getLatestDispatchForTerminal
}
export function attachDispatchLookup(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
getActiveDispatchForTerminal,
hasAnyDispatchContexts,
getActiveDispatchForIdentity,
findActiveDispatchForAssignee,
getLatestDispatchForTerminal
})
}
@@ -0,0 +1,125 @@
import type { WorkerReportOutcome, WorkerReportSettlement } from '../../types'
import type { OrchestrationDb } from '../orchestration-db'
export function settleWorkerReport(
this: OrchestrationDb,
params: {
taskId: string
dispatchId: string
outcome: WorkerReportOutcome
result: string
}
): WorkerReportSettlement {
this.db.exec('BEGIN IMMEDIATE')
try {
const settlement = this.settleWorkerReportInTransaction(params)
this.db.exec('COMMIT')
return settlement
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export function settleWorkerReportInTransaction(
this: OrchestrationDb,
params: {
taskId: string
dispatchId: string
outcome: WorkerReportOutcome
result: string
}
): WorkerReportSettlement {
const task = this.getTask(params.taskId)
if (!task) {
return { action: 'rejected', code: 'unknown_task', reason: `Unknown task ${params.taskId}.` }
}
const dispatch = this.getDispatchContextById(params.dispatchId)
if (!dispatch) {
return {
action: 'rejected',
code: 'unknown_dispatch',
reason: `Unknown dispatch ${params.dispatchId}.`
}
}
if (dispatch.task_id !== params.taskId) {
return {
action: 'rejected',
code: 'task_dispatch_mismatch',
reason: `Dispatch ${params.dispatchId} belongs to task ${dispatch.task_id}, not ${params.taskId}.`
}
}
const expectedDispatchStatus = params.outcome === 'succeeded' ? 'completed' : 'failed'
const expectedTaskStatus = params.outcome === 'succeeded' ? 'completed' : 'failed'
if (dispatch.status === expectedDispatchStatus && task.status === expectedTaskStatus) {
return { action: 'settled', outcome: params.outcome, duplicate: true }
}
if (dispatch.status !== 'dispatched' || task.status !== 'dispatched') {
return {
action: 'rejected',
code: 'inactive_dispatch',
reason: `inactive dispatch ${params.dispatchId}: it or task ${params.taskId} is already settled.`
}
}
const latest = this.getDispatchContext(params.taskId)
if (latest?.id !== params.dispatchId) {
return {
action: 'rejected',
code: 'stale_dispatch',
reason: `Dispatch ${params.dispatchId} is not the current dispatch for task ${params.taskId}.`
}
}
this.db.exec('SAVEPOINT settle_worker_report')
const dispatchUpdate = this.db
.prepare(
`UPDATE dispatch_contexts
SET status = ?, completed_at = datetime('now'),
last_failure = CASE WHEN ? = 'failed' THEN ? ELSE last_failure END,
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
WHERE id = ? AND status = 'dispatched'`
)
.run(expectedDispatchStatus, expectedDispatchStatus, params.result, params.dispatchId)
const taskUpdate = this.db
.prepare(
`UPDATE tasks
SET status = ?, result = ?, completed_at = datetime('now')
WHERE id = ? AND status = 'dispatched'`
)
.run(expectedTaskStatus, params.result, params.taskId)
if (dispatchUpdate.changes !== 1 || taskUpdate.changes !== 1) {
this.db.exec('ROLLBACK TO settle_worker_report')
this.db.exec('RELEASE settle_worker_report')
return {
action: 'rejected',
code: 'inactive_dispatch',
reason: `Dispatch ${params.dispatchId} changed while its worker report was settling.`
}
}
this.db
.prepare(
`UPDATE worker_dispatches
SET state = ?, stage = 'settled', updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'ready'`
)
.run(params.outcome === 'succeeded' ? 'succeeded' : 'failed', params.dispatchId)
this.closeQuestionsForDispatch(params.dispatchId)
if (params.outcome === 'succeeded') {
this.promoteReadyTasks(params.taskId)
}
this.db.exec('RELEASE settle_worker_report')
return { action: 'settled', outcome: params.outcome, duplicate: false }
}
export type WorkerReportSettlementMethods = {
settleWorkerReport: typeof settleWorkerReport
settleWorkerReportInTransaction: typeof settleWorkerReportInTransaction
}
export function attachWorkerReportSettlement(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
settleWorkerReport,
settleWorkerReportInTransaction
})
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { parseFederatedWorkerReportOutcome } from './federated-worker-report-outcome'
describe('federated-worker-report-outcome', () => {
it('reads succeeded or failed from a nested report payload', () => {
const succeeded = JSON.stringify({ payload: JSON.stringify({ outcome: 'succeeded' }) })
const failed = JSON.stringify({ payload: JSON.stringify({ outcome: 'failed' }) })
expect(parseFederatedWorkerReportOutcome(succeeded)).toBe('succeeded')
expect(parseFederatedWorkerReportOutcome(failed)).toBe('failed')
})
it('returns undefined for malformed or non-terminal outcomes', () => {
expect(parseFederatedWorkerReportOutcome('not-json')).toBeUndefined()
expect(parseFederatedWorkerReportOutcome(JSON.stringify({ payload: {} }))).toBeUndefined()
const other = JSON.stringify({ payload: JSON.stringify({ outcome: 'running' }) })
expect(parseFederatedWorkerReportOutcome(other)).toBeUndefined()
})
})
@@ -0,0 +1,18 @@
import type { WorkerReportOutcome } from '../types'
export function parseFederatedWorkerReportOutcome(
payload: string
): WorkerReportOutcome | undefined {
try {
const message = JSON.parse(payload) as { payload?: unknown }
if (typeof message.payload !== 'string') {
return undefined
}
const report = JSON.parse(message.payload) as { outcome?: unknown }
return report.outcome === 'succeeded' || report.outcome === 'failed'
? report.outcome
: undefined
} catch {
return undefined
}
}
@@ -0,0 +1,114 @@
import type { FederatedDispatchRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import type { OrchestrationDb } from '../orchestration-db'
export function getFederatedDispatch(
this: OrchestrationDb,
dispatchId: string
): FederatedDispatchRow | undefined {
return this.db
.prepare('SELECT * FROM federated_dispatches WHERE dispatch_id = ?')
.get(dispatchId) as FederatedDispatchRow | undefined
}
export function listActiveFederatedDispatches(
this: OrchestrationDb,
runId?: string
): FederatedDispatchRow[] {
return this.db
.prepare(
`SELECT fd.*
FROM federated_dispatches fd
INNER JOIN dispatch_contexts dc ON dc.id = fd.dispatch_id
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
WHERE wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
AND (? IS NULL OR dc.run_id = ?)
ORDER BY fd.rowid`
)
.all(runId ?? null, runId ?? null) as FederatedDispatchRow[]
}
export function findNextTerminalFederatedDispatchPendingAcknowledgment(
this: OrchestrationDb,
afterRowId: number
): { dispatchId: string; rowId: number } | undefined {
return this.db
.prepare(
`SELECT fd.dispatch_id AS dispatchId, fd.rowid AS rowId
FROM federated_dispatches fd
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
WHERE wd.state NOT IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
AND fd.to_home_acknowledged_sequence < fd.to_home_imported_sequence
AND fd.rowid > ?
ORDER BY fd.rowid
LIMIT 1`
)
.get(afterRowId) as { dispatchId: string; rowId: number } | undefined
}
export function isFederatedDispatchRelayEligible(
this: OrchestrationDb,
dispatchId: string
): boolean {
return Boolean(
this.db
.prepare(
`SELECT 1
FROM federated_dispatches fd
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
WHERE fd.dispatch_id = ?
AND (
wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
OR (
fd.to_home_acknowledged_sequence < fd.to_home_imported_sequence
)
)`
)
.get(dispatchId)
)
}
export function updateFederatedDispatchResources(
this: OrchestrationDb,
params: {
dispatchId: string
remoteRuntimeEpoch: string
worktreeId: string
terminalHandle: string
}
): FederatedDispatchRow {
this.db
.prepare(
`UPDATE federated_dispatches
SET remote_runtime_epoch = ?, remote_worktree_id = ?, remote_terminal_handle = ?,
updated_at = datetime('now')
WHERE dispatch_id = ?`
)
.run(params.remoteRuntimeEpoch, params.worktreeId, params.terminalHandle, params.dispatchId)
const row = this.getFederatedDispatch(params.dispatchId)
if (!row) {
throw new OrchestrationError(
'dispatch_not_found',
`Federated Dispatch ${params.dispatchId} was not found.`
)
}
return row
}
export type FederatedDispatchStoreMethods = {
getFederatedDispatch: typeof getFederatedDispatch
listActiveFederatedDispatches: typeof listActiveFederatedDispatches
findNextTerminalFederatedDispatchPendingAcknowledgment: typeof findNextTerminalFederatedDispatchPendingAcknowledgment
isFederatedDispatchRelayEligible: typeof isFederatedDispatchRelayEligible
updateFederatedDispatchResources: typeof updateFederatedDispatchResources
}
export function attachFederatedDispatchStore(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
getFederatedDispatch,
listActiveFederatedDispatches,
findNextTerminalFederatedDispatchPendingAcknowledgment,
isFederatedDispatchRelayEligible,
updateFederatedDispatchResources
})
}
@@ -0,0 +1,202 @@
import type {
WorkerReportOutcome,
FederationRelayDirection,
FederationRelayItemRow
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../../shared/protocol-version'
import { parseFederatedWorkerReportOutcome } from '../federated-worker-report-outcome'
import type { OrchestrationDb } from '../orchestration-db'
export function listFederationRelay(
this: OrchestrationDb,
params: {
dispatchId: string
direction: FederationRelayDirection
afterSequence: number
limit?: number
}
): FederationRelayItemRow[] {
return this.db
.prepare(
`SELECT * FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND sequence > ?
ORDER BY sequence LIMIT ?`
)
.all(
params.dispatchId,
params.direction,
params.afterSequence,
Math.min(Math.max(params.limit ?? 50, 1), 50)
) as FederationRelayItemRow[]
}
export function listPendingFederationRelay(
this: OrchestrationDb,
dispatchId: string,
direction: FederationRelayDirection,
limit = 50
): FederationRelayItemRow[] {
return this.db
.prepare(
`SELECT * FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL
ORDER BY sequence LIMIT ?`
)
.all(dispatchId, direction, Math.min(Math.max(limit, 1), 50)) as FederationRelayItemRow[]
}
export function acknowledgeFederationRelay(
this: OrchestrationDb,
params: {
dispatchId: string
direction: FederationRelayDirection
throughSequence: number
settleRemoteReports?: { sequence: number; outcome?: WorkerReportOutcome }[]
}
): void {
this.db.exec('BEGIN IMMEDIATE')
try {
const settledReports = params.settleRemoteReports ?? []
for (const settledReport of settledReports) {
const report = this.getFederationRelayItem(
params.dispatchId,
params.direction,
settledReport.sequence
)
if (
params.direction !== 'to_home' ||
settledReport.sequence > params.throughSequence ||
report?.kind !== 'worker_done' ||
(settledReport.outcome !== undefined &&
parseFederatedWorkerReportOutcome(report.payload) !== settledReport.outcome)
) {
throw new OrchestrationError(
'request_mismatch',
`Federation acknowledgment for ${params.dispatchId} does not match its queued worker_done.`
)
}
}
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
if (
params.direction === 'to_home' &&
attachment !== undefined &&
attachment.protocol_version >= ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION
) {
const acknowledgedReports = this.db
.prepare(
`SELECT sequence FROM federation_relay_items
WHERE dispatch_id = ? AND direction = 'to_home' AND kind = 'worker_done'
AND acked_at IS NULL AND sequence <= ?`
)
.all(params.dispatchId, params.throughSequence) as { sequence: number }[]
const settledSequences = new Set(settledReports.map((report) => report.sequence))
if (acknowledgedReports.some((report) => !settledSequences.has(report.sequence))) {
throw new OrchestrationError(
'request_mismatch',
`Federation acknowledgment for ${params.dispatchId} omits a worker_done settlement.`
)
}
}
const terminalOutcomes = new Set(
settledReports.flatMap((report) => (report.outcome ? [report.outcome] : []))
)
if (terminalOutcomes.size > 1) {
throw new OrchestrationError(
'request_mismatch',
`Federation acknowledgment for ${params.dispatchId} contains conflicting settlements.`
)
}
const terminalOutcome = settledReports.find((report) => report.outcome)?.outcome
if (terminalOutcome) {
this.settleRemoteAttachmentInRelayTransaction(
params.dispatchId,
terminalOutcome,
'worker_report_settled'
)
}
this.db
.prepare(
`UPDATE federation_relay_items SET acked_at = COALESCE(acked_at, datetime('now'))
WHERE dispatch_id = ? AND direction = ? AND sequence <= ?`
)
.run(params.dispatchId, params.direction, params.throughSequence)
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export function setFederatedHomeImportSequence(
this: OrchestrationDb,
dispatchId: string,
sequence: number
): void {
this.db
.prepare(
`UPDATE federated_dispatches
SET to_home_imported_sequence = ?, updated_at = datetime('now')
WHERE dispatch_id = ? AND to_home_imported_sequence < ?`
)
.run(sequence, dispatchId, sequence)
}
export function recordFederatedHomeAcknowledgment(
this: OrchestrationDb,
params: {
dispatchId: string
remoteRuntimeEpoch: string
sequence: number
}
): void {
const federated = this.getFederatedDispatch(params.dispatchId)
if (
!federated ||
!Number.isInteger(params.sequence) ||
params.sequence < 0 ||
params.sequence > federated.to_home_imported_sequence
) {
throw new OrchestrationError(
'request_mismatch',
`Federation acknowledgment for ${params.dispatchId} exceeds imported relay state.`
)
}
this.db
.prepare(
`UPDATE federated_dispatches
SET remote_runtime_epoch = ?,
to_home_acknowledged_sequence = CASE
WHEN remote_runtime_epoch = ?
THEN MAX(to_home_acknowledged_sequence, ?)
ELSE ?
END,
updated_at = datetime('now')
WHERE dispatch_id = ?`
)
.run(
params.remoteRuntimeEpoch,
params.remoteRuntimeEpoch,
params.sequence,
params.sequence,
params.dispatchId
)
}
export type FederationRelayAckMethods = {
listFederationRelay: typeof listFederationRelay
listPendingFederationRelay: typeof listPendingFederationRelay
acknowledgeFederationRelay: typeof acknowledgeFederationRelay
setFederatedHomeImportSequence: typeof setFederatedHomeImportSequence
recordFederatedHomeAcknowledgment: typeof recordFederatedHomeAcknowledgment
}
export function attachFederationRelayAck(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
listFederationRelay,
listPendingFederationRelay,
acknowledgeFederationRelay,
setFederatedHomeImportSequence,
recordFederatedHomeAcknowledgment
})
}
@@ -0,0 +1,185 @@
import type {
WorkerReportOutcome,
FederationRelayDirection,
FederationRelayItemRow
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { generateId } from '../generated-id'
import type { OrchestrationDb } from '../orchestration-db'
export function enqueueFederationRelay(
this: OrchestrationDb,
params: {
dispatchId: string
direction: FederationRelayDirection
kind: string
payload: string
messageId?: string
settleRemoteOutcome?: WorkerReportOutcome
remoteQuestion?: true
}
): FederationRelayItemRow {
const byteCount = Buffer.byteLength(params.payload, 'utf8')
const messageId = params.messageId ?? generateId('relay')
if (byteCount > 64 * 1024) {
throw new OrchestrationError(
'relay_quota_exceeded',
'A federated orchestration message cannot exceed 64 KiB.'
)
}
this.db.exec('BEGIN IMMEDIATE')
try {
if (params.settleRemoteOutcome) {
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
if (!attachment || attachment.state !== 'ready') {
throw new OrchestrationError(
'dispatch_inactive',
`Remote Dispatch ${params.dispatchId} is not active.`
)
}
}
if (params.kind === 'heartbeat') {
const heartbeat = this.db
.prepare(
`SELECT * FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat'
AND acked_at IS NULL
ORDER BY sequence DESC LIMIT 1`
)
.get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined
if (heartbeat) {
this.db
.prepare(
`UPDATE federation_relay_items
SET payload = ?, byte_count = ?, created_at = datetime('now')
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
)
.run(params.payload, byteCount, params.dispatchId, params.direction, heartbeat.sequence)
this.db.exec('COMMIT')
return this.getFederationRelayItem(
params.dispatchId,
params.direction,
heartbeat.sequence
) as FederationRelayItemRow
}
}
if (params.kind === 'worker_done') {
const identicalReport = this.db
.prepare(
`SELECT * FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND kind = 'worker_done'
AND payload = ? AND acked_at IS NULL
ORDER BY sequence DESC LIMIT 1`
)
.get(params.dispatchId, params.direction, params.payload) as
| FederationRelayItemRow
| undefined
if (identicalReport) {
this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome)
this.db.exec('COMMIT')
return identicalReport
}
}
const quota = this.db
.prepare(
`SELECT COUNT(*) AS count, COALESCE(SUM(byte_count), 0) AS bytes
FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL`
)
.get(params.dispatchId, params.direction) as { count: number; bytes: number }
if (quota.count >= 256 || quota.bytes + byteCount > 1024 * 1024) {
if (params.kind === 'worker_done') {
const heartbeat = this.db
.prepare(
`SELECT * FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat'
AND acked_at IS NULL
ORDER BY sequence LIMIT 1`
)
.get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined
if (heartbeat) {
this.db
.prepare(
`UPDATE federation_relay_items
SET message_id = ?, kind = ?, payload = ?, byte_count = ?,
created_at = datetime('now')
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
)
.run(
messageId,
params.kind,
params.payload,
byteCount,
params.dispatchId,
params.direction,
heartbeat.sequence
)
this.settleRemoteAttachmentInRelayTransaction(
params.dispatchId,
params.settleRemoteOutcome
)
this.db.exec('COMMIT')
return this.getFederationRelayItem(
params.dispatchId,
params.direction,
heartbeat.sequence
) as FederationRelayItemRow
}
}
throw new OrchestrationError(
'relay_quota_exceeded',
`Federated Dispatch ${params.dispatchId} has no relay capacity.`
)
}
const latest = this.db
.prepare(
`SELECT COALESCE(MAX(sequence), 0) AS sequence
FROM federation_relay_items WHERE dispatch_id = ? AND direction = ?`
)
.get(params.dispatchId, params.direction) as { sequence: number }
const sequence = latest.sequence + 1
this.db
.prepare(
`INSERT INTO federation_relay_items (
dispatch_id, direction, sequence, message_id, kind, payload, byte_count
) VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
params.dispatchId,
params.direction,
sequence,
messageId,
params.kind,
params.payload,
byteCount
)
if (params.remoteQuestion) {
this.db
.prepare(
`INSERT INTO remote_questions (message_id, dispatch_id)
VALUES (?, ?)`
)
.run(messageId, params.dispatchId)
}
this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome)
this.db.exec('COMMIT')
return this.getFederationRelayItem(
params.dispatchId,
params.direction,
sequence
) as FederationRelayItemRow
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export type FederationRelayEnqueueMethods = {
enqueueFederationRelay: typeof enqueueFederationRelay
}
export function attachFederationRelayEnqueue(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
enqueueFederationRelay
})
}
@@ -0,0 +1,140 @@
import type {
MessageType,
MessagePriority,
MessageRow,
WorkerReportOutcome,
WorkerReportSettlement
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import type { OrchestrationDb } from '../orchestration-db'
export function importFederatedRelayItem(
this: OrchestrationDb,
params: {
dispatchId: string
sequence: number
message: {
id: string
runId: string
from: string
to: string
subject: string
body: string
type: MessageType
priority: MessagePriority
threadId?: string
payload?: string
}
lifecycle:
| { kind: 'none' }
| { kind: 'heartbeat'; at: string }
| {
kind: 'worker_report'
taskId: string
outcome: WorkerReportOutcome
result: string
}
| { kind: 'rejected'; code: string; reason: string }
}
): {
message: MessageRow
duplicate: boolean
lifecycle?: WorkerReportSettlement | { action: 'rejected'; code: string; reason: string }
} {
this.db.exec('BEGIN IMMEDIATE')
try {
const federated = this.getFederatedDispatch(params.dispatchId)
if (!federated) {
throw new OrchestrationError(
'dispatch_not_found',
`Federated Dispatch ${params.dispatchId} was not found.`
)
}
const duplicate = params.sequence <= federated.to_home_imported_sequence
if (duplicate && !this.getMessageById(params.message.id)) {
throw new OrchestrationError(
'operation_unknown',
`Federated relay sequence ${params.sequence} was committed without its message.`
)
}
if (!duplicate && params.sequence !== federated.to_home_imported_sequence + 1) {
throw new OrchestrationError(
'operation_unknown',
`Federated relay for ${params.dispatchId} is not contiguous after sequence ${federated.to_home_imported_sequence}.`
)
}
let message = this.getMessageById(params.message.id)
if (!message) {
message = this.insertMessage(params.message)
} else if (
message.run_id !== params.message.runId ||
message.to_handle !== params.message.to ||
message.type !== params.message.type
) {
throw new OrchestrationError(
'request_mismatch',
`Federated relay message ${params.message.id} conflicts with an existing message.`
)
}
if (message.type === 'question') {
this.registerFederatedQuestion({
messageId: message.id,
runId: params.message.runId,
dispatchId: params.dispatchId
})
}
let lifecycle:
| WorkerReportSettlement
| { action: 'rejected'; code: string; reason: string }
| undefined
if (params.lifecycle.kind === 'heartbeat' && !duplicate) {
this.recordHeartbeat(params.dispatchId, params.lifecycle.at)
} else if (params.lifecycle.kind === 'worker_report') {
lifecycle = this.settleWorkerReportInTransaction({
taskId: params.lifecycle.taskId,
dispatchId: params.dispatchId,
outcome: params.lifecycle.outcome,
result: params.lifecycle.result
})
if (lifecycle.action === 'rejected' && !duplicate) {
message = this.convertLifecycleMessageToRejection(
message.id,
lifecycle.code,
lifecycle.reason
) as MessageRow
}
} else if (params.lifecycle.kind === 'rejected') {
lifecycle = {
action: 'rejected',
code: params.lifecycle.code,
reason: params.lifecycle.reason
}
if (!duplicate) {
message = this.convertLifecycleMessageToRejection(
message.id,
params.lifecycle.code,
params.lifecycle.reason
) as MessageRow
}
}
if (!duplicate) {
this.setFederatedHomeImportSequence(params.dispatchId, params.sequence)
}
this.db.exec('COMMIT')
return { message, duplicate, ...(lifecycle ? { lifecycle } : {}) }
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export type FederationRelayImportMethods = {
importFederatedRelayItem: typeof importFederatedRelayItem
}
export function attachFederationRelayImport(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
importFederatedRelayItem
})
}
@@ -0,0 +1,69 @@
import type {
WorkerReportOutcome,
FederationRelayDirection,
FederationRelayItemRow
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import type { OrchestrationDb } from '../orchestration-db'
export function getFederationRelayItem(
this: OrchestrationDb,
dispatchId: string,
direction: FederationRelayDirection,
sequence: number
): FederationRelayItemRow | undefined {
return this.db
.prepare(
`SELECT * FROM federation_relay_items
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
)
.get(dispatchId, direction, sequence) as FederationRelayItemRow | undefined
}
export function settleRemoteAttachmentInRelayTransaction(
this: OrchestrationDb,
dispatchId: string,
outcome: WorkerReportOutcome | undefined,
stage = 'worker_report_queued'
): void {
if (!outcome) {
return
}
const attachment = this.getRemoteDispatchAttachment(dispatchId)
const state = outcome === 'succeeded' ? 'succeeded' : 'failed'
if (!attachment) {
throw new OrchestrationError(
'dispatch_not_found',
`Remote Dispatch ${dispatchId} was not found.`
)
}
if (attachment.state === state) {
return
}
if (attachment.state !== 'ready') {
throw new OrchestrationError(
'request_mismatch',
`Remote Dispatch ${dispatchId} cannot settle as ${state} from ${attachment.state}.`
)
}
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET state = ?, stage = ?, capability_hash = NULL,
updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'ready'`
)
.run(state, stage, dispatchId)
}
export type FederationRelayItemMethods = {
getFederationRelayItem: typeof getFederationRelayItem
settleRemoteAttachmentInRelayTransaction: typeof settleRemoteAttachmentInRelayTransaction
}
export function attachFederationRelayItem(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
getFederationRelayItem,
settleRemoteAttachmentInRelayTransaction
})
}
@@ -0,0 +1,173 @@
import { randomBytes, timingSafeEqual } from 'node:crypto'
import type { RemoteDispatchAttachmentRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { hashDispatchCapability } from '../dispatch-capability-hash'
import { isEquivalentPaneKey } from '../pane-key-match'
import type { OrchestrationDb } from '../orchestration-db'
export function prepareRemoteAttachmentAuthority(
this: OrchestrationDb,
params: {
dispatchId: string
paneKey: string
processIncarnation: string
worktreeId: string
terminalHandle: string
setupState: string
effects: unknown[]
}
): string {
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
if (!attachment || attachment.state !== 'starting') {
throw new OrchestrationError(
'dispatch_inactive',
`Remote Dispatch ${params.dispatchId} is not starting.`
)
}
const capability = `dcap_${randomBytes(32).toString('base64url')}`
const result = this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET stage = 'authority_attached', capability_hash = ?, pane_key = ?,
process_incarnation = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?,
effects = ?, residual_resources = ?, updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'starting'`
)
.run(
hashDispatchCapability(capability),
params.paneKey,
params.processIncarnation,
params.worktreeId,
params.terminalHandle,
params.setupState,
JSON.stringify(params.effects),
JSON.stringify(
params.effects.filter((effect) =>
Boolean(
effect &&
typeof effect === 'object' &&
((effect as { action?: string }).action?.startsWith('created') ||
(effect as { action?: string }).action === 'reused_agent_terminal')
)
)
),
params.dispatchId
)
// Why: without this the caller keeps a capability whose hash was never stored, surfacing later as an authority mismatch.
if (result.changes !== 1) {
throw new OrchestrationError(
'dispatch_inactive',
`Remote Dispatch ${params.dispatchId} is not starting.`
)
}
return capability
}
export function markRemoteAttachmentReady(
this: OrchestrationDb,
dispatchId: string,
effects?: unknown[]
): RemoteDispatchAttachmentRow {
const result = this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET state = 'ready', stage = 'input_accepted',
effects = COALESCE(?, effects), updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'starting'`
)
.run(effects ? JSON.stringify(effects) : null, dispatchId)
if (result.changes !== 1) {
throw new OrchestrationError(
'dispatch_inactive',
`Remote Dispatch ${dispatchId} is not starting.`
)
}
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
}
export function failRemoteAttachment(
this: OrchestrationDb,
dispatchId: string,
stage: string,
reason: string,
unknown: boolean
): RemoteDispatchAttachmentRow {
const state = unknown ? 'start_unknown' : 'failed'
const result = this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET state = ?, stage = ?, last_error = ?, capability_hash = NULL,
updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'starting'`
)
.run(state, stage, reason, dispatchId)
if (result.changes !== 1) {
throw new OrchestrationError(
'dispatch_inactive',
`Remote Dispatch ${dispatchId} is not starting.`
)
}
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
}
export function verifyRemoteAttachmentAuthority(
this: OrchestrationDb,
params: {
dispatchId: string
capability: string | undefined
paneKey: string | null
processIncarnation: string | null
}
): boolean {
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
if (
!attachment?.capability_hash ||
!params.capability ||
!attachment.pane_key ||
!params.paneKey ||
!isEquivalentPaneKey(attachment.pane_key, params.paneKey) ||
!attachment.process_incarnation ||
attachment.process_incarnation !== params.processIncarnation
) {
return false
}
const expected = Buffer.from(attachment.capability_hash, 'hex')
const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex')
return expected.length === observed.length && timingSafeEqual(expected, observed)
}
export function isRemoteAttachmentProcessCurrent(
this: OrchestrationDb,
params: {
dispatchId: string
paneKey: string | null
processIncarnation: string | null
}
): boolean {
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
return Boolean(
attachment?.pane_key &&
params.paneKey &&
isEquivalentPaneKey(attachment.pane_key, params.paneKey) &&
attachment.process_incarnation &&
attachment.process_incarnation === params.processIncarnation
)
}
export type RemoteDispatchAttachmentAuthorityMethods = {
prepareRemoteAttachmentAuthority: typeof prepareRemoteAttachmentAuthority
markRemoteAttachmentReady: typeof markRemoteAttachmentReady
failRemoteAttachment: typeof failRemoteAttachment
verifyRemoteAttachmentAuthority: typeof verifyRemoteAttachmentAuthority
isRemoteAttachmentProcessCurrent: typeof isRemoteAttachmentProcessCurrent
}
export function attachRemoteDispatchAttachmentAuthority(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
prepareRemoteAttachmentAuthority,
markRemoteAttachmentReady,
failRemoteAttachment,
verifyRemoteAttachmentAuthority,
isRemoteAttachmentProcessCurrent
})
}
@@ -0,0 +1,177 @@
import type { WorkerDispatchState, RemoteDispatchAttachmentRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity'
import type { OrchestrationDb } from '../orchestration-db'
export function createRemoteDispatchAttachment(
this: OrchestrationDb,
params: {
dispatchId: string
taskId: string
homePeerFingerprint: string
protocolVersion: number
runtimeEpoch: string
mutationReceipt: {
callerFingerprint: string
requestId: string
method: string
payloadHash: string
}
}
): RemoteDispatchAttachmentRow {
this.db.exec('BEGIN IMMEDIATE')
try {
if (params.homePeerFingerprint !== params.mutationReceipt.callerFingerprint) {
throw new OrchestrationError(
'resource_server_mismatch',
'The authenticated Run-home peer does not match the attachment request.'
)
}
const existingReceipt = this.getMutationReceipt(
params.mutationReceipt.callerFingerprint,
params.mutationReceipt.requestId
)
if (existingReceipt) {
throw new OrchestrationError(
existingReceipt.method === params.mutationReceipt.method &&
existingReceipt.payload_hash === params.mutationReceipt.payloadHash
? 'operation_unknown'
: 'request_mismatch',
`Remote attachment request ${params.mutationReceipt.requestId} already exists.`
)
}
ensureMutationReceiptCapacity(this.db)
this.db
.prepare(
`INSERT INTO mutation_receipts (
caller_fingerprint, request_id, method, payload_hash, state, receipt
) VALUES (?, ?, ?, ?, 'pending', ?)`
)
.run(
params.mutationReceipt.callerFingerprint,
params.mutationReceipt.requestId,
params.mutationReceipt.method,
params.mutationReceipt.payloadHash,
JSON.stringify({ accepted: { dispatchId: params.dispatchId } })
)
this.db
.prepare(
`INSERT INTO remote_dispatch_attachments (
dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch
) VALUES (?, ?, ?, ?, ?)`
)
.run(
params.dispatchId,
params.taskId,
params.homePeerFingerprint,
params.protocolVersion,
params.runtimeEpoch
)
this.db.exec('COMMIT')
return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export function getRemoteDispatchAttachment(
this: OrchestrationDb,
dispatchId: string
): RemoteDispatchAttachmentRow | undefined {
return this.db
.prepare('SELECT * FROM remote_dispatch_attachments WHERE dispatch_id = ?')
.get(dispatchId) as RemoteDispatchAttachmentRow | undefined
}
export function recordRemoteAttachmentStage(
this: OrchestrationDb,
params: {
dispatchId: string
stage: string
state?: WorkerDispatchState
worktreeId?: string
terminalHandle?: string
setupState?: string
effects?: unknown[]
residualResources?: unknown[]
lastError?: string
}
): RemoteDispatchAttachmentRow {
const current = this.getRemoteDispatchAttachment(params.dispatchId)
if (!current) {
throw new OrchestrationError(
'dispatch_not_found',
`Remote Dispatch ${params.dispatchId} was not found.`
)
}
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET stage = ?, state = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?,
effects = ?, residual_resources = ?, last_error = ?, updated_at = datetime('now')
WHERE dispatch_id = ?`
)
.run(
params.stage,
params.state ?? current.state,
params.worktreeId ?? current.worktree_id,
params.terminalHandle ?? current.terminal_handle,
params.setupState ?? current.setup_state,
params.effects ? JSON.stringify(params.effects) : current.effects,
params.residualResources
? JSON.stringify(params.residualResources)
: current.residual_resources,
params.lastError ?? current.last_error,
params.dispatchId
)
return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow
}
export function updateRemoteAttachmentSetupEvidence(
this: OrchestrationDb,
params: {
dispatchId: string
setupState: string
effects: unknown[]
}
): { attachment: RemoteDispatchAttachmentRow; changed: boolean } {
const current = this.getRemoteDispatchAttachment(params.dispatchId)
if (!current) {
throw new OrchestrationError(
'dispatch_not_found',
`Remote Dispatch ${params.dispatchId} was not found.`
)
}
const effects = JSON.stringify(params.effects)
if (current.setup_state === params.setupState && current.effects === effects) {
return { attachment: current, changed: false }
}
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET setup_state = ?, effects = ?, updated_at = datetime('now')
WHERE dispatch_id = ?`
)
.run(params.setupState, effects, params.dispatchId)
return {
attachment: this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow,
changed: true
}
}
export type RemoteDispatchAttachmentCreateMethods = {
createRemoteDispatchAttachment: typeof createRemoteDispatchAttachment
getRemoteDispatchAttachment: typeof getRemoteDispatchAttachment
recordRemoteAttachmentStage: typeof recordRemoteAttachmentStage
updateRemoteAttachmentSetupEvidence: typeof updateRemoteAttachmentSetupEvidence
}
export function attachRemoteDispatchAttachmentCreate(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
createRemoteDispatchAttachment,
getRemoteDispatchAttachment,
recordRemoteAttachmentStage,
updateRemoteAttachmentSetupEvidence
})
}
@@ -0,0 +1,106 @@
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
import type { RemoteDispatchAttachmentRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { paneKeyMatchSuffix, REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match'
import type { OrchestrationDb } from '../orchestration-db'
export function beginRemoteAttachmentStop(
this: OrchestrationDb,
dispatchId: string
): RemoteDispatchAttachmentRow {
const attachment = this.getRemoteDispatchAttachment(dispatchId)
if (!attachment) {
throw new OrchestrationError(
'dispatch_not_found',
`Remote Dispatch ${dispatchId} was not found.`
)
}
if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(attachment.state)) {
return attachment
}
if (!['ready', 'start_unknown'].includes(attachment.state)) {
throw new OrchestrationError(
'dispatch_inactive',
`Remote Dispatch ${dispatchId} cannot stop from ${attachment.state}.`
)
}
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET state = 'stopping', stage = 'stop_requested', capability_hash = NULL,
updated_at = datetime('now')
WHERE dispatch_id = ? AND state IN ('ready', 'start_unknown')`
)
.run(dispatchId)
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
}
export function settleRemoteAttachmentStop(
this: OrchestrationDb,
dispatchId: string
): RemoteDispatchAttachmentRow {
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET state = 'stopped', stage = 'process_stopped', updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'stopping'`
)
.run(dispatchId)
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
}
export function markRemoteAttachmentStopUnknown(
this: OrchestrationDb,
dispatchId: string,
reason: string
): RemoteDispatchAttachmentRow {
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET state = 'stop_unknown', stage = 'stop_outcome_unknown', last_error = ?,
updated_at = datetime('now')
WHERE dispatch_id = ? AND state = 'stopping'`
)
.run(reason, dispatchId)
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
}
export function findActiveRemoteAttachmentForPane(
this: OrchestrationDb,
paneKey: string
): RemoteDispatchAttachmentRow | undefined {
if (!parsePaneKey(paneKey)) {
return this.db
.prepare(
`SELECT * FROM remote_dispatch_attachments
WHERE state IN ('starting', 'ready') AND pane_key = ?
ORDER BY rowid DESC LIMIT 1`
)
.get(paneKey) as RemoteDispatchAttachmentRow | undefined
}
return this.db
.prepare(
`SELECT * FROM remote_dispatch_attachments
WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL
AND instr(pane_key, ':') > 1
AND ${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL} = ?
ORDER BY rowid DESC LIMIT 1`
)
.get(paneKeyMatchSuffix(paneKey)) as RemoteDispatchAttachmentRow | undefined
}
export type RemoteDispatchAttachmentStopMethods = {
beginRemoteAttachmentStop: typeof beginRemoteAttachmentStop
settleRemoteAttachmentStop: typeof settleRemoteAttachmentStop
markRemoteAttachmentStopUnknown: typeof markRemoteAttachmentStopUnknown
findActiveRemoteAttachmentForPane: typeof findActiveRemoteAttachmentForPane
}
export function attachRemoteDispatchAttachmentStop(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
beginRemoteAttachmentStop,
settleRemoteAttachmentStop,
markRemoteAttachmentStopUnknown,
findActiveRemoteAttachmentForPane
})
}
@@ -0,0 +1,139 @@
import { OrchestrationError } from '../../orchestration-error'
import type { OrchestrationDb } from '../orchestration-db'
export function getRemoteQuestion(
this: OrchestrationDb,
messageId: string
):
| {
message_id: string
dispatch_id: string
status: 'pending' | 'answered' | 'closed'
answer_message_id: string | null
answer_body: string | null
}
| undefined {
return this.db.prepare('SELECT * FROM remote_questions WHERE message_id = ?').get(messageId) as
| {
message_id: string
dispatch_id: string
status: 'pending' | 'answered' | 'closed'
answer_message_id: string | null
answer_body: string | null
}
| undefined
}
type RemoteAnswerParams = {
messageId: string
dispatchId: string
answerMessageId: string
body: string
}
// Returns 'settled' when the stored row already carries this exact answer, 'writable' when the guarded UPDATE may apply.
function classifyRemoteQuestion(
this: OrchestrationDb,
params: RemoteAnswerParams
): 'settled' | 'writable' {
const question = this.getRemoteQuestion(params.messageId)
if (!question || question.dispatch_id !== params.dispatchId) {
throw new OrchestrationError(
'question_not_found',
`Remote Question ${params.messageId} was not found.`
)
}
if (question.status === 'answered') {
if (
question.answer_message_id !== params.answerMessageId ||
question.answer_body !== params.body
) {
throw new OrchestrationError(
'answer_conflict',
`Remote Question ${params.messageId} already has a different answer.`
)
}
return 'settled'
}
// Why: the guarded UPDATE is a silent no-op for a closed question, which the caller would read as stored.
if (question.status === 'closed') {
throw new OrchestrationError(
'question_not_found',
`Remote Question ${params.messageId} is closed.`
)
}
return 'writable'
}
export function answerRemoteQuestion(this: OrchestrationDb, params: RemoteAnswerParams): void {
if (classifyRemoteQuestion.call(this, params) === 'settled') {
return
}
const changes = this.db
.prepare(
`UPDATE remote_questions
SET status = 'answered', answer_message_id = ?, answer_body = ?,
answered_at = datetime('now')
WHERE message_id = ? AND status = 'pending'`
)
.run(params.answerMessageId, params.body, params.messageId).changes
if (changes > 0) {
return
}
// Why: a concurrent answer or close won the guarded UPDATE after our read; re-classify so the caller
// sees the stored outcome instead of a success that never happened.
if (classifyRemoteQuestion.call(this, params) === 'settled') {
return
}
throw new OrchestrationError(
'answer_conflict',
`Remote Question ${params.messageId} could not be answered.`
)
}
export function setRemoteWorkerImportSequence(
this: OrchestrationDb,
dispatchId: string,
sequence: number
): void {
this.db
.prepare(
`UPDATE remote_dispatch_attachments
SET to_worker_imported_sequence = ?, updated_at = datetime('now')
WHERE dispatch_id = ? AND to_worker_imported_sequence < ?`
)
.run(sequence, dispatchId, sequence)
}
export function registerFederatedQuestion(
this: OrchestrationDb,
params: {
messageId: string
runId: string
dispatchId: string
}
): void {
this.db
.prepare(
`INSERT OR IGNORE INTO question_threads (
message_id, run_id, dispatch_id, asker_handle
) VALUES (?, ?, ?, ?)`
)
.run(params.messageId, params.runId, params.dispatchId, `dispatch:${params.dispatchId}`)
}
export type RemoteQuestionStoreMethods = {
getRemoteQuestion: typeof getRemoteQuestion
answerRemoteQuestion: typeof answerRemoteQuestion
setRemoteWorkerImportSequence: typeof setRemoteWorkerImportSequence
registerFederatedQuestion: typeof registerFederatedQuestion
}
export function attachRemoteQuestionStore(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
getRemoteQuestion,
answerRemoteQuestion,
setRemoteWorkerImportSequence,
registerFederatedQuestion
})
}
@@ -0,0 +1,5 @@
import { randomBytes } from 'node:crypto'
export function generateId(prefix: string): string {
return `${prefix}_${randomBytes(6).toString('hex')}`
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import type { MessageRow } from '../types'
import {
legacyMessageMatchesQuestion,
normalizeLegacyQuestionOptions,
normalizeLegacyQuestionText
} from './legacy-question-identity'
function message(overrides: Partial<MessageRow> = {}): MessageRow {
return {
id: 'msg_1',
run_id: 'run_1',
delivery_contract: 'legacy_direct',
from_handle: 'from',
to_handle: 'to',
subject: 'q',
body: 'Ask this?\r\n',
type: 'question',
priority: 'normal',
thread_id: null,
payload: '{"options":[" yes ","no"]}',
read: 0,
sequence: 1,
created_at: '2026-01-01 00:00:00',
delivered_at: null,
sender_pane_key: null,
...overrides
}
}
describe('legacy-question-identity', () => {
it('normalizes question text and option arrays', () => {
expect(normalizeLegacyQuestionText(' a\r\nb ')).toBe('a\nb')
expect(normalizeLegacyQuestionOptions([' yes ', 'no'])).toBe('["yes","no"]')
expect(normalizeLegacyQuestionOptions(['yes', 1])).toBe('[]')
})
it('matches a question only when recipient, body, and options agree', () => {
expect(legacyMessageMatchesQuestion(message(), 'Ask this?', ['yes', 'no'], ['to'])).toBe(true)
expect(legacyMessageMatchesQuestion(message(), 'Ask this?', ['yes', 'no'], ['other'])).toBe(
false
)
expect(legacyMessageMatchesQuestion(message(), 'Different', ['yes', 'no'], ['to'])).toBe(false)
expect(
legacyMessageMatchesQuestion(message({ payload: '{' }), 'Ask this?', ['yes'], ['to'])
).toBe(false)
})
})
@@ -0,0 +1,34 @@
import type { MessageRow } from '../types'
export function normalizeLegacyQuestionText(value: string): string {
return value.replace(/\r\n/g, '\n').trim()
}
export function normalizeLegacyQuestionOptions(options: unknown): string {
if (!Array.isArray(options) || !options.every((option) => typeof option === 'string')) {
return '[]'
}
return JSON.stringify(options.map((option) => option.trim()))
}
export function legacyMessageMatchesQuestion(
message: MessageRow,
question: string,
options: string[],
recipientHandles: readonly string[]
): boolean {
if (
!recipientHandles.includes(message.to_handle) ||
normalizeLegacyQuestionText(message.body) !== normalizeLegacyQuestionText(question)
) {
return false
}
try {
const payload = JSON.parse(message.payload ?? '{}') as { options?: unknown }
return (
normalizeLegacyQuestionOptions(payload.options) === normalizeLegacyQuestionOptions(options)
)
} catch {
return false
}
}
@@ -0,0 +1,148 @@
import type { MessageRow, LegacyOperationReceiptRow, QuestionRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
import { legacyMessageMatchesQuestion } from '../legacy-question-identity'
import type { OrchestrationDb } from '../orchestration-db'
export function commitLegacyAskOperation(
this: OrchestrationDb,
params: {
principalId: string
operationKey: string
method: string
payloadHash: string
question: string
options?: string[]
recipientHandle: string
existingQuestionId?: string
}
): {
receipt: LegacyOperationReceiptRow
question: QuestionRow
message: MessageRow
duplicate: boolean
} {
this.db.exec('BEGIN IMMEDIATE')
try {
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'worker')
const receipt = this.requireMatchingLegacyOperationReceipt(params)
if (receipt) {
const response = JSON.parse(receipt.response_json) as { questionId: string }
const question = this.getQuestion(response.questionId)
const message = this.getMessageById(response.questionId)
if (!question || !message) {
throw new OrchestrationError(
'operation_unknown',
`Legacy ask ${params.operationKey} lost its durable question.`
)
}
this.db.exec('COMMIT')
return { receipt, question, message, duplicate: true }
}
const dispatchId = principal.dispatch_id as string
const dispatch = this.getDispatchContextById(dispatchId)
if (
!dispatch ||
dispatch.run_id !== principal.run_id ||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION ||
!['pending', 'dispatched'].includes(dispatch.status)
) {
throw new OrchestrationError(
'dispatch_inactive',
`Dispatch ${dispatchId} is not an active legacy attempt.`
)
}
const existingQuestionId =
params.existingQuestionId &&
!this.db
.prepare(
`SELECT 1 FROM legacy_operation_receipts
WHERE principal_id = ? AND method = 'orchestration.ask' AND effect_id = ?
LIMIT 1`
)
.get(principal.id, params.existingQuestionId)
? params.existingQuestionId
: undefined
let question: QuestionRow
let message: MessageRow
const delivery = this.resolveLegacyWorkerCoordinatorDelivery(
principal.run_id,
params.recipientHandle
)
if (existingQuestionId) {
const existingQuestion = this.getQuestion(existingQuestionId)
const existingMessage = this.getMessageById(existingQuestionId)
if (
!existingQuestion ||
!existingMessage ||
existingQuestion.run_id !== principal.run_id ||
existingQuestion.dispatch_id !== dispatchId ||
existingQuestion.status !== 'pending' ||
existingMessage.delivery_contract !== delivery.contract ||
!legacyMessageMatchesQuestion(existingMessage, params.question, params.options ?? [], [
delivery.to
])
) {
throw new OrchestrationError(
'request_mismatch',
`Question ${params.existingQuestionId} is not a pending ask for this principal.`
)
}
question = existingQuestion
message = existingMessage
} else {
message = this.insertMessage({
from: principal.terminal_handle,
to: delivery.to,
subject: 'Question',
body: params.question,
type: delivery.contract === 'legacy_direct' ? 'decision_gate' : 'question',
payload: JSON.stringify({
taskId: dispatch.task_id,
dispatchId,
question: params.question,
options: params.options ?? []
}),
senderPaneKey: principal.pane_key,
runId: principal.run_id,
deliveryContract: delivery.contract
})
this.db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(message.id, message.id)
this.db
.prepare(
`INSERT INTO question_threads (
message_id, run_id, dispatch_id, asker_handle
) VALUES (?, ?, ?, ?)`
)
.run(message.id, principal.run_id, dispatchId, principal.terminal_handle)
question = this.getQuestion(message.id) as QuestionRow
message = this.getMessageById(message.id) as MessageRow
}
const committedReceipt = this.insertLegacyOperationReceipt({
principalId: principal.id,
operationKey: params.operationKey,
method: params.method,
payloadHash: params.payloadHash,
effectId: question.message_id,
responseJson: JSON.stringify({ questionId: question.message_id })
})
this.db.exec('COMMIT')
return { receipt: committedReceipt, question, message, duplicate: false }
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export type LegacyAskOperationMethods = {
commitLegacyAskOperation: typeof commitLegacyAskOperation
}
export function attachLegacyAskOperation(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
commitLegacyAskOperation
})
}
@@ -0,0 +1,185 @@
import type {
DispatchContextRow,
LegacyCompatibilityPrincipalRow,
LegacyPrincipalRole
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
import { isEquivalentPaneKey } from '../pane-key-match'
import type { OrchestrationDb } from '../orchestration-db'
export function resolveLegacyCompatibilityPrincipalByIdentity(
this: OrchestrationDb,
params: {
runId: string
role: LegacyPrincipalRole
terminalHandle?: string
paneKey?: string
}
): LegacyCompatibilityPrincipalRow | undefined {
if (!params.terminalHandle && !params.paneKey) {
return undefined
}
const rows = (
this.db
.prepare(
`SELECT * FROM legacy_compatibility_principals
WHERE run_id = ? AND role = ? AND status IN ('committed', 'settled')
ORDER BY rowid`
)
.all(params.runId, params.role) as LegacyCompatibilityPrincipalRow[]
).filter((principal) =>
params.paneKey
? isEquivalentPaneKey(principal.pane_key, params.paneKey)
: principal.terminal_handle === params.terminalHandle
)
if (rows.length > 1) {
throw new OrchestrationError(
'operation_unknown',
'Multiple legacy principals match this process identity.'
)
}
return rows[0]
}
export function resolveLegacyWorkerCandidate(
this: OrchestrationDb,
params: {
runId?: string
terminalHandle?: string
paneKey?: string
dispatchId?: string
taskId?: string
}
): { dispatch: DispatchContextRow } | undefined {
if (!params.runId || (!params.terminalHandle && !params.paneKey)) {
return undefined
}
const rows = (
params.dispatchId
? [this.getDispatchContextById(params.dispatchId)].filter(
(row): row is DispatchContextRow => row !== undefined
)
: (this.db
.prepare(
`SELECT * FROM dispatch_contexts
WHERE run_id = ? AND contract_version = ?
AND status IN ('pending', 'dispatched')
ORDER BY rowid`
)
.all(params.runId, LEGACY_CONTRACT_VERSION) as DispatchContextRow[])
).filter(
(dispatch) =>
dispatch.run_id === params.runId &&
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
(!params.taskId || dispatch.task_id === params.taskId) &&
(params.paneKey
? Boolean(
dispatch.assignee_pane_key &&
isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey)
)
: dispatch.assignee_handle === params.terminalHandle)
)
if (rows.length > 1) {
throw new OrchestrationError(
'operation_unknown',
'Multiple active legacy Dispatches match this process identity.'
)
}
if (params.dispatchId && rows.length === 0) {
const target = this.getDispatchContextById(params.dispatchId)
if (target?.contract_version === LEGACY_CONTRACT_VERSION) {
throw new OrchestrationError(
'legacy_read_only',
`Dispatch ${params.dispatchId} is retained but this process cannot prove ownership.`
)
}
}
return rows[0] ? { dispatch: rows[0] } : undefined
}
export function resolveLegacyCoordinatorCandidate(
this: OrchestrationDb,
params: {
runId: string
terminalHandle?: string
paneKey?: string
}
): { terminalHandle: string; paneKey: string } | undefined {
if (!params.terminalHandle || !params.paneKey) {
return undefined
}
const run = this.getRunRaw(params.runId)
const principal = this.getLegacyCoordinatorPrincipal(params.runId)
if (principal) {
if (
principal.status !== 'committed' ||
principal.terminal_handle !== params.terminalHandle ||
!isEquivalentPaneKey(principal.pane_key, params.paneKey) ||
(run?.coordinator_pane_key !== null &&
(run?.coordinator_handle !== principal.terminal_handle ||
!isEquivalentPaneKey(run.coordinator_pane_key, principal.pane_key)))
) {
return undefined
}
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
}
// Why: the first current binding durably fences uncommitted legacy processes.
if (
!run ||
run.coordinator_pane_key !== null ||
this.getUniqueLegacyCoordinatorHandle(params.runId) !== params.terminalHandle
) {
return undefined
}
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
}
// Why: caller-side fence jurisdiction. Widening this fences more callers, so keep it narrow.
export function isLegacyCoordinatorHandle(
this: OrchestrationDb,
runId: string,
terminalHandle: string
): boolean {
const principal = this.getLegacyCoordinatorPrincipal(runId)
if (principal) {
return principal.terminal_handle === terminalHandle
}
return this.getUniqueLegacyCoordinatorHandle(runId) === terminalHandle
}
// Why: recipient-side permit for legacy lifecycle mail. After a takeover revokes the old principal
// the replacement coordinator is only reachable through the Run binding.
export function isLegacyCoordinatorDeliveryTarget(
this: OrchestrationDb,
runId: string,
terminalHandle: string
): boolean {
if (this.isLegacyCoordinatorHandle(runId, terminalHandle)) {
return true
}
if (this.getRunRaw(runId)?.coordinator_handle !== terminalHandle) {
return false
}
// Why: must match resolveLegacyWorkerCoordinatorDelivery's takeover test. A still-committed
// principal routes legacy_direct to this handle, and no reader can see that mailbox.
return this.getLegacyCoordinatorPrincipal(runId)?.status !== 'committed'
}
export type LegacyCompatibilityCandidatesMethods = {
resolveLegacyCompatibilityPrincipalByIdentity: typeof resolveLegacyCompatibilityPrincipalByIdentity
resolveLegacyWorkerCandidate: typeof resolveLegacyWorkerCandidate
resolveLegacyCoordinatorCandidate: typeof resolveLegacyCoordinatorCandidate
isLegacyCoordinatorHandle: typeof isLegacyCoordinatorHandle
isLegacyCoordinatorDeliveryTarget: typeof isLegacyCoordinatorDeliveryTarget
}
export function attachLegacyCompatibilityCandidates(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
resolveLegacyCompatibilityPrincipalByIdentity,
resolveLegacyWorkerCandidate,
resolveLegacyCoordinatorCandidate,
isLegacyCoordinatorHandle,
isLegacyCoordinatorDeliveryTarget
})
}
@@ -0,0 +1,188 @@
import type {
LegacyAdoptionRow,
LegacyCompatibilityPrincipalRow,
LegacyPrincipalRole
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { LEGACY_RUN_ID, LEGACY_CONTRACT_VERSION } from '../contract-constants'
import { generateId } from '../generated-id'
import type { OrchestrationDb } from '../orchestration-db'
// ── Legacy adoption and compatibility principals ──
export function getLegacyAdoption(this: OrchestrationDb): LegacyAdoptionRow | undefined {
return this.db
.prepare('SELECT * FROM legacy_adoptions WHERE source_run_id = ?')
.get(LEGACY_RUN_ID) as LegacyAdoptionRow | undefined
}
export function commitLegacyCompatibilityPrincipal(
this: OrchestrationDb,
params: {
runId: string
dispatchId?: string
role: LegacyPrincipalRole
hostScope: string
terminalHandle: string
paneKey: string
launchTokenHash: string
processIncarnation?: string
}
): { principal: LegacyCompatibilityPrincipalRow; duplicate: boolean } {
this.db.exec('BEGIN IMMEDIATE')
try {
const adoption = this.getLegacyAdoption()
if (!adoption || adoption.adopted_run_id !== params.runId) {
throw new OrchestrationError(
'request_mismatch',
`Run ${params.runId} is not the adopted legacy Run.`
)
}
const dispatchId = params.role === 'worker' ? (params.dispatchId ?? null) : null
let initialStatus: 'committed' | 'settled' = 'committed'
if (params.role === 'worker') {
const dispatch = dispatchId ? this.getDispatchContextById(dispatchId) : undefined
if (
!dispatch ||
dispatch.run_id !== params.runId ||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION
) {
throw new OrchestrationError(
'request_mismatch',
`Dispatch ${dispatchId ?? '(missing)'} is not a legacy attempt in this Run.`
)
}
initialStatus = ['pending', 'dispatched'].includes(dispatch.status) ? 'committed' : 'settled'
} else if (params.dispatchId) {
throw new OrchestrationError(
'request_mismatch',
'A coordinator compatibility principal cannot name a Dispatch.'
)
}
const existing = this.db
.prepare(
`SELECT * FROM legacy_compatibility_principals
WHERE role = ? AND run_id = ? AND dispatch_id IS ?`
)
.get(params.role, params.runId, dispatchId) as LegacyCompatibilityPrincipalRow | undefined
if (existing) {
const same =
existing.host_scope === params.hostScope &&
existing.terminal_handle === params.terminalHandle &&
existing.pane_key === params.paneKey &&
existing.launch_token_hash === params.launchTokenHash &&
existing.process_incarnation === (params.processIncarnation ?? null)
if (!same) {
throw new OrchestrationError(
'request_mismatch',
`The ${params.role} compatibility principal is already committed to different proof.`
)
}
if (existing.status === 'revoked') {
throw new OrchestrationError(
'legacy_read_only',
`The ${params.role} compatibility principal has been revoked. No effects were applied.`,
{ effectsApplied: false }
)
}
this.db.exec('COMMIT')
return { principal: existing, duplicate: true }
}
if (
params.role === 'coordinator' &&
!this.resolveLegacyCoordinatorCandidate({
runId: params.runId,
terminalHandle: params.terminalHandle,
paneKey: params.paneKey
})
) {
throw new OrchestrationError(
'legacy_read_only',
'This retained legacy coordinator no longer has lifecycle authority. No effects were applied.',
{ effectsApplied: false }
)
}
const id = generateId('legacy_principal')
this.db
.prepare(
`INSERT INTO legacy_compatibility_principals (
id, run_id, dispatch_id, role, host_scope, terminal_handle,
pane_key, launch_token_hash, process_incarnation, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
params.runId,
dispatchId,
params.role,
params.hostScope,
params.terminalHandle,
params.paneKey,
params.launchTokenHash,
params.processIncarnation ?? null,
initialStatus
)
const principal = this.getLegacyCompatibilityPrincipal(id) as LegacyCompatibilityPrincipalRow
if (principal.status === 'committed') {
this.initializeLegacyRecoveryCohort(principal)
}
this.db.exec('COMMIT')
return { principal, duplicate: false }
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export function getLegacyCompatibilityPrincipal(
this: OrchestrationDb,
id: string
): LegacyCompatibilityPrincipalRow | undefined {
return this.db.prepare('SELECT * FROM legacy_compatibility_principals WHERE id = ?').get(id) as
| LegacyCompatibilityPrincipalRow
| undefined
}
export function listLegacyCompatibilityPrincipals(
this: OrchestrationDb,
runId: string
): LegacyCompatibilityPrincipalRow[] {
return this.db
.prepare(
`SELECT * FROM legacy_compatibility_principals
WHERE run_id = ? ORDER BY rowid`
)
.all(runId) as LegacyCompatibilityPrincipalRow[]
}
export function getLegacyCoordinatorPrincipal(
this: OrchestrationDb,
runId: string
): LegacyCompatibilityPrincipalRow | undefined {
return this.db
.prepare(
`SELECT * FROM legacy_compatibility_principals
WHERE run_id = ? AND role = 'coordinator'`
)
.get(runId) as LegacyCompatibilityPrincipalRow | undefined
}
export type LegacyCompatibilityPrincipalsMethods = {
getLegacyAdoption: typeof getLegacyAdoption
commitLegacyCompatibilityPrincipal: typeof commitLegacyCompatibilityPrincipal
getLegacyCompatibilityPrincipal: typeof getLegacyCompatibilityPrincipal
listLegacyCompatibilityPrincipals: typeof listLegacyCompatibilityPrincipals
getLegacyCoordinatorPrincipal: typeof getLegacyCoordinatorPrincipal
}
export function attachLegacyCompatibilityPrincipals(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
getLegacyAdoption,
commitLegacyCompatibilityPrincipal,
getLegacyCompatibilityPrincipal,
listLegacyCompatibilityPrincipals,
getLegacyCoordinatorPrincipal
})
}
@@ -0,0 +1,196 @@
import type {
MessageType,
MessagePriority,
MessageRow,
WorkerReportOutcome,
WorkerReportSettlement,
LegacyOperationReceiptRow
} from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
import type { OrchestrationDb } from '../orchestration-db'
export function commitLegacyLifecycleOperation(
this: OrchestrationDb,
params: {
principalId: string
operationKey: string
method: string
payloadHash: string
message: {
existingId?: string
to: string
subject: string
body?: string
type: MessageType
priority?: MessagePriority
payload?: string
}
lifecycle:
| { kind: 'message_only' }
| { kind: 'heartbeat'; at: string }
| {
kind: 'worker_report'
taskId: string
outcome: WorkerReportOutcome
result: string
}
}
): {
receipt: LegacyOperationReceiptRow
message: MessageRow
settlement?: WorkerReportSettlement
duplicate: boolean
} {
this.db.exec('BEGIN IMMEDIATE')
try {
const principal = this.getLegacyCompatibilityPrincipal(params.principalId)
if (
!principal ||
principal.role !== 'worker' ||
!['committed', 'settled'].includes(principal.status)
) {
throw new OrchestrationError(
'request_mismatch',
`Legacy compatibility principal ${params.principalId} cannot send lifecycle work.`
)
}
const dispatchId = principal.dispatch_id as string
const existingReceipt = this.requireMatchingLegacyOperationReceipt(params)
if (existingReceipt) {
const response = JSON.parse(existingReceipt.response_json) as {
messageId: string
settlement?: WorkerReportSettlement
}
const message = this.getMessageById(response.messageId)
if (!message) {
throw new OrchestrationError(
'operation_unknown',
`Legacy operation ${params.operationKey} lost its recorded message.`
)
}
this.db.exec('COMMIT')
return {
receipt: existingReceipt,
message,
settlement: response.settlement,
duplicate: true
}
}
const dispatch = this.getDispatchContextById(dispatchId)
if (
!dispatch ||
dispatch.run_id !== principal.run_id ||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION
) {
throw new OrchestrationError(
'dispatch_inactive',
`Dispatch ${dispatchId} is not this principal's legacy attempt.`
)
}
if (
(principal.status === 'settled' || !['pending', 'dispatched'].includes(dispatch.status)) &&
(!params.message.existingId || params.lifecycle.kind !== 'worker_report')
) {
throw new OrchestrationError(
'dispatch_inactive',
`Dispatch ${dispatchId} is settled and only matching completion reconstruction is allowed.`
)
}
let message = params.message.existingId
? this.getMessageById(params.message.existingId)
: undefined
const delivery = this.resolveLegacyWorkerCoordinatorDelivery(
principal.run_id,
params.message.to
)
if (params.message.existingId) {
const matchesOriginalLegacyRoute =
message?.delivery_contract === 'legacy_direct' && message.to_handle === params.message.to
const matchesCurrentRoute =
message?.delivery_contract === delivery.contract && message.to_handle === delivery.to
if (
!message ||
message.run_id !== principal.run_id ||
message.from_handle !== principal.terminal_handle ||
(!matchesOriginalLegacyRoute && !matchesCurrentRoute)
) {
throw new OrchestrationError(
'request_mismatch',
`Existing legacy message ${params.message.existingId} does not match this principal.`
)
}
} else {
message = this.insertMessage({
from: principal.terminal_handle,
to: delivery.to,
subject: params.message.subject,
body: params.message.body,
type: params.message.type,
priority: params.message.priority,
payload: params.message.payload,
senderPaneKey: principal.pane_key,
runId: principal.run_id,
deliveryContract: delivery.contract
})
}
let settlement: WorkerReportSettlement | undefined
if (params.lifecycle.kind === 'heartbeat') {
this.recordHeartbeat(dispatchId, params.lifecycle.at)
} else if (params.lifecycle.kind === 'worker_report') {
const persistedOutcome =
params.message.existingId &&
dispatch.task_id === params.lifecycle.taskId &&
dispatch.status === 'completed'
? 'succeeded'
: params.message.existingId &&
dispatch.task_id === params.lifecycle.taskId &&
dispatch.status === 'failed'
? 'failed'
: undefined
settlement = persistedOutcome
? { action: 'settled', outcome: persistedOutcome, duplicate: true }
: this.settleWorkerReportInTransaction({
taskId: params.lifecycle.taskId,
dispatchId,
outcome: params.lifecycle.outcome,
result: params.lifecycle.result
})
if (settlement.action === 'rejected') {
throw new OrchestrationError(settlement.code, settlement.reason)
}
this.db
.prepare(
`UPDATE legacy_compatibility_principals
SET status = 'settled' WHERE id = ? AND status = 'committed'`
)
.run(principal.id)
}
const responseJson = JSON.stringify({ messageId: message.id, settlement })
const receipt = this.insertLegacyOperationReceipt({
principalId: principal.id,
operationKey: params.operationKey,
method: params.method,
payloadHash: params.payloadHash,
effectId: message.id,
responseJson
})
this.db.exec('COMMIT')
return { receipt, message, settlement, duplicate: false }
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export type LegacyLifecycleOperationMethods = {
commitLegacyLifecycleOperation: typeof commitLegacyLifecycleOperation
}
export function attachLegacyLifecycleOperation(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
commitLegacyLifecycleOperation
})
}
@@ -0,0 +1,113 @@
import type { MessageType, MessageRow, LegacyMailReceiptRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import type { OrchestrationDb } from '../orchestration-db'
export function acknowledgeLegacyMail(
this: OrchestrationDb,
params: {
principalId: string
messageIds: string[]
types?: MessageType[]
}
): {
receipts: LegacyMailReceiptRow[]
duplicate: boolean
} {
if (params.messageIds.length === 0) {
return { receipts: [], duplicate: true }
}
this.db.exec('BEGIN IMMEDIATE')
try {
const principal = this.requireLegacyMailPrincipal(params.principalId)
const uniqueIds = [...new Set(params.messageIds)]
const placeholders = uniqueIds.map(() => '?').join(',')
const prior = this.db
.prepare(
`SELECT COUNT(*) AS count FROM legacy_mail_receipts
WHERE principal_id = ? AND message_id IN (${placeholders})
AND acknowledged_at IS NOT NULL`
)
.get(params.principalId, ...uniqueIds) as { count: number }
if (prior.count !== uniqueIds.length) {
const actionable = this.getLegacyMailPage({
principalId: params.principalId,
limit: uniqueIds.length,
types: params.types
}).messages
if (
actionable.length !== uniqueIds.length ||
actionable.some((message, index) => message.id !== uniqueIds[index])
) {
throw new OrchestrationError(
'request_mismatch',
'Legacy mail acknowledgment does not match the current replay page.'
)
}
}
const rows = this.db
.prepare(
`SELECT * FROM messages
WHERE id IN (${placeholders}) AND run_id = ?
AND delivery_contract = 'legacy_direct'`
)
.all(...uniqueIds, principal.run_id) as MessageRow[]
const validIds = new Set(
rows
.filter(
(message) =>
message.to_handle === principal.terminal_handle ||
(principal.role === 'worker' &&
message.to_handle === `dispatch:${principal.dispatch_id}`)
)
.map((message) => message.id)
)
if (validIds.size !== uniqueIds.length || uniqueIds.some((id) => !validIds.has(id))) {
throw new OrchestrationError(
'request_mismatch',
'Legacy mail acknowledgment contains a message outside this principal inbox.'
)
}
this.db
.prepare(
`UPDATE messages
SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))
WHERE id IN (${placeholders})`
)
.run(...uniqueIds)
const insert = this.db.prepare(
`INSERT INTO legacy_mail_receipts (
principal_id, message_id, acknowledged_at
) VALUES (?, ?, datetime('now'))
ON CONFLICT(principal_id, message_id)
DO UPDATE SET acknowledged_at = COALESCE(
legacy_mail_receipts.acknowledged_at, excluded.acknowledged_at
)`
)
for (const messageId of uniqueIds) {
insert.run(params.principalId, messageId)
}
const receipts = this.db
.prepare(
`SELECT * FROM legacy_mail_receipts
WHERE principal_id = ? AND message_id IN (${placeholders})
ORDER BY message_id`
)
.all(params.principalId, ...uniqueIds) as LegacyMailReceiptRow[]
this.db.exec('COMMIT')
return { receipts, duplicate: prior.count === uniqueIds.length }
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export type LegacyMailAcknowledgeMethods = {
acknowledgeLegacyMail: typeof acknowledgeLegacyMail
}
export function attachLegacyMailAcknowledge(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
acknowledgeLegacyMail
})
}
@@ -0,0 +1,100 @@
import type { LegacyMailReceiptRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
import type { OrchestrationDb } from '../orchestration-db'
export function acknowledgeLegacyQuestionAnswer(
this: OrchestrationDb,
params: {
principalId: string
questionId: string
answerMessageId: string
}
): { receipt: LegacyMailReceiptRow; duplicate: boolean } {
this.db.exec('BEGIN IMMEDIATE')
try {
const principal = this.requireLegacyMailPrincipal(params.principalId, 'worker')
const question = this.getQuestionRaw(params.questionId)
const source = this.getMessageById(params.questionId)
const answer = this.getMessageById(params.answerMessageId)
const dispatch = principal.dispatch_id
? this.getDispatchContextById(principal.dispatch_id)
: undefined
const exactLegacyAnswer =
answer?.delivery_contract === 'legacy_direct' &&
(answer.to_handle === principal.terminal_handle ||
answer.to_handle === `dispatch:${principal.dispatch_id}`)
const adoption = this.getLegacyAdoption()
const exactTakenOverAnswer =
adoption?.adopted_run_id === principal.run_id &&
dispatch?.run_id === principal.run_id &&
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
source?.run_id === principal.run_id &&
source.from_handle === principal.terminal_handle &&
source.to_handle === `run:${principal.run_id}` &&
source.delivery_contract === 'current_delivery' &&
answer?.run_id === principal.run_id &&
answer?.delivery_contract === 'current_delivery' &&
answer.from_handle === `run:${principal.run_id}` &&
answer.to_handle === `dispatch:${principal.dispatch_id}` &&
answer.thread_id === question?.message_id
if (
!question ||
!answer ||
question.run_id !== principal.run_id ||
question.dispatch_id !== principal.dispatch_id ||
question.answer_message_id !== params.answerMessageId ||
(!exactLegacyAnswer && !exactTakenOverAnswer)
) {
throw new OrchestrationError(
'request_mismatch',
'Legacy answer acknowledgment does not match this principal question.'
)
}
const existing = this.db
.prepare(
`SELECT * FROM legacy_mail_receipts
WHERE principal_id = ? AND message_id = ?`
)
.get(params.principalId, params.answerMessageId) as LegacyMailReceiptRow | undefined
this.db
.prepare(
`UPDATE messages
SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))
WHERE id = ?`
)
.run(params.answerMessageId)
this.db
.prepare(
`INSERT INTO legacy_mail_receipts (
principal_id, message_id, acknowledged_at
) VALUES (?, ?, datetime('now'))
ON CONFLICT(principal_id, message_id)
DO UPDATE SET acknowledged_at = COALESCE(
legacy_mail_receipts.acknowledged_at, excluded.acknowledged_at
)`
)
.run(params.principalId, params.answerMessageId)
const receipt = this.db
.prepare(
`SELECT * FROM legacy_mail_receipts
WHERE principal_id = ? AND message_id = ?`
)
.get(params.principalId, params.answerMessageId) as LegacyMailReceiptRow
this.db.exec('COMMIT')
return { receipt, duplicate: Boolean(existing?.acknowledged_at) }
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
export type LegacyQuestionAcknowledgeMethods = {
acknowledgeLegacyQuestionAnswer: typeof acknowledgeLegacyQuestionAnswer
}
export function attachLegacyQuestionAcknowledge(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
acknowledgeLegacyQuestionAnswer
})
}
@@ -0,0 +1,126 @@
import type { MessageDeliveryContract, MessageRow, QuestionRow } from '../../types'
import { OrchestrationError } from '../../orchestration-error'
import { exposeQuestionTimestamps } from '../utc-timestamp'
import { legacyMessageMatchesQuestion } from '../legacy-question-identity'
import type { OrchestrationDb } from '../orchestration-db'
export function findPendingLegacyQuestions(
this: OrchestrationDb,
params: {
principalId: string
question: string
options?: string[]
recipientHandle: string
}
): { question: QuestionRow; message: MessageRow }[] {
return this.findLegacyQuestionsBySemanticIdentity(params)
.filter((row) => row.question.status === 'pending')
.map(({ question, message }) => ({ question, message }))
}
export function findLegacyQuestionsBySemanticIdentity(
this: OrchestrationDb,
params: {
principalId: string
question: string
options?: string[]
recipientHandle: string
}
): {
question: QuestionRow
message: MessageRow
answerAcknowledged: boolean
claimedByOperation: boolean
}[] {
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'worker')
const runAddress = `run:${principal.run_id}`
const rows = this.db
.prepare(
`SELECT q.*, m.id AS source_message_id,
EXISTS(
SELECT 1 FROM legacy_operation_receipts lor
WHERE lor.principal_id = ? AND lor.method = 'orchestration.ask'
AND lor.effect_id = q.message_id
) AS claimed_by_operation
FROM question_threads q
INNER JOIN messages m ON m.id = q.message_id
WHERE q.run_id = ? AND q.dispatch_id = ?
AND (
(m.delivery_contract = 'legacy_direct' AND m.to_handle = ?) OR
(m.delivery_contract = 'current_delivery' AND m.to_handle = ?)
)
ORDER BY m.sequence
LIMIT 501`
)
.all(
principal.id,
principal.run_id,
principal.dispatch_id,
params.recipientHandle,
runAddress
) as (QuestionRow & {
source_message_id: string
claimed_by_operation: number
})[]
if (rows.length > 500) {
throw new OrchestrationError(
'operation_unknown',
'Legacy ask identity is too ambiguous to reconstruct safely.'
)
}
return rows
.filter((row) => {
const message = this.getMessageById(row.source_message_id)
return Boolean(
message &&
legacyMessageMatchesQuestion(message, params.question, params.options ?? [], [
params.recipientHandle,
runAddress
])
)
})
.map((row) => ({
question: exposeQuestionTimestamps(row),
message: this.getMessageById(row.message_id) as MessageRow,
claimedByOperation: row.claimed_by_operation === 1,
answerAcknowledged: row.answer_message_id
? Boolean(
this.db
.prepare(
`SELECT 1 FROM legacy_mail_receipts
WHERE principal_id = ? AND message_id = ?
AND acknowledged_at IS NOT NULL`
)
.get(principal.id, row.answer_message_id)
)
: false
}))
}
export function resolveLegacyWorkerCoordinatorDelivery(
this: OrchestrationDb,
runId: string,
retainedCoordinatorHandle: string
): { to: string; contract: MessageDeliveryContract } {
const run = this.getRunRaw(runId)
const principal = this.getLegacyCoordinatorPrincipal(runId)
// Why: `!== null` alone reads an unknown run (undefined) as taken over and misroutes it to run: delivery.
const takenOver = run?.coordinator_handle != null && principal?.status !== 'committed'
return takenOver
? { to: `run:${runId}`, contract: 'current_delivery' }
: { to: retainedCoordinatorHandle, contract: 'legacy_direct' }
}
export type LegacyQuestionLookupMethods = {
findPendingLegacyQuestions: typeof findPendingLegacyQuestions
findLegacyQuestionsBySemanticIdentity: typeof findLegacyQuestionsBySemanticIdentity
resolveLegacyWorkerCoordinatorDelivery: typeof resolveLegacyWorkerCoordinatorDelivery
}
export function attachLegacyQuestionLookup(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
findPendingLegacyQuestions,
findLegacyQuestionsBySemanticIdentity,
resolveLegacyWorkerCoordinatorDelivery
})
}
@@ -0,0 +1,163 @@
import type { MessageType, MessageRow, LegacyCompatibilityPrincipalRow } from '../../types'
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from '../messages/mailbox-routing-page'
import { exposeMessageListTimestamps } from '../utc-timestamp'
import type { OrchestrationDb } from '../orchestration-db'
export function initializeLegacyRecoveryCohort(
this: OrchestrationDb,
principal: LegacyCompatibilityPrincipalRow
): void {
if (principal.role === 'worker') {
this.db
.prepare(
`INSERT OR IGNORE INTO legacy_mail_receipts (
principal_id, message_id, acknowledged_at
)
SELECT ?, m.id, NULL
FROM messages m
INNER JOIN dispatch_contexts d ON d.id = ?
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct' AND m.read = 1
AND d.status IN ('pending', 'dispatched')
AND m.created_at >= d.created_at
AND (m.to_handle = ? OR m.to_handle = ?)`
)
.run(
principal.id,
principal.dispatch_id,
principal.run_id,
principal.terminal_handle,
`dispatch:${principal.dispatch_id}`
)
return
}
this.db
.prepare(
`INSERT OR IGNORE INTO legacy_mail_receipts (
principal_id, message_id, acknowledged_at
)
SELECT ?, m.id, NULL
FROM messages m
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct' AND m.read = 1
AND m.to_handle = ?
AND EXISTS(
SELECT 1 FROM dispatch_contexts d
WHERE d.run_id = m.run_id
AND d.contract_version = ?
AND d.status IN ('pending', 'dispatched')
AND m.created_at >= d.created_at
AND (m.from_handle = d.assignee_handle OR m.from_handle = 'dispatch:' || d.id)
)`
)
.run(principal.id, principal.run_id, principal.terminal_handle, LEGACY_CONTRACT_VERSION)
}
export function getLegacyMailPage(
this: OrchestrationDb,
params: { principalId: string; limit?: number; types?: MessageType[] }
): {
messages: MessageRow[]
recovery: boolean
} {
const principal = this.requireLegacyMailPrincipal(params.principalId)
const limit = Math.min(
Math.max(params.limit ?? ORCHESTRATION_DELIVERY_BATCH_LIMIT, 1),
ORCHESTRATION_DELIVERY_BATCH_LIMIT
)
const addressSql =
principal.role === 'worker' ? '(m.to_handle = ? OR m.to_handle = ?)' : 'm.to_handle = ?'
const addressParams =
principal.role === 'worker'
? [principal.terminal_handle, `dispatch:${principal.dispatch_id}`]
: [principal.terminal_handle]
const typeSql =
params.types && params.types.length > 0
? `AND m.type IN (${params.types.map(() => '?').join(',')})`
: ''
const typeParams = params.types ?? []
const recovery = this.db
.prepare(
`SELECT m.*
FROM legacy_mail_receipts r
INNER JOIN messages m ON m.id = r.message_id
WHERE r.principal_id = ? AND r.acknowledged_at IS NULL
AND m.run_id = ? AND m.delivery_contract = 'legacy_direct'
AND ${addressSql}
${typeSql}
ORDER BY m.sequence ASC LIMIT ?`
)
.all(
params.principalId,
principal.run_id,
...addressParams,
...typeParams,
limit
) as MessageRow[]
if (recovery.length > 0) {
return { messages: exposeMessageListTimestamps(recovery), recovery: true }
}
const unread = this.db
.prepare(
`SELECT m.*
FROM messages m
LEFT JOIN legacy_mail_receipts r
ON r.principal_id = ? AND r.message_id = m.id
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct'
AND m.read = 0 AND r.message_id IS NULL AND ${addressSql}
${typeSql}
ORDER BY m.sequence ASC LIMIT ?`
)
.all(
params.principalId,
principal.run_id,
...addressParams,
...typeParams,
limit
) as MessageRow[]
return { messages: exposeMessageListTimestamps(unread), recovery: false }
}
export function getLegacyMailHistory(
this: OrchestrationDb,
params: { principalId: string; limit?: number; types?: MessageType[] }
): {
messages: MessageRow[]
recovery: false
} {
const principal = this.requireLegacyMailPrincipal(params.principalId)
const limit = Math.min(Math.max(params.limit ?? 100, 1), 100)
const addressSql =
principal.role === 'worker' ? '(to_handle = ? OR to_handle = ?)' : 'to_handle = ?'
const addressParams =
principal.role === 'worker'
? [principal.terminal_handle, `dispatch:${principal.dispatch_id}`]
: [principal.terminal_handle]
const typeSql =
params.types && params.types.length > 0
? `AND type IN (${params.types.map(() => '?').join(',')})`
: ''
const messages = this.db
.prepare(
`SELECT * FROM messages
WHERE run_id = ? AND delivery_contract = 'legacy_direct'
AND ${addressSql} ${typeSql}
ORDER BY sequence ASC LIMIT ?`
)
.all(principal.run_id, ...addressParams, ...(params.types ?? []), limit) as MessageRow[]
return { messages: exposeMessageListTimestamps(messages), recovery: false }
}
export type LegacyRecoveryCohortMethods = {
initializeLegacyRecoveryCohort: typeof initializeLegacyRecoveryCohort
getLegacyMailPage: typeof getLegacyMailPage
getLegacyMailHistory: typeof getLegacyMailHistory
}
export function attachLegacyRecoveryCohort(ctor: { prototype: object }): void {
Object.assign(ctor.prototype, {
initializeLegacyRecoveryCohort,
getLegacyMailPage,
getLegacyMailHistory
})
}

Some files were not shown because too many files have changed in this diff Show More