Skip to content

feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) - #24519

Merged
OrcaWin merged 1 commit into
mainfrom
OrcaWin/node-rt-p3-t6-8
Oct 2, 2026
Merged

OrcaWin merged 1 commit into
mainfrom
OrcaWin/node-rt-p3-t6-8

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1742 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1741
Prod 35 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3113 $\color{#cf222e}{\Huge{\mathbf{−}}}$​29 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3084

ELI5

To move a user off the SSH relay and onto a managed orcad server, Orca first has to package up everything the user's Orca keeps for that SSH host:

  • projects and folders;
  • worktree details;
  • saved terminal tabs and their scrollback;
  • automations;
  • some app routing state.

This PR adds that packaging step. It only reads: it builds a signed bundle (the "manifest") and leaves every row on the source side untouched. Nothing calls it yet, so users see no change.

What Changed

Export

New files under src/main/persistence/migrating-orcad-catalog/orcad-source-* and src/main/ssh/orcad-migration-manifest-export.ts.

  • What the manifest contains. For one SSH target, createOrcadMigrationManifest collects:

    • its repositories, folder workspaces and their project-group ancestry;
    • worktree metadata and lineage, workspace lineage, sparse presets, and retired worktree names;
    • the workspace session for that host, merged across session partitions;
    • dormant scrollback, stored as fixed descriptors (ref, sha256, byte length) instead of inline buffers;
    • disabled SSH-bridge automations and their runs;
    • client state: mobile tab selections, UI routing, saved port forwards, and browser close intents.
  • Validation. The result goes through T6-7's parseOrcadMigrationManifest (feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) #24422) and is signed with the same digest. Folder workspaces and git worktrees both export.

  • What doesn't fit. Anything the manifest can't represent is counted as blocked rather than guessed.

  • Reads go through the profile store. They go through a new read-only domain on the profile-state Store (refactor(persistence): retire ordinary JSON profile writes #23202), OrcadSourceExportPersistence. It offers four calls:

    • collectOrcadMigrationSourceDormantState
    • inspectOrcadMigrationSourceDependencies / inspectOrcadMigrationUntransferredDependencies
    • readOrcadMigrationSourceSnapshotChunk, which checks the manifest's digest, then the snapshot's length and sha256, before returning a bounded chunk
    • retainOrcadMigrationScrollback / releaseOrcadMigrationScrollback

    It holds the runtime state and nothing that writes.

Snapshot transfer

  • Sending side: orcad-migration-snapshot-coordinator.ts sends a manifest's snapshots in bounded, resumable chunks. The destination's catalog operations are passed in, so it has no transport or cutover of its own. A chunk whose acknowledgement was lost is confirmed by reading the destination's recorded offset, never assumed. The bytes stay retained for the transfer's duration.
  • Receiving side: orcad-scrollback-snapshot-transfer.ts stages, verifies and commits chunks on the destination. It now receives the staged manifest from its caller instead of reading destination catalog state, so T6-9's import can plug in.

Preflight

  • New preflight. ssh-target-orcad-preflight.ts adds preflightOrcadMigrationExport. It reuses T6-5's claim structure (the catalog blockers were split out as collectTargetCatalogBlockers).
  • Census. It extends ssh-target-orcad-dependents.ts with collectUntransferredDependentBlockers instead of building a second census.
  • What drains and what blocks:
    • The target's repositories, folder workspaces and saved port forwards drain; they don't block.
    • Live relay terminal leases block.
    • Dependent state the manifest can't carry blocks.
    • A census the store couldn't take is unverifiable, never empty.

Held changes from P7/P9

  • Session-owner transfer: extractSessionOwnersForTransfer gains optional projectSleepingAgentSession and projectSessionFocus projection hooks, and re-exports the markdown id helpers.
  • Durable writes: syncDirectoryDurablySync is exported, and writeFileDurableSync gains a creation mode.
  • Scrollback store: getTerminalScrollbackSnapshotRoot, getTerminalScrollbackSnapshotPath and readTerminalScrollbackStoredBytesSync are now exported.
  • Retained refs: snapshot cleanup, both sync and async, takes retainedRefs. Session publication passes the refs that in-flight exports retain, so closing a tab mid-transfer can't delete bytes the manifest promised.
  • Dormant tabs: a tab with no PTY (ptyId === null) keeps its saved buffer, since there's nothing live to replay from.

Not ported

These are T8 retirement/cutover pieces:

  • The source cutover, cutover admission and validation, and the dormant, workspace-session, client and automation retirement functions (retire* / assert*Retired).
  • The live state and session projection, and orcad-source-session-mirror. Its only consumer is the live projection.
  • orcad-managed-migration-status, which only lists source cutovers.
  • The renderer's orcad-migration-source-catalog, which only builds patches that retire the source catalog after a cutover.

Also, from the destination side, only toOrcadDestinationRepository is taken. It moved to orcad-destination-catalog-projection.ts.

Why

  • Read-only export. Moving users off the relay requires moving their data, but the source must stay usable until the destination has verified and imported the exact manifest. Export therefore only reads, and the digest-checked chunk reads refuse anything that changed since export.
  • Destination is injected. Passing in the destination operations, and the staged manifest on the receiving side, keeps this PR from depending on T6-9 or T8.

Linked Issue

Part of the Node runtime plan (Phase 3), T6 track (#16741): T6-8, source-side dormant export. Inert until T6-10.

Visual Proof

N/A. There's no UI change and no production caller.

Testing

  • Ported and adapted from feat(orcad): consolidate Bun runtime and SSH/Remote migration #16741: dependency census; client state, now with typed fixtures instead of casts; snapshot staging, which now takes the staged manifest; scrollback state; manifest export; snapshot coordinator, which now covers retention as well.

  • New tests:

    • orcad-source-export.test.ts, against a real profile-state Store:
      • the export reads the target's catalog and dormant scrollback and leaves the source byte-for-byte unchanged;
      • a chunk is refused after its buffer changes, and for a manifest whose digest doesn't match.
    • ssh-target-orcad-preflight.test.ts:
      • the catalog drains instead of blocking;
      • live leases block;
      • untransferable dependents block;
      • a census the store couldn't take is unverifiable;
      • ownership and an unknown target are handled.
    • orcad-source-scope.test.ts: folder workspaces and worktrees are both in scope.
    • orcad-migration-export-holds.test.ts: the stored-bytes reader, sync and async retained refs, the durable-write mode, and the projection hooks.
    • A dormant-tab buffer case in workspace-session-terminal-buffers.test.ts.
  • Regression: every test under src/main/persistence, the orcad and ssh-target tests in src/main/ssh, src/main/orca-profiles, the scrollback and durable-write tests, the shared migration contracts, and the import boundary.

    • 206 files: 202 passed, 3 skipped, 1 failed.
    • 2,068 tests passed, 8 skipped, 3 failed.
    • All 3 failures are the 30s timeouts in profile-state-backup-rotation.test.ts. That file fails identically on unmodified origin/main on this machine.
  • Static checks:

    • node config/scripts/check-changed-code-quality.mjs origin/main passes;
    • a scoped tsc --noEmit over the changed files is clean (the full typecheck was not run);
    • oxlint is clean;
    • pnpm-lock.yaml is unchanged;
    • no Bun, and no direct child_process import.
  • I manually tested these changes locally

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

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH and remote: the export is the client-side half of moving an SSH target to a managed server. There's no wire change.
  • Cross-platform: export is plain data plus Node fs, and works the same on every OS. The durable-mode test skips win32, where file modes aren't meaningful.

…16741 T6-8)

Read-only export of a direct-SSH target's catalog and dormant state into the signed T6-7 manifest: repositories, folder workspaces and their project groups, worktree metadata and lineage, sparse presets, retired worktree names, the workspace session with bounded scrollback snapshots, automations, and client routing. Reads go through the profile-state Store via a read-only OrcadSourceExportPersistence domain; nothing retires the source. Adds the export-aware migration preflight on top of T6-5's dependents census, a resumable snapshot transfer driver with injected destination operations, and destination-side chunk staging keyed to a caller-supplied staged manifest. Lands the P7/P9 holds: session-owner projection hooks, syncDirectoryDurablySync and the durable-write mode, scrollback path and stored-bytes exports, retained refs, and dormant-tab buffer preservation. Inert until T6-10.
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5

[High risk] Adds migration export infrastructure for SSH-hosted sessions.

The PR is not ready to merge because export ownership, lease preflight, and manifest dependency checks can report or carry the wrong state.

Findings

  1. P1 Security Foreign host state enters export ▶
  2. P1 Security Foreign parent group exported ▶
  3. P1 Expired leases pass preflight ▶
  4. P1 Stale manifests miss new state ▶
  5. P2 Retained snapshots remain orphaned ▶

Summary

The PR adds read-only SSH-target catalog and dormant-state export, resumable scrollback transfer helpers, and export preflight, plus snapshot-retention and session-projection support.

  • Host ownership checks in the export can admit state from another host or target.
  • Preflight can miss an expired lease, and the untransferred census can treat state absent from an older manifest as covered.
  • Releasing retained scrollback does not reclaim files skipped when tabs closed.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[SSH target and profile store] --> B[Catalog and dormant-state projection]
  B --> C[Validated manifest]
  C --> D[Export preflight and dependency census]
  C --> E[Source chunk reader]
  E --> F[Destination snapshot staging]
  F --> G[Verified snapshot commit]
Loading

Reviews (1) · Last reviewed commit: "feat(orcad): source-side dormant export ..."

return false
}
const rawValue = isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value
if (scope.folderWorkspaceKeys.has(rawValue)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Foreign host state enters export

If another host has the same repository and worktree ID as the SSH target, this check removes the host qualifier before deciding ownership. Export scans every host’s session, so it can copy that other host’s dormant tab into the target’s manifest and attribute it to the destination. Check an explicit host qualifier before treating the worktree as belonging to this target.

How this was verified: Host-qualified keys distinguish identical worktree IDs on different hosts, but export matches the unqualified ID while scanning all host sessions.

Knowledge Base Used: Workspace and worktree lifecycle

Comment on lines +49 to +53
const group = byId.get(nextId)
if (!group?.parentGroupId || includedIds.has(group.parentGroupId)) {
continue
}
includedIds.add(group.parentGroupId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Foreign parent group exported

If a group belonging to this SSH target has a parent whose connectionId names another target, this walk includes the parent without checking ownership. Group creation and manifest validation allow that relationship, so the signed export carries the other target’s group metadata. Cross-target ancestry needs to be rejected or handled separately.

How this was verified: The ancestry walk does not check the parent’s connection, and neither group creation nor manifest validation requires parent and child to share a target.

Knowledge Base Used: Workspace and worktree lifecycle

const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)]
const terminalLeases = store
.getSshRemotePtyLeases(targetId)
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Expired leases pass preflight

If a target’s only remaining PTY lease is expired, this filter omits it and preflight can report the target as claimable. Expiration means the client lost its route, not that the remote process stopped. The dependency census still counts the lease, but preflight excludes lease counts from that check, so migration could proceed while remote work remains live.

Knowledge Base Used:

Comment on lines +137 to +145
manifest.destinationEnvironmentId
)
const dormantMatches =
ignoreTransferredDormantState ||
serializeOrcadMigrationValue(dormant.payload) ===
serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload())
if (dormantMatches) {
for (const kind of ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS) {
counts[kind] = dormant.blockedCounts[kind]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stale manifests miss new state

If a transferable dormant tab or automation appears after a manifest was created, the untransferred census skips comparing current state with that manifest. It then replaces the dependency count with the current projection’s blocked count, which can be zero even though the new row is absent from the manifest. A caller checking whether that manifest covers the source can therefore receive a false zero.

Knowledge Base Used: Desktop persistence and lifecycle

Comment on lines +91 to +93
releaseOrcadMigrationScrollback(migrationId: string): void {
this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.delete(migrationId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Retained snapshots remain orphaned

If a tab closes during transfer, retention keeps its scrollback file while the session drops the ref. Release only removes the retention entry; later cleanup looks at refs in the previous session, where that ref no longer exists. The unreferenced file is therefore left on disk indefinitely, adding storage use with each affected transfer.

Knowledge Base Used: Desktop persistence and lifecycle

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (1)
AGENTS.md — auto-discovered
📝 Walkthrough

Walkthrough

The pull request adds Orcad migration manifest export, source-state projection, dependency preflight, and terminal scrollback snapshot transfer. It adds migration-scoped retention so cleanup preserves snapshots used by in-flight exports. It also adds destination upload staging and validation, plus tests for migration projections, dependency counts, snapshot retention, and transfer behavior.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to 77375

The new migration export can wrongly block or allow migrations, and it can export large local-only scrollback outside the verified transfer path. Nothing calls it in production yet. Fix these issues before a caller is wired in.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 77375

The change prepares saved terminal output and other workspace data for transfer. Selection and integrity checks limit the risk, and no newly exposed external transfer path was established. Recovery ownership still needs clarification before transfers are enabled.

Retained concerns

  • Medium · reliability · inferred: Source retention belongs to an upload attempt rather than an explicit migration recovery owner. The coordinator releases it unconditionally on success or failure, while destination commit and abort remain separate operations. Retaining twice under the same migration ID also replaces one hold, so either overlapping attempt can release protection needed by the other. Subsequent session cleanup can remove source bytes still needed for retry or rollback. Matching-final recovery and exact retry checks protect destination integrity, but do not establish source lifetime across these transitions. No production transfer caller was identified, so this is a bounded integration concern rather than demonstrated user-facing data loss.
Security review details

Security Blast Radius

  • inferred — The intended export boundary is one selected SSH target within a supplied profile store, including its saved terminal output. Destination snapshot operations affect the supplied snapshot storage root. No production transfer caller was identified in the inspected references, so broader remote, tenant, or environment exposure was not established.

Trust Boundaries and Controls

  • observed — Destination staging binds writes to an existing staged manifest and declared snapshot, bounds offsets and lengths, and rejects nonidentical overlapping retries. New staging directories and files request owner-only permissions. These controls constrain content and filesystem effects but do not replace transport authorization.

Resilience and Maintainability Implications

  • observed — Chunk contents are fsynced before acknowledgement, and lost acknowledgements are reconciled by rereading destination offsets. The staging path does not explicitly sync newly created directory entries before acknowledgement; directory syncing during commit is best-effort. Crash-durable handoff before source release therefore remains unproven.

Hardening Proposals

  • proposed — Before enabling transport, define an authorization contract binding the caller, source profile and target, and destination. Give the migration lifecycle an explicit retention lease that survives recoverable failures, handles overlapping attempts, and is released only after verified durable handoff or intentional abort.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 153 functions across 46 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: source-side dormant export for a relay-hosted SSH target.
Description check ✅ Passed The description is detailed and covers the required change, rationale, issue, testing, visual proof, scope, and compatibility notes. It also discloses known test failures and that full typechecking wa…
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.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 80a89497-a2aa-475c-9642-30763acb99b3

📥 Commits

Reviewing files that changed from the base of the PR and between 24b97e8 and 77375bb.

📒 Files selected for processing (46)
  • src/main/durable-file-write.ts
  • src/main/orca-profiles/profile-session-owner-transfer.ts
  • src/main/orcad-migration-export-holds.test.ts
  • src/main/orcad/orcad-migration-manifest-digest.ts
  • src/main/persistence/loading-store/session-snapshot-operations.ts
  • src/main/persistence/loading-store/store-domain-composition.ts
  • src/main/persistence/loading-store/store-runtime-state.ts
  • src/main/persistence/loading-store/terminal-session-cleanup.ts
  • src/main/persistence/loading-store/workspace-session-snapshot-publication.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts
  • src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts
  • src/main/ssh/orcad-migration-manifest-export.test.ts
  • src/main/ssh/orcad-migration-manifest-export.ts
  • src/main/ssh/orcad-migration-snapshot-coordinator.test.ts
  • src/main/ssh/orcad-migration-snapshot-coordinator.ts
  • src/main/ssh/ssh-target-orcad-claims.ts
  • src/main/ssh/ssh-target-orcad-dependents.ts
  • src/main/ssh/ssh-target-orcad-preflight.test.ts
  • src/main/ssh/ssh-target-orcad-preflight.ts
  • src/main/terminal-scrollback-snapshot-async-migration.ts
  • src/main/terminal-scrollback-snapshots.ts
  • src/main/worktree-retirement-namespace.ts
  • src/shared/workspace-session-terminal-buffers.test.ts
  • src/shared/workspace-session-terminal-buffers.ts

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

Comment on lines +200 to +208
const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter(
([paneKey]) => {
const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false
if (!allowed) {
blockedCounts['ui-routing'] += 1
}
return allowed
}
)

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 | 🟠 Major | ⚡ Quick win

Count only source-scoped agent acknowledgements as ui-routing blockers.

This filter runs over every entry in the profile-wide ui.acknowledgedAgentsByPaneKey. Each pane key outside eligible.tabIds increments blockedCounts['ui-routing']. That includes acknowledgements for local tabs and for tabs on other SSH targets. If eligible is null, every acknowledgement in the profile counts as a blocker.

collectOrcadMigrationUntransferredDependencyCensus copies dormant.blockedCounts['ui-routing'] into the census, and collectUntransferredDependentBlockers turns a nonzero count into orcad_migration_dependent_state. As a result, one acknowledged agent anywhere in the profile makes preflightOrcadMigrationExport return claimable: false for every target.

Skip pane keys that do not belong to a source-scoped tab. Count a blocker only when a source-scoped pane is not representable in the eligible session.

🐛 Proposed fix
   const eligible = session ? collectEligibleSessionIdentity(session, scope) : null
+  const sourceTabIds = inspectOrcadMigrationSourceSessions(state, {
+    targetId: scope.targetId,
+    hostId: scope.hostId,
+    ownerMatches: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope)
+  }).tabIds
   const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter(
     ([paneKey]) => {
+      if (!orcadMigrationPaneBelongsToTabs(paneKey, sourceTabIds)) {
+        return false
+      }
       const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false
       if (!allowed) {
         blockedCounts['ui-routing'] += 1
       }
       return allowed
     }
   )

Import inspectOrcadMigrationSourceSessions from ./orcad-source-session-dependencies.

📝 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
const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter(
([paneKey]) => {
const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false
if (!allowed) {
blockedCounts['ui-routing'] += 1
}
return allowed
}
)
const sourceTabIds = inspectOrcadMigrationSourceSessions(state, {
targetId: scope.targetId,
hostId: scope.hostId,
ownerMatches: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope)
}).tabIds
const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter(
([paneKey]) => {
if (!orcadMigrationPaneBelongsToTabs(paneKey, sourceTabIds)) {
return false
}
const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false
if (!allowed) {
blockedCounts['ui-routing'] += 1
}
return allowed
}
)

Comment on lines +49 to +56
const leafIds = new Set([
...Object.keys(sourceLayout.buffersByLeafId ?? {}),
...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {})
])
for (const leafId of [...leafIds].sort(compareKeys)) {
const buffer = sourceLayout.buffersByLeafId?.[leafId]
const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId]
const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Handle localOnlyScrollbackByTabId in the scrollback projection.

extractSessionOwnersForTransfer copies localOnlyScrollbackByTabId for the transferred tabs (profile-session-owner-transfer.ts, lines 112-118). This projection reads only buffersByLeafId and scrollbackRefsByLeafId, and it never removes the local-only field. This causes two failures:

  • The raw local-only buffers stay inline in dormantState.workspaceSession. They bypass MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES and the snapshot descriptors. The WorkspaceSessionState doc comment states that this field can be tens of MiB and "never leaves this client".
  • A leaf whose only copy is local-only gets no snapshot descriptor. Its scrollback therefore does not go through the verified chunk-transfer path.

Resolve each leaf through resolveLeafScrollbackBuffers, as the type documentation requires. Then delete localOnlyScrollbackByTabId from projected. Apply the same resolution in findSnapshotBytes (lines 132-138), so that chunk reads match the exported descriptors.

🐛 Sketch
-    const leafIds = new Set([
-      ...Object.keys(sourceLayout.buffersByLeafId ?? {}),
-      ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {})
-    ])
+    const resolvedBuffers = resolveLeafScrollbackBuffers(session, tabId) // per its actual signature
+    const leafIds = new Set([
+      ...Object.keys(resolvedBuffers ?? {}),
+      ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {})
+    ])
     for (const leafId of [...leafIds].sort(compareKeys)) {
-      const buffer = sourceLayout.buffersByLeafId?.[leafId]
+      const buffer = resolvedBuffers?.[leafId]
+  delete projected.localOnlyScrollbackByTabId
   if (
     snapshots.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS ||

Comment on lines +45 to +51
// Closing a tab mid-transfer must not delete the bytes this manifest promised.
args.source.retainOrcadMigrationScrollback(args.manifest)
try {
await sendSnapshots(args, snapshots)
} finally {
args.source.releaseOrcadMigrationScrollback(args.manifest.migrationId)
}

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

🔎 Supported by static analysis

🏁 Script executed:

set -eu
printf '%s\n' '--- changed file ---'
cat -n src/main/ssh/orcad-migration-snapshot-coordinator.ts
printf '%s\n' '--- retain/release definitions and usages ---'
rg -n -C 5 'retainOrcadMigrationScrollback|releaseOrcadMigrationScrollback' .
printf '%s\n' '--- relevant diff ---'
git diff --unified=80 444f1952c771e08134d490145ca6c756487ad163 77375bb61e7fa834324b280b16e9a0071c871aac -- src/main/ssh/orcad-migration-snapshot-coordinator.ts

Repository: stablyai/orca

Length of output: 18974


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings

Length of output: 17478


🏁 Script executed:

set -eu
cat -n src/main/ssh/orcad-migration-snapshot-coordinator.ts
printf '%s\n' '--- methods ---'
rg -n -C 8 'retainOrcadMigrationScrollback|releaseOrcadMigrationScrollback' .
printf '%s\n' '--- diff ---'
git diff --unified=60 444f1952c771e08134d490145ca6c756487ad163 77375bb61e7fa834324b280b16e9a0071c871aac -- src/main/ssh/orcad-migration-snapshot-coordinator.ts

Repository: stablyai/orca

Length of output: 22531


🏁 Script executed:

set -eu
git diff --unified=20 444f1952c771e08134d490145ca6c756487ad163 77375bb61e7fa834324b280b16e9a0071c871aac -- src/main/ssh/orcad-migration-snapshot-coordinator.ts
rg -n -C 4 'retainOrcadMigrationScrollback|releaseOrcadMigrationScrollback' .

Repository: stablyai/orca

Length of output: 12025


🏁 Script executed:

set -eu
printf '%s\n' '--- retention map consumers ---'
rg -n -C 10 'retainedScrollbackRefsByMigrationId|terminalScrollbackSnapshotStorage|delete.*scrollback|unlink.*scrollback' src/main
printf '%s\n' '--- coordinator callers ---'
rg -n -C 8 'transferOrcadMigrationSnapshots' src
printf '%s\n' '--- source export context ---'
sed -n '1,190p' src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts

Repository: stablyai/orca

Length of output: 41811


🏁 Script executed:

set -eu
printf '%s\n' '--- exact retention consumers ---'
rg -n -C 14 'retainedScrollbackRefsByMigrationId' src/main
printf '%s\n' '--- cleanup APIs and callers ---'
rg -n -C 10 'deleteRemovedTerminalScrollbackSnapshots|removedSnapshot|retainedScrollback' src/main/persistence src/main/terminal-scrollback-snapshot-async-migration.ts

Repository: stablyai/orca

Length of output: 44939


Serialize or reference-count transfers per migrationId.

When two transferOrcadMigrationSnapshots calls for the same migrationId overlap, the first call deletes the shared retention entry while the second call is still reading snapshots. Cleanup can then delete bytes that the second transfer still needs.

Serialize transfers for each migrationId, or release the entry only after the final overlapping transfer completes.

const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)]
const terminalLeases = store
.getSshRemotePtyLeases(targetId)
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')

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 | 🟠 Major | ⚡ Quick win

