Skip to content

feat(daemon): the host keeps its own record of old-client restores it refuses - #27496

Merged
OrcaWin merged 2 commits into
mainfrom
np/d1c-legacy-restores
Oct 11, 2026
Merged

OrcaWin merged 2 commits into
mainfrom
np/d1c-legacy-restores

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​300 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​300
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​336 $\color{#cf222e}{\Huge{\mathbf{−}}}$​34 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​302

ELI5

An older Orca client (an older desktop or a phone build from before #27452) deletes its own "resume this agent" note before it asks the host to restart the agent. If the host refuses that restart, because the machine is overloaded or at its terminal limit (#27488), the note is gone and the user cannot resume that agent. Now the host keeps its own copy of every restore like that. orca serve restore resume lists them with the exact command that resumes each one, and a restore of the same agent from any client removes the copy.

What Changed

  • Before: an old client's refused restore lost its resume handle. The client had already cleared its sleeping record (sleeping-agent-session-launch.ts:109), and the host remembered nothing.
  • After: the terminal daemon keeps a small private file beside its crash marker: daemon-v<N>.legacy-restores.json (mode 0600, the 32 newest, 14 days). A record holds the session id, the reason (pressure or host_cap), when it happened, the agent, the working directory, the worktree, the resume command line and the claim digest. It never holds the environment, which can carry credentials, or the scrollback seed.
    • Recorded when: a request that declared no intent and launches an agent with a command is refused, whether under critical pressure (terminal_spawn_refused) or at the host terminal cap. A client that declared its intent is never recorded, because its pane already stays asleep with its handle. A plain shell is never recorded.
    • One record per agent session: keyed by the claim digest when the client sent one, otherwise by the command. The newest refusal wins.
    • Dropped when: a restore of the same agent session starts, from any client, declared or not.
  • Exposure: AutomaticWorkStatus gains an optional legacyRestores list, which flows through the existing daemon → runtime → runtime.automaticWork.status/resume → CLI chain. orca serve restore resume prints the kept restores after its usual message, for example: - claude in /repo, refused 2026-10-11T12:00:00.000Z (the host was under heavy process load). Resume it with: claude --resume 6f1c.
  • Removal tag: the shim carries COMPAT(legacy-restore-intent): added 2026-10-11 (v1.4.214) for clients that predate spawnIntent (#27452). Remove after 2027-04-11 with the gate's undeclared-caller branches.
  • To stay under the file-size limit: the gate's waiter set moves to terminal-spawn-waiters.ts, and the recording wrapper lives in legacy-restore-admission.ts. Gate behavior is unchanged.

Why

Decision Q9: old clients are paced and refused only at critical pressure, and "the host records every legacy restore it defers or refuses, keyed by session identity, so the user can resume it later from any client". The daemon is the only place every refusal passes through: local IPC, paired desktops and phones all reach the same gate, so recording there covers every client path with one hook.

Why not rebuild the client's sleeping record instead. That record needs the provider session key and id. The host only sees the command and an HMAC claim digest, which cannot be reversed, so it cannot rebuild the record faithfully. The command is exactly what the old client was about to run, so it is the faithful thing to keep.

Wire safety: legacyRestores is an optional field on an existing result. Older clients ignore it, and older daemons omit it, which reads as "none". No wire message or error string changes; the refusal text an old client sees is identical.

Linked Issue

Part of the headless-serve robustness work for #10493 (D1 step 4, decision Q9). #27492 is merged; this PR is based on main.

Visual Proof

N/A. CLI text and host state only; no UI change. The CLI output is pinned by a test.

Testing

  • Store: survives a daemon restart with a private file mode; one record per restore; dropped once the same session starts; bounded to 32 and aged out after 14 days; an unreadable or unwritable file never throws into a spawn; path sits beside the run marker.

  • Gate, through a real TerminalHost: an old client's restore refused under critical pressure is recorded with agent, cwd, worktree, command and claim digest, and the env token is absent; a refusal at the host cap is recorded as host_cap; a plain shell and a declared restore are never recorded; a later user-resume of the same session clears the record.

  • CLI: lists kept restores with their resume command, says when a command was not recorded, and prints nothing extra when there are none.

  • pnpm tc, pnpm lint, oxlint and pnpm run check:code-quality:changed pass. The gate, shared-budget, guard, spawn-intent, async-cancellation, runtime automatic-work, CLI serve restore and renderer resume suites pass.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

SSH: the record lives on the execution host that refused the restore, next to its daemon, and the CLI and RPC read it from that host. Security: the file is 0600 in the daemon's private runtime dir, and the environment is never stored. The command line can include the user's own agent arguments, which paired clients with workspace permission can already see in the terminal itself. Windows: the same file logic, written with the existing durable-write helper.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@OrcaWin
OrcaWin force-pushed the np/d1c-shared-budget branch from 0c98466 to 11a1d7f Compare October 11, 2026 14:29
@OrcaWin
OrcaWin force-pushed the np/d1c-legacy-restores branch from f481c99 to 4307225 Compare October 11, 2026 14:39
@OrcaWin
OrcaWin changed the base branch from np/d1c-shared-budget to main October 11, 2026 14:45
@OrcaWin
OrcaWin force-pushed the np/d1c-legacy-restores branch from 4307225 to 21ec56b Compare October 11, 2026 14:45
@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 43aaf00e-c204-498d-a2be-8b91504ee33b

📥 Commits

Reviewing files that changed from the base of the PR and between e2344f830737261a1f5ade2439bc4c91cc27696d and ff54f5f.


You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough
📝 Walkthrough
📝 Walkthrough
📝 Walkthrough

Walkthrough

The daemon now records qualifying restores refused for legacy clients and includes those records in automatic-work status. The CLI displays the refusal reason, time, available restore details, and recorded resume command. The change also moves spawn-gate waiter tracking into a helper.








Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 21ec5

Expired refused-restore records can stay listed in a daemon that runs for more than 14 days. This is a minor bound violation, not a failure of the feature, and can be fixed before or shortly after merge.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is complete and aligned with the template. It explains the user impact, implementation, rationale, linked work, testing, security, cross-platform considerations, and checklist status. …
Title check Passed The title clearly and concisely describes the main change: the daemon keeps records of refused restores from older clients.





✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR







  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 62885918-a513-4028-90ff-10c46d6937f7
📥 Commits

Reviewing files that changed from the base of the PR and between 6e9dc09 and 21ec56b99e460be84e78bdce961daf317d488cfa.

📒 Files selected for processing (12)
  • src/cli/handlers/serve-restore.test.ts
  • src/cli/handlers/serve-restore.ts
  • src/cli/specs/serve.ts
  • src/main/daemon/daemon-automatic-work-guard.ts
  • src/main/daemon/legacy-restore-admission.ts
  • src/main/daemon/legacy-restore-records.test.ts
  • src/main/daemon/legacy-restore-records.ts
  • src/main/daemon/terminal-host-session-create.ts
  • src/main/daemon/terminal-spawn-gate-legacy-restores.test.ts
  • src/main/daemon/terminal-spawn-gate.ts
  • src/main/daemon/terminal-spawn-waiters.ts
  • src/shared/host-spawn-admission.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Comment on lines +97 to +116
private load(): LegacyRestoreRecord[] {
if (this.records) {
return this.records
}
let parsed: unknown = null
try {
parsed = this.path ? JSON.parse(readFileSync(this.path, 'utf8')) : null
} catch {
parsed = null
}
const raw =
typeof parsed === 'object' && parsed !== null && 'records' in parsed ? parsed.records : null
const now = this.now()
this.records = (Array.isArray(raw) ? raw : [])
.map(parseRecord)
.filter((record): record is LegacyRestoreRecord => record !== null)
.filter((record) => now - record.refusedAt <= MAX_AGE_MS)
.slice(0, MAX_RECORDS)
return this.records
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Apply the 14-day age limit on every read, not only on the first load.

load() applies MAX_AGE_MS only while this.records is still null. After the first read, list(), record(), and clearStarted() all reuse the cached array. If a daemon runs longer than 14 days, status() keeps returning expired records. save() also writes those expired records back to the file. The records disappear only when the daemon restarts. This breaks the 14-day bound that the PR states.

Fix: filter the cached array by age in load() before it returns.

🐛 Proposed fix
--- "a/src/main/daemon/legacy-restore-records.ts"
+++ "b/src/main/daemon/legacy-restore-records.ts"
@@ -94,10 +94,12 @@
     }
   }
 
   private load(): LegacyRestoreRecord[] {
     if (this.records) {
-      return this.records
+      const now = this.now()
+      this.records = this.records.filter((record) => now - record.refusedAt <= MAX_AGE_MS)
+      return this.records
     }
     let parsed: unknown = null
     try {
       parsed = this.path ? JSON.parse(readFileSync(this.path, 'utf8')) : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private load(): LegacyRestoreRecord[] {
if (this.records) {
return this.records
}
let parsed: unknown = null
try {
parsed = this.path ? JSON.parse(readFileSync(this.path, 'utf8')) : null
} catch {
parsed = null
}
const raw =
typeof parsed === 'object' && parsed !== null && 'records' in parsed ? parsed.records : null
const now = this.now()
this.records = (Array.isArray(raw) ? raw : [])
.map(parseRecord)
.filter((record): record is LegacyRestoreRecord => record !== null)
.filter((record) => now - record.refusedAt <= MAX_AGE_MS)
.slice(0, MAX_RECORDS)
return this.records
}
private load(): LegacyRestoreRecord[] {
if (this.records) {
const now = this.now()
this.records = this.records.filter((record) => now - record.refusedAt <= MAX_AGE_MS)
return this.records
}
let parsed: unknown = null
try {
parsed = this.path ? JSON.parse(readFileSync(this.path, 'utf8')) : null
} catch {
parsed = null
}
const raw =
typeof parsed === 'object' && parsed !== null && 'records' in parsed ? parsed.records : null
const now = this.now()
this.records = (Array.isArray(raw) ? raw : [])
.map(parseRecord)
.filter((record): record is LegacyRestoreRecord => record !== null)
.filter((record) => now - record.refusedAt <= MAX_AGE_MS)
.slice(0, MAX_RECORDS)
return this.records
}

@OrcaWin
OrcaWin force-pushed the np/d1c-legacy-restores branch from 21ec56b to e2344f8 Compare October 11, 2026 15:00
@OrcaWin
OrcaWin force-pushed the np/d1c-legacy-restores branch from e2344f8 to ff54f5f Compare October 11, 2026 16:05
@OrcaWin
OrcaWin merged commit 9c80a61 into main Oct 11, 2026
44 checks passed
@OrcaWin
OrcaWin deleted the np/d1c-legacy-restores branch October 11, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant