mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
docs: remove tracked design plans (#16663)
This commit is contained in:
@@ -98,9 +98,6 @@ docs/**
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/agent-skill-sharing-installation-plan.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/automations-all-hosts-design.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
|
||||
@@ -314,8 +314,6 @@ Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL se
|
||||
identical to macOS. The user-driven signed-in desktop and real-host production journey, supported
|
||||
Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep.
|
||||
|
||||
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
|
||||
|
||||
This checklist turns the architecture plan into ordered implementation and release work. A checked
|
||||
item means evidence exists in code, tests, reviewed infrastructure, or release documentation; it
|
||||
does not mean the surrounding phase is complete.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,596 +0,0 @@
|
||||
# AI Vault process isolation architecture and implementation plan
|
||||
|
||||
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
|
||||
|
||||
Last updated: 2026-08-09.
|
||||
|
||||
## Decision summary
|
||||
|
||||
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
|
||||
|
||||
The target has three rules:
|
||||
|
||||
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
|
||||
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
|
||||
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
|
||||
|
||||
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
|
||||
|
||||
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
|
||||
|
||||
## Goals
|
||||
|
||||
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
|
||||
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
|
||||
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
|
||||
- The service can be restarted without restarting the app, runtime, relay, or terminals.
|
||||
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
|
||||
- Mixed-version remote peers continue to work.
|
||||
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
|
||||
- Rewriting the scanner or changing session discovery semantics during the process migration.
|
||||
- Moving xterm into another renderer.
|
||||
- Adding a process per window, worktree, repository, or SSH request.
|
||||
- Removing the old-relay compatibility fallback in the same change.
|
||||
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
|
||||
|
||||
## Current architecture and coupling
|
||||
|
||||
| Path | Current execution | Remaining coupling |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
|
||||
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
|
||||
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
|
||||
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
|
||||
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
|
||||
|
||||
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
|
||||
|
||||
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
|
||||
|
||||
## Feature-parity contract
|
||||
|
||||
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
|
||||
|
||||
| Capability | Required behavior after migration | Owner |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
|
||||
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
|
||||
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
|
||||
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
|
||||
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
|
||||
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
|
||||
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
|
||||
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
|
||||
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
|
||||
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
|
||||
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
|
||||
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
|
||||
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
|
||||
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
|
||||
|
||||
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
|
||||
|
||||
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
|
||||
|
||||
## Target topology
|
||||
|
||||
```text
|
||||
Desktop / runtime host
|
||||
|
||||
Integrated renderer
|
||||
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
|
||||
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
|
||||
│
|
||||
└─ host merge + compatibility routing
|
||||
|
||||
SSH execution host
|
||||
|
||||
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
|
||||
└────────────────── PTY handler/event loop
|
||||
```
|
||||
|
||||
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
|
||||
|
||||
## Process ownership
|
||||
|
||||
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
|
||||
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
|
||||
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
|
||||
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
|
||||
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
The service owns:
|
||||
|
||||
- Source discovery and filesystem metadata reads.
|
||||
- Transcript streaming and parsing for all supported agents.
|
||||
- OpenCode SQLite reads and its nested worker lifecycle.
|
||||
- In-memory and persisted session parse caches.
|
||||
- The bounded title index and transcript title resolution.
|
||||
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
|
||||
- Scan parse concurrency and cooperative cancellation.
|
||||
- Result construction, deduplication, sorting, and internal paging.
|
||||
- Per-request metrics that contain counts and timings, not session content.
|
||||
|
||||
The router owns:
|
||||
|
||||
- Electron IPC, runtime RPC, or relay method registration.
|
||||
- Authentication and connection ownership already enforced by that transport.
|
||||
- Public argument validation and execution-host selection.
|
||||
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
|
||||
- Multi-host result merging.
|
||||
- Request coalescing and mapping caller cancellation to the service request.
|
||||
- Validation of service responses before publishing them across a public boundary.
|
||||
- Compatibility fallback selection.
|
||||
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
|
||||
|
||||
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
|
||||
|
||||
## Internal process protocol
|
||||
|
||||
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
|
||||
|
||||
Parent to service:
|
||||
|
||||
```ts
|
||||
type ServiceRequest =
|
||||
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
|
||||
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
|
||||
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
|
||||
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
|
||||
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
|
||||
| { type: 'invalidate'; paths: string[]; generation: number }
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
```
|
||||
|
||||
Service to parent:
|
||||
|
||||
```ts
|
||||
type ServiceResponse =
|
||||
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
|
||||
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
|
||||
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
|
||||
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
|
||||
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
|
||||
| { type: 'fatal'; code: ServiceErrorCode; message: string }
|
||||
```
|
||||
|
||||
Protocol rules:
|
||||
|
||||
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
|
||||
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
|
||||
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
|
||||
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
|
||||
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
|
||||
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
|
||||
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
|
||||
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
|
||||
- Continue returning the last good renderer result when a refresh fails.
|
||||
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
|
||||
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
|
||||
|
||||
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
|
||||
|
||||
## Lifecycle and supervision
|
||||
|
||||
### Startup
|
||||
|
||||
- Start lazily on the first scan or title request.
|
||||
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
|
||||
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
|
||||
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
|
||||
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
|
||||
|
||||
### Steady state
|
||||
|
||||
- Reuse the process and caches across calls.
|
||||
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
|
||||
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
|
||||
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
|
||||
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
|
||||
|
||||
### Faults and restart
|
||||
|
||||
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
|
||||
- Retry queued work in a new process only when it is safe and has not been cancelled.
|
||||
- Back off at 250 ms, 1 second, then 5 seconds.
|
||||
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
|
||||
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
|
||||
|
||||
## Resource and quality-of-service policy
|
||||
|
||||
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
|
||||
|
||||
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
|
||||
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
|
||||
- Queue: 16 calls, coalescing, background replacement, explicit priority.
|
||||
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
|
||||
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
|
||||
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
|
||||
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
|
||||
|
||||
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
|
||||
|
||||
## Renderer publication
|
||||
|
||||
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
|
||||
|
||||
Add `ai-vault-session-publication-gate.ts` with this behavior:
|
||||
|
||||
1. Cache the validated result immediately so another caller can reuse it.
|
||||
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
|
||||
3. Otherwise retain only the newest pending result and wait for quiet.
|
||||
4. Publish after 1 second even if input continues, using a transition and one bounded page.
|
||||
5. Cancel a pending publication when the scope, host, or component request token changes.
|
||||
|
||||
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
|
||||
|
||||
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
|
||||
|
||||
## Desktop and runtime implementation
|
||||
|
||||
Create these concrete modules:
|
||||
|
||||
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
|
||||
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
|
||||
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
|
||||
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
|
||||
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
|
||||
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
|
||||
|
||||
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
|
||||
|
||||
- `src/main/ai-vault/cached-session-list.ts`
|
||||
- `src/main/ai-vault/session-title-resolver.ts`
|
||||
- `src/main/ipc/ai-vault-subagent-list.ts`
|
||||
- `src/main/ai-vault/session-first-user-prompt-read.ts`
|
||||
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
|
||||
- `src/main/ipc/ai-vault.ts`
|
||||
- `src/main/runtime/rpc/methods/ai-vault.ts`
|
||||
|
||||
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
|
||||
|
||||
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
|
||||
|
||||
## SSH relay sidecar implementation
|
||||
|
||||
Create:
|
||||
|
||||
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
|
||||
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
|
||||
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
|
||||
|
||||
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
|
||||
|
||||
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
|
||||
|
||||
If the sidecar cannot start or crashes:
|
||||
|
||||
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
|
||||
- Do not scan inline in the relay.
|
||||
- Keep all PTYs, reconnect state, and other relay methods available.
|
||||
- Allow the next manual refresh to cross the restart circuit breaker once.
|
||||
|
||||
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
|
||||
|
||||
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
|
||||
- New desktop with current relay: calls the same methods and receives the same result shape.
|
||||
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
|
||||
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
|
||||
|
||||
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
|
||||
|
||||
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
|
||||
|
||||
## Folder workspaces, WSL, and paths
|
||||
|
||||
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
|
||||
- Continue using `path.join` and the existing host-platform path adapters.
|
||||
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
|
||||
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
|
||||
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
|
||||
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
|
||||
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service is a performance and reliability boundary, not a privilege boundary.
|
||||
|
||||
- It has the same OS user and must access the same agent stores.
|
||||
- Do not expose its IPC channel to renderer code or the network.
|
||||
- The parent passes validated options; the service validates again before filesystem access.
|
||||
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
|
||||
- Preserve symlink and allowed-root checks for title reads.
|
||||
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
|
||||
|
||||
## Packaging
|
||||
|
||||
Desktop packaging changes:
|
||||
|
||||
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
|
||||
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
|
||||
- Reuse the existing unpacked entry-path pattern used by other forked processes.
|
||||
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
|
||||
|
||||
Relay packaging changes:
|
||||
|
||||
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
|
||||
- Include it in `.version` content hashing and deploy completeness assertions.
|
||||
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
|
||||
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans and counters for:
|
||||
|
||||
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
|
||||
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
|
||||
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
|
||||
- Parent/child serialization duration and serialized bytes.
|
||||
- Child RSS and heap-used snapshots at request completion.
|
||||
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
|
||||
- Relay sidecar failures separately from PTY and relay-loop health.
|
||||
|
||||
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
|
||||
|
||||
| Layer | Required coverage |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
|
||||
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
|
||||
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
|
||||
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
|
||||
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
|
||||
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
|
||||
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
|
||||
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
|
||||
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
|
||||
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
|
||||
|
||||
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
| Failure | User-visible Vault behavior | Terminal behavior |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
|
||||
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
|
||||
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
|
||||
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
|
||||
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
|
||||
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
|
||||
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
|
||||
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
|
||||
|
||||
## Benchmark methodology
|
||||
|
||||
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
|
||||
|
||||
Each iteration:
|
||||
|
||||
1. Keeps the Vault panel mounted in both arms.
|
||||
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
|
||||
3. Alternates arm order to reduce warm-up bias.
|
||||
4. Runs paced raw-mode terminal echo typing as the control.
|
||||
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
|
||||
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
|
||||
|
||||
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
|
||||
|
||||
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
|
||||
|
||||
## Current measurements
|
||||
|
||||
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
|
||||
|
||||
Pre-implementation parity baseline:
|
||||
|
||||
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
|
||||
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
|
||||
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
|
||||
|
||||
### Representative pass
|
||||
|
||||
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | -------------------: | ------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
|
||||
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
|
||||
| Worst timer drift | 14.2 ms | 24.5 ms |
|
||||
| Worst frame gap | 9.4 ms | 24.9 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 |
|
||||
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
|
||||
|
||||
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
|
||||
|
||||
### Stress pass
|
||||
|
||||
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | ------------------: | -------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
|
||||
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
|
||||
| Worst timer drift | 9.3 ms | 43.6 ms |
|
||||
| Worst frame gap | 9.4 ms | 50.0 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 240 | 0 / 240 |
|
||||
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
|
||||
|
||||
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
|
||||
|
||||
### Measurement limits
|
||||
|
||||
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
|
||||
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
|
||||
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
|
||||
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
|
||||
|
||||
## Post-implementation A/B
|
||||
|
||||
Run the same build with the rollout switch off and on, alternating launch order:
|
||||
|
||||
```bash
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
|
||||
```
|
||||
|
||||
Run at least five representative and five stress passes on:
|
||||
|
||||
- macOS Apple Silicon.
|
||||
- Windows x64 with a folder workspace and a WSL-backed source present.
|
||||
- Ubuntu 20.04-compatible x64 packaging/runtime.
|
||||
- A 4-core or smaller machine or constrained CI runner.
|
||||
|
||||
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
|
||||
|
||||
## Implemented result
|
||||
|
||||
The implementation now matches the target topology:
|
||||
|
||||
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
|
||||
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
|
||||
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
|
||||
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
|
||||
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
|
||||
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
|
||||
|
||||
Local validation on 2026-08-09:
|
||||
|
||||
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
|
||||
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
|
||||
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
|
||||
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
|
||||
|
||||
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
|
||||
|
||||
| Metric | Worker control | Worker refresh | Process control | Process refresh |
|
||||
| --------------------- | -------------: | -------------: | --------------: | --------------: |
|
||||
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
|
||||
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
|
||||
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
|
||||
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
|
||||
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
|
||||
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
|
||||
|
||||
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
|
||||
|
||||
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Correctness:
|
||||
|
||||
- Zero missing terminal echoes.
|
||||
- The latest seeded Vault session is visibly published in every treatment arm.
|
||||
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
|
||||
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
|
||||
|
||||
Performance:
|
||||
|
||||
- Treatment parse p95 is no more than 10 ms above its paired control.
|
||||
- Treatment render p95 is no more than 15 ms above its paired control.
|
||||
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
|
||||
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
|
||||
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
|
||||
|
||||
Isolation:
|
||||
|
||||
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
|
||||
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
|
||||
- The service respects queue, timeout, idle, and heap bounds.
|
||||
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — measurement harness (completed in this change)
|
||||
|
||||
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
|
||||
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
|
||||
- Capture representative and stress baselines.
|
||||
|
||||
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
|
||||
|
||||
### Phase 1 — desktop and runtime service behind a switch
|
||||
|
||||
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
|
||||
- Reuse current scanner/cache implementations.
|
||||
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
|
||||
- Keep the worker path as the disabled-switch fallback.
|
||||
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
|
||||
- Add E2E fault injection that kills the service during active terminal typing.
|
||||
|
||||
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
|
||||
|
||||
### Phase 2 — make the desktop/runtime process the default
|
||||
|
||||
- Enable the process by default in development, E2E, and canary builds.
|
||||
- Collect process RSS, restart, timeout, and latency telemetry without content.
|
||||
- Validate Windows, Linux, WSL, and constrained-runner results.
|
||||
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
|
||||
|
||||
Exit: no unexplained crash or timeout increase and all acceptance gates green.
|
||||
|
||||
### Phase 3 — SSH relay sidecar
|
||||
|
||||
- Add the relay sidecar entry and supervisor.
|
||||
- Remove scanner/parser/cache imports from the relay handler.
|
||||
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
|
||||
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
|
||||
- Make inline relay scanning impossible; sidecar failure returns a host issue.
|
||||
|
||||
Exit: remote typing and fault gates green; current public relay methods unchanged.
|
||||
|
||||
### Phase 4 — renderer quiet publication
|
||||
|
||||
- Add the shared quiet-publication gate and React transition.
|
||||
- Retain only the newest pending result per scope/host.
|
||||
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
|
||||
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
|
||||
|
||||
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
|
||||
|
||||
### Phase 5 — hardening and cleanup
|
||||
|
||||
- Remove the worker implementation after one stable release if the kill switch was unused.
|
||||
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
|
||||
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
|
||||
- Document operational recovery and service diagnostics.
|
||||
|
||||
Exit: kill switch and dead worker code removed only after evidence supports it.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
|
||||
- Renderer: disable quiet publication and apply the validated complete result directly.
|
||||
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
|
||||
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
|
||||
|
||||
## Confidence and remaining questions
|
||||
|
||||
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
|
||||
|
||||
The remaining implementation questions are deliberately narrow:
|
||||
|
||||
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
|
||||
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
|
||||
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
|
||||
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
|
||||
@@ -1,530 +0,0 @@
|
||||
# All-host Automations
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. The protocol, identity, and fencing decisions are settled here. The product decisions listed under `Deferred / Open Questions` at the end of this document are still open and need an owner before the first release.
|
||||
|
||||
## Summary
|
||||
|
||||
The Automations page is global, but its data is currently loaded from one active authority and several code paths later infer ownership from a bare automation, run, repo, workspace, or host ID. That is unsafe once records from multiple desktop and runtime stores appear together.
|
||||
|
||||
This design introduces an authority-qualified automation catalog. Every row captures the exact desktop/runtime and self/SSH incarnation that produced it. Lists are cached per visible host, mutations and secondary reads are fenced against that captured incarnation, runtime version skew has an explicit fallback, and failures remain isolated to one authority.
|
||||
|
||||
`All hosts` is a renderer aggregation mode, not a backend-wide query. Selecting one host performs no discovery or automation query against unrelated authorities.
|
||||
|
||||
## Goals
|
||||
|
||||
- Show Orca automations stored by the desktop and every saved remote Orca runtime, including each authority's user-visible SSH targets.
|
||||
- Make host scope visible and persistent without treating an unhydrated catalog as removal.
|
||||
- Prevent ID collisions, runtime re-pairing, SSH remove/re-add, and late responses from crossing ownership boundaries.
|
||||
- Keep useful cached results visible when one authority is slow, offline, incompatible, or stale.
|
||||
- Route list, edit, delete, pause/resume, Run Now, run history, usage, navigation, and workspace actions through the captured owner.
|
||||
- Avoid startup fanout, remote-manager fanout, unbounded retries, and all-run history downloads.
|
||||
- Bring every dispatched run to a terminal state without a client attached, including unselected, page-closed, and headless runs.
|
||||
- Remain safe when desktop clients and runtime servers update independently.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- A global server-side automation index.
|
||||
- Persisting automation list results across app restarts.
|
||||
- Moving an automation between desktop and runtime authorities.
|
||||
- Showing external automation managers owned by remote runtimes in the first release.
|
||||
- Changing mobile routes, mobile RPCs, pairing, E2EE, or relay framing.
|
||||
- Changing shell, path, Git, or Git-provider behavior.
|
||||
|
||||
## Required invariants
|
||||
|
||||
1. Storage authority is not inferred from `runContext.hostId`, `schedulerOwner`, the active runtime, or the selected workspace. Those fields may mark a record ambiguous; they may never assign it to a different authority.
|
||||
2. An ID is unique only inside its authority. Automation, run, repo, workspace, setup, navigation, dialog, availability, and in-flight action keys are authority-qualified.
|
||||
3. A stable host selection survives label and connection-state changes, but data or actions captured from an old runtime/SSH incarnation do not.
|
||||
4. Missing catalog data is not evidence of removal. Removal requires a hydrated authoritative catalog or a tombstone.
|
||||
5. Local selection never contacts a runtime or probes desktop SSH external managers. One runtime selection never contacts another runtime.
|
||||
6. Target execution health and authority query health are separate. A disconnected SSH target does not prevent its owning, reachable authority from listing or editing stored automations.
|
||||
7. A parameterless `automation.list` keeps returning the authority's complete list for old clients.
|
||||
|
||||
## Ownership and identity
|
||||
|
||||
Use separate stable and incarnation-bearing forms:
|
||||
|
||||
```ts
|
||||
type AutomationAuthorityRef =
|
||||
| { kind: 'desktop' }
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
pairingRevision: number
|
||||
}
|
||||
|
||||
type AutomationHostSelector =
|
||||
| { kind: 'self' }
|
||||
| {
|
||||
kind: 'ssh'
|
||||
targetId: string
|
||||
targetGeneration: number
|
||||
}
|
||||
|
||||
type AutomationOwnerRef = {
|
||||
authority: AutomationAuthorityRef
|
||||
selector: AutomationHostSelector
|
||||
}
|
||||
|
||||
type StableAutomationHostRef = {
|
||||
authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string }
|
||||
selector: { kind: 'self' } | { kind: 'ssh'; targetId: string }
|
||||
}
|
||||
|
||||
type StableAutomationCatalogRef =
|
||||
| StableAutomationHostRef
|
||||
| {
|
||||
authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string }
|
||||
selector: { kind: 'orphan' }
|
||||
}
|
||||
```
|
||||
|
||||
`pairingRevision` is the saved runtime environment's existing pairing revision. `targetGeneration` is a durable SSH registration incarnation, not a connection attempt counter. Add an optional generation to stored SSH targets and `SshTargetSummary`; assign one while loading legacy targets and advance it only when a target is deleted/re-created or explicitly re-adopted. Connection, reconnect, and status transitions do not advance it.
|
||||
|
||||
Allocate generations from a persisted monotonic counter owned by each automation authority. On load, the authority reloads that counter as a high-water mark — `max(persisted counter, highest generation on any stored target or automation) + 1` — so a counter lost to a rollback can never reissue a generation an automation already captured. The migration first scans stored automations: a referenced missing SSH target creates a bounded removal tombstone with its last known label before generations are assigned. That makes legacy ghosts discoverable from the existing mirrored `removedTargetLabels`, including on a newly paired client with no list cache. Runtime-owned migrations run on the runtime authority, not on the desktop copy of its catalog.
|
||||
|
||||
New and migrated automations persist the selected SSH generation as an optional owner field. A legacy automation whose target still exists adopts the target's current generation during migration. A legacy automation whose target is absent becomes an orphan and is not silently assigned to Self or to a later same-ID target.
|
||||
|
||||
### Orphan and ambiguous records
|
||||
|
||||
An orphan has a known storage authority and no executable owner. An ambiguous record is a desktop-stored automation whose `schedulerOwner` or run context points at a runtime. Both are readable, and both follow the same rules:
|
||||
|
||||
- The owning authority refuses to dispatch them. Its scheduler and `automation.runNow` return the typed `target_removed` conflict and record a skipped-run reason. Disabling actions in the client is presentation, not enforcement.
|
||||
- Migration persists `enabled: false` for every record it classifies as orphaned or ambiguous, so a classified record cannot keep firing on a guessed host before its authority is upgraded.
|
||||
- Run Now, edit, and workspace navigation are disabled. Pause/resume and Delete stay enabled: both need only the storage authority, which is known.
|
||||
- Re-adoption is offered only when a compatible target is present under the same authority. When none is, Delete is the only remaining repair. Cross-authority moves stay unsupported.
|
||||
- Orphan reads are fenced like owned reads. List requests, `automation.show`, and cache entries carry `{ kind: 'orphan' }` as the selector, and commit compares authority, catalog, and request generations only. A record moving into or out of orphan publishes one source and one destination event, or a single unscoped authority event when the old selector cannot be recovered.
|
||||
|
||||
Two keys serve different purposes:
|
||||
|
||||
- `hostStableKey(StableAutomationCatalogRef)` excludes incarnation fields and is used by the persisted filter and display slot.
|
||||
- `ownerKey(AutomationOwnerRef)` includes `pairingRevision` and `targetGeneration` and is used by fetched rows, mutations, secondary reads, and request commit checks.
|
||||
|
||||
Use one canonical encoder with explicit kind prefixes and encoded components; do not use display labels, bare IDs, or ad hoc `JSON.stringify` order as keys.
|
||||
|
||||
```ts
|
||||
type AutomationListRow =
|
||||
| {
|
||||
kind: 'owned'
|
||||
key: string // ownerKey + automation.id
|
||||
owner: AutomationOwnerRef
|
||||
automation: Automation
|
||||
usageSummary: AutomationUsageSummary | null
|
||||
}
|
||||
| {
|
||||
kind: 'orphan'
|
||||
key: string // authority stable key + automation.id
|
||||
authority: AutomationAuthorityRef
|
||||
automation: Automation
|
||||
issue: string
|
||||
}
|
||||
```
|
||||
|
||||
Every owner-qualified navigation record includes the automation ID and, when applicable, the run ID, repo ID, workspace ID, and project-host-setup ID. Existing maps keyed only by repo/workspace ID cannot be used to resolve these rows because cross-host ID collisions are legal.
|
||||
|
||||
Before any mutation or secondary read, resolve the current catalog entry for the stable key and compare its full owner to the captured owner. A mismatch fails closed with `This automation's host changed. Reload it before continuing.` The backend repeats this validation for capable runtimes; client-side checking alone is not sufficient.
|
||||
|
||||
## Automation host catalog
|
||||
|
||||
Create a dedicated automation host catalog. Do not overload the flat `ExecutionHostId`, because Runtime + SSH needs both a parent authority and a nested target.
|
||||
|
||||
```ts
|
||||
type AutomationHostCatalogEntry = {
|
||||
stableRef: StableAutomationCatalogRef
|
||||
owner: AutomationOwnerRef | null
|
||||
stableKey: string
|
||||
label: string
|
||||
authorityLabel: string
|
||||
kind: 'self' | 'ssh' | 'orphan'
|
||||
catalogState: 'authoritative' | 'unhydrated' | 'removed'
|
||||
authorityHealth: AutomationAuthorityHealth
|
||||
executionHealth: AutomationExecutionHealth
|
||||
querySupport: 'scoped' | 'legacy-unscoped' | 'incompatible'
|
||||
}
|
||||
```
|
||||
|
||||
Catalog projection rules:
|
||||
|
||||
- Project Desktop + Self and desktop-owned saved SSH targets from the existing execution-host registry.
|
||||
- Project Runtime + Self from saved runtime environments, even while a runtime is offline.
|
||||
- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its entries key on target ID alone and are view-only.
|
||||
- Preserve labels from saved environments, `targetLabels`, and `removedTargetLabels` during outages.
|
||||
- Hide runtime-owned ephemeral SSH targets under the existing visibility rule.
|
||||
- Preserve a ghost/orphan entry when referenced by a stored automation, cached row, persisted filter, or removal/re-adoption tombstone.
|
||||
- Do not initiate runtime connections, target-list calls, or SSH connections merely to render the picker. Runtime SSH discovery consumes already mirrored state and becomes authoritative only when `targetsHydrated` is true.
|
||||
|
||||
Catalog generation is tracked per authority and advances whenever that authority's authoritative membership or incarnation changes. The commit fence compares only the generation of the authority that owns the entry, so one host's membership change — another runtime's target bucket hydrating, a target added anywhere — cannot discard every other host's in-flight response and exhaust the retry cap. Runtime connection status and SSH connection status update health without changing catalog generation.
|
||||
|
||||
Deterministic order is: All hosts, Desktop + Self, desktop SSH targets by locale-aware label then target ID, the desktop orphan entry, runtime authorities by label then environment ID, and each runtime's SSH targets immediately after its Self entry by label then target ID followed by that authority's orphan entry. Omit empty orphan entries. Construct one collator and precomputed sort fields per catalog rebuild.
|
||||
|
||||
## Persisted filter and hydration
|
||||
|
||||
Persist this optional UI value:
|
||||
|
||||
```ts
|
||||
type AutomationHostFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'host'; host: StableAutomationCatalogRef }
|
||||
```
|
||||
|
||||
Only the stable form is persisted. Restore it after the relevant catalogs settle:
|
||||
|
||||
- Desktop + Self is immediately authoritative.
|
||||
- Desktop SSH absence counts only after `sshTargetsHydrated`.
|
||||
- Runtime Self absence counts only after the saved runtime catalog settles.
|
||||
- Runtime SSH absence counts only after that runtime's target bucket hydrates or a removal tombstone provides positive evidence.
|
||||
|
||||
While relevant state is unhydrated, retain the selection and show `Loading host…`; do not fall back or write `All hosts` over the saved value. Once positive removal evidence exists, keep an orphan choice if automations/tombstones still reference it; otherwise switch to All hosts and announce the change.
|
||||
|
||||
A persisted orphan selection settles only after the owning authority has returned an authoritative `orphanCount` or an old-server unscoped list. If the authority is offline, retain the selection as unavailable rather than assuming the orphan was repaired.
|
||||
|
||||
SSH re-adoption migrates automation owner generations, repo/workspace references, removal tombstones, and this persisted filter in one persistence transaction. Same-ID runtime re-pairing retains the display selection, but evicts old authority data and requires a fresh query before rows or actions return.
|
||||
|
||||
## List contract and runtime compatibility
|
||||
|
||||
Add two string capability constants to `src/shared/protocol-version.ts`:
|
||||
|
||||
```ts
|
||||
export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY =
|
||||
'automation.list-host-scope.v1' as const
|
||||
|
||||
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY =
|
||||
'automation.owner-fencing.v1' as const
|
||||
```
|
||||
|
||||
The first capability guarantees scoped list parameters, runtime response validation fields, bounded usage summaries, and expected SSH-generation checking for list reads. The second covers mutation and secondary-read owner preconditions.
|
||||
|
||||
Both constants must also be appended to the exported `RUNTIME_CAPABILITIES` array in the same file. `getStatus()` advertises only entries drawn from that array, so a declared-but-unregistered capability leaves every capable runtime looking legacy and makes the New/New matrix row unreachable.
|
||||
|
||||
The exact `automation.list` request is:
|
||||
|
||||
```ts
|
||||
type AutomationListParams =
|
||||
| undefined
|
||||
| null
|
||||
| {
|
||||
selector?:
|
||||
| { kind: 'self' }
|
||||
| { kind: 'ssh'; targetId: string; expectedTargetGeneration: number }
|
||||
| { kind: 'orphan' }
|
||||
}
|
||||
```
|
||||
|
||||
Omitted params, `null`, `{}`, or an omitted `selector` return the authority's complete automation list. This preserves old-client behavior. A supplied selector filters in the authority backend before serialization. Self means records explicitly stored with a self/local execution target; it does not mean “anything not recognized as SSH.”
|
||||
|
||||
The response keeps the existing `automations: Automation[]` field so an old client can consume a new server. A capable server adds projection metadata; for a scoped request, `items` is required and has a validated one-to-one match with `automations`:
|
||||
|
||||
```ts
|
||||
type AutomationListResult = {
|
||||
automations: Automation[]
|
||||
items?: Array<{
|
||||
automationId: string
|
||||
selector:
|
||||
| { kind: 'self' }
|
||||
| { kind: 'ssh'; targetId: string; targetGeneration: number }
|
||||
| { kind: 'orphan'; issue: string }
|
||||
usageSummary?: AutomationUsageSummary | null
|
||||
}>
|
||||
orphanCount?: number
|
||||
}
|
||||
```
|
||||
|
||||
The client runtime-validates rather than casts. It rejects malformed top-level responses, drops and records malformed individual rows, requires exactly one metadata item per scoped automation ID, verifies every returned selector matches the request, and never reclassifies a mismatched row into Self. A malformed or missing metadata item drops its paired automation from the committed rows and increments the invalid-row counter; the one-to-one requirement is evaluated over the surviving pairs, not over the raw `automations` array, so one bad row neither hides a whole host nor produces an unqualified row. Old clients ignore `items` and `orphanCount`; new clients talking to old servers use the legacy partition below. Any scoped response may report `orphanCount`; a positive count adds the authority's orphan catalog entry and queues one orphan-scope request. An orphan row has known storage authority but no executable owner, so it is readable and all actions are disabled.
|
||||
|
||||
Routing is by authority:
|
||||
|
||||
- Desktop uses local IPC with the same selector semantics and owner preconditions.
|
||||
- A capable runtime gets one scoped request per requested host.
|
||||
- An older runtime gets at most one parameterless `automation.list` request per authority per refresh cycle. Partition that result into all requested Self/SSH cache entries; never make the same full-list request once per selector.
|
||||
|
||||
Legacy partitioning is explicit:
|
||||
|
||||
- Self requires positive evidence: `executionTargetType` is `local` **and** the owning repo resolves with no connection ID. The desktop store writes the literal `local` whenever that repo lookup misses, and for repos hosted on a runtime, so a bare `local` value is not evidence on its own.
|
||||
- A valid SSH record with a non-empty target ID goes to that target's entry, creating a ghost entry if necessary. Legacy SSH rows carry no generation: key them by target ID alone and keep them view-only until the authority advertises `automation.owner-fencing.v1`.
|
||||
- Unknown execution types, missing SSH IDs, contradictory target fields, records whose owning repo no longer resolves, records whose `schedulerOwner` or `runContext.hostId` points at a runtime, and other malformed legacy records go to an `Unassigned legacy automations` orphan entry.
|
||||
- `runContext.hostId` and `schedulerOwner` mark a record ambiguous. They never override storage ownership, never assign a record to a different authority, and never repair a malformed selector.
|
||||
- The authority's create and update paths stop re-deriving `local` from a missed repo lookup. They preserve the stored selector and fail closed instead, so a deleted project cannot silently convert an SSH automation into a local one.
|
||||
|
||||
The legacy response has no bounded usage projection. Do not compensate by downloading all run histories. Render neutral `Usage unavailable` copy until the selected automation's lazy history is loaded.
|
||||
|
||||
If a runtime lacks owner fencing, all of its rows stay readable and none are mutable. Runtime + Self and Runtime + SSH are both view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. Pairing-revision freshness authenticates the connection, not the record's selector, so it is not a substitute — a record whose target changed server-side since the last refresh would still run on a host the user never saw. This mirrors the fail-closed capability assertion the codebase already applies to remote file mutations.
|
||||
|
||||
### Owner-fenced operation contract
|
||||
|
||||
For capable runtimes, add this optional precondition to `automation.show`, `automation.runs`, `automation.update`, `automation.delete`, and `automation.runNow`; add `destination` to create and to updates that may move selectors:
|
||||
|
||||
```ts
|
||||
type AutomationOwnerPrecondition = {
|
||||
selector:
|
||||
| { kind: 'self' }
|
||||
| { kind: 'ssh'; targetId: string; targetGeneration: number }
|
||||
}
|
||||
|
||||
type AutomationOwnedIdParams = {
|
||||
id: string
|
||||
expectedOwner?: AutomationOwnerPrecondition
|
||||
}
|
||||
|
||||
type AutomationCreateDestination = {
|
||||
destination?: AutomationOwnerPrecondition
|
||||
}
|
||||
```
|
||||
|
||||
Concretely, show/delete/runNow use `{ id, expectedOwner? }`; runs uses `{ automationId?, expectedOwner? }`; update uses `{ id, updates, expectedOwner?, destination? }`; and create appends `destination?` to its existing fields. Pause and resume are `automation.update` payloads and carry the same `expectedOwner` precondition; if they ever land as dedicated RPCs they take it too, so scheduler state cannot be changed for the wrong incarnation while every neighbouring action is fenced. A capable new client always supplies the applicable fields. Parameterless old-client shapes remain valid.
|
||||
|
||||
The authority is implicit in the local IPC endpoint or runtime RPC connection; it is never supplied as a caller-chosen environment ID inside the payload. Runtime calls also pass the captured `pairingRevision` to the existing renderer/main runtime-environment revision guard before transport dispatch and again before accepting the response. The runtime backend compares the expected selector with the stored automation's selector and current SSH registration generation in the same synchronous persistence operation that reads or mutates the record. Create validates the destination immediately before insert. Update validates both the stored source and requested destination before replacement. Validation resolves `destination.selector` against the authority's current saved target registry inside that same operation: Self is allowed, SSH requires a present target ID whose current registration generation matches, and orphan or unknown selectors are rejected with a structured error and no write. A destination is never accepted as a free-form value, so an automation cannot be attached to a ghost or future same-ID target and become runnable on the wrong host once a later registration satisfies the fence.
|
||||
|
||||
The preconditions are optional on the wire so old clients can call new servers, but optional is not unenforced. A capable server rejects any mutation or execution request that omits `expectedOwner` when the stored automation carries an SSH selector with a generation, returning a typed upgrade-required conflict with no side effects. The precondition stays genuinely optional only for Self records and for legacy SSH rows that still have no generation, so unfenced mutation never becomes permanent server behavior for a caller that skips the field. A new client sends the fields whenever `automation.owner-fencing.v1` is advertised. A mismatch returns a typed conflict and performs no mutation or execution. Validation, not Zod stripping of unknown fields, defines this behavior.
|
||||
|
||||
## Cache and request lifecycle
|
||||
|
||||
Keep an in-memory entry per catalog stable key:
|
||||
|
||||
```ts
|
||||
type CacheEntry = {
|
||||
data: AutomationListRow[]
|
||||
fetchedAt: number | null
|
||||
attempt: number
|
||||
requestGeneration: number
|
||||
catalogGeneration: number
|
||||
request: Promise<void> | null
|
||||
error: AutomationHostQueryError | null
|
||||
}
|
||||
```
|
||||
|
||||
Errors use a typed code rather than copy matching:
|
||||
|
||||
```ts
|
||||
type AutomationHostQueryError = {
|
||||
code:
|
||||
| 'authority_unavailable'
|
||||
| 'timeout'
|
||||
| 'permission_denied'
|
||||
| 'incompatible'
|
||||
| 'invalid_response'
|
||||
| 'owner_changed'
|
||||
| 'target_removed'
|
||||
| 'unknown'
|
||||
message: string
|
||||
retryable: boolean
|
||||
retryAt: number | null
|
||||
}
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
- TTL is 30 seconds. Fresh data is returned without a request; stale data remains visible during revalidation.
|
||||
- A request captures stable key, full owner, request generation, the owning authority's catalog generation, and the authority connection generation. The authority connection generation is the saved runtime environment's current `pairingRevision` — the same value the existing renderer/main revision guard compares — and a fixed constant for the desktop authority. It is not a third counter.
|
||||
- Refresh, mutation invalidation, authority re-pair, SSH re-adoption/removal, and entry eviction advance `requestGeneration` before starting replacement work.
|
||||
- A response commits only when every captured generation still matches and no removal tombstone supersedes it. Otherwise it is discarded.
|
||||
- Concurrent callers for the same owner share `request`. One authority-level legacy fallback request may fulfill several entries.
|
||||
- A failed refresh retains successful data and records the error separately. Success clears `attempt` and `error`.
|
||||
- Automatic transient retry uses full jitter with a 1-second base and 30-second cap, at most three attempts while the page remains visible. Permanent validation, permission, and incompatibility failures do not retry. Manual Retry bypasses cooldown and starts one new attempt.
|
||||
- Authority calls use a four-request global pool. The selected host and Desktop + Self have priority. Obsolete queued work is cancelled when the filter/catalog changes; already-sent transport work may finish but must pass the commit fence.
|
||||
- An All-hosts refresh never initiates an authority connection. It queries only authorities with an established connection; a saved-but-disconnected authority renders a compact status row with a Reconnect action and is fetched only after the user reconnects or selects that host directly. Reconnect triggers one prioritized refresh for stale entries owned by that authority. Nested SSH disconnection does not block a list query when its authority is reachable.
|
||||
- Manual All-host refresh bypasses TTL for reachable authorities only. Unreachable authorities retain stale data and expose Reconnect; the refresh does not enqueue requests already known to fail.
|
||||
- Focus/visibility refreshes only stale entries and is coalesced. No fixed polling interval is added.
|
||||
- Removed non-visible entries are evicted after request invalidation. Retired cache entries are LRU-capped at 256; visible catalog entries and active requests are not retained in that retired pool.
|
||||
|
||||
Mutation invalidation happens before the request is sent, so an older in-flight list cannot overwrite the mutation result. Create/update/delete/pause/resume/Run Now invalidate only affected entries. An update may move an automation between Self and SSH selectors inside one authority: remove it optimistically from the source, invalidate both source and destination, and reconcile both responses. Cross-authority movement is rejected.
|
||||
|
||||
## Editing, creation, and actions
|
||||
|
||||
Edit hydration, conflict checks, save, delete, pause/resume, Run Now, and lazy history all receive the selected row's `AutomationOwnerRef`. They must not call the desktop store or active runtime as a fallback.
|
||||
|
||||
The editor resolves project, repo, workspace, folder-workspace, and project-host-setup options within the captured authority. Those option identities are authority-qualified before entering maps. Selecting another project may move between Self and SSH selectors owned by the same authority; the save request includes both expected source owner and destination owner. It cannot move between desktop and runtime authorities.
|
||||
|
||||
Creation rules:
|
||||
|
||||
- A single concrete host filter preselects and constrains the destination. Orphan entries cannot create.
|
||||
- Under All hosts, default from the active workspace's qualified owner only when it resolves to a catalog entry with a non-null executable owner, and show that owner explicitly before submit. When that owner is missing, unhydrated, orphaned, or not uniquely resolvable, require an explicit host choice before submit; never fall back to the active runtime or to a bare workspace or repo ID.
|
||||
- The create request captures and validates the destination incarnation immediately before submit.
|
||||
- If a concurrent catalog change makes the destination stale, fail closed and preserve the form.
|
||||
- After success, select the created row. If it is unexpectedly outside the current filter, intentionally switch the filter to its destination and announce that change; never let a successful creation silently disappear.
|
||||
|
||||
Dialogs keep their captured owner for their entire lifetime. An authority/target incarnation change disables submit and presents Reload; it never silently retargets an open form.
|
||||
|
||||
## Run history, usage, and completion
|
||||
|
||||
Run history stays lazy and authority-qualified: fetch only the selected automation's runs, page/cap them under the existing retention rules, and key selection/navigation by owner + automation ID + run ID.
|
||||
|
||||
The list must not fetch all runs to build usage. Capable list projections include the existing aggregate fields (`knownRuns`, `unavailableRuns`, token totals, and estimated cost) computed by the authority while it already owns bounded retained runs. Legacy rows show neutral unavailable usage until their selected history is present.
|
||||
|
||||
Move dispatched-run completion and usage reconciliation out of `AutomationsPage` rendering into the authority-owned automation service/dispatcher. The desktop service and each runtime service observe their own dispatched sessions, persist terminal status exactly once, and publish change events whether or not any client has the Automations page open. On authority startup, reconcile retained non-terminal runs against owned execution/session state. This prevents unselected or headless runs remaining stuck in `dispatched` and avoids client fanout.
|
||||
|
||||
## External automation managers
|
||||
|
||||
The first release includes external managers only for Desktop + Self and desktop-owned SSH hosts. Runtime + Self and Runtime + SSH show Orca automations only. This is an explicit scope boundary, not an unknown-source fallback. On a runtime-owned host, both the empty and the populated state say so: the absence of manager rows there is a stated scope boundary and is never presented as "none configured".
|
||||
|
||||
Replace the broad desktop `listExternalAutomationManagers()` page call with scoped IPC operations that accept the captured desktop `AutomationOwnerRef` and provider. Every scoped call — list, runs, create, update, and action — re-applies the two checks the broad call enforced, before contacting a relay or launching a provider command: the runtime-owned-target exclusion that keeps hidden targets out of user-facing surfaces, and the same saved-owner comparison used for Orca mutations, including `targetGeneration` against the current SSH registration. A mismatch fails closed with the host-changed conflict and performs no probe. Selecting Local probes only local managers. Selecting one desktop SSH host probes only that target. All hosts schedules `{host, provider}` calls through the same bounded pool at lower priority than Orca automation list and mutation traffic for the selected host and Desktop + Self, cancels them when the filter leaves desktop scope, and does not eagerly probe every SSH target from a Local view. Manager work counts toward the release profiling in-flight assertion.
|
||||
|
||||
Manager, job, run, dialog, and action keys include owner + provider + provider ID. Cache and error state are per `{owner, provider}`, separate from Orca automation storage health. An external-manager failure cannot mark the host's Orca store unavailable.
|
||||
|
||||
Adding runtime-owned external managers later requires dedicated runtime list/runs/create/update/action RPCs, authority-qualified target types, capabilities, validation, and old-server UX. It must not be implemented by tunneling a desktop-only target or treating unknown authority as Local.
|
||||
|
||||
## Security and trust boundaries
|
||||
|
||||
Runtime authentication and authorization remain those of the existing paired RPC connection. The server derives authority from that connection, validates selectors against its saved target registry, and treats target IDs only as identifiers; no selector is interpolated into a path or shell command. Owner conflicts, permission errors, and invalid schemas return structured errors without record or prompt contents. Response validation and telemetry never log prompts, precheck commands, run output, credentials, or external-manager payloads.
|
||||
|
||||
The renderer cannot bypass fencing by supplying a different authority or generation. Local IPC performs the same saved-owner comparison, and runtime RPC handlers compare against server-owned state. External-manager scoped IPC verifies the requested desktop target and provider allowlist before contacting a relay or launching a provider command.
|
||||
|
||||
## Change events and invalidation
|
||||
|
||||
Add an `automationsChanged` event to the existing local event channel and runtime client event stream:
|
||||
|
||||
```ts
|
||||
type AutomationsChangedEvent = {
|
||||
type: 'automationsChanged'
|
||||
selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' }
|
||||
reason?: 'definition' | 'run' | 'usage'
|
||||
}
|
||||
```
|
||||
|
||||
CRUD, scheduler transitions, run creation/status changes, and usage updates publish after persistence succeeds. Runtime authority is derived from the subscription environment and its current pairing revision; it is never accepted from the event body.
|
||||
|
||||
A scoped event invalidates its one stable entry. An older or unscoped event invalidates all entries for that one authority only. Coalesce event bursts in one microtask and share any legacy authority request. Reconnect, focus, and TTL revalidation remain fallback paths for older servers that publish no event.
|
||||
|
||||
An update that moves selectors publishes one event for the source and one for the destination; if the old selector cannot be recovered, publish one unscoped authority event. Subscribers treat duplicate events as harmless invalidations.
|
||||
|
||||
The event is additive. Old clients must continue to ignore the unknown event type. Do not introduce a new stream opcode; if transport framing ever requires one, capability-negotiate it because old decoders may silently drop unknown opcodes.
|
||||
|
||||
## User experience
|
||||
|
||||
The host picker and search field remain visible in loading, empty, no-match, and failure states. Use the existing Select primitive for eight or fewer entries and the existing searchable Command/Popover for nine or more. Both expose the label `Filter by host`; the searchable variant focuses search on open, Enter selects, and Esc closes without changing selection.
|
||||
|
||||
Every row shows a host badge rendered with the existing `RepoBadgeLabel` component already used for host and repo labels elsewhere in the automations UI. The pill `Badge` component has no `muted` variant, and this design does not add one. Badge truncation retains the full accessible name and tooltip. Search runs after host selection and matches name, project, workspace, agent, host label, and at most the first 2,048 prompt characters. Build the normalized search index once per changed row set, not during each render or comparator call.
|
||||
|
||||
Keep these states distinct:
|
||||
|
||||
- Authority query: loading, fresh, refreshing, stale-error, unavailable, incompatible.
|
||||
- Execution target: connected, connecting, disconnected, unavailable, unknown.
|
||||
|
||||
Under All hosts, rows are grouped by host in the same deterministic catalog order the picker uses, with each host's compact host-level status row anchored at the top of its group, so incomplete authorities do not reflow the list as responses arrive. Healthy and stale rows render inside their group as they arrive. A stale row remains readable. Each persistent failure supplies the relevant Retry, Reconnect, or Update server action; do not rely on a toast for recoverable errors. Run Now is disabled when execution health is insufficient, with a concrete reason, while storage-only actions follow authority and fencing availability.
|
||||
|
||||
Use a polite, deduplicated `aria-live` summary for partial failures, worded `<N> of <M> hosts could not be loaded`. Re-announce only when the failed-host count changes, not on every retry attempt. Do not move focus when status rows appear. If filtering or refresh removes the focused row, move focus to the next row, then previous row, then the picker; changing selection must not open a detail or fetch history until the replacement row is rendered.
|
||||
|
||||
Empty copy distinguishes `No automations on <host>`, `No automations across loaded hosts`, `No automations match your search`, and `Automations could not be loaded from <host>`. Copy never claims a disconnected or unhydrated host is empty. On a runtime-owned host it also states that external automation managers are not listed for that host in this release, so a scope-limited host is never presented as clean.
|
||||
|
||||
## Performance and resource budgets
|
||||
|
||||
- No automation or external-manager network work is added to app startup or picker render.
|
||||
- Selected-host first usable rows require at most one authority request; old-server All hosts requires at most one unscoped list request per authority.
|
||||
- Concurrency is four remote requests, retries are capped, event bursts are coalesced, and timers/listeners are disposed when the page/controller closes.
|
||||
- Prompt indexing is bounded per row. Retired cache history is capped. Tombstone history is capped per authority, but a tombstone still referenced by a stored automation, cached row, or persisted filter is retained past the cap: a tombstone becomes evictable only after its owning authority has returned an authoritative catalog with no remaining reference to that target. Positive removal evidence is never discarded while something still depends on it. Run lists continue to use existing retention/pagination caps.
|
||||
- Cache instrumentation records request counts by authority/stable key, in-flight dedupe hits, discarded stale responses, result row counts/bytes, and refresh duration. It does not log prompts or run output.
|
||||
- Release profiling covers 1,000 automations across 50 hosts, one offline authority, one old runtime, rapid filter changes, and an event burst. Acceptance: no more than four remote calls in flight, one legacy call per authority per cycle, no stale commit, and no synchronous long task over 50 ms attributable to filter/search on release hardware.
|
||||
|
||||
## Migration and mixed-version behavior
|
||||
|
||||
Persisted additions are optional so rollback builds ignore them:
|
||||
|
||||
- SSH target registration generation.
|
||||
- Automation SSH owner generation.
|
||||
- Persisted stable automation host filter.
|
||||
|
||||
Migration is idempotent and preserves unknown fields. It assigns generations only with positive current-target evidence, preserves missing references as ghosts, and never rewrites an orphan to Self.
|
||||
|
||||
Supported rollback fixtures must prove the previous desktop/runtime builds preserve the optional generation fields on read/write. If a rollback-era state has lost those fields and a same-ID SSH replacement cannot be distinguished from the prior target, re-upgrade classifies the automation as ambiguous and requires explicit re-adoption; it does not guess and run on the replacement host.
|
||||
|
||||
Existing desktop-stored records whose `schedulerOwner` or run context points at a runtime need explicit classification. Physical storage remains the authority unless a separate verified migration proves the runtime holds the canonical record. The first release does not delete or automatically transfer such records. Ambiguous records appear in the legacy/orphan entry with actions disabled and recovery guidance, preventing duplicate scheduling or destructive guessing.
|
||||
|
||||
Desktop/runtime version matrix:
|
||||
|
||||
|
||||
| Desktop | Runtime | Behavior |
|
||||
| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. |
|
||||
| New | Old | One unscoped list per authority, defensive partition, neutral usage, all runtime rows view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the row surfaces Update server rather than hanging with no explanation. |
|
||||
| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. |
|
||||
| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. |
|
||||
|
||||
|
||||
No mobile-facing route, schema, RPC, handshake, protocol minimum, recommended app version, pairing file, E2EE, or relay surface changes. Automation RPC/event additions remain runtime-scoped, additive, and optional. No platform-specific path, shell, keyboard, native-module, filesystem, or Git behavior is introduced; the design applies equally on macOS, Windows, Linux, WSL, SSH, relay, git worktrees, and folder workspaces.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
1. Add canonical stable/owner refs, SSH registration generation persistence/migration, qualified row/run/navigation keys, and owner comparison helpers.
|
||||
2. Build the dedicated catalog and persisted-filter hydration/re-adoption behavior.
|
||||
3. Add local scoped IPC, runtime list capability/schema/validation, legacy authority partitioning, and owner-fenced mutation/secondary-read contracts.
|
||||
4. Add the generation-fenced cache, bounded scheduler, invalidation events, retry policy, and instrumentation.
|
||||
5. Move run completion/usage reconciliation into authority-owned services and add list usage projections. This step fixes a defect every user hits today — runs stuck in `dispatched` whenever the page is closed — and depends on nothing in steps 1-4, so it can land on its own schedule rather than waiting on the multi-host work.
|
||||
6. Convert edit/create/actions/dialogs/project/workspace resolution to captured owners.
|
||||
7. Add picker, badges, search index, partial states, recovery actions, focus, and announcements using the style guide and existing primitives/tokens.
|
||||
8. Replace broad external-manager discovery with desktop-only scoped provider calls.
|
||||
9. Remove the active-runtime inference and broad all-run fetch paths only after the new tests pass.
|
||||
|
||||
Each step lands with its compatibility tests; do not ship a UI that merges authorities before mutation and navigation identity are converted.
|
||||
|
||||
## Test plan
|
||||
|
||||
### Identity, catalog, and persistence
|
||||
|
||||
- Equal automation/run/repo/workspace IDs under two authorities remain distinct through selection, edit, navigation, and React keys.
|
||||
- Equal SSH target IDs under Desktop and a runtime do not collide.
|
||||
- Same-ID runtime re-pair and SSH remove/re-add discard stale rows, requests, dialogs, and actions.
|
||||
- Unhydrated absence retains the persisted selection; authoritative removal falls back or preserves a ghost as specified.
|
||||
- Rename preserves stable selection and cache slot; re-adoption migrates automations and filter atomically.
|
||||
- Ephemeral runtime SSH targets stay hidden; parent outage preserves nested labels without claiming removal.
|
||||
|
||||
### Query, cache, and performance
|
||||
|
||||
- Local selection makes zero runtime and SSH-manager calls.
|
||||
- Runtime selection contacts only that runtime; nested SSH disconnection does not block authority listing.
|
||||
- Capable All hosts makes scoped calls with at most four remote requests in flight.
|
||||
- Old-server All hosts makes exactly one unscoped request per authority and partitions Self, SSH, and orphan records correctly.
|
||||
- Fresh/stale/error behavior, in-flight dedupe, capped retry, reconnect refresh, queue cancellation, and LRU eviction are deterministic with fake time.
|
||||
- Late responses after refresh, mutation, removal, re-adoption, re-pair, and catalog replacement cannot commit.
|
||||
- Search indexing is bounded and the 1,000-row/50-host profiling fixture meets the stated budgets.
|
||||
|
||||
### Actions, create, and edit
|
||||
|
||||
- Show, edit hydration/conflict/save, delete, pause/resume, Run Now, runs, usage, and open-workspace route through captured owners.
|
||||
- Incarnation changes while a dialog is open fail closed and preserve input.
|
||||
- In-authority selector movement invalidates/removes source and destination correctly; cross-authority movement is rejected.
|
||||
- Single-host create is constrained; All hosts defaults visibly from the active qualified workspace; post-create selection never disappears silently.
|
||||
- Folder workspaces and git worktrees both resolve inside the captured authority.
|
||||
- An enabled automation whose SSH target was deleted produces no run on any host: its authority refuses the dispatch, records a skipped-run reason, and the migration left the record disabled.
|
||||
- An automation whose owning repo was deleted, and a desktop-stored record scheduled against a runtime, both land in the orphan entry rather than under Desktop + Self.
|
||||
- Delete and pause remain available on orphan and ambiguous rows; Run Now, edit, and workspace navigation do not.
|
||||
|
||||
### Runs and events
|
||||
|
||||
- List rendering fetches no broad run history.
|
||||
- Selected history is lazy and authority-qualified; legacy usage copy is neutral.
|
||||
- Unselected, page-closed, remote, restarted, and headless dispatched runs reach a terminal state through authority-owned reconciliation on capable authorities. Against an older runtime the run stays `dispatched` and surfaces Update server instead of hanging with no explanation.
|
||||
- CRUD/scheduler/run/usage events invalidate one selector; old unscoped events invalidate one authority; bursts coalesce.
|
||||
- Old clients ignore the new event and still use parameterless list behavior.
|
||||
|
||||
### Failures, accessibility, and compatibility
|
||||
|
||||
- One timeout, malformed response, permission error, offline authority, or incompatible runtime does not hide other hosts.
|
||||
- Stale data remains readable and persistent errors expose working Retry/Reconnect/Update actions.
|
||||
- Picker/search remain present in every empty/error state; full badge labels, keyboard behavior, focus recovery, and deduplicated `aria-live` output pass accessibility tests.
|
||||
- New/old desktop-runtime matrix tests cover omitted/null/empty/scoped list params and unknown optional fields.
|
||||
- Persisted migration/rollback fixtures cover missing generations, orphan targets, same-ID replacement, and ambiguous desktop-stored remote-scheduled records.
|
||||
- No mobile legacy fixture, protocol minimum, or mobile package changes; Windows/Linux/macOS static review finds no new platform-specific behavior.
|
||||
|
||||
## Release gates
|
||||
|
||||
- All unit, integration, and RPC compatibility tests above pass.
|
||||
- Real desktop + current runtime, desktop + previous runtime, SSH, runtime-owned SSH, runtime re-pair, SSH re-adoption, folder workspace, and offline/reconnect smoke tests pass.
|
||||
- The request-count and stale-response assertions pass under rapid interaction and network failure.
|
||||
- There is no unresolved path that infers authority from a bare ID, active runtime, `runContext.hostId`, or `schedulerOwner`.
|
||||
- External managers remain desktop-scoped unless their full runtime RPC design lands separately.
|
||||
|
||||
## Existing code that must change
|
||||
|
||||
- `src/main/runtime/rpc/methods/automations.ts`: `automation.list` is currently parameterless.
|
||||
- `src/renderer/src/components/automations/automation-host-client.ts`: ownership currently collapses to local vs environment and can fall back from `runContext.hostId`.
|
||||
- `src/renderer/src/components/automations/AutomationsPage.tsx`: refresh currently fetches broad runs, completion reconciliation is page-owned, and edit/refresh paths use the wrong ambient target.
|
||||
- `src/main/persistence.ts`: update can change execution target and SSH re-adoption does not migrate automations/filter state. Create and update also derive `executionTargetType` from the owning repo, writing the literal `local` whenever that lookup misses.
|
||||
- `src/main/ipc/automations.ts`: `automations:list` takes no selector, and `automations:markDispatchResult` is driven by the renderer.
|
||||
- `src/main/automations/service.ts`: only the headless dispatch path reconciles completion; renderer-dispatched runs depend on the page being open.
|
||||
- `src/renderer/src/store/worktree-repo-index.ts`: bare-ID maps cannot resolve cross-authority collisions.
|
||||
- `src/renderer/src/store/slices/runtime-environment-ssh.ts`: runtime SSH state is correctly separate and supplies the basis for nested catalog projection, but connection generation is not the durable target registration generation required here.
|
||||
- `src/main/automations/external-manager.ts`: manager listing currently probes all desktop SSH targets.
|
||||
- `src/shared/automations-types.ts`: external targets and existing automation owner data lack authority/incarnation qualification.
|
||||
- `src/shared/runtime-client-events.ts`: no automation invalidation event exists.
|
||||
- `src/shared/protocol-version.ts`: runtime capabilities are string constants and need the new additive entries.
|
||||
@@ -1,442 +0,0 @@
|
||||
# Mobile Relay UX — Investigation Findings & Fix Plan
|
||||
|
||||
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
|
||||
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
|
||||
desktop or relay-server code. All file references are in `mobile/` of this worktree.
|
||||
|
||||
## 1. Symptom → root-cause summary
|
||||
|
||||
| # | Symptom | Root cause (verified) |
|
||||
|---|---------|----------------------|
|
||||
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
|
||||
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
|
||||
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
|
||||
|
||||
## 2. Verified end-to-end causal chains
|
||||
|
||||
### S1 — Resume dead-ends on the host index page
|
||||
|
||||
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
|
||||
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
|
||||
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
|
||||
under the thumb.
|
||||
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
|
||||
`/h/[hostId]/session/[worktreeId]`.
|
||||
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
|
||||
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
|
||||
documented twice in-repo ("cold Expo deep links resolve to index" —
|
||||
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
|
||||
root cause named by PR #12001.
|
||||
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
|
||||
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
|
||||
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
|
||||
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
|
||||
every fetch no-ops on `!client || connState !== 'connected'`
|
||||
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
|
||||
static toolbar UI, so the page looks "real" but dead.
|
||||
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
|
||||
navigation history.
|
||||
|
||||
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
|
||||
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
|
||||
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
|
||||
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
|
||||
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
|
||||
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
|
||||
|
||||
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
|
||||
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
|
||||
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
|
||||
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
|
||||
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
|
||||
|
||||
### S2 — grey blink when focusing a healthy relay host
|
||||
|
||||
1. Every focus of the host screen fires `notifyForeground()`
|
||||
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
|
||||
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
|
||||
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
|
||||
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
|
||||
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
|
||||
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
|
||||
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
|
||||
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
|
||||
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
|
||||
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
|
||||
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
|
||||
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
|
||||
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
|
||||
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
|
||||
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
|
||||
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
|
||||
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
|
||||
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
|
||||
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
|
||||
|
||||
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
|
||||
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
|
||||
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
|
||||
|
||||
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
|
||||
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
|
||||
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
|
||||
connected direct client runs an activity probe that detects a half-open socket in ≤8s
|
||||
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
|
||||
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
|
||||
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
|
||||
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
|
||||
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
|
||||
|
||||
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
|
||||
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
|
||||
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
|
||||
during the async client acquire (Keychain read) that home never shows.
|
||||
|
||||
### S3 — silent 5–10s relay-forced pairing
|
||||
|
||||
Pairing phase:
|
||||
|
||||
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
|
||||
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
|
||||
2. The coordinator threads it **only to the direct candidate**
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
|
||||
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
|
||||
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
|
||||
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
|
||||
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
|
||||
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
|
||||
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
|
||||
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
|
||||
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
|
||||
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
|
||||
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
|
||||
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
|
||||
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
|
||||
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
|
||||
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
|
||||
5. Pairing logs also never reach `connectionLogStore` (single producer:
|
||||
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
|
||||
pairing that just failed.
|
||||
|
||||
Post-pairing phase:
|
||||
|
||||
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
|
||||
(`app/pair-confirm.tsx:118-123`).
|
||||
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
|
||||
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
|
||||
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
|
||||
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
|
||||
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
|
||||
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
|
||||
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
|
||||
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
|
||||
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
|
||||
~29–58s under the old session's labels.
|
||||
10. The "Orca Relay" path label only renders once `state === 'connected'`
|
||||
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
|
||||
after the wait ends, and `classifyConnection` has no relay-aware branch
|
||||
(`src/transport/connection-health.ts:45-100`).
|
||||
|
||||
## 3. Anti-pattern sweep
|
||||
|
||||
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
|
||||
|
||||
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
|
||||
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
|
||||
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
|
||||
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
|
||||
|
||||
Bare pushes remaining (host stack plausibly cold at each):
|
||||
|
||||
| Call site | Target | Cold scenario |
|
||||
|---|---|---|
|
||||
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
|
||||
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
|
||||
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
|
||||
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
|
||||
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
|
||||
|
||||
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
|
||||
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
|
||||
|
||||
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
|
||||
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
|
||||
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
|
||||
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
|
||||
can be destroyed by the gate cycle.
|
||||
|
||||
### (b) Surfaces that render grey 'disconnected' during expected transients
|
||||
|
||||
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
|
||||
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
|
||||
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
|
||||
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
|
||||
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
|
||||
locally what every other surface gets wrong.
|
||||
|
||||
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
|
||||
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
|
||||
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
|
||||
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
|
||||
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
|
||||
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
|
||||
amber**.
|
||||
|
||||
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
|
||||
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
|
||||
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
|
||||
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
|
||||
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
|
||||
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
|
||||
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
|
||||
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
|
||||
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
|
||||
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
|
||||
Action gates all read `=== 'connected'`; voice settings goes fully inert
|
||||
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
|
||||
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
|
||||
|
||||
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
|
||||
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
|
||||
correct per billing, but state stays grey through the entire foreground re-dial rather than
|
||||
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
|
||||
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
|
||||
(`:107,114,135`).
|
||||
|
||||
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
|
||||
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
|
||||
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
|
||||
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
|
||||
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
|
||||
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
|
||||
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
|
||||
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
|
||||
entry means an empty remount.
|
||||
|
||||
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
|
||||
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
|
||||
and `tasks.tsx:10970`).
|
||||
|
||||
### (c) Invisible relay establishment phases
|
||||
|
||||
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
|
||||
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
|
||||
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
|
||||
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
|
||||
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
|
||||
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
|
||||
`mobile-relay-direct-upgrade-controller.ts:19`).
|
||||
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
|
||||
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
|
||||
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
|
||||
- Regression suite for connect-label stalls exists for the direct path only
|
||||
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
|
||||
|
||||
## 4. Fix plan
|
||||
|
||||
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
|
||||
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
|
||||
|
||||
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
|
||||
|
||||
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
|
||||
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
|
||||
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
|
||||
branch. The PR routes Resume through the shared mount-then-replace mechanism
|
||||
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
|
||||
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
|
||||
extracted module.
|
||||
Backward compat: navigation-only, none.
|
||||
Residuals tracked as F4/F7/F8/F9.
|
||||
|
||||
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
|
||||
|
||||
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
|
||||
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
|
||||
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
|
||||
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
|
||||
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
|
||||
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
|
||||
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
|
||||
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
|
||||
still `'connected'`, so the fallback suspend is required for convergence).
|
||||
|
||||
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
|
||||
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
|
||||
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
|
||||
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
|
||||
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
|
||||
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
|
||||
accepts this). Half-open regression risk is covered by the fallback suspend.
|
||||
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
|
||||
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
|
||||
background-suspend test unchanged.
|
||||
|
||||
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
|
||||
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
|
||||
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
|
||||
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
|
||||
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
|
||||
currently drives the UI grey before amber.
|
||||
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
|
||||
cold-start gap (`client-context.tsx:87-153`) is amber too.
|
||||
|
||||
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
|
||||
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
|
||||
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
|
||||
extending that predicate would silently disable the stale-list fallback and blank the list —
|
||||
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
|
||||
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
|
||||
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
|
||||
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
|
||||
of `null`).
|
||||
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
|
||||
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
|
||||
key on `'disconnected'` specifically.
|
||||
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
|
||||
host screen test for the stale-list fallback under `'connecting'`.
|
||||
|
||||
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
|
||||
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
|
||||
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
|
||||
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
|
||||
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
|
||||
under the resolved host id so the Connection Log screen has a record post-pairing.
|
||||
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
|
||||
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
|
||||
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
|
||||
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
|
||||
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
|
||||
|
||||
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
|
||||
|
||||
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
|
||||
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
|
||||
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
|
||||
follow-up). `orca://` deep links can follow later via a route-level guard.
|
||||
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
|
||||
tolerates that by waiting for state commits.
|
||||
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
|
||||
|
||||
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
|
||||
|
||||
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
|
||||
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
|
||||
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
|
||||
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
|
||||
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
|
||||
`MobileHostCard`/`classifyConnection` render it while not yet connected.
|
||||
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
|
||||
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
|
||||
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
|
||||
forwarding strictly gated on suspended/disconnected.
|
||||
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
|
||||
forwarded states during suspended dial, no forwarding during live-session replacement, failure
|
||||
restores `'disconnected'`.
|
||||
|
||||
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
|
||||
|
||||
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
|
||||
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
|
||||
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
|
||||
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
|
||||
sockets on healthy LANs.
|
||||
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
|
||||
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
|
||||
the path is naturally guarded).
|
||||
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
|
||||
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
|
||||
broadly.
|
||||
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
|
||||
relay socket; relay loser closed after direct wins.
|
||||
|
||||
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
|
||||
|
||||
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
|
||||
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
|
||||
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
|
||||
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
|
||||
to the host index with a notice. (3) Use the validating reader in
|
||||
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
|
||||
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
|
||||
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
|
||||
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
|
||||
exemptions.
|
||||
|
||||
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
|
||||
|
||||
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
|
||||
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
|
||||
from the snapshot in a disabled state until the host connects.
|
||||
Files: `app/index.tsx` footer.
|
||||
Risk: none.
|
||||
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
|
||||
|
||||
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
|
||||
|
||||
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
|
||||
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
|
||||
replace only for the `blocked` verdict.
|
||||
Files: `src/components/HostProtocolGate.tsx`.
|
||||
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
|
||||
overlay must still block interaction until resolved; verify child mount effects don't fire gated
|
||||
RPCs pre-verdict before choosing overlay vs. current behavior.
|
||||
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
|
||||
|
||||
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
|
||||
|
||||
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
|
||||
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
|
||||
impact: git history blanking rows on the reconnect branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
|
||||
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
|
||||
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
|
||||
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
|
||||
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
|
||||
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
|
||||
(`app/h/[hostId]/index.tsx:129,330`).
|
||||
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
|
||||
rather than inventing new indicators. The in-repo reference pattern is
|
||||
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
|
||||
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
|
||||
preserves proven counts across a failed refresh, flagging only `staleCounts`.
|
||||
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
|
||||
(suspend blips), so this is hardening, not the primary fix.
|
||||
|
||||
## 5. Backward compatibility (old/new phone × old/new desktop)
|
||||
|
||||
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
|
||||
steps change. Old phones against any desktop are untouched (they don't have the code).
|
||||
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
|
||||
never activate; pairing keeps the existing `method_not_found` downgrade
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
|
||||
candidate is created).
|
||||
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
|
||||
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
|
||||
the same resume-credential flow lease rotation already exercises against production desktops.
|
||||
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
|
||||
|
||||
## 6. Constants appendix (verified)
|
||||
|
||||
| Constant | Value | Where |
|
||||
|---|---|---|
|
||||
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
|
||||
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
|
||||
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
|
||||
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
|
||||
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
|
||||
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
|
||||
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
|
||||
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
|
||||
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
|
||||
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
|
||||
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
|
||||
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
|
||||
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
|
||||
Reference in New Issue
Block a user