Block on expired relay leases or prove that they cannot own remote work.

This filter excludes expired leases from the orcad_migration_direct_ssh_terminal_leases blocker. The same PR treats those leases as live authority in two other places:

  • countTerminalRecoveryState in orcad-source-dependency-census.ts states "Expired routes may still own remote work; only terminated leases resolve recovery authority" and filters only !== 'terminated'.
  • shutdownMarkerHasTerminalAuthority treats any non-terminated lease as PTY authority.

With an expired lease and no consumer-recovery record, preflight reports claimable: true. The relay PTY can still be running. The migration can then proceed while the source still holds remote work that cannot move to orcad. Align the predicate with the census so that only terminated leases do not block.

🐛 Proposed fix
-    .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
+    // Expired routes may still own remote work; only terminated leases are final.
+    .filter((lease) => lease.state !== 'terminated')
📝 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
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
// Expired routes may still own remote work; only terminated leases are final.
.filter((lease) => lease.state !== 'terminated')

@pullfrog pullfrog 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.

Important

Two real correctness gaps in the export/preflight path, plus two smaller ones in the snapshot lifecycle. The PR is inert (no production caller yet), so nothing is broken today — but these will bite when T6-10 wires the export up. The expired-lease gap is the one I'd fix before this becomes live.

Reviewed changes — reviewed the single commit 77375bb in full (46 files, +4855/−30).

  • Signed manifest export — createOrcadMigrationManifest collects the target's catalog + dormant state, validates through T6-7's parser, and signs the digest.
  • Read-only store domain — OrcadSourceExportPersistence exposes the dependency census, dormant collection, digest-checked chunk reads, and retained-ref tracking; registered in the store composition.
  • Dormant projection — workspace-session eligibility/fragments/layout/chunk merging, worktree metadata/lineage, sparse presets, retired names, automations, and client state, each filtered to the target's owner scope.
  • Chunked snapshot transfer — destination staging/verify/hard-link commit plus a source coordinator with resume and lost-acknowledgement reconciliation.
  • Export preflight — preflightOrcadMigrationExport drains catalog rows, blocks live leases and untransferred dependents, and treats an unreadable census as unverifiable.
  • Held changes — session-owner projection hooks, durable-write mode + syncDirectoryDurablySync, scrollback reader/path exports, retained-ref plumbing, and dormant-tab buffer retention.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)]
