From 30b76e6edb594fd2053f51ce08027d03930e930a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:52:10 -0700 Subject: [PATCH 001/109] fix(orchestration): enforce task dispatch state invariant (#14961) --- src/main/runtime/orca-runtime.ts | 2 +- .../__snapshots__/preamble.test.ts.snap | 6 +- .../context-only-dispatch-release.ts | 19 +- .../coordinator-decision-gates.test.ts | 103 +++++ .../coordinator-decision-gates.ts | 33 +- .../coordinator-escalation-triage.test.ts | 65 +++ .../coordinator-escalation-triage.ts | 29 +- .../runtime/orchestration/coordinator.test.ts | 9 +- .../db-task-dispatch-invariant.test.ts | 387 ++++++++++++++++ .../db-task-dispatch-lifecycle-guards.test.ts | 433 ++++++++++++++++++ .../db-task-dispatch-races.test.ts | 198 ++++++++ src/main/runtime/orchestration/db.ts | 1 + .../db/attach-orchestration-db-methods.ts | 2 + .../db/decision-gates/decision-gate-store.ts | 126 +++-- .../dispatch-context/dispatch-completion.ts | 109 +++-- .../dispatch-context-store.ts | 103 +++-- .../db/dispatch-context/dispatch-lookup.ts | 36 +- .../task-dispatch-reconciliation.ts | 30 ++ .../worker-report-settlement.ts | 40 +- .../db/messages/message-inbox.ts | 8 +- .../db/orchestration-db-methods.ts | 2 + .../db/tasks/task-status-transition.ts | 123 +++++ .../orchestration/db/tasks/task-store.ts | 24 - .../federated-worker-start-reconcile.ts | 9 +- .../worker-dispatch-abandon.ts | 10 +- .../worker-dispatch-authority.ts | 8 +- .../worker-dispatch-outcome.ts | 8 +- .../worker-dispatch/worker-dispatch-stop.ts | 12 +- .../worker-terminal-recovery.ts | 8 +- .../orchestration/dispatch-message-binding.ts | 23 + .../orchestration/federation-sync.test.ts | 68 +++ .../runtime/orchestration/federation-sync.ts | 3 +- ...tion-creator-authority-performance.test.ts | 28 +- .../runtime/orchestration/preamble.test.ts | 2 + src/main/runtime/orchestration/preamble.ts | 6 +- .../rpc/methods/orchestration-check.test.ts | 4 +- ...hestration-manual-dispatch-release.test.ts | 8 +- ...hestration-send-dispatch-authority.test.ts | 153 +++++++ .../rpc/methods/orchestration-send.test.ts | 126 ++++- src/main/runtime/rpc/methods/orchestration.ts | 137 ++++-- ...on-legacy-compatibility-dispatcher.test.ts | 15 +- .../rpc/orchestration-legacy-lifecycle.ts | 7 +- ...hestration-task-dispatch-invariant.test.ts | 430 +++++++++++++++++ 43 files changed, 2750 insertions(+), 203 deletions(-) create mode 100644 src/main/runtime/orchestration/coordinator-decision-gates.test.ts create mode 100644 src/main/runtime/orchestration/coordinator-escalation-triage.test.ts create mode 100644 src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts create mode 100644 src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts create mode 100644 src/main/runtime/orchestration/db-task-dispatch-races.test.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-context/task-dispatch-reconciliation.ts create mode 100644 src/main/runtime/orchestration/db/tasks/task-status-transition.ts create mode 100644 src/main/runtime/orchestration/dispatch-message-binding.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts create mode 100644 src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f5af4fb0786..1ef6d8b0e38 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16738,7 +16738,7 @@ export class OrcaRuntimeService { } const errorContext = `Agent exited with code ${exitCode}` - this._orchestrationDb.failDispatch(dispatch.id, errorContext) + this._orchestrationDb.failDispatch(dispatch.id, errorContext, { workerProcessExited: true }) // Why: create an escalation message so the coordinator is notified about // the unexpected exit on its next check cycle, even if the circuit breaker diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index e8d272f388e..d92228517aa 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -48,8 +48,8 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # Ask the coordinator a question and block until it answers. # # BEHAVIOR RULE #1 (MUST NOT VIOLATE): - # NEVER use AskUserQuestion; use \`orca orchestration ask\` or send - # --type decision_gate. AskUserQuestion opens a local TUI prompt that the + # NEVER use AskUserQuestion; use \`orca orchestration ask\`. + # AskUserQuestion opens a local TUI prompt that the # coordinator cannot see and cannot answer — your session will hang forever # waiting on a human. Every interactive question goes through \`ask\` below. # @@ -67,7 +67,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. orca orchestration send --from term_WORKER \\ --type escalation --subject "Blocked: " \\ --body "
" \\ - --task-id task_SNAP + --task-id task_SNAP --dispatch-id ctx_SNAP # Check for messages from the coordinator: orca orchestration check --terminal term_WORKER diff --git a/src/main/runtime/orchestration/context-only-dispatch-release.ts b/src/main/runtime/orchestration/context-only-dispatch-release.ts index ce4d7a7e6fe..f293e5d57ec 100644 --- a/src/main/runtime/orchestration/context-only-dispatch-release.ts +++ b/src/main/runtime/orchestration/context-only-dispatch-release.ts @@ -12,18 +12,16 @@ export type ContextOnlyDispatchReleaseResult = { export function releaseContextOnlyDispatch( db: Database.Database, dispatch: DispatchContextRow, - latestDispatchId: string | undefined, requestedState: 'abandoned' | 'stopped' ): ContextOnlyDispatchReleaseResult { if (dispatch.status !== 'pending' && dispatch.status !== 'dispatched') { return { state: persistedReleaseState(dispatch), alreadySettled: true, - releasedCurrentTask: latestDispatchId === dispatch.id + releasedCurrentTask: false } } - const releasedCurrentTask = latestDispatchId === dispatch.id db.prepare( `UPDATE dispatch_contexts SET status = 'failed', last_failure = ?, @@ -31,9 +29,18 @@ export function releaseContextOnlyDispatch( completed_at = COALESCE(completed_at, datetime('now')) WHERE id = ? AND status IN ('pending', 'dispatched')` ).run(requestedState, dispatch.id) - if (releasedCurrentTask) { - db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id) - } + const remaining = db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE task_id = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(dispatch.task_id) + const releasedCurrentTask = Boolean( + !remaining && + db + .prepare("UPDATE tasks SET status = 'blocked' WHERE id = ? AND status = 'dispatched'") + .run(dispatch.task_id).changes + ) return { state: requestedState, alreadySettled: false, releasedCurrentTask } } diff --git a/src/main/runtime/orchestration/coordinator-decision-gates.test.ts b/src/main/runtime/orchestration/coordinator-decision-gates.test.ts new file mode 100644 index 00000000000..e5a76ab6a22 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-decision-gates.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { openDecisionGateFromMessage } from './coordinator-decision-gates' +import { OrchestrationDb } from './db' + +describe('coordinator decision-gate authority', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('opens a gate only for the sender-owned active Dispatch', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'owned gate target' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', 'tab_owner:leaf_owner') + const logs: string[] = [] + + openDecisionGateFromMessage( + db, + db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'Need approval', + type: 'decision_gate', + senderPaneKey: 'tab_owner:leaf_owner', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + question: 'Proceed?' + }) + }), + (message) => logs.push(message) + ) + + expect(db.listGates({ taskId: task.id })).toHaveLength(1) + expect(db.getTask(task.id)?.status).toBe('blocked') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(logs.at(-1)).toBe(`Task ${task.id} blocked on decision gate`) + }) + + it('rejects a gate targeting another active Dispatch without mutating either Task', () => { + db = new OrchestrationDb(':memory:') + const attackerTask = db.createTask({ spec: 'attacker assignment' }) + const attacker = db.createDispatchContext( + attackerTask.id, + 'term_attacker', + 'tab_attacker:leaf_attacker' + ) + const victimTask = db.createTask({ spec: 'victim assignment' }) + const victim = db.createDispatchContext(victimTask.id, 'term_victim', 'tab_victim:leaf_victim') + const logs: string[] = [] + + openDecisionGateFromMessage( + db, + db.insertMessage({ + from: 'term_attacker', + to: 'term_coordinator', + subject: 'Block the victim', + type: 'decision_gate', + senderPaneKey: 'tab_attacker:leaf_attacker', + payload: JSON.stringify({ + taskId: victimTask.id, + dispatchId: attacker.id, + question: 'Stop?' + }) + }), + (message) => logs.push(message) + ) + + expect(db.listGates({ taskId: victimTask.id })).toHaveLength(0) + expect(db.getTask(attackerTask.id)?.status).toBe('dispatched') + expect(db.getTask(victimTask.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(attacker.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(victim.id)?.status).toBe('dispatched') + expect(logs.at(-1)).toContain('Rejected decision gate from term_attacker') + }) + + it('accepts the canonical sender of an imported federated Dispatch', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'remote gate target' }) + const dispatch = db.createDispatchContext(task.id, 'remote-worker') + + openDecisionGateFromMessage( + db, + db.insertMessage({ + from: `dispatch:${dispatch.id}`, + to: 'term_coordinator', + subject: 'Remote approval required', + type: 'decision_gate', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + question: 'Proceed remotely?' + }) + }), + () => {} + ) + + expect(db.listGates({ taskId: task.id })).toHaveLength(1) + expect(db.getTask(task.id)?.status).toBe('blocked') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + }) +}) diff --git a/src/main/runtime/orchestration/coordinator-decision-gates.ts b/src/main/runtime/orchestration/coordinator-decision-gates.ts index fa9fa3b73d9..3da386fc2ed 100644 --- a/src/main/runtime/orchestration/coordinator-decision-gates.ts +++ b/src/main/runtime/orchestration/coordinator-decision-gates.ts @@ -1,5 +1,6 @@ /** Decision-gate handling: opening a gate from a worker message and keeping gated tasks blocked. */ import type { OrchestrationDb } from './db' +import { OrchestrationError } from './orchestration-error' import type { MessageRow } from './types' export function openDecisionGateFromMessage( @@ -9,7 +10,7 @@ export function openDecisionGateFromMessage( ): void { onLog(`Decision gate from ${msg.from_handle}: ${msg.subject}`) - let payload: { taskId?: string; question?: string; options?: string[] } = {} + let payload: { taskId?: string; dispatchId?: string; question?: string; options?: string[] } = {} if (msg.payload) { try { payload = JSON.parse(msg.payload) @@ -18,16 +19,32 @@ export function openDecisionGateFromMessage( } } - if (!payload.taskId || !payload.question) { - onLog(`Warning: decision_gate missing taskId or question`) + if (!payload.taskId || !payload.dispatchId || !payload.question) { + onLog(`Warning: decision_gate missing taskId, dispatchId, or question`) return } - db.createGate({ - taskId: payload.taskId, - question: payload.question, - options: payload.options - }) + try { + db.createGate({ + taskId: payload.taskId, + question: payload.question, + options: payload.options, + requester: { + handle: msg.from_handle, + paneKey: msg.sender_pane_key, + dispatchId: payload.dispatchId + } + }) + } catch (error) { + if ( + error instanceof OrchestrationError && + (error.code === 'consumer_fenced' || error.code === 'task_not_startable') + ) { + onLog(`Rejected decision gate from ${msg.from_handle}: ${error.message}`) + return + } + throw error + } onLog(`Task ${payload.taskId} blocked on decision gate`) } diff --git a/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts b/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts new file mode 100644 index 00000000000..af973100336 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { applyEscalationToDispatch } from './coordinator-escalation-triage' +import { OrchestrationDb } from './db' + +describe('coordinator escalation authority', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('rejects an escalation targeting another active Dispatch', () => { + db = new OrchestrationDb(':memory:') + const attackerTask = db.createTask({ spec: 'attacker assignment' }) + const attacker = db.createDispatchContext( + attackerTask.id, + 'term_attacker', + 'tab_attacker:leaf_attacker' + ) + const victimTask = db.createTask({ spec: 'victim assignment' }) + const victim = db.createDispatchContext(victimTask.id, 'term_victim') + const logs: string[] = [] + + applyEscalationToDispatch( + db, + db.insertMessage({ + from: 'term_attacker', + to: 'term_coordinator', + subject: 'Fail the victim', + type: 'escalation', + senderPaneKey: 'tab_attacker:leaf_attacker', + payload: JSON.stringify({ taskId: victimTask.id, dispatchId: attacker.id }) + }), + (message) => logs.push(message) + ) + + expect(db.getTask(victimTask.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(victim.id)?.status).toBe('dispatched') + expect(logs.at(-1)).toContain('Rejected escalation from term_attacker') + }) + + it('accepts the canonical sender of an imported federated Dispatch', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'remote escalation target' }) + const dispatch = db.createDispatchContext(task.id, 'remote-worker') + + applyEscalationToDispatch( + db, + db.insertMessage({ + from: `dispatch:${dispatch.id}`, + to: 'term_coordinator', + subject: 'Remote worker failed', + type: 'escalation', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }), + () => {} + ) + + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + status: 'failed', + failure_count: 1 + }) + }) +}) diff --git a/src/main/runtime/orchestration/coordinator-escalation-triage.ts b/src/main/runtime/orchestration/coordinator-escalation-triage.ts index f37fd654399..4c4a195ec92 100644 --- a/src/main/runtime/orchestration/coordinator-escalation-triage.ts +++ b/src/main/runtime/orchestration/coordinator-escalation-triage.ts @@ -9,16 +9,19 @@ export function applyEscalationToDispatch( onLog: (msg: string) => void ): string | null { let taskId: string | undefined + let dispatchId: string | undefined if (msg.payload) { try { const payload = JSON.parse(msg.payload) taskId = payload.taskId + dispatchId = payload.dispatchId } catch { // Escalation without structured payload — log subject as context } } - if (!taskId) { + if (!taskId || !dispatchId) { + onLog(`Rejected escalation from ${msg.from_handle}: missing exact Dispatch binding`) return null } @@ -27,8 +30,28 @@ export function applyEscalationToDispatch( return null } - const dispatch = db.getDispatchContext(taskId) - if (!dispatch) { + const dispatch = db.getDispatchContextById(dispatchId) + if (!dispatch || dispatch.task_id !== taskId) { + onLog(`Rejected escalation from ${msg.from_handle}: Dispatch does not own Task ${taskId}`) + return null + } + if ( + !db.isDispatchMessageSender({ + dispatchId: dispatch.id, + handle: msg.from_handle, + paneKey: msg.sender_pane_key, + allowCanonicalDispatchHandle: true + }) + ) { + onLog(`Rejected escalation from ${msg.from_handle}: it does not own Task ${taskId}`) + return null + } + + const worker = db.getWorkerDispatch(dispatch.id) + if (worker && !['failed', 'succeeded', 'stopped', 'abandoned'].includes(worker.state)) { + onLog( + `Task ${taskId} remains dispatched until supervised worker ${dispatch.id} stops or reports.` + ) return null } diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 1ace48cb1c3..7fcc3483be1 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -335,12 +335,14 @@ describe('Coordinator', () => { await new Promise((r) => { setTimeout(r, 100) }) + const dispatch = db.getDispatchContext(task.id) + expect(dispatch).toBeDefined() db.insertMessage({ - from: `term_${i === 0 ? 'a' : 'b'}`, + from: dispatch?.assignee_handle ?? 'missing-worker', to: 'coord', subject: `Failed attempt ${i + 1}`, type: 'escalation', - payload: JSON.stringify({ taskId: task.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch!.id }) }) } @@ -392,6 +394,8 @@ describe('Coordinator', () => { }) // Worker sends decision gate + const dispatch = db.getDispatchContext(task.id) + expect(dispatch).toBeDefined() db.insertMessage({ from: 'term_a', to: 'coord', @@ -399,6 +403,7 @@ describe('Coordinator', () => { type: 'decision_gate', payload: JSON.stringify({ taskId: task.id, + dispatchId: dispatch!.id, question: 'Proceed with destructive migration?', options: ['yes', 'no'] }) diff --git a/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts b/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts new file mode 100644 index 00000000000..a5b8cfd912a --- /dev/null +++ b/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts @@ -0,0 +1,387 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +type DatabaseHarness = { + db: OrchestrationDb + dir: string + path: string +} + +const harnesses: DatabaseHarness[] = [] + +afterEach(() => { + vi.restoreAllMocks() + const closed = harnesses.splice(0) + for (const harness of closed) { + harness.db.close() + } + for (const dir of new Set(closed.map((harness) => harness.dir))) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Task/Dispatch invariant transactions', () => { + it.each(['completed', 'failed'] as const)( + 'rolls back a %s Task when Dispatch settlement fails', + (status) => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'atomic work' }) + const dependent = db.createTask({ spec: 'dependent work', deps: [task.id] }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'worker:1' + }) + sqliteFor(db).exec(` + CREATE TRIGGER reject_dispatch_settlement + BEFORE UPDATE OF status ON dispatch_contexts + WHEN OLD.id = '${dispatch.id}' + BEGIN + SELECT RAISE(ABORT, 'forced dispatch settlement failure'); + END; + `) + + expect(() => db.updateTaskStatus(task.id, status, 'must roll back')).toThrow( + 'forced dispatch settlement failure' + ) + expect(db.getTask(task.id)).toMatchObject({ + status: 'dispatched', + result: null, + completed_at: null + }) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + status: 'dispatched', + completed_at: null, + capability_revoked_at: null + }) + expect( + db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'worker:1' + }) + ).toEqual({ valid: true }) + expect(db.getTask(dependent.id)?.status).toBe('pending') + } + ) + + it('does not commit a caller-owned transaction', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'outer transaction work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + const sqlite = sqliteFor(db) + + sqlite.exec('BEGIN IMMEDIATE') + db.updateTaskStatus(task.id, 'failed', 'inside outer transaction') + expect(db.getTask(task.id)?.status).toBe('failed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('failed') + sqlite.exec('ROLLBACK') + + expect(db.getTask(task.id)).toMatchObject({ + status: 'dispatched', + result: null, + completed_at: null + }) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + status: 'dispatched', + completed_at: null, + capability_revoked_at: null + }) + }) + + it('keeps Dispatch creation inside a caller-owned transaction', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'outer transaction dispatch' }) + const sqlite = sqliteFor(db) + + sqlite.exec('BEGIN IMMEDIATE') + const dispatch = db.createDispatchContext(task.id, 'term_worker') + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + sqlite.exec('ROLLBACK') + + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContextById(dispatch.id)).toBeUndefined() + }) + + it.each(['completed', 'failed'] as const)( + 'settles every active Dispatch left by a pre-fix split when the Task becomes %s', + (status) => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'legacy split work' }) + const first = db.createDispatchContext(task.id, 'term_first') + sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const second = db.createDispatchContext(task.id, 'term_second') + + db.updateTaskStatus(task.id, status, 'terminal result') + + for (const dispatch of [first, second]) { + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + status, + completed_at: expect.any(String), + capability_revoked_at: expect.any(String) + }) + } + expect(db.getActiveDispatchForTerminal('term_first')).toBeUndefined() + expect(db.getActiveDispatchForTerminal('term_second')).toBeUndefined() + expect(() => + db.createDispatchContext(db.createTask({ spec: 'first later work' }).id, 'term_first') + ).not.toThrow() + expect(() => + db.createDispatchContext(db.createTask({ spec: 'second later work' }).id, 'term_second') + ).not.toThrow() + } + ) + + it('does not requeue a legacy split Task while another Dispatch remains active', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'legacy split retry' }) + const first = db.createDispatchContext(task.id, 'term_first') + sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const second = db.createDispatchContext(task.id, 'term_second') + + db.failDispatch(second.id, 'targeted failure') + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(first.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(second.id)?.status).toBe('failed') + }) + + it('does not block a legacy split Task while another Dispatch remains active', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'legacy split release' }) + const first = db.createDispatchContext(task.id, 'term_first') + sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const second = db.createDispatchContext(task.id, 'term_second') + + expect(db.beginWorkerStop(second.id)).toMatchObject({ + disposition: 'context_only', + releasedCurrentTask: false + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(first.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(second.id)?.status).toBe('failed') + }) + + it.each(['pending', 'ready', 'blocked'] as const)( + 'rejects moving a Task to %s while a Dispatch remains active', + (status) => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'guarded work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + + expect(() => db.updateTaskStatus(task.id, status, 'must not persist')).toThrowError( + expect.objectContaining({ + code: 'task_not_startable', + data: { taskId: task.id, dispatchId: dispatch.id } + }) + ) + expect(db.getTask(task.id)).toMatchObject({ status: 'dispatched', result: null }) + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + } + ) + + it('rejects moving a Task to dispatched without an active Dispatch', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'unassigned work' }) + + expect(() => db.updateTaskStatus(task.id, 'dispatched')).toThrowError( + expect.objectContaining({ + code: 'task_not_startable', + data: { taskId: task.id } + }) + ) + expect(db.getTask(task.id)?.status).toBe('ready') + }) + + it('rejects a Dispatch when failure wins after readiness was observed', () => { + const first = createDatabase() + const concurrent = createDatabase(first.path) + const task = first.db.createTask({ spec: 'interleaved work' }) + const sqlite = sqliteFor(first.db) + const prepare = sqlite.prepare.bind(sqlite) + let injected = false + vi.spyOn(sqlite, 'prepare').mockImplementation((sql) => { + if (!injected && sql.includes('INSERT INTO dispatch_contexts')) { + injected = true + concurrent.db.updateTaskStatus(task.id, 'failed', 'failure won') + } + return prepare(sql) + }) + + expect(() => first.db.createDispatchContext(task.id, 'term_worker')).toThrow( + `Task ${task.id} is failed; only ready tasks can be dispatched` + ) + expect(injected).toBe(true) + expect(first.db.getTask(task.id)).toMatchObject({ status: 'failed', result: 'failure won' }) + expect(first.db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('atomically rejects a same-pane Dispatch that loses the occupancy race', () => { + const first = createDatabase() + const concurrent = createDatabase(first.path) + const firstTask = first.db.createTask({ spec: 'first terminal claimant' }) + const secondTask = first.db.createTask({ spec: 'second terminal claimant' }) + const sqlite = sqliteFor(first.db) + const prepare = sqlite.prepare.bind(sqlite) + let winnerId: string | undefined + vi.spyOn(sqlite, 'prepare').mockImplementation((sql) => { + if (!winnerId && sql.includes('INSERT INTO dispatch_contexts')) { + winnerId = concurrent.db.createDispatchContext( + secondTask.id, + 'term_reminted', + 'tab_new:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ).id + } + return prepare(sql) + }) + + expect(() => + first.db.createDispatchContext( + firstTask.id, + 'term_worker', + 'tab_old:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ) + ).toThrow(/already has an active dispatch/) + expect(winnerId).toBeDefined() + expect(first.db.getTask(firstTask.id)?.status).toBe('ready') + expect(first.db.getTask(secondTask.id)?.status).toBe('dispatched') + expect( + sqlite + .prepare( + "SELECT COUNT(*) AS count FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')" + ) + .get() + ).toEqual({ count: 1 }) + }) + + it('rejects worker authority when another Dispatch owns the pane', () => { + const { db } = createDatabase() + const ownerTask = db.createTask({ spec: 'current pane owner' }) + const owner = db.createDispatchContext( + ownerTask.id, + 'term_owner', + 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + ) + const workerTask = db.createTask({ spec: 'competing supervised worker' }) + const started = db.createStartingWorkerDispatch({ + taskId: workerTask.id, + startOptions: {} + }) + + expect(() => + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_reminted', + paneKey: 'tab_new:cccccccc-cccc-4ccc-8ccc-cccccccccccc', + processIncarnation: 'worker:1', + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'external' + }) + ).toThrow(`already has an active dispatch (${owner.id} for task ${ownerTask.id})`) + expect(db.getDispatchContextById(started.dispatch.id)).toMatchObject({ + status: 'pending', + assignee_handle: null, + capability_hash: null + }) + expect(db.getWorkerDispatch(started.dispatch.id)).toMatchObject({ + state: 'starting', + stage: 'accepted', + agent_terminal_handle: null + }) + }) + + it.each(['completed', 'failed'] as const)( + 'rejects a %s Task update while its supervised worker remains active', + (status) => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'supervised lifecycle' }) + const started = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:dddddddd-dddd-4ddd-8ddd-dddddddddddd', + processIncarnation: 'worker:1', + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + + expect(() => db.updateTaskStatus(task.id, status, 'must not persist')).toThrowError( + expect.objectContaining({ + code: 'task_not_startable', + data: { taskId: task.id, dispatchId: started.dispatch.id } + }) + ) + expect(db.getTask(task.id)).toMatchObject({ + status: 'dispatched', + result: null, + completed_at: null + }) + expect(db.getDispatchContextById(started.dispatch.id)).toMatchObject({ + status: 'pending', + completed_at: null, + capability_revoked_at: null + }) + expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('starting') + expect( + db.verifyDispatchCapability({ + dispatchId: started.dispatch.id, + capability, + paneKey: 'tab_worker:dddddddd-dddd-4ddd-8ddd-dddddddddddd', + processIncarnation: 'worker:1' + }) + ).toEqual({ valid: true }) + } + ) + + it('keeps a federated late start authoritative after rejecting Task failure', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'federated lifecycle' }) + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + federation: { + environmentId: 'server-1', + environmentName: 'worker server', + peerFingerprint: 'peer-1', + protocolVersion: 3 + } + }) + + expect(() => + db.updateTaskStatus(task.id, 'failed', 'must not outrun remote start') + ).toThrowError( + expect.objectContaining({ + code: 'task_not_startable', + data: { taskId: task.id, dispatchId: started.dispatch.id } + }) + ) + expect(() => db.markWorkerDispatchReady(started.dispatch.id)).not.toThrow() + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') + expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready') + }) +}) + +function createDatabase(path?: string): DatabaseHarness { + const dir = path ? harnesses.find((harness) => harness.path === path)?.dir : undefined + const ownedDir = dir ?? mkdtempSync(join(tmpdir(), 'orca-task-dispatch-db-')) + const dbPath = path ?? join(ownedDir, 'orchestration.db') + const harness = { db: new OrchestrationDb(dbPath), dir: ownedDir, path: dbPath } + harnesses.push(harness) + return harness +} + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} diff --git a/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts b/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts new file mode 100644 index 00000000000..02f2abba521 --- /dev/null +++ b/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts @@ -0,0 +1,433 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +type WorkerFixture = { + dispatchId: string + capability: string + handle: string + paneKey: string + processIncarnation: string +} + +let db: OrchestrationDb | undefined +let dir: string | undefined + +afterEach(() => { + db?.close() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + db = undefined + dir = undefined +}) + +describe('Task/Dispatch lifecycle guards', () => { + it('rejects a worker report while another supervised Dispatch is active', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'legacy supervised split' }) + const first = startWorker(database, task.id, 'first') + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const second = startWorker(database, task.id, 'second') + + expect( + database.settleWorkerReport({ + taskId: task.id, + dispatchId: second.dispatchId, + outcome: 'succeeded', + result: 'must wait for first' + }) + ).toMatchObject({ action: 'rejected', code: 'inactive_dispatch' }) + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(first.dispatchId)?.status).toBe('dispatched') + expect(database.getDispatchContextById(second.dispatchId)?.status).toBe('dispatched') + expect(database.getWorkerDispatch(first.dispatchId)?.state).toBe('ready') + expect(database.getWorkerDispatch(second.dispatchId)?.state).toBe('ready') + expectCapability(database, first, true) + expectCapability(database, second, true) + }) + + it.each(['succeeded', 'failed'] as const)( + 'settles context-only legacy siblings after a %s worker report', + (outcome) => { + const database = createDatabase() + const task = database.createTask({ spec: 'legacy mixed split' }) + const contextOnly = database.createDispatchContext(task.id, 'term_context') + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const worker = startWorker(database, task.id, 'reporter') + + expect( + database.settleWorkerReport({ + taskId: task.id, + dispatchId: worker.dispatchId, + outcome, + result: `${outcome} result` + }) + ).toEqual({ action: 'settled', outcome, duplicate: false }) + const expectedStatus = outcome === 'succeeded' ? 'completed' : 'failed' + expect(database.getTask(task.id)?.status).toBe(expectedStatus) + expect(database.getDispatchContextById(contextOnly.id)).toMatchObject({ + status: expectedStatus, + capability_revoked_at: expect.any(String) + }) + expect(database.getActiveDispatchForTerminal('term_context')).toBeUndefined() + expect(() => + database.createDispatchContext( + database.createTask({ spec: 'later context work' }).id, + 'term_context' + ) + ).not.toThrow() + } + ) + + it('settles a newer context-only legacy sibling after a worker report', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'reversed legacy mixed split' }) + const worker = startWorker(database, task.id, 'reversed_reporter') + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const contextOnly = database.createDispatchContext(task.id, 'term_reversed_context') + + expect( + database.settleWorkerReport({ + taskId: task.id, + dispatchId: worker.dispatchId, + outcome: 'succeeded', + result: 'reversed sibling completed' + }) + ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) + expect(database.getTask(task.id)?.status).toBe('completed') + expect(database.getDispatchContextById(contextOnly.id)).toMatchObject({ + status: 'completed', + capability_revoked_at: expect.any(String) + }) + expect(database.getActiveDispatchForTerminal('term_reversed_context')).toBeUndefined() + }) + + it('rejects generic failure while a supervised worker remains active', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'supervised failure guard' }) + const worker = startWorker(database, task.id, 'guarded') + + expect(() => database.failDispatch(worker.dispatchId, 'unsafe retry')).toThrowError( + expect.objectContaining({ + code: 'task_not_startable', + data: { dispatchId: worker.dispatchId } + }) + ) + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(worker.dispatchId)).toMatchObject({ + status: 'dispatched', + failure_count: 0, + capability_revoked_at: null + }) + expect(database.getWorkerDispatch(worker.dispatchId)?.state).toBe('ready') + expectCapability(database, worker, true) + }) + + it('atomically settles worker state when a proven process exit fails its Dispatch', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'exited worker' }) + const worker = startWorker(database, task.id, 'exited') + + expect( + database.failDispatch(worker.dispatchId, 'process exited', { workerProcessExited: true }) + ).toMatchObject({ status: 'failed', failure_count: 1 }) + expect(database.getTask(task.id)?.status).toBe('ready') + expect(database.getWorkerDispatch(worker.dispatchId)).toMatchObject({ + state: 'failed', + stage: 'process_exited', + last_error: 'process exited' + }) + expectCapability(database, worker, false) + }) + + it('keeps a Task dispatched when missing-terminal recovery leaves another worker active', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'legacy missing-terminal split' }) + const missing = startWorker(database, task.id, 'missing') + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const live = startWorker(database, task.id, 'live') + + database.reconcileMissingWorkerTerminal(missing.dispatchId, 'terminal missing') + + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(missing.dispatchId)).toMatchObject({ + status: 'failed', + capability_revoked_at: expect.any(String) + }) + expect(database.getWorkerDispatch(missing.dispatchId)?.state).toBe('abandoned') + expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') + expect(database.getWorkerDispatch(live.dispatchId)?.state).toBe('ready') + expectCapability(database, missing, false) + expectCapability(database, live, true) + + database.reconcileMissingWorkerTerminal(live.dispatchId, 'second terminal missing') + expect(database.getTask(task.id)?.status).toBe('ready') + expectCapability(database, live, false) + }) + + it.each(['local', 'federated'] as const)( + 'keeps a Task dispatched when a %s worker start fails beside a live worker', + (kind) => { + const database = createDatabase() + const task = database.createTask({ spec: `${kind} split start failure` }) + const failed = database.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + ...(kind === 'federated' + ? { + federation: { + environmentId: 'server-1', + environmentName: 'worker server', + peerFingerprint: 'peer-1', + protocolVersion: 3 + } + } + : {}) + }) + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const live = startWorker(database, task.id, `${kind}_live`) + + if (kind === 'local') { + database.failWorkerStart(failed.dispatch.id, 'start_failed', 'worker failed to start') + } else { + database.reconcileFederatedWorkerStart({ + dispatchId: failed.dispatch.id, + state: 'failed', + stage: 'start_failed', + lastError: 'worker failed to start' + }) + } + + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(failed.dispatch.id)).toMatchObject({ + status: 'failed', + capability_revoked_at: expect.any(String) + }) + expect(database.getWorkerDispatch(failed.dispatch.id)?.state).toBe('failed') + expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') + expect(database.getWorkerDispatch(live.dispatchId)?.state).toBe('ready') + expectCapability(database, live, true) + } + ) + + it.each(['stop', 'abandon'] as const)( + '%s releases the last context-only sibling after a newer worker start fails', + (operation) => { + const database = createDatabase() + const task = database.createTask({ spec: `${operation} historical sibling` }) + const contextOnly = database.createDispatchContext(task.id, `term_${operation}`) + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const failed = database.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + + database.failWorkerStart(failed.dispatch.id, 'start_failed', 'worker failed to start') + expect(database.getTask(task.id)?.status).toBe('dispatched') + + const released = + operation === 'stop' + ? database.beginWorkerStop(contextOnly.id) + : database.abandonWorkerDispatch(contextOnly.id) + expect(released).toMatchObject({ + disposition: 'context_only', + alreadySettled: false, + releasedCurrentTask: true + }) + expect(database.getTask(task.id)?.status).toBe('blocked') + expect(database.getDispatchContextById(contextOnly.id)?.status).toBe('failed') + expect(database.getActiveDispatchForTerminal(`term_${operation}`)).toBeUndefined() + expect(() => + database.createDispatchContext( + database.createTask({ spec: `${operation} later work` }).id, + `term_${operation}` + ) + ).not.toThrow() + } + ) + + it.each(['stop', 'abandon'] as const)( + '%s preserves a live worker sibling and lets it report', + (operation) => { + const database = createDatabase() + const task = database.createTask({ spec: `${operation} legacy worker split` }) + const live = startWorker(database, task.id, `${operation}_live`) + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const released = startWorker(database, task.id, `${operation}_released`) + + if (operation === 'stop') { + expect(database.beginWorkerStop(released.dispatchId).disposition).toBe('stopping') + expect(database.settleWorkerStop(released.dispatchId).state).toBe('stopped') + } else { + expect(database.abandonWorkerDispatch(released.dispatchId).disposition).toBe('abandoned') + } + + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') + expectCapability(database, live, true) + expect( + database.settleWorkerReport({ + taskId: task.id, + dispatchId: live.dispatchId, + outcome: 'succeeded', + result: `${operation} sibling completed` + }) + ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) + expect(database.getTask(task.id)?.status).toBe('completed') + expectCapability(database, live, false) + } + ) + + it('blocks a Task when an interleaved stop settles its final active Dispatch', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'interleaved legacy worker release' }) + const stopping = startWorker(database, task.id, 'interleaved_stopping') + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const abandoned = startWorker(database, task.id, 'interleaved_abandoned') + + expect(database.beginWorkerStop(stopping.dispatchId).disposition).toBe('stopping') + expect(database.abandonWorkerDispatch(abandoned.dispatchId).disposition).toBe('abandoned') + expect(database.getTask(task.id)?.status).toBe('dispatched') + + expect(database.settleWorkerStop(stopping.dispatchId).state).toBe('stopped') + expect(database.getTask(task.id)?.status).toBe('blocked') + expect(database.getDispatchContextById(stopping.dispatchId)?.status).toBe('failed') + expect(database.getDispatchContextById(abandoned.dispatchId)?.status).toBe('failed') + }) + + it('restores a live sibling after stopping an uncertain worker start', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'uncertain legacy worker split' }) + const live = startWorker(database, task.id, 'uncertain_live') + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const uncertain = database.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + database.markWorkerStartUnknown(uncertain.dispatch.id, 'agent_readiness', 'outcome unknown') + expect(database.getTask(task.id)?.status).toBe('blocked') + + expect(database.beginWorkerStop(uncertain.dispatch.id).disposition).toBe('stopping') + expect(database.settleWorkerStop(uncertain.dispatch.id).state).toBe('stopped') + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect( + database.settleWorkerReport({ + taskId: task.id, + dispatchId: live.dispatchId, + outcome: 'succeeded', + result: 'live sibling completed' + }) + ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) + }) + + it.each(['federated-reconcile', 'missing-terminal'] as const)( + 'restores a live sibling after an uncertain worker start fails through %s', + (recovery) => { + const database = createDatabase() + const task = database.createTask({ spec: `${recovery} uncertain sibling` }) + const live = startWorker(database, task.id, `${recovery}_live`) + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const uncertain = database.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + database.markWorkerStartUnknown(uncertain.dispatch.id, 'agent_readiness', 'outcome unknown') + + if (recovery === 'federated-reconcile') { + database.reconcileFederatedWorkerStart({ + dispatchId: uncertain.dispatch.id, + state: 'failed', + stage: 'start_failed', + lastError: 'worker did not start' + }) + } else { + database.reconcileMissingWorkerTerminal(uncertain.dispatch.id, 'worker terminal missing') + } + + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') + expect( + database.settleWorkerReport({ + taskId: task.id, + dispatchId: live.dispatchId, + outcome: 'succeeded', + result: 'live sibling completed' + }) + ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) + } + ) + + it('rejects gate creation while a supervised worker remains active', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'worker gate guard' }) + const worker = startWorker(database, task.id, 'gate') + + expect(() => database.createGate({ taskId: task.id, question: 'Proceed?' })).toThrowError( + expect.objectContaining({ + code: 'task_not_startable', + data: { taskId: task.id, dispatchId: worker.dispatchId } + }) + ) + expect(database.listGates({ taskId: task.id })).toHaveLength(0) + expect(database.getTask(task.id)?.status).toBe('dispatched') + expect(database.getDispatchContextById(worker.dispatchId)?.status).toBe('dispatched') + expect(database.getWorkerDispatch(worker.dispatchId)?.state).toBe('ready') + expectCapability(database, worker, true) + }) + + it('rolls back gate resolution when an active Dispatch blocks readiness', () => { + const database = createDatabase() + const task = database.createTask({ spec: 'corrupt gated task' }) + const gate = database.createGate({ taskId: task.id, question: 'Proceed?' }) + sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + const dispatch = database.createDispatchContext(task.id, 'term_worker') + sqliteFor(database).prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(task.id) + + expect(() => database.resolveGate(gate.id, 'yes')).toThrowError( + expect.objectContaining({ code: 'task_not_startable' }) + ) + expect(database.getGate(gate.id)).toMatchObject({ + status: 'pending', + resolution: null, + resolved_at: null + }) + expect(database.getTask(task.id)?.status).toBe('blocked') + expect(database.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + }) +}) + +function createDatabase(): OrchestrationDb { + dir = mkdtempSync(join(tmpdir(), 'orca-task-dispatch-lifecycle-')) + db = new OrchestrationDb(join(dir, 'orchestration.db')) + return db +} + +function startWorker(database: OrchestrationDb, taskId: string, name: string): WorkerFixture { + const started = database.createStartingWorkerDispatch({ taskId, startOptions: {} }) + const paneSuffix = name.length.toString(16).padStart(12, '0') + const paneKey = `tab_${name}:aaaaaaaa-aaaa-4aaa-8aaa-${paneSuffix}` + const processIncarnation = `${name}:1` + const handle = `term_${name}` + const capability = database.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey, + processIncarnation, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + database.markWorkerDispatchReady(started.dispatch.id) + return { dispatchId: started.dispatch.id, capability, handle, paneKey, processIncarnation } +} + +function expectCapability(database: OrchestrationDb, worker: WorkerFixture, valid: boolean): void { + expect( + database.verifyDispatchCapability({ + dispatchId: worker.dispatchId, + capability: worker.capability, + paneKey: worker.paneKey, + processIncarnation: worker.processIncarnation + }).valid + ).toBe(valid) +} + +function sqliteFor(database: OrchestrationDb): Database.Database { + return (database as unknown as { db: Database.Database }).db +} diff --git a/src/main/runtime/orchestration/db-task-dispatch-races.test.ts b/src/main/runtime/orchestration/db-task-dispatch-races.test.ts new file mode 100644 index 00000000000..c9c765f9757 --- /dev/null +++ b/src/main/runtime/orchestration/db-task-dispatch-races.test.ts @@ -0,0 +1,198 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +type DatabaseHarness = { + db: OrchestrationDb + dir: string + path: string +} + +const harnesses: DatabaseHarness[] = [] + +afterEach(() => { + vi.restoreAllMocks() + const closed = harnesses.splice(0) + for (const harness of closed) { + harness.db.close() + } + for (const dir of new Set(closed.map((harness) => harness.dir))) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Task/Dispatch concurrency', () => { + it('rolls back Dispatch failure when Task requeue fails', () => { + const { db } = createDatabase() + const task = db.createTask({ spec: 'atomic retry failure' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + sqliteFor(db).exec(` + CREATE TRIGGER reject_task_requeue + BEFORE UPDATE OF status ON tasks + WHEN OLD.id = '${task.id}' + BEGIN + SELECT RAISE(ABORT, 'forced task requeue failure'); + END; + `) + + expect(() => db.failDispatch(dispatch.id, 'must roll back')).toThrow( + 'forced task requeue failure' + ) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + status: 'dispatched', + failure_count: 0, + last_failure: null, + completed_at: null, + capability_revoked_at: null + }) + }) + + it('does not let stale failure overwrite a completed worker report', () => { + const first = createDatabase() + const concurrent = createDatabase(first.path) + const task = first.db.createTask({ spec: 'worker completion wins' }) + const started = first.db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const capability = first.db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + processIncarnation: 'worker:1', + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + first.db.markWorkerDispatchReady(started.dispatch.id) + const sqlite = sqliteFor(first.db) + const prepare = sqlite.prepare.bind(sqlite) + let completionWon = false + vi.spyOn(sqlite, 'prepare').mockImplementation((sql) => { + if ( + !completionWon && + sql.includes('UPDATE dispatch_contexts') && + sql.includes('failure_count') + ) { + completionWon = true + expect( + concurrent.db.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatch.id, + outcome: 'succeeded', + result: 'completed concurrently' + }) + ).toMatchObject({ action: 'settled', duplicate: false }) + } + return prepare(sql) + }) + + expect( + first.db.failDispatch(started.dispatch.id, 'stale failure', { workerProcessExited: true }) + ).toMatchObject({ + status: 'completed', + failure_count: 0 + }) + expect(completionWon).toBe(true) + expect(first.db.getTask(task.id)).toMatchObject({ + status: 'completed', + result: 'completed concurrently' + }) + expect(first.db.getWorkerDispatch(started.dispatch.id)?.state).toBe('succeeded') + expect( + first.db.verifyDispatchCapability({ + dispatchId: started.dispatch.id, + capability, + paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + processIncarnation: 'worker:1' + }) + ).toMatchObject({ valid: false }) + }) + + it('serializes reminted-pane worker authority claims', () => { + const first = createDatabase() + const concurrent = createDatabase(first.path) + const losingTask = first.db.createTask({ spec: 'losing worker' }) + const winningTask = first.db.createTask({ spec: 'winning worker' }) + const loser = first.db.createStartingWorkerDispatch({ + taskId: losingTask.id, + startOptions: {} + }) + const winner = concurrent.db.createStartingWorkerDispatch({ + taskId: winningTask.id, + startOptions: {} + }) + const sqlite = sqliteFor(first.db) + const exec = sqlite.exec.bind(sqlite) + let winningCapability: string | undefined + vi.spyOn(sqlite, 'exec').mockImplementation((sql) => { + if (!winningCapability && sql === 'BEGIN IMMEDIATE') { + winningCapability = concurrent.db.prepareStartingWorkerAuthority({ + dispatchId: winner.dispatch.id, + handle: 'term_reminted', + paneKey: 'tab_new:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + processIncarnation: 'winner:1', + worktreeId: 'repo::winner', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + } + return exec(sql) + }) + + expect(() => + first.db.prepareStartingWorkerAuthority({ + dispatchId: loser.dispatch.id, + handle: 'term_original', + paneKey: 'tab_old:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + processIncarnation: 'loser:1', + worktreeId: 'repo::loser', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + ).toThrow(`already has an active dispatch (${winner.dispatch.id} for task ${winningTask.id})`) + expect(winningCapability).toBeDefined() + expect(first.db.getDispatchContextById(loser.dispatch.id)).toMatchObject({ + assignee_handle: null, + capability_hash: null + }) + expect(first.db.getWorkerDispatch(loser.dispatch.id)).toMatchObject({ + stage: 'accepted', + agent_terminal_handle: null + }) + expect(first.db.getWorkerTerminalResourceByOwner(loser.dispatch.id)).toBeUndefined() + expect(first.db.getDispatchContextById(winner.dispatch.id)).toMatchObject({ + assignee_handle: 'term_reminted', + capability_hash: expect.any(String) + }) + expect(first.db.getWorkerTerminalResourceByOwner(winner.dispatch.id)).toMatchObject({ + terminal_handle: 'term_reminted', + ownership_state: 'owned' + }) + expect( + sqlite + .prepare( + `SELECT COUNT(*) AS count FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') AND capability_hash IS NOT NULL` + ) + .get() + ).toEqual({ count: 1 }) + }) +}) + +function createDatabase(path?: string): DatabaseHarness { + const dir = path ? harnesses.find((harness) => harness.path === path)?.dir : undefined + const ownedDir = dir ?? mkdtempSync(join(tmpdir(), 'orca-task-dispatch-races-')) + const dbPath = path ?? join(ownedDir, 'orchestration.db') + const harness = { db: new OrchestrationDb(dbPath), dir: ownedDir, path: dbPath } + harnesses.push(harness) + return harness +} + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index fc5253aa411..03a6ee36dfa 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -6,6 +6,7 @@ export { } from './db/contract-constants' export type { RunListPage, TaskRuntimeLineageRow } from './db/run-list-page' export { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from './db/messages/mailbox-routing-page' +export { DISPATCH_CONTEXT_CLAIM_SQL } from './db/dispatch-context/dispatch-context-store' export type { ForeignDirectMailboxRoutingPage, MailboxRoutingPage diff --git a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts index 22bf627a673..087ec7e1e53 100644 --- a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts @@ -44,6 +44,7 @@ import { attachMigrateLegacyContractStorage } from './schema/migrate-legacy-cont import { attachSchemaMigrate } from './schema/migrate' import { attachSchemaColumnProbes } from './schema/schema-column-probes' import { attachTaskStore } from './tasks/task-store' +import { attachTaskStatusTransition } from './tasks/task-status-transition' 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' @@ -88,6 +89,7 @@ export function attachOrchestrationDbMethods(ctor: { prototype: object }): void attachForeignDirectMailboxRouting(ctor) attachQuestionThreads(ctor) attachTaskStore(ctor) + attachTaskStatusTransition(ctor) attachWorkerDispatchStart(ctor) attachWorkerDispatchStage(ctor) attachWorkerDispatchAuthority(ctor) diff --git a/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts b/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts index 7a7851bbfe5..2583d8e8f5b 100644 --- a/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts +++ b/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts @@ -1,4 +1,5 @@ -import type { GateStatus, DecisionGateRow } from '../../types' +import type { DecisionGateRow, DispatchContextRow, GateStatus } from '../../types' +import { OrchestrationError } from '../../orchestration-error' import { LEGACY_RUN_ID } from '../contract-constants' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' @@ -7,26 +8,81 @@ import type { OrchestrationDb } from '../orchestration-db' export function createGate( this: OrchestrationDb, - gate: { taskId: string; question: string; options?: string[] } + gate: { + taskId: string + question: string + options?: string[] + requester?: { handle: string; paneKey?: string | null; dispatchId: 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 + this.db.exec('SAVEPOINT create_gate') + try { + const active = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE task_id = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(gate.taskId) as DispatchContextRow | undefined + if ( + gate.requester && + (!active || + active.id !== gate.requester.dispatchId || + !this.isDispatchMessageSender({ + dispatchId: active.id, + handle: gate.requester.handle, + paneKey: gate.requester.paneKey, + allowCanonicalDispatchHandle: true + })) + ) { + throw new OrchestrationError( + 'consumer_fenced', + `Terminal ${gate.requester.handle} does not own the active Dispatch for Task ${gate.taskId}.`, + { taskId: gate.taskId, dispatchId: active?.id } + ) + } + const activeWorker = this.db + .prepare( + `SELECT active.id + FROM dispatch_contexts active + JOIN worker_dispatches worker ON worker.dispatch_id = active.id + WHERE active.task_id = ? AND active.status IN ('pending', 'dispatched') + AND worker.state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned') + ORDER BY active.rowid DESC LIMIT 1` + ) + .get(gate.taskId) as { id: string } | undefined + if (activeWorker) { + throw new OrchestrationError( + 'task_not_startable', + `Task ${gate.taskId} cannot open a gate while supervised Dispatch ${activeWorker.id} is active; stop or settle its worker first.`, + { taskId: gate.taskId, dispatchId: activeWorker.id } + ) + } + 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.completeActiveDispatchesForTask(gate.taskId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId) + const created = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as + | DecisionGateRow + | undefined + this.db.exec('RELEASE create_gate') + return created as DecisionGateRow + } catch (error) { + this.db.exec('ROLLBACK TO create_gate') + this.db.exec('RELEASE create_gate') + throw error + } } export function resolveGate( @@ -41,18 +97,24 @@ export function resolveGate( 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 + this.db.exec('SAVEPOINT resolve_gate') + try { + this.db + .prepare( + "UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?" + ) + .run(resolution, gateId) + this.updateTaskStatus(gate.task_id, 'ready') + const resolved = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + this.db.exec('RELEASE resolve_gate') + return resolved + } catch (error) { + this.db.exec('ROLLBACK TO resolve_gate') + this.db.exec('RELEASE resolve_gate') + throw error + } } export function timeoutGate(this: OrchestrationDb, gateId: string): DecisionGateRow | undefined { diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts index fa3006cd5b6..30451458541 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts @@ -1,6 +1,8 @@ -import type { TaskStatus, DispatchStatus, DispatchContextRow } from '../../types' +import type { TaskStatus, DispatchContextRow } from '../../types' +import { OrchestrationError } from '../../orchestration-error' import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './dispatch-circuit-breaker' import type { OrchestrationDb } from '../orchestration-db' +import { getActiveDispatchForTask } from './task-dispatch-reconciliation' const FAIL_DISPATCH_SAVEPOINT = 'fail_dispatch' @@ -13,15 +15,28 @@ export function completeDispatch(this: OrchestrationDb, ctxId: string): void { .run(ctxId) } -export function completeActiveDispatchForTask(this: OrchestrationDb, taskId: string): void { - const active = this.db +export function settleActiveDispatchesForTask( + db: OrchestrationDb, + taskId: string, + status: 'completed' | 'failed', + failure?: string +): void { + db.db .prepare( - "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + `UPDATE dispatch_contexts + SET status = ?, completed_at = COALESCE(completed_at, datetime('now')), + last_failure = CASE + WHEN ? = 'failed' THEN COALESCE(?, last_failure, 'Task marked failed') + ELSE last_failure + END, + capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE task_id = ? AND status IN ('pending', 'dispatched')` ) - .get(taskId) as DispatchContextRow | undefined - if (active) { - this.completeDispatch(active.id) - } + .run(status, status, failure ?? null, taskId) +} + +export function completeActiveDispatchesForTask(this: OrchestrationDb, taskId: string): void { + settleActiveDispatchesForTask(this, taskId, 'completed') } export function failActiveDispatchForTask( @@ -29,11 +44,7 @@ export function failActiveDispatchForTask( 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 + const active = getActiveDispatchForTask(this, taskId) return active ? this.failDispatch(active.id, error) : undefined } @@ -65,42 +76,68 @@ export function getStaleDispatches( export function failDispatch( this: OrchestrationDb, ctxId: string, - error: string + error: string, + options: { workerProcessExited?: boolean } = {} ): 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 = ?, + SET status = CASE WHEN failure_count + 1 >= ? THEN 'circuit_broken' ELSE 'failed' END, + failure_count = failure_count + 1, 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')` + WHERE id = ? AND status IN ('pending', 'dispatched') + AND (? = 1 OR NOT EXISTS ( + SELECT 1 FROM worker_dispatches worker + WHERE worker.dispatch_id = dispatch_contexts.id + AND worker.state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned') + ))` ) - .run(newStatus, newFailureCount, error, ctxId) - if (result.changes !== 1) { + .run(DISPATCH_CIRCUIT_BREAK_FAILURES, error, ctxId, options.workerProcessExited ? 1 : 0) + const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined + const worker = this.getWorkerDispatch(ctxId) + if (result.changes !== 1 || !ctx) { + if ( + ctx && + worker && + !['failed', 'succeeded', 'stopped', 'abandoned'].includes(worker.state) && + !options.workerProcessExited + ) { + throw new OrchestrationError( + 'task_not_startable', + `Dispatch ${ctxId} has an active supervised worker; stop it or settle its report first.`, + { dispatchId: ctxId } + ) + } this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`) - return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as - | DispatchContextRow - | undefined + return ctx + } + if (worker && options.workerProcessExited) { + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'failed', stage = 'process_exited', last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ? + AND state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned')` + ) + .run(error, ctxId) } // 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' + const taskStatus: TaskStatus = ctx.status === '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')") + .prepare( + `UPDATE tasks SET status = ? + WHERE id = ? AND status = 'dispatched' AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND status IN ('pending', 'dispatched') + )` + ) .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 @@ -115,7 +152,7 @@ export function failDispatch( export type DispatchCompletionMethods = { completeDispatch: typeof completeDispatch - completeActiveDispatchForTask: typeof completeActiveDispatchForTask + completeActiveDispatchesForTask: typeof completeActiveDispatchesForTask failActiveDispatchForTask: typeof failActiveDispatchForTask recordHeartbeat: typeof recordHeartbeat getStaleDispatches: typeof getStaleDispatches @@ -125,7 +162,7 @@ export type DispatchCompletionMethods = { export function attachDispatchCompletion(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { completeDispatch, - completeActiveDispatchForTask, + completeActiveDispatchesForTask, failActiveDispatchForTask, recordHeartbeat, getStaleDispatches, diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index f80c421c24a..c8415a0214c 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -1,9 +1,41 @@ import type { DispatchContextRow } from '../../types' import { OrchestrationError } from '../../orchestration-error' +import { parsePaneKey } from '../../../../../shared/stable-pane-id' import { CURRENT_CONTRACT_VERSION } from '../contract-constants' import { generateId } from '../generated-id' +import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +export const DISPATCH_CONTEXT_CLAIM_SQL = `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 +) +SELECT ?, run_id, id, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now') +FROM tasks +WHERE id = ? AND status = 'ready' + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_handle = ? + AND active.status IN ('pending', 'dispatched') + ) + AND ( + ? IS NULL OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_pane_key = ? + AND active.status IN ('pending', 'dispatched') + ) + ) + AND ( + ? IS NULL OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_pane_key IS NOT NULL + AND active.status IN ('pending', 'dispatched') + AND instr(active.assignee_pane_key, ':') > 1 + AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? + ) + )` + export function createDispatchContext( this: OrchestrationDb, taskId: string, @@ -36,33 +68,52 @@ export function createDispatchContext( .get(taskId) as { max_failures: number | null } | undefined const priorFailures = prior?.max_failures ?? 0 + const paneSuffix = + assigneePaneKey && parsePaneKey(assigneePaneKey) ? paneKeyMatchSuffix(assigneePaneKey) : null 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 + this.db.exec('SAVEPOINT create_dispatch_context') + try { + const inserted = this.db + .prepare(DISPATCH_CONTEXT_CLAIM_SQL) + .run( + id, + CURRENT_CONTRACT_VERSION, + launchTokenHash ?? null, + assigneeHandle, + assigneePaneKey ?? null, + processIncarnation ?? null, + priorFailures, + taskId, + assigneeHandle, + assigneePaneKey ?? null, + assigneePaneKey ?? null, + paneSuffix, + paneSuffix + ) + if (inserted.changes !== 1) { + const current = this.getTask(taskId) + const occupied = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey) + if (current?.status === 'ready' && occupied) { + throw new Error( + `Terminal ${assigneeHandle} already has an active dispatch (${occupied.id} for task ${occupied.task_id})` + ) + } + throw new Error( + `Task ${taskId} is ${current?.status ?? 'missing'}; only ready tasks can be dispatched` + ) + } + this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) + const dispatch = this.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(id) as DispatchContextRow + this.db.exec('RELEASE create_dispatch_context') + this.hasAnyDispatchContextsCache = true + return dispatch + } catch (error) { + this.db.exec('ROLLBACK TO create_dispatch_context') + this.db.exec('RELEASE create_dispatch_context') + throw error + } } export function getDispatchContext( diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts index 178d17dd88a..f5f760f8a8b 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts @@ -1,6 +1,10 @@ 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 { + DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, + isEquivalentPaneKey, + paneKeyMatchSuffix +} from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' export function getActiveDispatchForTerminal( @@ -33,6 +37,34 @@ export function getActiveDispatchForIdentity( return this.findActiveDispatchForAssignee(handle, paneKey) } +export function isDispatchMessageSender( + this: OrchestrationDb, + params: { + dispatchId: string + handle: string + paneKey?: string | null + allowCanonicalDispatchHandle?: boolean + } +): boolean { + const dispatch = this.getDispatchContextById(params.dispatchId) + if (!dispatch || !['pending', 'dispatched'].includes(dispatch.status)) { + return false + } + if (params.allowCanonicalDispatchHandle && params.handle === `dispatch:${dispatch.id}`) { + return true + } + if ( + params.paneKey && + dispatch.assignee_pane_key && + isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey) + ) { + return true + } + return ( + params.handle === dispatch.assignee_handle && (!params.paneKey || !dispatch.assignee_pane_key) + ) +} + export function findActiveDispatchForAssignee( this: OrchestrationDb, assigneeHandle: string, @@ -93,6 +125,7 @@ export type DispatchLookupMethods = { getActiveDispatchForTerminal: typeof getActiveDispatchForTerminal hasAnyDispatchContexts: typeof hasAnyDispatchContexts getActiveDispatchForIdentity: typeof getActiveDispatchForIdentity + isDispatchMessageSender: typeof isDispatchMessageSender findActiveDispatchForAssignee: typeof findActiveDispatchForAssignee getLatestDispatchForTerminal: typeof getLatestDispatchForTerminal } @@ -102,6 +135,7 @@ export function attachDispatchLookup(ctor: { prototype: object }): void { getActiveDispatchForTerminal, hasAnyDispatchContexts, getActiveDispatchForIdentity, + isDispatchMessageSender, findActiveDispatchForAssignee, getLatestDispatchForTerminal }) diff --git a/src/main/runtime/orchestration/db/dispatch-context/task-dispatch-reconciliation.ts b/src/main/runtime/orchestration/db/dispatch-context/task-dispatch-reconciliation.ts new file mode 100644 index 00000000000..da675580f6d --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/task-dispatch-reconciliation.ts @@ -0,0 +1,30 @@ +import type { DispatchContextRow } from '../../types' +import type { OrchestrationDb } from '../orchestration-db' + +export function getActiveDispatchForTask( + db: OrchestrationDb, + taskId: string +): DispatchContextRow | undefined { + return db.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 +} + +export function reconcileTaskAfterDispatchInterruption( + db: OrchestrationDb, + taskId: string, + dispatchId: string +): void { + db.db + .prepare( + `UPDATE tasks + SET status = CASE WHEN EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND id != ? AND status IN ('pending', 'dispatched') + ) THEN 'dispatched' ELSE 'blocked' END + WHERE id = ? AND status IN ('dispatched', 'blocked')` + ) + .run(dispatchId, taskId) +} diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts index 7c1fc5f9429..734c25b1264 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts @@ -1,5 +1,7 @@ import type { WorkerReportOutcome, WorkerReportSettlement } from '../../types' import type { OrchestrationDb } from '../orchestration-db' +import { settleActiveDispatchesForTask } from './dispatch-completion' +import { getActiveDispatchForTask } from './task-dispatch-reconciliation' export function settleWorkerReport( this: OrchestrationDb, @@ -62,14 +64,39 @@ export function settleWorkerReportInTransaction( reason: `inactive dispatch ${params.dispatchId}: it or task ${params.taskId} is already settled.` } } - const latest = this.getDispatchContext(params.taskId) - if (latest?.id !== params.dispatchId) { + const conflictingWorker = this.db + .prepare( + `SELECT active.id + FROM dispatch_contexts active + JOIN worker_dispatches worker ON worker.dispatch_id = active.id + WHERE active.task_id = ? AND active.id != ? + AND active.status IN ('pending', 'dispatched') + AND worker.state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned') + ORDER BY active.rowid DESC LIMIT 1` + ) + .get(params.taskId, params.dispatchId) as { id: string } | undefined + if (conflictingWorker) { + return { + action: 'rejected', + code: 'inactive_dispatch', + reason: `Task ${params.taskId} still has active supervised Dispatch ${conflictingWorker.id}; stop or settle it before completing ${params.dispatchId}.` + } + } + const reportingWorker = this.getWorkerDispatch(params.dispatchId) + const latest = getActiveDispatchForTask(this, params.taskId) + if (!reportingWorker && latest?.id !== params.dispatchId) { return { action: 'rejected', code: 'stale_dispatch', reason: `Dispatch ${params.dispatchId} is not the current dispatch for task ${params.taskId}.` } } + const siblingDispatchIds = this.db + .prepare( + `SELECT id FROM dispatch_contexts + WHERE task_id = ? AND id != ? AND status IN ('pending', 'dispatched')` + ) + .all(params.taskId, params.dispatchId) as { id: string }[] this.db.exec('SAVEPOINT settle_worker_report') const dispatchUpdate = this.db @@ -104,7 +131,16 @@ export function settleWorkerReportInTransaction( WHERE dispatch_id = ? AND state = 'ready'` ) .run(params.outcome === 'succeeded' ? 'succeeded' : 'failed', params.dispatchId) + settleActiveDispatchesForTask( + this, + params.taskId, + expectedDispatchStatus, + params.outcome === 'failed' ? params.result : undefined + ) this.closeQuestionsForDispatch(params.dispatchId) + for (const sibling of siblingDispatchIds) { + this.closeQuestionsForDispatch(sibling.id) + } if (params.outcome === 'succeeded') { this.promoteReadyTasks(params.taskId) } diff --git a/src/main/runtime/orchestration/db/messages/message-inbox.ts b/src/main/runtime/orchestration/db/messages/message-inbox.ts index e5d03eea771..af94b2e7c16 100644 --- a/src/main/runtime/orchestration/db/messages/message-inbox.ts +++ b/src/main/runtime/orchestration/db/messages/message-inbox.ts @@ -64,7 +64,10 @@ export function convertLifecycleMessageToRejection( reason: string ): MessageRow | undefined { const message = this.getMessageById(messageId) - if (!message || (message.type !== 'worker_done' && message.type !== 'heartbeat')) { + if ( + !message || + !['worker_done', 'heartbeat', 'escalation', 'decision_gate'].includes(message.type) + ) { return message } @@ -75,7 +78,8 @@ export function convertLifecycleMessageToRejection( this.db .prepare( `UPDATE messages - SET priority = 'high', subject = ?, body = ?, payload = ? + SET type = CASE WHEN type IN ('escalation', 'decision_gate') THEN 'status' ELSE type END, + priority = 'high', subject = ?, body = ?, payload = ? WHERE id = ?` ) .run(`Rejected ${message.type}: ${message.subject}`, body, payload, messageId) diff --git a/src/main/runtime/orchestration/db/orchestration-db-methods.ts b/src/main/runtime/orchestration/db/orchestration-db-methods.ts index 129bd1ddb54..8fa48a0edd3 100644 --- a/src/main/runtime/orchestration/db/orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/orchestration-db-methods.ts @@ -44,6 +44,7 @@ import type { MigrateLegacyContractStorageMethods } from './schema/migrate-legac import type { SchemaMigrateMethods } from './schema/migrate' import type { SchemaColumnProbesMethods } from './schema/schema-column-probes' import type { TaskStoreMethods } from './tasks/task-store' +import type { TaskStatusTransitionMethods } from './tasks/task-status-transition' import type { FederatedWorkerStartReconcileMethods } from './worker-dispatch/federated-worker-start-reconcile' import type { WorkerDispatchAbandonMethods } from './worker-dispatch/worker-dispatch-abandon' import type { WorkerDispatchAuthorityMethods } from './worker-dispatch/worker-dispatch-authority' @@ -87,6 +88,7 @@ export type OrchestrationDbMethods = CreateTablesMethods & ForeignDirectMailboxRoutingMethods & QuestionThreadsMethods & TaskStoreMethods & + TaskStatusTransitionMethods & WorkerDispatchStartMethods & WorkerDispatchStageMethods & WorkerDispatchAuthorityMethods & diff --git a/src/main/runtime/orchestration/db/tasks/task-status-transition.ts b/src/main/runtime/orchestration/db/tasks/task-status-transition.ts new file mode 100644 index 00000000000..1fa73c3ee1f --- /dev/null +++ b/src/main/runtime/orchestration/db/tasks/task-status-transition.ts @@ -0,0 +1,123 @@ +import { OrchestrationError } from '../../orchestration-error' +import type { TaskRow, TaskStatus } from '../../types' +import { settleActiveDispatchesForTask } from '../dispatch-context/dispatch-completion' +import type { OrchestrationDb } from '../orchestration-db' + +export function updateTaskStatus( + this: OrchestrationDb, + id: string, + status: TaskStatus, + result?: string +): TaskRow | undefined { + const terminalStatus = status === 'completed' || status === 'failed' + const requiresActiveDispatch = status === 'dispatched' + const permitsActiveDispatch = terminalStatus || requiresActiveDispatch + this.db.exec('SAVEPOINT update_task_status') + try { + const completedAt = terminalStatus ? new Date().toISOString() : null + const update = this.db + .prepare( + `UPDATE tasks + SET status = ?, result = COALESCE(?, result), + completed_at = COALESCE(?, completed_at) + WHERE id = ? + AND ( + ? = 0 OR EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND status IN ('pending', 'dispatched') + ) + ) + AND ( + ? = 1 OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND status IN ('pending', 'dispatched') + ) + ) + AND ( + ? = 0 OR NOT EXISTS ( + SELECT 1 + FROM dispatch_contexts active + JOIN worker_dispatches worker ON worker.dispatch_id = active.id + WHERE active.task_id = tasks.id + AND active.status IN ('pending', 'dispatched') + AND worker.state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned') + ) + )` + ) + .run( + status, + result ?? null, + completedAt, + id, + requiresActiveDispatch ? 1 : 0, + permitsActiveDispatch ? 1 : 0, + terminalStatus ? 1 : 0 + ) + if (update.changes !== 1) { + const task = this.getTask(id) + const active = this.db + .prepare( + `SELECT id FROM dispatch_contexts + WHERE task_id = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(id) as { id: string } | undefined + const activeWorker = terminalStatus + ? (this.db + .prepare( + `SELECT active.id + FROM dispatch_contexts active + JOIN worker_dispatches worker ON worker.dispatch_id = active.id + WHERE active.task_id = ? AND active.status IN ('pending', 'dispatched') + AND worker.state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned') + ORDER BY active.rowid DESC LIMIT 1` + ) + .get(id) as { id: string } | undefined) + : undefined + if (task && activeWorker) { + throw new OrchestrationError( + 'task_not_startable', + `Task ${id} cannot move to ${status} while supervised Dispatch ${activeWorker.id} is active; stop or settle its worker first.`, + { taskId: id, dispatchId: activeWorker.id } + ) + } + if (task && requiresActiveDispatch && !active) { + throw new OrchestrationError( + 'task_not_startable', + `Task ${id} cannot move to dispatched without an active Dispatch.`, + { taskId: id } + ) + } + if (task && active && !permitsActiveDispatch) { + throw new OrchestrationError( + 'task_not_startable', + `Task ${id} cannot move to ${status} while Dispatch ${active.id} is active.`, + { taskId: id, dispatchId: active.id } + ) + } + this.db.exec('RELEASE update_task_status') + return task + } + if (terminalStatus) { + settleActiveDispatchesForTask(this, id, status, result) + } + if (status === 'completed') { + this.promoteReadyTasks(id) + } + const task = this.getTask(id) + this.db.exec('RELEASE update_task_status') + return task + } catch (error) { + this.db.exec('ROLLBACK TO update_task_status') + this.db.exec('RELEASE update_task_status') + throw error + } +} + +export type TaskStatusTransitionMethods = { + updateTaskStatus: typeof updateTaskStatus +} + +export function attachTaskStatusTransition(ctor: { prototype: object }): void { + Object.assign(ctor.prototype, { updateTaskStatus }) +} diff --git a/src/main/runtime/orchestration/db/tasks/task-store.ts b/src/main/runtime/orchestration/db/tasks/task-store.ts index b06920503c1..9a0b15259ba 100644 --- a/src/main/runtime/orchestration/db/tasks/task-store.ts +++ b/src/main/runtime/orchestration/db/tasks/task-store.ts @@ -190,28 +190,6 @@ export function listTasksWithDispatch( })[] } -export function updateTaskStatus( - this: OrchestrationDb, - id: string, - status: TaskStatus, - result?: string -): TaskRow | undefined { - const completedAt = - status === 'completed' || status === 'failed' ? new Date().toISOString() : null - this.db - .prepare( - 'UPDATE tasks SET status = ?, result = COALESCE(?, result), completed_at = COALESCE(?, completed_at) WHERE id = ?' - ) - .run(status, result ?? null, completedAt, id) - - if (status === 'completed') { - this.promoteReadyTasks(id) - this.completeActiveDispatchForTask(id) - } - - return this.getTask(id) -} - // Why: runs in the status-update transaction, so a completed task never leaves its ready children unpromoted. export function promoteReadyTasks(this: OrchestrationDb, completedTaskId: string): void { const candidates = this.db @@ -239,7 +217,6 @@ export type TaskStoreMethods = { getTask: typeof getTask listTasks: typeof listTasks listTasksWithDispatch: typeof listTasksWithDispatch - updateTaskStatus: typeof updateTaskStatus promoteReadyTasks: typeof promoteReadyTasks } @@ -249,7 +226,6 @@ export function attachTaskStore(ctor: { prototype: object }): void { getTask, listTasks, listTasksWithDispatch, - updateTaskStatus, promoteReadyTasks }) } diff --git a/src/main/runtime/orchestration/db/worker-dispatch/federated-worker-start-reconcile.ts b/src/main/runtime/orchestration/db/worker-dispatch/federated-worker-start-reconcile.ts index 7b36845525e..402809c0c8a 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/federated-worker-start-reconcile.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/federated-worker-start-reconcile.ts @@ -1,6 +1,7 @@ import type { WorkerDispatchRow } from '../../types' import { OrchestrationError } from '../../orchestration-error' import type { OrchestrationDb } from '../orchestration-db' +import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation' export function reconcileFederatedWorkerStart( this: OrchestrationDb, @@ -87,9 +88,15 @@ export function reconcileFederatedWorkerStart( WHERE id = ? AND status IN ('pending', 'dispatched')` ) .run(reason, params.dispatchId) + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, params.dispatchId) this.db .prepare( - "UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE id = ? AND status IN ('blocked', 'dispatched')" + `UPDATE tasks SET status = 'failed', completed_at = datetime('now') + WHERE id = ? AND status IN ('blocked', 'dispatched') + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND status IN ('pending', 'dispatched') + )` ) .run(dispatch.task_id) this.closeQuestionsForDispatch(params.dispatchId) diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts index 93c93a3550c..b6c18081e76 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts @@ -5,6 +5,7 @@ import { type ContextOnlyDispatchReleaseResult } from '../../context-only-dispatch-release' import type { OrchestrationDb } from '../orchestration-db' +import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation' export function abandonWorkerDispatch( this: OrchestrationDb, @@ -23,12 +24,7 @@ export function abandonWorkerDispatch( throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) } if (!worker) { - const released = releaseContextOnlyDispatch( - this.db, - dispatch, - this.getDispatchContext(dispatch.task_id)?.id, - 'abandoned' - ) + const released = releaseContextOnlyDispatch(this.db, dispatch, 'abandoned') if (!released.alreadySettled) { this.closeQuestionsForDispatch(dispatchId) } @@ -65,7 +61,7 @@ export function abandonWorkerDispatch( WHERE id = ?` ) .run(dispatchId) - this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id) + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) this.closeQuestionsForDispatch(dispatchId) this.db.exec('COMMIT') return { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index af395f7399f..449704017c1 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -21,7 +21,6 @@ export function prepareStartingWorkerAuthority( terminalOwnership?: 'created' | 'external' } ): string { - const capability = `dcap_${randomBytes(32).toString('base64url')}` this.db.exec('BEGIN IMMEDIATE') try { // Why: read inside the transaction so the guarded UPDATEs below cannot lose a race with a concurrent state change. @@ -43,6 +42,13 @@ export function prepareStartingWorkerAuthority( `Dispatch ${params.dispatchId} already has a different launch-token commitment.` ) } + const existing = this.findActiveDispatchForAssignee(params.handle, params.paneKey) + if (existing && existing.id !== params.dispatchId) { + throw new Error( + `Terminal ${params.handle} already has an active dispatch (${existing.id} for task ${existing.task_id})` + ) + } + const capability = `dcap_${randomBytes(32).toString('base64url')}` const contextUpdate = this.db .prepare( `UPDATE dispatch_contexts diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts index d4f50dfb2bf..d9a8b23a39b 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts @@ -62,7 +62,13 @@ export function failWorkerStart( ) .run(stage, reason, dispatchId) this.db - .prepare("UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE id = ?") + .prepare( + `UPDATE tasks SET status = 'failed', completed_at = datetime('now') + WHERE id = ? AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND status IN ('pending', 'dispatched') + )` + ) .run(dispatch.task_id) this.closeQuestionsForDispatch(dispatchId) this.db.exec('COMMIT') diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts index d10148c4daf..93fb4d7cf73 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts @@ -6,6 +6,7 @@ import { } from '../../context-only-dispatch-release' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation' export function isDispatchProcessCurrent( this: OrchestrationDb, @@ -40,12 +41,7 @@ export function beginWorkerStop( throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) } if (!worker) { - const released = releaseContextOnlyDispatch( - this.db, - dispatch, - this.getDispatchContext(dispatch.task_id)?.id, - 'stopped' - ) + const released = releaseContextOnlyDispatch(this.db, dispatch, 'stopped') if (!released.alreadySettled) { this.closeQuestionsForDispatch(dispatchId) } @@ -76,7 +72,7 @@ export function beginWorkerStop( WHERE id = ?` ) .run(dispatchId) - this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id) + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) this.closeQuestionsForDispatch(dispatchId) this.db.exec('COMMIT') return { @@ -112,6 +108,7 @@ export function settleWorkerStop(this: OrchestrationDb, dispatchId: string): Wor WHERE id = ? AND status IN ('pending', 'dispatched')` ) .run(dispatchId) + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) this.db.exec('COMMIT') return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow } catch (error) { @@ -160,6 +157,7 @@ export function reconcileFederatedWorkerStop( WHERE id = ? AND status IN ('pending', 'dispatched')` ) .run(dispatchId) + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) this.db.exec('COMMIT') return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow } catch (error) { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts index c73cb4a74e8..904cc0d5b98 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts @@ -7,6 +7,7 @@ import type { import { OrchestrationError } from '../../orchestration-error' import { DISPATCH_CIRCUIT_BREAK_FAILURES } from '../dispatch-context/dispatch-circuit-breaker' import type { OrchestrationDb } from '../orchestration-db' +import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation' export function listLegacyWorkerTerminalRecoveryRows( this: OrchestrationDb @@ -59,11 +60,16 @@ export function reconcileMissingWorkerTerminal( .run(dispatchStatus, failureCount, reason, dispatchId) if (!stopWasPending) { const taskStatus: TaskStatus = dispatchStatus === 'circuit_broken' ? 'failed' : 'ready' + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) this.db .prepare( `UPDATE tasks SET status = ?, completed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END - WHERE id = ? AND status IN ('dispatched', 'blocked')` + WHERE id = ? AND status IN ('dispatched', 'blocked') + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE task_id = tasks.id AND status IN ('pending', 'dispatched') + )` ) .run(taskStatus, taskStatus, dispatch.task_id) } diff --git a/src/main/runtime/orchestration/dispatch-message-binding.ts b/src/main/runtime/orchestration/dispatch-message-binding.ts new file mode 100644 index 00000000000..2646e346af7 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-message-binding.ts @@ -0,0 +1,23 @@ +import type { MessageType } from './types' + +export function bindCoordinatorMutationPayload( + type: MessageType, + payload: string | null | undefined, + dispatchId: string +): string | undefined { + if (type !== 'escalation' && type !== 'decision_gate') { + return payload ?? undefined + } + if (!payload) { + return JSON.stringify({ dispatchId }) + } + try { + const parsed: unknown = JSON.parse(payload) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return payload + } + return JSON.stringify({ ...parsed, dispatchId }) + } catch { + return payload + } +} diff --git a/src/main/runtime/orchestration/federation-sync.test.ts b/src/main/runtime/orchestration/federation-sync.test.ts index b5a57e0394f..55372924d57 100644 --- a/src/main/runtime/orchestration/federation-sync.test.ts +++ b/src/main/runtime/orchestration/federation-sync.test.ts @@ -116,6 +116,74 @@ describe('federation relay parsing', () => { parseRelayedMessage(JSON.stringify({ subject: 'bad', body: 'Blocked', type: 'invented' })) ).toThrowError('Federated relay message type invented is not supported.') }) + + it.each(['escalation', 'decision_gate'] as const)( + 'binds an old remote %s payload to the imported Dispatch', + async (type) => { + const db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'Federated mutation binding', + coordinatorHandle: 'term_coordinator', + coordinatorPaneKey: 'tab_coordinator:11111111-1111-4111-8111-111111111111' + }) + const task = db.createTask({ spec: 'Remote work', runId: run.id }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + federation: { + environmentId: 'environment_windows', + environmentName: 'windows', + peerFingerprint: 'windows_peer_fingerprint', + protocolVersion: 3 + } + }) + db.recordWorkerStage({ dispatchId: dispatch.id, stage: 'ready', state: 'ready' }) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + peerFingerprint: 'windows_peer_fingerprint' + } as never) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockImplementation( + async (_environmentId, method) => { + if (method === 'orchestration.federationPull') { + return { + runtimeEpoch: 'remote_epoch_1', + items: [ + { + dispatch_id: dispatch.id, + direction: 'to_home', + sequence: 1, + message_id: `msg_remote_${type}`, + kind: type, + payload: JSON.stringify({ + subject: 'Remote control mutation', + body: '', + type, + payload: JSON.stringify({ + taskId: task.id, + ...(type === 'decision_gate' ? { question: 'Proceed?' } : {}) + }) + }) + } + ] + } + } + if (method === 'orchestration.federationAck') { + return { acknowledgedThrough: 1 } + } + throw new Error(`Unexpected method ${method}`) + } + ) + + await syncFederatedDispatch(runtime, dispatch.id) + + expect(JSON.parse(db.getMessageById(`msg_remote_${type}`)!.payload!)).toMatchObject({ + taskId: task.id, + dispatchId: dispatch.id + }) + db.close() + } + ) }) describe('federation relay acknowledgments', () => { diff --git a/src/main/runtime/orchestration/federation-sync.ts b/src/main/runtime/orchestration/federation-sync.ts index 3bc7f0f5180..673d8c62002 100644 --- a/src/main/runtime/orchestration/federation-sync.ts +++ b/src/main/runtime/orchestration/federation-sync.ts @@ -14,6 +14,7 @@ import { recordFederationAckCheckpoint } from './federation-ack-checkpoints' import { parseFederatedWorkerReportPayload } from './federation-worker-report-payload' +import { bindCoordinatorMutationPayload } from './dispatch-message-binding' const MESSAGE_TYPE_SET = new Set(MESSAGE_TYPES) const FEDERATION_PULL_PAGE_SIZE = 50 @@ -112,7 +113,7 @@ async function syncFederatedDispatchPages( type: message.type, priority: message.priority, threadId: message.threadId ?? undefined, - payload: message.payload ?? undefined + payload: bindCoordinatorMutationPayload(message.type, message.payload, dispatchId) }, lifecycle: parseFederatedLifecycle(message, item.message_id, dispatchId, dispatch.task_id) }) diff --git a/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts index 4a6067aa959..af67cf49ba9 100644 --- a/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts +++ b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import type Database from '../../sqlite/sync-database' -import { OrchestrationDb } from './db' +import { DISPATCH_CONTEXT_CLAIM_SQL, OrchestrationDb } from './db' const CREATOR_PANE = 'tab-creator:11111111-1111-4111-8111-111111111111' const CREATOR_PROCESS = 'pty-creator:incarnation-a' @@ -50,6 +50,32 @@ describe('creator authority lookup performance', () => { expect(paneDetails).toContain('idx_dispatch_assignee_pane_leaf') }) + it('uses active-assignee indexes for Dispatch occupancy claims', () => { + db = new OrchestrationDb(':memory:') + const plan = sqliteFor(db) + .prepare(`EXPLAIN QUERY PLAN ${DISPATCH_CONTEXT_CLAIM_SQL}`) + .all( + 'ctx_claimant', + 1, + null, + 'term_worker', + 'tab_worker:33333333-3333-4333-8333-333333333333', + 'worker:1', + 0, + 'task_claimant', + 'term_worker', + 'tab_worker:33333333-3333-4333-8333-333333333333', + 'tab_worker:33333333-3333-4333-8333-333333333333', + '33333333-3333-4333-8333-333333333333', + '33333333-3333-4333-8333-333333333333' + ) as { detail: string }[] + const details = plan.map((row) => row.detail).join(' | ') + + expect(details).toContain('idx_dispatch_active_assignee_handle') + expect(details).toContain('idx_dispatch_active_assignee_pane_key') + expect(details).toContain('idx_dispatch_assignee_pane_leaf') + }) + it('keeps 300 Task reads bounded with 50,000 retained Runs', () => { db = new OrchestrationDb(':memory:') const run = db.createRun({ diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 68ff9269615..3cedb9fa242 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -96,6 +96,7 @@ describe('buildDispatchPreamble', () => { expect(result).toMatch(/orchestration ask --from term_worker/) expect(result).toContain('--question') expect(result).toContain('--timeout-ms 600000') + expect(result).not.toContain('--type decision_gate') // Why: the exact phrase is asserted so the rule can't be trimmed away by // accident. BEHAVIOR RULE #1 is the only place AskUserQuestion appears. expect(result).toContain('BEHAVIOR RULE #1') @@ -112,6 +113,7 @@ describe('buildDispatchPreamble', () => { expect(result).toMatch(/orchestration ask --from term_worker/) expect(result).toMatch(/orchestration send --from term_worker \\\n --type escalation/) + expect(result).toContain('--task-id task_abc123 --dispatch-id ctx_def456') expect(result).toContain('orchestration check --terminal term_worker') }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 9d51ca40480..d98f33763d2 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -104,8 +104,8 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # Ask the coordinator a question and block until it answers. # # BEHAVIOR RULE #1 (MUST NOT VIOLATE): - # NEVER use AskUserQuestion; use \`${cli} orchestration ask\` or send - # --type decision_gate. AskUserQuestion opens a local TUI prompt that the + # NEVER use AskUserQuestion; use \`${cli} orchestration ask\`. + # AskUserQuestion opens a local TUI prompt that the # coordinator cannot see and cannot answer — your session will hang forever # waiting on a human. Every interactive question goes through \`ask\` below. # @@ -123,7 +123,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} \\ --type escalation --subject "Blocked: " \\ --body "
" \\ - --task-id ${params.taskId} + --task-id ${params.taskId} --dispatch-id ${params.dispatchId} # Check for messages from the coordinator: ${cli} orchestration check --terminal ${params.workerHandle} diff --git a/src/main/runtime/rpc/methods/orchestration-check.test.ts b/src/main/runtime/rpc/methods/orchestration-check.test.ts index 9738c374afe..ce45a2cfec4 100644 --- a/src/main/runtime/rpc/methods/orchestration-check.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-check.test.ts @@ -686,13 +686,13 @@ describe('orchestration RPC methods', () => { from: 'worker', to: 'coord', subject: 'needs attention', - type: 'escalation', + type: 'question', run: activeRunId }) const result = await waitPromise expect(result.count).toBe(1) - expect(result.messages[0].type).toBe('escalation') + expect(result.messages[0].type).toBe('question') }) it('does not mark existing messages read when the check starts aborted', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts b/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts index d4a124cf895..6f98b3b8cf2 100644 --- a/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type Database from '../../../sqlite/sync-database' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { ORCHESTRATION_METHODS } from './orchestration' @@ -103,7 +104,8 @@ describe('manual Dispatch release', () => { it('fences a superseded context without blocking its current replacement', async () => { const task = createTask('superseded') const superseded = await dispatchTask(task, TARGET) - db.updateTaskStatus(task, 'ready') + // Why: recovery still needs coverage for contradictory rows persisted before ready resets were guarded. + sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task) const current = await dispatchTask(task, OTHER) await expect(call('orchestration.workerStop', { dispatch: superseded })).resolves.toMatchObject( @@ -184,3 +186,7 @@ describe('manual Dispatch release', () => { function paneKey(handle: string): string { return `tab:${handle}` } + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts b/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts new file mode 100644 index 00000000000..cbba56c1c2a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' +import type { OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { openDecisionGateFromMessage } from '../../orchestration/coordinator-decision-gates' +import { applyEscalationToDispatch } from '../../orchestration/coordinator-escalation-triage' +import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' + +describe('orchestration.send Dispatch authority', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + + function setup(): void { + ;({ db, runtime, ctx } = harness.setup()) + } + + async function send(params: Record) { + return harness.call('orchestration.send', params, ctx) + } + + afterEach(() => { + harness.cleanup() + }) + + it.each([false, true])( + 'rejects cross-Task escalation with legacy authority=%s', + async (legacyAuthority) => { + setup() + const attackerTask = db.createTask({ spec: 'attacker assignment' }) + const attacker = db.createDispatchContext( + attackerTask.id, + 'term_attacker', + 'tab_attacker:leaf_attacker', + undefined, + legacyAuthority ? undefined : 'runtime_test:term_attacker:1' + ) + const victimTask = db.createTask({ spec: 'victim assignment' }) + const victim = db.createDispatchContext(victimTask.id, 'term_victim') + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_attacker' ? 'tab_attacker:leaf_attacker' : harness.coordinatorPaneKey + ) + if (!legacyAuthority) { + ctx = { + runtime, + orchestrationCapability: db.mintDispatchCapability({ + dispatchId: attacker.id, + paneKey: 'tab_attacker:leaf_attacker', + processIncarnation: 'runtime_test:term_attacker:1' + }) + } + } + + const result = (await send({ + from: 'term_attacker', + type: 'escalation', + subject: 'Fail the victim', + payload: JSON.stringify({ taskId: victimTask.id }) + })) as { lifecycle: { action: string; code: string }; message: { type: string } } + + expect(result.lifecycle).toMatchObject({ + action: 'rejected', + code: 'task_dispatch_mismatch' + }) + expect(result.message.type).toBe('status') + expect(db.getTask(attackerTask.id)?.status).toBe('dispatched') + expect(db.getTask(victimTask.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(attacker.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(victim.id)?.status).toBe('dispatched') + } + ) + + it('rejects a caller-spoofed canonical Dispatch sender', async () => { + setup() + const task = db.createTask({ spec: 'legacy victim assignment' }) + const dispatch = db.createDispatchContext(task.id, 'term_victim') + + const result = (await send({ + from: `dispatch:${dispatch.id}`, + type: 'escalation', + subject: 'Spoof imported federation mail', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + })) as { lifecycle: { action: string; code: string }; message: { type: string } } + + expect(result.lifecycle).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(result.message.type).toBe('status') + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + }) + + it.each(['escalation', 'decision_gate'] as const)( + 'accepts a matching legacy sender with a newly observed pane for %s', + async (type) => { + setup() + const task = db.createTask({ spec: 'legacy owned assignment' }) + db.createDispatchContext(task.id, 'term_legacy') + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_legacy' ? 'tab_legacy:leaf_legacy' : harness.coordinatorPaneKey + ) + + const result = (await send({ + from: 'term_legacy', + type, + subject: 'Legitimate legacy control', + payload: JSON.stringify({ + taskId: task.id, + ...(type === 'decision_gate' ? { question: 'Proceed?' } : {}) + }) + })) as { message: { type: string }; lifecycle?: { action: string } } + + expect(result.message.type).toBe(type) + expect(result.lifecycle).toBeUndefined() + expect(db.getTask(task.id)?.status).toBe('dispatched') + } + ) + + it.each(['escalation', 'decision_gate'] as const)( + 'binds queued legacy %s mail to its exact Dispatch before handle reuse', + async (type) => { + setup() + const task = db.createTask({ spec: 'legacy re-dispatch target' }) + const first = db.createDispatchContext(task.id, 'term_legacy') + + const sent = (await send({ + from: 'term_legacy', + type, + subject: 'Queued legacy control', + payload: JSON.stringify({ + taskId: task.id, + ...(type === 'decision_gate' ? { question: 'Proceed?' } : {}) + }) + })) as { message: { id: string; payload: string } } + + expect(JSON.parse(sent.message.payload)).toMatchObject({ dispatchId: first.id }) + db.failDispatch(first.id, 'worker stopped before coordinator read its mail') + const second = db.createDispatchContext(task.id, 'term_legacy') + + if (type === 'escalation') { + applyEscalationToDispatch(db, db.getMessageById(sent.message.id)!, () => {}) + } else { + openDecisionGateFromMessage(db, db.getMessageById(sent.message.id)!, () => {}) + } + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(second.id)?.status).toBe('dispatched') + expect(db.listGates({ taskId: task.id })).toHaveLength(0) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index 0462249bf32..1169e7b4e1c 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -8,7 +8,9 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' -function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { +function lifecycleGroupRecipientError( + type: 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' +): string { return `${type} messages belong to one exact Dispatch and cannot target a group address.` } @@ -206,6 +208,107 @@ describe('orchestration RPC methods', () => { expect(result).toMatchObject({ lifecycle: { action: 'completed' } }) }) + it('fences a replacement process for a capability-less manual Dispatch', async () => { + setup() + const task = db.createTask({ spec: 'process-bound manual work' }) + const dispatch = db.createDispatchContext( + task.id, + 'term_worker', + 'tab_worker:leaf_worker', + undefined, + 'runtime_test:term_worker:1' + ) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey + ) + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') + const payload = JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + + const rejected = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done after replacement', + type: 'worker_done', + payload + })) as { lifecycle: { action: string; code: string } } + + expect(rejected.lifecycle).toEqual({ + action: 'rejected', + code: 'sender_not_assignee', + reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:1') + const accepted = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done by assignee', + type: 'worker_done', + payload + })) as { lifecycle: { action: string } } + expect(accepted.lifecycle.action).toBe('completed') + }) + + it.each(['escalation', 'decision_gate'] as const)( + 'fences a replacement process from a %s mutation', + async (type) => { + setup() + const task = db.createTask({ spec: `process-bound ${type}` }) + const dispatch = db.createDispatchContext( + task.id, + 'term_worker', + 'tab_worker:leaf_worker', + undefined, + 'runtime_test:term_worker:1' + ) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey + ) + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue( + 'runtime_test:term_worker:2' + ) + + const rejected = (await call('orchestration.send', { + from: 'term_worker', + subject: `${type} after replacement`, + type, + payload: JSON.stringify({ + taskId: task.id, + ...(type === 'decision_gate' ? { question: 'Proceed?' } : {}) + }) + })) as { + lifecycle: { action: string; code: string } + message: { type: string } + } + + expect(rejected.lifecycle).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(db.listGates({ taskId: task.id })).toHaveLength(0) + expect(rejected.message.type).toBe('status') + + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue( + 'runtime_test:term_worker:1' + ) + const accepted = (await call('orchestration.send', { + from: 'term_worker', + subject: `${type} by assignee`, + type, + payload: JSON.stringify({ + taskId: task.id, + ...(type === 'decision_gate' ? { question: 'Proceed?' } : {}) + }) + })) as { message: { type: string } } + expect(accepted.message.type).toBe(type) + } + ) + it('rejects an identity-less lifecycle send resolved through the coordinator handle', async () => { setup() const task = db.createTask({ spec: 'work' }) @@ -551,6 +654,27 @@ describe('orchestration RPC methods', () => { expect(db.getInbox(100)).toHaveLength(0) }) + it.each(['escalation', 'decision_gate'] as const)( + 'rejects %s group sends before inserting rows', + async (type) => { + setup() + const listTerminals = vi.spyOn(runtime, 'listTerminals') + + await expect( + call('orchestration.send', { + from: 'term_worker', + to: '@all', + subject: `${type} broadcast`, + type, + payload: JSON.stringify({ taskId: 'task_1' }) + }) + ).rejects.toThrow(lifecycleGroupRecipientError(type)) + + expect(listTerminals).not.toHaveBeenCalled() + expect(db.getInbox(100)).toHaveLength(0) + } + ) + it('continues to send worker_done to a concrete terminal handle', async () => { setup() const task = db.createTask({ spec: 'work' }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index f52ee65da19..908673ec7a5 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -31,6 +31,7 @@ import { OrchestrationError } from '../../orchestration/orchestration-error' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RunRow } from '../../orchestration/types' import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION @@ -64,7 +65,20 @@ async function routeAllMailboxPages( } } -function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { +type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' + +function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { return `${type} messages belong to one exact Dispatch and cannot target a group address.` } @@ -82,6 +96,22 @@ function parseRemoteWorkerPayload(payload: string | undefined): Record { - if ( - (params.type !== 'worker_done' && params.type !== 'heartbeat') || - !params.to || - !isGroupAddress(params.to) - ) { + if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { return } // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. @@ -486,7 +512,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ type, priority: params.priority ?? 'normal', threadId: params.threadId ?? null, - payload: params.payload ?? null + payload: bindCoordinatorMutationPayload( + type, + params.payload, + remoteAttachment.dispatch_id + ) }), ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) }) @@ -619,15 +649,21 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } // Point-to-point — existing single-recipient behavior revalidateLegacyCoordinator?.() + const dispatch = routing.dispatchId + ? db.getDispatchContextById(routing.dispatchId) + : undefined + const messageType = (params.type ?? 'status') as MessageType const msg = db.insertMessage({ from, to, subject: params.subject, body: params.body, - type: params.type as MessageType, + type: messageType, priority: params.priority as MessagePriority, threadId: params.threadId, - payload: params.payload, + payload: dispatch + ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) + : params.payload, senderPaneKey, runId: routing.run?.id, deliveryContract: legacyWorkerDeliveryContract( @@ -636,32 +672,77 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ to ) }) - const dispatch = routing.dispatchId - ? db.getDispatchContextById(routing.dispatchId) - : undefined - if ((msg.type === 'worker_done' || msg.type === 'heartbeat') && dispatch?.capability_hash) { - const authority = db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability: orchestrationCapability, - paneKey: senderPaneKey, - processIncarnation: - attestedCaller?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(from) ?? - undefined - }) + const dispatchMutationMessage = isDispatchMutationMessageType(msg.type) + if (dispatchMutationMessage) { + const processIncarnation = + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined + const taskId = parseMessageTaskId(params.payload) + const capabilityBacked = Boolean(dispatch?.capability_hash) + const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' + let authority: { + valid: boolean + code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' + reason: string + } + if (!dispatch) { + authority = { + valid: !coordinatorMutation, + code: 'sender_not_assignee', + reason: 'No active Dispatch belongs to this message sender.' + } + } else if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { + authority = { + valid: false, + code: 'task_dispatch_mismatch', + reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` + } + } else if (capabilityBacked) { + const capabilityAuthority = db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability: orchestrationCapability, + paneKey: senderPaneKey, + processIncarnation + }) + authority = { + valid: capabilityAuthority.valid, + code: 'dispatch_capability_invalid', + reason: capabilityAuthority.valid ? '' : capabilityAuthority.reason + } + } else if (dispatch.process_incarnation) { + authority = { + valid: db.isDispatchProcessCurrent({ + dispatchId: dispatch.id, + paneKey: senderPaneKey ?? null, + processIncarnation: processIncarnation ?? null + }), + code: 'sender_not_assignee', + reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + } + } else { + authority = { + valid: + !coordinatorMutation || + db.isDispatchMessageSender({ + dispatchId: dispatch.id, + handle: from, + paneKey: senderPaneKey + }), + code: 'sender_not_assignee', + reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + } + } if (!authority.valid) { + const code = authority.code const rejection = - db.convertLifecycleMessageToRejection( - msg.id, - 'dispatch_capability_invalid', - authority.reason - ) ?? msg + db.convertLifecycleMessageToRejection(msg.id, code, authority.reason) ?? msg runtime.notifyMessageArrived(rejection.to_handle, rejection.type) return { message: rejection, lifecycle: { action: 'rejected', - code: 'dispatch_capability_invalid', + code, reason: authority.reason } } diff --git a/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts b/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts index 3c087702627..0379646cb52 100644 --- a/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import type Database from '../../sqlite/sync-database' +import { applyEscalationToDispatch } from '../orchestration/coordinator-escalation-triage' import { cleanupLegacyCompatibilityDispatcherHarnesses, COORDINATOR_HANDLE, @@ -51,7 +52,10 @@ describe('legacy compatibility through RpcDispatcher', () => { harness.dispatcher, request( 'orchestration.send', - escalationParams(harness), + { + ...escalationParams(harness), + payload: JSON.stringify({ taskId: harness.taskId }) + }, evidence('worker', valid), `${transport}-${valid}` ), @@ -75,6 +79,15 @@ describe('legacy compatibility through RpcDispatcher', () => { legacyCompatibility: { replayed: false } } }) + const message = (response as { result: { message: { id: string; payload: string } } }).result + .message + expect(JSON.parse(message.payload)).toMatchObject({ + taskId: harness.taskId, + dispatchId: harness.dispatchId + }) + applyEscalationToDispatch(harness.db, harness.db.getMessageById(message.id)!, () => {}) + expect(harness.db.getTask(harness.taskId)?.status).toBe('ready') + expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('failed') expect(counts(harness.db)).toEqual({ ...before, messages: before.messages + 1, diff --git a/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts b/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts index c0d700b2ed1..ebf6cfc2869 100644 --- a/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts +++ b/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts @@ -2,6 +2,7 @@ import type { RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' import type { MessageType } from '../orchestration/db' import { OrchestrationError } from '../orchestration/orchestration-error' +import { bindCoordinatorMutationPayload } from '../orchestration/dispatch-message-binding' import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority' import { inferLegacyWorkerOutcome, @@ -89,7 +90,11 @@ export async function handleLegacyLifecycleSend(args: { body: params.body, type: params.type as MessageType, priority: params.priority, - payload: params.payload + payload: bindCoordinatorMutationPayload( + params.type as MessageType, + params.payload, + dispatch.id + ) }, lifecycle: params.type === 'heartbeat' diff --git a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts new file mode 100644 index 00000000000..539ddd1429a --- /dev/null +++ b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts @@ -0,0 +1,430 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import Database from '../../sqlite/sync-database' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationDb } from '../orchestration/db' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ORCHESTRATION_METHODS } from './methods/orchestration' + +const COORDINATOR_HANDLE = 'term_invariant_coordinator' +const COORDINATOR_PANE = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const WORKER_HANDLE = 'term_invariant_worker' +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const WORKER_PROCESS = 'pty-worker:incarnation-1' + +type Harness = { + db: OrchestrationDb + dbPath: string + dispatcher: RpcDispatcher + runId: string +} + +const harnesses: Harness[] = [] +const tempDirs: string[] = [] +let requestSequence = 0 + +afterEach(() => { + for (const harness of harnesses.splice(0)) { + harness.db.close() + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Task/Dispatch state invariant', () => { + it.each(['pending', 'dispatched'] as const)( + 'rejects a ready reset while the Dispatch is %s without mutating either row', + async (dispatchStatus) => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'retain assignment', runId: harness.runId }) + const dispatch = + dispatchStatus === 'pending' + ? harness.db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }).dispatch + : await dispatchTask(harness, task.id, WORKER_HANDLE) + + const response = await updateTask(harness, task.id, 'ready', 'must not persist') + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'task_not_startable', + data: { taskId: task.id, dispatchId: dispatch.id } + } + }) + expect(readPersistedPair(harness.dbPath, task.id, dispatch.id)).toEqual({ + taskStatus: 'dispatched', + taskResult: null, + taskCompletedAt: null, + dispatchStatus, + dispatchCompletedAt: null, + capabilityRevokedAt: null + }) + } + ) + + it('rejects a dispatched Task status without an active Dispatch', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'unassigned work', runId: harness.runId }) + + const response = await updateTask(harness, task.id, 'dispatched', 'must not persist') + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'task_not_startable', + data: { taskId: task.id } + } + }) + expect(readPersistedActiveState(harness.dbPath, task.id)).toEqual({ + taskStatus: 'ready', + activeDispatches: 0 + }) + }) + + it('atomically fails a context-only Dispatch, revokes its capability, and frees the terminal', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'failing assignment', runId: harness.runId }) + const { dispatch, capability } = await createCapableDispatch(harness, task.id, 'dispatched') + + const response = await updateTask(harness, task.id, 'failed', 'coordinator stopped work') + + expect(response).toMatchObject({ ok: true, result: { task: { status: 'failed' } } }) + expect(readPersistedPair(harness.dbPath, task.id, dispatch.id)).toMatchObject({ + taskStatus: 'failed', + taskResult: 'coordinator stopped work', + taskCompletedAt: expect.any(String), + dispatchStatus: 'failed', + dispatchCompletedAt: expect.any(String), + capabilityRevokedAt: expect.any(String) + }) + expect( + harness.db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS + }) + ).toEqual({ valid: false, reason: `Dispatch ${dispatch.id} capability is revoked.` }) + + const laterTask = harness.db.createTask({ spec: 'later assignment', runId: harness.runId }) + await expect(dispatchTask(harness, laterTask.id, WORKER_HANDLE)).resolves.toMatchObject({ + status: 'dispatched' + }) + }) + + it.each([ + ['pending', 'failed'], + ['pending', 'completed'], + ['dispatched', 'failed'], + ['dispatched', 'completed'] + ] as const)( + 'rejects moving a %s supervised Dispatch Task to %s without mutating lifecycle state', + async (dispatchStatus, taskStatus) => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'supervised assignment', runId: harness.runId }) + const { dispatch, capability } = createSupervisedDispatch(harness, task.id, dispatchStatus) + + const response = await updateTask(harness, task.id, taskStatus, 'must not persist') + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'task_not_startable', + data: { taskId: task.id, dispatchId: dispatch.id } + } + }) + expect(readPersistedPair(harness.dbPath, task.id, dispatch.id)).toEqual({ + taskStatus: 'dispatched', + taskResult: null, + taskCompletedAt: null, + dispatchStatus, + dispatchCompletedAt: null, + capabilityRevokedAt: null + }) + expect(readPersistedWorkerState(harness.dbPath, dispatch.id)).toEqual({ + workerState: dispatchStatus === 'pending' ? 'starting' : 'ready', + ownershipState: 'owned', + releaseState: 'not_requested' + }) + expect( + harness.db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS + }) + ).toEqual({ valid: true }) + } + ) + + it('preserves completed settlement and terminal reuse', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'successful assignment', runId: harness.runId }) + const dispatch = await dispatchTask(harness, task.id, WORKER_HANDLE) + + const response = await updateTask(harness, task.id, 'completed', 'done') + + expect(response).toMatchObject({ ok: true, result: { task: { status: 'completed' } } }) + expect(readPersistedPair(harness.dbPath, task.id, dispatch.id)).toMatchObject({ + taskStatus: 'completed', + taskResult: 'done', + taskCompletedAt: expect.any(String), + dispatchStatus: 'completed', + dispatchCompletedAt: expect.any(String), + capabilityRevokedAt: expect.any(String) + }) + const laterTask = harness.db.createTask({ spec: 'later assignment', runId: harness.runId }) + await expect(dispatchTask(harness, laterTask.id, WORKER_HANDLE)).resolves.toMatchObject({ + status: 'dispatched' + }) + }) + + it.each([ + ['dispatch first', ['dispatch', 'ready'], false], + ['ready first', ['ready', 'dispatch'], true] + ] as const)( + 'preserves the invariant when %s RPC calls run in order', + async (_label, order, readySucceeds) => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'racing assignment', runId: harness.runId }) + const responses: RpcResponse[] = [] + for (const operation of order) { + responses.push( + await (operation === 'dispatch' + ? dispatchTaskResponse(harness, task.id, WORKER_HANDLE) + : updateTask(harness, task.id, 'ready', 'race result')) + ) + } + const dispatchResponse = responses[order.indexOf('dispatch')] + const readyResponse = responses[order.indexOf('ready')] + + expect(dispatchResponse).toMatchObject({ + ok: true, + result: { dispatch: { status: 'dispatched' } } + }) + expect(readyResponse.ok).toBe(readySucceeds) + if (!readySucceeds) { + expect(readyResponse).toMatchObject({ + error: { code: 'task_not_startable' } + }) + } + expect(readPersistedActiveState(harness.dbPath, task.id)).toEqual({ + taskStatus: 'dispatched', + activeDispatches: 1 + }) + } + ) +}) + +function createHarness(): Harness { + const dir = mkdtempSync(join(tmpdir(), 'orca-task-dispatch-invariant-')) + const dbPath = join(dir, 'orchestration.db') + tempDirs.push(dir) + const db = new OrchestrationDb(dbPath) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => { + if (handle === COORDINATOR_HANDLE) { + return COORDINATOR_PANE + } + if (handle === WORKER_HANDLE) { + return WORKER_PANE + } + return null + }) + const runId = db.createRun({ + objective: 'Enforce Task/Dispatch state', + coordinatorHandle: COORDINATOR_HANDLE, + coordinatorPaneKey: COORDINATOR_PANE + }).id + const harness = { + db, + dbPath, + dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }), + runId + } + harnesses.push(harness) + return harness +} + +async function dispatchTask( + harness: Harness, + taskId: string, + terminalHandle: string +): Promise<{ id: string; status: string }> { + const response = await dispatchTaskResponse(harness, taskId, terminalHandle) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return (response.result as { dispatch: { id: string; status: string } }).dispatch +} + +function dispatchTaskResponse( + harness: Harness, + taskId: string, + terminalHandle: string +): Promise { + return harness.dispatcher.dispatch( + request('orchestration.dispatch', { + task: taskId, + to: terminalHandle, + from: COORDINATOR_HANDLE, + run: harness.runId + }) + ) +} + +async function createCapableDispatch( + harness: Harness, + taskId: string, + status: 'pending' | 'dispatched' +): Promise<{ dispatch: { id: string }; capability: string }> { + if (status === 'pending') { + const dispatch = harness.db.createStartingWorkerDispatch({ taskId, startOptions: {} }).dispatch + const capability = harness.db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: WORKER_HANDLE, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS, + worktreeId: 'repo::worker', + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'external' + }) + return { dispatch, capability } + } + const dispatch = await dispatchTask(harness, taskId, WORKER_HANDLE) + const capability = harness.db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS + }) + return { dispatch, capability } +} + +function createSupervisedDispatch( + harness: Harness, + taskId: string, + status: 'pending' | 'dispatched' +): { dispatch: { id: string }; capability: string } { + const dispatch = harness.db.createStartingWorkerDispatch({ taskId, startOptions: {} }).dispatch + const capability = harness.db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: WORKER_HANDLE, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS, + worktreeId: 'repo::worker', + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + if (status === 'dispatched') { + harness.db.markWorkerDispatchReady(dispatch.id) + } + return { dispatch, capability } +} + +function updateTask( + harness: Harness, + taskId: string, + status: 'ready' | 'dispatched' | 'completed' | 'failed', + result: string +): Promise { + return harness.dispatcher.dispatch( + request('orchestration.taskUpdate', { + id: taskId, + status, + result, + callerTerminalHandle: COORDINATOR_HANDLE, + run: harness.runId + }) + ) +} + +function request(method: string, params: Record): RpcRequest { + requestSequence += 1 + return { + id: `rpc_task_dispatch_invariant_${requestSequence}`, + authToken: 'test-token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `task_dispatch_invariant_${requestSequence}` + } +} + +function readPersistedPair(dbPath: string, taskId: string, dispatchId: string) { + const sqlite = new Database(dbPath, { readonly: true }) + try { + const task = sqlite + .prepare('SELECT status, result, completed_at FROM tasks WHERE id = ?') + .get(taskId) as { status: string; result: string | null; completed_at: string | null } + const dispatch = sqlite + .prepare( + 'SELECT status, completed_at, capability_revoked_at FROM dispatch_contexts WHERE id = ?' + ) + .get(dispatchId) as { + status: string + completed_at: string | null + capability_revoked_at: string | null + } + return { + taskStatus: task.status, + taskResult: task.result, + taskCompletedAt: task.completed_at, + dispatchStatus: dispatch.status, + dispatchCompletedAt: dispatch.completed_at, + capabilityRevokedAt: dispatch.capability_revoked_at + } + } finally { + sqlite.close() + } +} + +function readPersistedActiveState(dbPath: string, taskId: string) { + const sqlite = new Database(dbPath, { readonly: true }) + try { + const task = sqlite.prepare('SELECT status FROM tasks WHERE id = ?').get(taskId) as { + status: string + } + const active = sqlite + .prepare( + `SELECT COUNT(*) AS count FROM dispatch_contexts + WHERE task_id = ? AND status IN ('pending', 'dispatched')` + ) + .get(taskId) as { count: number } + return { taskStatus: task.status, activeDispatches: active.count } + } finally { + sqlite.close() + } +} + +function readPersistedWorkerState(dbPath: string, dispatchId: string) { + const sqlite = new Database(dbPath, { readonly: true }) + try { + const row = sqlite + .prepare( + `SELECT worker.state AS worker_state, resource.ownership_state, resource.release_state + FROM worker_dispatches worker + JOIN worker_terminal_resources resource ON resource.owner_dispatch_id = worker.dispatch_id + WHERE worker.dispatch_id = ?` + ) + .get(dispatchId) as { + worker_state: string + ownership_state: string + release_state: string + } + return { + workerState: row.worker_state, + ownershipState: row.ownership_state, + releaseState: row.release_state + } + } finally { + sqlite.close() + } +} From 04e7f5c80578ede26ff3ab91c7246b804762b49a Mon Sep 17 00:00:00 2001 From: Sebastian <165098826+stroland02@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:52:38 -0400 Subject: [PATCH 002/109] fix(cli): relativize absolute POSIX paths against UNC worktree roots in WSL (#11406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ignore worktrees directory * fix(cli): relativize absolute POSIX paths against UNC worktree roots in WSL * fix(cli): prevent double-prefixing UNC paths in WSL path normalization * fix(cli): gate the WSL path rewrite on a UNC worktree root WSL_DISTRO_NAME is also set for a plain Linux CLI inside the distro, where worktree roots are POSIX; prefixing there stranded every absolute path. Rewrite only when the root really is a WSL UNC path, and cover the legacy wsl$ alias, cross-distro paths, and the non-WSL case. * test(cli): pin WSL_DISTRO_NAME absent for the whole file-path suite Contributors run this suite inside WSL, where the inherited distro name would flip the rewrite on for every POSIX-root case. * fix(cli): never rewrite a Linux path that contains a backslash Backslash is a legal Linux filename character but a separator once the path reads as UNC, so `a\b.ts` relativized to `a/b.ts` — a different file. Such a path has no UNC spelling; let it fail the match instead. * test(cli): pin the WSL rewrite's negative space Sibling-prefix roots, Linux-tail case sensitivity, and Windows drive-letter workspaces all passed only by construction. * test(cli): pin the distro-case fold from the CLI side The negative-only case passed identically with the fold broken. * fix(cli): name the WSL distro from the invocation cwd when the env is absent WSL_DISTRO_NAME only reaches the CLI if interop forwards it across the PowerShell bridge, which nothing in the launcher guarantees. ORCA_CLI_CWD is set explicitly and its UNC form already names the distro. * test(cli): match the launcher's real cwd spelling and fix an over-claim wslpath -w emits a backslash UNC path; the fallback test now uses that shape. The aliasing test's comment described a state the || guard makes unreachable. * refactor(cli): spell the WSL rewrite with the shared toWindowsWslPath helper src/shared/wsl-paths.ts already owns "absolute Linux path in a known distro -> its Windows form" and has five production callers; the handler hand-rolled a fourth copy of the UNC template. Behavior is identical under the UNC-root guard, and passing distro as a real argument makes the null check a compile error rather than an untested branch. * test(cli): drop a WSL case that pins the guard shape and kills no mutant Deleting the distro null check left the case green — it asserts the same passthrough as 'does not rewrite when the CLI is not running under WSL'. The check is now enforced by the compiler instead. * chore: keep WSL path fix scoped --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/cli/handlers/file-absolute-paths.test.ts | 172 ++++++++++++++++++- src/cli/handlers/file.ts | 35 +++- 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/src/cli/handlers/file-absolute-paths.test.ts b/src/cli/handlers/file-absolute-paths.test.ts index aeeda196e8f..267d014762c 100644 --- a/src/cli/handlers/file-absolute-paths.test.ts +++ b/src/cli/handlers/file-absolute-paths.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() @@ -39,11 +39,18 @@ describe('absolute file CLI paths', () => { vi.restoreAllMocks() callMock.mockReset() process.exitCode = undefined + // Why: a contributor running this suite inside WSL inherits a real + // WSL_DISTRO_NAME, which would flip the rewrite on for every case below. + vi.stubEnv('WSL_DISTRO_NAME', '') vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) }) - it('reproduces the issue positional WSL command without invalid_relative_path', async () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('reproduces the issue positional command without invalid_relative_path', async () => { const issuePath = '/root/orca/workspaces/xxx/xxx/xxx.ts' callMock.mockImplementation(async (method: string, params: { relativePath?: string }) => { if (method === 'worktree.list') { @@ -78,6 +85,167 @@ describe('absolute file CLI paths', () => { }) }) + describe('WSL UNC worktree roots', () => { + const uncRoot = '//wsl.localhost/Ubuntu/root/orca/workspaces/xxx' + + function mockWorktreeShow(rootPath: string): void { + queueFixtures( + callMock, + okFixture('req_show', { worktree: buildWorktree(rootPath, 'feature') }), + okFixture('req_open', { + worktree: 'wt-1', + relativePath: 'xxx/xxx.ts', + kind: 'text', + opened: true + }) + ) + } + + function expectOpenedWith(relativePath: string): void { + expect(process.exitCode).toBeUndefined() + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath + }) + } + + async function openPath(path: string, cwd = '/tmp'): Promise { + await main(['file', 'open', '--path', path, '--worktree', 'id:wt-1'], cwd) + } + + it('relativizes a Linux path typed inside the distro against the UNC root', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts') + + expectOpenedWith('xxx/xxx.ts') + }) + + it('relativizes against a legacy backslash wsl$ root', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow('\\\\wsl$\\Ubuntu\\root\\orca\\workspaces\\xxx') + + await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts') + + expectOpenedWith('xxx/xxx.ts') + }) + + it('does not double-prefix a UNC path the user already typed', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow(uncRoot) + + await openPath(`${uncRoot}/xxx/xxx.ts`) + + expectOpenedWith('xxx/xxx.ts') + }) + + // Why: backslash is a legal Linux filename character, but reads as a separator + // once the path is UNC — rewriting `a\b.ts` would open `a/b.ts` instead. + it('leaves a Linux path containing a backslash for the runtime guard', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/workspaces/xxx/a\\b.ts') + + expectOpenedWith('/root/orca/workspaces/xxx/a\\b.ts') + }) + + it('leaves a Linux path from another distro for the runtime guard', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Debian') + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts') + + expectOpenedWith('/root/orca/workspaces/xxx/xxx/xxx.ts') + }) + + // Why: WSL_DISTRO_NAME is also set for a plain Linux CLI inside the distro, where + // roots are POSIX; prefixing there would strand every absolute path (#11393 regression). + it('keeps relativizing POSIX roots while WSL_DISTRO_NAME is set', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow('/root/orca/workspaces/xxx') + + await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts') + + expectOpenedWith('xxx/xxx.ts') + }) + + it('does not relativize a sibling directory that merely shares the root prefix', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/workspaces/xxx-2/xxx.ts') + + expectOpenedWith('/root/orca/workspaces/xxx-2/xxx.ts') + }) + + // Why: Windows folds the distro segment, so only its case may differ from the root. + it('matches when only the distro case differs', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'ubuntu') + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts') + + expectOpenedWith('xxx/xxx.ts') + }) + + it('leaves a Linux path whose own case does not match the root', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/Workspaces/xxx/xxx.ts') + + expectOpenedWith('/root/orca/Workspaces/xxx/xxx.ts') + }) + + it('leaves a Windows drive-letter workspace alone while WSL_DISTRO_NAME is set', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + mockWorktreeShow('C:\\Users\\me\\repo') + + await openPath('C:/Users/me/repo/xxx/xxx.ts') + + expectOpenedWith('xxx/xxx.ts') + }) + + // Why: WSL_DISTRO_NAME only arrives if interop forwards it, but the launcher + // always sets ORCA_CLI_CWD, which reaches the handler as the invocation cwd. + it('falls back to the distro named by a UNC cwd', async () => { + mockWorktreeShow(uncRoot) + + await openPath( + '/root/orca/workspaces/xxx/xxx/xxx.ts', + '\\\\wsl.localhost\\Ubuntu\\root\\orca\\workspaces\\xxx\\xxx' + ) + + expectOpenedWith('xxx/xxx.ts') + }) + + it('does not rewrite when the CLI is not running under WSL', async () => { + mockWorktreeShow(uncRoot) + + await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts') + + expectOpenedWith('/root/orca/workspaces/xxx/xxx/xxx.ts') + }) + + it('rejects the UNC worktree root itself typed as a Linux path', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + queueFixtures( + callMock, + okFixture('req_show', { worktree: buildWorktree(uncRoot, 'feature') }) + ) + + await openPath('/root/orca/workspaces/xxx') + + expect(process.exitCode).toBe(1) + expect(console.error).toHaveBeenCalledWith( + 'The selected worktree root is a directory, not a file-open target.' + ) + expect(callMock).toHaveBeenCalledTimes(1) + }) + }) + it('relativizes absolute file diff paths', async () => { queueFixtures( callMock, diff --git a/src/cli/handlers/file.ts b/src/cli/handlers/file.ts index 4609b3275fb..85e91fedeb1 100644 --- a/src/cli/handlers/file.ts +++ b/src/cli/handlers/file.ts @@ -1,6 +1,7 @@ import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types' import type { RuntimeFileOpenResult, RuntimeWorktreeRecord } from '../../shared/runtime-types' import { isRuntimePathAbsolute, relativePathInsideRoot } from '../../shared/cross-platform-path' +import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import type { CommandHandler, HandlerContext } from '../dispatch' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' import { printResult } from '../format' @@ -46,6 +47,33 @@ async function getFileWorktreeSelector({ flags, cwd, client }: HandlerContext): return await resolveCurrentWorktreeSelector(cwd, client) } +/** + * Why: a WSL workspace stores its worktree root as a Windows UNC path while the + * user types the Linux path they see inside the distro, so the two never match + * (#11393). Only the distro the caller is sitting in can name that Linux path, and + * only a UNC root wants the rewrite — a POSIX root already matches, so rewriting + * it would strand every absolute path. + * + * Backslash is a legal Linux filename character but a separator once the path + * reads as UNC, so `a\b.ts` would relativize to a different file, `a/b.ts`. + * Such a path has no Windows spelling; leave it to fail the match instead. + */ +function toWorktreeRootPathFlavor(rootPath: string, cwd: string, path: string): string { + // Why: WSL_DISTRO_NAME reaches this process only if interop forwards it, but the + // WSL launcher always sets ORCA_CLI_CWD, and its UNC form names the distro itself. + const distro = process.env.WSL_DISTRO_NAME || parseWslUncPath(cwd)?.distro + if ( + !distro || + !path.startsWith('/') || + path.startsWith('//') || + path.includes('\\') || + !isWslUncPath(rootPath) + ) { + return path + } + return toWindowsWslPath(path, distro) +} + async function resolveFilePath( ctx: HandlerContext, worktree: string, @@ -58,7 +86,12 @@ async function resolveFilePath( const result = await ctx.client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { worktree }) - const relativePath = relativePathInsideRoot(result.result.worktree.path, path) + + const rootPath = result.result.worktree.path + const relativePath = relativePathInsideRoot( + rootPath, + toWorktreeRootPathFlavor(rootPath, ctx.cwd, path) + ) if (relativePath === '') { throw new RuntimeClientError( 'invalid_argument', From 7c798907c5723fca194a4a6a5d467749f7d87b3c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:55:04 -0700 Subject: [PATCH 003/109] fix(skills): harden cross-host bundle installs (#15000) --- src/main/ipc/skill-cloud-ipc-handlers.ts | 3 +- .../orca-runtime-skill-recovery.test.ts | 9 + src/main/runtime/orca-runtime.ts | 83 +++-- src/main/skills/skill-bundle-creation.test.ts | 28 +- src/main/skills/skill-bundle-creation.ts | 24 +- src/main/skills/skill-bundle-extraction.ts | 17 +- .../skill-bundle-install-service.test.ts | 30 ++ .../skill-bundle-ssh-relay-service.test.ts | 305 +++++++++++++++++- .../skills/skill-bundle-ssh-relay-service.ts | 237 +++++++++++--- src/main/skills/skill-install-lock-owner.ts | 38 +++ src/main/skills/skill-install-lock-release.ts | 53 +++ src/main/skills/skill-install-lock.test.ts | 269 ++++++++++++++- src/main/skills/skill-install-lock.ts | 273 ++++++++++++---- src/main/skills/skill-install-provenance.ts | 12 +- .../skills/skill-operation-observability.ts | 6 +- .../skills/skill-package-identity.test.ts | 27 ++ src/main/skills/skill-package-identity.ts | 16 +- .../skill-share-preparation-service.test.ts | 98 +++++- .../skills/skill-share-preparation-service.ts | 63 +++- src/main/skills/skill-ssh-relay-client.ts | 6 +- .../skills/skill-ssh-relay-service.test.ts | 25 ++ src/main/skills/skill-ssh-relay-service.ts | 83 +++-- .../skill-upload-session-service.test.ts | 18 ++ .../skills/skill-upload-session-service.ts | 24 +- src/relay/skill-install-handler.test.ts | 46 ++- src/relay/skill-install-handler.ts | 24 +- .../skills/SkillBundleInstallFlow.tsx | 7 +- .../skills/SkillBundleInstallOutcome.tsx | 36 ++- .../skill-bundle-retry-selection.test.ts | 57 ++++ .../skills/skill-bundle-retry-selection.ts | 20 ++ src/renderer/src/i18n/locales/en.json | 3 +- src/shared/skill-install-capability.ts | 1 + src/shared/skill-ssh-relay-contract.ts | 14 +- 33 files changed, 1722 insertions(+), 233 deletions(-) create mode 100644 src/main/skills/skill-install-lock-owner.ts create mode 100644 src/main/skills/skill-install-lock-release.ts create mode 100644 src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts create mode 100644 src/renderer/src/components/skills/skill-bundle-retry-selection.ts diff --git a/src/main/ipc/skill-cloud-ipc-handlers.ts b/src/main/ipc/skill-cloud-ipc-handlers.ts index f3b90a7bf95..8c2ec63f770 100644 --- a/src/main/ipc/skill-cloud-ipc-handlers.ts +++ b/src/main/ipc/skill-cloud-ipc-handlers.ts @@ -52,7 +52,8 @@ function registerSharingHandlers( { publishVersion: (request) => runtime.publishSkillPackageVersion(request), createShare: (packageId, request) => runtime.createSkillPackageShare(packageId, request) - } + }, + { installStateDirectory: join(app.getPath('userData'), 'skill-installs') } ) handleMainWindowSkillIpc('skills:prepareShare', async (_event, value: unknown) => { const input = skillSharePrepareIpcSchema.parse(value) diff --git a/src/main/runtime/orca-runtime-skill-recovery.test.ts b/src/main/runtime/orca-runtime-skill-recovery.test.ts index 3f1f72841ce..1f07f3d2d57 100644 --- a/src/main/runtime/orca-runtime-skill-recovery.test.ts +++ b/src/main/runtime/orca-runtime-skill-recovery.test.ts @@ -29,4 +29,13 @@ describe('managed skill startup recovery', () => { finishRecovery() await expect(listing).resolves.toEqual([]) }) + + it('continues skill management after startup recovery fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const runtime = new OrcaRuntimeService(null, undefined, { + skillTransactionRecovery: Promise.reject(new Error('transient-recovery-failure')) + }) + + await expect(runtime.listManagedSkillInstalls()).resolves.toEqual([]) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1ef6d8b0e38..ed68896ecbe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -191,7 +191,6 @@ import type { SkillBundleInstallRequest, SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract' -import { SkillBundleInstallPreviewSchema } from '../../shared/skill-bundle-install-contract' import { executeSkillInstallRequest } from '../skills/skill-install-request-service' import { executeSkillBundleInstallRequest } from '../skills/skill-bundle-install-request-service' import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations' @@ -216,7 +215,10 @@ import type { } from '../../shared/skill-upload-session-contract' import { SkillUploadSessionService } from '../skills/skill-upload-session-service' import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' -import { installSkillBundleOnSshHost } from '../skills/skill-bundle-ssh-relay-service' +import { + installSkillBundleOnSshHost, + previewSkillBundleInstallOnSshHost +} from '../skills/skill-bundle-ssh-relay-service' import { installSkillOnSshHost, listSkillInstallsOnSshHost, @@ -3676,7 +3678,11 @@ export class OrcaRuntimeService { this.store?.setMobileClientTabSelections?.(state) }) this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null - this.skillTransactionRecovery = deps?.skillTransactionRecovery ?? Promise.resolve() + this.skillTransactionRecovery = (deps?.skillTransactionRecovery ?? Promise.resolve()).catch( + (error) => { + console.warn('[skills] startup transaction recovery failed:', error) + } + ) if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) @@ -4941,10 +4947,16 @@ export class OrcaRuntimeService { const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors) const operationRoot = join(app.getPath('userData'), 'agent-skill-share-operations') const cloud = this.requireSkillCloudService() - const preparations = new SkillSharePreparationService(operationRoot, { - publishVersion: (input) => cloud.publishVersion(input), - createShare: (packageId, input) => cloud.createShare(packageId, input) - }) + const preparations = new SkillSharePreparationService( + operationRoot, + { + publishVersion: (input) => cloud.publishVersion(input), + createShare: (packageId, input) => cloud.createShare(packageId, input) + }, + { + installStateDirectory: join(app.getPath('userData'), 'skill-installs') + } + ) let preparationId: string | null = null const cancel = (): void => { if (preparationId) { @@ -5280,37 +5292,18 @@ export class OrcaRuntimeService { async previewSharedSkillBundleInstallRequest( request: SkillBundleInstallPreviewRequest ): Promise { - if (await this.resolveSkillSshTarget(request.destination)) { - const previews: SkillInstallPreview[] = [] - for (let offset = 0; offset < request.selectedSkills.length; offset += 8) { - const batch = request.selectedSkills.slice(offset, offset + 8) - previews.push( - ...(await Promise.all( - batch.map((skill) => - this.previewSharedSkillInstallRequest({ - package: { - packageId: request.package.packageId, - versionId: request.package.versionId, - packageDigest: skill.digest, - archiveSha256: request.package.archiveSha256, - compressedBytes: request.package.compressedBytes - }, - name: skill.name, - destination: request.destination - }) - ) - )) - ) - } - return SkillBundleInstallPreviewSchema.parse({ - packageId: request.package.packageId, - versionId: request.package.versionId, - bundleDigest: request.package.bundleDigest, - destinationIdentity: previews[0]?.destinationIdentity ?? '', - skills: request.selectedSkills.map((skill, index) => ({ - ...skill, - currentState: previews[index].currentState - })) + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return previewSkillBundleInstallOnSshHost({ + provider: sshTarget.provider, + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace }) } await this.skillTransactionRecovery @@ -5508,13 +5501,15 @@ export class OrcaRuntimeService { } private async resolveSkillSshTarget(destination: SkillInstallRequest['destination']): Promise<{ - provider: IPtyProvider + provider: () => IPtyProvider workspace?: SkillSshWorkspaceAuthority } | null> { if (destination.scope === 'global') { - return destination.executionTarget?.kind === 'ssh' - ? { provider: this.requireSkillSshProvider(destination.executionTarget.connectionId) } - : null + if (destination.executionTarget?.kind !== 'ssh') { + return null + } + const connectionId = destination.executionTarget.connectionId + return { provider: () => this.requireSkillSshProvider(connectionId) } } if (destination.worktreeId) { const repo = this.listRepos().find( @@ -5528,7 +5523,7 @@ export class OrcaRuntimeService { throw new Error('skill-install-workspace-not-found') } return { - provider: this.requireSkillSshProvider(repo.connectionId), + provider: () => this.requireSkillSshProvider(repo.connectionId!), workspace: { kind: 'worktree', id: worktree.id, path: worktree.path } } } @@ -5539,7 +5534,7 @@ export class OrcaRuntimeService { return null } return { - provider: this.requireSkillSshProvider(folder.connectionId), + provider: () => this.requireSkillSshProvider(folder.connectionId!), workspace: { kind: 'folder', id: folder.id, path: folder.folderPath } } } diff --git a/src/main/skills/skill-bundle-creation.test.ts b/src/main/skills/skill-bundle-creation.test.ts index f4d0c5eece1..3611d8b73ea 100644 --- a/src/main/skills/skill-bundle-creation.test.ts +++ b/src/main/skills/skill-bundle-creation.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -36,6 +36,32 @@ afterEach(async () => { }) describe('skill bundle creation and extraction', () => { + it('verifies manifest executable paths without Windows mode bits', async () => { + const root = await temporaryDirectory() + const source = await createSkill(root, 'script-skill', 'Script') + const script = join(source, 'run.sh') + await writeFile(script, '#!/bin/sh\necho ok\n') + await chmod(script, 0o700) + const created = await createSkillBundleArchive({ + sources: [{ sourceDirectory: source }], + archivePath: join(root, 'bundle.tar.gz'), + packageId: 'package_windows', + versionId: 'version_windows', + bundleName: 'windows-bundle' + }) + expect(created.manifest.skills[0].files).toContainEqual( + expect.objectContaining({ path: 'run.sh', executable: true }) + ) + + await expect( + extractSkillBundleArchive({ + archivePath: created.archivePath, + destinationDirectory: join(root, 'windows-extracted'), + platform: 'win32' + }) + ).resolves.toMatchObject({ manifest: { bundleName: 'windows-bundle' } }) + }) + it('round trips multiple skills through an Agent Plugins compatible root', async () => { const root = await temporaryDirectory() const alpha = await createSkill(root, 'alpha-skill', 'Alpha') diff --git a/src/main/skills/skill-bundle-creation.ts b/src/main/skills/skill-bundle-creation.ts index 93faf3a8c1c..bfd2b29780d 100644 --- a/src/main/skills/skill-bundle-creation.ts +++ b/src/main/skills/skill-bundle-creation.ts @@ -24,7 +24,11 @@ import { observeSkillPackage, type ObservedSkillPackage } from './skill-package- import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar' import { startSkillPhaseOperation } from './skill-operation-observability' -export type SkillBundleSource = { id?: string; sourceDirectory: string } +export type SkillBundleSource = { + id?: string + sourceDirectory: string + executablePaths?: ReadonlySet +} export type CreatedSkillBundle = { pluginManifest: AgentPluginManifestV1 @@ -92,7 +96,14 @@ async function stageSkill(input: { force: false, errorOnExist: true }) - const stagedObservation = await observeSkillPackage(stagedDirectory) + const stagedObservation = await observeSkillPackage( + stagedDirectory, + undefined, + input.source.executablePaths, + undefined, + process.platform, + process.platform === 'win32' + ) if (!observationsMatch(input.sourceObservation, stagedObservation)) { throw new Error('skill-package-source-changed-during-staging') } @@ -135,7 +146,14 @@ async function createSkillBundleArchiveUnobserved( const observed = await Promise.all( batch.map(async (source) => { const [observation, markdown] = await Promise.all([ - observeSkillPackage(source.sourceDirectory), + observeSkillPackage( + source.sourceDirectory, + undefined, + source.executablePaths, + undefined, + process.platform, + process.platform === 'win32' + ), readFile(join(source.sourceDirectory, 'SKILL.md'), 'utf8') ]) const summary = summarizeSkillMarkdown(markdown) diff --git a/src/main/skills/skill-bundle-extraction.ts b/src/main/skills/skill-bundle-extraction.ts index 2c779693dda..44405aef3dc 100644 --- a/src/main/skills/skill-bundle-extraction.ts +++ b/src/main/skills/skill-bundle-extraction.ts @@ -130,9 +130,20 @@ async function verifySkill(input: { directory: string manifest: SkillBundleManifestV1['skills'][number] signal?: AbortSignal + platform: NodeJS.Platform }): Promise { throwIfCancelled(input.signal) - const observed = await observeSkillPackage(input.directory, undefined, undefined, input.signal) + const executablePaths = + input.platform === 'win32' + ? new Set(input.manifest.files.filter((file) => file.executable).map((file) => file.path)) + : undefined + const observed = await observeSkillPackage( + input.directory, + undefined, + executablePaths, + input.signal, + input.platform + ) throwIfCancelled(input.signal) if ( observed.observedDigest !== input.manifest.digest || @@ -163,6 +174,7 @@ export async function extractSkillBundleArchive(input: { expectedPackageId?: string expectedVersionId?: string signal?: AbortSignal + platform?: NodeJS.Platform }): Promise { const archive = await openSkillTarGzip(input.archivePath) let destinationCreated = false @@ -227,7 +239,8 @@ export async function extractSkillBundleArchive(input: { verifySkill({ directory: join(skillsDirectory, skill.name), manifest: skill, - signal: input.signal + signal: input.signal, + platform: input.platform ?? process.platform }) ) ) diff --git a/src/main/skills/skill-bundle-install-service.test.ts b/src/main/skills/skill-bundle-install-service.test.ts index aca545fe759..78c8eae0652 100644 --- a/src/main/skills/skill-bundle-install-service.test.ts +++ b/src/main/skills/skill-bundle-install-service.test.ts @@ -258,5 +258,35 @@ describe('skill bundle installation', () => { } ] }) + + await rm(providerPath, { recursive: true }) + const retried = await installSkillBundle({ + operationId: 'operation_provider_retry', + archivePath: bundle.archivePath, + packageId: bundle.manifest.packageId, + versionId: bundle.manifest.versionId, + bundleDigest: bundle.manifest.bundleDigest, + selectedSkillIds: ['alpha-skill'], + expectedArchiveSha256: bundle.archiveSha256, + scope: 'global', + homeDirectory, + orcaStateDirectory: join(root, 'state'), + detectedProviders: ['claude'], + destinationIdentity: 'local-global', + hostIdentity: 'host_1' + }) + + expect(retried).toMatchObject({ + status: 'complete', + skills: [ + { + name: 'alpha-skill', + status: 'unchanged', + placements: expect.arrayContaining([ + expect.objectContaining({ provider: 'claude', status: 'unchanged' }) + ]) + } + ] + }) }) }) diff --git a/src/main/skills/skill-bundle-ssh-relay-service.test.ts b/src/main/skills/skill-bundle-ssh-relay-service.test.ts index 2b0be4f1cbc..02ef8912230 100644 --- a/src/main/skills/skill-bundle-ssh-relay-service.test.ts +++ b/src/main/skills/skill-bundle-ssh-relay-service.test.ts @@ -3,10 +3,16 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SkillBundleInstallRequest } from '../../shared/skill-bundle-install-contract' +import type { + SkillBundleInstallPreviewRequest, + SkillBundleInstallRequest +} from '../../shared/skill-bundle-install-contract' import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest' import type { IPtyProvider } from '../providers/pty-provider-contract' -import { installSkillBundleOnSshHost } from './skill-bundle-ssh-relay-service' +import { + installSkillBundleOnSshHost, + previewSkillBundleInstallOnSshHost +} from './skill-bundle-ssh-relay-service' const roots: string[] = [] @@ -52,7 +58,85 @@ function result() { } } +function previewRequest(count = 30): SkillBundleInstallPreviewRequest { + return { + package: { + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 100 + }, + selectedSkills: Array.from({ length: count }, (_, index) => ({ + id: `skill-${index}`, + name: `skill-${index}`, + digest: String(index).padStart(64, '0') + })), + destination: { scope: 'global', executionTarget: { kind: 'host' } } + } +} + describe('installSkillBundleOnSshHost', () => { + it('adopts the current provider generation when an RPC retry follows reconnect', async () => { + const secondRpc = vi.fn(async (method: string) => + method === 'relay.status' ? { capabilities: ['skills.install.bundle.v1'] } : result() + ) + const secondProvider = { requestHostRpc: secondRpc } as unknown as IPtyProvider + let currentProvider: IPtyProvider + const firstRpc = vi.fn(async (method: string) => { + if (method === 'relay.status') { + return { capabilities: ['skills.install.bundle.v1'] } + } + currentProvider = secondProvider + throw new Error('disconnected-provider-generation') + }) + currentProvider = { requestHostRpc: firstRpc } as unknown as IPtyProvider + + await expect( + installSkillBundleOnSshHost({ + provider: () => currentProvider, + userDataPath: await userDataPath(), + request: request(Buffer.from('archive')), + requireHttps: true + }) + ).resolves.toEqual(result()) + + expect(firstRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.installBundle' + ]) + expect(secondRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.installBundle' + ]) + }) + + it('does not reuse newer capabilities after reconnecting to an older host', async () => { + const secondRpc = vi.fn(async (_method: string) => ({ + capabilities: ['skills.install.v1'] + })) + const secondProvider = { requestHostRpc: secondRpc } as unknown as IPtyProvider + let currentProvider: IPtyProvider + const firstRpc = vi.fn(async (method: string) => { + if (method === 'relay.status') { + return { capabilities: ['skills.install.bundle.v1'] } + } + currentProvider = secondProvider + throw new Error('disconnected-provider-generation') + }) + currentProvider = { requestHostRpc: firstRpc } as unknown as IPtyProvider + + await expect( + installSkillBundleOnSshHost({ + provider: () => currentProvider, + userDataPath: await userDataPath(), + request: request(Buffer.from('archive')), + requireHttps: true + }) + ).rejects.toThrow('skill-bundle-ssh-update-required') + expect(secondRpc.mock.calls.map(([method]) => method)).toEqual(['relay.status']) + }) + it('uses the additive method only when advertised by the SSH host', async () => { const bytes = Buffer.from('private bundle archive') const requestHostRpc = vi.fn(async (method: string) => { @@ -171,6 +255,7 @@ describe('installSkillBundleOnSshHost', () => { expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([ 'relay.status', 'skills.installBundle', + 'relay.status', 'skills.beginUpload', 'skills.uploadChunk', 'skills.commitUpload', @@ -179,3 +264,219 @@ describe('installSkillBundleOnSshHost', () => { ]) }) }) + +describe('previewSkillBundleInstallOnSshHost', () => { + it('previews the complete bundle with one capability check and one RPC', async () => { + const request = previewRequest() + const response = { + packageId: request.package.packageId, + versionId: request.package.versionId, + bundleDigest: request.package.bundleDigest, + destinationIdentity: 'global:ssh-host', + skills: request.selectedSkills.map((skill) => ({ ...skill, currentState: 'missing' })) + } + const requestHostRpc = vi.fn(async (method: string) => + method === 'relay.status' ? { capabilities: ['skills.preview.bundle.v1'] } : response + ) + + await expect( + previewSkillBundleInstallOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + request + }) + ).resolves.toEqual(response) + expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.previewBundleInstall' + ]) + }) + + it('falls back to bounded per-skill previews on an older SSH host', async () => { + const request = previewRequest() + let active = 0 + let maximumActive = 0 + const requestHostRpc = vi.fn(async (method: string, params: unknown) => { + if (method === 'relay.status') { + return { capabilities: ['skills.manage.v1'] } + } + if (method !== 'skills.previewInstall') { + throw new Error(`unexpected method ${method}`) + } + active += 1 + maximumActive = Math.max(maximumActive, active) + await Promise.resolve() + active -= 1 + const input = params as { request: { name: string; package: { packageDigest: string } } } + return { + packageDigest: input.request.package.packageDigest, + name: input.request.name, + destinationIdentity: 'global:ssh-host', + currentState: 'missing', + providers: [] + } + }) + + await expect( + previewSkillBundleInstallOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + request + }) + ).resolves.toEqual({ + packageId: request.package.packageId, + versionId: request.package.versionId, + bundleDigest: request.package.bundleDigest, + destinationIdentity: 'global:ssh-host', + skills: request.selectedSkills.map((skill) => ({ ...skill, currentState: 'missing' })) + }) + expect(requestHostRpc.mock.calls.map(([method]) => method)).not.toContain( + 'skills.previewBundleInstall' + ) + expect(requestHostRpc).toHaveBeenCalledTimes(request.selectedSkills.length + 1) + expect(maximumActive).toBeLessThanOrEqual(8) + }) + + it('settles a failed legacy batch before retrying it', async () => { + let active = 0 + let cancelled = 0 + let maximumActive = 0 + const requestHostRpc = vi.fn( + async (method: string, params: unknown, options?: { signal?: AbortSignal }) => { + if (method === 'relay.status') { + return { capabilities: ['skills.manage.v1'] } + } + const input = params as { request: { name: string; package: { packageDigest: string } } } + active += 1 + maximumActive = Math.max(maximumActive, active) + try { + if (input.request.name === 'skill-0') { + throw new Error('preview transport failed') + } + await new Promise((_resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('preview cancellation timeout')), 250) + options?.signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout) + cancelled += 1 + const error = new Error('preview aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + return { + packageDigest: input.request.package.packageDigest, + name: input.request.name, + destinationIdentity: 'global:ssh-host', + currentState: 'missing', + providers: [] + } + } finally { + active -= 1 + } + } + ) + + await expect( + previewSkillBundleInstallOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + request: previewRequest() + }) + ).rejects.toThrow('preview transport failed') + + expect(maximumActive).toBeLessThanOrEqual(8) + expect(active).toBe(0) + expect(cancelled).toBe(21) + }) + + it('does not retry malformed legacy preview responses', async () => { + const requestHostRpc = vi.fn(async (method: string) => + method === 'relay.status' ? { capabilities: ['skills.manage.v1'] } : { malformed: true } + ) + + await expect( + previewSkillBundleInstallOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + request: previewRequest(8) + }) + ).rejects.toThrow() + + expect(requestHostRpc).toHaveBeenCalledTimes(9) + }) + + it('preserves first-rejection retry semantics while settling a legacy batch', async () => { + const nonRetryable = Object.assign(new Error('preview rejected'), { code: 400 }) + const requestHostRpc = vi.fn(async (method: string, params: unknown) => { + if (method === 'relay.status') { + return { capabilities: ['skills.manage.v1'] } + } + const input = params as { request: { name: string } } + if (input.request.name === 'skill-0') { + await new Promise((resolve) => setTimeout(resolve, 5)) + throw new Error('later transport failure') + } + throw nonRetryable + }) + + await expect( + previewSkillBundleInstallOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + request: previewRequest(2) + }) + ).rejects.toThrow('preview rejected') + + expect(requestHostRpc).toHaveBeenCalledTimes(3) + }) + + it('requires an update when the SSH host lacks both preview capabilities', async () => { + const requestHostRpc = vi.fn(async () => ({ capabilities: [] })) + + await expect( + previewSkillBundleInstallOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + request: previewRequest() + }) + ).rejects.toThrow('skill-bundle-ssh-update-required') + expect(requestHostRpc).toHaveBeenCalledOnce() + }) + + it('adopts the current provider generation when preview retries after reconnect', async () => { + const request = previewRequest() + const response = { + packageId: request.package.packageId, + versionId: request.package.versionId, + bundleDigest: request.package.bundleDigest, + destinationIdentity: 'global:ssh-host', + skills: request.selectedSkills.map((skill) => ({ + ...skill, + currentState: 'missing' as const + })) + } + const secondRpc = vi.fn(async (method: string) => + method === 'relay.status' ? { capabilities: ['skills.preview.bundle.v1'] } : response + ) + const secondProvider = { requestHostRpc: secondRpc } as unknown as IPtyProvider + let currentProvider: IPtyProvider + const firstRpc = vi.fn(async (method: string) => { + if (method === 'relay.status') { + return { capabilities: ['skills.preview.bundle.v1'] } + } + currentProvider = secondProvider + throw new Error('disconnected-provider-generation') + }) + currentProvider = { requestHostRpc: firstRpc } as unknown as IPtyProvider + + await expect( + previewSkillBundleInstallOnSshHost({ provider: () => currentProvider, request }) + ).resolves.toEqual(response) + expect(firstRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.previewBundleInstall' + ]) + expect(secondRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.previewBundleInstall' + ]) + }) +}) diff --git a/src/main/skills/skill-bundle-ssh-relay-service.ts b/src/main/skills/skill-bundle-ssh-relay-service.ts index 0f5dfe04858..aff74eb8f85 100644 --- a/src/main/skills/skill-bundle-ssh-relay-service.ts +++ b/src/main/skills/skill-bundle-ssh-relay-service.ts @@ -1,12 +1,18 @@ +import { ZodError } from 'zod' import { SkillBundleInstallProgressSchema, + SkillBundleInstallPreviewSchema, SkillBundleInstallResultSchema, type SkillBundleInstallProgress, + type SkillBundleInstallPreview, + type SkillBundleInstallPreviewRequest, type SkillBundleInstallRequest, type SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract' import { SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_BUNDLE_PREVIEW_CAPABILITY, + SKILL_MANAGEMENT_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY @@ -15,9 +21,14 @@ import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, + SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, + SKILL_SSH_RELAY_PREVIEW_METHOD, type SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' -import type { IPtyProvider } from '../providers/pty-provider-contract' +import { + SkillInstallPreviewSchema, + type SkillInstallPreview +} from '../../shared/skill-install-contract' import { SKILL_SSH_REQUEST_TIMEOUT_MS, requireSkillSshRelayClient, @@ -25,13 +36,94 @@ import { shouldUseSkillSshClientTransfer, skillSshRelayCapabilities } from './skill-ssh-relay-client' +import type { SkillSshProviderSource, SkillSshRelayClient } from './skill-ssh-relay-client' import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer' import { retrySkillTransferRpc } from './skill-transfer-rpc-retry' import { startSkillInstallProgressPolling } from './skill-install-progress-polling' import { recordSkillCapabilityAbsence } from './skill-operation-observability' +const NON_RETRYABLE_BUNDLE_TRANSFER_ERRORS = new Set([ + 'skill-bundle-ssh-update-required', + 'skill-bundle-ssh-download-unavailable' +]) +const LEGACY_BUNDLE_PREVIEW_CONCURRENCY = 8 + +async function previewBundleWithLegacyRpc( + client: SkillSshRelayClient, + input: { + request: SkillBundleInstallPreviewRequest + workspace?: SkillSshWorkspaceAuthority + } +): Promise { + const previews: SkillInstallPreview[] = [] + for ( + let offset = 0; + offset < input.request.selectedSkills.length; + offset += LEGACY_BUNDLE_PREVIEW_CONCURRENCY + ) { + const batch = input.request.selectedSkills.slice( + offset, + offset + LEGACY_BUNDLE_PREVIEW_CONCURRENCY + ) + const batchAbort = new AbortController() + const failures: { reason: unknown }[] = [] + const results = await Promise.allSettled( + batch.map(async (skill) => { + try { + return SkillInstallPreviewSchema.parse( + await client( + SKILL_SSH_RELAY_PREVIEW_METHOD, + { + request: { + package: { + packageId: input.request.package.packageId, + versionId: input.request.package.versionId, + packageDigest: skill.digest, + archiveSha256: input.request.package.archiveSha256, + compressedBytes: input.request.package.compressedBytes + }, + name: skill.name, + destination: input.request.destination + }, + workspace: input.workspace + }, + { timeoutMs: 30_000, signal: batchAbort.signal } + ) + ) + } catch (error) { + failures.push({ reason: error }) + if (failures.length === 1) { + batchAbort.abort() + } + throw error + } + }) + ) + const [firstFailure] = failures + if (firstFailure) { + throw firstFailure.reason + } + for (const result of results) { + if (result.status !== 'fulfilled') { + continue + } + previews.push(result.value) + } + } + return SkillBundleInstallPreviewSchema.parse({ + packageId: input.request.package.packageId, + versionId: input.request.package.versionId, + bundleDigest: input.request.package.bundleDigest, + destinationIdentity: previews[0]?.destinationIdentity ?? '', + skills: input.request.selectedSkills.map((skill, index) => ({ + ...skill, + currentState: previews[index]?.currentState + })) + }) +} + export async function installSkillBundleOnSshHost(input: { - provider: IPtyProvider + provider: SkillSshProviderSource userDataPath: string request: SkillBundleInstallRequest workspace?: SkillSshWorkspaceAuthority @@ -40,49 +132,60 @@ export async function installSkillBundleOnSshHost(input: { onProgress?: (progress: SkillBundleInstallProgress) => void fetcher?: typeof fetch }): Promise { - const client = requireSkillSshRelayClient(input.provider) - const supported = await skillSshRelayCapabilities(client) const request = input.request - if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) { - throw new Error('skill-bundle-ssh-update-required') + let stopProgress = (): void => undefined + function restartProgress(client: SkillSshRelayClient, supported: string[]): void { + stopProgress() + stopProgress = (): void => undefined + if (input.onProgress && supported.includes(SKILL_INSTALL_PROGRESS_CAPABILITY)) { + stopProgress = startSkillInstallProgressPolling({ + read: async () => { + const value = await client( + SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, + { operationId: request.operationId }, + { timeoutMs: 2_000, signal: input.signal } + ) + if (value === null) { + return null + } + const parsed = SkillBundleInstallProgressSchema.safeParse(value) + return parsed.success ? parsed.data : null + }, + onProgress: input.onProgress + }) + } } - if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) { - recordSkillCapabilityAbsence({ - capability: SKILL_BUNDLE_INSTALL_CAPABILITY, - destination: 'global-ssh' - }) - throw new Error('skill-bundle-ssh-update-required') - } - const stopProgress = - input.onProgress && supported.includes(SKILL_INSTALL_PROGRESS_CAPABILITY) - ? startSkillInstallProgressPolling({ - read: async () => { - const value = await client( - SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, - { operationId: request.operationId }, - { timeoutMs: 2_000, signal: input.signal } - ) - if (value === null) { - return null - } - const parsed = SkillBundleInstallProgressSchema.safeParse(value) - return parsed.success ? parsed.data : null - }, - onProgress: input.onProgress - }) - : null try { try { return SkillBundleInstallResultSchema.parse( await retrySkillTransferRpc({ signal: input.signal, - retryable: retryableSkillSshTransportError, - call: () => - client( + retryable: (error) => + (error as Error)?.message !== 'skill-bundle-ssh-update-required' && + retryableSkillSshTransportError(error), + call: async () => { + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) + if ( + request.providers !== undefined && + !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY) + ) { + throw new Error('skill-bundle-ssh-update-required') + } + if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) { + recordSkillCapabilityAbsence({ + capability: SKILL_BUNDLE_INSTALL_CAPABILITY, + destination: 'global-ssh' + }) + throw new Error('skill-bundle-ssh-update-required') + } + restartProgress(client, supported) + return client( SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, - { request: request, workspace: input.workspace }, + { request, workspace: input.workspace }, { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } ) + } }) ) } catch (error) { @@ -93,17 +196,29 @@ export async function installSkillBundleOnSshHost(input: { throw error } } - if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { - recordSkillCapabilityAbsence({ - capability: SKILL_UPLOAD_CAPABILITY, - destination: 'global-ssh' - }) - throw new Error('skill-bundle-ssh-download-unavailable') - } return await retrySkillTransferRpc({ signal: input.signal, - retryable: retryableSkillSshTransportError, + retryable: (error) => + !NON_RETRYABLE_BUNDLE_TRANSFER_ERRORS.has((error as Error)?.message) && + retryableSkillSshTransportError(error), call: async () => { + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) + if ( + !supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) || + (request.providers !== undefined && + !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) + ) { + throw new Error('skill-bundle-ssh-update-required') + } + if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { + recordSkillCapabilityAbsence({ + capability: SKILL_UPLOAD_CAPABILITY, + destination: 'global-ssh' + }) + throw new Error('skill-bundle-ssh-download-unavailable') + } + restartProgress(client, supported) const uploadId = await transferSkillPackageToSshHost(client, input) try { return SkillBundleInstallResultSchema.parse( @@ -125,6 +240,40 @@ export async function installSkillBundleOnSshHost(input: { } }) } finally { - stopProgress?.() + stopProgress() } } + +export async function previewSkillBundleInstallOnSshHost(input: { + provider: SkillSshProviderSource + request: SkillBundleInstallPreviewRequest + workspace?: SkillSshWorkspaceAuthority +}): Promise { + return SkillBundleInstallPreviewSchema.parse( + await retrySkillTransferRpc({ + retryable: (error) => + !(error instanceof ZodError) && + (error as Error)?.message !== 'skill-bundle-ssh-update-required' && + retryableSkillSshTransportError(error), + call: async () => { + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) + if (supported.includes(SKILL_BUNDLE_PREVIEW_CAPABILITY)) { + return client( + SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, + { request: input.request, workspace: input.workspace }, + { timeoutMs: 30_000 } + ) + } + recordSkillCapabilityAbsence({ + capability: SKILL_BUNDLE_PREVIEW_CAPABILITY, + destination: 'global-ssh' + }) + if (!supported.includes(SKILL_MANAGEMENT_CAPABILITY)) { + throw new Error('skill-bundle-ssh-update-required') + } + return previewBundleWithLegacyRpc(client, input) + } + }) + ) +} diff --git a/src/main/skills/skill-install-lock-owner.ts b/src/main/skills/skill-install-lock-owner.ts new file mode 100644 index 00000000000..0a856bbd7ef --- /dev/null +++ b/src/main/skills/skill-install-lock-owner.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises' + +export type SkillInstallLockOwner = { + token: string + pid: number + createdAt: number +} + +function validOwner(value: unknown): SkillInstallLockOwner | null { + const owner = value as Partial | null + return owner && + typeof owner.token === 'string' && + typeof owner.pid === 'number' && + Number.isInteger(owner.pid) && + owner.pid > 0 && + typeof owner.createdAt === 'number' + ? (owner as SkillInstallLockOwner) + : null +} + +export async function readSkillInstallLockOwner( + path: string +): Promise { + try { + return validOwner(JSON.parse(await readFile(path, 'utf8'))) + } catch { + return null + } +} + +export function skillInstallLockOwnerProcessIsAlive(owner: SkillInstallLockOwner): boolean { + try { + process.kill(owner.pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} diff --git a/src/main/skills/skill-install-lock-release.ts b/src/main/skills/skill-install-lock-release.ts new file mode 100644 index 00000000000..0b56ffcc9a1 --- /dev/null +++ b/src/main/skills/skill-install-lock-release.ts @@ -0,0 +1,53 @@ +import { readdir, rmdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' + +const RELEASE_ENTRY_NAME = /^[a-f0-9-]{36}\.(?:owner|released)$/ +const RECOVERY_RMDIR_IGNORED_CODES = new Set(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY']) + +async function unlinkIfPresent(path: string): Promise { + await unlink(path).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) +} + +async function removeDirectoryIfPresent( + path: string, + removeDirectory: (path: string) => Promise +): Promise { + await removeDirectory(path).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) +} + +export async function cleanupReleasedSkillInstallLock( + path: string, + token: string, + removeDirectory: (path: string) => Promise = rmdir +): Promise { + await unlinkIfPresent(join(path, `${token}.owner`)) + await unlinkIfPresent(join(path, `${token}.released`)) + await removeDirectoryIfPresent(path, removeDirectory) +} + +export async function reclaimReleasedSkillInstallLock(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + }) + await Promise.all( + entries + .filter((entry) => entry.isFile() && RELEASE_ENTRY_NAME.test(entry.name)) + .map((entry) => unlinkIfPresent(join(path, entry.name))) + ) + await rmdir(path).catch((error) => { + if (!RECOVERY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { + throw error + } + }) +} diff --git a/src/main/skills/skill-install-lock.test.ts b/src/main/skills/skill-install-lock.test.ts index e4d8d12a48f..32bfb9b4e2b 100644 --- a/src/main/skills/skill-install-lock.test.ts +++ b/src/main/skills/skill-install-lock.test.ts @@ -1,8 +1,13 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { acquireSkillInstallLock, skillInstallLockPath } from './skill-install-lock' +import { + acquireSkillInstallLock, + reclaimDeadSkillInstallLocks, + skillInstallLockPath +} from './skill-install-lock' +import { readSkillInstallLockOwner } from './skill-install-lock-owner' const roots: string[] = [] @@ -10,8 +15,25 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) +async function readPublishedOwner(lockPath: string): Promise> { + const ownerName = (await readdir(lockPath)).find((name) => name.endsWith('.owner')) + if (!ownerName) { + throw new Error('missing-owner') + } + return JSON.parse(await readFile(join(lockPath, ownerName), 'utf8')) as Record +} + describe('skill install lock', () => { - it('reclaims a fresh lock whose process was killed', async () => { + it.each([0, -1])('rejects a non-positive owner pid (%s)', async (pid) => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const ownerPath = join(root, 'owner.json') + await writeFile(ownerPath, JSON.stringify({ token: 'invalid-pid', pid, createdAt: Date.now() })) + + await expect(readSkillInstallLockOwner(ownerPath)).resolves.toBeNull() + }) + + it('reclaims a fresh legacy lock whose process was killed', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) roots.push(root) const lockPath = skillInstallLockPath(join(root, 'state'), join(root, 'skills', 'alpha')) @@ -22,8 +44,245 @@ describe('skill install lock', () => { ) const release = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 100 }) - expect(JSON.parse(await readFile(lockPath, 'utf8')).token).not.toBe('dead-owner') + expect((await readPublishedOwner(lockPath)).token).not.toBe('dead-owner') await release() - await expect(readFile(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readdir(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('reclaims a lock after its release deletion fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const lockPath = skillInstallLockPath(join(root, 'state'), join(root, 'skills', 'alpha')) + const release = await acquireSkillInstallLock({ + path: lockPath, + timeoutMs: 100, + removeLock: async () => { + throw new Error('injected-delete-failure') + } + }) + + await expect(release()).rejects.toThrow('injected-delete-failure') + await expect(readdir(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(dirname(lockPath))).toEqual( + expect.arrayContaining([expect.stringMatching(/\.lock\.[a-f0-9-]+\.released$/)]) + ) + const secondRelease = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 100 }) + await secondRelease() + await expect(readdir(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('publishes a complete candidate atomically when acquisitions overlap', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const lockPath = skillInstallLockPath(join(root, 'state'), join(root, 'skills', 'alpha')) + let ownerWritten!: () => void + const ownerIsVisible = new Promise((resolve) => { + ownerWritten = resolve + }) + let finishWrite!: () => void + const mayFinishWrite = new Promise((resolve) => { + finishWrite = resolve + }) + let contentionObserved!: () => void + const firstContention = new Promise((resolve) => { + contentionObserved = resolve + }) + const firstAcquire = acquireSkillInstallLock({ + path: lockPath, + writeOwner: async (handle, value) => { + await handle.writeFile(value, 'utf8') + await handle.sync() + ownerWritten() + await mayFinishWrite + }, + publishLock: async (candidatePath, targetPath) => { + try { + await rename(candidatePath, targetPath) + } catch (error) { + contentionObserved() + throw error + } + } + }) + + await ownerIsVisible + await expect(readdir(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }) + const secondRelease = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 100 }) + let firstPublished = false + const observedFirstAcquire = firstAcquire.then( + () => { + firstPublished = true + }, + () => { + firstPublished = true + } + ) + finishWrite() + await firstContention + expect(firstPublished).toBe(false) + await secondRelease() + const release = await firstAcquire + await observedFirstAcquire + await expect(readPublishedOwner(lockPath)).resolves.toMatchObject({ pid: process.pid }) + await release() + }) + + it('does not publish a lock when writing its owner fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const lockPath = skillInstallLockPath(join(root, 'state'), join(root, 'skills', 'alpha')) + + await expect( + acquireSkillInstallLock({ + path: lockPath, + writeOwner: async () => { + throw new Error('injected-write-failure') + } + }) + ).rejects.toThrow('injected-write-failure') + + await expect(readdir(dirname(lockPath))).resolves.toEqual([]) + const release = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 100 }) + await release() + }) + + it('retries when the current owner releases after a contended rename', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const lockPath = skillInstallLockPath(join(root, 'state'), join(root, 'skills', 'alpha')) + const firstRelease = await acquireSkillInstallLock({ path: lockPath }) + let attempts = 0 + + const secondRelease = await acquireSkillInstallLock({ + path: lockPath, + timeoutMs: 500, + publishLock: async (candidatePath, targetPath) => { + attempts += 1 + if (attempts === 1) { + await firstRelease() + const error = new Error('injected-contention') as NodeJS.ErrnoException + error.code = 'ENOTEMPTY' + throw error + } + await rename(candidatePath, targetPath) + } + }) + + expect(attempts).toBe(2) + await secondRelease() + }) + + it('reclaims abandoned legacy owner files at startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const stateDirectory = join(root, 'state') + const lockPath = skillInstallLockPath(stateDirectory, join(root, 'skills', 'alpha')) + const ownerPath = `${lockPath}.11111111-1111-4111-8111-111111111111.owner` + await mkdir(dirname(ownerPath), { recursive: true }) + await writeFile( + ownerPath, + JSON.stringify({ token: 'abandoned-owner', pid: 2_147_483_647, createdAt: Date.now() }) + ) + + await expect(reclaimDeadSkillInstallLocks(stateDirectory)).resolves.toMatchObject({ + scanned: 1, + reclaimed: 1 + }) + await expect(readFile(ownerPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('reclaims dead locks, abandoned candidates, and completed releases at startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const stateDirectory = join(root, 'state') + const deadPath = skillInstallLockPath(stateDirectory, join(root, 'skills', 'alpha')) + const releasedLockPath = skillInstallLockPath(stateDirectory, join(root, 'skills', 'beta')) + const candidateLockPath = skillInstallLockPath(stateDirectory, join(root, 'skills', 'gamma')) + const deadToken = '11111111-1111-4111-8111-111111111111' + const releasedToken = '22222222-2222-4222-8222-222222222222' + const candidateToken = '33333333-3333-4333-8333-333333333333' + await mkdir(deadPath, { recursive: true }) + const releasedPath = `${releasedLockPath}.${releasedToken}.released` + const candidatePath = `${candidateLockPath}.${candidateToken}.candidate` + await mkdir(releasedPath, { recursive: true }) + await mkdir(candidatePath, { recursive: true }) + await writeFile( + join(deadPath, `${deadToken}.owner`), + JSON.stringify({ token: deadToken, pid: 2_147_483_647, createdAt: Date.now() }) + ) + await writeFile( + join(releasedPath, `${releasedToken}.owner`), + JSON.stringify({ token: releasedToken, pid: process.pid, createdAt: Date.now() }) + ) + await writeFile( + join(candidatePath, `${candidateToken}.owner`), + JSON.stringify({ token: candidateToken, pid: 2_147_483_647, createdAt: Date.now() }) + ) + + await expect(reclaimDeadSkillInstallLocks(stateDirectory)).resolves.toEqual({ + scanned: 3, + reclaimed: 3, + truncated: false + }) + }) + + it('continues startup recovery past a non-empty released lock', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const stateDirectory = join(root, 'state') + const lockDirectory = join(stateDirectory, 'locks') + const releasedToken = '11111111-1111-4111-8111-111111111111' + const deadToken = '22222222-2222-4222-8222-222222222222' + const releasedPath = join(lockDirectory, `${'0'.repeat(64)}.lock.${releasedToken}.released`) + const deadPath = join(lockDirectory, `${'f'.repeat(64)}.lock`) + await mkdir(releasedPath, { recursive: true }) + await mkdir(deadPath) + await writeFile(join(releasedPath, 'unexpected-entry'), 'preserve') + await writeFile( + join(deadPath, `${deadToken}.owner`), + JSON.stringify({ token: deadToken, pid: 2_147_483_647, createdAt: Date.now() }) + ) + + await expect(reclaimDeadSkillInstallLocks(stateDirectory)).resolves.toEqual({ + scanned: 2, + reclaimed: 1, + truncated: false + }) + await expect(readdir(releasedPath)).resolves.toEqual(['unexpected-entry']) + await expect(readdir(deadPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('frees the canonical lock before release cleanup finishes', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-')) + roots.push(root) + const stateDirectory = join(root, 'state') + const lockPath = skillInstallLockPath(stateDirectory, join(root, 'skills', 'alpha')) + let deletionStarted!: () => void + const deletionIsPending = new Promise((resolve) => { + deletionStarted = resolve + }) + let finishDeletion!: () => void + const mayFinishDeletion = new Promise((resolve) => { + finishDeletion = resolve + }) + const release = await acquireSkillInstallLock({ + path: lockPath, + removeLock: async (path) => { + deletionStarted() + await mayFinishDeletion + await rmdir(path) + } + }) + const releasing = release() + expect(release()).toBe(releasing) + + await deletionIsPending + await expect(reclaimDeadSkillInstallLocks(stateDirectory)).resolves.toMatchObject({ + reclaimed: 1 + }) + const secondRelease = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 100 }) + await secondRelease() + finishDeletion() + await releasing }) }) diff --git a/src/main/skills/skill-install-lock.ts b/src/main/skills/skill-install-lock.ts index 3e07d3da09e..2bde269e368 100644 --- a/src/main/skills/skill-install-lock.ts +++ b/src/main/skills/skill-install-lock.ts @@ -1,50 +1,138 @@ import { randomUUID } from 'node:crypto' -import { mkdir, open, readdir, readFile, rm, stat } from 'node:fs/promises' +import { + mkdir, + open, + readdir, + rename, + rmdir, + stat, + unlink, + type FileHandle +} from 'node:fs/promises' import { dirname, join } from 'node:path' import { SKILL_INSTALL_BUSY_FAILURE } from '../../shared/skill-install-failure' import { SkillInstallOperationError } from './skill-install-operation-error' +import { + readSkillInstallLockOwner, + skillInstallLockOwnerProcessIsAlive, + type SkillInstallLockOwner +} from './skill-install-lock-owner' +import { + cleanupReleasedSkillInstallLock, + reclaimReleasedSkillInstallLock +} from './skill-install-lock-release' import { skillInstallStateKey } from './skill-install-provenance' const LOCK_RETRY_MS = 50 const LOCK_STALE_MS = 30 * 60 * 1000 const MAX_STARTUP_LOCKS = 128 const LOCK_NAME = /^[a-f0-9]{64}\.lock$/ +const LEGACY_OWNER_NAME = /^[a-f0-9]{64}\.lock\.[a-f0-9-]{36}\.owner$/ +const CANDIDATE_LOCK_NAME = /^[a-f0-9]{64}\.lock\.[a-f0-9-]{36}\.candidate$/ +const RELEASED_LOCK_NAME = /^[a-f0-9]{64}\.lock\.[a-f0-9-]{36}\.released$/ +const OWNER_ENTRY_NAME = /^([a-f0-9-]{36})\.owner$/ +const RELEASE_ENTRY_NAME = /^([a-f0-9-]{36})\.released$/ +const activeLockTokens = new Set() -type SkillInstallLockOwner = { - token: string - pid: number - createdAt: number +function ownerIsReclaimable(owner: SkillInstallLockOwner): boolean { + if (owner.pid === process.pid && !activeLockTokens.has(owner.token)) { + return true + } + return !skillInstallLockOwnerProcessIsAlive(owner) } -function processIsAlive(pid: number): boolean { +async function removeObservedLegacyFile(path: string): Promise { try { - process.kill(pid, 0) - return true + await unlink(path) } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM' + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') { + return + } + if ( + (code === 'EISDIR' || code === 'EPERM') && + (await stat(path).catch(() => null))?.isDirectory() + ) { + return + } + throw error } } +async function removeStaleLegacyLock(path: string): Promise { + const lockStat = await stat(path).catch(() => null) + if (!lockStat?.isFile()) { + return + } + const owner = await readSkillInstallLockOwner(path) + if (owner ? ownerIsReclaimable(owner) : Date.now() - lockStat.mtimeMs >= LOCK_STALE_MS) { + await removeObservedLegacyFile(path) + } +} + +async function removeStaleLockDirectory(path: string, incompleteStaleMs = 0): Promise { + const lockStat = await stat(path).catch(() => null) + if (!lockStat?.isDirectory()) { + return + } + const entries = await readdir(path, { withFileTypes: true }).catch(() => null) + if (!entries) { + return + } + const releasedTokens = new Set( + entries.flatMap((entry) => { + const match = entry.isFile() ? RELEASE_ENTRY_NAME.exec(entry.name) : null + return match?.[1] ? [match[1]] : [] + }) + ) + let mayRemoveDirectory = releasedTokens.size > 0 + for (const entry of entries) { + const match = entry.isFile() ? OWNER_ENTRY_NAME.exec(entry.name) : null + if (!match?.[1]) { + continue + } + const ownerPath = join(path, entry.name) + const owner = await readSkillInstallLockOwner(ownerPath) + const ownerStat = owner ? null : await stat(ownerPath).catch(() => null) + const reclaimable = + releasedTokens.has(match[1]) || + (owner + ? ownerIsReclaimable(owner) + : Boolean(ownerStat && Date.now() - ownerStat.mtimeMs >= incompleteStaleMs)) + if (!reclaimable) { + return + } + await unlink(ownerPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) + mayRemoveDirectory = true + } + for (const token of releasedTokens) { + await unlink(join(path, `${token}.released`)).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) + } + if (!mayRemoveDirectory && Date.now() - lockStat.mtimeMs < incompleteStaleMs) { + return + } + await rmdir(path).catch((error) => { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') { + throw error + } + }) +} + async function removeStaleLock(path: string): Promise { const lockStat = await stat(path).catch(() => null) - if (!lockStat) { - return - } - let owner: SkillInstallLockOwner | null = null - try { - owner = JSON.parse(await readFile(path, 'utf8')) as SkillInstallLockOwner - } catch { - owner = null - } - if (owner && Number.isInteger(owner.pid)) { - if (processIsAlive(owner.pid)) { - return - } - await rm(path, { force: true }) - return - } - if (Date.now() - lockStat.mtimeMs >= LOCK_STALE_MS) { - await rm(path, { force: true }) + if (lockStat?.isDirectory()) { + await removeStaleLockDirectory(path) + } else if (lockStat?.isFile()) { + await removeStaleLegacyLock(path) } } @@ -61,12 +149,24 @@ export async function reclaimDeadSkillInstallLocks(stateDirectory: string): Prom throw error }) const locks = entries - .filter((entry) => entry.isFile() && LOCK_NAME.test(entry.name)) + .filter( + (entry) => + (LOCK_NAME.test(entry.name) && (entry.isFile() || entry.isDirectory())) || + (entry.isFile() && LEGACY_OWNER_NAME.test(entry.name)) || + (entry.isDirectory() && CANDIDATE_LOCK_NAME.test(entry.name)) || + (entry.isDirectory() && RELEASED_LOCK_NAME.test(entry.name)) + ) .sort((left, right) => left.name.localeCompare(right.name)) let reclaimed = 0 for (const lock of locks.slice(0, MAX_STARTUP_LOCKS)) { const path = join(directory, lock.name) - await removeStaleLock(path) + await (LEGACY_OWNER_NAME.test(lock.name) + ? removeStaleLegacyLock(path) + : CANDIDATE_LOCK_NAME.test(lock.name) + ? removeStaleLockDirectory(path, LOCK_STALE_MS) + : RELEASED_LOCK_NAME.test(lock.name) + ? reclaimReleasedSkillInstallLock(path) + : removeStaleLock(path)) if (!(await stat(path).catch(() => null))) { reclaimed += 1 } @@ -82,9 +182,40 @@ export function skillInstallLockPath(stateDirectory: string, canonicalPath: stri return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`) } +async function writeOwnerRecord( + path: string, + value: string, + writer?: (handle: FileHandle, value: string) => Promise +): Promise { + const handle = await open(path, 'wx', 0o600) + try { + await ( + writer ?? + (async (lockHandle, record) => { + await lockHandle.writeFile(record, 'utf8') + await lockHandle.sync() + }) + )(handle, value) + } finally { + await handle.close() + } +} + +async function markReleased(path: string): Promise { + const handle = await open(path, 'wx', 0o600) + try { + await handle.sync() + } finally { + await handle.close() + } +} + export async function acquireSkillInstallLock(input: { path: string timeoutMs?: number + removeLock?: (path: string) => Promise + publishLock?: (candidatePath: string, lockPath: string) => Promise + writeOwner?: (handle: FileHandle, value: string) => Promise }): Promise<() => Promise> { await mkdir(dirname(input.path), { recursive: true, mode: 0o700 }) const deadline = Date.now() + (input.timeoutMs ?? 5_000) @@ -93,35 +224,69 @@ export async function acquireSkillInstallLock(input: { pid: process.pid, createdAt: Date.now() } - for (;;) { - try { - const handle = await open(input.path, 'wx', 0o600) + const ownerRecord = JSON.stringify(owner) + const candidatePath = `${input.path}.${owner.token}.candidate` + const candidateOwnerPath = join(candidatePath, `${owner.token}.owner`) + const ownerPath = join(input.path, `${owner.token}.owner`) + const releasedPath = `${input.path}.${owner.token}.released` + await mkdir(candidatePath, { mode: 0o700 }) + activeLockTokens.add(owner.token) + let published = false + try { + await writeOwnerRecord(candidateOwnerPath, ownerRecord, input.writeOwner) + for (;;) { try { - await handle.writeFile(JSON.stringify(owner), 'utf8') - await handle.sync() - } finally { - await handle.close() - } - return async () => { - let current: SkillInstallLockOwner | null = null - try { - current = JSON.parse(await readFile(input.path, 'utf8')) as SkillInstallLockOwner - } catch { - current = null + await (input.publishLock ?? rename)(candidatePath, input.path) + published = true + break + } catch (error) { + if (!(await stat(candidatePath).catch(() => null))?.isDirectory()) { + throw error } - if (current?.token === owner.token) { - await rm(input.path, { force: true }) + if (await stat(input.path).catch(() => null)) { + await removeStaleLock(input.path) } + if (!(await stat(input.path).catch(() => null))) { + if (Date.now() >= deadline) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)) + continue + } + if (Date.now() >= deadline) { + throw new SkillInstallOperationError(SKILL_INSTALL_BUSY_FAILURE) + } + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)) } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw error - } - await removeStaleLock(input.path) - if (Date.now() >= deadline) { - throw new SkillInstallOperationError(SKILL_INSTALL_BUSY_FAILURE) - } - await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)) + } + } finally { + if (!published) { + activeLockTokens.delete(owner.token) + await unlink(candidateOwnerPath).catch(() => undefined) + await rmdir(candidatePath).catch(() => undefined) } } + let releasePromise: Promise | null = null + return () => { + releasePromise ??= (async () => { + try { + if ((await readSkillInstallLockOwner(ownerPath))?.token !== owner.token) { + return + } + await markReleased(join(input.path, `${owner.token}.released`)) + try { + await rename(input.path, releasedPath) + } catch (error) { + if ((await readSkillInstallLockOwner(ownerPath))?.token === owner.token) { + throw error + } + return + } + await cleanupReleasedSkillInstallLock(releasedPath, owner.token, input.removeLock) + } finally { + activeLockTokens.delete(owner.token) + } + })() + return releasePromise + } } diff --git a/src/main/skills/skill-install-provenance.ts b/src/main/skills/skill-install-provenance.ts index 42b6c07f545..5f812fd8683 100644 --- a/src/main/skills/skill-install-provenance.ts +++ b/src/main/skills/skill-install-provenance.ts @@ -25,10 +25,13 @@ export type SkillInstallReceiptV1 = { wslDistro?: string } +function normalizedSkillInstallPath(canonicalPath: string): string { + const normalized = resolve(canonicalPath) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + export function skillInstallStateKey(canonicalPath: string): string { - const normalized = - process.platform === 'win32' ? resolve(canonicalPath).toLowerCase() : resolve(canonicalPath) - return createHash('sha256').update(normalized).digest('hex') + return createHash('sha256').update(normalizedSkillInstallPath(canonicalPath)).digest('hex') } export function skillInstallReceiptPath(stateDirectory: string, canonicalPath: string): string { @@ -136,7 +139,8 @@ export async function readSkillInstallReceipt( const parsed: unknown = JSON.parse( await readFile(skillInstallReceiptPath(stateDirectory, canonicalPath), 'utf8') ) - return isReceipt(parsed) && resolve(parsed.canonicalPath) === resolve(canonicalPath) + return isReceipt(parsed) && + normalizedSkillInstallPath(parsed.canonicalPath) === normalizedSkillInstallPath(canonicalPath) ? parsed : null } catch { diff --git a/src/main/skills/skill-operation-observability.ts b/src/main/skills/skill-operation-observability.ts index 6fd18dbe6b0..7bcc9718cca 100644 --- a/src/main/skills/skill-operation-observability.ts +++ b/src/main/skills/skill-operation-observability.ts @@ -151,7 +151,11 @@ export function startSkillPhaseOperation(input: SkillPhaseStart): SkillPhaseOper } export function recordSkillCapabilityAbsence(input: { - capability: 'skills.install.v1' | 'skills.install.bundle.v1' | 'skills.upload.v1' + capability: + | 'skills.install.v1' + | 'skills.install.bundle.v1' + | 'skills.preview.bundle.v1' + | 'skills.upload.v1' destination: 'remote-runtime' | 'global-ssh' }): void { const span = startSpan('skill.capability', { diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts index 031d0ab7530..d66f7835565 100644 --- a/src/main/skills/skill-package-identity.test.ts +++ b/src/main/skills/skill-package-identity.test.ts @@ -67,6 +67,33 @@ describe('skill package identity', () => { expect(binary.classification).toBe('binary') }) + it('infers shebang scripts as executable on Windows filesystems', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill\n') + await writeFile(join(root, 'run.sh'), '#!/bin/sh\necho ok\n') + + const observed = await observeSkillPackage(root, undefined, undefined, undefined, 'win32', true) + + expect(observed.files.find((file) => file.path === 'run.sh')?.executable).toBe(true) + expect(observed.files.find((file) => file.path === 'SKILL.md')?.executable).toBe(false) + }) + + it('matches receipt executable paths case-insensitively on Windows', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill\n') + await writeFile(join(root, 'run.sh'), '#!/bin/sh\necho ok\n') + + const observed = await observeSkillPackage( + root, + undefined, + new Set(['Run.sh']), + undefined, + 'win32' + ) + + expect(observed.files.find((file) => file.path === 'run.sh')?.executable).toBe(true) + }) + it('orders package files by locale-independent code units', async () => { const root = await temporarySkill() await writeFile(join(root, 'apple.md'), 'apple') diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 87356c4fedf..3613e4980a9 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -172,11 +172,17 @@ export async function observeSkillPackage( packageRoot: string, limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS, executablePaths?: ReadonlySet, - signal?: AbortSignal + signal?: AbortSignal, + platform: NodeJS.Platform = process.platform, + inferShebangExecutables = false ): Promise { const files: ObservedSkillFile[] = [] const treeEntries: SkillGitTreeFileEntry[] = [] const caseFoldedPaths = new Map() + const normalizedExecutablePaths = + platform === 'win32' && executablePaths + ? new Set([...executablePaths].map((path) => path.toLocaleLowerCase('en-US'))) + : executablePaths let entryCount = 0 let totalBytes = 0 @@ -258,9 +264,11 @@ export async function observeSkillPackage( signal ) totalBytes += bytes.length - const executable = executablePaths - ? executablePaths.has(manifestPath) - : (fileStat.mode & 0o111) !== 0 + const executable = normalizedExecutablePaths + ? normalizedExecutablePaths.has(platform === 'win32' ? folded : manifestPath) + : platform === 'win32' && inferShebangExecutables + ? bytes.subarray(0, 2).equals(Buffer.from('#!')) + : (fileStat.mode & 0o111) !== 0 files.push(describeObservedSkillFile(manifestPath, bytes, executable)) treeEntries.push({ path: manifestPath, executable, blobSha: gitBlobSha(bytes) }) } else { diff --git a/src/main/skills/skill-share-preparation-service.test.ts b/src/main/skills/skill-share-preparation-service.test.ts index d28759bdf66..cee4b29dbb4 100644 --- a/src/main/skills/skill-share-preparation-service.test.ts +++ b/src/main/skills/skill-share-preparation-service.test.ts @@ -1,9 +1,10 @@ -import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SkillCloudVersion } from '../../shared/skill-cloud-contract' import { SkillSharePreparationService } from './skill-share-preparation-service' +import { writeSkillInstallReceipt } from './skill-install-provenance' const temporaryDirectories: string[] = [] @@ -50,6 +51,101 @@ afterEach(async () => { }) describe('SkillSharePreparationService', () => { + it('preserves executable modes from a managed Windows install receipt', async () => { + const { root, source } = await createSource() + await writeFile(join(source, 'run.sh'), 'echo without shebang\n') + const stateDirectory = join(root, 'state') + await writeSkillInstallReceipt(stateDirectory, { + schemaVersion: 1, + packageId: 'package_1', + versionId: 'version_1', + packageDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + scope: 'global', + destinationIdentity: 'global:windows', + canonicalPath: source, + placements: [], + installedAt: '2026-08-16T12:00:00.000Z', + hostIdentity: 'windows', + fileModes: [ + { path: 'SKILL.md', executable: false }, + { path: 'run.sh', executable: true } + ] + }) + const service = new SkillSharePreparationService( + join(root, 'preparations'), + { publishVersion: vi.fn(), createShare: vi.fn() }, + { installStateDirectory: stateDirectory, platform: 'win32' } + ) + + const prepared = await service.prepare({ sourceDirectory: source }) + + expect(prepared.skills?.[0]?.executablePaths).toEqual(['run.sh']) + }) + + it('finds Windows install receipts through a provider junction', async () => { + const { root, source } = await createSource() + await writeFile(join(source, 'run.sh'), 'echo without shebang\n') + const canonicalSource = await realpath(source) + const providerSource = join(root, 'provider-source') + await symlink( + canonicalSource, + providerSource, + process.platform === 'win32' ? 'junction' : 'dir' + ) + const stateDirectory = join(root, 'state') + await writeSkillInstallReceipt(stateDirectory, { + schemaVersion: 1, + packageId: 'package_1', + versionId: 'version_1', + packageDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + scope: 'global', + destinationIdentity: 'global:windows', + canonicalPath: canonicalSource, + placements: [], + installedAt: '2026-08-16T12:00:00.000Z', + hostIdentity: 'windows', + fileModes: [ + { path: 'SKILL.md', executable: false }, + { path: 'run.sh', executable: true } + ] + }) + const service = new SkillSharePreparationService( + join(root, 'preparations'), + { publishVersion: vi.fn(), createShare: vi.fn() }, + { installStateDirectory: stateDirectory, platform: 'win32' } + ) + + const prepared = await service.prepare({ sourceDirectory: providerSource }) + + expect(prepared.skills?.[0]?.executablePaths).toEqual(['run.sh']) + }) + + it('retries initialization after a transient failure', async () => { + const { root, source } = await createSource() + const preparationRoot = join(root, 'preparations') + const initializeRoot = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('transient-init-failure')) + .mockImplementationOnce(async () => { + await mkdir(preparationRoot, { recursive: true }) + }) + const service = new SkillSharePreparationService( + preparationRoot, + { publishVersion: vi.fn(), createShare: vi.fn() }, + { initializeRoot } + ) + + await expect(service.prepare({ sourceDirectory: source })).rejects.toThrow( + 'transient-init-failure' + ) + await expect(service.prepare({ sourceDirectory: source })).resolves.toMatchObject({ + name: 'shared-skill' + }) + expect(initializeRoot).toHaveBeenCalledTimes(2) + }) + it('removes abandoned preparations from a previous process before preparing', async () => { const { root, source } = await createSource() const preparationRoot = join(root, 'preparations') diff --git a/src/main/skills/skill-share-preparation-service.ts b/src/main/skills/skill-share-preparation-service.ts index f974a95385b..7f7780469be 100644 --- a/src/main/skills/skill-share-preparation-service.ts +++ b/src/main/skills/skill-share-preparation-service.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { mkdir, rm } from 'node:fs/promises' +import { mkdir, realpath, rm } from 'node:fs/promises' import { join } from 'node:path' import type { SkillSharePreview, @@ -10,6 +10,7 @@ import type { import type { SkillCloudVersion } from '../../shared/skill-cloud-contract' import { createSkillBundleArchive, type CreatedSkillBundle } from './skill-bundle-creation' import type { SkillCloudService } from './skill-cloud-service' +import { readSkillInstallReceipt } from './skill-install-provenance' const PREPARATION_TTL_MS = 30 * 60 * 1000 const MAX_PREPARATIONS = 8 @@ -65,7 +66,12 @@ export class SkillSharePreparationService { constructor( private readonly root: string, - private readonly cloud: Pick + private readonly cloud: Pick, + private readonly options: { + initializeRoot?: () => Promise + installStateDirectory?: string + platform?: NodeJS.Platform + } = {} ) {} async prepare(input: { @@ -85,8 +91,39 @@ export class SkillSharePreparationService { const directory = join(this.root, preparationId) try { await mkdir(directory, { recursive: true, mode: 0o700 }) - const sources = + const requestedSources = input.sources ?? (input.sourceDirectory ? [{ sourceDirectory: input.sourceDirectory }] : []) + const sources = await Promise.all( + requestedSources.map(async (source) => { + if ( + (this.options.platform ?? process.platform) !== 'win32' || + !this.options.installStateDirectory + ) { + return source + } + let receipt = await readSkillInstallReceipt( + this.options.installStateDirectory, + source.sourceDirectory + ) + if (!receipt) { + const physicalSource = await realpath(source.sourceDirectory).catch(() => null) + if (physicalSource) { + receipt = await readSkillInstallReceipt( + this.options.installStateDirectory, + physicalSource + ) + } + } + return receipt?.fileModes + ? { + ...source, + executablePaths: new Set( + receipt.fileModes.filter((file) => file.executable).map((file) => file.path) + ) + } + : source + }) + ) const created = await createSkillBundleArchive({ sources, bundleName: input.bundleName ?? 'shared-skill', @@ -198,10 +235,22 @@ export class SkillSharePreparationService { } private async initialize(): Promise { - this.initialized ??= (async () => { - await rm(this.root, { recursive: true, force: true }) - await mkdir(this.root, { recursive: true, mode: 0o700 }) - })() + if (!this.initialized) { + const initialization = (async () => { + if (this.options.initializeRoot) { + await this.options.initializeRoot() + } else { + await rm(this.root, { recursive: true, force: true }) + await mkdir(this.root, { recursive: true, mode: 0o700 }) + } + })() + this.initialized = initialization + void initialization.catch(() => { + if (this.initialized === initialization) { + this.initialized = null + } + }) + } await this.initialized } } diff --git a/src/main/skills/skill-ssh-relay-client.ts b/src/main/skills/skill-ssh-relay-client.ts index cfe6ff726da..8799b5534b4 100644 --- a/src/main/skills/skill-ssh-relay-client.ts +++ b/src/main/skills/skill-ssh-relay-client.ts @@ -9,12 +9,14 @@ const DEVELOPMENT_DOWNLOAD_POLICY_FAILURES = new Set([ ]) export type SkillSshRelayClient = NonNullable +export type SkillSshProviderSource = IPtyProvider | (() => IPtyProvider) -export function requireSkillSshRelayClient(provider: IPtyProvider): SkillSshRelayClient { +export function requireSkillSshRelayClient(source: SkillSshProviderSource): SkillSshRelayClient { + const provider = typeof source === 'function' ? source() : source if (!provider.requestHostRpc) { throw new Error('skill-install-ssh-relay-unavailable') } - return provider.requestHostRpc + return provider.requestHostRpc.bind(provider) } export function shouldUseSkillSshClientTransfer(error: unknown, requireHttps: boolean): boolean { diff --git a/src/main/skills/skill-ssh-relay-service.test.ts b/src/main/skills/skill-ssh-relay-service.test.ts index c23d092362b..c2cabfcf3cd 100644 --- a/src/main/skills/skill-ssh-relay-service.test.ts +++ b/src/main/skills/skill-ssh-relay-service.test.ts @@ -49,6 +49,30 @@ function request(bytes: Buffer) { } describe('installSkillOnSshHost', () => { + it('does not reuse newer capabilities after reconnecting to an older host', async () => { + const secondRpc = vi.fn(async (_method: string) => ({ capabilities: [] })) + const secondProvider = { requestHostRpc: secondRpc } as unknown as IPtyProvider + let currentProvider: IPtyProvider + const firstRpc = vi.fn(async (method: string) => { + if (method === 'relay.status') { + return { capabilities: ['skills.install.v1'] } + } + currentProvider = secondProvider + throw new Error('disconnected-provider-generation') + }) + currentProvider = { requestHostRpc: firstRpc } as unknown as IPtyProvider + + await expect( + installSkillOnSshHost({ + provider: () => currentProvider, + userDataPath: await userDataPath(), + request: request(Buffer.from('archive')), + requireHttps: true + }) + ).rejects.toThrow('skill-install-ssh-update-required') + expect(secondRpc.mock.calls.map(([method]) => method)).toEqual(['relay.status']) + }) + it('requires a relay update before sending an explicit provider choice', async () => { const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] })) await expect( @@ -132,6 +156,7 @@ describe('installSkillOnSshHost', () => { expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([ 'relay.status', 'skills.install', + 'relay.status', 'skills.beginUpload', 'skills.beginUpload', 'skills.uploadChunk', diff --git a/src/main/skills/skill-ssh-relay-service.ts b/src/main/skills/skill-ssh-relay-service.ts index a2ab25bdf2f..7f9a7d009dc 100644 --- a/src/main/skills/skill-ssh-relay-service.ts +++ b/src/main/skills/skill-ssh-relay-service.ts @@ -23,7 +23,6 @@ import { SKILL_SSH_RELAY_REMOVE_METHOD, type SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' -import type { IPtyProvider } from '../providers/pty-provider-contract' import { recordSkillCapabilityAbsence } from './skill-operation-observability' import { retrySkillTransferRpc } from './skill-transfer-rpc-retry' import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer' @@ -34,15 +33,23 @@ import { shouldUseSkillSshClientTransfer, skillSshRelayCapabilities } from './skill-ssh-relay-client' +import type { SkillSshProviderSource } from './skill-ssh-relay-client' -export async function supportsSkillManagementOnSsh(provider: IPtyProvider): Promise { +const NON_RETRYABLE_SKILL_TRANSFER_ERRORS = new Set([ + 'skill-install-ssh-update-required', + 'skill-install-ssh-download-unavailable' +]) + +export async function supportsSkillManagementOnSsh( + provider: SkillSshProviderSource +): Promise { return (await skillSshRelayCapabilities(requireSkillSshRelayClient(provider))).includes( SKILL_MANAGEMENT_CAPABILITY ) } export async function installSkillOnSshHost(input: { - provider: IPtyProvider + provider: SkillSshProviderSource userDataPath: string request: SkillInstallRequest workspace?: SkillSshWorkspaceAuthority @@ -50,30 +57,36 @@ export async function installSkillOnSshHost(input: { signal?: AbortSignal fetcher?: typeof fetch }): Promise { - const client = requireSkillSshRelayClient(input.provider) - const supported = await skillSshRelayCapabilities(client) const request = input.request - if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) { - throw new Error('skill-install-ssh-update-required') - } - if (!supported.includes(SKILL_INSTALL_CAPABILITY)) { - recordSkillCapabilityAbsence({ - capability: SKILL_INSTALL_CAPABILITY, - destination: 'global-ssh' - }) - throw new Error('skill-install-ssh-update-required') - } try { return SkillInstallResultSchema.parse( await retrySkillTransferRpc({ signal: input.signal, - retryable: retryableSkillSshTransportError, - call: () => - client( + retryable: (error) => + (error as Error)?.message !== 'skill-install-ssh-update-required' && + retryableSkillSshTransportError(error), + call: async () => { + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) + if ( + request.providers !== undefined && + !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY) + ) { + throw new Error('skill-install-ssh-update-required') + } + if (!supported.includes(SKILL_INSTALL_CAPABILITY)) { + recordSkillCapabilityAbsence({ + capability: SKILL_INSTALL_CAPABILITY, + destination: 'global-ssh' + }) + throw new Error('skill-install-ssh-update-required') + } + return client( SKILL_SSH_RELAY_INSTALL_METHOD, - { request: request, workspace: input.workspace }, + { request, workspace: input.workspace }, { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } ) + } }) ) } catch (error) { @@ -84,17 +97,27 @@ export async function installSkillOnSshHost(input: { throw error } } - if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { - recordSkillCapabilityAbsence({ - capability: SKILL_UPLOAD_CAPABILITY, - destination: 'global-ssh' - }) - throw new Error('skill-install-ssh-download-unavailable') - } return retrySkillTransferRpc({ signal: input.signal, - retryable: retryableSkillSshTransportError, + retryable: (error) => + !NON_RETRYABLE_SKILL_TRANSFER_ERRORS.has((error as Error)?.message) && + retryableSkillSshTransportError(error), call: async () => { + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) + if ( + !supported.includes(SKILL_INSTALL_CAPABILITY) || + (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) + ) { + throw new Error('skill-install-ssh-update-required') + } + if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { + recordSkillCapabilityAbsence({ + capability: SKILL_UPLOAD_CAPABILITY, + destination: 'global-ssh' + }) + throw new Error('skill-install-ssh-download-unavailable') + } const uploadId = await transferSkillPackageToSshHost(client, input) try { return SkillInstallResultSchema.parse( @@ -118,7 +141,7 @@ export async function installSkillOnSshHost(input: { } export async function previewSkillInstallOnSshHost(input: { - provider: IPtyProvider + provider: SkillSshProviderSource request: SkillInstallPreviewRequest workspace?: SkillSshWorkspaceAuthority }): Promise { @@ -136,7 +159,7 @@ export async function previewSkillInstallOnSshHost(input: { } export async function removeSkillInstallOnSshHost(input: { - provider: IPtyProvider + provider: SkillSshProviderSource request: SkillRemoveRequest workspace?: SkillSshWorkspaceAuthority }): Promise { @@ -154,7 +177,7 @@ export async function removeSkillInstallOnSshHost(input: { } export async function listSkillInstallsOnSshHost(input: { - provider: IPtyProvider + provider: SkillSshProviderSource connectionId: string workspaces: SkillSshWorkspaceAuthority[] }): Promise { diff --git a/src/main/skills/skill-upload-session-service.test.ts b/src/main/skills/skill-upload-session-service.test.ts index 7f7744ba00e..314df03afab 100644 --- a/src/main/skills/skill-upload-session-service.test.ts +++ b/src/main/skills/skill-upload-session-service.test.ts @@ -22,6 +22,24 @@ function identity(bytes: Buffer) { } describe('SkillUploadSessionService', () => { + it('retries initialization after a transient failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-session-')) + roots.push(root) + const uploads = join(root, 'uploads') + const initializeRoot = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('transient-init-failure')) + .mockImplementationOnce(async () => { + await mkdir(uploads, { recursive: true }) + }) + const service = new SkillUploadSessionService(uploads, { initializeRoot }) + const request = { package: identity(Buffer.from('new package')) } + + await expect(service.begin(request)).rejects.toThrow('transient-init-failure') + await expect(service.begin(request)).resolves.toMatchObject({ acknowledgedOffset: 0 }) + expect(initializeRoot).toHaveBeenCalledTimes(2) + }) + it('removes abandoned staging bytes when a runtime starts a fresh service', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-session-')) roots.push(root) diff --git a/src/main/skills/skill-upload-session-service.ts b/src/main/skills/skill-upload-session-service.ts index 18bcc963fe1..b5ebabbf5f6 100644 --- a/src/main/skills/skill-upload-session-service.ts +++ b/src/main/skills/skill-upload-session-service.ts @@ -32,7 +32,7 @@ export class SkillUploadSessionService { constructor( private readonly root: string, - options: { idleMs?: number } = {} + private readonly options: { idleMs?: number; initializeRoot?: () => Promise } = {} ) { this.idleMs = options.idleMs ?? SESSION_IDLE_MS } @@ -220,11 +220,23 @@ export class SkillUploadSessionService { } private async initialize(): Promise { - this.initialized ??= (async () => { - await rm(this.root, { recursive: true, force: true }) - await mkdir(this.root, { recursive: true, mode: 0o700 }) - })() - return this.initialized + if (!this.initialized) { + const initialization = (async () => { + if (this.options.initializeRoot) { + await this.options.initializeRoot() + } else { + await rm(this.root, { recursive: true, force: true }) + await mkdir(this.root, { recursive: true, mode: 0o700 }) + } + })() + this.initialized = initialization + void initialization.catch(() => { + if (this.initialized === initialization) { + this.initialized = null + } + }) + } + await this.initialized } private async prune(): Promise { diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts index 8bb46cd3d1e..d2f19b5a38a 100644 --- a/src/relay/skill-install-handler.test.ts +++ b/src/relay/skill-install-handler.test.ts @@ -12,6 +12,7 @@ import { SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, SKILL_SSH_RELAY_INSTALL_METHOD, SKILL_SSH_RELAY_LIST_METHOD, + SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD } from '../shared/skill-ssh-relay-contract' import { @@ -37,7 +38,8 @@ async function fixture( home: string source: string state: string - }) => Promise + }) => Promise, + options: { detectProviders?: () => Promise; recovery?: Promise } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-relay-skill-test-')) roots.push(root) @@ -64,7 +66,8 @@ async function fixture( new SkillInstallHandler(dispatcher, { homeDirectory: home, stateDirectory: state, - detectProviders: async () => [] + detectProviders: options.detectProviders ?? (async () => []), + recovery: options.recovery }) const call = (method: string, params: Record) => handlers.get(method)!(params, { @@ -115,12 +118,51 @@ describe('SkillInstallHandler', () => { 'skills.install.v1', 'skills.install-providers.v1', 'skills.install.bundle.v1', + 'skills.preview.bundle.v1', 'skills.install-progress.v1', 'skills.upload.v1', 'skills.manage.v1' ]) }) + it('previews a bundle with one provider detection pass', async () => { + const detectProviders = vi.fn(async () => []) + const { call } = await fixture(undefined, { detectProviders }) + const selectedSkills = Array.from({ length: 30 }, (_, index) => ({ + id: `skill-${index}`, + name: `skill-${index}`, + digest: String(index).padStart(64, '0') + })) + + const result = (await call(SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, { + request: { + package: { + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 100 + }, + selectedSkills, + destination: { scope: 'global', executionTarget: { kind: 'host' } } + } + })) as { skills: unknown[] } + + expect(result.skills).toHaveLength(30) + expect(detectProviders).toHaveBeenCalledOnce() + }) + + it('continues after transient startup recovery failure', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const recovery = Promise.reject(new Error('transient-recovery-failure')) + void recovery.catch(() => undefined) + const { call } = await fixture(undefined, { + recovery + }) + + await expect(call(SKILL_SSH_RELAY_LIST_METHOD, { workspaces: [] })).resolves.toEqual([]) + }) + it('recovers a committed install before the first managed-install listing', async () => { let canonicalPath = '' const { archive, call, state } = await fixture(async (input) => { diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts index 7c1abe68526..7d0496b1d81 100644 --- a/src/relay/skill-install-handler.ts +++ b/src/relay/skill-install-handler.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_BUNDLE_PREVIEW_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, @@ -19,6 +20,7 @@ import { SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, SKILL_SSH_RELAY_LIST_METHOD, SKILL_SSH_RELAY_PREVIEW_METHOD, + SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, SKILL_SSH_RELAY_REMOVE_METHOD, SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, SkillSshInstallBundleParamsSchema, @@ -26,6 +28,7 @@ import { SkillSshInstallParamsSchema, SkillSshListParamsSchema, SkillSshPreviewParamsSchema, + SkillSshPreviewBundleParamsSchema, SkillSshRemoveParamsSchema, type SkillSshWorkspaceAuthority } from '../shared/skill-ssh-relay-contract' @@ -43,6 +46,7 @@ import type { RelayDispatcher } from './dispatcher' import { isCommandOnPathForRelay } from './preflight-handler' import type { SkillInstallDestinationAuthority } from '../main/skills/skill-install-destinations' import { + previewSharedSkillBundleInstall, previewSharedSkillInstall, removeSharedSkillInstall } from '../main/skills/skill-install-management-service' @@ -63,6 +67,7 @@ export const SKILL_RELAY_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_BUNDLE_PREVIEW_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY @@ -82,6 +87,7 @@ export class SkillInstallHandler { homeDirectory?: string stateDirectory?: string detectProviders?: () => Promise + recovery?: Promise } = {} ) { this.homeDirectory = options.homeDirectory ?? homedir() @@ -90,7 +96,12 @@ export class SkillInstallHandler { join(this.stateDirectory, 'skill-installs', 'remote-uploads') ) this.detectProviders = options.detectProviders ?? detectRelaySkillProviders - this.recovery = recoverPendingSkillTransactions(join(this.stateDirectory, 'skill-installs')) + this.recovery = ( + options.recovery ?? + recoverPendingSkillTransactions(join(this.stateDirectory, 'skill-installs')) + ).catch((error) => { + console.warn('[skills] relay startup transaction recovery failed:', error) + }) this.registerHandlers() } @@ -145,6 +156,17 @@ export class SkillInstallHandler { }) ) }) + this.dispatcher.onRequest(SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, async (params) => { + const input = SkillSshPreviewBundleParamsSchema.parse(params) + return this.executeSkillOperation(() => + previewSharedSkillBundleInstall(input.request, { + authority: this.authority(input.workspace), + stateDirectory: this.stateDirectory, + detectProviders: this.detectProviders, + resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots() + }) + ) + }) this.dispatcher.onRequest(SKILL_SSH_RELAY_REMOVE_METHOD, async (params) => { const input = SkillSshRemoveParamsSchema.parse(params) return this.executeSkillOperation(() => diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx index f925a43073e..74d3c994c14 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -23,6 +23,7 @@ import { useSkillInstallProgress } from './skill-install-progress-state' import { translate } from '@/i18n/i18n' import { checklistItemsFromVersion } from './skill-package-checklist-items' import { summarizeSkillInstallRisk } from './skill-package-install-risk' +import { retryableSkillIds } from './skill-bundle-retry-selection' type BundleVersion = SkillCloudVersion & { manifest: Extract @@ -224,11 +225,7 @@ export function SkillBundleInstallFlow(props: { } } - const retryIds = new Set( - result?.skills - .filter((skill) => skill.status === 'failed' || skill.status === 'cancelled') - .map((skill) => skill.skillId) ?? [] - ) + const retryIds = retryableSkillIds(result) return ( <> diff --git a/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx b/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx index 65f8bd1505f..97824ecf385 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx @@ -1,15 +1,9 @@ import { AlertTriangle, Check } from 'lucide-react' import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract' import { translate } from '@/i18n/i18n' +import { skillBundleSkillNeedsRetry } from './skill-bundle-retry-selection' -const RESULT_GROUPS = [ - 'installed', - 'updated', - 'unchanged', - 'kept-local', - 'failed', - 'cancelled' -] as const +const RESULT_GROUPS = ['installed', 'updated', 'unchanged', 'kept-local'] as const function resultGroupLabel(status: (typeof RESULT_GROUPS)[number]): string { const labels = { @@ -25,9 +19,7 @@ function resultGroupLabel(status: (typeof RESULT_GROUPS)[number]): string { 'kept-local': translate( 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e04', 'Kept local' - ), - failed: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e05', 'Failed'), - cancelled: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e06', 'Cancelled') + ) } return labels[status] } @@ -38,6 +30,7 @@ export function SkillBundleInstallOutcome({ result: SkillBundleInstallResult }): React.JSX.Element { const incomplete = result.status !== 'complete' + const retrySkills = result.skills.filter(skillBundleSkillNeedsRetry) return (
+ {retrySkills.length ? ( +
+

+ {translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e10', + 'Needs retry' + )}{' '} + · {retrySkills.length} +

+ {retrySkills.map((skill) => ( +

+ {skill.name} + {skill.errorCategory ? ` · ${skill.errorCategory}` : ''} +

+ ))} +
+ ) : null} {RESULT_GROUPS.map((status) => { - const skills = result.skills.filter((skill) => skill.status === status) + const skills = result.skills.filter( + (skill) => skill.status === status && !skillBundleSkillNeedsRetry(skill) + ) return skills.length ? (

diff --git a/src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts b/src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts new file mode 100644 index 00000000000..bdfffb6516b --- /dev/null +++ b/src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract' +import { retryableSkillIds } from './skill-bundle-retry-selection' + +function result(): SkillBundleInstallResult { + return { + operationId: 'operation_1', + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + status: 'partial', + skills: [ + { + skillId: 'partial-placement', + name: 'partial-placement', + digest: 'b'.repeat(64), + status: 'installed', + placements: [ + { + provider: 'claude', + path: '/skills/partial-placement', + topology: 'provider-alias', + status: 'skipped' + } + ] + }, + { + skillId: 'complete-skill', + name: 'complete-skill', + digest: 'c'.repeat(64), + status: 'installed', + placements: [] + } + ] + } +} + +describe('bundle retry selection', () => { + it('includes skills whose provider placement is incomplete', () => { + expect([...retryableSkillIds(result())]).toEqual(['partial-placement']) + }) + + it('includes a skill whose top-level result failed', () => { + const failedResult = result() + failedResult.skills = [ + { + skillId: 'failed-skill', + name: 'failed-skill', + digest: 'd'.repeat(64), + status: 'failed', + placements: [] + } + ] + + expect([...retryableSkillIds(failedResult)]).toEqual(['failed-skill']) + }) +}) diff --git a/src/renderer/src/components/skills/skill-bundle-retry-selection.ts b/src/renderer/src/components/skills/skill-bundle-retry-selection.ts new file mode 100644 index 00000000000..8d518a911f7 --- /dev/null +++ b/src/renderer/src/components/skills/skill-bundle-retry-selection.ts @@ -0,0 +1,20 @@ +import type { + SkillBundleInstallResult, + SkillBundleSkillResult +} from '../../../../shared/skill-bundle-install-contract' + +export function skillBundleSkillNeedsRetry(skill: SkillBundleSkillResult): boolean { + return ( + skill.status === 'failed' || + skill.status === 'cancelled' || + skill.placements.some( + (placement) => placement.status === 'failed' || placement.status === 'skipped' + ) + ) +} + +export function retryableSkillIds(result: SkillBundleInstallResult | null): Set { + return new Set( + result?.skills.filter(skillBundleSkillNeedsRetry).map((skill) => skill.skillId) ?? [] + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 518076227d3..d68878909e8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4401,7 +4401,8 @@ "01c5a12e06": "Cancelled", "01c5a12e07": "Bundle installation needs attention.", "01c5a12e08": "Skills installed and verified.", - "01c5a12e09": "{{value0}} selected skills checked." + "01c5a12e09": "{{value0}} selected skills checked.", + "01c5a12e10": "Needs retry" }, "SkillBundleInstallReview": { "01c5a11e01": "Immutable version", diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts index 80feeb4fffb..a8666fb1b5d 100644 --- a/src/shared/skill-install-capability.ts +++ b/src/shared/skill-install-capability.ts @@ -4,6 +4,7 @@ export const SKILL_INSTALL_CANCEL_CAPABILITY = 'skills.install-cancel.v1' as con export const SKILL_UPLOAD_CAPABILITY = 'skills.upload.v1' as const export const SKILL_MANAGEMENT_CAPABILITY = 'skills.manage.v1' as const export const SKILL_BUNDLE_INSTALL_CAPABILITY = 'skills.install.bundle.v1' as const +export const SKILL_BUNDLE_PREVIEW_CAPABILITY = 'skills.preview.bundle.v1' as const export const SKILL_INSTALL_PROGRESS_CAPABILITY = 'skills.install-progress.v1' as const /** Why: the install request schema is strict, so a host that predates the agent * picker rejects the whole request rather than ignoring the new field. */ diff --git a/src/shared/skill-ssh-relay-contract.ts b/src/shared/skill-ssh-relay-contract.ts index 1b24b068ec8..8cfcba2fcef 100644 --- a/src/shared/skill-ssh-relay-contract.ts +++ b/src/shared/skill-ssh-relay-contract.ts @@ -4,11 +4,15 @@ import { SkillInstallRequestSchema, SkillRemoveRequestSchema } from './skill-install-contract' -import { SkillBundleInstallRequestSchema } from './skill-bundle-install-contract' +import { + SkillBundleInstallPreviewRequestSchema, + SkillBundleInstallRequestSchema +} from './skill-bundle-install-contract' export const SKILL_SSH_RELAY_INSTALL_METHOD = 'skills.install' as const export const SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD = 'skills.installBundle' as const export const SKILL_SSH_RELAY_PREVIEW_METHOD = 'skills.previewInstall' as const +export const SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD = 'skills.previewBundleInstall' as const export const SKILL_SSH_RELAY_REMOVE_METHOD = 'skills.removeInstall' as const export const SKILL_SSH_RELAY_LIST_METHOD = 'skills.listManagedInstalls' as const export const SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD = 'skills.beginUpload' as const @@ -49,6 +53,13 @@ export const SkillSshPreviewParamsSchema = z }) .strict() +export const SkillSshPreviewBundleParamsSchema = z + .object({ + request: SkillBundleInstallPreviewRequestSchema, + workspace: SkillSshWorkspaceAuthoritySchema.optional() + }) + .strict() + export const SkillSshRemoveParamsSchema = z .object({ request: SkillRemoveRequestSchema, @@ -64,4 +75,5 @@ export type SkillSshWorkspaceAuthority = z.infer export type SkillSshInstallBundleParams = z.infer export type SkillSshPreviewParams = z.infer +export type SkillSshPreviewBundleParams = z.infer export type SkillSshRemoveParams = z.infer From 6387e5b8d3bed57b8258bc46f32bcc3805394faf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:01:28 -0700 Subject: [PATCH 004/109] fix(folder-workspaces): keep the broken-folder marker when a host sends a new reason (#15027) FolderWorkspacePathStatus is cast, not decoded, off the runtime RPC wire -- runtime-rpc-envelope declares result: z.unknown(), so unwrapRuntimeRpcResult hands back whatever the host sent. Both title and description switch on status.reason with no runtime guard, so a newer host publishing a fifth reason matched nothing and returned undefined. FolderPathStatusIndicator's `!title` check then dropped the whole indicator, and a broken folder workspace rendered as healthy -- worse than the blank toast #15002 fixed, because there the warning was empty and here it is gone. Guard before each switch, the shape #15002 landed. A default: arm is not available: the type-aware config sets allowDefaultCaseForExhaustiveSwitch:false and rejects one with switch-exhaustiveness-check. Extract that guard into isHandledWireDiscriminant instead of hand-writing a third and fourth copy, and move #15002's two bespoke guards onto it. It takes unknown and checks typeof before Object.hasOwn -- hasOwn coerces its key, so a host that widened the field to an array sends ['missing'], which a hasOwn-only guard admits before the switch drops it straight back out. That was the P1 found in review on #15002; one implementation makes it structural instead of tribal. An unrecognized reason gets its own copy rather than reusing 'unavailable'. The unavailable remedy -- "Check the runtime or SSH connection and try again" -- is a false lead here: the host did check and reported the folder unusable, so retrying and inspecting a healthy connection wastes the user's time. Update Orca is the real remedy. Adding a fifth reason still fails typecheck in two places: TS2741 on the Record and TS2366 plus switch-exhaustiveness-check on both switches. --- .../rows/FolderPathStatusIndicator.test.tsx | 65 ++++++++++++++++ src/renderer/src/i18n/locales/en.json | 6 +- .../src/lib/browser-cookie-import-toast.ts | 15 +--- .../lib/folder-workspace-path-status.test.ts | 77 +++++++++++++++++++ .../src/lib/folder-workspace-path-status.ts | 35 ++++++++- src/shared/handled-wire-discriminant.test.ts | 34 ++++++++ src/shared/handled-wire-discriminant.ts | 15 ++++ 7 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx create mode 100644 src/renderer/src/lib/folder-workspace-path-status.test.ts create mode 100644 src/shared/handled-wire-discriminant.test.ts create mode 100644 src/shared/handled-wire-discriminant.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx new file mode 100644 index 00000000000..cb9c608dff6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' + +// Why: the status is cast, not decoded, off the runtime RPC wire, so a newer host really can put a +// reason this build has never heard of on the wire; the cast at the boundary is the point. +function renderIndicator(reason: unknown): string { + const status = { + path: '/srv/scans', + exists: false, + reason + } as unknown as FolderWorkspacePathStatus + return renderToStaticMarkup( + + + + ) +} + +describe('FolderPathStatusIndicator', () => { + it('marks a folder missing for a declared reason', () => { + const markup = renderIndicator('missing') + + expect(markup).toContain('aria-label="Folder not found"') + expect(markup).toContain('text-destructive') + }) + + // Why: losing the marker is worse than an imprecise one — the sidebar then renders a broken + // folder workspace as healthy, with nothing to click and nothing to read. + it('still renders the marker when a newer host sends an undeclared reason', () => { + const markup = renderIndicator('permission-denied') + + expect(markup).not.toBe('') + expect(markup).toContain('aria-label="') + expect(markup).not.toContain('aria-label=""') + // Why: an imprecise label must not borrow a specific cause it cannot substantiate. + expect(markup).not.toContain('aria-label="Folder not found"') + // Why: unknown is not confirmed stale, so the marker stays neutral rather than destructive. + expect(markup).toContain('text-muted-foreground') + }) + + it('still renders the marker when the wire carries a non-string reason', () => { + const markup = renderIndicator(['missing']) + + expect(markup).not.toBe('') + expect(markup).toContain('aria-label="') + expect(markup).not.toContain('aria-label=""') + // Why: hasOwn coerces its key, so ['missing'] must not be read as the confirmed-stale 'missing'. + expect(markup).not.toContain('aria-label="Folder not found"') + }) + + it('renders nothing for a healthy folder', () => { + const markup = renderToStaticMarkup( + + + + ) + + expect(markup).toBe('') + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d68878909e8..2d62d5f1858 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -697,13 +697,15 @@ "missing": "Folder not found", "notDirectory": "Path is not a folder", "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" + "unavailable": "Cannot check folder", + "unrecognized": "Folder is not usable" }, "description": { "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", "notDirectory": "{{path}} exists, but it is not a folder.", "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.", + "unrecognized": "Orca cannot use {{path}}, and this version does not recognize the reason. Update Orca to see the details." }, "createError": { "title": { diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index d8afad09db3..ef96a9489ce 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import type { BrowserCookieImportSummary } from '../../../shared/browser-workspace-types' +import { isHandledWireDiscriminant } from '../../../shared/handled-wire-discriminant' import { translate } from '@/i18n/i18n' type CookieImportWarning = NonNullable @@ -20,19 +21,9 @@ const HANDLED_UNDECRYPTABLE_REASONS: Record = { unknown: true } -// Why: typeof first — hasOwn coerces its key, so a host that widened `reason` to an array would -// send ['unknown'], pass a hasOwn-only guard, then fall straight back out of the switch. -function isHandledWarningCode(code: unknown): code is CookieImportWarningCode { - return typeof code === 'string' && Object.hasOwn(HANDLED_WARNING_CODES, code) -} - -function isHandledUndecryptableReason(reason: unknown): reason is UndecryptableReason { - return typeof reason === 'string' && Object.hasOwn(HANDLED_UNDECRYPTABLE_REASONS, reason) -} - function formatCookieImportWarning(warning: CookieImportWarning): string { const code: unknown = warning.code - if (!isHandledWarningCode(code)) { + if (!isHandledWireDiscriminant(code, HANDLED_WARNING_CODES)) { return translate( 'auto.lib.browser.cookie.import.toast.unrecognizedWarning', 'The cookie import finished with a warning this version of Orca does not recognize. Update Orca to see the details, then check this profile before relying on its cookies.' @@ -56,7 +47,7 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { ) case 'cookies-undecryptable': { const reason: unknown = warning.reason - if (!isHandledUndecryptableReason(reason)) { + if (!isHandledWireDiscriminant(reason, HANDLED_UNDECRYPTABLE_REASONS)) { return translate( 'auto.lib.browser.cookie.import.toast.undecryptableUnrecognizedReason', '{{value0}} cookies could not be decrypted and were skipped for a reason this version of Orca does not recognize. Update Orca to see the details, then try the import again.', diff --git a/src/renderer/src/lib/folder-workspace-path-status.test.ts b/src/renderer/src/lib/folder-workspace-path-status.test.ts new file mode 100644 index 00000000000..3464a6680da --- /dev/null +++ b/src/renderer/src/lib/folder-workspace-path-status.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' +import { + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from './folder-workspace-path-status' + +// Why: the status crosses the runtime RPC wire and is cast, not decoded (`result: z.unknown()`), so +// the cast at the test boundary is the point — a newer host really can put these on the wire. +function wireStatus(reason: unknown): FolderWorkspacePathStatus { + return { path: '/srv/scans', exists: false, reason } as unknown as FolderWorkspacePathStatus +} + +describe('getFolderWorkspacePathStatusTitle', () => { + it('keeps the declared reasons on their own copy', () => { + expect(getFolderWorkspacePathStatusTitle(wireStatus('missing'))).toBe('Folder not found') + expect(getFolderWorkspacePathStatusTitle(wireStatus('not-directory'))).toBe( + 'Path is not a folder' + ) + expect(getFolderWorkspacePathStatusTitle(wireStatus('ambiguous-connection'))).toBe( + 'Cannot determine connection' + ) + expect(getFolderWorkspacePathStatusTitle(wireStatus('unavailable'))).toBe('Cannot check folder') + expect(getFolderWorkspacePathStatusTitle(wireStatus(undefined))).toBe('Cannot check folder') + }) + + it('still titles a broken folder when a newer host sends an undeclared reason', () => { + const title = getFolderWorkspacePathStatusTitle(wireStatus('permission-denied')) + + expect(typeof title).toBe('string') + expect(title).not.toBe('') + }) + + // Why: Object.hasOwn coerces its key, so ['missing'] passes a hasOwn-only guard and then falls + // straight back out of the switch — the exact P1 found in review on #15002. + it('does not admit a non-string reason through the membership guard', () => { + const title = getFolderWorkspacePathStatusTitle(wireStatus(['missing'])) + + expect(typeof title).toBe('string') + expect(title).not.toBe('') + expect(title).not.toBe('Folder not found') + }) + + it('stays silent for a healthy or absent status', () => { + expect(getFolderWorkspacePathStatusTitle(null)).toBeNull() + expect(getFolderWorkspacePathStatusTitle({ path: '/srv/scans', exists: true })).toBeNull() + }) +}) + +describe('getFolderWorkspacePathStatusDescription', () => { + it('keeps the declared reasons on their own copy', () => { + expect(getFolderWorkspacePathStatusDescription(wireStatus('missing'))).toBe( + 'Orca cannot find /srv/scans. Remove and re-import this folder workspace.' + ) + expect(getFolderWorkspacePathStatusDescription(wireStatus(undefined))).toBe( + 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + ) + }) + + it('still describes a broken folder when a newer host sends an undeclared reason', () => { + const description = getFolderWorkspacePathStatusDescription(wireStatus('permission-denied')) + + expect(typeof description).toBe('string') + expect(description).not.toBe('') + expect(description).toContain('/srv/scans') + }) + + it('does not admit a non-string reason through the membership guard', () => { + const description = getFolderWorkspacePathStatusDescription(wireStatus(['missing'])) + + expect(typeof description).toBe('string') + expect(description).not.toBe('') + expect(description).not.toBe( + 'Orca cannot find /srv/scans. Remove and re-import this folder workspace.' + ) + }) +}) diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 51f2807d6cc..3e4b95e3e7b 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -1,6 +1,24 @@ import { translate } from '@/i18n/i18n' -import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusReason +} from '../../../shared/folder-workspace-path-status' import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status' +import { isHandledWireDiscriminant } from '../../../shared/handled-wire-discriminant' + +// Why: the status is cast, not decoded, off the runtime RPC wire, so a newer host can send a fifth +// reason. The keys are the union itself, so a new member fails typecheck here and in both switches. +const HANDLED_PATH_STATUS_REASONS: Record = { + missing: true, + 'not-directory': true, + unavailable: true, + 'ambiguous-connection': true +} + +// An absent reason is a handled case in both switches; anything else off the wire is not. +function isHandledPathStatusReason(reason: unknown): boolean { + return reason === undefined || isHandledWireDiscriminant(reason, HANDLED_PATH_STATUS_REASONS) +} export function getFolderWorkspacePathStatusTitle( status: FolderWorkspacePathStatus | null | undefined @@ -8,6 +26,14 @@ export function getFolderWorkspacePathStatusTitle( if (!status || status.exists) { return null } + // Why: without this the switch matches nothing, returns undefined, and FolderPathStatusIndicator's + // `!title` check drops the whole marker — a broken folder workspace then renders as healthy. + if (!isHandledPathStatusReason(status.reason)) { + return translate( + 'auto.lib.folderWorkspacePathStatus.title.unrecognized', + 'Folder is not usable' + ) + } switch (status.reason) { case 'missing': return translate('auto.lib.folderWorkspacePathStatus.title.missing', 'Folder not found') @@ -36,6 +62,13 @@ export function getFolderWorkspacePathStatusDescription( if (!status || status.exists) { return null } + if (!isHandledPathStatusReason(status.reason)) { + return translate( + 'auto.lib.folderWorkspacePathStatus.description.unrecognized', + 'Orca cannot use {{path}}, and this version does not recognize the reason. Update Orca to see the details.', + { path: status.path } + ) + } switch (status.reason) { case 'missing': return translate( diff --git a/src/shared/handled-wire-discriminant.test.ts b/src/shared/handled-wire-discriminant.test.ts new file mode 100644 index 00000000000..154be062878 --- /dev/null +++ b/src/shared/handled-wire-discriminant.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { isHandledWireDiscriminant } from './handled-wire-discriminant' + +const HANDLED: Record<'missing' | 'unavailable', true> = { + missing: true, + unavailable: true +} + +describe('isHandledWireDiscriminant', () => { + it('admits the handled members', () => { + expect(isHandledWireDiscriminant('missing', HANDLED)).toBe(true) + expect(isHandledWireDiscriminant('unavailable', HANDLED)).toBe(true) + }) + + it('rejects a member this build has never heard of', () => { + expect(isHandledWireDiscriminant('permission-denied', HANDLED)).toBe(false) + }) + + // Why: Object.hasOwn coerces its key, so a guard typed (x: string) admits ['missing'] — whose + // toString matches a handled key — and the value then falls straight back out of the switch. + // This is the P1 found in review on #15002; it is the reason the parameter is `unknown`. + it('rejects a non-string the wire can carry', () => { + for (const value of [['missing'], null, undefined, 0, true, { missing: true }, Symbol('x')]) { + expect(isHandledWireDiscriminant(value, HANDLED), String(value)).toBe(false) + } + }) + + // Why: hasOwn, not `in` — inherited keys are not handled members. + it('does not admit inherited object keys', () => { + expect(isHandledWireDiscriminant('toString', HANDLED)).toBe(false) + expect(isHandledWireDiscriminant('constructor', HANDLED)).toBe(false) + expect(isHandledWireDiscriminant('__proto__', HANDLED)).toBe(false) + }) +}) diff --git a/src/shared/handled-wire-discriminant.ts b/src/shared/handled-wire-discriminant.ts new file mode 100644 index 00000000000..eb352ae490f --- /dev/null +++ b/src/shared/handled-wire-discriminant.ts @@ -0,0 +1,15 @@ +// Why: runtime RPC results are cast, not decoded — `runtime-rpc-envelope` declares `result: +// z.unknown()` — so a host on a newer build routinely puts a discriminant on the wire that this +// build's union has never heard of, and a switch on it falls through to `undefined`. +// +// Callers pass a `Record` so a new union member fails typecheck twice: on the Record +// (TS2741) and on the switch (`switch-exhaustiveness-check`, which forbids an absorbing `default:`). +// +// typeof first: `Object.hasOwn` coerces its key, so a host that widened the field to an array sends +// `['missing']`, which a hasOwn-only guard admits before the switch drops it straight back out. +export function isHandledWireDiscriminant( + value: unknown, + handled: Record +): value is T { + return typeof value === 'string' && Object.hasOwn(handled, value) +} From 876e5b88a46384d8d06bc0b8b6f50b338683b33d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:31 -0700 Subject: [PATCH 005/109] fix(ime): scope the composition route and deferred newline to owned sessions (#15056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a terminal composition went wrong, both from state that was not scoped to the session it belonged to. The route called preventDefault() before checking whether it owned the session. The patched xterm treats that cancellation as "someone else will deliver this" and skips its own triggerDataEvent, so a route installed mid-composition — the connection effect re-runs, a reconnect swaps the transport, StrictMode remounts — suppressed the insertion and then returned without delivering anything. The commit vanished. Ownership is now decided first; preventDefault stays for sessions the route does own, including the ones it deliberately drops after a transport swap, since that drop is its decision to make. Pending-composition state was a bare per-element count, so a waiter could only ask "is anything composing", not "is what I was waiting for still composing". A composition the user starts after pressing Enter is behind that Enter, not in front of it, but it kept the count non-zero and held the newline anyway — `한` Enter `글` reaching the terminal as `한글\n`. The count is now reference-counted per session id, and the deferred send snapshots the sessions open when it starts and waits only for those. Reference counts rather than a set, so two overlapping routes owning the same session cannot clear each other's pending state. Co-authored-by: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> --- .../terminal-ime-composition-route.test.ts | 70 +++++++++++++++++ .../terminal-ime-composition-route.ts | 77 +++++++++++++++---- .../terminal-ime-deferred-newline.test.ts | 31 ++++++++ .../terminal-ime-deferred-newline.ts | 8 +- 4 files changed, 171 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts index 16fb09252e0..57359c7ffc5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts @@ -149,6 +149,76 @@ describe('installTerminalImeCompositionRoute', () => { expect(harness.input).not.toHaveBeenCalled() }) + // A route that never saw the start cannot deliver the commit, so cancelling the event would + // suppress xterm's own triggerDataEvent with nothing standing in for it. + it('leaves an uncaptured session to xterm when installed mid-composition', () => { + const harness = createHarness() + + expect(harness.end(1, '한')).toBe(true) + expect(harness.input).not.toHaveBeenCalled() + }) + + it('does not swallow a commit when the connection re-installs the route mid-composition', () => { + const element = document.createElement('div') + const transport = createTransport('pty-original') + const input = vi.fn() + const install = () => + installTerminalImeCompositionRoute({ + terminalElement: element, + terminal: { input }, + capturedTransport: transport, + getCurrentTransport: () => transport + }) + const firstRoute = install() + + element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 1)) + // Reconnect/effect re-run swaps the route while the preedit is still open. + firstRoute.dispose() + const secondRoute = install() + + expect(element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 1, '한'))).toBe( + true + ) + expect(input).not.toHaveBeenCalled() + + secondRoute.dispose() + }) + + it('still suppresses xterm insertion for a captured session it deliberately drops', () => { + const harness = createHarness() + harness.start(1) + harness.state.currentTransport = createTransport('pty-replacement') + + // Owned, so xterm must stand down — dropping the commit is this route's decision. + expect(harness.end(1, '한')).toBe(false) + expect(harness.input).not.toHaveBeenCalled() + }) + + it('keeps a shared session pending until every overlapping route releases it', () => { + const element = document.createElement('div') + const firstTransport = createTransport('pty-first') + const secondTransport = createTransport('pty-second') + const firstRoute = installTerminalImeCompositionRoute({ + terminalElement: element, + terminal: { input: vi.fn() }, + capturedTransport: firstTransport, + getCurrentTransport: () => firstTransport + }) + const secondRoute = installTerminalImeCompositionRoute({ + terminalElement: element, + terminal: { input: vi.fn() }, + capturedTransport: secondTransport, + getCurrentTransport: () => secondTransport + }) + + element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 1)) + firstRoute.dispose() + expect(hasPendingTerminalImeComposition(element)).toBe(true) + + secondRoute.dispose() + expect(hasPendingTerminalImeComposition(element)).toBe(false) + }) + it.each(['terminal close', 'tab unmount'])( 'releases the captured session and listeners on %s', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts index 788fa9b83ba..8287b4854cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts @@ -14,21 +14,65 @@ type CapturedCompositionSession = { ptyId: string | null } -const pendingCompositionCountByElement = new WeakMap() +export type TerminalImeCompositionSessionSnapshot = ReadonlySet -function adjustPendingCompositionCount(terminalElement: HTMLElement, delta: number): void { - const count = Math.max(0, (pendingCompositionCountByElement.get(terminalElement) ?? 0) + delta) - if (count === 0) { - pendingCompositionCountByElement.delete(terminalElement) - return - } - pendingCompositionCountByElement.set(terminalElement, count) +// Reference-counted per session id, not a bare per-element count: a caller waiting on a +// composition must be able to tell its own sessions from ones that started after it, and two +// overlapping routes can own the same session without clearing each other's pending state. +const pendingCompositionSessionCountsByElement = new WeakMap>() + +function addPendingCompositionSession(terminalElement: HTMLElement, sessionId: number): void { + const sessionCounts = pendingCompositionSessionCountsByElement.get(terminalElement) ?? new Map() + sessionCounts.set(sessionId, (sessionCounts.get(sessionId) ?? 0) + 1) + pendingCompositionSessionCountsByElement.set(terminalElement, sessionCounts) } -export function hasPendingTerminalImeComposition( +function removePendingCompositionSession(terminalElement: HTMLElement, sessionId: number): void { + const sessionCounts = pendingCompositionSessionCountsByElement.get(terminalElement) + const count = sessionCounts?.get(sessionId) + if (!sessionCounts || count === undefined) { + return + } + if (count > 1) { + sessionCounts.set(sessionId, count - 1) + } else { + sessionCounts.delete(sessionId) + } + if (!sessionCounts.size) { + pendingCompositionSessionCountsByElement.delete(terminalElement) + } +} + +export function capturePendingTerminalImeCompositionSessions( terminalElement: HTMLElement | null | undefined +): TerminalImeCompositionSessionSnapshot { + if (!terminalElement) { + return new Set() + } + return new Set(pendingCompositionSessionCountsByElement.get(terminalElement)?.keys()) +} + +/** With a snapshot, only the sessions it captured count as pending. */ +export function hasPendingTerminalImeComposition( + terminalElement: HTMLElement | null | undefined, + snapshot?: TerminalImeCompositionSessionSnapshot ): boolean { - return Boolean(terminalElement && pendingCompositionCountByElement.has(terminalElement)) + if (!terminalElement) { + return false + } + const sessionCounts = pendingCompositionSessionCountsByElement.get(terminalElement) + if (!sessionCounts) { + return false + } + if (!snapshot) { + return true + } + for (const sessionId of snapshot) { + if (sessionCounts.has(sessionId)) { + return true + } + } + return false } function getCompositionDetail(event: Event): CompositionSessionDetail | null { @@ -70,7 +114,7 @@ export function installTerminalImeCompositionRoute(args: { return } if (!sessions.has(detail.id)) { - adjustPendingCompositionCount(terminalElement, 1) + addPendingCompositionSession(terminalElement, detail.id) } sessions.set(detail.id, { ptyId: args.capturedTransport.getPtyId() @@ -82,13 +126,16 @@ export function installTerminalImeCompositionRoute(args: { if (!detail) { return } - event.preventDefault() const captured = sessions.get(detail.id) if (!captured) { + // Not our session — the route was installed mid-composition, so no start was seen. + // Cancelling here would suppress xterm's own insertion with nothing to replace it. return } + // Owned, so xterm stands down even on the drop paths below: that drop is this route's call. + event.preventDefault() sessions.delete(detail.id) - adjustPendingCompositionCount(terminalElement, -1) + removePendingCompositionSession(terminalElement, detail.id) if ( disposed || detail.dataPendingReconciliation || @@ -108,7 +155,9 @@ export function installTerminalImeCompositionRoute(args: { return { dispose: () => { disposed = true - adjustPendingCompositionCount(terminalElement, -sessions.size) + for (const sessionId of sessions.keys()) { + removePendingCompositionSession(terminalElement, sessionId) + } sessions.clear() terminalElement.removeEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, onSessionStart) terminalElement.removeEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onSessionEnd) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts index 0ac8ce17377..5508565e7f6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts @@ -142,6 +142,37 @@ describe('sendTerminalInputAfterComposition', () => { route.dispose() }) + // A composition the user starts after pressing Enter must not hold that Enter behind itself, or + // the terminal sees `한글\n` for what was typed as `한` Enter `글`. + it('does not let a composition started after the defer hold the newline', () => { + const el = document.createElement('div') + const send = vi.fn() + const terminal = { input: vi.fn() } + const transport = { getPtyId: () => 'pty-1' } as unknown as PtyTransport + const route = installTerminalImeCompositionRoute({ + terminalElement: el, + terminal, + capturedTransport: transport, + getCurrentTransport: () => transport + }) + const sessionEvent = (type: string, id: number, data: string) => + new CustomEvent(type, { cancelable: true, detail: { id, data } }) + + el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 1, '')) + const stopWaiting = sendTerminalInputAfterComposition(el, send, { fallbackMs: null }) + el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 2, '')) + expect(send).not.toHaveBeenCalled() + + el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 1, '한')) + vi.advanceTimersByTime(0) + + expect(send).toHaveBeenCalledTimes(1) + expect(terminal.input.mock.calls).toEqual([['한']]) + + stopWaiting() + route.dispose() + }) + it('sends only once and drops the listener after firing', () => { const el = document.createElement('div') const send = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts index b27f25e4742..c46a519924c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts @@ -1,4 +1,5 @@ import { + capturePendingTerminalImeCompositionSessions, hasPendingTerminalImeComposition, XTERM_COMPOSITION_SESSION_END_EVENT } from './terminal-ime-composition-route' @@ -14,6 +15,10 @@ export const TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS = 200 * Returns a disposer that stops waiting without sending. An indefinite wait has no other exit, so * a caller that can outlive the composition must hold it — otherwise the listeners stay on the * terminal element and a later composition flushes the stale send. + * + * The wait is scoped to the compositions already open when it starts. A composition the user + * begins afterwards is behind this input, not in front of it, so letting it hold the wait would + * reorder the two — `한` Enter `글` reaching the terminal as `한글\n`. */ export function sendTerminalInputAfterComposition( terminalElement: HTMLElement | null | undefined, @@ -30,6 +35,7 @@ export function sendTerminalInputAfterComposition( ? null : (options?.fallbackMs ?? TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS) let done = false + const capturedSessions = capturePendingTerminalImeCompositionSessions(terminalElement) const stopWaiting = (): void => { if (done) { @@ -56,7 +62,7 @@ export function sendTerminalInputAfterComposition( } const finishAfterPendingComposition = (): void => { - if (!hasPendingTerminalImeComposition(terminalElement)) { + if (!hasPendingTerminalImeComposition(terminalElement, capturedSessions)) { finish() } } From 47a53b694bc015a747ce37afbc2a95535a5e24da Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:35 -0700 Subject: [PATCH 006/109] fix(ui): ignore IME composition Enter in the comment composer and replace field (#15057) The keydown that exits a CJK composition carries isComposing: true (UI Events 3.6.5, row 5) and, when the IME is processing key input, keyCode 229 (7.3.1). Two handlers acted on it: - right-panel-comment-composer: Cmd/Ctrl+Enter posted the comment while the last syllable was still composing, so it went out truncated. - RichMarkdownSearchBar: Enter ran replace-current, mutating the document from a keystroke aimed at the candidate list; Escape closed the bar instead of letting the IME cancel the composition. Same in the find field. Guard all three with the existing isImeCompositionKeyDown helper, matching the rename and title inputs already on it. The held modifier does not change ownership: a composing Ctrl+Enter is still the IME's keydown, which is why useImeEnterGestureOwnership also owns it and only lets the post-compositionend redispatch chord through. Co-authored-by: BaeTab --- .../RichMarkdownSearchBar.ime-enter.test.tsx | 210 ++++++++++++++++++ .../editor/RichMarkdownSearchBar.tsx | 13 ++ ...-panel-comment-composer.ime-enter.test.tsx | 112 ++++++++++ .../right-panel-comment-composer.tsx | 6 + 4 files changed, 341 insertions(+) create mode 100644 src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx new file mode 100644 index 00000000000..c67702ad350 --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment happy-dom + +import { act, createRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RichMarkdownSearchBar } from './RichMarkdownSearchBar' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: () => null +})) + +const QUERY = '배포' +const REPLACEMENT = '릴리스' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +type BarSpies = { + onClose?: () => void + onMoveToMatch?: (direction: 1 | -1) => void + onReplaceCurrent?: () => void +} + +function renderBar(spies: BarSpies): { + findInput: HTMLInputElement + replaceInput: HTMLInputElement +} { + act(() => { + root.render( + ()} + wholeWord={false} + onClose={spies.onClose ?? vi.fn()} + onMoveToMatch={spies.onMoveToMatch ?? vi.fn()} + onQueryChange={vi.fn()} + onReplaceAll={vi.fn()} + onReplaceCurrent={spies.onReplaceCurrent ?? vi.fn()} + onReplaceQueryChange={vi.fn()} + onToggleMatchCase={vi.fn()} + onToggleReplaceMode={vi.fn()} + onToggleWholeWord={vi.fn()} + /> + ) + }) + const inputs = [...container.querySelectorAll('input')] + // Why: match on value rather than order so a layout change fails loudly instead of + // silently pointing the assertions at the wrong field. + const findInput = inputs.find((candidate) => candidate.value === QUERY) + const replaceInput = inputs.find((candidate) => candidate.value === REPLACEMENT) + if (!findInput || !replaceInput) { + throw new Error('search bar fields not rendered') + } + return { findInput, replaceInput } +} + +function pressKey( + input: HTMLInputElement, + key: string, + init?: KeyboardEventInit & { keyCode?: number } +): void { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...init + }) + if (init?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + } + act(() => { + input.dispatchEvent(event) + }) +} + +describe('RichMarkdownSearchBar replace field IME guard', () => { + it('does not replace on the Enter that commits a CJK composition', () => { + const onReplaceCurrent = vi.fn() + const { replaceInput } = renderBar({ onReplaceCurrent }) + + pressKey(replaceInput, 'Enter', { isComposing: true }) + + expect(onReplaceCurrent).not.toHaveBeenCalled() + }) + + it('does not replace on an Enter the IME reports as keyCode 229', () => { + const onReplaceCurrent = vi.fn() + const { replaceInput } = renderBar({ onReplaceCurrent }) + + pressKey(replaceInput, 'Enter', { keyCode: 229 }) + + expect(onReplaceCurrent).not.toHaveBeenCalled() + }) + + it('does not close the bar on the Escape that cancels a composition', () => { + const onClose = vi.fn() + const { replaceInput } = renderBar({ onClose }) + + pressKey(replaceInput, 'Escape', { isComposing: true }) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('still replaces on a plain Enter', () => { + const onReplaceCurrent = vi.fn() + const { replaceInput } = renderBar({ onReplaceCurrent }) + + pressKey(replaceInput, 'Enter') + + expect(onReplaceCurrent).toHaveBeenCalledTimes(1) + }) + + it('still closes the bar on a plain Escape', () => { + const onClose = vi.fn() + const { replaceInput } = renderBar({ onClose }) + + pressKey(replaceInput, 'Escape') + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe('RichMarkdownSearchBar find field IME guard', () => { + it('does not move to the next match on the Enter that commits a composition', () => { + const onMoveToMatch = vi.fn() + const { findInput } = renderBar({ onMoveToMatch }) + + pressKey(findInput, 'Enter', { isComposing: true }) + + expect(onMoveToMatch).not.toHaveBeenCalled() + }) + + it('does not move to the previous match on a composing Shift+Enter', () => { + const onMoveToMatch = vi.fn() + const { findInput } = renderBar({ onMoveToMatch }) + + pressKey(findInput, 'Enter', { isComposing: true, shiftKey: true }) + + expect(onMoveToMatch).not.toHaveBeenCalled() + }) + + it('does not move on an Enter the IME reports as keyCode 229', () => { + const onMoveToMatch = vi.fn() + const { findInput } = renderBar({ onMoveToMatch }) + + pressKey(findInput, 'Enter', { keyCode: 229 }) + + expect(onMoveToMatch).not.toHaveBeenCalled() + }) + + it('does not close the bar on the Escape that cancels a composition', () => { + const onClose = vi.fn() + const { findInput } = renderBar({ onClose }) + + pressKey(findInput, 'Escape', { isComposing: true }) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('still moves to the next match on a plain Enter', () => { + const onMoveToMatch = vi.fn() + const { findInput } = renderBar({ onMoveToMatch }) + + pressKey(findInput, 'Enter') + + expect(onMoveToMatch).toHaveBeenCalledWith(1) + }) + + it('still moves to the previous match on a plain Shift+Enter', () => { + const onMoveToMatch = vi.fn() + const { findInput } = renderBar({ onMoveToMatch }) + + pressKey(findInput, 'Enter', { shiftKey: true }) + + expect(onMoveToMatch).toHaveBeenCalledWith(-1) + }) + + it('still closes the bar on a plain Escape', () => { + const onClose = vi.fn() + const { findInput } = renderBar({ onClose }) + + pressKey(findInput, 'Escape') + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index 9ea8d17e068..4b8944142ea 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -12,6 +12,7 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { translate } from '@/i18n/i18n' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type RichMarkdownSearchBarProps = { @@ -111,6 +112,12 @@ export function RichMarkdownSearchBar({ value={query} onChange={(event) => onQueryChange(event.target.value)} onKeyDown={(event) => { + // Why: mid-composition Enter only confirms the candidate and Escape + // only cancels it, so navigating matches or closing the bar here acts + // on a keystroke that was aimed at the IME. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter' && event.shiftKey) { event.preventDefault() onMoveToMatch(-1) @@ -247,6 +254,12 @@ export function RichMarkdownSearchBar({ value={replaceQuery} onChange={(event) => onReplaceQueryChange(event.target.value)} onKeyDown={(event) => { + // Why: replacing on the Enter that only confirms a candidate mutates + // the document from a keystroke the user aimed at the IME; Escape + // during composition belongs to the IME's cancel, not to the bar. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() onReplaceCurrent() diff --git a/src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx b/src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx new file mode 100644 index 00000000000..504f1955eb5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RightPanelCommentComposer } from './right-panel-comment-composer' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ShortcutKeyCombo', () => ({ + ShortcutKeyCombo: () => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children?: React.ReactNode }) =>

{children}
, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children} +})) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + // Why: the composer picks its submit modifier off the user agent; pin the non-Mac + // branch so the test drives Ctrl+Enter regardless of the runner's platform. + vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('X11; Linux x86_64') + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() +}) + +function renderComposer(onSubmit: () => Promise<{ ok: true }>): HTMLTextAreaElement { + act(() => { + root.render( + + ) + }) + const textarea = container.querySelector('textarea') + if (!textarea) { + throw new Error('comment textarea not rendered') + } + act(() => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set?.call( + textarea, + '확인했습니다' + ) + textarea.dispatchEvent(new Event('input', { bubbles: true })) + }) + return textarea +} + +function pressCtrlEnter( + textarea: HTMLTextAreaElement, + init?: KeyboardEventInit & { keyCode?: number } +): void { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + ctrlKey: true, + ...init + }) + if (init?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + } + act(() => { + textarea.dispatchEvent(event) + }) +} + +describe('RightPanelCommentComposer IME Enter guard', () => { + it('does not submit on the Ctrl+Enter that commits a CJK composition', () => { + const onSubmit = vi.fn(async () => ({ ok: true }) as const) + const textarea = renderComposer(onSubmit) + + pressCtrlEnter(textarea, { isComposing: true }) + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('does not submit on a Ctrl+Enter the IME reports as keyCode 229', () => { + const onSubmit = vi.fn(async () => ({ ok: true }) as const) + const textarea = renderComposer(onSubmit) + + pressCtrlEnter(textarea, { keyCode: 229 }) + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('still submits on a plain Ctrl+Enter', () => { + const onSubmit = vi.fn(async () => ({ ok: true }) as const) + const textarea = renderComposer(onSubmit) + + pressCtrlEnter(textarea) + + expect(onSubmit).toHaveBeenCalledWith('확인했습니다') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/right-panel-comment-composer.tsx b/src/renderer/src/components/right-sidebar/right-panel-comment-composer.tsx index a3f1e7bae5a..bb6744525e1 100644 --- a/src/renderer/src/components/right-sidebar/right-panel-comment-composer.tsx +++ b/src/renderer/src/components/right-sidebar/right-panel-comment-composer.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { getCommentBodySubmitState, hasBoundedCommentBodyText @@ -171,6 +172,11 @@ export function RightPanelCommentComposer({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { + // Why: the Enter that only confirms a CJK candidate still reports the held + // modifier, so submitting here posts the comment without its last syllable. + if (isImeCompositionKeyDown(event)) { + return + } const modifierPressed = isMac ? event.metaKey : event.ctrlKey if (event.key === 'Enter' && modifierPressed) { event.preventDefault() From c7995a66aeec3fe9a6b7bc50d96c82a5439bf679 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:11:24 -0700 Subject: [PATCH 007/109] fix(mobile-native-chat): reland glued pending retirement without the two revert causes (STA-4482, STA-4492) (#14936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile-native-chat): reland glued pending retirement without the two revert causes Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when two fast sends landed as one transcript row, but shipped two regressions; both are fixed here rather than re-applied and hoped for. 1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to `text.trimEnd()` at the top of `sendMessage` and then used that one value for both the bytes on the wire and the composer restore, so a rejection put back less than the user typed. The draft and the payload are now separate values: `draftText` is what the user typed and is what `clearDraftForSend` / `restoreRejectedDraft` see; only the transported `text` is trimmed. 2. Sends issued during hydration were stranded forever. #14665 persisted `glueBaselineTrusted: false` on any send captured while the transcript was still loading and never cleared it, so that send could never retire and stood as a permanent glue barrier for its neighbours. A hydration-time baseline is now a placeholder (`baselineResolved: false`) that the first authoritative read rebases onto real rows, ordinals included, instead of a permanent disqualification. That is STA-4492. The intended behavior is unchanged: one transcript user turn retires a run of 2+ adjacent text-only pending sends only when it exactly spells their normalized concatenation, every send is bounded by its OWN transcript tail, and exact landings, image echoes and unresolved tails stay barriers. No wire change: `baselineResolved` and the baseline tail are client-local React state in `pendingBySession` and are never exchanged with a host. The only client->host difference is trailing whitespace no longer being written onto the agent's input line, over the existing `terminal.send` params. Refs STA-4482, STA-4492. Original PR #14665, revert #14819. * fix(mobile-native-chat): let the untrimmed draft reach the send seam The composer sent `value.trimEnd()`, so the raw draft never reached `sendMessage` and a rejected send still handed back a trimmed composer — the split of `draftText` from the transported `text` had nothing to restore. Pass the draft through; the seam already owns the wire trim. Also pins the array-identity contract of `retireLandedMobileNativeChatPending`: the drafts effect early-outs on `next === current`, and nothing tested it. * docs(mobile-native-chat): name the hydration rebase's residual ambiguity * fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo Rebasing recounted the send's ordinal against the first authoritative read. That read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever exists now — so the ordinal landed one past anything the transcript could supply. The bubble never cleared, it stayed a live segment at the head of its run so no later pair could glue either, and `earlierOutstanding` carried the inflation onto the next send of the same text. Only the tail needs recovering; the ordinal was already counted against an empty transcript, which is right for "no history was known". A caption-less image echo keeps its captured tail, since it counts turns after it. `baselineResolved` also has to mean "captured against a settled read", not merely "not loading": a read that failed hands back an empty list that reads as an empty conversation, and the null tail then let any row the successful read finally brought glue-retire those sends. * test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable A held send sits as a live segment at the head of its run, so the cursor can never reach a later pair — the stuck bubble takes the whole feature down with it. Goes red against the ordinal recount. * fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness. * fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read The image-preview pass runs before the rebase, so a send captured with no boundary matched any image turn the settled read carried — binding the user's freshly attached photo to an old one and retiring the bubble through landedImagePendingIds, which short-circuits the retirement path entirely. Pinning the tail in the rebase could not help: the claim was already made. Such an entry now waits one tick and claims against a real tail. * fix(mobile-native-chat): never move a boundary the send already captured An unsettled read still shows this session's own retained history — a reconnect or a failed read keeps the conversation on screen rather than blanking it — so sends made across one already own a correct tail. The rebase overwrote it with the tail of the read that followed, which sits at or after their own glued row, so `turn.index <= segment.tail` rejected every turn and the pair stayed queued for the session, blocking every later pair in the run. Pin only a send that captured no tail at all. A captioned image echo is now left alone entirely: it binds its preview by an ordinal counted over the whole transcript, so supplying a tail without recounting left it matching nothing, forever. * fix(mobile-native-chat): supply a boundary only to a text-bearing send An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured. * fix(mobile-native-chat): stop one unmatchable send freezing glue for the session The match cursor only advanced on a hit, so a head that could never match — a pair whose glued row arrived with the read, or a send the count pass claimed against an older row — froze the run behind it and every later rapid pair became permanently unretirable. Two cases previously disclosed as bounded were not bounded at all. Slide past a non-matching head, keeping the cursor monotonic so a later turn can never take a send an earlier one claimed. The slide widens the search, so a span cap keeps the work linear in the run length instead of quadratic; the existing budget test now asserts that bound rather than the old one it silently broke. Re-fuzzed at 250k seeds: the boundary guarantee still holds. Also corrects a comment that claimed the preview-pass filter made a photo claim against a real tail. It does not — an image echo keeps whatever tail it captured, so a caption-less photo can still bind to an older photo turn, as on main. * fix(mobile-native-chat): stop the span cap stranding a long glued run Capping each match attempt at 8 segments did not truncate a longer glue, it rejected it outright: a row spelling 9+ sends exhausted the loop without reaching the end of the text and returned zero, so none of the nine retired — and each stuck send then inflated `earlierOutstanding` for the next send of the same text. Nothing bounds how many sends pile onto the agent's input line; accumulation ends when the agent accepts input again, not at any fixed count. One inspection budget now covers the whole slide instead. The first attempt spans the entire run and always fits, so a genuine glue is never truncated; only a run of identical prefix-matching sends can exhaust the budget, which is exactly the case that should be cheap. The in-flight attempt may overshoot the remainder — that is what makes the guarantee hold — so the budget test asserts the real ceiling. Re-fuzzed at 250k seeds with runs past the budget. --- .../session/MobileNativeChatComposer.test.ts | 8 +- .../src/session/MobileNativeChatComposer.tsx | 4 +- ...obile-native-chat-pending-baseline.test.ts | 164 ++++++++ .../mobile-native-chat-pending-baseline.ts | 53 +++ .../mobile-native-chat-pending-echo.ts | 7 + ...ile-native-chat-pending-retirement.test.ts | 323 ++++++++++++++++ .../mobile-native-chat-pending-retirement.ts | 173 +++++++++ .../use-mobile-native-chat-controller.test.ts | 3 +- .../use-mobile-native-chat-controller.ts | 3 +- ...e-native-chat-drafts-glued-pending.test.ts | 359 ++++++++++++++++++ ...le-native-chat-drafts-launch-draft.test.ts | 7 +- .../use-mobile-native-chat-drafts.test.ts | 7 +- .../session/use-mobile-native-chat-drafts.ts | 61 +-- ...se-mobile-native-chat-message-send.test.ts | 65 +++- .../use-mobile-native-chat-message-send.ts | 13 +- 15 files changed, 1204 insertions(+), 46 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-pending-baseline.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-baseline.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-retirement.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-retirement.ts create mode 100644 mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 1d1b0288467..3c8afe2cefd 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -84,7 +84,9 @@ describe('MobileNativeChatComposer', () => { await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith(' hello') + // Verbatim: the send seam trims for the wire, so a rejected send can hand + // back the draft byte-for-byte (#14819). + expect(onSend).toHaveBeenCalledWith(' hello ') expect(onChangeText).not.toHaveBeenCalled() }) @@ -120,7 +122,7 @@ describe('MobileNativeChatComposer', () => { ) }) await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith(' /clear is prose') + expect(onSend).toHaveBeenCalledWith(' /clear is prose ') }) it('locks the option pickers while a composer send is in flight', async () => { @@ -219,7 +221,7 @@ describe('MobileNativeChatComposer', () => { await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith(' hello') + expect(onSend).toHaveBeenCalledWith(' hello ') expect(onChangeText).not.toHaveBeenCalled() }) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 9b289b4b6e8..ee937369ea7 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -151,7 +151,9 @@ export function MobileNativeChatComposer({ sendingRef.current = true setSending(true) try { - const accepted = await onSend(value.trimEnd()) + // Raw, not trimmed: the send seam owns the wire trim, and a rejection has + // to hand the user back exactly what they typed (#14819). + const accepted = await onSend(value) if (accepted) { setCursor(0) } diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts new file mode 100644 index 00000000000..1abdc714e84 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' + +function userTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +function unresolved( + id: string, + text: string, + expectedOccurrence = 1 +): MobileNativeChatPendingMessage { + return { id, text, expectedOccurrence, baselineTailMessageId: null, baselineResolved: false } +} + +describe('rebaseMobileNativeChatPendingBaselines', () => { + const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)] + + it('returns the same array when every baseline is already resolved', () => { + const pending: MobileNativeChatPendingMessage[] = [ + { + id: 'p1', + text: 'hi', + expectedOccurrence: 1, + baselineTailMessageId: 'm2', + baselineResolved: true + } + ] + expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending) + }) + + it('gives an unresolved send the loaded tail as its glue boundary', () => { + expect( + rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')]) + ).toEqual([ + { + id: 'p1', + text: 'run the tests', + // NOT recounted against this read: the read may already carry this send's + // own echo, and counting it as history would strand the bubble forever. + expectedOccurrence: 1, + baselineTailMessageId: 'm2', + baselineResolved: true + } + ]) + }) + + it('pins to null when the authoritative transcript is empty', () => { + expect(rebaseMobileNativeChatPendingBaselines([], [unresolved('p1', 'hi')])).toEqual([ + { + id: 'p1', + text: 'hi', + expectedOccurrence: 1, + baselineTailMessageId: null, + baselineResolved: true + } + ]) + }) + + it('keeps the ordinals the queue already assigned', () => { + expect( + rebaseMobileNativeChatPendingBaselines(history, [ + unresolved('p1', 'run the tests', 1), + unresolved('p2', 'run the tests', 2) + ]).map((item) => item.expectedOccurrence) + ).toEqual([1, 2]) + }) + + it('leaves already-resolved neighbours untouched', () => { + const resolved: MobileNativeChatPendingMessage = { + id: 'p1', + text: 'run the tests', + expectedOccurrence: 9, + baselineTailMessageId: 'm1', + baselineResolved: true + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + resolved, + unresolved('p2', 'run the tests', 2) + ]) + expect(rebased[0]).toBe(resolved) + expect(rebased[1]).toEqual({ + id: 'p2', + text: 'run the tests', + expectedOccurrence: 2, + baselineTailMessageId: 'm2', + baselineResolved: true + }) + }) + + // An unsettled read still shows this session's own retained history, so a send + // made across a reconnect already owns a real boundary. Moving it onto the read + // that follows pushes it past the send's own echo and strands the bubble. + it('never moves a tail the send actually captured', () => { + const captured: MobileNativeChatPendingMessage = { + id: 'p1', + text: 'run the tests', + expectedOccurrence: 1, + baselineTailMessageId: 'm1', + baselineResolved: false + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [captured]) + expect(rebased[0]?.baselineTailMessageId).toBe('m1') + expect(rebased[0]?.baselineResolved).toBe(true) + }) + + // An image echo counts image turns AFTER its tail, so moving a real tail onto + // this read would discard an echo the read already carries. + it('keeps a caption-less image echo on the tail it actually captured', () => { + const images = { + ...unresolved('p1', ''), + baselineTailMessageId: 'm1', + images: ['file:///a.png'] + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [images]) + expect(rebased[0]?.baselineTailMessageId).toBe('m1') + expect(rebased[0]?.expectedOccurrence).toBe(1) + expect(rebased[0]?.baselineResolved).toBe(true) + }) + + // A captioned image echo binds by an ordinal counted over the whole transcript. + // Pinning a tail without recounting leaves it matching nothing, forever. + it('leaves a captioned image echo entirely alone', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + { ...unresolved('p1', 'here'), images: ['file:///a.png'] } + ]) + expect(rebased[0]?.baselineTailMessageId).toBe(null) + expect(rebased[0]?.expectedOccurrence).toBe(1) + expect(rebased[0]?.baselineResolved).toBe(true) + }) + + // A supplied tail would sit at or after this send's own echo, and an image + // echo counts turns AFTER its tail — so it would exclude the very row it is + // waiting for, and image entries have no other retirement path. + it('never supplies a tail to a caption-less image echo either', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + { ...unresolved('p1', ''), images: ['file:///a.png'] }, + { ...unresolved('p2', '', 2), images: ['file:///b.png'] } + ]) + expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null]) + expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2]) + expect(rebased.every((item) => item.baselineResolved)).toBe(true) + }) + + // Same rule for a text-empty entry with no images: it reconciles by counting + // image-source turns after its tail, so a supplied tail strands it too. + it('never supplies a tail to a text-empty entry', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' ')]) + expect(rebased[0]?.baselineTailMessageId).toBe(null) + expect(rebased[0]?.baselineResolved).toBe(true) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts new file mode 100644 index 00000000000..903bf9d8f32 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -0,0 +1,53 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' + +/** + * Give the sends that never saw a transcript the boundary they lack, on the + * first settled read. + * + * Such a send has no row to judge candidates against — which held it out of + * matching entirely, stranding it as a queued bubble and, worse, as a permanent + * glue barrier for its neighbours. + * + * Only a send that captured NO tail is pinned. An unsettled read still shows + * this session's own retained history (see `createNativeChatTranscriptRetention` + * — a reconnect or a failed read keeps the conversation on screen rather than + * blanking it), so a send made then already owns a correct boundary. Moving it + * onto this read would push it past the send's own echo and strand the bubble + * for good. + * + * The ordinal is deliberately NOT recounted. The read can already carry the + * send's own echo — a re-subscribe returns whatever exists now — and nothing + * local separates that echo from an identical older prompt: the transport writes + * keystrokes into a TUI and carries no message id, and row timestamps come from + * the host while the send time comes from the phone. Counting it as history puts + * the ordinal one past anything the transcript can supply. + * + * And only a TEXT-bearing send is given one. The glue matcher is the sole + * consumer that a supplied tail helps; every other one is harmed by it. An image + * echo reconciles by counting turns AFTER its tail, so a tail taken from a read + * that already carries its echo excludes that echo and the bubble can never + * retire — image entries have no other retirement path. A captioned one is worse + * still: it binds by an ordinal counted over the whole transcript, so a tail + * without a matching recount leaves it claiming nothing at all. + */ +export function rebaseMobileNativeChatPendingBaselines( + messages: readonly NativeChatMessage[], + current: MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (current.every((item) => item.baselineResolved)) { + return current + } + const baselineTailMessageId = messages.at(-1)?.id ?? null + return current.map((item) => { + if (item.baselineResolved) { + return item + } + const reconcilesAgainstItsOwnTail = + Boolean(item.images?.length) || normalizeReconcileText(item.text) === '' + return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail + ? { ...item, baselineResolved: true } + : { ...item, baselineResolved: true, baselineTailMessageId } + }) +} diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index b18136a9d47..7466eae905f 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -5,6 +5,11 @@ export type MobileNativeChatPendingMessage = { /** Local preview URIs carried by the send for its optimistic echo. */ images?: string[] baselineTailMessageId: string | null + /** Whether the transcript this baseline was captured from was already this + * session's own history. A send issued mid-hydration is captured unresolved + * and rebased onto the first authoritative read instead of reconciling + * against rows that may belong to another tab. */ + baselineResolved: boolean } export type MobileNativeChatSendOrigin = { @@ -13,6 +18,7 @@ export type MobileNativeChatSendOrigin = { normalizedText: string baselineOccurrences: number baselineTailMessageId: string | null + baselineResolved: boolean } type PendingByKey = Record @@ -56,6 +62,7 @@ export function appendMobileNativeChatPending( ? expectedImageEchoOrdinal : origin.baselineOccurrences + earlierOutstanding + 1, baselineTailMessageId: origin.baselineTailMessageId, + baselineResolved: origin.baselineResolved, ...(images?.length ? { images } : {}) } ] diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts new file mode 100644 index 00000000000..1c865e2853c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' +import { + GLUE_SLIDE_BUDGET, + retireLandedMobileNativeChatPending, + selectGluedPendingIds +} from './mobile-native-chat-pending-retirement' + +const NO_IMAGE_ECHOES: ReadonlySet = new Set() + +function userTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +/** A send captured with `tail` as the last transcript message it could see. */ +function pendingSend( + id: string, + text: string, + tail: string | null, + expectedOccurrence = 1, + baselineResolved = true +): MobileNativeChatPendingMessage { + return { id, text, expectedOccurrence, baselineTailMessageId: tail, baselineResolved } +} + +function retiredIds( + messages: readonly NativeChatMessage[], + pending: readonly MobileNativeChatPendingMessage[] +): string[] { + return [...selectGluedPendingIds(messages, pending)].sort() +} + +describe('selectGluedPendingIds', () => { + it('retires both bubbles when two rapid sends collapse into one user row', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the tests again', 5000) + ] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('retires a glued row that concatenated with no separator at all', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the testsagain', 5000) + ] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('retires three collapsed prompts in one row', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two three', 5000)] + const pending = [ + pendingSend('p1', 'one', 'm1'), + pendingSend('p2', 'two', 'm1'), + pendingSend('p3', 'three', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3']) + }) + + it('matches across tab, newline and repeated-space separators', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the tests\t\n again', 5000) + ] + const pending = [ + pendingSend('p1', ' run the tests\n', 'm1'), + pendingSend('p2', '\tagain ', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('matches prompts that themselves contain the separator', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'fix the bug and run the tests', 5000) + ] + const pending = [ + pendingSend('p1', 'fix the bug', 'm1'), + pendingSend('p2', 'and run the tests', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('matches when one pending is a strict prefix of the next', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run run the tests', 5000)] + const pending = [pendingSend('p1', 'run', 'm1'), pendingSend('p2', 'run the tests', 'm1')] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('matches identical prompts sent twice and three times', () => { + const twice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi', 5000)] + expect( + retiredIds(twice, [pendingSend('p1', 'hi', 'm1', 1), pendingSend('p2', 'hi', 'm1', 2)]) + ).toEqual(['p1', 'p2']) + + const thrice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi hi', 5000)] + expect( + retiredIds(thrice, [ + pendingSend('p1', 'hi', 'm1', 1), + pendingSend('p2', 'hi', 'm1', 2), + pendingSend('p3', 'hi', 'm1', 3) + ]) + ).toEqual(['p1', 'p2', 'p3']) + }) + + it('retires two separate glued rows against their own runs', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'one two', 5000), + userTurn('m3', 'three four', 6000) + ] + const pending = [ + pendingSend('p1', 'one', 'm1'), + pendingSend('p2', 'two', 'm1'), + pendingSend('p3', 'three', 'm2'), + pendingSend('p4', 'four', 'm2') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3', 'p4']) + }) + + // FP1: the concatenation already exists in history, older than the sends. + it('never lets a transcript row that predates the sends retire them', () => { + const messages = [ + userTurn('m1', 'run the tests again', 1), + assistantTurn('m2', 'done', 2), + assistantTurn('m3', 'anything else?', 3) + ] + const pending = [pendingSend('p1', 'run the tests', 'm3'), pendingSend('p2', 'again', 'm3')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + // FP2: an old turn reads like the concatenation of two much later sends. + it('never lets an older turn retire sends captured after it', () => { + const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] + const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('rejects a row the pending run only prefixes', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the tests again with coverage', 5000) + ] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('rejects a run that only partly spells the row', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run the tests', 5000)] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + // A lone exact match is an ordinary landing, not glue. + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('rejects glue when a send in the run cannot resolve its own tail', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'paginated-away')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('treats a null tail as "transcript was empty", so any row can glue', () => { + const messages = [userTurn('m1', 'one two', 5000)] + const pending = [pendingSend('p1', 'one', null), pendingSend('p2', 'two', null)] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + // Unresolved baselines are held out of matching until the drafts hook rebases + // them onto the first authoritative read — see mobile-native-chat-pending-baseline. + it('holds out sends whose baseline has not been rebased onto a real transcript', () => { + const messages = [userTurn('m1', 'one two', 1000)] + const pending = [ + pendingSend('p1', 'one', null, 1, false), + pendingSend('p2', 'two', null, 1, false) + ] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + // A head that can never match must not freeze the run behind it: one stuck + // bubble would otherwise disable glue retirement for the rest of the session. + it('glues a later pair even when the head of the run can never match', () => { + const messages = [ + userTurn('m0', 'unrelated', 1000), + userTurn('m1', 'fix the bug', 5000), + userTurn('m2', 'ship the fix', 6000) + ] + const stuckHead = pendingSend('p0', 'bug', 'm0') + const pair = [pendingSend('p1', 'ship the', 'm0'), pendingSend('p2', 'fix', 'm0')] + expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2']) + }) + + // Nothing bounds how many sends pile onto the agent's input line, so the + // slide's budget must never truncate a genuine glue. + it('retires a glued run far longer than the slide budget', () => { + const words = Array.from({ length: 12 }, (_, index) => `w${index}`) + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', words.join(' '), 5000)] + const pending = words.map((word, index) => pendingSend(`p${index}`, word, 'm1')) + expect(retiredIds(messages, pending)).toHaveLength(words.length) + }) + + it('skips a caption-less image echo instead of gluing it', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [ + pendingSend('p1', '', 'm1'), + pendingSend('p2', 'one', 'm1'), + pendingSend('p3', 'two', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p2', 'p3']) + }) + + it('keeps a captioned image echo and its neighbors until the preview is rebound', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [ + { ...pendingSend('p1', 'one', 'm1'), images: ['file:///a.png'] }, + pendingSend('p2', 'two', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual([]) + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1', 'p2']) + }) + + it('does nothing for a single pending send', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) + }) + + // The cursor slides past a head that cannot match, so a turn may try several + // start positions — but one inspection budget covers the whole slide, keeping + // the work linear in the run length rather than quadratic. The attempt already + // in flight is allowed to overshoot the remaining budget, deliberately: that is + // what guarantees a genuine long glue is never truncated. + it('keeps segment inspection linear in the run length per transcript turn', () => { + const pendingCount = 96 + const turnCount = 12 + const text = `${'a '.repeat(pendingCount)}x` + const messages = [ + assistantTurn('tail', 'ready', 1000), + ...Array.from({ length: turnCount }, (_, index) => userTurn(`m${index}`, text, 2000 + index)) + ] + const pending = Array.from({ length: pendingCount }, (_, index) => + pendingSend(`p${index}`, 'a', 'tail', index + 1) + ) + const startsWith = vi.spyOn(String.prototype, 'startsWith') + let segmentChecks = 0 + try { + expect(retiredIds(messages, pending)).toEqual([]) + segmentChecks = startsWith.mock.calls.length + } finally { + startsWith.mockRestore() + } + expect(segmentChecks).toBeLessThanOrEqual(turnCount * (2 * pendingCount + GLUE_SLIDE_BUDGET)) + }) +}) + +describe('retireLandedMobileNativeChatPending', () => { + it('retires exact landings by ordinal and glued rows in the same pass', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'standalone', 4000), + userTurn('m3', 'run the tests again', 5000) + ] + const pending = [ + pendingSend('p0', 'standalone', 'm1'), + pendingSend('p1', 'run the tests', 'm2'), + pendingSend('p2', 'again', 'm2') + ] + expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([]) + }) + + // The drafts effect early-outs on `next === current`; a fresh array on the + // no-op path would re-enter it on every transcript frame. + it('returns the input array itself when nothing retires', () => { + const messages = [assistantTurn('m1', 'ready', 1000)] + const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'm1')] + expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toBe(pending) + }) + + it('keeps sends whose glue candidate predates them', () => { + const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] + const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')] + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1', 'p2']) + }) + + it('does not glue former neighbors across an exact-landed send', () => { + const messages = [ + assistantTurn('m0', 'ready', 1000), + userTurn('m1', 'middle', 2000), + userTurn('m2', 'one two', 3000) + ] + const pending = [ + pendingSend('p1', 'one', 'm0'), + pendingSend('p2', 'middle', 'm0'), + pendingSend('p3', 'two', 'm0') + ] + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1', 'p3']) + }) + + it('keeps image echoes until their preview is rebound', () => { + const messages = [assistantTurn('m1', 'ready', 1000)] + const pending = [{ ...pendingSend('p1', 'photo', 'm1'), images: ['file:///a.png'] }] + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1']) + expect(retireLandedMobileNativeChatPending(messages, pending, new Set(['p1']))).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts new file mode 100644 index 00000000000..ffdde70ddd4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -0,0 +1,173 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + countImageSourceTurnsAfter, + normalizeReconcileText, + normalizedUserText +} from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' + +const SPACE = ' ' +const NO_PENDING_IDS: ReadonlySet = new Set() +// Slack the cursor slide may spend re-trying later start positions, on top of +// one free pass over the run. Nothing bounds how many sends accumulate on the +// agent's input line — that ends when the agent accepts input again — so the +// budget must never truncate a genuine glue: the first attempt covers the whole +// run and always fits. It only stops a run of identical prefix-matching sends +// from making the scan quadratic. +export const GLUE_SLIDE_BUDGET = 8 + +type UserTurn = { index: number; text: string } +type GlueSegment = { text: string; tail: number } | null + +/** Pending ids represented by post-send transcript rows that glued adjacent sends. */ +export function selectGluedPendingIds( + messages: readonly NativeChatMessage[], + pending: readonly MobileNativeChatPendingMessage[], + excludedPendingIds: ReadonlySet = NO_PENDING_IDS +): ReadonlySet { + const retired = new Set() + if (pending.length < 2) { + return retired + } + const messageIndexById = new Map() + const turns: UserTurn[] = [] + for (const [index, message] of messages.entries()) { + messageIndexById.set(message.id, index) + const text = normalizedUserText(message) + if (text) { + turns.push({ index, text }) + } + } + const segments: GlueSegment[] = pending.map((item) => { + const text = normalizeReconcileText(item.text) + const tail = + item.baselineTailMessageId === null + ? -1 + : (messageIndexById.get(item.baselineTailMessageId) ?? null) + return excludedPendingIds.has(item.id) || + !item.baselineResolved || + item.images?.length || + text === '' || + tail === null + ? null + : { text, tail } + }) + + // Barriers preserve original adjacency after exact landings retire. + let runStart = 0 + while (runStart < pending.length) { + while (runStart < pending.length && segments[runStart] === null) { + runStart += 1 + } + let runEnd = runStart + while (runEnd < pending.length && segments[runEnd] !== null) { + runEnd += 1 + } + let cursor = runStart + for (const turn of turns) { + if (cursor >= runEnd - 1) { + break + } + // A send that can never match must not freeze the run behind it. One + // permanently unretirable head — a pair whose own glued row arrived with + // the read, or a send the count pass claimed against an older row — would + // otherwise disable glue retirement for every later pair, for the rest of + // the session. Slide past it; the cursor stays monotonic, so a later turn + // can never claim a send an earlier one already took. + let budget = runEnd - runStart + GLUE_SLIDE_BUDGET + let start = cursor + let matched = 0 + for (; start <= runEnd - 2 && budget > 0; start++) { + const attempt = matchGluedRun(turn, segments, start, runEnd) + budget -= attempt.inspected + matched = attempt.matched + if (matched > 0) { + break + } + } + if (matched === 0) { + continue + } + for (let index = start; index < start + matched; index++) { + retired.add(pending[index]!.id) + } + cursor = start + matched + } + runStart = runEnd + 1 + } + return retired +} + +/** Length of the exact glued run at `start`, plus the segments it had to read. */ +function matchGluedRun( + turn: UserTurn, + segments: readonly GlueSegment[], + start: number, + end: number +): { matched: number; inspected: number } { + let at = 0 + let matched = 0 + let inspected = 0 + for (let index = start; index < end; index++) { + const segment = segments[index]! + inspected += 1 + // Every send carries its OWN boundary: a row that already existed when this + // send was issued can never be part of its echo, however well it reads. + if (turn.index <= segment.tail) { + return { matched: 0, inspected } + } + if (at > 0 && turn.text[at] === SPACE) { + at += 1 + } + if (!turn.text.startsWith(segment.text, at)) { + return { matched: 0, inspected } + } + at += segment.text.length + matched += 1 + if (at === turn.text.length) { + // A lone exact match is an ordinary landing, which the count pass owns. + return { matched: matched > 1 ? matched : 0, inspected } + } + } + return { matched: 0, inspected } +} + +/** Retires exact and glued transcript landings while preserving pending order. */ +export function retireLandedMobileNativeChatPending( + messages: readonly NativeChatMessage[], + current: MobileNativeChatPendingMessage[], + landedImagePendingIds: ReadonlySet +): MobileNativeChatPendingMessage[] { + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + const landedPendingIds = new Set() + for (const item of current) { + if (landedImagePendingIds.has(item.id)) { + landedPendingIds.add(item.id) + continue + } + // Keep image echoes until their local preview reaches the authoritative message. + // An unresolved baseline has nothing to count against yet — `messages` is not + // known to be the transcript this send was issued into. + if (item.images?.length || !item.baselineResolved) { + continue + } + const landed = + item.text.trim() === '' + ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= + item.expectedOccurrence + : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence + if (landed) { + landedPendingIds.add(item.id) + } + } + const glued = selectGluedPendingIds(messages, current, landedPendingIds) + return landedPendingIds.size === 0 && glued.size === 0 + ? current + : current.filter((item) => !landedPendingIds.has(item.id) && !glued.has(item.id)) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index c04b560798d..a6308d08be5 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -96,7 +96,8 @@ const ORIGIN = { pendingKey: 'h\0w\0tab-1\0session-1', normalizedText: 'look', baselineOccurrences: 0, - baselineTailMessageId: null + baselineTailMessageId: null, + baselineResolved: true } describe('useMobileNativeChatController handleNativeChatSend', () => { diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 46012dadbe4..27ad8049c1d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -111,7 +111,8 @@ export function useMobileNativeChatController(args: { // a null is indistinguishable from a host retraction, and peeking at the // terminal view would permanently decline the prefill. chatActive: showNativeChat, - transcriptLoading: nativeChatSession.transcriptLoading + transcriptLoading: nativeChatSession.transcriptLoading, + transcriptSettled: nativeChatSession.status === 'ready' }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts new file mode 100644 index 00000000000..b5d1f92ec82 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -0,0 +1,359 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildMobileNativeChatTransientData } from './mobile-native-chat-render-data' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType +type TestRenderer = { + unmount(): void + update(element: ReturnType): void +} + +function userTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +function imageTurn(id: string, path: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text: `[Image: source: ${path}]` }], + timestamp, + source: 'transcript' + } +} + +/** Texts of the optimistic bubbles the chat list actually renders. */ +function renderedPendingTexts( + messages: NativeChatMessage[], + state: DraftState | null +): (string | undefined)[] { + const { data } = buildMobileNativeChatTransientData({ + folded: messages, + streaming: null, + pending: state?.pending ?? [] + }) + return data + .filter((message) => !messages.some((existing) => existing.id === message.id)) + .map((message) => message.blocks.find((block) => block.type === 'text')?.text) +} + +describe('useMobileNativeChatDrafts glued pending sends', () => { + let renderer: TestRenderer | null = null + let state: DraftState | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + messages, + transcriptLoading = false, + transcriptSettled = !transcriptLoading + }: { + messages: NativeChatMessage[] + transcriptLoading?: boolean + transcriptSettled?: boolean + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages, + transcriptLoading, + transcriptSettled + }) + return null + } + + async function mount( + messages: NativeChatMessage[], + transcriptLoading = false, + transcriptSettled = !transcriptLoading + ): Promise { + await act(async () => { + renderer = create(createElement(Harness, { messages, transcriptLoading, transcriptSettled })) + }) + } + + async function update( + messages: NativeChatMessage[], + transcriptLoading = false, + transcriptSettled = !transcriptLoading + ): Promise { + await act(async () => + renderer?.update(createElement(Harness, { messages, transcriptLoading, transcriptSettled })) + ) + } + + function send(text: string): void { + const origin = state?.captureSendOrigin(text) + if (origin) { + state?.acceptSend(origin, text) + } + } + + /** Two composer sends fired before either transcript echo lands. */ + function rapidSend(first: string, second: string): void { + act(() => { + send(first) + send(second) + }) + } + + const pendingTexts = (): (string | undefined)[] | undefined => + state?.pending.map((item) => item.text) + + it('retires both bubbles when the rapid sends land as one glued user row', async () => { + const history = [assistantTurn('m1', 'ready', 1000)] + await mount(history) + rapidSend('run the tests', 'again') + expect(pendingTexts()).toEqual(['run the tests', 'again']) + + const glued = [...history, userTurn('m2', 'run the tests again', 5000)] + await update(glued) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(glued, state)).toEqual([]) + }) + + // FP1: the concatenation already sits in history, older than the sends. + it('keeps and renders both bubbles when the matching row predates the sends', async () => { + const history = [ + userTurn('m1', 'run the tests again', 1000), + assistantTurn('m2', 'done', 2000), + assistantTurn('m3', 'anything else?', 3000) + ] + await mount(history) + rapidSend('run the tests', 'again') + + await update([...history]) + expect(pendingTexts()).toEqual(['run the tests', 'again']) + expect(renderedPendingTexts(history, state)).toEqual(['run the tests', 'again']) + }) + + // FP2: an older turn reads like the concatenation of two much later sends. + it('keeps and renders both bubbles when an older turn spells them out', async () => { + const history = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] + await mount(history) + rapidSend('fix the', 'bug') + + await update([...history]) + expect(pendingTexts()).toEqual(['fix the', 'bug']) + expect(renderedPendingTexts(history, state)).toEqual(['fix the', 'bug']) + }) + + it('still retires each bubble on its own when the sends do not glue', async () => { + const history = [assistantTurn('m1', 'ready', 1000)] + await mount(history) + rapidSend('run the tests', 'again') + + const separate = [ + ...history, + userTurn('m2', 'run the tests', 5000), + userTurn('m3', 'again', 5100) + ] + await update(separate) + expect(state?.pending).toEqual([]) + }) + + it('keeps the second bubble when only the first send has echoed', async () => { + const history = [assistantTurn('m1', 'ready', 1000)] + await mount(history) + rapidSend('run the tests', 'again') + + const partial = [...history, userTurn('m2', 'run the tests', 5000)] + await update(partial) + expect(pendingTexts()).toEqual(['again']) + expect(renderedPendingTexts(partial, state)).toEqual(['again']) + }) + + // STA-4492 / #14819: sends issued mid-hydration used to be marked permanently + // untrusted, so their glued row could never retire them and they also barred + // their neighbours from matching. The first authoritative read rebases them + // instead: history that arrives with it is still not theirs, but everything + // after it is. + describe('sends issued while the transcript is still hydrating', () => { + const loadedHistory = [userTurn('m1', 'run the tests again', 1000)] + + it('keeps both bubbles when the loaded history only looks like their glue', async () => { + await mount([], true) + rapidSend('run the tests', 'again') + + await update(loadedHistory) + expect(pendingTexts()).toEqual(['run the tests', 'again']) + expect(renderedPendingTexts(loadedHistory, state)).toEqual(['run the tests', 'again']) + }) + + it('retires both bubbles once their glued row lands after hydration', async () => { + await mount([], true) + rapidSend('run the tests', 'again') + await update(loadedHistory) + + const glued = [...loadedHistory, userTurn('m2', 'run the tests again', 5000)] + await update(glued) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(glued, state)).toEqual([]) + }) + + it('retires a hydration-time send on its own exact echo', async () => { + await mount([], true) + act(() => send('run the tests')) + await update(loadedHistory) + expect(pendingTexts()).toEqual(['run the tests']) + + const echoed = [...loadedHistory, userTurn('m2', 'run the tests', 5000)] + await update(echoed) + expect(state?.pending).toEqual([]) + }) + + it('stops barring a later pair from gluing once its own echo lands', async () => { + await mount([], true) + act(() => send('hold this')) + await update(loadedHistory) + rapidSend('fix the', 'bug') + + const landed = [ + ...loadedHistory, + userTurn('m2', 'hold this', 5000), + userTurn('m3', 'fix the bug', 5100) + ] + await update(landed) + expect(state?.pending).toEqual([]) + }) + + // The read that resolves a hydration-time send can already contain that + // send's own echo — a re-subscribe returns whatever exists now — and nothing + // local tells that echo from an identical older prompt. Claiming the row is + // the lesser evil, not a free one: normally the real echo lands a round trip + // later, but a send that never landed and was claimed by an older identical + // row stops being tracked at all — worst for short repeats ("yes", "1"). + // Holding instead costs a queued bubble that never clears AND a run that can + // never glue again, for the rest of the session. + it('retires a hydration-time send whose echo arrives with the read', async () => { + await mount([], true) + act(() => send('run the tests')) + + const echoed = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)] + await update(echoed) + expect(state?.pending).toEqual([]) + }) + + // The same read seen one reconnect later: the send left, the agent echoed it, + // and only then did the first authoritative read land. + it('retires a hydration-time send after a reconnect delivered the read late', async () => { + await mount([], true) + act(() => send('run the tests')) + // Still loading: the first read never arrived before the drop. + await update([], true) + + const afterReconnect = [ + userTurn('m1', 'run the tests', 5000), + assistantTurn('m2', 'ok', 5100) + ] + await update(afterReconnect) + expect(state?.pending).toEqual([]) + + // Nothing later resurrects it, and the run stays glue-capable. + await update([...afterReconnect, assistantTurn('m3', 'all green', 6000)]) + expect(state?.pending).toEqual([]) + }) + + // A reconnect leaves this session's own retained history on screen, so sends + // made across it own a real boundary already. Moving it onto the read that + // follows would push it past their own glued row and strand both bubbles. + it('retires a pair sent across a reconnect whose glued row arrives with the read', async () => { + const retained = [userTurn('m1', 'earlier prompt', 1000), assistantTurn('m2', 'sure', 1100)] + await mount(retained) + // The read drops; the retained history stays visible and is still ours. + await update(retained, true) + rapidSend('run the tests', 'again') + + const settled = [...retained, userTurn('m3', 'run the tests again', 5000)] + await update(settled) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(settled, state)).toEqual([]) + }) + + // A send held here would sit as a live segment at the head of its run, so + // the cursor could never reach a later pair — the stuck bubble would take + // the whole feature down with it for the rest of the session. + // An image entry retires ONLY by binding its preview, so a supplied tail + // that excluded its own echo would leave the bubble — and the photo — stuck + // for the life of the session. + it('retires a photo whose image echo arrives with the read', async () => { + await mount([], true) + act(() => { + const origin = state?.captureSendOrigin('') + if (origin) { + state?.acceptSend(origin, '', ['file:///new.png']) + } + }) + + const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)] + await update(withEcho) + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] }) + }) + + it('leaves a later pair able to glue after the late read resolved the first send', async () => { + await mount([], true) + act(() => send('run the tests')) + const afterReconnect = [userTurn('m1', 'run the tests', 5000)] + await update(afterReconnect) + expect(state?.pending).toEqual([]) + + rapidSend('fix the', 'bug') + const glued = [...afterReconnect, userTurn('m2', 'fix the bug', 6000)] + await update(glued) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(glued, state)).toEqual([]) + }) + }) + + // A read that failed is not a boundary: `messages` is empty because the history + // is unknown, not because the conversation is. Without that distinction the + // empty list reads as "the transcript was empty", and any row the successful + // read finally brings can claim these sends. + describe('sends issued before any read settled', () => { + it('keeps both bubbles when the late read carries only an older glue-alike', async () => { + await mount([], false, false) + rapidSend('fix the', 'bug') + + const olderIdentical = [ + userTurn('m1', 'fix the bug', 1000), + assistantTurn('m2', 'fixed', 1100) + ] + await update(olderIdentical) + expect(pendingTexts()).toEqual(['fix the', 'bug']) + expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug']) + }) + + it('still retires them once their own glued row lands', async () => { + await mount([], false, false) + rapidSend('fix the', 'bug') + const olderIdentical = [ + userTurn('m1', 'fix the bug', 1000), + assistantTurn('m2', 'fixed', 1100) + ] + await update(olderIdentical) + + await update([...olderIdentical, userTurn('m3', 'fix the bug', 5000)]) + expect(state?.pending).toEqual([]) + }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 665d3ac1f2f..0c8d3bf674a 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -36,7 +36,8 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraft = null, launchDraftCreatedAt = null, chatActive = true, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { tabId: string sessionId?: string | null @@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraftCreatedAt?: number | null chatActive?: boolean transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -55,7 +57,8 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraft, launchDraftCreatedAt, chatActive, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index daf451e53f9..2a5ff2d54c6 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -42,7 +42,8 @@ describe('useMobileNativeChatDrafts', () => { messages = [], launchDraft = null, chatActive = true, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { tabId: string sessionId?: string | null @@ -50,6 +51,7 @@ describe('useMobileNativeChatDrafts', () => { launchDraft?: string | null chatActive?: boolean transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -59,7 +61,8 @@ describe('useMobileNativeChatDrafts', () => { messages, launchDraft, chatActive, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 55fff5b42e3..784f025847b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,16 +1,16 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { - countImageSourceTurnsAfter, countUserTextOccurrences, findLandedImagePreviewEchoes, findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, normalizeReconcileText, - normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' import { appendMobileNativeChatPending, combineMobileNativeChatPending, @@ -47,6 +47,10 @@ export function useMobileNativeChatDrafts(args: { * transcript still belongs to the previously active tab), so it cannot be * trusted to decline or retire the seed. */ transcriptLoading?: boolean + /** `messages` is this session's own settled history — so an empty one really + * is an empty conversation, not a read that failed or never ran. Only then + * does a send's captured tail describe a real boundary. */ + transcriptSettled: boolean }): { composerText: string setComposerText: Dispatch> @@ -80,7 +84,8 @@ export function useMobileNativeChatDrafts(args: { launchDraft, launchDraftCreatedAt, chatActive = true, - transcriptLoading + transcriptLoading, + transcriptSettled } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -138,10 +143,14 @@ export function useMobileNativeChatDrafts(args: { pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), - baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null + baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, + // Only a settled read makes this a boundary. Anything else — hydrating, + // or a read that failed — hands back an empty list that reads as "the + // conversation was empty", which lets any row claim this send later. + baselineResolved: transcriptSettled } }, - [draftKey, pendingKey] + [draftKey, pendingKey, transcriptSettled] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -282,7 +291,16 @@ export function useMobileNativeChatDrafts(args: { if (pending.length === 0) { return } - const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + // Only judge a send against a read known to be this session's. Note this + // does NOT give an image echo a boundary — the rebase deliberately leaves + // those on whatever they captured — so a caption-less photo sent before any + // read settled can still claim an older photo turn, exactly as it does on + // main. Fixing that needs a tail that excludes older image turns without + // excluding the send's own echo, which is a separate change. + const landedImagePreviews = findLandedImagePreviewEchoes( + messages, + pending.filter((item) => item.baselineResolved) + ) const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) if (landedImagePreviews.length > 0) { setImagePreviewsBySession((previous) => @@ -291,28 +309,13 @@ export function useMobileNativeChatDrafts(args: { } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] - const landedCounts = new Map() - for (const message of messages) { - const text = normalizedUserText(message) - if (text) { - landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) - } - } - // Image-only source-turn counts stay stable across reruns and ignore paginated history. - const next = current.filter((item) => { - if (landedImagePendingIds.has(item.id)) { - return false - } - // Keep image echoes until their local preview reaches the authoritative message. - if (item.images?.length) { - return true - } - return item.text.trim() === '' - ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence - : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) < item.expectedOccurrence - }) - if (next.length === current.length) { + // Rebase before retiring: a send captured before the history was known has + // to own a real boundary before any row can be judged against it. + const rebased = transcriptSettled + ? rebaseMobileNativeChatPendingBaselines(messages, current) + : current + const next = retireLandedMobileNativeChatPending(messages, rebased, landedImagePendingIds) + if (next === current) { return previous } if (next.length > 0) { @@ -322,7 +325,7 @@ export function useMobileNativeChatDrafts(args: { delete remaining[pendingKey] return remaining }) - }, [messages, pending, pendingKey]) + }, [messages, pending, pendingKey, transcriptSettled]) return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index dd67cf46da8..e22c601df31 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -4,6 +4,8 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// Type-only, so it is erased before the `./mobile-native-chat-send` mock below applies. +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() @@ -36,6 +38,9 @@ describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null const acceptSend = vi.fn() + const captureSendOrigin = vi.fn(() => ({ draftKey: 'k', pendingKey: 'p' }) as never) + const clearDraftForSend = vi.fn() + const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() const onCommandSend = vi.fn() const commandSendRef = { current: onCommandSend } @@ -55,10 +60,10 @@ describe('useMobileNativeChatMessageSend', () => { deviceTokenRef: { current: 'device' }, agentRef, commandSendRef, - captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, + captureSendOrigin, readSeededLaunchDraftSeed, - clearDraftForSend: () => {}, - restoreRejectedDraft: () => {}, + clearDraftForSend, + restoreRejectedDraft, acceptSend, holdUnconfirmedSend, onSendError @@ -71,10 +76,12 @@ describe('useMobileNativeChatMessageSend', () => { } const sentArgs = (): { + text?: string clearInputFirst?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } => sendWithOutcome.mock.calls[0]![0] as { + text?: string clearInputFirst?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } @@ -89,6 +96,9 @@ describe('useMobileNativeChatMessageSend', () => { typeCommandWithOutcome.mockReset() typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() + captureSendOrigin.mockClear() + clearDraftForSend.mockReset() + restoreRejectedDraft.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() commandSendRef.current = onCommandSend @@ -381,4 +391,53 @@ describe('useMobileNativeChatMessageSend', () => { expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true) releaseMobileNativeChatTerminalWrite('term') }) + + // The host writes these bytes verbatim, so whitespace the match key drops must + // not reach the agent's input line and glue the next rapid send onto it (#14262). + it('trims trailing whitespace without changing leading prompt content', async () => { + mount(() => null) + await act(async () => { + await api!.send(' run the tests \n') + }) + expect(sentArgs().text).toBe(' run the tests') + expect(captureSendOrigin).toHaveBeenCalledWith(' run the tests') + expect(acceptSend.mock.calls[0]![1]).toBe(' run the tests') + }) + + it('trims trailing whitespace on a question answer too', async () => { + mount(() => null) + await act(async () => { + await api!.answerQuestion('\n 1 ') + }) + expect(sentArgs().text).toBe('\n 1') + }) + + // #14819: the trim is a wire concern only. A rejected send hands the composer + // back to the user, and it has to be the draft they typed, blank lines included. + it('restores the untrimmed draft when the send is rejected', async () => { + const draft = 'first line\n\nsecond line\n\n' + sendWithOutcome.mockResolvedValue('rejected') + mount(() => null) + + await act(async () => { + await api!.send(draft) + }) + + expect(sentArgs().text).toBe('first line\n\nsecond line') + expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft) + expect(clearDraftForSend.mock.calls[0]![1]).toBe(draft) + }) + + it('restores the untrimmed draft when the launch-draft pre-clear is rejected', async () => { + const draft = 'ship it ' + clearInputWrite.mockResolvedValue(false) + mount(() => ({ text: DRAFT, createdAt: 1 })) + + await act(async () => { + await api!.send(draft) + }) + + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 17f34b86d27..53b914657a3 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -85,12 +85,17 @@ export function useMobileNativeChatMessageSend(args: { const sendMessage = useCallback( async ( - text: string, + draftText: string, images: string[] | undefined, syncComposer: boolean, recordControlSend: boolean, sharedDeadline?: number ): Promise => { + // The host writes trailing whitespace verbatim onto the agent's input line, + // where it can glue the next rapid send onto this one (#14262). Only the + // bytes that go out are trimmed: `draftText` is what the user typed, and a + // rejected send has to put back exactly that (#14819). + const text = draftText.trimEnd() const handle = handleRef.current const origin = captureSendOrigin(text) const agent = agentRef.current @@ -124,7 +129,7 @@ export function useMobileNativeChatMessageSend(args: { // round trip is visible, and a lost ack must not strand the sent prompt // in the box. Only a definite rejection puts the text back. if (syncComposer) { - clearDraftForSend(origin, text) + clearDraftForSend(origin, draftText) } // Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears // only one logical line. Size the clear to the text Orca injected, with @@ -150,7 +155,7 @@ export function useMobileNativeChatMessageSend(args: { }) if (!cleared) { if (syncComposer) { - restoreRejectedDraft(origin, text) + restoreRejectedDraft(origin, draftText) } onSendError('Message not sent') return 'rejected' @@ -204,7 +209,7 @@ export function useMobileNativeChatMessageSend(args: { } if (outcome === 'rejected') { if (syncComposer) { - restoreRejectedDraft(origin, text) + restoreRejectedDraft(origin, draftText) } onSendError('Message not sent') return 'rejected' From 1aa51f6914372a4a09d7005d6f58c8b62fa719b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:20:31 -0700 Subject: [PATCH 008/109] fix(computer): preserve accessibility value types (#15031) * fix(computer): preserve AX value types * fix(computer): preserve exact integer values * fix(computer): preserve exact integer exponent forms --- .../Sources/OrcaComputerUseMacOS/main.swift | 40 ++++- .../AttributeValueCoercion.swift | 165 ++++++++++++++++++ .../AttributeValueCoercionTests.swift | 146 ++++++++++++++++ src/shared/computer-use-runtime-types.ts | 1 + 4 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AttributeValueCoercion.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 82a39489a6f..6e1d92adcce 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -810,14 +810,31 @@ final class Provider { guard isSettable(record.element, kAXValueAttribute as String) else { throw ProviderError.coded("value_not_settable", "element \(record.index) is not settable") } - let result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, expected as CFString) + let current = rawAttributeValue(record.element, kAXValueAttribute as String) + let coercion = AttributeValueCoercion(existingValue: current, requested: expected) + let result: AXError + switch coercion.writeValue { + case .string: + result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, expected as CFString) + case let .integer(value): + result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, NSNumber(value: value)) + case let .double(value): + result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, NSNumber(value: value)) + case let .boolean(value): + result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, value ? kCFBooleanTrue : kCFBooleanFalse) + } guard result == .success else { throw ProviderError.coded("accessibility_error", "AXUIElementSetAttributeValue failed with \(result.rawValue)") } - let actual = rawStringAttribute(record.element, kAXValueAttribute as String) - let verification = actual == expected - ? verifiedAction(property: "value", expected: expected, actualPreview: actual) - : unverifiedAction(reason: actual == nil ? "provider_unavailable" : "value_mismatch", expected: expected, actualPreview: actual) + let verification: [String: Any] + switch coercion.compare(readback: rawAttributeValue(record.element, kAXValueAttribute as String)) { + case let .match(actualPreview): + verification = verifiedAction(property: "value", expected: expected, actualPreview: actualPreview) + case let .mismatch(actualPreview): + verification = unverifiedAction(reason: "value_mismatch", expected: expected, actualPreview: actualPreview) + case .unsupported: + verification = unverifiedAction(reason: "readback_unsupported", expected: expected) + } return actionMetadata(path: "accessibility", actionName: "AXSetValue", verification: verification) } @@ -1495,16 +1512,21 @@ private func stringAttribute(_ element: AXUIElement, _ attribute: String) -> Str } private func rawStringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, - let value, - CFGetTypeID(value) == CFStringGetTypeID() + guard let value = rawAttributeValue(element, attribute), CFGetTypeID(value) == CFStringGetTypeID() else { return nil } return value as? String } +private func rawAttributeValue(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { + return nil + } + return value +} + private func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? { var value: CFTypeRef? guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AttributeValueCoercion.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AttributeValueCoercion.swift new file mode 100644 index 00000000000..974e6b99d73 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AttributeValueCoercion.swift @@ -0,0 +1,165 @@ +import CoreFoundation +import Foundation + +public struct AttributeValueCoercion: Equatable { + public enum Value: Equatable { + case string(String) + case integer(Int64) + case double(Double) + case boolean(Bool) + + public var preview: String { + switch self { + case let .string(value): + return value + case let .integer(value): + return String(value) + case let .double(value): + return String(value) + case let .boolean(value): + return String(value) + } + } + } + + public enum ReadbackComparison: Equatable { + case match(actualPreview: String) + case mismatch(actualPreview: String) + case unsupported + } + + public let writeValue: Value + + public init(existingValue: CFTypeRef?, requested: String) { + guard let existingValue, let current = Self.decode(existingValue) else { + writeValue = .string(requested) + return + } + + writeValue = Self.coerce(requested, matching: current) ?? .string(requested) + } + + public func compare(readback: CFTypeRef?) -> ReadbackComparison { + guard let readback, let actual = Self.decode(readback) else { + return .unsupported + } + return actual == writeValue + ? .match(actualPreview: actual.preview) + : .mismatch(actualPreview: actual.preview) + } + + private static func coerce(_ requested: String, matching current: Value) -> Value? { + let trimmed = requested.trimmingCharacters(in: .whitespacesAndNewlines) + switch current { + case .string: + return .string(requested) + case .integer: + return parseInteger(trimmed).map(Value.integer) + case .double: + guard let value = Double(trimmed), value.isFinite else { return nil } + return .double(value) + case .boolean: + switch trimmed.lowercased() { + case "true", "1": + return .boolean(true) + case "false", "0": + return .boolean(false) + default: + return nil + } + } + } + + private static func parseInteger(_ value: String) -> Int64? { + if let integer = Int64(value) { + return integer + } + + var unsigned = value[...] + let isNegative = unsigned.first == "-" + if isNegative || unsigned.first == "+" { + unsigned = unsigned.dropFirst() + } + + let exponentIndex = unsigned.firstIndex { $0 == "e" || $0 == "E" } + let significand = exponentIndex.map { unsigned[..<$0] } ?? unsigned + let exponentText = exponentIndex.map { unsigned[unsigned.index(after: $0)...] } + guard exponentText?.contains(where: { $0 == "e" || $0 == "E" }) != true else { return nil } + + let exponent: Int? + if let exponentText { + let digits = exponentText.first == "+" || exponentText.first == "-" + ? exponentText.dropFirst() + : exponentText + guard !digits.isEmpty, digits.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { + return nil + } + exponent = Int(exponentText) + } else { + exponent = 0 + } + + let parts = significand.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count <= 2 else { return nil } + let whole = parts[0] + let fraction = parts.count == 2 ? parts[1] : Substring() + guard !whole.isEmpty || !fraction.isEmpty, + whole.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), + fraction.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) + else { + return nil + } + + let digits = whole + fraction + let significant = digits.drop(while: { $0 == "0" }) + guard !significant.isEmpty else { return 0 } + guard let exponent else { return nil } + + let trailingZeroCount = significant.reversed().prefix(while: { $0 == "0" }).count + let normalized: String + if exponent >= fraction.count { + let zeroCount = exponent - fraction.count + guard significant.count <= 19, zeroCount <= 19 - significant.count else { return nil } + normalized = String(significant) + String(repeating: "0", count: zeroCount) + } else { + let removedCount: Int + if exponent >= 0 { + removedCount = fraction.count - exponent + } else { + guard fraction.count <= trailingZeroCount, + exponent.magnitude <= UInt(trailingZeroCount - fraction.count) + else { + return nil + } + removedCount = fraction.count + Int(exponent.magnitude) + } + guard removedCount <= trailingZeroCount else { return nil } + normalized = String(significant.dropLast(removedCount)) + } + + return Int64(isNegative ? "-\(normalized)" : normalized) + } + + private static func decode(_ value: CFTypeRef) -> Value? { + let typeID = CFGetTypeID(value) + if typeID == CFStringGetTypeID() { + return (value as? String).map(Value.string) + } + if typeID == CFBooleanGetTypeID() { + return (value as? Bool).map(Value.boolean) + } + guard typeID == CFNumberGetTypeID() else { return nil } + + guard let bridgedNumber = value as? NSNumber else { return nil } + let number = bridgedNumber as CFNumber + if CFNumberIsFloatType(number) { + var decoded = 0.0 + guard CFNumberGetValue(number, .doubleType, &decoded), decoded.isFinite else { return nil } + return .double(decoded) + } + + var decoded: Int64 = 0 + guard CFNumberGetValue(number, .sInt64Type, &decoded) else { return nil } + return .integer(decoded) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift new file mode 100644 index 00000000000..9ea18666579 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift @@ -0,0 +1,146 @@ +import CoreFoundation +import Foundation +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class AttributeValueCoercionTests: XCTestCase { + func testStringValueRemainsAStringWithoutTrimming() { + let coercion = AttributeValueCoercion(existingValue: "old" as CFString, requested: " new ") + + XCTAssertEqual(coercion.writeValue, .string(" new ")) + } + + func testIntegerValuePreservesIntegerKind() { + let coercion = AttributeValueCoercion(existingValue: NSNumber(value: Int32(2)), requested: "3.0") + + XCTAssertEqual(coercion.writeValue, .integer(3)) + } + + func testIntegerDecimalConversionIsExactAboveDoublePrecision() { + let coercion = AttributeValueCoercion( + existingValue: NSNumber(value: Int64(1)), + requested: "9007199254740993.0" + ) + + XCTAssertEqual(coercion.writeValue, .integer(9_007_199_254_740_993)) + XCTAssertEqual( + coercion.compare(readback: NSNumber(value: Int64(9_007_199_254_740_992))), + .mismatch(actualPreview: "9007199254740992") + ) + } + + func testIntegerScientificNotationIsParsedExactly() { + let cases: [(String, Int64)] = [ + ("1e3", 1_000), + ("9.007199254740993e15", 9_007_199_254_740_993), + ("10.0e-1", 1), + ("+0.000E999999999999999999999", 0), + ("-.0e-999999999999999999999", 0), + ] + + for (requested, expected) in cases { + XCTAssertEqual( + AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: requested).writeValue, + .integer(expected) + ) + } + } + + func testIntegerParsingPreservesInt64Boundaries() { + XCTAssertEqual( + AttributeValueCoercion( + existingValue: NSNumber(value: 1), + requested: "9223372036854775807.0" + ).writeValue, + .integer(.max) + ) + XCTAssertEqual( + AttributeValueCoercion( + existingValue: NSNumber(value: 1), + requested: "-9.223372036854775808e18" + ).writeValue, + .integer(.min) + ) + } + + func testNonIntegralAndOutOfRangeIntegerFormsUseStringFallback() { + for requested in ["1e-1", "9223372036854775808", "-9223372036854775809", "1e999999999999999999999"] { + XCTAssertEqual( + AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: requested).writeValue, + .string(requested) + ) + } + } + + func testDoubleValuePreservesDoubleKindForIntegralInput() { + let coercion = AttributeValueCoercion(existingValue: NSNumber(value: 2.5), requested: "3") + + XCTAssertEqual(coercion.writeValue, .double(3.0)) + } + + func testBooleanValueAcceptsWordsAndNumericAliases() { + XCTAssertEqual( + AttributeValueCoercion(existingValue: kCFBooleanFalse, requested: "TRUE").writeValue, + .boolean(true) + ) + XCTAssertEqual( + AttributeValueCoercion(existingValue: kCFBooleanTrue, requested: "0").writeValue, + .boolean(false) + ) + } + + func testUnreadableAndUnhandledValuesUseStringFallback() { + XCTAssertEqual( + AttributeValueCoercion(existingValue: nil, requested: "7").writeValue, + .string("7") + ) + XCTAssertEqual( + AttributeValueCoercion(existingValue: [1, 2] as CFArray, requested: "7").writeValue, + .string("7") + ) + } + + func testInvalidHandledValuesUseStringFallback() { + XCTAssertEqual( + AttributeValueCoercion(existingValue: NSNumber(value: 2), requested: "2.5").writeValue, + .string("2.5") + ) + XCTAssertEqual( + AttributeValueCoercion(existingValue: NSNumber(value: 2.5), requested: "many").writeValue, + .string("many") + ) + XCTAssertEqual( + AttributeValueCoercion(existingValue: kCFBooleanFalse, requested: "maybe").writeValue, + .string("maybe") + ) + } + + func testReadbackMatchesEachSupportedType() { + let cases: [(AttributeValueCoercion, CFTypeRef, String)] = [ + (AttributeValueCoercion(existingValue: "old" as CFString, requested: "new"), "new" as CFString, "new"), + (AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: "2"), NSNumber(value: 2), "2"), + (AttributeValueCoercion(existingValue: NSNumber(value: 1.5), requested: "2.5"), NSNumber(value: 2.5), "2.5"), + (AttributeValueCoercion(existingValue: kCFBooleanFalse, requested: "true"), kCFBooleanTrue, "true"), + ] + + for (coercion, readback, preview) in cases { + XCTAssertEqual(coercion.compare(readback: readback), .match(actualPreview: preview)) + } + } + + func testSupportedMismatchedReadbackIncludesActualPreview() { + let coercion = AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: "2") + + XCTAssertEqual( + coercion.compare(readback: NSNumber(value: 3)), + .mismatch(actualPreview: "3") + ) + } + + func testUnreadableAndUnhandledReadbackAreUnsupported() { + let coercion = AttributeValueCoercion(existingValue: "old" as CFString, requested: "new") + + XCTAssertEqual(coercion.compare(readback: nil), .unsupported) + XCTAssertEqual(coercion.compare(readback: ["new"] as CFArray), .unsupported) + } +} diff --git a/src/shared/computer-use-runtime-types.ts b/src/shared/computer-use-runtime-types.ts index fb847a0fbbb..f47826ea6fe 100644 --- a/src/shared/computer-use-runtime-types.ts +++ b/src/shared/computer-use-runtime-types.ts @@ -107,6 +107,7 @@ export type ComputerActionVerification = | 'synthetic_input' | 'clipboard_paste' | 'provider_unavailable' + | 'readback_unsupported' | 'window_changed' | 'value_mismatch' expected?: string | null From 7fad71e44842e7de293db3300f4a0eefb6040412 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:23:08 -0700 Subject: [PATCH 009/109] fix(worktree): skip retirement backfill on every non-local host (#15023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backfill guard tested repo.connectionId, but a runtime-owned repo carries executionHostId with no connectionId, so it read as local. The scan then walked this machine's workspace and agent-transcript directories and filed the result under the runtime host's namespace — retiring names never used there while missing the ones that were. Guard on the execution host id instead. Ongoing retirement was already correct for these repos; only the one-time historical seed was wrong. --- src/main/worktree-name-retirement.test.ts | 56 +++++++++++++++++++++++ src/main/worktree-name-retirement.ts | 11 +++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/main/worktree-name-retirement.test.ts b/src/main/worktree-name-retirement.test.ts index 8a0e8237caf..e7c1c249c56 100644 --- a/src/main/worktree-name-retirement.test.ts +++ b/src/main/worktree-name-retirement.test.ts @@ -336,4 +336,60 @@ describe('ensureRetiredWorktreeNamesBackfilled', () => { expect(merged).toEqual([]) }) + + it('skips a runtime-owned repo, which has no connectionId but is still not local', async () => { + // Why: a `connectionId` check calls this repo local, so the scan reads THIS machine's + // directories and files them under the runtime's namespace — retiring names never used there + // while missing the ones that were. The host id is the only reliable local test. + const root = await mkdtemp(join(tmpdir(), 'orca-retirement-runtime-')) + const workspaceRoot = join(root, 'workspaces') + await mkdir(join(workspaceRoot, FIRST), { recursive: true }) + const merged: string[] = [] + const store = { + mergeRetiredWorktreeNames: (_repoId: string, names: Iterable) => { + merged.push(...names) + return true + } + } + const runtimeRepo = { + ...makeRepo('repo-runtime', '/repos/runtime'), + executionHostId: 'runtime:env-1' + } as Repo + + try { + const collisionKey = await ensureRetiredWorktreeNamesBackfilled(store, runtimeRepo, { + workspaceDir: workspaceRoot, + nestWorkspaces: false + }) + + expect(merged).toEqual([]) + expect(collisionKey).toBeNull() + } finally { + await rm(root, { force: true, recursive: true }) + } + }) + + it('still backfills a plain local repo, so the skip is scoped to non-local hosts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-retirement-local-')) + const workspaceRoot = join(root, 'workspaces') + await mkdir(join(workspaceRoot, FIRST), { recursive: true }) + const merged: string[] = [] + const store = { + mergeRetiredWorktreeNames: (_repoId: string, names: Iterable) => { + merged.push(...names) + return true + } + } + + try { + await ensureRetiredWorktreeNamesBackfilled(store, makeRepo('repo-local', '/repos/local'), { + workspaceDir: workspaceRoot, + nestWorkspaces: false + }) + + expect(merged).toEqual([FIRST]) + } finally { + await rm(root, { force: true, recursive: true }) + } + }) }) diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts index 53ac593011d..dda9a2e0110 100644 --- a/src/main/worktree-name-retirement.ts +++ b/src/main/worktree-name-retirement.ts @@ -1,7 +1,7 @@ import { readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' -import { getRepoExecutionHostId } from '../shared/execution-host' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { creatureNameTier, EMPTY_RETIRED_NAME_REGISTRY, @@ -239,8 +239,13 @@ export async function ensureRetiredWorktreeNamesBackfilled( settings: RetirementPathSettings ): Promise { // Remote workspaces keep their agent state on the execution host, which this scan cannot see, so - // a re-added SSH repo does not recover its retirements the way a local one does. - if (isFolderRepo(repo) || repo.connectionId) { + // a re-added remote repo does not recover its retirements the way a local one does. + // + // Why the host id and not `connectionId`: a runtime-owned repo carries `executionHostId` with no + // `connectionId`, so a connectionId check calls it local, scans THIS machine's directories, and + // files the result under the runtime's namespace — retiring names never used there while missing + // the ones that were. + if (isFolderRepo(repo) || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { return null } const probePath = await computeWorktreePathAsync( From be07b43a2b7377b69bed04e0f39bc4cbb70593fc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:50:17 -0700 Subject: [PATCH 010/109] fix(orchestration): enforce honest recipient routing (#14964) --- ...rchestration-send-receipt-warnings.test.ts | 87 +++ src/cli/handlers/orchestration.ts | 36 +- src/main/runtime/orca-runtime.test.ts | 18 + src/main/runtime/orca-runtime.ts | 17 + .../orchestration-cli-subprocess.test.ts | 9 +- src/main/runtime/orchestration/db.ts | 2 + .../db/dispatch-context/dispatch-lookup.ts | 45 ++ .../db/messages/message-insert.ts | 52 +- .../runs/legacy-coordinator-mail-takeover.ts | 8 +- .../orchestration/db/runs/run-lookup.ts | 46 ++ .../message-batch-atomicity.test.ts | 29 + .../orchestration-legacy-storage-db.test.ts | 17 + src/main/runtime/rpc/errors.ts | 3 + .../rpc/methods/orchestration-check.test.ts | 3 + .../orchestration-recipient-routing.test.ts | 582 ++++++++++++++++++ .../orchestration-recipient-routing.ts | 145 +++++ .../methods/orchestration-rpc-test-harness.ts | 3 + .../rpc/methods/orchestration-send.test.ts | 7 + src/main/runtime/rpc/methods/orchestration.ts | 131 +++- ...hestration-legacy-coordinator-race.test.ts | 16 +- ...stration-runtime-update-settlement.test.ts | 5 +- src/main/ssh/ssh-remote-orca-cli.test.ts | 23 +- 22 files changed, 1223 insertions(+), 61 deletions(-) create mode 100644 src/cli/handlers/orchestration-send-receipt-warnings.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-recipient-routing.ts diff --git a/src/cli/handlers/orchestration-send-receipt-warnings.test.ts b/src/cli/handlers/orchestration-send-receipt-warnings.test.ts new file mode 100644 index 00000000000..8514b1d3b61 --- /dev/null +++ b/src/cli/handlers/orchestration-send-receipt-warnings.test.ts @@ -0,0 +1,87 @@ +import { expect, it, vi } from 'vitest' + +const callMock = vi.fn() +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +async function formatSend(result: unknown): Promise { + callMock.mockReset().mockResolvedValueOnce({ result }) + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_sender'], + ['to', 'term_recipient'], + ['subject', 'ping'] + ]), + client: { call: callMock }, + cwd: '/workspace', + json: false + } as never) + const printCall = vi.mocked(printResult).mock.calls.at(-1) + const formatter = printCall?.[2] as (value: unknown) => string + return formatter(result) +} + +it('prints a live terminal-only delivery limitation', async () => { + const line = await formatSend({ + message: { id: 'msg_1' }, + warnings: [ + { + code: 'legacy_terminal_recipient', + recipient: 'term_recipient', + message: 'term_recipient is live now, but its mailbox is not restart-durable.' + } + ] + }) + + expect(line).toBe( + 'Sent msg_1\nWarning: term_recipient is live now, but its mailbox is not restart-durable.' + ) +}) + +it('shows partial fan-out omissions without hiding delivered recipients', async () => { + const line = await formatSend({ + messages: [{ id: 'msg_1' }], + recipients: 1, + warnings: [ + { + code: 'recipient_unreachable', + recipient: 'term_gone', + message: 'Terminal term_gone has no live pane or durable mailbox.' + } + ] + }) + + expect(line).toBe( + 'Sent 1 messages to 1 recipients\nWarning: Terminal term_gone has no live pane or durable mailbox.' + ) +}) + +it('prints delivery limitations on relayed receipts', async () => { + const line = await formatSend({ + relay: { + messageId: 'relay_1', + sequence: 1, + dispatchId: 'ctx_remote', + destination: 'worker', + accepted: true + }, + warnings: [ + { + code: 'legacy_terminal_recipient', + recipient: 'term_remote', + message: 'term_remote is reachable through a compatibility address.' + } + ] + }) + + expect(line).toBe( + 'Queued relay_1 for worker Dispatch ctx_remote\nWarning: term_remote is reachable through a compatibility address.' + ) +}) + +it('leaves a canonical receipt unchanged', async () => { + await expect(formatSend({ message: { id: 'msg_1' } })).resolves.toBe('Sent msg_1') +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 5fe16b22181..e529fa62d11 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -100,9 +100,23 @@ type LifecycleSendResult = | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } | { action: 'rejected'; code: string; reason: string } +type SendRecipientWarning = { + code: string + recipient: string + message: string +} + type OrchestrationSendResult = - | { message: { id: string; run_id?: string }; lifecycle?: LifecycleSendResult } - | { messages: { id: string }[]; recipients: number } + | { + message: { id: string; run_id?: string } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + | { + messages: { id: string }[] + recipients: number + warnings?: SendRecipientWarning[] + } | { relay: { messageId: string @@ -112,6 +126,7 @@ type OrchestrationSendResult = accepted: true } lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] } function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { @@ -598,16 +613,25 @@ export const ORCHESTRATION_HANDLERS: Record = { throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) } printResult(result, json, (r) => { + const warnings = 'warnings' in r ? (r.warnings ?? []) : [] + const withWarnings = (line: string): string => + warnings.length > 0 + ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') + : line if ('message' in r) { - return `Sent ${r.message.id}` + return withWarnings(`Sent ${r.message.id}`) } if ('relay' in r) { if (r.relay.destination === 'worker') { - return `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}` + return withWarnings( + `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}` + ) } - return `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})` + return withWarnings( + `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})` + ) } - return `Sent ${r.messages.length} messages to ${r.recipients} recipients` + return withWarnings(`Sent ${r.messages.length} messages to ${r.recipients} recipients`) }) }, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 17117479f80..8c327526bf1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -49884,6 +49884,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.resolveLeafForHandle(handle)).toEqual({ ptyId: 'pty-b' }) // The guarded resolver surfaces the staleness so clients can re-derive. expect(() => runtime.resolveLiveLeafForHandle(handle)).toThrow('terminal_handle_stale') + expect(runtime.getLiveTerminalPaneKey(handle)).toBeNull() }) it('lets a handle issued before its first PTY adopt that PTY without erroring', async () => { @@ -49904,6 +49905,23 @@ describe('OrcaRuntimeService', () => { expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-a' }) }) + + it('keeps terminal cwd resolution fail-soft when the provider is unavailable', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + syncSingleTerminalGraph(runtime, 'pty-a') + const handle = issueLeafHandle(runtime, 'pty-a') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getCwd: async () => { + throw new Error('ssh disconnected') + } + }) + + await expect(runtime.resolveTerminalCwd(handle)).resolves.toBeNull() + }) }) describe('mobile terminal create resilience (#7718)', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ed68896ecbe..50843abb927 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -17765,6 +17765,23 @@ export class OrcaRuntimeService { return this.getPaneKeyForTerminalHandle(handle) } + getLiveTerminalPaneKey(handle: string): string | null { + const runtimePty = this.getLivePtyForHandle(handle) + if (runtimePty) { + return runtimePty.pty.connected ? (runtimePty.pty.paneKey ?? null) : null + } + try { + const leaf = this.resolveLiveLeafForHandle(handle) + if (!leaf?.ptyId) { + return null + } + const pty = this.ptysById.get(leaf.ptyId) + return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle) + } catch { + return null + } + } + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index b511c472931..d8306f9399d 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -200,7 +200,14 @@ describeIfBuilt('orca orchestration reset subprocess', () => { runtime.setOrchestrationDb(db) const coordinatorPaneKey = 'tab_cli:11111111-1111-4111-8111-111111111111' vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_cli' ? coordinatorPaneKey : null + handle === 'term_cli' + ? coordinatorPaneKey + : handle === 'term_target' + ? 'tab_target:33333333-3333-4333-8333-333333333333' + : null + ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) ) db.createRun({ objective: 'CLI reset subprocess fixture', diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 03a6ee36dfa..c6cab061700 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -11,6 +11,8 @@ export type { ForeignDirectMailboxRoutingPage, MailboxRoutingPage } from './db/messages/mailbox-routing-page' +export type { MessageInsert } from './db/messages/message-insert' +export type { LegacyAdoptedMailboxOwner } from './db/runs/run-lookup' export type { MessageType, diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts index f5f760f8a8b..1fa95aa2882 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts @@ -37,6 +37,49 @@ export function getActiveDispatchForIdentity( return this.findActiveDispatchForAssignee(handle, paneKey) } +export function getActiveDispatchMailboxOwners( + this: OrchestrationDb, + handle: string, + paneKey?: string +): DispatchContextRow[] { + const byHandle = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC` + ) + .all(handle) as DispatchContextRow[] + if (byHandle.length > 0 || !paneKey) { + return byHandle + } + + const byExactPane = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC` + ) + .all(paneKey) as DispatchContextRow[] + if (byExactPane.length > 0 || !parsePaneKey(paneKey)) { + return byExactPane + } + 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` + ) + .all(paneKeyMatchSuffix(paneKey)) as DispatchContextRow[] + ).filter( + (dispatch) => + dispatch.assignee_pane_key !== null && + isEquivalentPaneKey(dispatch.assignee_pane_key, paneKey) + ) +} + export function isDispatchMessageSender( this: OrchestrationDb, params: { @@ -125,6 +168,7 @@ export type DispatchLookupMethods = { getActiveDispatchForTerminal: typeof getActiveDispatchForTerminal hasAnyDispatchContexts: typeof hasAnyDispatchContexts getActiveDispatchForIdentity: typeof getActiveDispatchForIdentity + getActiveDispatchMailboxOwners: typeof getActiveDispatchMailboxOwners isDispatchMessageSender: typeof isDispatchMessageSender findActiveDispatchForAssignee: typeof findActiveDispatchForAssignee getLatestDispatchForTerminal: typeof getLatestDispatchForTerminal @@ -135,6 +179,7 @@ export function attachDispatchLookup(ctor: { prototype: object }): void { getActiveDispatchForTerminal, hasAnyDispatchContexts, getActiveDispatchForIdentity, + getActiveDispatchMailboxOwners, isDispatchMessageSender, findActiveDispatchForAssignee, getLatestDispatchForTerminal diff --git a/src/main/runtime/orchestration/db/messages/message-insert.ts b/src/main/runtime/orchestration/db/messages/message-insert.ts index d1a1b2f5611..f1a3a83dbbd 100644 --- a/src/main/runtime/orchestration/db/messages/message-insert.ts +++ b/src/main/runtime/orchestration/db/messages/message-insert.ts @@ -6,23 +6,24 @@ import type { OrchestrationDb } from '../orchestration-db' // ── Messages ── -export function insertMessage( - this: OrchestrationDb, - msg: { - id?: string - from: string - to: string - subject: string - body?: string - type?: MessageType - priority?: MessagePriority - threadId?: string - payload?: string - senderPaneKey?: string - runId?: string - deliveryContract?: MessageDeliveryContract - } -): MessageRow { +const MESSAGE_INSERT_SAVEPOINT = 'message_insert_batch' + +export type MessageInsert = { + id?: string + from: string + to: string + subject: string + body?: string + type?: MessageType + priority?: MessagePriority + threadId?: string + payload?: string + senderPaneKey?: string + runId?: string + deliveryContract?: MessageDeliveryContract +} + +export function insertMessage(this: OrchestrationDb, msg: MessageInsert): MessageRow { const runId = msg.runId ?? LEGACY_RUN_ID const deliveryContract = msg.deliveryContract ?? 'current_delivery' this.requireRun(runId) @@ -53,12 +54,27 @@ export function insertMessage( ) } +export function insertMessages(this: OrchestrationDb, messages: MessageInsert[]): MessageRow[] { + this.db.exec(`SAVEPOINT ${MESSAGE_INSERT_SAVEPOINT}`) + try { + const inserted = messages.map((message) => this.insertMessage(message)) + this.db.exec(`RELEASE ${MESSAGE_INSERT_SAVEPOINT}`) + return inserted + } catch (error) { + this.db.exec(`ROLLBACK TO ${MESSAGE_INSERT_SAVEPOINT}`) + this.db.exec(`RELEASE ${MESSAGE_INSERT_SAVEPOINT}`) + throw error + } +} + export type MessageInsertMethods = { insertMessage: typeof insertMessage + insertMessages: typeof insertMessages } export function attachMessageInsert(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { - insertMessage + insertMessage, + insertMessages }) } diff --git a/src/main/runtime/orchestration/db/runs/legacy-coordinator-mail-takeover.ts b/src/main/runtime/orchestration/db/runs/legacy-coordinator-mail-takeover.ts index f2892b0880b..972b3b83228 100644 --- a/src/main/runtime/orchestration/db/runs/legacy-coordinator-mail-takeover.ts +++ b/src/main/runtime/orchestration/db/runs/legacy-coordinator-mail-takeover.ts @@ -83,15 +83,14 @@ export function getUniqueLegacyCoordinatorHandle( .prepare( `SELECT DISTINCT assignee_handle AS handle FROM dispatch_contexts - WHERE run_id = ? AND contract_version = ? - AND assignee_handle IS NOT NULL + WHERE run_id = ? AND assignee_handle IS NOT NULL UNION SELECT DISTINCT terminal_handle AS handle FROM legacy_compatibility_principals WHERE run_id = ? AND role = 'worker' AND status IN ('committed', 'settled')` ) - .all(runId, LEGACY_CONTRACT_VERSION, runId) as { handle: string }[] + .all(runId, runId) as { handle: string }[] ).map((row) => row.handle) ) const durableRows = this.db @@ -107,10 +106,9 @@ export function getUniqueLegacyCoordinatorHandle( AND EXISTS( SELECT 1 FROM dispatch_contexts d WHERE d.task_id = t.id AND d.run_id = t.run_id - AND d.contract_version = ? )` ) - .all(adoption.adopted_at, runId, adoption.adopted_at, LEGACY_CONTRACT_VERSION) as { + .all(adoption.adopted_at, runId, adoption.adopted_at) as { handle: string }[] if (durableRows.some((row) => workerHandles.has(row.handle))) { diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index dd09d1f832b..061a7b39497 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -10,11 +10,53 @@ import { encodeRunListCursor, decodeRunListCursor } from '../run-list-cursor' import type { RunListPage } from '../run-list-page' import type { OrchestrationDb } from '../orchestration-db' +export type LegacyAdoptedMailboxOwner = { + runId: string + terminalHandle: string +} + export function getRun(this: OrchestrationDb, id: string): RunRow | undefined { const run = this.getRunRaw(id) return run ? exposeRunTimestamps(run) : undefined } +export function getLegacyAdoptedRunMailboxOwner( + this: OrchestrationDb +): LegacyAdoptedMailboxOwner | null { + const adoption = this.getLegacyAdoption() + if (!adoption) { + return null + } + const terminalHandle = this.getUniqueLegacyCoordinatorHandle(adoption.adopted_run_id) + return terminalHandle ? { runId: adoption.adopted_run_id, terminalHandle } : null +} + +export function getRunMailboxOwnerIdsForHandle( + this: OrchestrationDb, + terminalHandle: string, + legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null +): string[] { + const runIds = ( + this.db + .prepare( + `SELECT coordinator.run_id + FROM run_coordinator_handles AS coordinator + JOIN runs ON runs.id = coordinator.run_id AND runs.legacy = 0 + WHERE coordinator.terminal_handle = ? + ORDER BY coordinator.run_id` + ) + .all(terminalHandle) as { run_id: string }[] + ).map((row) => row.run_id) + const adoptedOwner = + legacyAdoptedMailboxOwner === undefined + ? this.getLegacyAdoptedRunMailboxOwner() + : legacyAdoptedMailboxOwner + if (adoptedOwner?.terminalHandle === terminalHandle) { + runIds.push(adoptedOwner.runId) + } + return [...new Set(runIds)].sort() +} + export function listRuns( this: OrchestrationDb, params: { limit?: number; cursor?: string } = {} @@ -117,6 +159,8 @@ export function fenceOutstandingDelivery(this: OrchestrationDb, runId: string): export type RunLookupMethods = { getRun: typeof getRun + getLegacyAdoptedRunMailboxOwner: typeof getLegacyAdoptedRunMailboxOwner + getRunMailboxOwnerIdsForHandle: typeof getRunMailboxOwnerIdsForHandle listRuns: typeof listRuns getCurrentRunForPane: typeof getCurrentRunForPane runsBoundToPane: typeof runsBoundToPane @@ -129,6 +173,8 @@ export type RunLookupMethods = { export function attachRunLookup(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { getRun, + getLegacyAdoptedRunMailboxOwner, + getRunMailboxOwnerIdsForHandle, listRuns, getCurrentRunForPane, runsBoundToPane, diff --git a/src/main/runtime/orchestration/message-batch-atomicity.test.ts b/src/main/runtime/orchestration/message-batch-atomicity.test.ts index a9bf6237f55..3c35465a3b4 100644 --- a/src/main/runtime/orchestration/message-batch-atomicity.test.ts +++ b/src/main/runtime/orchestration/message-batch-atomicity.test.ts @@ -91,4 +91,33 @@ describe('message batch atomicity', () => { } expect(first).toEqual({ subject: 'outer change', read: 0 }) }) + + it('preserves an outer transaction when a message insert batch rolls back', () => { + db = new OrchestrationDb(':memory:') + const sqlite = (db as unknown as { db: Database.Database }).db + sqlite.exec(` + CREATE TRIGGER reject_second_message_insert + BEFORE INSERT ON messages WHEN NEW.id = 'inner_second' + BEGIN + SELECT RAISE(ABORT, 'blocked'); + END; + BEGIN IMMEDIATE; + INSERT INTO messages (id, from_handle, to_handle, subject) + VALUES ('outer', 'sender', 'recipient', 'outer change'); + `) + + expect(() => + db?.insertMessages([ + { id: 'inner_first', from: 'sender', to: 'recipient', subject: 'first' }, + { id: 'inner_second', from: 'sender', to: 'recipient', subject: 'second' } + ]) + ).toThrow('blocked') + sqlite.exec('COMMIT') + + expect( + sqlite + .prepare("SELECT id FROM messages WHERE id IN ('outer', 'inner_first') ORDER BY id") + .all() + ).toEqual([{ id: 'outer' }]) + }) }) diff --git a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts index b6889d20c4e..6efdf16f1ff 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts @@ -79,6 +79,8 @@ describe('OrchestrationDb legacy contract storage', () => { scheduler_state_lost: 1 }) expect(db.getRun(adoptedRunId)).toMatchObject({ legacy: 0, consumer_generation: 0 }) + expect(db.getRunMailboxOwnerIdsForHandle('term_legacy_coord')).toEqual([adoptedRunId]) + expect(db.getRunMailboxOwnerIdsForHandle('term_invented')).toEqual([]) expect(db.listTasks({ runId: LEGACY_RUN_ID })).toEqual([]) expect(db.getDispatchContextById(fixture.legacyDispatchId)).toMatchObject({ run_id: adoptedRunId, @@ -146,6 +148,21 @@ describe('OrchestrationDb legacy contract storage', () => { ) }) + it('fails closed when an adopted coordinator handle becomes a current-contract worker', () => { + const state = openAdoptedFixture() + expect(db!.getRunMailboxOwnerIdsForHandle('term_legacy_coord')).toEqual([state.adoptedRunId]) + const task = db!.createTask({ + runId: state.adoptedRunId, + spec: 'mixed contract worker identity', + createdByTerminalHandle: 'term_legacy_coord' + }) + + const dispatch = db!.createDispatchContext(task.id, 'term_legacy_coord', 'tab_mixed:leaf_mixed') + + expect(dispatch.contract_version).toBe(CURRENT_CONTRACT_VERSION) + expect(db!.getRunMailboxOwnerIdsForHandle('term_legacy_coord')).toEqual([]) + }) + it('does not synthesize an adopted Run or compatibility authority for a fresh database', () => { db = new OrchestrationDb(':memory:') diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 5d07ace973d..0b083a64d61 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -86,6 +86,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'task_not_startable', 'dispatch_not_found', 'dispatch_run_mismatch', + 'terminal_not_found', + 'recipient_ambiguous', + 'recipient_run_mismatch', 'dispatch_inactive', 'worker_identity_changed', 'cursor_invalid', diff --git a/src/main/runtime/rpc/methods/orchestration-check.test.ts b/src/main/runtime/rpc/methods/orchestration-check.test.ts index ce45a2cfec4..b933bbb0831 100644 --- a/src/main/runtime/rpc/methods/orchestration-check.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-check.test.ts @@ -659,6 +659,9 @@ describe('orchestration RPC methods', () => { it('keeps waiting for requested types when an unrelated status arrives', async () => { setup() + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'coord' ? 'tab_wait:leaf_wait' : null + ) const waitPromise = call('orchestration.check', { terminal: 'coord', diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts new file mode 100644 index 00000000000..99a4c238807 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts @@ -0,0 +1,582 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' +import type { OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext, RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' +import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' + +type SendWarning = { code: string; recipient: string; message: string } +type SendResult = { + message: { id: string; run_id: string; to_handle: string } + warnings?: SendWarning[] +} +type GroupSendResult = { + messages: { id: string; run_id: string; to_handle: string }[] + recipients: number + warnings?: SendWarning[] +} + +describe('orchestration recipient routing oracle', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let senderRunId: string + + function setup(): void { + const state = harness.setup() + db = state.db + runtime = state.runtime + ctx = state.ctx + senderRunId = state.activeRunId! + } + + async function call(params: Record): Promise { + return harness.call('orchestration.send', params, ctx) + } + + function mockTerminalPaneKeys(resolve: (handle: string) => string | null): void { + vi.mocked(runtime.getTerminalPaneKey).mockImplementation(resolve) + vi.mocked(runtime.getLiveTerminalPaneKey).mockImplementation(resolve) + } + + afterEach(() => harness.cleanup()) + + it('rejects an unknown terminal without creating an unread row', async () => { + setup() + + await expect( + call({ from: 'term_coord', to: 'term_invented', subject: 'unreachable' }) + ).rejects.toMatchObject({ code: 'terminal_not_found' }) + expect(db.getInbox(100)).toEqual([]) + }) + + it('rejects a closed terminal that has no durable mailbox owner', async () => { + setup() + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('retained-pty-record') + + await expect( + call({ from: 'term_coord', to: 'term_closed', subject: 'closed' }) + ).rejects.toMatchObject({ code: 'terminal_not_found' }) + expect(db.getInbox(100)).toEqual([]) + }) + + it('keeps a live terminal-only recipient readable and reports its delivery limitation', async () => { + setup() + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_live' + ? 'tab_live:leaf_live' + : null + ) + + const result = (await call({ + from: 'term_coord', + to: 'term_live', + subject: 'compatibility' + })) as SendResult + + expect(result).toMatchObject({ + message: { run_id: senderRunId, to_handle: 'term_live' }, + warnings: [{ code: 'legacy_terminal_recipient', recipient: 'term_live' }] + }) + const check = (await harness.call( + 'orchestration.check', + { terminal: 'term_live', peek: true }, + ctx + )) as { + messages: { id: string }[] + } + expect(check.messages.map((message) => message.id)).toEqual([result.message.id]) + }) + + it('normalizes a cross-Run coordinator handle to the recipient Run mailbox', async () => { + setup() + const recipientPane = 'tab_recipient:leaf_recipient' + const recipientRun = db.createRun({ + objective: 'Recipient Run', + coordinatorHandle: 'term_recipient', + coordinatorPaneKey: recipientPane + }) + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_recipient' + ? recipientPane + : null + ) + + const result = (await call({ + from: 'term_coord', + to: 'term_recipient', + subject: 'cross-run' + })) as SendResult + + expect(result.message).toMatchObject({ + run_id: recipientRun.id, + to_handle: `run:${recipientRun.id}` + }) + const check = (await harness.call( + 'orchestration.check', + { terminal: 'term_recipient', peek: true }, + ctx + )) as { messages: { id: string }[] } + expect(check.messages.map((message) => message.id)).toEqual([result.message.id]) + }) + + it('never lets a stale leaf handle adopt its replacement pane Run', async () => { + setup() + const staleOwner = db.createRun({ + objective: 'Original pane owner', + coordinatorHandle: 'term_stale', + coordinatorPaneKey: 'tab_original:leaf_shared' + }) + const replacement = db.createRun({ + objective: 'Replacement pane owner', + coordinatorHandle: 'term_replacement', + coordinatorPaneKey: 'tab_replacement:leaf_shared' + }) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_stale' || handle === 'term_unowned_stale' + ? 'tab_replacement:leaf_shared' + : null + ) + vi.mocked(runtime.getLiveTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' ? harness.coordinatorPaneKey : null + ) + + const routed = (await call({ + from: 'term_coord', + to: 'term_stale', + subject: 'historical owner' + })) as SendResult + + expect(routed.message).toMatchObject({ + run_id: staleOwner.id, + to_handle: `run:${staleOwner.id}` + }) + expect(routed.message.run_id).not.toBe(replacement.id) + await expect( + call({ from: 'term_coord', to: 'term_unowned_stale', subject: 'no owner' }) + ).rejects.toMatchObject({ code: 'terminal_not_found' }) + }) + + it('normalizes an active Dispatch owner even when no pane is live', async () => { + setup() + const task = db.createTask({ spec: 'detached worker' }) + const dispatch = db.createDispatchContext(task.id, 'term_detached', 'tab_gone:leaf_gone') + + const result = (await call({ + from: 'term_coord', + to: 'term_detached', + subject: 'wait durably' + })) as SendResult + + expect(result.message).toMatchObject({ + run_id: senderRunId, + to_handle: `dispatch:${dispatch.id}` + }) + expect(result.warnings).toBeUndefined() + expect(db.getUnreadMessages(`dispatch:${dispatch.id}`)).toHaveLength(1) + }) + + it('reports an explicit Run mismatch for one detached Dispatch owner', async () => { + setup() + const foreignRun = db.createRun({ + objective: 'Foreign worker Run', + coordinatorHandle: 'term_foreign_coord', + coordinatorPaneKey: 'tab_foreign:leaf_coord' + }) + const task = db.createTask({ spec: 'detached foreign worker', runId: foreignRun.id }) + db.createDispatchContext(task.id, 'term_detached_foreign', 'tab_gone:leaf_gone') + + await expect( + call({ + from: 'term_coord', + to: 'term_detached_foreign', + run: senderRunId, + subject: 'wrong Run' + }) + ).rejects.toMatchObject({ code: 'recipient_run_mismatch' }) + expect(db.getInbox(100)).toEqual([]) + }) + + it('matches check precedence when a live Run coordinator pane overlaps a Dispatch', async () => { + setup() + const overlapPane = 'tab_overlap:leaf_overlap' + const task = db.createTask({ spec: 'overlapped worker' }) + db.createDispatchContext(task.id, 'term_overlap', overlapPane) + const recipientRun = db.createRun({ + objective: 'Overlapping coordinator', + coordinatorHandle: 'term_overlap', + coordinatorPaneKey: overlapPane + }) + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_overlap' + ? overlapPane + : null + ) + + const result = (await call({ + from: 'term_coord', + to: 'term_overlap', + subject: 'same read path' + })) as SendResult + + expect(result.message).toMatchObject({ + run_id: recipientRun.id, + to_handle: `run:${recipientRun.id}` + }) + const check = (await harness.call( + 'orchestration.check', + { terminal: 'term_overlap', peek: true }, + ctx + )) as { messages: { id: string }[] } + expect(check.messages.map((message) => message.id)).toEqual([result.message.id]) + }) + + it('keeps same-Run historical coordinator routing from the canonical mailbox change', async () => { + setup() + db.bindRun({ + runId: senderRunId, + coordinatorHandle: 'term_current', + coordinatorPaneKey: 'tab_current:leaf_current' + }) + mockTerminalPaneKeys((handle) => + handle === 'term_current' ? 'tab_current:leaf_current' : null + ) + + const result = (await call({ + from: 'term_current', + to: 'term_coord', + subject: 'historical' + })) as SendResult + + expect(result.message.to_handle).toBe(`run:${senderRunId}`) + expect(result.warnings).toBeUndefined() + }) + + it('rejects a historical handle that names more than one foreign Run', async () => { + setup() + db.createRun({ + objective: 'First owner', + coordinatorHandle: 'term_ambiguous', + coordinatorPaneKey: 'tab_first:leaf_first' + }) + db.createRun({ + objective: 'Second owner', + coordinatorHandle: 'term_ambiguous', + coordinatorPaneKey: 'tab_second:leaf_second' + }) + + await expect( + call({ from: 'term_coord', to: 'term_ambiguous', subject: 'ambiguous' }) + ).rejects.toMatchObject({ code: 'recipient_ambiguous' }) + expect(db.getInbox(100)).toEqual([]) + }) + + it.each(['@all', '@worktree:wt_target'])( + 'partially delivers %s when a listed recipient disappears before routing', + async (address) => { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminal('term_coord'), terminal('term_live'), terminal('term_disappeared')], + totalCount: 3, + truncated: false + }) + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_live' + ? 'tab_live:leaf_live' + : null + ) + const adoptionLookup = vi.spyOn(db, 'getLegacyAdoptedRunMailboxOwner') + + const result = (await call({ + from: 'term_coord', + to: address, + subject: 'fan-out' + })) as GroupSendResult + + expect(result.messages).toHaveLength(1) + expect(result.messages[0]).toMatchObject({ to_handle: 'term_live' }) + expect(result.recipients).toBe(1) + expect(result.warnings?.map((warning) => warning.code).sort()).toEqual([ + 'legacy_terminal_recipient', + 'recipient_unreachable' + ]) + expect(db.getInbox(100)).toHaveLength(1) + expect(adoptionLookup).toHaveBeenCalledTimes(1) + } + ) + + it('fans out once when historical handles resolve to the same Run mailbox', async () => { + setup() + const foreignRun = db.createRun({ + objective: 'Foreign Run', + coordinatorHandle: 'term_foreign_first', + coordinatorPaneKey: 'tab_foreign_first:leaf_foreign_first' + }) + db.bindRun({ + runId: foreignRun.id, + coordinatorHandle: 'term_foreign_second', + coordinatorPaneKey: 'tab_foreign_second:leaf_foreign_second' + }) + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + terminal('term_coord'), + terminal('term_foreign_first'), + terminal('term_foreign_second') + ], + totalCount: 3, + truncated: false + }) + mockTerminalPaneKeys((handle) => (handle === 'term_coord' ? harness.coordinatorPaneKey : null)) + + const result = (await call({ + from: 'term_coord', + to: '@all', + subject: 'one mailbox' + })) as GroupSendResult + + expect(result.recipients).toBe(1) + expect(result.messages).toHaveLength(1) + expect(result.messages[0]).toMatchObject({ + run_id: foreignRun.id, + to_handle: `run:${foreignRun.id}` + }) + expect(db.getInbox(100)).toHaveLength(1) + }) + + it('excludes historical handles that resolve back to the sender mailbox', async () => { + setup() + db.bindRun({ + runId: senderRunId, + coordinatorHandle: 'term_middle', + coordinatorPaneKey: 'tab_middle:leaf_middle' + }) + db.bindRun({ + runId: senderRunId, + coordinatorHandle: 'term_sender', + coordinatorPaneKey: 'tab_sender:leaf_sender' + }) + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + terminal('term_sender'), + terminal('term_coord'), + terminal('term_middle'), + terminal('term_live') + ], + totalCount: 4, + truncated: false + }) + mockTerminalPaneKeys((handle) => + handle === 'term_sender' + ? 'tab_sender:leaf_sender' + : handle === 'term_live' + ? 'tab_live:leaf_live' + : null + ) + + const result = (await call({ + from: 'term_sender', + to: '@all', + subject: 'exclude self aliases' + })) as GroupSendResult + + expect(result.messages).toHaveLength(1) + expect(result.messages[0].to_handle).toBe('term_live') + expect(result.warnings).toMatchObject([{ code: 'legacy_terminal_recipient' }]) + }) + + it('replays one honest receipt and discards retry receipts for rejected recipients', async () => { + setup() + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_live' + ? 'tab_live:leaf_live' + : null + ) + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const rejected = request('rpc_reject_1', 'retry_reject', 'term_missing') + + const firstRejected = await dispatcher.dispatch(rejected) + const retriedRejected = await dispatcher.dispatch({ ...rejected, id: 'rpc_reject_2' }) + expect(firstRejected).toMatchObject({ ok: false, error: { code: 'terminal_not_found' } }) + expect(retriedRejected).toMatchObject({ ok: false, error: { code: 'terminal_not_found' } }) + expect(db.getInbox(100)).toEqual([]) + const callerFingerprint = db.getOrCreateLocalMutationCallerFingerprint() + expect(db.getMutationReceipt(callerFingerprint, 'retry_reject')).toBeUndefined() + + const accepted = request('rpc_accept_1', 'retry_accept', 'term_live') + const firstAccepted = await dispatcher.dispatch(accepted) + const replayed = await dispatcher.dispatch({ ...accepted, id: 'rpc_accept_2' }) + expect(firstAccepted).toMatchObject({ + ok: true, + result: { + message: { id: expect.stringMatching(/^msg_/) }, + warnings: [{ code: 'legacy_terminal_recipient' }], + mutation: { requestId: 'retry_accept', replayed: false } + } + }) + expect(replayed).toMatchObject({ + ok: true, + result: { + message: { id: firstAccepted.ok ? (firstAccepted.result as SendResult).message.id : '' }, + warnings: [{ code: 'legacy_terminal_recipient' }], + mutation: { requestId: 'retry_accept', replayed: true } + } + }) + expect(db.getInbox(100)).toHaveLength(1) + expect(db.getMutationReceipt(callerFingerprint, 'retry_accept')).toMatchObject({ + state: 'completed', + receipt: expect.stringContaining('legacy_terminal_recipient') + }) + }) + + it('serializes ambiguity and explicit Run mismatch through the RPC boundary', async () => { + setup() + db.createRun({ + objective: 'First owner', + coordinatorHandle: 'term_ambiguous', + coordinatorPaneKey: 'tab_first:leaf_first' + }) + db.createRun({ + objective: 'Second owner', + coordinatorHandle: 'term_ambiguous', + coordinatorPaneKey: 'tab_second:leaf_second' + }) + const foreignRun = db.createRun({ + objective: 'Foreign owner', + coordinatorHandle: 'term_foreign', + coordinatorPaneKey: 'tab_foreign:leaf_foreign' + }) + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + + const ambiguous = await dispatcher.dispatch( + request('rpc_ambiguous', 'retry_ambiguous', 'term_ambiguous') + ) + const mismatch = await dispatcher.dispatch( + request('rpc_mismatch', 'retry_mismatch', 'term_foreign', { run: senderRunId }) + ) + + expect(ambiguous).toMatchObject({ ok: false, error: { code: 'recipient_ambiguous' } }) + expect(mismatch).toMatchObject({ ok: false, error: { code: 'recipient_run_mismatch' } }) + expect(foreignRun.id).not.toBe(senderRunId) + expect(db.getInbox(100)).toEqual([]) + }) + + it('rolls back a partial group insert before an idempotent retry', async () => { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminal('term_coord'), terminal('term_first'), terminal('term_second')], + totalCount: 3, + truncated: false + }) + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_first' + ? 'tab_first:leaf_first' + : handle === 'term_second' + ? 'tab_second:leaf_second' + : null + ) + const insertMessage = db.insertMessage.bind(db) + vi.spyOn(db, 'insertMessage') + .mockImplementationOnce(insertMessage) + .mockImplementationOnce(() => { + throw new Error('injected second insert failure') + }) + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const group = request('rpc_group_partial', 'retry_group_partial', '@all') + + const failed = await dispatcher.dispatch(group) + expect(failed.ok).toBe(false) + expect(db.getInbox(100)).toEqual([]) + + const retried = await dispatcher.dispatch({ ...group, id: 'rpc_group_partial_retry' }) + expect(retried).toMatchObject({ + ok: true, + result: { messages: [{ id: expect.any(String) }, { id: expect.any(String) }] } + }) + expect(db.getInbox(100)).toHaveLength(2) + }) + + it('replays a completed group receipt when notification fails after durable insertion', async () => { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminal('term_coord'), terminal('term_live')], + totalCount: 2, + truncated: false + }) + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_live' + ? 'tab_live:leaf_live' + : null + ) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementationOnce(() => { + throw new Error('injected notification failure') + }) + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const group = request('rpc_group_notify', 'retry_group_notify', '@all') + + const failed = await dispatcher.dispatch(group) + const retried = await dispatcher.dispatch({ ...group, id: 'rpc_group_notify_retry' }) + + expect(failed.ok).toBe(false) + expect(retried).toMatchObject({ + ok: true, + result: { + messages: [{ id: expect.any(String) }], + mutation: { requestId: 'retry_group_notify', replayed: true } + } + }) + expect(db.getInbox(100)).toHaveLength(1) + }) +}) + +function terminal(handle: string): RuntimeTerminalSummary { + return { + handle, + ptyId: handle, + worktreeId: 'wt_target', + worktreePath: '/workspace', + branch: 'main', + tabId: `tab_${handle}`, + leafId: `leaf_${handle}`, + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + } +} + +function request( + id: string, + requestId: string, + to: string, + extraParams: Record = {} +): RpcRequest { + return { + id, + authToken: 'test-token', + method: 'orchestration.send', + params: { from: 'term_coord', to, subject: 'retry', ...extraParams }, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: requestId + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts new file mode 100644 index 00000000000..1de1151d8a2 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts @@ -0,0 +1,145 @@ +import type { LegacyAdoptedMailboxOwner, OrchestrationDb } from '../../orchestration/db' +import type { DispatchContextRow } from '../../orchestration/types' +import type { OrcaRuntimeService } from '../../orca-runtime' + +export type SendRecipientWarning = { + code: + | 'legacy_terminal_recipient' + | 'recipient_unreachable' + | 'recipient_ambiguous' + | 'recipient_run_mismatch' + recipient: string + message: string +} + +export type BareRecipientResolution = + | { + ok: true + to: string + runId: string | undefined + warning?: SendRecipientWarning + } + | { + ok: false + code: 'terminal_not_found' | 'recipient_ambiguous' | 'recipient_run_mismatch' + message: string + warning: SendRecipientWarning + } + +export function resolveBareOrchestrationRecipient(params: { + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + senderRunId?: string + explicitRunId?: string + legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null +}): BareRecipientResolution { + const { runtime, db, handle } = params + const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined + const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (boundRun) { + const mismatch = runMismatch(handle, boundRun.id, params.explicitRunId) + return mismatch ?? { ok: true, to: `run:${boundRun.id}`, runId: boundRun.id } + } + + const dispatches = db.getActiveDispatchMailboxOwners(handle, paneKey) + const dispatch = selectDispatch(dispatches, params.explicitRunId) + if (dispatches.length > 0 && !dispatch) { + return ambiguous( + handle, + dispatches.map((candidate) => `dispatch:${candidate.id}`) + ) + } + if (dispatch) { + const mismatch = runMismatch(handle, dispatch.run_id, params.explicitRunId) + return mismatch ?? { ok: true, to: `dispatch:${dispatch.id}`, runId: dispatch.run_id } + } + + const ownerRunIds = db.getRunMailboxOwnerIdsForHandle(handle, params.legacyAdoptedMailboxOwner) + const selectedRunId = selectHistoricalRun(ownerRunIds, params) + if (ownerRunIds.length > 0 && !selectedRunId) { + return ambiguous( + handle, + ownerRunIds.map((runId) => `run:${runId}`) + ) + } + if (selectedRunId) { + const mismatch = runMismatch(handle, selectedRunId, params.explicitRunId) + return mismatch ?? { ok: true, to: `run:${selectedRunId}`, runId: selectedRunId } + } + + if (paneKey) { + return { + ok: true, + to: handle, + runId: params.senderRunId, + warning: { + code: 'legacy_terminal_recipient', + recipient: handle, + message: `${handle} is a live terminal-only mailbox. Delivery is not durable after that terminal closes; prefer run: or dispatch:.` + } + } + } + + const message = `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` + return { + ok: false, + code: 'terminal_not_found', + message, + warning: { code: 'recipient_unreachable', recipient: handle, message } + } +} + +function selectDispatch( + dispatches: DispatchContextRow[], + explicitRunId: string | undefined +): DispatchContextRow | undefined { + if (dispatches.length === 1) { + return dispatches[0] + } + if (!explicitRunId) { + return undefined + } + const matches = dispatches.filter((dispatch) => dispatch.run_id === explicitRunId) + return matches.length === 1 ? matches[0] : undefined +} + +function selectHistoricalRun( + ownerRunIds: string[], + params: { senderRunId?: string; explicitRunId?: string } +): string | undefined { + if (params.explicitRunId && ownerRunIds.includes(params.explicitRunId)) { + return params.explicitRunId + } + if (params.senderRunId && ownerRunIds.includes(params.senderRunId)) { + return params.senderRunId + } + return ownerRunIds.length === 1 ? ownerRunIds[0] : undefined +} + +function ambiguous(handle: string, addresses: string[]): BareRecipientResolution { + const message = `${handle} resolves to multiple durable mailboxes (${addresses.join(', ')}). Use an explicit canonical address.` + return { + ok: false, + code: 'recipient_ambiguous', + message, + warning: { code: 'recipient_ambiguous', recipient: handle, message } + } +} + +function runMismatch( + handle: string, + resolvedRunId: string, + explicitRunId: string | undefined +): BareRecipientResolution | undefined { + if (!explicitRunId || explicitRunId === resolvedRunId) { + return undefined + } + const message = `${handle} belongs to Run ${resolvedRunId}, not explicitly requested Run ${explicitRunId}.` + return { + ok: false, + code: 'recipient_run_mismatch', + message, + warning: { code: 'recipient_run_mismatch', recipient: handle, message } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-rpc-test-harness.ts b/src/main/runtime/rpc/methods/orchestration-rpc-test-harness.ts index c08a335548a..b0a77bfa29f 100644 --- a/src/main/runtime/rpc/methods/orchestration-rpc-test-harness.ts +++ b/src/main/runtime/rpc/methods/orchestration-rpc-test-harness.ts @@ -30,6 +30,9 @@ export function createOrchestrationRpcHarness() { vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_coord' ? coordinatorPaneKey : null ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => handle.startsWith('term_') ? `runtime_test:${handle}:1` : null ) diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index 1169e7b4e1c..dafcbcc4327 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -601,6 +601,13 @@ describe('orchestration RPC methods', () => { totalCount: terminals.length, truncated: false }) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => { + if (handle === 'term_coord') { + return coordinatorPaneKey + } + const terminal = terminals.find((candidate) => candidate.handle === handle) + return terminal ? `${terminal.tabId}:${terminal.leafId}` : null + }) vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( (handle: string) => agentStatuses?.[handle] ?? null ) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 908673ec7a5..30e0b516129 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -23,6 +23,10 @@ import { } from '../../../../shared/orchestration-rpc-contract' import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' +import { + resolveBareOrchestrationRecipient, + type SendRecipientWarning +} from './orchestration-recipient-routing' import { resolveRunScope } from './orchestration-run-scope' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' @@ -445,6 +449,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ legacyCoordinatorRunId, revalidateLegacyCoordinator, orchestrationCompatibilityCallerAuthority, + recordMutationReceipt, signal } ) => { @@ -592,8 +597,35 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ ) } + const sendWarnings: SendRecipientWarning[] = [] + let messageRunId = routing.run?.id + if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + const recipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: to, + senderRunId: routing.run?.id, + explicitRunId: params.run + }) + if (!recipient.ok) { + throw new OrchestrationError(recipient.code, recipient.message) + } + to = recipient.to + messageRunId = recipient.runId + if (recipient.warning) { + sendWarnings.push(recipient.warning) + } + } + const withSendWarnings = ( + receipt: T + ): T & { + warnings?: SendRecipientWarning[] + } => (sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt) + if (!isGroupAddress(to)) { - const federatedDispatchId = routing.dispatchId + const federatedDispatchId = to.startsWith('dispatch:') + ? to.slice('dispatch:'.length) + : undefined const federatedTarget = federatedDispatchId && to === `dispatch:${federatedDispatchId}` ? db.getFederatedDispatch(federatedDispatchId) @@ -636,8 +668,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ payload: params.payload ?? null }) }) - runtime.ensureOrchestrationFederationRelay(routing.run?.id) - return { + runtime.ensureOrchestrationFederationRelay(messageRunId) + return withSendWarnings({ relay: { messageId: relay.message_id, sequence: relay.sequence, @@ -645,7 +677,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ destination: 'worker', accepted: true } - } + }) } // Point-to-point — existing single-recipient behavior revalidateLegacyCoordinator?.() @@ -665,10 +697,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) : params.payload, senderPaneKey, - runId: routing.run?.id, + runId: messageRunId, deliveryContract: legacyWorkerDeliveryContract( runtime, - routing.run?.id ?? legacyCoordinatorRunId, + messageRunId ?? legacyCoordinatorRunId, to ) }) @@ -738,14 +770,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const rejection = db.convertLifecycleMessageToRejection(msg.id, code, authority.reason) ?? msg runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return { + return withSendWarnings({ message: rejection, lifecycle: { action: 'rejected', code, reason: authority.reason } - } + }) } } // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. @@ -753,20 +785,20 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const reconciled = reconcileLifecycleMessage(db, msg) // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. if (reconciled.action === 'suppressed') { - return { message: msg } + return withSendWarnings({ message: msg }) } if (reconciled.action === 'rejected') { const rejection = db.getMessageById(msg.id) ?? msg runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return { message: rejection, lifecycle: reconciled } + return withSendWarnings({ message: rejection, lifecycle: reconciled }) } runtime.notifyMessageArrived(msg.to_handle, msg.type) - return msg.type === 'worker_done' - ? { message: msg, lifecycle: reconciled } - : { message: msg } + return withSendWarnings( + msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } + ) } runtime.notifyMessageArrived(msg.to_handle, msg.type) - return { message: msg } + return withSendWarnings({ message: msg }) } // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). @@ -781,12 +813,57 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ throw new Error(`No recipients resolved for group address: ${to}`) } + const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() + const resolvedRecipients = handles.map((handle) => ({ + handle, + resolution: resolveBareOrchestrationRecipient({ + runtime, + db, + handle, + senderRunId: routing.run?.id, + explicitRunId: params.run, + legacyAdoptedMailboxOwner + }) + })) + const deliverableRecipients = resolvedRecipients.filter( + ( + recipient + ): recipient is typeof recipient & { + resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } + } => recipient.resolution.ok + ) + const senderRecipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: from, + senderRunId: routing.run?.id, + legacyAdoptedMailboxOwner + }) + const senderMailboxKey = senderRecipient.ok + ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` + : undefined + const seenMailboxes = new Set() + const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { + const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` + if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { + return false + } + seenMailboxes.add(mailboxKey) + return true + }) + if (uniqueRecipients.length === 0) { + throw new OrchestrationError( + 'terminal_not_found', + `No recipient of ${to} resolved to a live terminal or durable Run/Dispatch mailbox.` + ) + } + revalidateLegacyCoordinator?.() const threadId = params.threadId ?? `thread_${Date.now()}` - const messages = handles.map((handle) => - db.insertMessage({ + const messages = db.insertMessages( + uniqueRecipients.map(({ resolution }) => ({ from, - to: handle, + to: resolution.to, subject: params.subject, body: params.body, type: params.type as MessageType, @@ -794,19 +871,27 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ threadId, payload: params.payload, senderPaneKey, - runId: routing.run?.id, + runId: resolution.runId, deliveryContract: legacyWorkerDeliveryContract( runtime, - routing.run?.id ?? legacyCoordinatorRunId, - handle + resolution.runId ?? legacyCoordinatorRunId, + resolution.to ) - }) + })) ) + const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => + resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + ) + const receipt = { + messages, + recipients: messages.length, + ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) + } + recordMutationReceipt?.(receipt) for (const message of messages) { runtime.notifyMessageArrived(message.to_handle, message.type) } - - return { messages, recipients: handles.length } + return receipt } }), diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 468f261b725..4b8bb388226 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -68,6 +68,9 @@ function createHarness(): Harness { vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation( (compatibilityEvidence) => { const coordinator = @@ -199,7 +202,7 @@ describe('legacy coordinator takeover races', () => { result: { message: { run_id: harness.adoptedRunId, - to_handle: WORKER_HANDLE, + to_handle: `dispatch:${harness.dispatchId}`, delivery_contract: 'legacy_direct' } } @@ -310,6 +313,15 @@ describe('legacy coordinator takeover races', () => { it('partitions a coordinator group send by legacy recipient contract', async () => { const harness = createHarness() + vi.mocked(harness.runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === COORDINATOR_HANDLE + ? COORDINATOR_PANE + : handle === WORKER_HANDLE + ? WORKER_PANE + : handle === 'term_current_worker' + ? 'tab_current_worker:leaf_current_worker' + : null + ) vi.spyOn(harness.runtime, 'listTerminals').mockResolvedValue({ terminals: [ { handle: COORDINATOR_HANDLE }, @@ -336,7 +348,7 @@ describe('legacy coordinator takeover races', () => { expect.arrayContaining([ expect.objectContaining({ run_id: harness.adoptedRunId, - to_handle: WORKER_HANDLE, + to_handle: `dispatch:${harness.dispatchId}`, delivery_contract: 'legacy_direct' }), expect.objectContaining({ diff --git a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts index 57ac1b014ff..4dc8cd892b1 100644 --- a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts +++ b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts @@ -192,7 +192,7 @@ function entityCounts(db: OrchestrationDb): Record { } function resultOf(response: RpcResponse): Record { - expect(response.ok).toBe(true) + expect(response.ok, JSON.stringify(response)).toBe(true) if (!response.ok) { throw new Error(response.error.message) } @@ -352,6 +352,9 @@ describe('orchestration runtime update settlement', () => { it('routes ordinary mail with the same attested authority without settling work', async () => { const harness = createUpdateHarness() + expect(harness.db.getRunMailboxOwnerIdsForHandle(COORDINATOR_HANDLE)).toEqual([ + harness.adoptedRunId + ]) const response = await harness.createDispatcher().dispatch( request( 'orchestration.send', diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 27947c1d052..e9cb35e352b 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -81,7 +81,9 @@ describe('runRemoteOrcaCli', () => { }), getLegacyAdoption: vi.fn(() => undefined), getActiveDispatchForIdentity: vi.fn(() => undefined), + getActiveDispatchMailboxOwners: vi.fn(() => []), getCurrentRunForPane: vi.fn(() => undefined), + getRunMailboxOwnerIdsForHandle: vi.fn(() => []), findActiveRemoteAttachmentForPane: vi.fn(() => undefined) } const runtime = { @@ -96,6 +98,8 @@ describe('runRemoteOrcaCli', () => { }), getOrchestrationDb: () => db, getTerminalPaneKey: () => null, + getLiveTerminalPaneKey: (handle: string) => + handle === 'term_windows' ? 'tab_windows:leaf_windows' : null, deliverPendingMessagesForHandle: vi.fn(), notifyMessageArrived: vi.fn(), linearIssueContext: vi.fn(async (request: unknown) => ({ @@ -188,9 +192,18 @@ describe('runRemoteOrcaCli', () => { LEGACY_FALLBACK_OPTIONS ) - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { ok: boolean } - expect(payload.ok).toBe(true) + expect(result.exitCode, result.stdout).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { + warnings: [ + { + code: 'legacy_terminal_recipient', + recipient: 'term_windows' + } + ] + } + }) expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh') }) @@ -210,7 +223,7 @@ describe('runRemoteOrcaCli', () => { LEGACY_FALLBACK_OPTIONS ) - expect(result.exitCode).toBe(0) + expect(result.exitCode, result.stdout).toBe(0) expect(db.insertMessage).toHaveBeenCalledWith( expect.objectContaining({ senderPaneKey: undefined }) ) @@ -481,7 +494,7 @@ describe('runRemoteOrcaCli', () => { LEGACY_FALLBACK_OPTIONS ) - expect(result.exitCode).toBe(0) + expect(result.exitCode, result.stdout).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean } expect(payload.ok).toBe(true) const message = db.getUnreadMessages('term_windows')[0] From b0e27354b565ab8d8dd1bb9b26a6a078811a08ef Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:14:42 -0700 Subject: [PATCH 011/109] fix(mobile): escalate continuous Relay outages (STA-4587) (#15071) --- config/reliability-gates.jsonc | 51 ++++++++++---- .../logical-client-connection-path.ts | 26 +++++++- .../mobile-endpoint-supervisor-test-fakes.ts | 22 ++++++- .../transport/mobile-endpoint-supervisor.ts | 13 ++-- .../mobile-relay-outage-escalation.test.ts | 66 +++++++++++++++++++ .../mobile-relay-reconnect-controller.test.ts | 20 +++++- .../mobile-relay-reconnect-controller.ts | 28 ++++---- .../mobile-relay-runtime-failover.test.ts | 24 ++++++- .../transport/relay-recovery-failure-count.ts | 36 ++++++++++ .../stable-logical-rpc-client.test.ts | 19 ++++++ .../transport/stable-logical-rpc-client.ts | 9 ++- 11 files changed, 270 insertions(+), 44 deletions(-) create mode 100644 mobile/src/transport/mobile-relay-outage-escalation.test.ts create mode 100644 mobile/src/transport/relay-recovery-failure-count.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index e91aee76a78..fe4d1c1fdad 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-14", + "updatedAt": "2026-08-17", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -943,18 +943,24 @@ "providers": ["lan", "tailscale", "cloud-relay"], "coveredPlatforms": ["macos"], "coveredProviders": ["lan", "tailscale", "cloud-relay"], - "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", - "motivatingLinks": ["https://github.com/stablyai/orca-cloud/pull/96"], - "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, and remains pending through a genuine failed-dial cooldown. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", - "oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", + "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, continuous-outage escalation, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca-cloud/pull/96", + "https://linear.app/stably/issue/STA-4587" + ], + "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, remains pending through a genuine failed-dial cooldown, and exposes the supervisor's consecutive failed-dial count so a continuous outage escalates to unreachable. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", + "oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, establish Relay through the supervisor and require its active close plus 11 failed replacements to publish reconnect attempt 12 and a Relay-unreachable verdict, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", "commands": [ - "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot" ], "testFiles": [ "mobile/src/transport/mobile-direct-endpoint-probe.test.ts", "mobile/src/transport/mobile-relay-reconnect-controller.test.ts", "mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "mobile/src/transport/mobile-relay-outage-escalation.test.ts", + "mobile/src/transport/stable-logical-rpc-client.test.ts", + "mobile/src/transport/mobile-relay-runtime-failover.test.ts", "src/shared/mobile-relay-close-codes.test.ts" ], "assertionRefs": [ @@ -981,6 +987,27 @@ "a genuine failed Relay dial remains pending through its retry cooldown" ] }, + { + "file": "mobile/src/transport/mobile-relay-outage-escalation.test.ts", + "assertions": [ + "a supervisor-established Relay close plus 11 failed replacements publish reconnect attempt 12", + "the existing connection classifier escalates the continuous outage to Can't connect via Relay" + ] + }, + { + "file": "mobile/src/transport/stable-logical-rpc-client.test.ts", + "assertions": [ + "supervisor Relay attempts overlay but never replace a larger active-session retry count", + "count-only changes notify connection-path subscribers and clearing recovery restores the physical count" + ] + }, + { + "file": "mobile/src/transport/mobile-relay-runtime-failover.test.ts", + "assertions": [ + "a fresh credential starts Relay presentation with the controller's current count", + "missing or expired credentials never open a Relay socket" + ] + }, { "file": "src/shared/mobile-relay-close-codes.test.ts", "assertions": ["HOST_OFFLINE maps to self-healing full-jitter recovery"] @@ -988,13 +1015,13 @@ ], "evidenceRuns": [ { - "date": "2026-08-16", + "date": "2026-08-17", "runner": "local", "platform": "macos", - "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.test.ts", "result": "passed", - "durationSeconds": 1.23, - "summary": "Three focused mobile transport files passed with 54 assertions." + "durationSeconds": 2.31, + "summary": "Six focused mobile transport files passed with 82 assertions, including production-lifecycle outage escalation and logical count publication." }, { "date": "2026-08-16", @@ -1016,11 +1043,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The prior external-signal HOST_OFFLINE policy fails the retry oracle, and the prior serial direct probe fails the first-authenticated-endpoint timing oracle. Both pass with the candidate behavior." + "evidence": "The pre-fix production-lifecycle oracle establishes Relay through the supervisor, then completes one active close plus 11 replacement failures while the public retry count remains 0; the candidate publishes 12 and reaches the existing Relay-unreachable verdict. The prior external-signal HOST_OFFLINE policy and serial direct probe also fail their respective retry and first-authenticated-endpoint timing oracles." }, "performanceBudget": { "required": true, - "evidence": "All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms in the deterministic test, and every losing client is closed. A physical-device radio and battery budget is still required before promotion." + "evidence": "The production diff only projects the existing supervisor failure count through local setters and subscribers; it adds no call to a Relay dial, timer, socket, probe, or wire API. The deterministic outage asserts exactly one successful establishment plus 11 failed replacements at the escalation boundary. All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms, and every losing client is closed." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history.", diff --git a/mobile/src/transport/logical-client-connection-path.ts b/mobile/src/transport/logical-client-connection-path.ts index cec69bc0118..9af644f30eb 100644 --- a/mobile/src/transport/logical-client-connection-path.ts +++ b/mobile/src/transport/logical-client-connection-path.ts @@ -3,6 +3,7 @@ import type { MobileConnectionPath } from './stable-logical-rpc-client' export class LogicalClientConnectionPath { private migration: MobileConnectionPath | null = null private recovery: MobileConnectionPath | null = null + private recoveryAttempt = 0 private readonly listeners = new Set<() => void>() constructor(private readonly isConnected: () => boolean) {} @@ -17,14 +18,32 @@ export class LogicalClientConnectionPath { }) } + reconnectAttempt(activeAttempt: number): number { + return this.pending() === 'relay' + ? Math.max(activeAttempt, this.recoveryAttempt) + : activeAttempt + } + clearAfterConnected(): void { this.migration = null this.recovery = null + this.recoveryAttempt = 0 } - setRecovery(path: MobileConnectionPath | null): void { + setRecovery(path: MobileConnectionPath | null, attempt?: number): void { this.update(() => { this.recovery = path + if (path === null) { + this.recoveryAttempt = 0 + } else if (attempt !== undefined) { + this.recoveryAttempt = Math.max(0, Math.trunc(attempt)) + } + }) + } + + setRecoveryAttempt(attempt: number): void { + this.update(() => { + this.recoveryAttempt = Math.max(0, Math.trunc(attempt)) }) } @@ -34,9 +53,10 @@ export class LogicalClientConnectionPath { } private update(apply: () => void): void { - const previous = this.pending() + const previousPath = this.pending() + const previousAttempt = this.reconnectAttempt(0) apply() - if (previous === this.pending()) { + if (previousPath === this.pending() && previousAttempt === this.reconnectAttempt(0)) { return } for (const listener of this.listeners) { diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index affa4d12636..57e5dc6b5cf 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -66,6 +66,7 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath private recoveryPath: MobileConnectionPath | null = null + private recoveryAttempt = 0 private generation = 1 private readonly pathListeners = new Set<() => void>() @@ -92,18 +93,35 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl } this.path = path this.recoveryPath = null + this.recoveryAttempt = 0 this.generation += 1 // Connected-state publication carries the migration cleanup. this.publishState('connected') } ) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getReconnectAttempt = () => (this.getPendingPath() === 'relay' ? this.recoveryAttempt : 0) getActivePath = () => this.path getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath) - setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { + setRecoveryPath = vi.fn((path: MobileConnectionPath | null, attempt?: number) => { const previous = this.getPendingPath() + const previousAttempt = this.getReconnectAttempt() this.recoveryPath = path - if (previous !== this.getPendingPath()) { + if (path === null) { + this.recoveryAttempt = 0 + } else if (attempt !== undefined) { + this.recoveryAttempt = attempt + } + if (previous !== this.getPendingPath() || previousAttempt !== this.getReconnectAttempt()) { + for (const listener of this.pathListeners) { + listener() + } + } + }) + setRecoveryAttempt = vi.fn((attempt: number) => { + const previous = this.getReconnectAttempt() + this.recoveryAttempt = attempt + if (previous !== this.getReconnectAttempt()) { for (const listener of this.pathListeners) { listener() } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 8292c871e35..9e17b0db4e6 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -58,6 +58,7 @@ export class MobileEndpointSupervisor { }) this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog) this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this)) + this.relayReconnect.reportFailureCountTo(logical.setRecoveryAttempt) this.nudgeRouter = new MobileEndpointNudgeRouter({ logical, controller: this.relayReconnect, @@ -179,11 +180,7 @@ export class MobileEndpointSupervisor { } else { // Why: background phones must not hold billed relay data splices. this.relayReconnect.suspendActiveRelay(this.logical) - this.directProbe.clear() - this.relayReconnect.clear() - this.leaseRotation.clear() - this.directGrace.clear() - this.logical.setRecoveryPath(null) + this.clearScheduledRecovery() } } @@ -193,6 +190,10 @@ export class MobileEndpointSupervisor { this.stopped = true this.unsubscribeState?.() this.unsubscribeState = null + this.clearScheduledRecovery() + } + + private clearScheduledRecovery(): void { this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() @@ -268,7 +269,7 @@ export class MobileEndpointSupervisor { if (this.stopped || !this.foreground || !recoveryNeeded) { return } - this.logical.setRecoveryPath('relay') + this.logical.setRecoveryPath('relay', this.relayReconnect.getFailureCount()) const dialed = await this.sessionEstablisher.dialEligible(selection.credentials) if (dialed.outcome === 'established') { // Why: a fresh socket satisfies any replacement intent queued mid-dial. diff --git a/mobile/src/transport/mobile-relay-outage-escalation.test.ts b/mobile/src/transport/mobile-relay-outage-escalation.test.ts new file mode 100644 index 00000000000..93694f1efac --- /dev/null +++ b/mobile/src/transport/mobile-relay-outage-escalation.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { classifyConnection } from './connection-health' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { + dependencies, + FakeRelaySession, + FakeSession, + host +} from './mobile-endpoint-supervisor-test-fakes' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import { createStableLogicalRpcClient } from './stable-logical-rpc-client' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +describe('continuous Relay outage escalation', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-17T04:00:00Z')) + }) + + afterEach(() => vi.useRealTimers()) + + it('publishes supervisor recovery attempts and escalates the Relay verdict', async () => { + const failure = new RelayOuterError(4408) + const activeRelay = new FakeRelaySession('connected', failure) + activeRelay.getLastConnectedAt = () => Date.now() - 120_000 + const logical = createStableLogicalRpcClient(new FakeSession('disconnected'), 'tailscale') + const publishedAttempts: number[] = [] + logical.onConnectionPathChange(() => publishedAttempts.push(logical.getReconnectAttempt())) + const openRelay = vi.fn(() => { + const session = new FakeRelaySession('connecting', failure) + setTimeout(() => session.publishState('disconnected'), 0) + return session + }) + openRelay.mockReturnValueOnce(activeRelay) + const deps = dependencies({ + openDirect: vi.fn(() => new FakeSession('disconnected')), + openRelay, + randomBytes: () => new Uint8Array([0, 0]) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + activeRelay.publishState('disconnected') + await vi.advanceTimersByTimeAsync(3_000) + + expect(openRelay).toHaveBeenCalledTimes(12) + expect(logical.getPendingPath()).toBe('relay') + expect(logical.getReconnectAttempt()).toBe(12) + expect(publishedAttempts.at(-1)).toBe(12) + expect( + classifyConnection({ + state: logical.getState(), + reconnectAttempts: logical.getReconnectAttempt(), + lastConnectedAt: logical.getLastConnectedAt(), + pendingPath: logical.getPendingPath() + }) + ).toMatchObject({ kind: 'unreachable', label: "Can't connect via Relay" }) + supervisor.stop() + logical.close() + }) +}) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index 4f50878c6d5..2854ac70a18 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -49,6 +49,17 @@ describe('relay reconnect controller', () => { expect(onRetry).not.toHaveBeenCalled() }) + it('publishes consecutive failures and the direct-connection reset', () => { + const published: number[] = [] + const reconnect = createController(vi.fn(), (count) => published.push(count)) + + reconnect.registerFailure(new RelayOuterError(4408), false) + reconnect.registerFailure(new RelayOuterError(4408), false) + reconnect.resetForDirectConnection() + + expect(published).toEqual([0, 1, 2, 0]) + }) + it('reprobes slowly after rejected E2EE authentication instead of parking forever', () => { // Why: on a relay-only phone a permanent gate is a permanent outage — the // desktop can commit pairing credentials moments after the first rejection. @@ -267,8 +278,11 @@ describe('relay reconnect controller', () => { }) }) -function createController(onRetry: () => void): RelayReconnectController { - return new RelayReconnectController( +function createController( + onRetry: () => void, + reportFailureCount: (count: number) => void = () => {} +): RelayReconnectController { + const controller = new RelayReconnectController( { now: Date.now, randomBytes: () => new Uint8Array([128, 0]), @@ -277,4 +291,6 @@ function createController(onRetry: () => void): RelayReconnectController { }, onRetry ) + controller.reportFailureCountTo(reportFailureCount) + return controller } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index ff67750c0e0..db28b81b948 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -8,6 +8,7 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { RelayOuterError } from './mobile-relay-e2ee-link' import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' import { RelayCredentialEligibility } from './relay-credential-eligibility' +import { RelayRecoveryFailureCount } from './relay-recovery-failure-count' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState, ForegroundNudgeReason } from './types' @@ -23,7 +24,7 @@ export type RelayReconnectDependencies = { type RecoveryGate = 'external-signal' | 'fresh-credential' export class RelayReconnectController { - private consecutiveFailures = 0 + private readonly failureCount = new RelayRecoveryFailureCount(RELAY_STABLE_CONNECTION_MS) private activeRelayConnectedAt: number | null = null private nextAttemptAt = 0 private timer: ReturnType | null = null @@ -42,6 +43,14 @@ export class RelayReconnectController { this.credentials = new RelayCredentialEligibility(dependencies.now) } + getFailureCount(): number { + return this.failureCount.current() + } + + reportFailureCountTo(reporter: (count: number) => void): void { + this.failureCount.reportTo(reporter) + } + handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { // Why: an app resume is a fresh signal, unlike repeated network-flap @@ -116,7 +125,7 @@ export class RelayReconnectController { // Why: the rejected credential stays unusable until its replacement is // durable. No reprobe timer here — direct is live, rotation over it // clears the gate, and any later state failure re-arms via shouldDefer. - this.consecutiveFailures = 0 + this.failureCount.reset() this.nextAttemptAt = 0 this.recoveryGate = 'fresh-credential' this.gateReprobePending = false @@ -239,20 +248,13 @@ export class RelayReconnectController { return } const now = this.dependencies.now() - if ( - this.activeRelayConnectedAt != null && - now - this.activeRelayConnectedAt >= RELAY_STABLE_CONNECTION_MS - ) { - this.consecutiveFailures = 0 - } - // Why: elapsed time inside a slow failed dial is not evidence of recovery; - // only an authenticated relay that survived the stability window resets the streak. + const failureCount = this.failureCount.recordAfterConnection(this.activeRelayConnectedAt, now) + // Why: only a stable authenticated Relay resets the failure streak. this.activeRelayConnectedAt = null - this.consecutiveFailures += 1 const delay = recovery?.kind === 'retry-after-host-offline' ? this.delays.hostOfflineDelayMs() - : this.delays.transportDelayMs(this.consecutiveFailures) + : this.delays.transportDelayMs(failureCount) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { // Why: an E2EE rejection is usually pairing revocation, but it also fires @@ -304,7 +306,7 @@ export class RelayReconnectController { } reset(): void { - this.consecutiveFailures = 0 + this.failureCount.reset() this.activeRelayConnectedAt = null this.nextAttemptAt = 0 this.liftGate() diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 9ef0ea70c04..5506129dca7 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -91,6 +91,7 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath private recoveryPath: MobileConnectionPath | null = null + private recoveryAttempt = 0 private generation = 1 private readonly pathListeners = new Set<() => void>() @@ -106,17 +107,34 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { } this.path = path this.recoveryPath = null + this.recoveryAttempt = 0 this.generation += 1 // Connected-state publication carries the migration cleanup. this.publishState('connected') }) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getReconnectAttempt = () => (this.getPendingPath() === 'relay' ? this.recoveryAttempt : 0) getActivePath = () => this.path getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath) - setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { + setRecoveryPath = vi.fn((path: MobileConnectionPath | null, attempt?: number) => { const previous = this.getPendingPath() + const previousAttempt = this.getReconnectAttempt() this.recoveryPath = path - if (previous !== this.getPendingPath()) { + if (path === null) { + this.recoveryAttempt = 0 + } else if (attempt !== undefined) { + this.recoveryAttempt = attempt + } + if (previous !== this.getPendingPath() || previousAttempt !== this.getReconnectAttempt()) { + for (const listener of this.pathListeners) { + listener() + } + } + }) + setRecoveryAttempt = vi.fn((attempt: number) => { + const previous = this.getReconnectAttempt() + this.recoveryAttempt = attempt + if (previous !== this.getReconnectAttempt()) { for (const listener of this.pathListeners) { listener() } @@ -266,7 +284,7 @@ describe('relay runtime recovery without direct connectivity', () => { await vi.advanceTimersByTimeAsync(60_000) expect(deps.openRelay).toHaveBeenCalledOnce() - expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay') + expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay', 0) expect(logical.getActivePath()).toBe('relay') supervisor.stop() }) diff --git a/mobile/src/transport/relay-recovery-failure-count.ts b/mobile/src/transport/relay-recovery-failure-count.ts new file mode 100644 index 00000000000..1bbadbcd400 --- /dev/null +++ b/mobile/src/transport/relay-recovery-failure-count.ts @@ -0,0 +1,36 @@ +export class RelayRecoveryFailureCount { + private count = 0 + private reporter: ((count: number) => void) | null = null + + constructor(private readonly stableConnectionMs: number) {} + + current(): number { + return this.count + } + + reportTo(reporter: (count: number) => void): void { + this.reporter = reporter + reporter(this.count) + } + + record(resetFirst: boolean): number { + this.update((resetFirst ? 0 : this.count) + 1) + return this.count + } + + recordAfterConnection(connectedAt: number | null, now: number): number { + return this.record(connectedAt != null && now - connectedAt >= this.stableConnectionMs) + } + + reset(): void { + this.update(0) + } + + private update(count: number): void { + if (this.count === count) { + return + } + this.count = count + this.reporter?.(count) + } +} diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 132530440ce..0f38579465c 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -302,6 +302,25 @@ describe('stable logical RPC client', () => { expect(paths).toEqual(['relay', null]) }) + it('publishes supervisor Relay attempts without replacing the physical retry count', () => { + const direct = new FakeSession('reconnecting') + direct.getReconnectAttempt = () => 5 + const client = createStableLogicalRpcClient(direct, 'tailscale') + const attempts: number[] = [] + client.onConnectionPathChange(() => attempts.push(client.getReconnectAttempt())) + + client.setRecoveryPath('relay', 3) + expect(client.getReconnectAttempt()).toBe(5) + + client.setRecoveryAttempt(7) + expect(client.getReconnectAttempt()).toBe(7) + expect(attempts).toEqual([5, 7]) + + client.setRecoveryPath(null) + expect(client.getReconnectAttempt()).toBe(5) + expect(attempts).toEqual([5, 7, 5]) + }) + it('does not revive a stale recovery path after a connection later drops', () => { const direct = new FakeSession('reconnecting') const client = createStableLogicalRpcClient(direct, 'tailscale') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 0a1062eeadc..52c8165ba70 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -50,7 +50,9 @@ export type StableLogicalRpcClient = RpcClient & { getActivePath(): MobileConnectionPath // The path the user is waiting on while migration or scheduled recovery is active. getPendingPath(): MobileConnectionPath | null - setRecoveryPath(path: MobileConnectionPath | null): void + setRecoveryPath(path: MobileConnectionPath | null, attempt?: number): void + setRecoveryAttempt(attempt: number): void + // Recovery attempts share this signal so status-only changes rerender. onConnectionPathChange(listener: () => void): () => void getGeneration(): number } @@ -153,7 +155,7 @@ export function createStableLogicalRpcClient( }, getState: () => state, - getReconnectAttempt: () => activeSession.getReconnectAttempt(), + getReconnectAttempt: () => connectionPath.reconnectAttempt(activeSession.getReconnectAttempt()), getLastConnectedAt: () => activeSession.getLastConnectedAt(), onStateChange(listener) { stateListeners.add(listener) @@ -272,7 +274,8 @@ export function createStableLogicalRpcClient( // Why: a previous session that recovers mid-dial makes the pending path a lie — // once we're connected the user is no longer waiting on anything. getPendingPath: () => connectionPath.pending(), - setRecoveryPath: (path) => connectionPath.setRecovery(path), + setRecoveryPath: (path, attempt) => connectionPath.setRecovery(path, attempt), + setRecoveryAttempt: (attempt) => connectionPath.setRecoveryAttempt(attempt), onConnectionPathChange: (listener) => connectionPath.subscribe(listener), getGeneration: () => generation } From 7ae6aedc02dbca2a9edc56e18df153ad81cabb6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:19:57 -0700 Subject: [PATCH 012/109] fix(codex): stop a transient filesystem error from logging out the active account (#15046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): stop a transient filesystem error from logging out the active account A single unreadable read of a managed Codex home's ownership marker cleared the user's active account selection, permanently. On Windows any exclusive lock — Defender real-time scanning, a backup agent, a sync client — makes every read of that marker fail with EBUSY, and the background rate-limit poll runs every 15 minutes plus once at every app start. Root cause: the ownership gate answered two very different questions through one channel. "This home is not ours" (a successful observation that failed a trust check) and "we could not read it" both surfaced as a throw, which the caller flattened to null, which three call sites took as proof the home was untrustworthy and wrote activeCodexManagedAccountId: null. Refusing to USE an unverified home is correct. Erasing the user's account selection because a file was briefly locked is not. The gate now returns a tri-state verdict. `untrusted` comes only from a proven trust failure or a definitive ENOENT/ENOTDIR where absence is itself the verdict; every other filesystem exception is `indeterminate`. Only `untrusted` may touch persisted state. Because `null` already meant "fall through to the system default" on both the launch and poll paths, not-clearing on its own would have run a DIFFERENT account behind a UI still showing the selected one. So the refusal needed real channels rather than a sentinel: - the poll returns an explicit skip; returning null would not have skipped at all, since the fetcher maps null to ~/.codex and would have spawned a token-refreshing app-server inside the user's real credential home - pane launch throws a typed temporary-unavailability error that both PTY implementations convert into a clean refusal with a retry message, including the re-resolution after the async auth-readiness wait - automatic session resume resolves the selected home eagerly, so an unreadable account can no longer be silently replaced by another one in the ranking - config-sync status reports a distinct managed-home-unavailable stall instead of "synced", with a bounded renderer retry so it clears on its own Also fixes the ticket's second symptom. The status bar's Sign in button called a re-auth that captured the selection before login and restored it after, so re-authenticating a deselected account restored `null` — a successful login that left the account inactive, with no success toast to distinguish it from failure. It now activates the account it just signed in, but only when the pre-login selection was empty, so it cannot silently switch accounts for multi-account users, and it runs the same restart prompt an explicit switch does. No retry or grace window inside the synchronous gate: it runs on the Electron main process in a loop over accounts, so a sleep there would freeze the UI. Recovery is simply the next readable evaluation. The WSL lane has the same class of defect, including one path that deletes a credential mirror. It is pre-existing, unreachable from these host code paths, and deliberately left for its own change; the host clearing sites cannot reach a WSL account because getSelfContainedManagedHostAccount excludes them. Fixes STA-4422 * test(codex): cover pending reset home ownership --- .../host-codex-managed-home-ownership.ts | 168 ++++++-- ...runtime-home-managed-auth-recovery.test.ts | 5 +- .../runtime-home-mirrored-status-home.test.ts | 12 +- .../runtime-home-per-account-homes.test.ts | 14 +- ...untime-home-real-home-lane-routing.test.ts | 20 +- ...runtime-home-resume-selection-gate.test.ts | 176 +++++++++ ...time-home-retained-auth-provenance.test.ts | 55 ++- ...home-service-per-account-migration.test.ts | 15 +- .../codex-accounts/runtime-home-service.ts | 169 +++++++-- .../runtime-home-wsl-managed-accounts.test.ts | 12 +- .../runtime-home-wsl-system-default.test.ts | 31 +- .../service-reauthenticate-activation.test.ts | 206 ++++++++++ ...ervice-reset-credit-home-ownership.test.ts | 251 ++++++++++++ ...ervice-reset-credit-target-routing.test.ts | 2 +- .../codex-accounts/service-test-harness.ts | 7 +- src/main/codex-accounts/service.ts | 107 ++++-- ...sta-4422-transient-ownership-error.test.ts | 221 +++++++++++ .../codex-ai-vault-session-resume.test.ts | 81 ++++ .../codex/codex-ai-vault-session-resume.ts | 27 ++ src/main/index.ts | 39 +- src/main/ipc/codex-accounts.ts | 8 +- src/main/ipc/codex-config-sync.test.ts | 26 +- src/main/ipc/codex-config-sync.ts | 15 +- .../pty-spawn-codex-home-unavailable.test.ts | 359 ++++++++++++++++++ src/main/ipc/pty.ts | 87 +++-- .../service-account-target-selection.test.ts | 98 ++++- .../service-inactive-account-previews.test.ts | 109 +++++- src/main/rate-limits/service.ts | 128 +++++-- .../commit-message-agent-environment.test.ts | 21 + src/preload/api/agent-account-api.ts | 6 +- src/preload/index.ts | 6 +- .../src/components/settings/AccountsPane.tsx | 94 +++-- .../src/components/status-bar/StatusBar.tsx | 47 ++- .../status-bar/codex-status-sign-in.test.tsx | 262 +++++++++++++ src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- src/shared/codex-config-sync-types.ts | 10 +- 40 files changed, 2637 insertions(+), 282 deletions(-) create mode 100644 src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts create mode 100644 src/main/codex-accounts/service-reauthenticate-activation.test.ts create mode 100644 src/main/codex-accounts/service-reset-credit-home-ownership.test.ts create mode 100644 src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts create mode 100644 src/main/codex/codex-ai-vault-session-resume.test.ts create mode 100644 src/main/codex/codex-ai-vault-session-resume.ts create mode 100644 src/main/ipc/pty-spawn-codex-home-unavailable.test.ts create mode 100644 src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts index 6f0603c73f4..8dfebe8df1d 100644 --- a/src/main/codex-accounts/host-codex-managed-home-ownership.ts +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' +import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' type HostCodexManagedHomeOwnershipOptions = { @@ -8,6 +8,43 @@ type HostCodexManagedHomeOwnershipOptions = { expectedAccountId?: string } +export const MISSING_MANAGED_HOME_MESSAGE = 'Managed Codex home directory does not exist on disk.' + +/** + * Why: the gate answers two different questions and callers act on them very + * differently. `untrusted` is a *successful observation* that failed a trust + * check (or a definitive absence where absence is itself the verdict), and only + * it may clear the user's persisted account selection. `indeterminate` means we + * could not read the home at all — the home may be perfectly valid, so callers + * must refuse to *use* it without erasing durable state (#STA-4422: one EBUSY + * from an antivirus lock used to log the user out permanently). + */ +export type HostCodexManagedHomeVerdict = + | { kind: 'owned'; homePath: string } + | { kind: 'untrusted'; reason: string } + | { kind: 'indeterminate'; error: unknown } + +/** Thrown for a proven trust failure; safe to clear selection on. */ +export class UntrustedManagedCodexHomeError extends Error {} + +/** + * Thrown when the home could not be read. Callers must refuse the operation and + * leave persisted selection, credentials, and managed directories untouched. + */ +export class ManagedCodexHomeTemporarilyUnavailableError extends Error { + constructor( + message = 'Codex account files are temporarily locked. Retry in a moment.', + options?: { cause?: unknown } + ) { + super(message, options) + } +} + +function isDefinitiveAbsence(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + function pathsEqual(left: string, right: string): boolean { const resolvedLeft = resolve(left) const resolvedRight = resolve(right) @@ -24,73 +61,148 @@ function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean { ) } +/** + * Why: the system Codex home is optional — a user with no ~/.codex is normal, + * and its absence says nothing about the managed candidate. Only a definitive + * absence may fall back to the resolved spelling; any other read error is + * inconclusive and must reach the caller as `indeterminate`. + */ function canonicalizeIfPresent(candidatePath: string): string { const resolvedPath = resolve(candidatePath) - return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath + try { + return realpathSync(resolvedPath) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return resolvedPath + } + throw error + } } -/** - * Proves a host managed home is an Orca-owned account directory and cannot - * resolve into the user's real CODEX_HOME before callers write launch state. - */ -export function assertOwnedHostCodexManagedHomePath({ +function evaluate({ candidatePath, managedAccountsRoot, systemCodexHomePath, expectedAccountId -}: HostCodexManagedHomeOwnershipOptions): string { +}: HostCodexManagedHomeOwnershipOptions): HostCodexManagedHomeVerdict { const resolvedCandidate = resolve(candidatePath) const resolvedRoot = resolve(managedAccountsRoot) - if (!existsSync(resolvedCandidate)) { - throw new Error('Managed Codex home directory does not exist on disk.') + + // Why: absence of the candidate IS the structural verdict here, but only a + // definitive one. `existsSync` used to fold EPERM into "does not exist", which + // is what made an antivirus lock look like a deleted account. + let canonicalCandidate: string + try { + statSync(resolvedCandidate) + canonicalCandidate = realpathSync(resolvedCandidate) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: MISSING_MANAGED_HOME_MESSAGE } + } + return { kind: 'indeterminate', error } } - // Why: macOS can spell one path as /var and /private/var, while a replaced - // path component must still be caught as a canonical containment escape. - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(resolvedRoot) - const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath) + + let canonicalRoot: string + let canonicalSystemHome: string + try { + canonicalRoot = realpathSync(resolvedRoot) + canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath) + } catch (error) { + return { kind: 'indeterminate', error } + } + if (expectedAccountId !== undefined) { const candidateUsesManagedRootSpelling = pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) || pathIsInsideOrEqual(canonicalRoot, resolvedCandidate) - const canonicalExpectedHome = canonicalizeIfPresent( - join(canonicalRoot, expectedAccountId, 'home') - ) + let canonicalExpectedHome: string + try { + canonicalExpectedHome = canonicalizeIfPresent(join(canonicalRoot, expectedAccountId, 'home')) + } catch (error) { + return { kind: 'indeterminate', error } + } if ( !candidateUsesManagedRootSpelling || !pathsEqual(canonicalCandidate, canonicalExpectedHome) ) { - throw new Error('Managed Codex home does not match its persisted account ID.') + return { + kind: 'untrusted', + reason: 'Managed Codex home does not match its persisted account ID.' + } } } + // Why: a replaced codex-accounts directory could otherwise redirect config, // hook, or resource writes into the user's real ~/.codex tree. if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) { - throw new Error('Managed Codex home resolves inside the system Codex home.') + return { + kind: 'untrusted', + reason: 'Managed Codex home resolves inside the system Codex home.' + } } if ( !pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) || canonicalRoot === canonicalCandidate ) { - throw new Error( - `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` - ) + return { + kind: 'untrusted', + reason: `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` + } } const markerPath = join(canonicalCandidate, '.orca-managed-home') let markerIsRegularFile: boolean + let markerContents: string try { markerIsRegularFile = lstatSync(markerPath).isFile() + markerContents = markerIsRegularFile ? readFileSync(markerPath, 'utf-8') : '' } catch (error) { - throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error }) + // Why: the marker is required, so its definitive absence is structural — but + // an unreadable marker is not evidence of anything. + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: 'Managed Codex home is missing Orca ownership marker.' } + } + return { kind: 'indeterminate', error } } if (!markerIsRegularFile) { - throw new Error('Managed Codex home ownership marker is not a regular file.') + return { + kind: 'untrusted', + reason: 'Managed Codex home ownership marker is not a regular file.' + } } - const markerContents = readFileSync(markerPath, 'utf-8') if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) { - throw new Error('Managed Codex home ownership marker does not match its account ID.') + return { + kind: 'untrusted', + reason: 'Managed Codex home ownership marker does not match its account ID.' + } } - return canonicalCandidate + return { kind: 'owned', homePath: canonicalCandidate } +} + +/** Non-throwing verdict; callers decide how to treat each kind. */ +export function resolveHostCodexManagedHomeVerdict( + options: HostCodexManagedHomeOwnershipOptions +): HostCodexManagedHomeVerdict { + return evaluate(options) +} + +/** + * Throwing wrapper for write paths (add, re-auth, migration) that must never + * proceed on an unproven home. Distinct error classes keep an unreadable home + * from being mistaken for a deleted one — `isMissingManagedHomeError` must not + * match the temporary case, or re-auth would recreate the home and rewrite its + * ownership marker after a transient stat failure. + */ +export function assertOwnedHostCodexManagedHomePath( + options: HostCodexManagedHomeOwnershipOptions +): string { + const verdict = evaluate(options) + if (verdict.kind === 'owned') { + return verdict.homePath + } + if (verdict.kind === 'untrusted') { + throw new UntrustedManagedCodexHomeError(verdict.reason) + } + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: verdict.error }) } diff --git a/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts b/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts index 6df03f63e77..a3a4355130d 100644 --- a/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts +++ b/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts @@ -101,7 +101,10 @@ describe('CodexRuntimeHomeService', () => { expect(warnSpy).not.toHaveBeenCalled() expect(service.isHostSystemDefaultRealHome()).toBe(false) - expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: managedHomePath1 + }) expect(service.prepareForCodexLaunch()).toBe(managedHomePath1) expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false) diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index d771ab9cbfd..58b48c099bd 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -76,7 +76,7 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(createStore([], null) as never) - expect(service.getMirroredHostHomePathForStatus()).toBeNull() + expect(service.getMirroredHostHomePathForStatus()).toEqual({ kind: 'ready', homePath: null }) }) it('returns the selected account own home, which is what its mirror targets', async () => { @@ -84,7 +84,10 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(createStore([account], account.id) as never) - expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath) + expect(service.getMirroredHostHomePathForStatus()).toEqual({ + kind: 'ready', + homePath: account.managedHomePath + }) }) it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => { @@ -95,6 +98,9 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { // Why: compare against the real helper, not a repeated literal, so the // status cannot silently drift if the managed home layout ever moves. - expect(service.getMirroredHostHomePathForStatus()).toBe(getOrcaManagedCodexHomePath()) + expect(service.getMirroredHostHomePathForStatus()).toEqual({ + kind: 'ready', + homePath: getOrcaManagedCodexHomePath() + }) }) }) diff --git a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts index f8accc5ada5..fd4823acfbc 100644 --- a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts +++ b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts @@ -221,7 +221,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForRateLimitFetch()).toBe(home1) + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: home1 }) }) it('preserves a managed selection whose auth.json is temporarily missing', async () => { @@ -253,7 +253,7 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(brokenHome) - expect(service.prepareForRateLimitFetch()).toBe(brokenHome) + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: brokenHome }) expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') }) @@ -435,12 +435,18 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } const syncSpy = vi.spyOn(service, 'syncForCurrentSelection') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) expect(syncSpy).not.toHaveBeenCalled() - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(service.prepareForCodexLaunch()).toBeNull() expect(syncSpy).not.toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts index 943261f66b2..9bd55bba6e4 100644 --- a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts +++ b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts @@ -109,7 +109,10 @@ describe('CodexRuntimeHomeService', () => { expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( true ) - expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getRuntimeCodexHomePath() + }) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) @@ -176,13 +179,19 @@ describe('CodexRuntimeHomeService', () => { try { // Background fetchers prefer ambient CODEX_HOME when passed null, so an // explicit path proves nested Orca launches cannot poll the managed home. - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) process.env.CODEX_HOME = getSystemCodexHomePath() delete process.env.ORCA_CODEX_HOME expect(service.isHostSystemDefaultRealHome()).toBe(true) process.env.CODEX_HOME = join(testState.fakeHomeDir, 'user-owned-codex-home') expect(service.isHostSystemDefaultRealHome()).toBe(false) - expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getRuntimeCodexHomePath() + }) } finally { if (previousCodexHome === undefined) { delete process.env.CODEX_HOME @@ -242,7 +251,10 @@ describe('CodexRuntimeHomeService', () => { service.reconcileLegacySharedHomeForRetainedPanes() expect(service.prepareForCodexLaunch()).toBeNull() - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(syncLegacySharedCodexConfigForRetainedPanes).not.toHaveBeenCalled() expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) diff --git a/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts b/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts new file mode 100644 index 00000000000..7e212fc5e25 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { realpathSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import type { CodexRuntimeHomeService } from './runtime-home-service' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getSystemCodexAuthPath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// STA-4422 P1g: automatic session resume picks the resumed pane's CODEX_HOME, +// i.e. its account. The read-only resolver collapses an unreadable home to +// `null`, which the ranking reads as "no account selected" — another account's +// readable alias then wins and the pane resumes under the wrong credentials. +// The resume boundary therefore resolves the selection eagerly through a +// refusing gate. + +const lstatFaults = vi.hoisted(() => { + const state = { + /** paths that fail on EVERY read until released (models a held AV lock) */ + held: new Set(), + reads: new Map(), + hold(path: string): void { + state.held.add(path) + }, + release(path: string): void { + state.held.delete(path) + }, + heldReads(path: string): number { + return state.reads.get(path) ?? 0 + }, + reset(): void { + state.held.clear() + state.reads.clear() + }, + consume(target: unknown): void { + if (typeof target !== 'string' || !state.held.has(target)) { + return + } + state.reads.set(target, (state.reads.get(target) ?? 0) + 1) + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, lstat '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = 'lstat' + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const original = actual.lstatSync as (...args: unknown[]) => unknown + const patched: Record = { + ...actual, + lstatSync: Object.assign((...args: unknown[]): unknown => { + lstatFaults.consume(args[0]) + return original(...args) + }, original) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +async function createServiceWithSelectedAccount(): Promise<{ + service: CodexRuntimeHomeService + store: ReturnType + managedHomePath: string +}> { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-a', + createCodexAuthJson('a@example.com', 'acct-a', 'refresh-a') + ) + createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'refresh-b') + ) + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', managedHomePath), + createCodexAccountRecord( + 'account-b', + 'b@example.com', + 'acct-b', + join(testState.userDataDir, 'codex-accounts', 'account-b', 'home') + ) + ], + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + return { service: new CodexRuntimeHomeService(store as never), store, managedHomePath } +} + +describe('CodexRuntimeHomeService.resolveSelectedHostAccountCodexHomePathForResume', () => { + beforeEach(() => { + lstatFaults.reset() + setupRuntimeHomeTest() + }) + + afterEach(() => { + lstatFaults.reset() + teardownRuntimeHomeTest() + }) + + it('refuses the resume while the selected account marker is locked, instead of reporting no selection', async () => { + const { service, store, managedHomePath } = await createServiceWithSelectedAccount() + const { ManagedCodexHomeTemporarilyUnavailableError } = + await import('./host-codex-managed-home-ownership') + + // Anchor: a readable marker resolves the selected account's own home. + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath) + + const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home') + lstatFaults.hold(markerPath) + + expect(() => service.resolveSelectedHostAccountCodexHomePathForResume()).toThrow( + ManagedCodexHomeTemporarilyUnavailableError + ) + // The injected fault really was consumed by the gate under test. + expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0) + // Why this is the whole point: the read-only resolver still answers `null`, + // which the resume ranking cannot distinguish from "system default", so the + // resume boundary must not use it. + expect(service.getSelectedHostAccountCodexHomePath()).toBeNull() + // Refusing is not clearing. + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-a') + expect(store.updateSettings).not.toHaveBeenCalled() + + // Recovery is automatic once the lock clears. + lstatFaults.release(markerPath) + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath) + }) + + it('reports no managed selection for a proven untrusted home rather than refusing', async () => { + const { service, store, managedHomePath } = await createServiceWithSelectedAccount() + + // A successful observation that fails a trust check: the marker names + // another account. The resume must proceed on the system/default ranking. + writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBeNull() + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() + expect(store.updateSettings).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts b/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts index ba41cb68dd0..29205f4472c 100644 --- a/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts +++ b/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts @@ -178,7 +178,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -266,9 +269,15 @@ describe('CodexRuntimeHomeService', () => { service.syncForCurrentSelection() writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -287,7 +296,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -305,7 +317,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') rmSync(getSystemCodexAuthPath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) }) @@ -331,7 +346,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -371,7 +389,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) }) @@ -390,7 +411,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) }) @@ -405,11 +429,17 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) }) @@ -443,7 +473,10 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') - expect(restartedService.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(restartedService.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) } ) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index be9901799ac..20b2ef75839 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -136,7 +136,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated) expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath) writeFileSync(sharedAuthPath(), laterShared, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: account.managedHomePath + }) expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath) expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated) expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n') @@ -157,7 +160,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } expect(service.prepareForCodexLaunch()).toBeNull() - expect(service.prepareForRateLimitFetch()).toBe(systemHome()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: systemHome() + }) expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(fresh) expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(mismatch) expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n') @@ -180,7 +186,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { writeFileSync(sharedAuthPath(), laterShared, 'utf-8') expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath) - expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: account.managedHomePath + }) expect(existsSync(accountAuthPath)).toBe(false) expect(settings.activeCodexManagedAccountId).toBe(account.id) expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 195e2a719df..62ef099031b 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -72,7 +72,10 @@ import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' +import { + ManagedCodexHomeTemporarilyUnavailableError, + resolveHostCodexManagedHomeVerdict +} from './host-codex-managed-home-ownership' import { codexAuthCouldBelongToManagedAccount, codexAuthIsFresher, @@ -169,6 +172,20 @@ function codexAuthIsMonotonicallyFresher( return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) } +/** + * Why: a skipped Codex quota poll must be distinguishable from "use the + * system-default home". `null` inside `ready` still means the system lane; + * `skip` means do not fetch at all this cycle (#STA-4422). + */ +/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */ +export type CodexMirroredHomeStatus = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable' } + +export type CodexRateLimitHomeResolution = + | { kind: 'ready'; codexHomePath: string | null } + | { kind: 'skip' } + export class CodexRuntimeHomeService { // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. private lastSyncedAccountId: string | null = null @@ -345,11 +362,18 @@ export class CodexRuntimeHomeService { account: CodexManagedAccount, unavailableManagedHomePath?: string ): string | null { - const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) - if (!perAccountHome) { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: refuse the launch rather than silently falling through to the + // system default, which would run a different account behind a UI still + // showing this one. The selection stays put; a later read may succeed. + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { this.clearSelfContainedManagedSelection(account) return null } + const perAccountHome = resolved.homePath if ( unavailableManagedHomePath && normalizeRuntimePathForComparison(unavailableManagedHomePath) === @@ -407,7 +431,13 @@ export class CodexRuntimeHomeService { // hot-swap or token read-back to reconcile. A trusted home remains selected // while Codex atomically replaces auth.json. private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: a sync runs on every app start, exactly when antivirus is busiest. + // An unreadable home must not deselect the account (#STA-4422). + return + } + const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null if (perAccountHome) { this.lastSyncedAccountId = account.id this.lastHostAccountUsedSelfContainedHome = true @@ -421,21 +451,41 @@ export class CodexRuntimeHomeService { this.clearSelfContainedManagedSelection(account) } - private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { - try { - assertOwnedHostCodexManagedHomePath({ - candidatePath: account.managedHomePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) + /** + * Why: an unreadable home and an untrustworthy one demand opposite responses. + * Only `untrusted` may clear the user's selection; `indeterminate` means we + * could not tell, so callers refuse the operation and leave durable state + * alone (#STA-4422). + */ + private resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { + const verdict = resolveHostCodexManagedHomeVerdict({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + if (verdict.kind === 'owned') { // Preserve the persisted path spelling (notably /var vs /private/var on // macOS) so injected CODEX_HOME stays stable across the rollout. - return account.managedHomePath - } catch (error) { - console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error) - return null + return { kind: 'owned', homePath: account.managedHomePath } } + if (verdict.kind === 'untrusted') { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) + return { kind: 'untrusted' } + } + console.warn( + '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', + verdict.error + ) + return { kind: 'indeterminate' } + } + + /** Read-only callers that mutate nothing and simply skip an unusable home. */ + private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' ? resolved.homePath : null } private clearSelfContainedManagedSelection( @@ -539,6 +589,42 @@ export class CodexRuntimeHomeService { : null } + /** + * Same selection, but an unreadable home refuses instead of collapsing to + * `null`. Session resume must not read "no managed selection" out of a failed + * marker stat: another account's readable alias would then win the legacy + * rescan and the pane would resume under that account's credentials while the + * UI still shows this one (#STA-4422). + */ + resolveSelectedHostAccountCodexHomePathForResume(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (!selfContainedAccount) { + return null + } + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(selfContainedAccount) + return null + } + return resolved.homePath + } + + /** Trust-gates host previews without changing WSL routing or durable account state. */ + resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { + if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { + return { kind: 'ready', homePath: account.managedHomePath } + } + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' + ? { kind: 'ready', homePath: resolved.homePath } + : { kind: 'skip' } + } + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { if (this.getSelfContainedManagedHostAccount()) { return 'account-home' @@ -672,22 +758,32 @@ export class CodexRuntimeHomeService { syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) } - prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { + // Why: `null` is a real value here — it means "use the system-default lane". + // A skipped poll needs its own channel or the fetcher silently retargets the + // user's real ~/.codex (#STA-4422). + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) - return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + return { + kind: 'ready', + codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() - const selfContainedHome = selfContainedAccount - ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) - : null - if (selfContainedAccount && selfContainedHome) { - // Why: the quota fetch reads the account's own auth.json in place; no - // shared-home hot-swap or per-poll resource relink (that is launch prep). - return selfContainedHome - } if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'owned') { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap or per-poll resource relink (that is launch prep). + return { kind: 'ready', codexHomePath: resolved.homePath } + } + if (resolved.kind === 'indeterminate') { + // Why: returning null here would NOT skip — the fetcher maps null to + // ~/.codex and would probe the user's real home with a token-refreshing + // app-server. Skip the poll outright and keep the selection. + return { kind: 'skip' } + } this.clearSelfContainedManagedSelection(selfContainedAccount) } if (this.isHostSystemDefaultRealHome()) { @@ -698,12 +794,12 @@ export class CodexRuntimeHomeService { if (hasRecordedLegacySharedCodexPane()) { this.syncLegacySharedSystemDefaultAuthForRetainedPanes() } - return getSystemCodexHomePath() + return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - return this.getRuntimeHomePath() + return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } } syncForCurrentSelection( @@ -1310,15 +1406,24 @@ export class CodexRuntimeHomeService { * real-home lane, which runs Codex directly against ~/.codex — there is no * mirror there, so there is nothing that can fall behind. */ - getMirroredHostHomePathForStatus(): string | null { + getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { - return this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + // Why: `null` here is a positive claim that no mirror exists, which the + // status channel reports as healthy. An unreadable home is not that. + return { kind: 'unavailable' } + } + return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } } if (this.isHostSystemDefaultRealHome()) { - return null + return { kind: 'ready', homePath: null } + } + return { + kind: 'ready', + homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') } - return join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') } private getRuntimeAuthPath(): string { diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 978768916b4..efc7ea3f2b9 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -92,10 +92,14 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( '{"account":"wsl"}\n' ) - expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath - ) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getRuntimeCodexHomePath() + }) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ + kind: 'ready', + codexHomePath: wslRuntimeHomePath + }) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index 978daf0d04c..bfac7707f5a 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -64,8 +64,14 @@ describe('CodexRuntimeHomeService', () => { const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') - expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) - expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(service.prepareForRateLimitFetch(target)).toEqual({ + kind: 'ready', + codexHomePath: expectedHome + }) + expect(service.prepareForRateLimitFetch(target)).toEqual({ + kind: 'ready', + codexHomePath: expectedHome + }) expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { @@ -138,9 +144,10 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe( - join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') - ) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ + kind: 'ready', + codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + }) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { @@ -194,9 +201,10 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath - ) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ + kind: 'ready', + codexHomePath: systemCodexHomePath + }) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) const externallyRefreshedAuth = createCodexAuthJson( 'wsl@example.com', @@ -205,9 +213,10 @@ describe('CodexRuntimeHomeService', () => { 3_000 ) writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8') - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath - ) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ + kind: 'ready', + codexHomePath: systemCodexHomePath + }) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe( externallyRefreshedAuth ) diff --git a/src/main/codex-accounts/service-reauthenticate-activation.test.ts b/src/main/codex-accounts/service-reauthenticate-activation.test.ts new file mode 100644 index 00000000000..37cd2a5e741 --- /dev/null +++ b/src/main/codex-accounts/service-reauthenticate-activation.test.ts @@ -0,0 +1,206 @@ +/** + * STA-4422 symptom 2: the status bar's "Sign in" action re-authenticates an + * account while nothing is selected, and the pre-login capture restored that + * empty selection — the browser said "Signed in to Codex" and the account was + * still inactive. The activation intent is deliberately narrow: it may only fill + * an empty lane, and the decision is made from the value captured *before* the + * OAuth await because the runtime-home poll runs outside the mutation queue. + */ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { + createCodexAuthJson, + createManagedHome, + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +function createHostAccounts(): GlobalSettings['codexManagedAccounts'] { + return ['account-1', 'account-2'].map((id, index) => ({ + id, + email: `${id}@example.com`, + managedHomePath: createManagedHome( + testState.userDataDir, + id, + '', + createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`) + ), + providerAccountId: `provider-${id}`, + workspaceLabel: null, + workspaceAccountId: `provider-${id}`, + createdAt: index + 1, + updatedAt: index + 1, + lastAuthenticatedAt: index + 1 + })) +} + +/** Real `codex login` stand-in: writes fresh credentials, optionally racing the store. */ +function createLoginSpawn(onLogin?: () => void) { + return vi.fn((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + onLogin?.() + writeFileSync( + join(options.env.CODEX_HOME!, 'auth.json'), + createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) +} + +describe('CodexAccountService reauthenticate activation intent', () => { + registerCodexAccountsTestHomes() + + it('activates the re-authed account when the intent is set and nothing was selected', async () => { + vi.resetModules() + const spawnMock = createLoginSpawn() + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + const settings = createSettings({ + codexManagedAccounts: createHostAccounts(), + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-2', { + activateIfSelectionWasEmpty: true + }) + + // Healthy anchor: the login really ran and its identity landed. + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(result.accounts.find((account) => account.id === 'account-2')).toMatchObject({ + email: 'reauthenticated@example.com', + providerAccountId: 'provider-new' + }) + expect(result.activeAccountId).toBe('account-2') + expect(result.activeAccountIdsByRuntime).toEqual({ host: 'account-2', wsl: {} }) + expect(store.getSettings()).toMatchObject({ + activeCodexManagedAccountId: 'account-2', + activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} } + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + }) + + it('keeps the previously selected account even when the selection is cleared during OAuth', async () => { + vi.resetModules() + // Why: the runtime-home poll runs outside the mutation queue, so it can null + // the lane while the login promise is pending. A post-login read of the + // selection would see that null and hand the lane to the wrong account. + const spawnMock = createLoginSpawn(() => { + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + host: null + } + }) + }) + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + const settings = createSettings({ + codexManagedAccounts: createHostAccounts(), + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-2', { + activateIfSelectionWasEmpty: true + }) + + // Healthy anchor: the concurrent clear really landed before the settings write. + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) + expect(result.activeAccountId).toBe('account-1') + expect(result.activeAccountIdsByRuntime).toEqual({ host: 'account-1', wsl: {} }) + expect(store.getSettings()).toMatchObject({ + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + }) + + it('leaves the selection empty without the intent, as the settings pane expects', async () => { + vi.resetModules() + const spawnMock = createLoginSpawn() + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + const settings = createSettings({ + codexManagedAccounts: createHostAccounts(), + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-2') + + // Healthy anchor: the same login ran; only the selection outcome differs. + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(result.accounts.find((account) => account.id === 'account-2')).toMatchObject({ + email: 'reauthenticated@example.com' + }) + expect(result.activeAccountId).toBeNull() + expect(result.activeAccountIdsByRuntime).toEqual({ host: null, wsl: {} }) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + }) +}) diff --git a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts new file mode 100644 index 00000000000..94e5a1746c3 --- /dev/null +++ b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { realpathSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import { + createManagedHome, + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' +import { + createResetCreditLimits, + createResetRateLimitState +} from './service-reset-credit-test-fixtures' + +const fsFaults = vi.hoisted(() => { + const held = new Set() + let heldReads = 0 + let mkdirCalls = 0 + return { + hold(path: string): void { + held.add(path) + }, + reset(): void { + held.clear() + heldReads = 0 + mkdirCalls = 0 + }, + heldReads(): number { + return heldReads + }, + resetMkdirCalls(): void { + mkdirCalls = 0 + }, + mkdirCalls(): number { + return mkdirCalls + }, + noteMkdir(): void { + mkdirCalls += 1 + }, + consumeLstat(path: unknown): void { + if (typeof path !== 'string' || !held.has(path)) { + return + } + heldReads += 1 + const error: NodeJS.ErrnoException = new Error(`EPERM: lstat '${path}'`) + error.code = 'EPERM' + throw error + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const patched = { + ...actual, + lstatSync: (...args: Parameters) => { + fsFaults.consumeLstat(args[0]) + return actual.lstatSync(...args) + }, + mkdirSync: (...args: Parameters) => { + fsFaults.noteMkdir() + return actual.mkdirSync(...args) + } + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('Codex reset-credit managed-home ownership', () => { + registerCodexAccountsTestHomes() + + it('refuses an indeterminate host home before ledger or provider mutation', async () => { + const fixture = await createFixture() + fsFaults.hold(join(realpathSync(fixture.managedHomePath), '.orca-managed-home')) + fsFaults.resetMkdirCalls() + + await expect( + fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).rejects.toThrow('temporarily locked') + + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fsFaults.mkdirCalls()).toBe(0) + expect(readFileSync(join(fixture.managedHomePath, 'auth.json'), 'utf-8')).toBe('auth-before') + }) + + it('refuses a proven-untrusted host home before ledger or provider mutation', async () => { + const fixture = await createFixture() + writeFileSync(join(fixture.managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + fsFaults.resetMkdirCalls() + + await expect( + fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).rejects.toThrow('ownership marker does not match') + + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fsFaults.mkdirCalls()).toBe(0) + }) + + it.each([ + { + homeState: 'indeterminate', + expectedError: 'temporarily locked', + makeHomeUnsafe: (managedHomePath: string) => + fsFaults.hold(join(realpathSync(managedHomePath), '.orca-managed-home')) + }, + { + homeState: 'proven untrusted', + expectedError: 'ownership marker does not match', + makeHomeUnsafe: (managedHomePath: string) => + writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + } + ])( + 'rechecks ownership for a durable providerPending retry when the home is $homeState', + async ({ expectedError, makeHomeUnsafe }) => { + const fixture = await createFixture() + const pendingLedger = { + version: 1 as const, + attempts: [ + { + idempotencyKey: fixture.idempotencyKey, + expectedScope: fixture.expectedScope, + state: 'providerPending' as const + } + ] + } + fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) + makeHomeUnsafe(fixture.managedHomePath) + + const settingsBefore = structuredClone(fixture.store.getSettings()) + const authPath = join(fixture.managedHomePath, 'auth.json') + const markerPath = join(fixture.managedHomePath, '.orca-managed-home') + const authBefore = readFileSync(authPath, 'utf-8') + const markerBefore = readFileSync(markerPath, 'utf-8') + const directoryEntriesBefore = readdirSync(fixture.managedHomePath).sort() + fixture.consume.mockClear() + fixture.store.getCodexResetCreditAttemptLedger.mockClear() + fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush.mockClear() + fixture.store.updateSettings.mockClear() + fsFaults.resetMkdirCalls() + + const restarted = fixture.createService() + await expect( + restarted.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).rejects.toThrow(expectedError) + + expect(fixture.store.getCodexResetCreditAttemptLedger).toHaveBeenCalledOnce() + expect(fixture.store.getCodexResetCreditAttemptLedger()).toEqual(pendingLedger) + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fixture.store.getSettings()).toEqual(settingsBefore) + expect(fsFaults.mkdirCalls()).toBe(0) + expect(readFileSync(authPath, 'utf-8')).toBe(authBefore) + expect(readFileSync(markerPath, 'utf-8')).toBe(markerBefore) + expect(readdirSync(fixture.managedHomePath).sort()).toEqual(directoryEntriesBefore) + } + ) + + it('replays a durable settled outcome without reading a locked managed home', async () => { + const fixture = await createFixture() + await expect( + fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).resolves.toMatchObject({ outcome: 'reset' }) + fixture.consume.mockClear() + fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush.mockClear() + fixture.store.updateSettings.mockClear() + fsFaults.hold(join(realpathSync(fixture.managedHomePath), '.orca-managed-home')) + fsFaults.resetMkdirCalls() + + await expect( + fixture + .createService() + .consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).resolves.toMatchObject({ outcome: 'reset', scope: fixture.expectedScope }) + + expect(fsFaults.heldReads()).toBe(0) + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fsFaults.mkdirCalls()).toBe(0) + expect(readFileSync(join(fixture.managedHomePath, 'auth.json'), 'utf-8')).toBe('auth-before') + }) + + async function createFixture() { + fsFaults.reset() + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1', '', 'auth-before') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id, + activeCodexManagedAccountIdsByRuntime: { host: account.id, wsl: {} } + }) + const store = createStore(settings) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const { CodexAccountService } = await import('./service') + const createService = () => + new CodexAccountService(store as never, rateLimits as never, createRuntimeHome() as never) + return { + service: createService(), + createService, + store, + consume, + expectedScope, + managedHomePath, + idempotencyKey: '11111111-1111-4111-8111-111111111111' + } + } +}) diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index f5940dba0b9..ebfe4e3ac1c 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -168,7 +168,7 @@ describe('CodexAccountService config sync', () => { consumeCodexRateLimitResetCredit: consume } const runtimeHome = createRuntimeHome() - runtimeHome.prepareForRateLimitFetch.mockReturnValue(null) + runtimeHome.prepareForRateLimitFetch.mockReturnValue({ kind: 'ready', codexHomePath: null }) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( createStore(settings) as never, diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 14af58f8f21..adc1f4304dd 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexRateLimitHomeResolution } from './runtime-home-service' export const testState = { userDataDir: '', @@ -197,14 +198,16 @@ export function createRateLimits(): RateLimitsStub { export type RuntimeHomeStub = { syncForCurrentSelection: Mock<(...args: unknown[]) => void> clearLastWrittenAuthJson: Mock<(...args: unknown[]) => void> - prepareForRateLimitFetch: Mock<(...args: unknown[]) => string | null> + prepareForRateLimitFetch: Mock<(...args: unknown[]) => CodexRateLimitHomeResolution> } export function createRuntimeHome(): RuntimeHomeStub { return { syncForCurrentSelection: vi.fn(), clearLastWrittenAuthJson: vi.fn(), - prepareForRateLimitFetch: vi.fn(() => null) + prepareForRateLimitFetch: vi.fn( + (): CodexRateLimitHomeResolution => ({ kind: 'ready', codexHomePath: null }) + ) } } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 54eaacef7ef..4b5a91c03f0 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -56,7 +56,10 @@ import { setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' -import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' +import { + assertOwnedHostCodexManagedHomePath, + ManagedCodexHomeTemporarilyUnavailableError +} from './host-codex-managed-home-ownership' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -92,6 +95,15 @@ export type CodexAccountAddTarget = { wslDistro?: string | null } +export type CodexAccountReauthenticateOptions = { + /** + * Local-only intent from the status bar's "Sign in to see usage" action: when + * this account's runtime lane had no selection before login, activate the + * account that just signed in instead of restoring the empty selection. + */ + activateIfSelectionWasEmpty?: boolean +} + export type CodexAccountServiceLifecycle = { onHostSystemDefaultSelected?: () => void } @@ -140,6 +152,10 @@ type CodexResetCreditAttempt = { settledOutcome: CodexRateLimitResetOutcome | null } +type CodexResetCreditScopeValidation = + | { kind: 'settledReplay' } + | { kind: 'providerMutation'; requireCurrentOffer: boolean } + class CodexResetCreditScopeRejection extends Error { constructor( readonly reason: CodexResetCreditRejectedBeforeProviderReason, @@ -297,8 +313,11 @@ export class CodexAccountService { return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target)) } - async reauthenticateAccount(accountId: string): Promise { - return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) + async reauthenticateAccount( + accountId: string, + options?: CodexAccountReauthenticateOptions + ): Promise { + return this.serializeMutation(() => this.doReauthenticateAccount(accountId, options)) } async removeAccount(accountId: string): Promise { @@ -332,7 +351,9 @@ export class CodexAccountService { } if (existing.state === 'settled' && existing.settledOutcome) { return this.serializeMutation(async () => { - const { rateLimits } = this.validateResetCreditScope(expectedScope, false) + const { rateLimits } = this.validateResetCreditScope(expectedScope, { + kind: 'settledReplay' + }) return { outcome: existing.settledOutcome!, scope: existing.expectedScope, @@ -424,7 +445,13 @@ export class CodexAccountService { if (this.hasPendingResetForTarget(target)) { throw new Error('A previous reset attempt for this target still has an unknown outcome.') } - const codexHomePath = this.runtimeHome.prepareForRateLimitFetch(target) + const homeResolution = this.runtimeHome.prepareForRateLimitFetch(target) + // Why: reject before the provider mutation — a skip must never be spent + // against the system-default home (#STA-4422). + if (homeResolution.kind === 'skip') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + const codexHomePath = homeResolution.codexHomePath return this.rateLimits.consumeCodexRateLimitResetCredit({ idempotencyKey: randomUUID(), target, @@ -471,7 +498,10 @@ export class CodexAccountService { const isFresh = attempt.state === 'fresh' let validation: { managedHomePath: string; rateLimits: RateLimitState } try { - validation = this.validateResetCreditScope(expectedScope, isFresh) + validation = this.validateResetCreditScope(expectedScope, { + kind: 'providerMutation', + requireCurrentOffer: isFresh + }) } catch (error) { if (isFresh && error instanceof CodexResetCreditScopeRejection) { this.releaseFreshResetAttempt(idempotencyKey, attempt) @@ -538,7 +568,7 @@ export class CodexAccountService { private validateResetCreditScope( expectedScope: CodexResetCreditExpectedScope, - requireCurrentOffer: boolean + validation: CodexResetCreditScopeValidation ): { managedHomePath: string; rateLimits: RateLimitState } { const rateLimitState = this.rateLimits.getState() if (!sameRateLimitTarget(rateLimitState.codexTarget, expectedScope.target)) { @@ -580,30 +610,35 @@ export class CodexAccountService { ) } - const currentScope = buildCodexResetCreditExpectedScope({ - target: rateLimitState.codexTarget, - account: this.toSummary(account), - limits: rateLimitState.codex - }) - // Why: a same-key replay resolves an already-started provider mutation; - // its credit snapshot may have refreshed, but its account/runtime identity may not change. - if (requireCurrentOffer && !currentScope) { - throw new CodexResetCreditScopeRejection( - 'offerUnavailable', - rateLimitState, - 'The Codex reset-credit offer is no longer available.' - ) + if (validation.kind === 'providerMutation' && validation.requireCurrentOffer) { + const currentScope = buildCodexResetCreditExpectedScope({ + target: rateLimitState.codexTarget, + account: this.toSummary(account), + limits: rateLimitState.codex + }) + if (!currentScope) { + throw new CodexResetCreditScopeRejection( + 'offerUnavailable', + rateLimitState, + 'The Codex reset-credit offer is no longer available.' + ) + } + if (resetScopeKey(expectedScope) !== resetScopeKey(currentScope)) { + throw new CodexResetCreditScopeRejection( + 'offerChanged', + rateLimitState, + 'The Codex reset-credit offer changed before reset.' + ) + } } - if ( - requireCurrentOffer && - currentScope && - resetScopeKey(expectedScope) !== resetScopeKey(currentScope) - ) { - throw new CodexResetCreditScopeRejection( - 'offerChanged', - rateLimitState, - 'The Codex reset-credit offer changed before reset.' - ) + + if (validation.kind === 'providerMutation' && expectedScope.target.runtime === 'host') { + assertOwnedHostCodexManagedHomePath({ + candidatePath: account.managedHomePath, + managedAccountsRoot: join(app.getPath('userData'), 'codex-accounts'), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) } return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState } @@ -847,7 +882,10 @@ export class CodexAccountService { return this.getSnapshot() } - private async doReauthenticateAccount(accountId: string): Promise { + private async doReauthenticateAccount( + accountId: string, + options?: CodexAccountReauthenticateOptions + ): Promise { const account = this.requireAccount(accountId) const managedHomePath = this.ensureManagedHomeForReauthentication(account) const accountTarget = getCodexSelectionTargetForAccount(account) @@ -855,6 +893,11 @@ export class CodexAccountService { this.store.getSettings(), accountTarget ) + // Why: decided from the pre-login capture, never a post-login read — the + // runtime-home poll runs outside the mutation queue and can clear this lane + // while OAuth is open, which would look like an empty selection to activate. + const activateAfterLogin = + options?.activateIfSelectionWasEmpty === true && selectedAccountId === null this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id) await this.runCodexLogin(managedHomePath) @@ -880,7 +923,7 @@ export class CodexAccountService { ) const activeSelection = setSelectedCodexAccountIdForTarget( normalizeCodexRuntimeSelection(settings), - selectedAccountId, + activateAfterLogin ? accountId : selectedAccountId, accountTarget ) diff --git a/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts b/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts new file mode 100644 index 00000000000..c3d628b93eb --- /dev/null +++ b/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { realpathSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getSystemCodexAuthPath, + getSystemCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// STA-4422 regression: a transient lstat failure on the ownership marker +// (Windows AV/indexer EPERM/EBUSY) must NOT deselect the managed account. The +// poll skips instead, and the selection survives — before the fix one fault +// cleared it permanently. + +const lstatFaults = vi.hoisted(() => { + const state = { + /** path -> remaining injected EPERM failures */ + pending: new Map(), + /** paths that fail on EVERY read until released (models a held AV lock) */ + held: new Set(), + failOnce(path: string): void { + state.pending.set(path, (state.pending.get(path) ?? 0) + 1) + }, + hold(path: string): void { + state.held.add(path) + }, + release(path: string): void { + state.held.delete(path) + }, + consumedHeld: new Map(), + heldReads(path: string): number { + return state.consumedHeld.get(path) ?? 0 + }, + remaining(path: string): number { + return state.pending.get(path) ?? 0 + }, + reset(): void { + state.pending.clear() + state.held.clear() + state.consumedHeld.clear() + }, + consume(target: unknown): void { + if (typeof target !== 'string') { + return + } + const remaining = state.pending.get(target) ?? 0 + const isHeld = state.held.has(target) + if (remaining <= 0 && !isHeld) { + return + } + if (isHeld) { + state.consumedHeld.set(target, (state.consumedHeld.get(target) ?? 0) + 1) + } else { + state.pending.set(target, remaining - 1) + } + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, lstat '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = 'lstat' + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const original = actual.lstatSync as (...args: unknown[]) => unknown + const patched: Record = { + ...actual, + lstatSync: Object.assign((...args: unknown[]): unknown => { + lstatFaults.consume(args[0]) + return original(...args) + }, original) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('STA-4422 Codex sessions keep logging out', () => { + beforeEach(() => { + lstatFaults.reset() + setupRuntimeHomeTest() + }) + + afterEach(() => { + lstatFaults.reset() + teardownRuntimeHomeTest() + }) + + it('keeps the selection and SKIPS the poll while the ownership marker is locked', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Anchor: a healthy poll resolves the managed home and touches nothing. + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: managedHomePath + }) + expect(store.updateSettings).not.toHaveBeenCalled() + + // Hold the marker for the whole window, the way an AV scan does. A + // single-shot fault would be too weak: it could be masked by any future + // retry and would green-light an unimplemented fix. + const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home') + lstatFaults.hold(markerPath) + + const duringLock = service.prepareForRateLimitFetch() + + // The fault really was consumed by the code under test. + expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0) + // THE FIX: skip the poll rather than retargeting the user's real ~/.codex. + expect(duringLock).toEqual({ kind: 'skip' }) + expect( + service.resolveCodexManagedAccountHomeForInactiveFetch( + store.getSettings().codexManagedAccounts[0]! + ) + ).toEqual({ kind: 'skip' }) + expect(duringLock).not.toEqual({ kind: 'ready', codexHomePath: getSystemCodexHomePath() }) + // THE FIX: the selection survives, and nothing was persisted. + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + expect(store.updateSettings).not.toHaveBeenCalled() + + // Repeated evaluation must not accumulate into a destructive verdict. + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'skip' }) + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'skip' }) + expect(store.updateSettings).not.toHaveBeenCalled() + + // Recovery is automatic: the next readable poll resolves normally. + lstatFaults.release(markerPath) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: managedHomePath + }) + expect( + service.resolveCodexManagedAccountHomeForInactiveFetch( + store.getSettings().codexManagedAccounts[0]! + ) + ).toEqual({ kind: 'ready', homePath: managedHomePath }) + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') + expect(store.updateSettings).not.toHaveBeenCalled() + }) + + it('still clears the selection when the home is genuinely untrusted', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + // A proven trust failure: the marker names a different account. + writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Why this case matters: the fix must not make the gate toothless. A + // successful observation that fails a trust check still deselects. + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(store.updateSettings).toHaveBeenCalledTimes(1) + expect( + service.resolveCodexManagedAccountHomeForInactiveFetch( + store.getSettings().codexManagedAccounts[0]! + ) + ).toEqual({ kind: 'skip' }) + }) +}) diff --git a/src/main/codex/codex-ai-vault-session-resume.test.ts b/src/main/codex/codex-ai-vault-session-resume.test.ts new file mode 100644 index 00000000000..172b4f34ec9 --- /dev/null +++ b/src/main/codex/codex-ai-vault-session-resume.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { linkSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume' + +describe('prepareCodexAiVaultSessionResume', () => { + let root: string + let peerHome: string + let selectedHome: string + let peerRolloutPath: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-ai-vault-resume-')) + peerHome = join(root, 'codex-accounts', 'account-a', 'home') + selectedHome = join(root, 'codex-accounts', 'account-b', 'home') + const relativeRolloutPath = join( + 'sessions', + '2026', + '08', + '17', + 'rollout-2026-08-17T10-00-00-session.jsonl' + ) + peerRolloutPath = join(peerHome, relativeRolloutPath) + const selectedRolloutPath = join(selectedHome, relativeRolloutPath) + mkdirSync(dirname(peerRolloutPath), { recursive: true }) + mkdirSync(dirname(selectedRolloutPath), { recursive: true }) + writeFileSync(peerRolloutPath, '{"type":"session_meta"}\n', 'utf-8') + linkSync(peerRolloutPath, selectedRolloutPath) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('refuses before falling through when selected-home trust is indeterminate', async () => { + const resolveSelectedHome = vi.fn((): string | null => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + const isSystemDefaultRealHome = vi.fn(() => true) + + await expect(prepare(resolveSelectedHome, isSystemDefaultRealHome)).rejects.toBeInstanceOf( + ManagedCodexHomeTemporarilyUnavailableError + ) + expect(resolveSelectedHome).toHaveBeenCalledOnce() + expect(isSystemDefaultRealHome).not.toHaveBeenCalled() + }) + + it('repins an aliased rollout when the selected home is owned', async () => { + await expect(prepare(() => selectedHome)).resolves.toEqual({ + useRealCodexHome: false, + substituteCodexHome: selectedHome + }) + }) + + it('preserves deliberate no-selection behavior for a proven-untrusted home', async () => { + await expect(prepare(() => null)).resolves.toEqual({ useRealCodexHome: false }) + }) + + function prepare( + resolveSelectedHome: () => string | null, + isSystemDefaultRealHome: () => boolean = () => false + ) { + return prepareCodexAiVaultSessionResume( + { + agent: 'codex', + filePath: peerRolloutPath, + codexHome: peerHome, + executionHostId: 'local' + }, + { + runtimeHome: { + isHostSystemDefaultRealHome: isSystemDefaultRealHome, + resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome + }, + systemCodexHomePath: join(root, 'system-codex-home') + } + ) + } +}) diff --git a/src/main/codex/codex-ai-vault-session-resume.ts b/src/main/codex/codex-ai-vault-session-resume.ts new file mode 100644 index 00000000000..da59287126b --- /dev/null +++ b/src/main/codex/codex-ai-vault-session-resume.ts @@ -0,0 +1,27 @@ +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' +import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' + +type CodexAiVaultRuntimeHome = Pick< + CodexRuntimeHomeService, + 'isHostSystemDefaultRealHome' | 'resolveSelectedHostAccountCodexHomePathForResume' +> + +/** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */ +export function prepareCodexAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs, + options: { + runtimeHome: CodexAiVaultRuntimeHome | null + systemCodexHomePath: string | undefined + } +): Promise { + return prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHome: () => options.runtimeHome?.isHostSystemDefaultRealHome() === true, + getSelectedHostAccountCodexHomePath: () => + options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null, + systemCodexHomePath: options.systemCodexHomePath + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 8769a055e5f..4ad93df0448 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -243,6 +243,7 @@ import { stopCodexStateDbBackfillRecoveries } from './codex/codex-state-db-backfill-recovery' import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' +import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume' import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' @@ -1073,6 +1074,9 @@ function prepareCodexRuntimeHomeForLaunch( return true } let realHomeHooksPrepared = ensureRealHomeHooksIfSelected() + // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — + // the fallbacks below all key off `null`, which means "system default", so + // swallowing the refusal would launch the wrong account (#STA-4422). let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { unavailableManagedHomePath: launchContext?.unavailableManagedHomePath }) @@ -1145,6 +1149,15 @@ async function prepareCodexSessionResumeForLaunch(args: { ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() ] const settingsStore = store + // Why: resolved eagerly, once, before any ranking or provenance match. The + // marker read used to be deferred into the ranking thunk so a + // provenance-present resume never paid for it, but that optimisation let an + // unreadable selected home reach the PTY as "no selection": the provenance + // branch simply omits the account from `trustedHomes` and another account's + // readable alias wins. A throw here refuses the whole resume instead + // (#STA-4422). + const selectedAccountCodexHome = + codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume() // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ @@ -1153,8 +1166,7 @@ async function prepareCodexSessionResumeForLaunch(args: { trustedCodexHomes: trustedHomes, // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; // rank it by the current selection so settings insertion order can never decide the account. - // Lazy: only the legacy branch ranks, so a provenance-present resume never stats the marker. - getSelectedAccountCodexHome: () => codexRuntimeHome!.getSelectedHostAccountCodexHomePath(), + getSelectedAccountCodexHome: () => selectedAccountCodexHome, systemCodexHomePath: systemHomePath, // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must // outrank the path-sorted account homes or a system-default selection resumes as an account. @@ -1466,11 +1478,8 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], prepareAiVaultSessionResume: (args) => - prepareLegacySharedCodexSessionResume(args, { - isHostSystemDefaultRealHome: () => - codexRuntimeHome?.isHostSystemDefaultRealHome() === true, - getSelectedHostAccountCodexHomePath: () => - codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), onBeforeRelaunch: async () => { @@ -2528,7 +2537,15 @@ void app.whenReady().then(async () => { ) return settings.codexManagedAccounts .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) + .map((account) => ({ + id: account.id, + resolveHome: () => { + const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) + return resolved.kind === 'ready' + ? { kind: 'ready' as const, managedHomePath: resolved.homePath } + : { kind: 'skip' as const } + } + })) }) const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { resolve: (selector) => { @@ -2589,10 +2606,8 @@ void app.whenReady().then(async () => { getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], prepareAiVaultSessionResume: (args) => - prepareLegacySharedCodexSessionResume(args, { - isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, - getSelectedHostAccountCodexHomePath: () => - codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), buildAgentHookPtyEnv: () => diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index f8c81fe0189..e89e8bca4be 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -38,8 +38,12 @@ export function registerCodexAccountHandlers( ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => codexAccounts.addAccount(args) ) - ipcMain.handle('codexAccounts:reauthenticate', (_event, args: { accountId: string }) => - codexAccounts.reauthenticateAccount(args.accountId) + ipcMain.handle( + 'codexAccounts:reauthenticate', + (_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) => + codexAccounts.reauthenticateAccount(args.accountId, { + activateIfSelectionWasEmpty: args.activateIfSelectionWasEmpty === true + }) ) ipcMain.handle('codexAccounts:remove', (_event, args: { accountId: string }) => codexAccounts.removeAccount(args.accountId) diff --git a/src/main/ipc/codex-config-sync.test.ts b/src/main/ipc/codex-config-sync.test.ts index e1219472c8d..aa64664a75b 100644 --- a/src/main/ipc/codex-config-sync.test.ts +++ b/src/main/ipc/codex-config-sync.test.ts @@ -25,9 +25,15 @@ import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types let root: string function invokeHandler(mirroredHome: string | null): CodexConfigSyncStatus { + return invokeHandlerWithStatus({ kind: 'ready', homePath: mirroredHome }) +} + +function invokeHandlerWithStatus( + mirrored: { kind: 'ready'; homePath: string | null } | { kind: 'unavailable' } +): CodexConfigSyncStatus { handleMock.mockClear() registerCodexConfigSyncHandlers({ - getMirroredHostHomePathForStatus: () => mirroredHome + getMirroredHostHomePathForStatus: () => mirrored }) const handler = handleMock.mock.calls.at(-1)?.[1] as () => CodexConfigSyncStatus return handler() @@ -78,3 +84,21 @@ describe('codexConfigSync:status handler', () => { expect(removeHandlerMock).toHaveBeenCalledWith('codexConfigSync:status') }) }) + +// STA-4422: an unreadable managed home must not be reported as healthy. Before +// the fix the resolver collapsed to `null`, which this channel reads as "no +// mirror exists" and reports as synced. +it('reports a managed-home-unavailable stall instead of synced when the home is unreadable', () => { + // Anchor: a genuine no-mirror lane still reports synced. + expect(invokeHandlerWithStatus({ kind: 'ready', homePath: null })).toEqual({ + state: 'synced', + reason: null, + systemConfigPath: join(root, '.codex', 'config.toml') + }) + + expect(invokeHandlerWithStatus({ kind: 'unavailable' })).toEqual({ + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath: join(root, '.codex', 'config.toml') + }) +}) diff --git a/src/main/ipc/codex-config-sync.ts b/src/main/ipc/codex-config-sync.ts index c61ba571bae..53a718da3c5 100644 --- a/src/main/ipc/codex-config-sync.ts +++ b/src/main/ipc/codex-config-sync.ts @@ -3,10 +3,11 @@ import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { getCodexConfigSyncStatus } from '../codex/config-sync-stall' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { CodexMirroredHomeStatus } from '../codex-accounts/runtime-home-service' /** The read-only slice of the runtime home service this channel needs. */ type CodexMirroredHomeResolver = { - getMirroredHostHomePathForStatus: () => string | null + getMirroredHostHomePathForStatus: () => CodexMirroredHomeStatus } /** Registers the read-only IPC channel the settings pane reads once per mount for Codex config sync health. */ @@ -14,7 +15,17 @@ export function registerCodexConfigSyncHandlers(runtimeHome: CodexMirroredHomeRe ipcMain.removeHandler('codexConfigSync:status') ipcMain.handle('codexConfigSync:status', (): CodexConfigSyncStatus => { const systemHomePath = getSystemCodexHomePath() - const runtimeHomePath = runtimeHome.getMirroredHostHomePathForStatus() + const mirrored = runtimeHome.getMirroredHostHomePathForStatus() + if (mirrored.kind === 'unavailable') { + // Why: do not throw — the settings pane catches thrown status errors and + // would show nothing at all. Report the stall so the user sees why. + return { + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath: join(systemHomePath, 'config.toml') + } + } + const runtimeHomePath = mirrored.homePath if (!runtimeHomePath) { // Why: the system default runs Codex directly against ~/.codex, so there // is no mirror that can fall behind. Reporting on the shared home here diff --git a/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts new file mode 100644 index 00000000000..e039f92ff87 --- /dev/null +++ b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSyncMock } from './pty-ipc-mock-registry' +import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { registerPtyHandlers } 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()) +) + +const UNAVAILABLE_MESSAGE = 'Codex account files are temporarily locked. Retry in a moment.' + +const MANAGED_ACCOUNT_SETTINGS = (() => + ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + }) as never) as () => never + +type RuntimeSpawnController = { + spawn(args: { cols: number; rows: number; launchAgent: 'codex' }): Promise<{ id: string }> +} + +/** + * #STA-4422: launch prep refuses an unreadable managed home by throwing, because + * `null` already means "launch the system default". A refusal that reached + * either spawn path as `null` would start the pane on the user's real ~/.codex + * while the UI still shows the managed account, so every assertion here pairs + * the rejection with a zero-spawn check — existing suites prove a `null` + * re-resolution deliberately launches with no CODEX_HOME. + */ +describe('registerPtyHandlers Codex launch refusal on an unreadable managed home', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) + + function makeRuntime() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + } + + function register( + resolveHome: ( + target?: unknown, + env?: NodeJS.ProcessEnv, + context?: { unavailableManagedHomePath?: string } + ) => string | null, + runtime?: ReturnType, + prepareCodexSessionResume?: () => Promise + ): void { + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + resolveHome, + MANAGED_ACCOUNT_SETTINGS, + undefined, + undefined, + prepareCodexSessionResume ? { prepareCodexSessionResume } : undefined + ) + } + + const RESUME_SESSION = { + key: 'session_id' as const, + id: 'resume-session-1', + transcriptPath: '/tmp/orca-codex-rollout.jsonl' + } + + function makeAuthUnreadable(): void { + readFileSyncMock.mockImplementation((filePath: string) => { + if (filePath.endsWith('auth.json')) { + throw Object.assign(new Error('missing auth'), { code: 'ENOENT' }) + } + return '' + }) + } + + describe('local pty:spawn handler', () => { + it('spawns with the managed CODEX_HOME when the home reads back cleanly', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + register(() => TEST_CODEX_HOME) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, launchAgent: 'codex' }) + + expect(daemonSpawn).toHaveBeenCalledOnce() + expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({ CODEX_HOME: TEST_CODEX_HOME }) + }) + + it('refuses the spawn when the first home resolution is indeterminate', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const resolveHome = vi.fn((): string | null => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + register(resolveHome) + + await expect( + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, launchAgent: 'codex' }) + ).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(resolveHome).toHaveBeenCalledOnce() + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the post-auth-wait re-resolution is indeterminate', async () => { + vi.useFakeTimers() + makeAuthUnreadable() + const daemonSpawn = setupDaemonAdapter() + const resolveHome = vi.fn( + ( + _target?: unknown, + _env?: NodeJS.ProcessEnv, + context?: { unavailableManagedHomePath?: string } + ): string | null => { + if (context?.unavailableManagedHomePath) { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return TEST_CODEX_HOME + } + ) + register(resolveHome) + + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex' + }) + const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE) + await vi.advanceTimersByTimeAsync(2_000) + await rejection + + // Why: the first resolution must have succeeded, or the rejection would + // prove nothing about the re-resolution this test exists for. + expect(resolveHome).toHaveBeenCalledTimes(2) + expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({ + unavailableManagedHomePath: TEST_CODEX_HOME + }) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the current-selection re-read is indeterminate after auth recovers', async () => { + vi.useFakeTimers() + let authReady = false + readFileSyncMock.mockImplementation((filePath: string) => { + if (!filePath.endsWith('auth.json')) { + return '' + } + if (!authReady) { + throw Object.assign(new Error('missing auth'), { code: 'ENOENT' }) + } + return TEST_CODEX_AUTH_JSON + }) + const daemonSpawn = setupDaemonAdapter() + const resolveHome = vi.fn((): string | null => { + if (resolveHome.mock.calls.length > 1) { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return TEST_CODEX_HOME + }) + register(resolveHome) + + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex' + }) + const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE) + await vi.advanceTimersByTimeAsync(0) + expect(daemonSpawn).not.toHaveBeenCalled() + authReady = true + await vi.advanceTimersByTimeAsync(25) + await rejection + + expect(resolveHome).toHaveBeenCalledTimes(2) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + // Why (#STA-4422 P1g): the eager selected-home gate rejects the awaited + // resume preparation. That rejection has to reach the spawn, not be + // downgraded to "no resume" — a resume that proceeds picks its CODEX_HOME + // from whichever account's alias stayed readable. + it('refuses the spawn when the eager resume gate rejects', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + register( + () => TEST_CODEX_HOME, + undefined, + () => Promise.reject(new ManagedCodexHomeTemporarilyUnavailableError()) + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex', + resumeProviderSession: RESUME_SESSION + }) + ).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('spawns a resume when the eager resume gate resolves', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + register(() => TEST_CODEX_HOME, undefined, (() => + Promise.resolve(null)) as unknown as () => Promise) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex', + resumeProviderSession: RESUME_SESSION + }) + + expect(daemonSpawn).toHaveBeenCalledOnce() + }) + }) + + describe('runtime pty controller spawn', () => { + it('spawns with the managed CODEX_HOME when the home reads back cleanly', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + register(() => TEST_CODEX_HOME, runtime) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' }) + + expect(daemonSpawn).toHaveBeenCalledOnce() + expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({ CODEX_HOME: TEST_CODEX_HOME }) + }) + + it('refuses the spawn when the first home resolution is indeterminate', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + const resolveHome = vi.fn((): string | null => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + register(resolveHome, runtime) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await expect(controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })).rejects.toThrow( + UNAVAILABLE_MESSAGE + ) + + expect(resolveHome).toHaveBeenCalledOnce() + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the post-auth-wait re-resolution is indeterminate', async () => { + vi.useFakeTimers() + makeAuthUnreadable() + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + const resolveHome = vi.fn( + ( + _target?: unknown, + _env?: NodeJS.ProcessEnv, + context?: { unavailableManagedHomePath?: string } + ): string | null => { + if (context?.unavailableManagedHomePath) { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return TEST_CODEX_HOME + } + ) + register(resolveHome, runtime) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + const spawnPromise = controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' }) + const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE) + await vi.advanceTimersByTimeAsync(2_000) + await rejection + + expect(resolveHome).toHaveBeenCalledTimes(2) + expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({ + unavailableManagedHomePath: TEST_CODEX_HOME + }) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the eager resume gate rejects', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + register( + () => TEST_CODEX_HOME, + runtime, + () => Promise.reject(new ManagedCodexHomeTemporarilyUnavailableError()) + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + launchAgent: 'codex', + resumeProviderSession: RESUME_SESSION + } as never) + ).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(daemonSpawn).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 10ee17200db..3c3e148ffb2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -212,6 +212,7 @@ import { isCodexHomeAuthReadyForLaunch, waitForManagedCodexAuthReady } from '../codex-accounts/managed-codex-auth-readiness' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { forgetCodexPaneAccount, getCodexPaneAccount, @@ -1344,6 +1345,26 @@ const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' +const MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE = + 'Codex account files are temporarily locked. Retry in a moment.' + +/** + * Why: launch prep refuses an unreadable managed home by throwing instead of + * returning `null`, because `null` already means "launch the system default". + * Turn that refusal into the same shape as the auth-unavailable refusal above — + * a user-facing spawn rejection — so the pane never starts on another account's + * credentials (#STA-4422). + */ +function resolveSelectedCodexHomeOrRefuseSpawn(resolve: () => string | null): string | null { + try { + return resolve() + } catch (error) { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + throw new Error(MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE, { cause: error }) + } + throw error + } +} type ManagedCodexAuthResolutionArgs = { selectedCodexHomePath: string | null @@ -4686,7 +4707,7 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - let selectedCodexHomePath = + let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => !preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, @@ -4706,6 +4727,7 @@ export function registerPtyHandlers( }) ?? null) ) : null + ) if ( !preAdoptedStablePane && args.launchAgent === 'codex' && @@ -4717,21 +4739,25 @@ export function registerPtyHandlers( requiredCodexHomePath: codexResumeHome?.codexHomePath, target: codexSelectionTarget, resolveCurrent: () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: 'codex' + }) ?? null + ) ), resolveAfterUnavailable: (unavailableManagedHomePath) => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) ) }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution @@ -6417,7 +6443,7 @@ export function registerPtyHandlers( // Why: declared after the strip so a local-provider spawn cannot capture the // pre-strip env — only the daemon branch below re-derives this from baseEnv. let env: Record | undefined = baseEnv - let selectedCodexHomePath = + let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => !preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, @@ -6437,6 +6463,7 @@ export function registerPtyHandlers( }) ?? null) ) : null + ) if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { const resolution = resolveCodexHomeAfterManagedAuthReadiness({ selectedCodexHomePath, @@ -6444,21 +6471,25 @@ export function registerPtyHandlers( requiredCodexHomePath: codexResumeHome?.codexHomePath, target: codexSelectionTarget, resolveCurrent: () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: 'codex' + }) ?? null + ) ), resolveAfterUnavailable: (unavailableManagedHomePath) => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) ) }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index a7a32a24b7e..86008a4bd84 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -58,7 +58,10 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' - const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + const resolver = vi.fn((target) => ({ + kind: 'ready' as const, + codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + })) service.setCodexHomePathResolver(resolver) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) @@ -71,10 +74,62 @@ describe('RateLimitService', () => { ) }) + it.each([ + [ + 'account-change refresh', + (service: RateLimitService) => service.refreshForCodexAccountChange(null, { runtime: 'host' }) + ], + [ + 'target refresh', + (service: RateLimitService) => service.refreshCodexForTarget({ runtime: 'host' }) + ] + ])('settles %s when managed-home resolution skips before the fetch', async (_label, refresh) => { + const service = new RateLimitService() + service.setCodexHomePathResolver(() => ({ kind: 'skip' })) + + await refresh(service) + + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + expect(service.getState().codex).toBeNull() + }) + + it('settles without applying a result when the managed home becomes unavailable mid-fetch', async () => { + const service = new RateLimitService() + const resolver = vi + .fn() + .mockReturnValueOnce({ kind: 'ready', codexHomePath: '/tmp/codex-home' }) + .mockReturnValue({ kind: 'skip' }) + service.setCodexHomePathResolver(resolver) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refreshForCodexAccountChange(null, { runtime: 'host' }) + + expect(resolver).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().codex).toBeNull() + }) + + it('settles the Codex slot when its home becomes unavailable during a full refresh', async () => { + const service = new RateLimitService() + service.setCodexHomePathResolver( + vi + .fn() + .mockReturnValueOnce({ kind: 'ready', codexHomePath: '/tmp/codex-home' }) + .mockReturnValue({ kind: 'skip' }) + ) + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().codex).toBeNull() + }) + it('reuses a caller-provided idempotency key when consuming a Codex reset credit', async () => { const service = new RateLimitService() const idempotencyKey = '11111111-1111-4111-8111-111111111111' - service.setCodexHomePathResolver(() => '/tmp/codex-home') + service.setCodexHomePathResolver(() => ({ kind: 'ready', codexHomePath: '/tmp/codex-home' })) vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now())) @@ -104,7 +159,7 @@ describe('RateLimitService', () => { ) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now())) - service.setCodexHomePathResolver(() => '/tmp/new-selection') + service.setCodexHomePathResolver(() => ({ kind: 'ready', codexHomePath: '/tmp/new-selection' })) const pending = service.consumeCodexRateLimitResetCredit({ idempotencyKey, target: { runtime: 'host', wslDistro: null }, @@ -139,9 +194,10 @@ describe('RateLimitService', () => { const service = new RateLimitService() const idempotencyKey = '33333333-3333-4333-8333-333333333333' const hostRefresh = deferred() - service.setCodexHomePathResolver((target) => - target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection' - ) + service.setCodexHomePathResolver((target) => ({ + kind: 'ready', + codexHomePath: target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection' + })) vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') vi.mocked(fetchCodexRateLimits) .mockReturnValueOnce(hostRefresh.promise) @@ -173,7 +229,10 @@ describe('RateLimitService', () => { it('does not let an older full refresh overwrite the post-reset Codex state', async () => { const service = new RateLimitService() const slowClaude = deferred() - service.setCodexHomePathResolver(() => '/tmp/approved-selection') + service.setCodexHomePathResolver(() => ({ + kind: 'ready', + codexHomePath: '/tmp/approved-selection' + })) vi.mocked(fetchClaudeRateLimits).mockReturnValueOnce(slowClaude.promise) vi.mocked(fetchCodexRateLimits) .mockResolvedValueOnce(okProvider('codex', 100, Date.now())) @@ -201,7 +260,10 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' - const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + const resolver = vi.fn((target) => ({ + kind: 'ready' as const, + codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + })) service.setCodexHomePathResolver(resolver) service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) @@ -218,7 +280,7 @@ describe('RateLimitService', () => { it('does not fetch host Codex usage when WSL home resolution fails', async () => { const service = new RateLimitService() - const resolver = vi.fn(() => null) + const resolver = vi.fn(() => ({ kind: 'ready' as const, codexHomePath: null })) service.setCodexHomePathResolver(resolver) service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) @@ -352,9 +414,10 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' - service.setCodexHomePathResolver((target) => - target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome - ) + service.setCodexHomePathResolver((target) => ({ + kind: 'ready', + codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + })) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits) @@ -375,7 +438,7 @@ describe('RateLimitService', () => { it('caches an outgoing weekly-only Codex account so the switcher keeps its inline bars', async () => { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-weekly', managedHomePath: '/tmp/account-weekly/home' } + inactiveCodexAccount('account-weekly', '/tmp/account-weekly/home') ]) const weeklyOnly: ProviderRateLimits = { @@ -410,7 +473,7 @@ describe('RateLimitService', () => { it('does not cache an outgoing Codex account that has no usage windows', async () => { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-empty', managedHomePath: '/tmp/account-empty/home' } + inactiveCodexAccount('account-empty', '/tmp/account-empty/home') ]) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) @@ -464,3 +527,10 @@ describe('RateLimitService', () => { ) }) }) + +function inactiveCodexAccount(id: string, managedHomePath: string) { + return { + id, + resolveHome: () => ({ kind: 'ready' as const, managedHomePath }) + } +} diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 53338098074..e734b52172a 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -10,6 +10,13 @@ import { resetRateLimitProviderMocks } from './rate-limit-service-test-harness' +function inactiveCodexAccount(id: string, managedHomePath: string) { + return { + id, + resolveHome: () => ({ kind: 'ready' as const, managedHomePath }) + } +} + vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), fetchManagedAccountUsage: vi.fn() @@ -84,7 +91,7 @@ describe('RateLimitService', () => { it('aborts inactive Codex preview fetches on stop', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + const account = inactiveCodexAccount('account-1', '/tmp/account-1/home') const capturedSignals: { codex?: AbortSignal } = {} service.setInactiveCodexAccountsResolver(() => [account]) vi.mocked(fetchCodexRateLimits).mockImplementation( @@ -116,7 +123,7 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-1', managedHomePath: wslCodexHome } + inactiveCodexAccount('account-1', wslCodexHome) ]) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now())) @@ -142,6 +149,52 @@ describe('RateLimitService', () => { ]) }) + it('skips an unavailable inactive home without dropping its cache and recovers later', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-17T12:00:00Z')) + try { + const service = new RateLimitService() + let unavailable = false + service.setInactiveCodexAccountsResolver(() => [ + { + id: 'account-1', + resolveHome: () => + unavailable + ? { kind: 'skip' as const } + : { kind: 'ready' as const, managedHomePath: '/tmp/account-1/home' } + } + ]) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 33, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 67, Date.now())) + + await service.fetchInactiveCodexAccountsOnOpen() + unavailable = true + await vi.advanceTimersByTimeAsync(10 * 60_000) + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().inactiveCodexAccounts).toEqual([ + expect.objectContaining({ + accountId: 'account-1', + isFetching: false, + rateLimits: expect.objectContaining({ + session: expect.objectContaining({ usedPercent: 33 }) + }) + }) + ]) + + unavailable = false + await vi.advanceTimersByTimeAsync(10 * 60_000) + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().inactiveCodexAccounts[0]?.rateLimits?.session?.usedPercent).toBe(67) + } finally { + vi.useRealTimers() + } + }) + it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { const service = new RateLimitService() const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } @@ -181,7 +234,7 @@ describe('RateLimitService', () => { const service = new RateLimitService() const accountFetch = deferred() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + inactiveCodexAccount('account-1', '/tmp/account-1/home') ]) vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) @@ -199,8 +252,8 @@ describe('RateLimitService', () => { const service = new RateLimitService() const accountFetch = deferred() let inactiveAccounts = [ - { id: 'account-a', managedHomePath: '/tmp/account-a/home' }, - { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + inactiveCodexAccount('account-a', '/tmp/account-a/home'), + inactiveCodexAccount('account-b', '/tmp/account-b/home') ] service.setInactiveCodexAccountsResolver(() => inactiveAccounts) vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) @@ -208,11 +261,10 @@ describe('RateLimitService', () => { const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() await Promise.resolve() expect(service.getState().inactiveCodexAccounts).toEqual([ - { accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true }, - { accountId: 'account-b', rateLimits: null, updatedAt: 0, isFetching: true } + { accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true } ]) - inactiveAccounts = [{ id: 'account-a', managedHomePath: '/tmp/account-a/home' }] + inactiveAccounts = [inactiveCodexAccount('account-a', '/tmp/account-a/home')] service.evictInactiveCodexCache('account-b') accountFetch.resolve(okProvider('codex', 64, Date.now())) await fetchOnOpen @@ -234,9 +286,12 @@ describe('RateLimitService', () => { it('does not recache an inactive Codex account that becomes active during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() - let inactiveAccounts = [{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }] + let inactiveAccounts = [inactiveCodexAccount('account-b', '/tmp/account-b/home')] service.setInactiveCodexAccountsResolver(() => inactiveAccounts) - service.setCodexHomePathResolver(() => '/tmp/account-b/home') + service.setCodexHomePathResolver(() => ({ + kind: 'ready', + codexHomePath: '/tmp/account-b/home' + })) vi.mocked(fetchCodexRateLimits) .mockReturnValueOnce(accountFetch.promise) .mockResolvedValueOnce(okProvider('codex', 7, Date.now())) @@ -258,7 +313,7 @@ describe('RateLimitService', () => { it('keeps the inactive Codex debounce across an account switch instead of re-probing', async () => { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + inactiveCodexAccount('account-b', '/tmp/account-b/home') ]) vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 10)) @@ -280,8 +335,8 @@ describe('RateLimitService', () => { try { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-a', managedHomePath: '/tmp/account-a/home' }, - { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + inactiveCodexAccount('account-a', '/tmp/account-a/home'), + inactiveCodexAccount('account-b', '/tmp/account-b/home') ]) vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5)) @@ -300,6 +355,34 @@ describe('RateLimitService', () => { } }) + it('does not start another inactive Codex batch during the inter-account stagger', async () => { + vi.useFakeTimers() + try { + const service = new RateLimitService() + service.setInactiveCodexAccountsResolver(() => [ + inactiveCodexAccount('account-a', '/tmp/account-a/home'), + inactiveCodexAccount('account-b', '/tmp/account-b/home') + ]) + vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5)) + + const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() + await vi.advanceTimersByTimeAsync(0) + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().inactiveCodexAccounts).toEqual([ + expect.objectContaining({ accountId: 'account-a', isFetching: false }) + ]) + + await service.fetchInactiveCodexAccountsOnOpen() + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(2_000) + await fetchOnOpen + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it('does not recache an inactive Claude account removed during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index a8db8ec6db6..02bd1930bb6 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -32,13 +32,14 @@ import { type CodexAccountSelectionTarget, type NormalizedCodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service' export type InactiveCodexAccountInfo = { id: string - managedHomePath: string + resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } } -type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null +type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution type KimiHomeResolver = () => Promise type ClaudeAuthPreparationResolver = ( target?: ClaudeAccountSelectionTarget @@ -241,6 +242,7 @@ export class RateLimitService { private inactiveCodexCache = new Map() private inactiveClaudeFetching = new Set() private inactiveCodexFetching = new Set() + private inactiveCodexFetchInFlight = false private lastInactiveClaudeFetchAt = 0 private inactiveClaudeAccountsGeneration = 0 private lastInactiveCodexFetchAt = 0 @@ -260,6 +262,21 @@ export class RateLimitService { this.codexHomePathResolver = resolver } + // Why: `skip` and a `ready` null are different answers — null still means the + // system-default lane, so it must never stand in for "don't fetch" (#STA-4422). + private resolveCodexHome(target?: CodexAccountSelectionTarget): { + skip: boolean + homePath: string | null + } { + const resolution = this.codexHomePathResolver?.(target) + if (!resolution) { + return { skip: false, homePath: null } + } + return resolution.kind === 'skip' + ? { skip: true, homePath: null } + : { skip: false, homePath: resolution.codexHomePath } + } + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) } @@ -635,7 +652,7 @@ export class RateLimitService { return } this.pruneInactiveCodexState() - if (this.inactiveCodexFetching.size > 0) { + if (this.inactiveCodexFetchInFlight) { return } const accounts = this.inactiveCodexAccountsResolver?.() ?? [] @@ -646,11 +663,7 @@ export class RateLimitService { const fetchGeneration = this.inactiveCodexAccountsGeneration const controller = this.beginFetchCycle() const signal = controller.signal - - for (const account of accounts) { - this.inactiveCodexFetching.add(account.id) - } - this.pushToRenderer() + this.inactiveCodexFetchInFlight = true let staggerNextProbe = false try { @@ -683,12 +696,18 @@ export class RateLimitService { continue } } + const home = account.resolveHome() + if (home.kind === 'skip') { + continue + } staggerNextProbe = true + this.inactiveCodexFetching.add(account.id) + this.pushToRenderer() try { // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. const fresh = await fetchCodexRateLimits({ - codexHomePath: account.managedHomePath, + codexHomePath: home.managedHomePath, allowPtyFallback: false, signal }) @@ -724,6 +743,7 @@ export class RateLimitService { this.lastInactiveCodexFetchAt = Date.now() } } finally { + this.inactiveCodexFetchInFlight = false this.finishFetchCycle(controller) } } @@ -1338,10 +1358,13 @@ export class RateLimitService { } const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentHomePath = this.codexHomePathResolver?.(target) ?? null + const currentCodexHome = this.resolveCodexHome(target) + // Why: a skip has no provenance to compare, so treat it as no longer active + // rather than publishing this result against the system-default lane. const stillActive = + !currentCodexHome.skip && this.isSameCodexTarget(this.codexFetchTarget, target) && - this.getCodexProvenance(target, currentHomePath) === + this.getCodexProvenance(target, currentCodexHome.homePath) === this.getCodexProvenance(target, codexHomePath) if (stillActive) { // Why: this post-redemption read is newer than every Codex fetch that @@ -1590,10 +1613,18 @@ export class RateLimitService { this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const codexTarget = this.codexFetchTarget - const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration const previousState = this.state + // Why: a skipped Codex poll must not stop the other providers' cycle, so gate + // only the Codex slot instead of returning early (#STA-4422). + const codexHome = this.resolveCodexHome(codexTarget) + const codexFetchGated = codexHome.skip + const codexHomePath = codexHome.homePath + const codexStateBeforeFetch = + previousState.codex?.status === 'fetching' ? null : previousState.codex + const codexProvenance = codexFetchGated + ? null + : this.getCodexProvenance(codexTarget, codexHomePath) + const codexGeneration = this.codexFetchGeneration const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' @@ -1627,7 +1658,10 @@ export class RateLimitService { this.updateState({ ...previousState, claude: this.withFetchingStatus(previousState.claude, 'claude'), - codex: this.withFetchingStatus(previousState.codex, 'codex'), + // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. + codex: codexFetchGated + ? codexStateBeforeFetch + : this.withFetchingStatus(previousState.codex, 'codex'), gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), opencodeGo: opencodeConfigChanged ? this.withFetchingStatus(null, 'opencode-go') @@ -1640,9 +1674,8 @@ export class RateLimitService { grok: this.withFetchingStatus(previousState.grok, 'grok') }) - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) + const missingWslCodexHome = + codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ signal, authReadResult: grokAuthReadResult @@ -1666,12 +1699,14 @@ export class RateLimitService { networkProxySettings: this.networkProxySettingsResolver?.(), signal }), - missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits( cookie, @@ -1780,15 +1815,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestCodexHome = this.resolveCodexHome(codexTarget) const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return } const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) + // Why: a finishing skip has no provenance, so an in-flight result must never be + // applied as though the target had become the system default (#STA-4422). const shouldApplyCodex = - codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + !codexFetchGated && + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + const codexBecameUnavailable = + !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. const shouldApplyClaude = !claudeFetchGated && @@ -1822,7 +1863,9 @@ export class RateLimitService { : this.state.claude, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) - : this.state.codex, + : codexBecameUnavailable + ? codexStateBeforeFetch + : this.state.codex, gemini: this.applyStalePolicy(gemini, previousState.gemini), opencodeGo: shouldApplyOpencode ? opencodeConfigChanged @@ -1865,9 +1908,20 @@ export class RateLimitService { return } const codexTarget = this.codexFetchTarget - const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration + const codexHome = this.resolveCodexHome(codexTarget) + // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). + if (codexHome.skip) { + if ( + codexGeneration === this.codexFetchGeneration && + this.state.codex?.status === 'fetching' + ) { + this.updateState({ ...this.state, codex: null }) + } + return + } + const codexHomePath = codexHome.homePath + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const previousState = this.state this.updateState({ @@ -1901,10 +1955,18 @@ export class RateLimitService { return } - const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null - const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) + const latestCodexHome = this.resolveCodexHome(codexTarget) + if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { + this.updateState({ + ...this.state, + codex: previousState.codex?.status === 'fetching' ? null : previousState.codex + }) + return + } const shouldApplyCodex = - codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) if (shouldApplyCodex) { this.trackActiveFailureStreak('codex', codex) diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index c34af95c3fc..33b66e1a4fe 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' const originalEnv = { ...process.env } const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -124,6 +125,26 @@ describe('prepareLocalCommitMessageAgentEnv', () => { }) }) + // Why (#STA-4422): launch prep throws when the managed home is unreadable. + // Falling through to an env would run the headless commit agent on the user's + // real ~/.codex while the UI shows the managed account selected. + it('fails commit generation instead of building a system-account env when the managed home is unreadable', async () => { + process.env.CODEX_HOME = '/home/me/.config/codex' + delete process.env.ORCA_CODEX_HOME + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + }) + + expect(result).toEqual({ + ok: false, + error: 'Failed to prepare the selected agent account for commit message generation.' + }) + expect(result).not.toHaveProperty('env') + }) + it('strips a nested-Orca CODEX_HOME override when the launch resolves to the real home', async () => { process.env.CODEX_HOME = '/managed/runtime/home' process.env.ORCA_CODEX_HOME = '/managed/runtime/home' diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index e3688b57f5b..ae75cbf1c35 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -11,7 +11,11 @@ export type CodexAccountsApi = { runtime?: 'host' | 'wsl' wslDistro?: string | null }) => Promise - reauthenticate: (args: { accountId: string }) => Promise + reauthenticate: (args: { + accountId: string + /** Local-only: activate the re-authed account when its runtime lane had no selection. */ + activateIfSelectionWasEmpty?: boolean + }) => Promise remove: (args: { accountId: string }) => Promise select: (args: { accountId: string | null diff --git a/src/preload/index.ts b/src/preload/index.ts index 2c7beed48a2..f1deda11071 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2184,8 +2184,10 @@ const api = { list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:reauthenticate', args), + reauthenticate: (args: { + accountId: string + activateIfSelectionWasEmpty?: boolean + }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:remove', args), select: (args: { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 13b93c24596..c778a982aa0 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -96,6 +96,49 @@ import { watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' +// Why: bounded so a permanently unreadable home cannot poll forever; ~5 minutes +// total is long enough to outlast an antivirus scan or backup pass. +const CODEX_CONFIG_SYNC_RETRY_MS = 30_000 +const CODEX_CONFIG_SYNC_RETRY_LIMIT = 10 + +function watchCodexConfigSyncStatus( + onStatus: (status: CodexConfigSyncStatus | null) => void +): () => void { + let cancelled = false + let attempts = 0 + let retryTimer: ReturnType | null = null + const poll = (): void => { + void window.api.codexConfigSync + .status() + .then((status) => { + if (cancelled) { + return + } + onStatus(status) + if ( + status.state === 'stalled' && + status.reason === 'managed-home-unavailable' && + attempts < CODEX_CONFIG_SYNC_RETRY_LIMIT + ) { + attempts += 1 + retryTimer = setTimeout(poll, CODEX_CONFIG_SYNC_RETRY_MS) + } + }) + .catch(() => { + if (!cancelled) { + onStatus(null) + } + }) + } + poll() + return () => { + cancelled = true + if (retryTimer !== null) { + clearTimeout(retryTimer) + } + } +} + export { getAccountsPaneSearchEntries } const EMPTY_WSL_DISTROS: string[] = [] @@ -452,22 +495,12 @@ export function AccountsPane({ setCodexConfigSync(null) return } - let cancelled = false - void window.api.codexConfigSync - .status() - .then((status) => { - if (!cancelled) { - setCodexConfigSync(status) - } - }) - .catch(() => { - if (!cancelled) { - setCodexConfigSync(null) - } - }) - return () => { - cancelled = true - } + // Why: a temporarily locked managed home clears on its own, but this effect + // only reruns on scope/runtime/selection changes — none of which a lock + // release triggers. Without a retry the warning would stick until remount. + // Serialized (timeout, not interval) so a slow response can never be + // overwritten by an older one. + return watchCodexConfigSyncStatus(setCodexConfigSync) // Why: the status resolves whichever home the ACTIVE selection mirrors into // (per-account, shared, or none for the real-home lane), so switching // accounts must refetch or the banner describes the previous account. @@ -1180,23 +1213,28 @@ export function AccountsPane({
- {codexConfigSyncWarning === 'missing-source' + {codexConfigSyncWarning === 'managed-home-unavailable' ? translate( - 'auto.components.settings.AccountsPane.codexConfigSyncMissingSource', - 'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.', - { value0: codexConfigSync?.systemConfigPath ?? '' } + 'auto.components.settings.AccountsPane.codexConfigSyncManagedHomeUnavailable', + 'Orca could not read this account\u2019s Codex files just now, so settings may not be syncing. This usually clears on its own \u2014 antivirus or a backup tool briefly locks them.' ) - : codexConfigSyncWarning === 'blank-source' + : codexConfigSyncWarning === 'missing-source' ? translate( - 'auto.components.settings.AccountsPane.codexConfigSyncBlankSource', - 'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.', + 'auto.components.settings.AccountsPane.codexConfigSyncMissingSource', + 'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.', { value0: codexConfigSync?.systemConfigPath ?? '' } ) - : translate( - 'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource', - "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.", - { value0: codexConfigSync?.systemConfigPath ?? '' } - )} + : codexConfigSyncWarning === 'blank-source' + ? translate( + 'auto.components.settings.AccountsPane.codexConfigSyncBlankSource', + 'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.', + { value0: codexConfigSync?.systemConfigPath ?? '' } + ) + : translate( + 'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource', + "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.", + { value0: codexConfigSync?.systemConfigPath ?? '' } + )}
) : null} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 0243c5f8dee..436da40e59c 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -96,6 +96,7 @@ import { selectClaudeProviderAccount, selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' +import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { getDisplayedUsagePercentage, @@ -1489,23 +1490,61 @@ export function CodexSwitcherMenu({ } } - const handleSignInAccount = async (accountId: string): Promise => { + const handleSignInAccount = async ( + accountId: string, + target: CodexStatusRuntimeTarget + ): Promise => { if (isSwitching || reauthenticatingAccountId !== null) { return } + const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setReauthenticatingAccountId(accountId) try { - const next = await window.api.codexAccounts.reauthenticate({ accountId }) + const next = await window.api.codexAccounts.reauthenticate({ + accountId, + // Why: signing in from a signed-out status bar should leave the account + // usable; the main process still refuses to steal an existing selection. + activateIfSelectionWasEmpty: true + }) recordFeatureInteraction('codex-account-switching') if (mountedRef.current) { setAccounts(next) } await fetchSettings() - if (mountedRef.current && accountsExpandedRef.current) { + const nextActiveAccountId = getCodexStatusActiveId(next, target) + if (previousActiveAccountId !== nextActiveAccountId) { + // Why: sign-in that lands on a new active account changes pane credentials + // exactly like an explicit switch, so it owes the same restart prompt. + await markLiveCodexSessionsForRestart({ + previousAccountLabel: resolveCodexRestartPromptAccountLabel( + accountState.accounts, + previousActiveAccountId + ), + nextAccountLabel: resolveCodexRestartPromptAccountLabel( + next.accounts, + nextActiveAccountId + ), + previousAccountId: previousActiveAccountId ?? null, + nextAccountId: nextActiveAccountId ?? null, + target + }) + if (mountedRef.current) { + setAccountsExpanded(false) + } + } else if (mountedRef.current && accountsExpandedRef.current) { await fetchInactiveCodexAccountUsage() } + toast.success( + translate('auto.components.status.bar.StatusBar.codexSignInSuccess', 'Signed in to Codex') + ) } catch (error) { console.error('Failed to re-authenticate Codex account from status bar:', error) + toast.error( + translate( + 'auto.components.status.bar.StatusBar.codexSignInError', + 'Codex sign-in failed. Please try again.' + ) + ) } finally { if (mountedRef.current) { setReauthenticatingAccountId(null) @@ -1802,7 +1841,7 @@ export function CodexSwitcherMenu({ onSignIn={() => { suppressNextAccountSelect() if (target.id !== null) { - void handleSignInAccount(target.id) + void handleSignInAccount(target.id, target.runtimeTarget) } }} /> diff --git a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx new file mode 100644 index 00000000000..446019b86be --- /dev/null +++ b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx @@ -0,0 +1,262 @@ +// @vitest-environment happy-dom +/** + * STA-4422 symptom 2: the status bar's inline "Sign in" action re-authenticated + * an account and then reported nothing — no toast on success or failure — and + * never ran the session-restart workflow that an explicit account switch runs, + * so live panes kept the old credentials. These render the real switcher and + * click the real button so the wiring (intent flag, runtime target, restart + * workflow, toasts) cannot pass while the handler is mis-wired. + */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { + CodexManagedAccountSummary, + CodexRateLimitAccountsState +} from '../../../../shared/managed-account-types' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +const markLiveCodexSessionsForRestart = vi.fn(async () => {}) +const toastSuccess = vi.fn() +const toastError = vi.fn() +const fetchInactiveCodexAccountUsage = vi.fn(async () => {}) +const fetchSettings = vi.fn(async () => {}) +const reauthenticate = vi.fn(async (_args: unknown) => codexSnapshot(null)) + +let storeSettings: GlobalSettings + +function codexAccount(id: string, updatedAt: number): CodexManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + managedHomeRuntime: 'host', + wslDistro: null, + providerAccountId: `provider-${id}`, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: updatedAt, + updatedAt, + lastAuthenticatedAt: updatedAt + } +} + +function codexSnapshot(activeAccountId: string | null): CodexRateLimitAccountsState { + return { + accounts: [codexAccount('account-1', 2), codexAccount('account-2', 1)], + activeAccountId, + activeAccountIdsByRuntime: { host: activeAccountId, wsl: {} } + } +} + +function settingsWithActive(activeAccountId: string | null): GlobalSettings { + return { + codexManagedAccounts: [codexAccount('account-1', 2), codexAccount('account-2', 1)], + activeCodexManagedAccountId: activeAccountId, + activeCodexManagedAccountIdsByRuntime: { host: activeAccountId, wsl: {} }, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + localAccountRuntime: 'host', + localAccountWslDistro: null, + skipCodexRateLimitResetConfirm: false + } as unknown as GlobalSettings +} + +/** An inactive account whose usage probe failed — the state that renders "Sign in". */ +const unavailableUsage: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: 'Not signed in', + updatedAt: 1 +} as unknown as ProviderRateLimits + +vi.mock('sonner', () => ({ + toast: { success: toastSuccess, error: toastError } +})) + +vi.mock('@/lib/codex-session-restart', () => ({ + markLiveCodexSessionsForRestart, + resolveCodexRestartPromptAccountLabel: ( + accounts: readonly { id: string; email: string }[], + accountId: string | null | undefined + ) => accounts.find((entry) => entry.id === accountId)?.email ?? 'System default' +})) + +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + fetchProviderAccountsSnapshot: vi.fn(async () => ({ + codex: codexSnapshot(null), + claude: { accounts: [], activeAccountId: null }, + failedProviders: [] + })), + selectCodexProviderAccount: vi.fn(async () => codexSnapshot(null)), + selectClaudeProviderAccount: vi.fn(async () => ({ accounts: [], activeAccountId: null })) +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => null +})) + +vi.mock('@/lib/windows-terminal-capabilities', () => ({ + useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }), + getWindowsTerminalCapabilityOwnerKey: () => 'local' +})) + +vi.mock('./tooltip', async (importOriginal) => ({ + ...(await importOriginal>()), + ProviderIcon: () => null, + ProviderPanel: () => null +})) + +// Why: Radix portals its menu behind pointer-capture the DOM shim cannot drive; +// passthrough shells render the same tree eagerly so the real handlers run. +vi.mock('@/components/ui/dropdown-menu', () => { + const passthrough = + (role?: string) => + ({ + children, + onSelect + }: { + children?: React.ReactNode + onSelect?: (event: { preventDefault: () => void }) => void + }): React.JSX.Element => + React.createElement( + 'div', + { + role, + onClick: onSelect ? () => onSelect({ preventDefault: () => {} }) : undefined + }, + children + ) + return { + DropdownMenu: passthrough(), + DropdownMenuCheckboxItem: passthrough(), + DropdownMenuContent: passthrough(), + DropdownMenuItem: passthrough('menuitem'), + DropdownMenuLabel: passthrough(), + DropdownMenuSeparator: passthrough(), + DropdownMenuSub: passthrough(), + DropdownMenuSubContent: passthrough(), + DropdownMenuSubTrigger: passthrough(), + DropdownMenuTrigger: passthrough() + } +}) + +vi.mock('../../store', () => { + const state = (): Record => ({ + settings: storeSettings, + runtimeEnvironments: [], + usagePercentageDisplay: 'used', + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + fetchSettings, + updateSettings: vi.fn(), + recordFeatureInteraction: vi.fn(), + refreshCodexRateLimitsForTarget: vi.fn(), + consumeCodexRateLimitResetCredit: vi.fn(), + fetchInactiveCodexAccountUsage, + rateLimits: { + inactiveCodexAccounts: [ + { accountId: 'account-2', isFetching: false, rateLimits: unavailableUsage } + ], + codexTarget: { runtime: 'host', wslDistro: null } + } + }) + const useAppStore = (selector: (value: Record) => unknown): unknown => + selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +const codexProvider: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: 'Not signed in', + updatedAt: 1 +} as unknown as ProviderRateLimits + +async function renderSwitcherAndOpenAccounts(summaryLabel: string): Promise { + const { CodexSwitcherMenu } = await import('./StatusBar') + render( + React.createElement(CodexSwitcherMenu, { + codex: codexProvider, + compact: false, + iconOnly: false + }) + ) + // The account list is collapsed behind the summary row, exactly as in the app. + fireEvent.click(screen.getByText(summaryLabel)) + await waitFor(() => expect(screen.getByRole('button', { name: /Sign in/ })).toBeTruthy()) +} + +describe('status bar Codex sign-in action', () => { + beforeEach(() => { + vi.clearAllMocks() + storeSettings = settingsWithActive(null) + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { codexAccounts: { reauthenticate } } + }) + }) + + afterEach(() => { + cleanup() + }) + + it('activates the signed-in account and runs the same restart workflow a switch runs', async () => { + reauthenticate.mockResolvedValue(codexSnapshot('account-2')) + + await renderSwitcherAndOpenAccounts('System default') + fireEvent.click(screen.getByRole('button', { name: /Sign in/ })) + + await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1)) + // Healthy anchor: the intent and the clicked row's lane both reach the main process. + expect(reauthenticate).toHaveBeenCalledWith({ + accountId: 'account-2', + activateIfSelectionWasEmpty: true + }) + expect(markLiveCodexSessionsForRestart).toHaveBeenCalledTimes(1) + expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith( + expect.objectContaining({ + previousAccountId: null, + nextAccountId: 'account-2', + target: { runtime: 'host', wslDistro: null } + }) + ) + expect(toastError).not.toHaveBeenCalled() + }) + + it('does not disturb live panes when the effective account did not change', async () => { + storeSettings = settingsWithActive('account-1') + reauthenticate.mockResolvedValue(codexSnapshot('account-1')) + + await renderSwitcherAndOpenAccounts('account-1@example.com') + fireEvent.click(screen.getByRole('button', { name: /Sign in/ })) + + await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1)) + // Healthy anchor: the same click reached the main process; only the follow-up differs. + expect(reauthenticate).toHaveBeenCalledWith({ + accountId: 'account-2', + activateIfSelectionWasEmpty: true + }) + expect(markLiveCodexSessionsForRestart).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + }) + + it('reports a failed sign-in instead of leaving the click silent', async () => { + reauthenticate.mockRejectedValue(new Error('Codex login exited with code 1.')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await renderSwitcherAndOpenAccounts('System default') + fireEvent.click(screen.getByRole('button', { name: /Sign in/ })) + + await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1)) + expect(toastSuccess).not.toHaveBeenCalled() + expect(markLiveCodexSessionsForRestart).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d62d5f1858..626231231ea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3489,7 +3489,9 @@ "antigravityUsageDetails": "Open Antigravity usage details", "grokUsageAria": "Open Grok usage details", "grokUsageMenu": "Grok Usage", - "floatingTerminalNewActivity": "{{label}}, new activity" + "floatingTerminalNewActivity": "{{label}}, new activity", + "codexSignInSuccess": "Signed in to Codex", + "codexSignInError": "Codex sign-in failed. Please try again." }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -6125,6 +6127,7 @@ "codexSystemDefaultNeedsSignIn": "No Codex sign-in was found for {{value0}}.", "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", + "codexConfigSyncManagedHomeUnavailable": "Orca could not read this account’s Codex files just now, so settings may not be syncing. This usually clears on its own — antivirus or a backup tool briefly locks them.", "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c3c6768dbd9..24e57ee1074 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3276,7 +3276,9 @@ "antigravityUsageDetails": "Abrir detalles de uso de Antigravity", "grokUsageAria": "Abrir detalles de uso de Grok", "grokUsageMenu": "Uso de Grok", - "floatingTerminalNewActivity": "{{label}}, nueva actividad" + "floatingTerminalNewActivity": "{{label}}, nueva actividad", + "codexSignInSuccess": "Sesión iniciada en Codex", + "codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo." }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -5338,6 +5340,7 @@ "codexSystemDefaultNeedsSignIn": "No se encontró ningún inicio de sesión de Codex para {{value0}}.", "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", + "codexConfigSyncManagedHomeUnavailable": "Orca no pudo leer los archivos de Codex de esta cuenta en este momento, por lo que es posible que la configuración no se esté sincronizando. Normalmente se resuelve solo: un antivirus o una herramienta de copia de seguridad puede bloquearlos brevemente.", "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6b3aa3ba9f8..f2d1b9c0b58 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3276,7 +3276,9 @@ "antigravityUsageDetails": "Antigravity の使用状況詳細を開く", "grokUsageAria": "Grok の使用状況詳細を開く", "grokUsageMenu": "Grok の使用状況", - "floatingTerminalNewActivity": "{{label}}、新規アクティビティ" + "floatingTerminalNewActivity": "{{label}}、新規アクティビティ", + "codexSignInSuccess": "Codex にサインインしました", + "codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -5323,6 +5325,7 @@ "codexSystemDefaultNeedsSignIn": "{{value0}} の Codex サインインが見つかりません。", "codexConfigSyncMissingSource": "{{value0}} が見つからないため、Codex は最後に同期した設定を使い続けています。同期を再開するには、そのファイルを復元してください。", "codexConfigSyncBlankSource": "{{value0}} が空のため、Codex は最後に同期した設定を使い続けています。同期フォルダーのダウンロードが完了するまでは正常な状態です。", + "codexConfigSyncManagedHomeUnavailable": "Orca は現在このアカウントの Codex ファイルを読み取れないため、設定が同期されていない可能性があります。ウイルス対策ソフトやバックアップツールによる一時的なロックが原因で、通常は自動的に解消されます。", "codexConfigSyncUnreadableSource": "{{value0}} を読み取れなかったため、Codex は最後に同期した設定を使い続けています。そのファイルの権限を確認してください。" }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5e67a56c55e..e10a0456175 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3278,7 +3278,9 @@ "antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기", "grokUsageAria": "Grok 사용량 세부 정보 열기", "grokUsageMenu": "Grok 사용량", - "floatingTerminalNewActivity": "{{label}}, 새 활동" + "floatingTerminalNewActivity": "{{label}}, 새 활동", + "codexSignInSuccess": "Codex에 로그인했습니다", + "codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요." }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -5325,6 +5327,7 @@ "codexSystemDefaultNeedsSignIn": "{{value0}}에 대한 Codex 로그인을 찾을 수 없습니다.", "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", + "codexConfigSyncManagedHomeUnavailable": "현재 Orca가 이 계정의 Codex 파일을 읽을 수 없어 설정이 동기화되지 않을 수 있습니다. 바이러스 백신이나 백업 도구가 파일을 잠시 잠근 경우가 많으며, 일반적으로 자동으로 해결됩니다.", "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f060a9c7529..31238ca11ec 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3288,7 +3288,9 @@ "antigravityUsageDetails": "打开 Antigravity 使用详情", "grokUsageAria": "打开 Grok 使用详情", "grokUsageMenu": "Grok 使用情况", - "floatingTerminalNewActivity": "{{label}},有新活动" + "floatingTerminalNewActivity": "{{label}},有新活动", + "codexSignInSuccess": "已登录 Codex", + "codexSignInError": "Codex 登录失败,请重试。" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -5335,6 +5337,7 @@ "codexSystemDefaultNeedsSignIn": "未找到 {{value0}} 的 Codex 登录信息。", "codexConfigSyncMissingSource": "由于缺少 {{value0}},Codex 仍在使用上次同步的设置。请恢复该文件以恢复同步。", "codexConfigSyncBlankSource": "由于 {{value0}} 为空,Codex 仍在使用上次同步的设置。同步文件夹完成下载前出现这种情况是正常的。", + "codexConfigSyncManagedHomeUnavailable": "Orca 暂时无法读取此账户的 Codex 文件,因此设置可能尚未同步。这通常会自行恢复——防病毒软件或备份工具可能只是短暂锁定了这些文件。", "codexConfigSyncUnreadableSource": "由于无法读取 {{value0}},Codex 仍在使用上次同步的设置。请检查该文件的权限。" }, "AdvancedPane": { diff --git a/src/shared/codex-config-sync-types.ts b/src/shared/codex-config-sync-types.ts index 32f413dbaef..92284dcccbd 100644 --- a/src/shared/codex-config-sync-types.ts +++ b/src/shared/codex-config-sync-types.ts @@ -1,7 +1,15 @@ // Why: the managed runtime config keeps serving the last good settings while the // source is unusable, so a stall is "working but not picking up your edits" — // the reason is what makes it actionable, and the path is what the user fixes. -export type CodexConfigSyncStallReason = 'missing-source' | 'blank-source' | 'unreadable-source' +// Why: the first three describe the SYSTEM config source. 'managed-home-unavailable' +// describes the managed home itself being temporarily unreadable (e.g. an antivirus +// lock on Windows) — a different file and a different remedy, so it needs its own +// reason rather than borrowing 'unreadable-source' and blaming the wrong path. +export type CodexConfigSyncStallReason = + | 'missing-source' + | 'blank-source' + | 'unreadable-source' + | 'managed-home-unavailable' export type CodexConfigSyncStatus = | { state: 'synced'; reason: null; systemConfigPath: string } From b2612de157d58320f2b90fa319d04c4f8bbab2cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:27:51 +0000 Subject: [PATCH 013/109] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 49234fe606a..059a9c9ee6b 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 21m + + downloads: 22m @@ -15,7 +15,7 @@ downloads downloads - 21m - 21m + 22m + 22m From a1cd7eaa7ed558f43312b8608a34181727b2a77c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:07:38 -0700 Subject: [PATCH 014/109] refactor(terminal): redesign quick command dialog with expanded layout (#15011) * refactor(terminal): redesign quick command dialog with expanded layout a - Enlarge dialog to 52rem width and add scrollable content area for improved editing experience - Restructure textarea with header (label, status badge) and footer (hints, controls) - Compact action toggle to use shorter labels with grid layout - Move append enter switch to textarea footer (compact mode) for terminal commands - Add scope summary to Advanced toggle when collapsed for quick reference - Update dialog description and add contextual hints for user guidance * fix(terminal): restore defaultAdvancedOpen on quick command dialog Settings still opens the Advanced section from settings; the redesign dropped the prop and failed typecheck. * Improve accessibility of terminal quick command dialog - Add inert attribute to prevent keyboard/screen reader access to hidden advanced section - Add aria-label to textarea for clearer form field labeling - Extract label text to variable to avoid duplication and ensure consistency * refactor: remove status badge from quick command header Simplifies the dialog header layout by removing the "sent to agent" / "runs in terminal" badge and its associated translation strings. --- .../TerminalQuickCommandActionToggle.tsx | 26 +- .../TerminalQuickCommandAdvancedSection.tsx | 105 +++++--- .../TerminalQuickCommandAppendEnterSwitch.tsx | 27 +- .../TerminalQuickCommandContentSection.tsx | 237 +++++++++++------- .../TerminalQuickCommandDialog.test.tsx | 68 ++++- .../TerminalQuickCommandDialog.tsx | 49 ++-- .../TerminalQuickCommandDialogFooter.tsx | 10 +- src/renderer/src/i18n/locales/en.json | 15 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 4 +- 12 files changed, 363 insertions(+), 184 deletions(-) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandActionToggle.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandActionToggle.tsx index 85a008cd5f7..0885812c5c6 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandActionToggle.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandActionToggle.tsx @@ -1,4 +1,5 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { cn } from '@/lib/utils' import type { TerminalQuickCommandDialogAction } from './terminal-quick-command-dialog-draft' import { QUICK_COMMAND_TOGGLE_ITEM_CLASS } from './terminal-quick-command-toggle-style' import { translate } from '@/i18n/i18n' @@ -21,20 +22,35 @@ export function TerminalQuickCommandActionToggle({ onActionChange(value) } }} - className="justify-start" + // Why: equal columns keep the control width stable when labels differ by locale. + className="grid w-[11.5rem] grid-cols-2" variant="outline" > - - {translate( + - + > {translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandActionToggle.terminal_short', + 'Terminal' + )} + + + {translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandActionToggle.agent_short', + 'Agent' + )} ) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx index cca68ca54aa..1ad49ee76a0 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx @@ -3,39 +3,58 @@ import { ChevronDown } from 'lucide-react' import type { Repo } from '../../../../shared/repo-types' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import type { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands' -import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { TerminalQuickCommandAppendEnterSwitch } from './TerminalQuickCommandAppendEnterSwitch' -import { TerminalQuickCommandCollapsibleRow } from './TerminalQuickCommandCollapsibleRow' import { TerminalQuickCommandScopeField } from './TerminalQuickCommandScopeField' type TerminalQuickCommandAdvancedSectionProps = { - draft: TerminalQuickCommand repos: readonly Pick[] advancedOpen: boolean selectedScope: ReturnType + selectedRepo: Pick | null selectedRepoId: string selectedRepoMissing: boolean lastRepoScopeIdRef: MutableRefObject setAdvancedOpen: Dispatch> setDraft: Dispatch> - toggleAppendEnter: () => void +} + +function getScopeSummaryLabel({ + selectedScope, + selectedRepo +}: { + selectedScope: ReturnType + selectedRepo: Pick | null +}): string { + if (selectedScope.type === 'global') { + return translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandScopeField.b83efc79e2', + 'Global' + ) + } + if (selectedRepo) { + return selectedRepo.displayName || selectedRepo.path + } + return translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandScopeField.3834d24243', + 'Project' + ) } export function TerminalQuickCommandAdvancedSection({ - draft, repos, advancedOpen, selectedScope, + selectedRepo, selectedRepoId, selectedRepoMissing, lastRepoScopeIdRef, setAdvancedOpen, - setDraft, - toggleAppendEnter + setDraft }: TerminalQuickCommandAdvancedSectionProps): React.JSX.Element { + const scopeSummary = getScopeSummaryLabel({ selectedScope, selectedRepo }) + return (
- - { - lastRepoScopeIdRef.current = repoId - }} - setDraft={setDraft} - /> - {!isTerminalAgentQuickCommand(draft) ? ( - - ) : null} - +
+
+
+ { + lastRepoScopeIdRef.current = repoId + }} + setDraft={setDraft} + /> +
+
+
) } diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx index 73f69ebf5d8..187fa402207 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx @@ -1,16 +1,38 @@ import { translate } from '@/i18n/i18n' import { Switch } from '@/components/ui/switch' + type TerminalQuickCommandAppendEnterSwitchProps = { appendEnter: boolean onToggle: () => void - disabled?: boolean + compact?: boolean } export function TerminalQuickCommandAppendEnterSwitch({ appendEnter, onToggle, - disabled = false + compact = false }: TerminalQuickCommandAppendEnterSwitchProps): React.JSX.Element { + if (compact) { + return ( + + ) + } + return (
@@ -29,7 +51,6 @@ export function TerminalQuickCommandAppendEnterSwitch({
setDraft: Dispatch> + toggleAppendEnter: () => void } export function TerminalQuickCommandContentSection({ @@ -36,85 +36,122 @@ export function TerminalQuickCommandContentSection({ isAgentAction, selectedAgent, draftMemoryRef, - setDraft + setDraft, + toggleAppendEnter }: TerminalQuickCommandContentSectionProps): React.JSX.Element { - return ( -
- - - - + const commandText = isTerminalAgentQuickCommand(draft) ? draft.prompt : draft.command + // Why: the frame header is a plain span, so the textarea carries the accessible name itself. + const commandFieldLabel = isAgentAction + ? translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.dc921c17ee', + 'Prompt' + ) + : translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.command_label', + 'Command' + ) -
-