const terminalLeases = store
.getSshRemotePtyLeases(targetId)
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')

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.

Filtering out expired leases leaves a target with a client-side expiry (an expired lease carrying no supersededBy/relayIdRecycled mark) with no lease blocker at all: the census deliberately omits terminal-lease from CENSUS_DEPENDENCY_KINDS, and the manifest carries no lease field. sshRemotePtyLeaseAllowsReattach documents that such an orphan is not provably dead, so preflightOrcadMigrationExport would report claimable: true and cutover would strand the remote shell. Gate the lease list on sshRemotePtyLeaseAllowsReattach(lease) instead of the hand-rolled state check.

Technical details
# Expired orphan lease drains silently

## Affected sites
- `src/main/ssh/ssh-target-orcad-preflight.ts:48` — excludes `expired` unconditionally.
- `src/main/ssh/ssh-target-orcad-dependents.ts:82-84` — `terminal-lease` excluded from `CENSUS_DEPENDENCY_KINDS`, so the census can never surface it.
- `src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts:158-164` — `countTerminalRecoveryState` counts a lease as active when `state !== 'terminated'` (i.e. includes `expired`), the opposite predicate to the preflight, producing the false-positive block noted below.

## Required outcome
- An `expired` lease that cannot be shown dead (no `supersededBy`, no `relayIdRecycled`) blocks export, matching `src/shared/ssh-types.ts:275-293` and `docs/reference/ssh-execution-boundary.md`.
- An `expired` lease that *is* provably dead (either mark present) drains.
- The recovery predicate in `countTerminalRecoveryState` should use the same `sshRemotePtyLeaseAllowsReattach` gate so the two sites agree; today an expired orphan lease plus a `sshPtyConsumerRecovery` row produces `orcad_migration_dependent_state` and strands a drainable target.

## Open questions for the human
- Is the deliberate `expired` exclusion here intended to mean "client lost its route, so release the host", or was it meant to mirror the old `collectDependentStateBlockers`, which counts every lease status and blocks? The two paths disagree.

const ref = layout.scrollbackRefsByLeafId?.[descriptor.leafId]
const bytes = buffer
? Buffer.from(buffer, 'utf8')
: ref === descriptor.ref

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.

findSnapshotBytes resolves bytes only through the live session's scrollbackRefsByLeafId/buffersByLeafId. retainOrcadMigrationScrollback keeps a snapshot file alive on disk, but once a tab closes mid-transfer its ref disappears from every session partition, so the retained file is never consulted and the next chunk throws orcad_migration_source_snapshot_changed. Retention exists precisely to survive that close, so a retained ref should be readable straight from storage by descriptor.ref before falling back to the session lookup.

Technical details
# Retained snapshot bytes are not readable after a tab close

## Affected sites
- `src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts:122-148` — `findSnapshotBytes` requires `layout.scrollbackRefsByLeafId[leafId] === descriptor.ref` (or an in-memory buffer); a removed tab's layout is gone.
- `src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts:81-93` — `retainOrcadMigrationScrollback` records refs, but `readOrcadMigrationSourceSnapshotChunk` never reads those files directly.
- `src/main/persistence/loading-store/workspace-session-snapshot-publication.ts:104-109` — retention is what suppresses the deletion of those files.

## Required outcome
A chunk read for a ref the manifest names must still succeed after its tab is closed while retained. Either resolve the bytes by `descriptor.ref` from the (retained) store before the session lookup, or persist buffer-sourced snapshots to a ref-addressed file at export time so every descriptor has durable bytes independent of the live session.

## Open questions for the human
- `projectOrcadMigrationSessionScrollback` computes `sha256`/`byteLength` for buffer-sourced (dormant-tab) snapshots without writing them to disk; should it call `writeTerminalScrollbackSnapshotSync` so every snapshot has a file to retain?

}

releaseOrcadMigrationScrollback(migrationId: string): void {
this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.delete(migrationId)

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.

releaseOrcadMigrationScrollback only drops the retained set; it never re-runs the deletion that was skipped while the ref was retained. If a tab closed during the transfer, the ref is already absent from every future prior session by the time release happens, so deleteRemovedTerminalScrollbackSnapshots[Async] can never see it again and the ${ref}.bin file leaks permanently — nothing in the repo enumerates the snapshot root to reclaim it (pruneOrcadMigrationSnapshotStaging only sweeps staging directories). Re-run the deletion for the just-released refs, or add an orphan sweep, before forgetting them.

Technical details
# Released retained refs are never reclaimed

## Affected sites
- `src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts:91-93` — release deletes the map entry and nothing else.
- `src/main/ssh/orcad-migration-snapshot-coordinator.ts:46-51` — retain at transfer start, release in `finally`.
- `src/main/persistence/loading-store/terminal-session-cleanup.ts:31-40` and `src/main/terminal-scrollback-snapshot-async-migration.ts:44-53` — deletion is ref-addressed `prior`/`next` only.
- `src/main/terminal-scrollback-snapshots.ts:216-236` — delete helpers take a ref; no directory enumeration exists under the snapshot root.

## Required outcome
When retention ends, any retained ref no longer referenced by the current session is deleted. This can be done by invoking the existing deletion pass with the released refs as `prior` refs against the current session, or by a bounded orphan sweep of `${ref}.bin` files at release time.

## Suggested approach (optional)
Have `releaseOrcadMigrationScrollback` return the released refs; the coordinator (or the Store) then calls `deleteRemovedTerminalScrollbackSnapshots` with a synthetic prior session carrying those refs.

throw error
}
}
rmSync(stagedPath, { force: true })

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.

The staged name is unlinked before the final hard link is made durable: syncDirectoryDurablySync(root) runs only after the whole loop (line 146). A crash between linkSync (line 134) and that fsync can replay the staged unlink without the final link, leaving the inode with zero links — and the source already released retention when chunk transfer finished, so the bytes cannot be re-fetched if the tab has since closed. Fsync root immediately after linking, then remove the staged name.

Technical details
# Link not durable before staged unlink

## Affected sites
- `src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts:134` — `linkSync(stagedPath, finalPath)`.
- `src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts:144` — `rmSync(stagedPath)` runs before the fsync.
- `src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts:146` — `syncDirectoryDurablySync(root)` after the loop.
- `src/main/ssh/orcad-migration-snapshot-coordinator.ts:46-51` — source retention released before commit runs.

## Required outcome
After linking a descriptor, fsync the directory containing `finalPath` before unlinking `stagedPath`, so a crash cannot persist the unlink without the link. (`syncDirectoryDurablySync` is already exported by this PR.)

@OrcaWin
OrcaWin merged commit 783101b into main Oct 2, 2026
61 checks passed
OrcaWin added a commit that referenced this pull request Oct 2, 2026
* Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)"

This reverts commit 783101b.

* Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)"

This reverts commit 38c2d1d.

* Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)"

This reverts commit 8b76683.

* Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)"

This reverts commit d3f8c50.

* Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)"

This reverts commit 43d9b43.

* Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)"

This reverts commit b093d3a.

* Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)"

This reverts commit 1a9ac0e.

* Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)"

This reverts commit 99db2bf.

* Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)"

This reverts commit 34a582b.

* Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)"

This reverts commit d53063d.

* Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)"

This reverts commit ff212db.

* Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)"

This reverts commit 92cb717.

* Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)"

This reverts commit 6b36e4f.

* Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)"

This reverts commit d23ecef.

* Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)"

This reverts commit dd87ae5.

* Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)"

This reverts commit ece9e4d.

* Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)"

This reverts commit 3fbdaba.

* Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)"

This reverts commit 4e8edc8.

* Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)"

This reverts commit 60c9326.

* Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)"

This reverts commit 99e0303.

* Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)"

This reverts commit 817af76.

* Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)"

This reverts commit c991893.

* Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)"

This reverts commit dc08ffe.

* Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)"

This reverts commit a789233.

* Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)"

This reverts commit 0b812bd.

* Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)"

This reverts commit 3aa2d3a.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
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