From 3286f3b25913eaa7065b4ce984d9b17825e53c40 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 23 Oct 2026 19:07:32 -0700 Subject: [PATCH] [comment audit] Clean up IPC, host lifecycle and native-chat comments (#32966) * [comment audit] Tighten watcher lifetime and file-write constraints [skip ci] * [comment audit] Clarify IPC routing and notification comments [skip ci] * [comment audit] Shorten watcher, preflight and PTY comments [skip ci] * [comment audit] Clarify PTY delivery and restore ownership [skip ci] * [comment audit] Tighten PTY environment and flow-control rationale [skip ci] * [comment audit] Clarify IPC spawn and stable-pane guarantees [skip ci] * [comment audit] Tighten PTY provider and filesystem ownership rationale [skip ci] * [comment audit] Clarify clone ownership and remote runtime recovery [skip ci] * [comment audit] Condense upload, settings and SSH lifecycle comments [skip ci] * [comment audit] Shorten shutdown, preview and worktree watcher rationale [skip ci] * [comment audit] Clarify worktree identity, remote ownership and copy limits [skip ci] * [comment audit] Shorten worktree catalog, removal and WSL routing rationale [skip ci] * [comment audit] Tighten Linear credential, pagination and export comments [skip ci] * [comment audit] Condense managed-server activation, snapshot and launch rationale [skip ci] * [comment audit] Trim runtime installation and managed-server lifecycle comments [skip ci] * [comment audit] Simplify managed tunnel, memory and menu comments [skip ci] * [comment audit] Condense integration, model catalog and attachment rationale [skip ci] * [comment audit] Shorten journal persistence, replay and queue rationale [skip ci] * [comment audit] Condense journal ordering and timeline lifecycle comments [skip ci] * [comment audit] Trim timeline and subscriber delivery explanations [skip ci] * [comment audit] Shorten Stop, child-exit and recovery contracts [skip ci] * [comment audit] Simplify handover and host recovery comments [skip ci] * [comment audit] Trim queue delivery and provider startup commentary [skip ci] * [comment audit] Clarify restart and rewind safety comments [skip ci] * [comment audit] Trim status, Stop and completion commentary [skip ci] * [comment audit] Simplify transcript lookup and visual containment comments [skip ci] * [comment audit] Trim transcript reader, watcher and WSL transport commentary [skip ci] --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../browser-managed-session-profiles.ts | 3 +- .../external-conversation-events.ts | 17 +- .../external-conversation-prompts.ts | 5 +- ...filesystem-watcher-lifecycle-model.test.ts | 18 +- .../ipc/filesystem-watcher-lifecycle-state.ts | 23 +-- .../filesystem-watcher-listener-lifecycle.ts | 10 +- .../ipc/filesystem-watcher-local-capacity.ts | 2 +- .../filesystem-watcher-local-events.test.ts | 5 +- .../ipc/filesystem-watcher-local-events.ts | 53 ++---- .../ipc/filesystem-watcher-local-install.ts | 9 +- .../ipc/filesystem-watcher-local-removal.ts | 24 +-- .../filesystem-watcher-local-subscription.ts | 17 +- ...lesystem-watcher-local-unsubscribe.test.ts | 3 +- ...filesystem-watcher-native-capacity.test.ts | 2 +- src/main/ipc/filesystem-watcher-paths.ts | 6 +- src/main/ipc/filesystem-watcher-real.test.ts | 33 +--- ...ilesystem-watcher-removal-deadline.test.ts | 14 +- ...filesystem-watcher-removal-restore.test.ts | 10 +- src/main/ipc/filesystem-watcher-shutdown.ts | 6 +- src/main/ipc/filesystem-watcher-wsl.test.ts | 4 +- src/main/ipc/filesystem-watcher-wsl.ts | 28 +-- src/main/ipc/filesystem-watcher.ts | 2 - src/main/ipc/filesystem.test.ts | 25 +-- .../filesystem-download-handlers.ts | 1 - .../filesystem-download-promotion.ts | 2 +- .../filesystem-file-content-inspection.ts | 11 +- .../filesystem-git-commit-handlers.ts | 2 +- .../filesystem-git-status-handlers.ts | 8 +- .../filesystem/filesystem-git-url-handlers.ts | 4 +- .../filesystem/filesystem-read-handlers.ts | 2 +- .../filesystem/filesystem-search-handlers.ts | 2 +- .../filesystem-source-control-ai-targets.ts | 6 +- .../filesystem/filesystem-write-handlers.ts | 6 +- .../git-remote/branch-mutation-handlers.ts | 2 +- .../local-disk-write-precondition.test.ts | 10 +- .../local-disk-write-precondition.ts | 43 ++--- .../local-regular-file-read.test.ts | 2 - .../ipc/filesystem/local-regular-file-read.ts | 13 +- src/main/ipc/floating-workspace-directory.ts | 8 +- src/main/ipc/folder-repo-git-upgrade.test.ts | 29 +-- src/main/ipc/folder-repo-git-upgrade.ts | 41 +--- src/main/ipc/github-ipc-module-mocks.ts | 3 +- src/main/ipc/github-ipc-test-harness.ts | 5 +- .../ipc/github-issue-mutation-handlers.ts | 2 +- .../github-issue-source-preference.test.ts | 18 +- src/main/ipc/github-repo-routing.ts | 6 +- src/main/ipc/github-work-item-args.test.ts | 2 +- src/main/ipc/github-work-item-args.ts | 8 +- src/main/ipc/gitlab-issue-handlers.ts | 4 +- src/main/ipc/gitlab-repo-access.ts | 5 +- src/main/ipc/gitlab-work-item-handlers.ts | 14 +- src/main/ipc/gitlab.test.ts | 6 +- src/main/ipc/gitlab.ts | 4 +- .../register-worktree-hook-check-handler.ts | 5 +- .../register-worktree-hook-file-handlers.ts | 1 - ...gister-worktree-hook-inspection-handler.ts | 2 +- .../worktree-hook-execution-host.test.ts | 2 +- src/main/ipc/hosted-review.test.ts | 13 +- src/main/ipc/hosted-review.ts | 11 +- src/main/ipc/jira/cancellable-requests.ts | 14 +- src/main/ipc/jira/register-handlers.ts | 1 - src/main/ipc/jira/user-search.ts | 1 - src/main/ipc/linear/issue-handlers.ts | 7 +- ...ile-access-resolution-network-path.test.ts | 10 +- .../ipc/local-file-access-resolution.test.ts | 4 +- src/main/ipc/local-file-access-resolution.ts | 61 ++---- src/main/ipc/local-watcher-event-observers.ts | 6 +- .../local-worktree-runtime-options.test.ts | 5 +- .../ipc/local-worktree-runtime-options.ts | 10 +- src/main/ipc/localhost-worktree-labels.ts | 10 +- src/main/ipc/macos-keyboard-input-source.ts | 8 +- src/main/ipc/macos-notification-center.ts | 9 +- src/main/ipc/minimax-credentials.test.ts | 4 +- .../mobile-pairing-listener-family.test.ts | 2 +- src/main/ipc/mobile.test.ts | 67 +++---- src/main/ipc/mobile.ts | 57 ++---- .../native-chat-subscribe-lifecycle.test.ts | 6 +- src/main/ipc/native-chat.test.ts | 15 +- src/main/ipc/native-chat.ts | 34 +--- src/main/ipc/native-notification-delivery.ts | 17 +- src/main/ipc/native-notification-lifecycle.ts | 2 +- src/main/ipc/notebook.test.ts | 2 +- src/main/ipc/notebook.ts | 11 +- .../ipc/notification-authorization-status.ts | 24 +-- src/main/ipc/notification-options.ts | 7 +- src/main/ipc/notification-permission-probe.ts | 19 +- src/main/ipc/notification-sound-ipc.ts | 2 +- .../ipc/notifications-delivery-gating.test.ts | 3 +- ...otifications-permission-onboarding.test.ts | 11 +- .../notifications-retention-lifecycle.test.ts | 5 +- .../notifications-subject-retirement.test.ts | 7 +- src/main/ipc/notifications-test-harness.ts | 7 +- ...ifications-workspace-click-binding.test.ts | 3 +- src/main/ipc/notifications.ts | 14 +- src/main/ipc/onboarding.ts | 4 +- src/main/ipc/opencode-go-credentials.test.ts | 2 +- .../ipc/orca-profile-auth-handlers.test.ts | 3 +- src/main/ipc/orca-profiles.test.ts | 6 +- src/main/ipc/orca-profiles.ts | 16 +- .../ipc/parcel-watcher-canary-directory.ts | 3 +- .../parcel-watcher-cancellation-tracker.ts | 3 +- src/main/ipc/parcel-watcher-child-launch.ts | 12 +- src/main/ipc/parcel-watcher-child-registry.ts | 9 +- src/main/ipc/parcel-watcher-child-slot.ts | 3 - .../ipc/parcel-watcher-child-termination.ts | 6 +- src/main/ipc/parcel-watcher-crash-fuse.ts | 13 +- .../ipc/parcel-watcher-delivery-canary.ts | 42 +--- .../ipc/parcel-watcher-delivery-health.ts | 8 +- .../ipc/parcel-watcher-entry-path.test.ts | 3 +- src/main/ipc/parcel-watcher-event-delivery.ts | 3 +- .../ipc/parcel-watcher-host-subscriptions.ts | 3 +- .../ipc/parcel-watcher-in-process-fallback.ts | 12 +- ...parcel-watcher-interrupted-cancellation.ts | 3 +- .../parcel-watcher-pending-cancellation.ts | 6 +- .../ipc/parcel-watcher-process-entry.test.ts | 9 +- src/main/ipc/parcel-watcher-process-entry.ts | 35 +--- src/main/ipc/parcel-watcher-process.test.ts | 15 +- src/main/ipc/parcel-watcher-process.ts | 12 +- .../parcel-watcher-root-path-rewrite.test.ts | 17 +- ...arcel-watcher-shallow-file-subscription.ts | 13 +- .../parcel-watcher-shallow-subscription.ts | 20 +- ...parcel-watcher-supervisor-capacity-wait.ts | 9 +- .../ipc/parcel-watcher-supervisor-message.ts | 6 +- .../parcel-watcher-supervisor-subscribe.ts | 8 +- ...parcel-watcher-unsubscribe-timeout.test.ts | 5 +- src/main/ipc/pet-bundle-import.ts | 9 +- src/main/ipc/pet-bundle-manifest-schema.ts | 6 +- src/main/ipc/pet-bundle-sprite-metadata.ts | 9 +- src/main/ipc/pet-bundle-spritesheet-source.ts | 9 +- src/main/ipc/pet-bundle.test.ts | 8 +- src/main/ipc/pet-bundle.ts | 7 +- src/main/ipc/pet-image-formats.ts | 1 - src/main/ipc/pet-import-size-limits.ts | 4 +- src/main/ipc/pet-storage-paths.ts | 8 +- src/main/ipc/pet-symlink-safe-copy.ts | 2 +- src/main/ipc/pet.ts | 12 +- src/main/ipc/plugins.ts | 21 +- ...ight-agent-detection-no-subprocess.test.ts | 16 +- .../ipc/preflight-agent-detection.test.ts | 20 +- src/main/ipc/preflight-agent-refresh.test.ts | 17 +- src/main/ipc/preflight-command-exec.test.ts | 5 +- src/main/ipc/preflight-command-exec.ts | 28 +-- .../ipc/preflight-host-cli-status.test.ts | 34 +--- src/main/ipc/preflight-local-env.ts | 3 +- src/main/ipc/preflight-remote-ssh.test.ts | 4 +- src/main/ipc/preflight-test-harness.ts | 12 +- .../ipc/preflight-wsl-agent-detection.test.ts | 46 ++--- src/main/ipc/preflight-wsl-agent-detection.ts | 19 +- src/main/ipc/preflight-wsl-command.test.ts | 14 +- src/main/ipc/preflight-wsl-command.ts | 12 +- src/main/ipc/preflight.ts | 2 - src/main/ipc/pty-absent-pane-respawn.test.ts | 3 +- .../ipc/pty-agent-launch-pane-attach.test.ts | 5 +- src/main/ipc/pty-agent-pane-launch.test.ts | 3 +- .../ipc/pty-applied-size-reporting.test.ts | 9 +- .../pty-attach-view-joins-main-start.test.ts | 5 +- .../ipc/pty-buffer-snapshot-dispatch.test.ts | 8 +- .../ipc/pty-codex-account-attribution.test.ts | 10 +- .../pty-controller-ownership-routing.test.ts | 10 +- .../pty-controller-process-inventory.test.ts | 8 +- .../pty-daemon-controller-teardown.test.ts | 15 +- .../pty-daemon-spawn-agent-home-env.test.ts | 24 +-- .../ipc/pty-daemon-spawn-codex-auth.test.ts | 4 +- .../pty-daemon-spawn-session-identity.test.ts | 20 +- .../ipc/pty-daemon-spawn-wsl-runtime.test.ts | 17 +- src/main/ipc/pty-dead-owner-respawn.test.ts | 14 +- src/main/ipc/pty-delivery-health-heal.test.ts | 3 +- ...y-dispatcher-handshake-osc-answers.test.ts | 15 +- .../ipc/pty-global-renderer-credit.test.ts | 2 +- src/main/ipc/pty-hidden-at-spawn-mark.test.ts | 12 +- src/main/ipc/pty-hidden-delivery-gate.test.ts | 12 +- src/main/ipc/pty-hidden-delivery-gate.ts | 64 ++----- src/main/ipc/pty-host-bindings.ts | 28 +-- .../ipc/pty-ipc-daemon-provider-fixtures.ts | 2 - .../ipc/pty-ipc-hidden-delivery-gate.test.ts | 23 +-- .../ipc/pty-ipc-hidden-restore-ack.test.ts | 23 +-- src/main/ipc/pty-ipc-listener-accessors.ts | 3 +- src/main/ipc/pty-ipc-mock-registry.ts | 15 +- src/main/ipc/pty-ipc-process-env-scope.ts | 7 +- src/main/ipc/pty-ipc-provider-fixtures.ts | 4 - src/main/ipc/pty-ipc-spawn-drivers.ts | 11 +- src/main/ipc/pty-ipc-suite-environment.ts | 34 ++-- src/main/ipc/pty-ipc-test-constants.ts | 12 +- src/main/ipc/pty-ipc-test-harness.ts | 5 - src/main/ipc/pty-ipc-test-surface.ts | 6 +- .../pty-launch-parity-caller.test-cases.ts | 19 +- .../ipc/pty-launch-parity-host.test-cases.ts | 23 +-- .../ipc/pty-launch-parity.test-fixture.ts | 20 +- src/main/ipc/pty-launch-parity.test.ts | 34 +--- src/main/ipc/pty-launch-race-parity.test.ts | 17 +- src/main/ipc/pty-launch-race-test-fixture.ts | 4 - .../pty-listener-teardown-and-orphans.test.ts | 25 +-- .../pty-login-shell-startup-commands.test.ts | 3 +- src/main/ipc/pty-management.test.ts | 64 ++----- src/main/ipc/pty-management.ts | 33 ++-- ...nted-cold-restore-ack-layout-owner.test.ts | 1 - .../ipc/pty-pane-claim-arbitration.test.ts | 2 +- .../ipc/pty-pane-materialization-race.test.ts | 1 - .../ipc/pty-pane-owner-swap.test-fixture.ts | 5 +- .../pty-pane-reservation-settlement.test.ts | 6 +- src/main/ipc/pty-pane-restart-replace.test.ts | 3 +- .../ipc/pty-pending-data-drain-contract.ts | 3 +- src/main/ipc/pty-pending-data-drain-queue.ts | 2 +- src/main/ipc/pty-pending-output-cap.test.ts | 20 +- .../pty-persisted-incarnation-repair.test.ts | 2 - .../ipc/pty-producer-flow-control.test.ts | 7 +- src/main/ipc/pty-producer-flow-control.ts | 24 +-- .../ipc/pty-renderer-inflight-credit.test.ts | 15 +- ...-renderer-lifecycle-delivery-reset.test.ts | 26 +-- .../ipc/pty-renderer-liveness-guard.test.ts | 19 +- .../ipc/pty-restore-record-seeding.test.ts | 23 +-- .../pty-runtime-hidden-at-spawn-mark.test.ts | 2 - .../ipc/pty-runtime-kill-and-exit.test.ts | 2 +- .../pty-serializer-settlement-mapping.test.ts | 2 +- ...pty-session-liveness-and-ownership.test.ts | 9 +- .../pty-spawn-codex-home-unavailable.test.ts | 21 +- ...pty-spawn-cwd-fallback-hung-folder.test.ts | 2 +- src/main/ipc/pty-spawn-cwd-fallback.test.ts | 12 +- .../ipc/pty-spawn-env-agent-overlays.test.ts | 6 +- .../pty-spawn-env-codex-home-routing.test.ts | 5 +- ...-spawn-env-codex-resume-provenance.test.ts | 33 +--- .../ipc/pty-spawn-env-terminal-basics.test.ts | 10 +- .../pty-spawn-runtime-handle-binding.test.ts | 8 +- src/main/ipc/pty-spawn-timing.ts | 8 +- src/main/ipc/pty-ssh-stop-verdict.test.ts | 4 +- .../pty-startup-barrier-and-listing.test.ts | 1 - .../pty-startup-swap-window-presence.test.ts | 34 +--- .../ipc/pty-windows-shell-selection.test.ts | 6 +- src/main/ipc/pty-write-ipc-validation.test.ts | 14 +- .../ipc/pty/configured-terminal-shell-args.ts | 3 +- src/main/ipc/pty/delivery/accept.ts | 4 +- src/main/ipc/pty/delivery/accounting.ts | 8 +- .../ipc/pty/delivery/attached-pty-size.ts | 17 +- src/main/ipc/pty/delivery/debug-snapshot.ts | 6 +- src/main/ipc/pty/delivery/debug.ts | 18 +- src/main/ipc/pty/delivery/exit.ts | 10 +- src/main/ipc/pty/delivery/flush.ts | 17 +- .../delivery/interactive-typing-burst.test.ts | 29 +-- src/main/ipc/pty/delivery/interactive.ts | 2 +- src/main/ipc/pty/delivery/lifecycle-reset.ts | 5 +- src/main/ipc/pty/delivery/payload.ts | 4 +- src/main/ipc/pty/delivery/pending.ts | 10 +- src/main/ipc/pty/delivery/producer-sync.ts | 5 +- src/main/ipc/pty/delivery/visibility-state.ts | 12 +- src/main/ipc/pty/delivery/wire-session.ts | 10 +- src/main/ipc/pty/host-env/assembly.ts | 44 ++--- src/main/ipc/pty/host-env/codex-home.ts | 20 +- .../codex-hook-reconcile-spawn.test.ts | 3 +- src/main/ipc/pty/host-env/codex-resume.ts | 11 +- .../pty/host-env/codex-terminal-clipboard.ts | 16 +- .../ipc/pty/host-env/fresh-spawn-routing.ts | 2 +- .../opencode-hook-installation.test.ts | 4 +- src/main/ipc/pty/host-env/path.ts | 5 +- src/main/ipc/pty/host-env/pi-agent.ts | 12 +- src/main/ipc/pty/host-env/spawn-cwd.ts | 9 +- src/main/ipc/pty/host-env/spawn-env-keys.ts | 6 +- src/main/ipc/pty/host-env/types.ts | 15 +- .../ipc/pty/ipc/claude-diff-panel-notice.ts | 8 +- .../ipc/pty/ipc/claude-old-terminal.test.ts | 3 - src/main/ipc/pty/ipc/claude-old-terminal.ts | 7 +- .../ipc/pty/ipc/closed-pane-spawn-refusal.ts | 8 +- .../ipc/pty/ipc/codex-shared-server.test.ts | 1 - src/main/ipc/pty/ipc/codex-shared-server.ts | 11 +- src/main/ipc/pty/ipc/inspect.ts | 43 ++--- .../pty/ipc/painted-cold-restore-ack.test.ts | 3 +- .../ipc/pty/ipc/painted-cold-restore-ack.ts | 7 +- src/main/ipc/pty/ipc/renderer-kill.ts | 22 +-- src/main/ipc/pty/ipc/resize-visibility.ts | 48 +++-- src/main/ipc/pty/ipc/serialize-buffer.ts | 7 +- src/main/ipc/pty/ipc/session-listing.ts | 6 +- src/main/ipc/pty/ipc/snapshot.ts | 7 +- .../ipc/pty/ipc/spawn-absent-binding-swap.ts | 7 +- .../ipc/pty/ipc/spawn-agent-launch-end.ts | 16 +- src/main/ipc/pty/ipc/spawn-agent-launch.ts | 6 +- src/main/ipc/pty/ipc/spawn-begin.ts | 21 +- .../pty/ipc/spawn-commit-run-facts.test.ts | 2 +- src/main/ipc/pty/ipc/spawn-commit.ts | 8 +- src/main/ipc/pty/ipc/spawn-env.ts | 8 +- src/main/ipc/pty/ipc/spawn-execute.ts | 6 +- .../ipc/spawn-options-codex-isolation.test.ts | 3 +- .../spawn-options-terminal-shell-args.test.ts | 3 +- src/main/ipc/pty/ipc/spawn-options.ts | 5 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 7 +- ...sh-target-materialization-real-git.test.ts | 25 +-- .../spawn-push-target-materialization.test.ts | 5 +- .../ipc/spawn-push-target-materialization.ts | 13 +- src/main/ipc/pty/ipc/spawn-qoder-command.ts | 2 +- .../pty/ipc/spawn-reattach-size-cache.test.ts | 6 +- src/main/ipc/pty/ipc/spawn-run.ts | 15 +- src/main/ipc/pty/ipc/spawn-state.ts | 2 - src/main/ipc/pty/ipc/spawn-types.ts | 16 +- src/main/ipc/pty/ipc/spawn.ts | 1 - .../pty/ipc/terminal-view-attributes-ipc.ts | 5 +- .../pty/ipc/write-input-chunk-yield.test.ts | 3 +- src/main/ipc/pty/ipc/write-input.ts | 8 +- src/main/ipc/pty/ipc/write.ts | 4 +- src/main/ipc/pty/kill-all.ts | 4 +- src/main/ipc/pty/pane/absent-pane-owner.ts | 11 +- .../pty/pane/agent-launch-pane-evidence.ts | 3 +- src/main/ipc/pty/pane/agent-session-owners.ts | 20 +- src/main/ipc/pty/pane/attach-only-view.ts | 16 +- src/main/ipc/pty/pane/join-pane-spawn.test.ts | 1 - src/main/ipc/pty/pane/key-state.ts | 9 +- .../ipc/pty/pane/pane-owner-replacement.ts | 5 +- src/main/ipc/pty/pane/pane-spawn-operation.ts | 2 +- .../ipc/pty/pane/resume-launch-identity.ts | 16 +- src/main/ipc/pty/pane/serializer-state.ts | 11 +- .../ipc/pty/pane/spawn-registration.test.ts | 3 +- src/main/ipc/pty/pane/spawn-registration.ts | 13 +- src/main/ipc/pty/pane/spawn-reservation.ts | 9 +- ...ble-pane-absence-death-certificate.test.ts | 8 +- .../pane/stable-pane-absence-respawn.test.ts | 8 +- src/main/ipc/pty/pane/stable-pane-attach.ts | 6 +- .../ipc/pty/pane/terminal-spawn-restore.ts | 3 +- src/main/ipc/pty/provider/bind-listeners.ts | 16 +- .../ipc/pty/provider/listener-lifecycle.ts | 17 +- src/main/ipc/pty/provider/liveness.ts | 12 +- src/main/ipc/pty/provider/local-configure.ts | 5 +- .../pty/provider/local-provider-rebound.ts | 3 +- src/main/ipc/pty/provider/ownership-state.ts | 3 +- .../provider/registry-host-routing.test.ts | 4 +- src/main/ipc/pty/provider/registry.ts | 23 +-- src/main/ipc/pty/provider/state-cleanup.ts | 35 ++-- src/main/ipc/pty/pty-first-size.ts | 14 +- src/main/ipc/pty/register-handlers.ts | 27 ++- src/main/ipc/pty/runtime/controller.ts | 9 +- .../kill-legacy-relay-bridged-exit.test.ts | 1 - .../pty/runtime/kill-same-id-respawn.test.ts | 3 +- src/main/ipc/pty/runtime/kill.ts | 51 ++--- src/main/ipc/pty/runtime/operations.ts | 30 ++- .../pty/runtime/queried-host-kinds.test.ts | 7 +- .../pty/runtime/spawn-commit-pty-size.test.ts | 3 +- .../pty/runtime/spawn-commit-response.test.ts | 1 - .../ipc/pty/runtime/spawn-commit-response.ts | 8 +- .../runtime/spawn-commit-run-facts.test.ts | 2 +- src/main/ipc/pty/runtime/spawn-commit.ts | 2 +- src/main/ipc/pty/runtime/spawn-early.ts | 3 +- src/main/ipc/pty/runtime/spawn-execute.ts | 19 +- .../ipc/pty/runtime/spawn-hidden-delivery.ts | 12 +- src/main/ipc/pty/runtime/spawn-options.ts | 17 +- .../spawn-preflight-requested-shell.test.ts | 7 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 7 +- src/main/ipc/pty/runtime/spawn-state.ts | 4 +- src/main/ipc/pty/runtime/spawn.ts | 7 +- src/main/ipc/pty/session.ts | 4 +- src/main/ipc/pty/spawn-commit-assembly.ts | 19 +- src/main/ipc/pty/spawn-commit-publication.ts | 17 +- src/main/ipc/pty/spawn-execute-failure.ts | 6 +- src/main/ipc/pty/spawn-host-env.ts | 21 +- ...pawn-options-agent-workspace-trust.test.ts | 1 - src/main/ipc/pty/spawn-options-assembly.ts | 25 +-- src/main/ipc/pty/spawn-pane-env.ts | 5 +- src/main/ipc/pty/spawn-startup-target.ts | 7 +- src/main/ipc/readdir-error-diagnostics.ts | 15 +- src/main/ipc/rebinding-file-watch.ts | 31 +-- .../register-core-handlers.test.ts | 10 +- .../register-core-handlers.ts | 13 +- .../ipc/registered-worktree-root-owner.ts | 3 +- .../ipc/registered-worktree-root-probes.ts | 6 +- .../registered-worktree-root-relist-policy.ts | 23 +-- ...gistered-worktree-root-wsl-runtime.test.ts | 4 +- .../ipc/registered-worktree-roots-cache.ts | 17 +- ...worktree-roots-scoped-invalidation.test.ts | 9 +- ...ered-worktree-roots-scoped-invalidation.ts | 15 +- src/main/ipc/remote-filesystem-owner.ts | 9 +- src/main/ipc/renderer-lifetime-abort.test.ts | 4 +- src/main/ipc/renderer-lifetime-abort.ts | 16 +- .../ipc/renderer-shutdown-checkpoint.test.ts | 4 +- src/main/ipc/renderer-shutdown-checkpoint.ts | 18 +- .../ipc/repos-add-linked-worktree.test.ts | 11 +- src/main/ipc/repos-create.test.ts | 64 +------ .../ipc/repos-local-clone-lifecycle.test.ts | 9 +- src/main/ipc/repos-nested-import.test.ts | 1 - .../ipc/repos-remote-base-ref-queries.test.ts | 2 - .../ipc/repos-remote-client-events.test.ts | 8 +- src/main/ipc/repos-remote-test-harness.ts | 9 +- src/main/ipc/repos.ts | 2 +- src/main/ipc/repos/base-ref-query-handlers.ts | 10 +- .../ipc/repos/folder-workspace-handlers.ts | 2 +- src/main/ipc/repos/local-repo-registration.ts | 9 +- .../ipc/repos/nested-repo-import-handler.ts | 3 +- src/main/ipc/repos/nested-repo-scan-ipc.ts | 2 +- src/main/ipc/repos/project-group-handlers.ts | 2 +- .../ipc/repos/project-host-setup-handlers.ts | 6 +- src/main/ipc/repos/repo-added-telemetry.ts | 8 +- src/main/ipc/repos/repo-catalog-handlers.ts | 13 +- src/main/ipc/repos/repo-clone-lifecycle.ts | 31 ++- .../repo-creation-git-availability.test.ts | 5 +- src/main/ipc/repos/repo-creation-handlers.ts | 40 ++-- .../ipc/repos/repo-folder-picker-handlers.ts | 4 +- src/main/ipc/repos/repo-ipc-arg-schemas.ts | 2 +- src/main/ipc/repos/repo-update-handler.ts | 12 +- .../ipc/repos/repos-changed-notification.ts | 6 +- src/main/ipc/repos/sparse-preset-handlers.ts | 3 - .../runtime-agent-launch-capability.test.ts | 15 +- .../managed-orcad-actions.ts | 10 +- .../orcad-delta-move-handlers.test.ts | 2 +- ...orcad-runtime-maintenance-handlers.test.ts | 1 - .../orcad-runtime-maintenance-handlers.ts | 2 +- ...onment-browser-client-host-handler.test.ts | 6 +- ...me-environment-cli-removal-routing.test.ts | 6 +- ...ntime-environment-connectivity-handlers.ts | 6 +- .../runtime-environment-host-dispatch.test.ts | 4 +- .../runtime-environment-host-key.ts | 6 +- .../runtime-environment-host-service-ops.ts | 5 +- ...nvironment-managed-host-disconnect.test.ts | 1 - ...ime-environment-managed-pairing-refusal.ts | 9 +- ...runtime-environment-managed-tunnel.test.ts | 5 +- .../runtime-environment-managed-tunnel.ts | 24 +-- .../runtime-environment-manual-disconnect.ts | 2 +- .../runtime-environment-mobile-relay-hosts.ts | 11 +- ...me-environment-pairing-refusal-registry.ts | 7 +- ...untime-environment-pairing-verification.ts | 14 +- .../runtime-environment-preference.test.ts | 2 +- .../runtime-environment-preference.ts | 6 +- .../runtime-environment-recovery-handler.ts | 2 +- .../runtime-environment-removal-cleanup.ts | 7 +- ...runtime-environment-request-connections.ts | 12 +- ...nvironment-retry-connections-probe.test.ts | 1 - ...runtime-environment-runtime-pinned-send.ts | 5 +- .../runtime-environment-session-reconcile.ts | 13 +- ...environment-session-tabs-delta-decoding.ts | 7 +- ...ent-session-tabs-delta.integration.test.ts | 2 +- ...time-environment-shared-control-answers.ts | 7 +- .../runtime-environment-status-owner.ts | 9 +- .../runtime-environment-status-probe.ts | 7 +- ...ntime-environment-subscription-handlers.ts | 9 +- ...-environment-subscription-identity.test.ts | 1 - ...ment-subscription-layout-recording.test.ts | 3 +- .../runtime-environment-support-routing.ts | 6 +- ...t-transport-routing-tailscale-hint.test.ts | 7 +- .../runtime-environment-transport-routing.ts | 12 +- .../runtime-environment-unreachable-hint.ts | 3 +- .../runtime-environment-usage-record.ts | 11 +- ...me-environment-verified-pairing-publish.ts | 12 +- .../runtime-environments-call-routing.test.ts | 4 +- .../runtime-environments-ipc-test-harness.ts | 1 - .../runtime-environments-pairing.test.ts | 5 +- ...environments-subscription-teardown.test.ts | 28 +-- .../runtime-environments.ts | 20 +- src/main/ipc/runtime-import-limits.ts | 4 +- src/main/ipc/runtime-ssh-access-handlers.ts | 2 +- .../ipc/runtime-subscribe-lifecycle.test.ts | 1 - .../ipc/runtime-upload-file-stream.test.ts | 12 +- src/main/ipc/runtime-upload-file-stream.ts | 61 ++---- .../runtime-upload-slice-boundaries.test.ts | 8 +- src/main/ipc/runtime-upload-temp-sweep.ts | 12 +- .../ipc/runtime-watcher-disposal-owners.ts | 2 +- .../ipc/runtime-watcher-pending-assignment.ts | 3 +- .../ipc/runtime-watcher-pool-lifecycle.ts | 2 +- src/main/ipc/runtime-watcher-process-pool.ts | 17 +- src/main/ipc/runtime-window-tab-start.test.ts | 3 +- src/main/ipc/runtime.ts | 35 +--- src/main/ipc/session-window-view-save.test.ts | 3 +- src/main/ipc/session.ts | 21 +- src/main/ipc/settings.test.ts | 5 +- src/main/ipc/settings.ts | 39 +--- .../ipc/shallow-watch-delivery-probe.test.ts | 3 +- src/main/ipc/shallow-watch-delivery-probe.ts | 17 +- src/main/ipc/shell-editor-ssh-host.ts | 6 +- src/main/ipc/shell-owned-path-target.ts | 9 +- src/main/ipc/shell-wsl-owner.test.ts | 2 +- src/main/ipc/shell.test.ts | 3 +- src/main/ipc/shell.ts | 28 +-- src/main/ipc/single-flight-promise.ts | 5 +- src/main/ipc/skill-delete/handlers.ts | 10 +- .../source-control-ai-linked-issue.test.ts | 5 +- .../ipc/source-control-ai-linked-issue.ts | 29 +-- src/main/ipc/sparse-preset-attribution.ts | 7 +- src/main/ipc/speech.ts | 14 +- .../ipc/ssh/plain-ssh-terminal-handlers.ts | 4 +- src/main/ipc/ssh/ssh-app-shutdown.test.ts | 2 +- src/main/ipc/ssh/ssh-browse.test.ts | 48 +---- .../ipc/ssh/ssh-connect-attempt-registry.ts | 11 +- .../ssh-connect-decision-cancellation.test.ts | 1 - src/main/ipc/ssh/ssh-connect-flow.ts | 21 +- src/main/ipc/ssh/ssh-connection-handlers.ts | 11 +- .../ipc/ssh/ssh-connection-state-callbacks.ts | 5 +- .../ipc/ssh/ssh-credential-cancel.test.ts | 3 - .../ipc/ssh/ssh-host-launch-session-note.ts | 12 +- src/main/ipc/ssh/ssh-host-server-connect.ts | 13 +- .../ssh-host-server-on-connect-wiring.test.ts | 2 - .../ssh/ssh-host-server-on-connect-wiring.ts | 10 +- src/main/ipc/ssh/ssh-host-sleep-reconnect.ts | 11 +- src/main/ipc/ssh/ssh-host-user-disconnect.ts | 21 +- src/main/ipc/ssh/ssh-ipc-context.ts | 2 - src/main/ipc/ssh/ssh-ipc-mock-shapes.ts | 6 +- src/main/ipc/ssh/ssh-ipc-module-mocks.ts | 3 +- src/main/ipc/ssh/ssh-ipc-test-harness.ts | 3 - ...anaged-host-port-forward-lifecycle.test.ts | 2 +- .../ssh/ssh-managed-port-forward-restore.ts | 5 +- ...sh-managed-server-unlink-broadcast.test.ts | 2 +- src/main/ipc/ssh/ssh-passphrase.ts | 4 +- .../ipc/ssh/ssh-port-forward-handlers.test.ts | 1 - src/main/ipc/ssh/ssh-port-forward-handlers.ts | 6 +- .../ipc/ssh/ssh-port-forward-persistence.ts | 11 +- src/main/ipc/ssh/ssh-renderer-broadcast.ts | 11 +- src/main/ipc/ssh/ssh-session-teardown.ts | 27 +-- src/main/ipc/ssh/ssh-shutdown-drain.ts | 25 +-- .../ipc/ssh/ssh-sign-in-requirement.test.ts | 2 - src/main/ipc/ssh/ssh-sign-in-requirement.ts | 8 +- src/main/ipc/ssh/ssh-target-crud-handlers.ts | 19 +- .../ipc/ssh/ssh-target-lifecycle-queue.ts | 2 +- src/main/ipc/ssh/ssh.ts | 4 +- .../ipc/startup-notification-registration.ts | 15 +- src/main/ipc/telemetry.test.ts | 33 +--- src/main/ipc/telemetry.ts | 47 ++--- .../terminal-preview-output-stream.test.ts | 7 +- .../ipc/terminal-preview-output-stream.ts | 17 +- src/main/ipc/terminal-preview.test.ts | 11 +- src/main/ipc/terminal-preview.ts | 28 +-- .../ipc/terminal-render-desync-evidence.ts | 6 +- ...rminal-startup-color-query-replies.test.ts | 4 +- .../terminal-startup-color-query-replies.ts | 11 +- src/main/ipc/ui.ts | 12 +- src/main/ipc/usage-provider-handlers.ts | 2 +- .../watched-worktree-catalog-notification.ts | 2 +- src/main/ipc/watcher-aliased-root-fixture.ts | 11 +- .../watcher-event-root-path-rewrite.test.ts | 9 +- .../ipc/watcher-event-root-path-rewrite.ts | 57 +----- src/main/ipc/watcher-removal-drain.ts | 15 +- src/main/ipc/watcher-removal-gate.test.ts | 6 +- src/main/ipc/watcher-removal-gate.ts | 19 +- .../ipc/workspace-cleanup-activity.test.ts | 7 +- src/main/ipc/workspace-cleanup-activity.ts | 31 +-- .../ipc/workspace-cleanup-broad-scan.test.ts | 2 - src/main/ipc/workspace-cleanup-candidate.ts | 13 +- .../ipc/workspace-cleanup-disconnected-ssh.ts | 3 +- ...ace-cleanup-execution-host-routing.test.ts | 6 +- src/main/ipc/workspace-cleanup-git-route.ts | 30 +-- .../ipc/workspace-cleanup-scan-eligibility.ts | 12 +- .../ipc/workspace-cleanup-scan-primitives.ts | 6 +- src/main/ipc/workspace-cleanup-scan.ts | 23 +-- .../workspace-cleanup-snapshot-ipc.test.ts | 6 +- .../ipc/workspace-cleanup-worktree-listing.ts | 4 +- src/main/ipc/workspace-cleanup.test.ts | 2 +- src/main/ipc/workspace-cleanup.ts | 18 +- .../ipc/workspace-create-error-classifier.ts | 25 +-- src/main/ipc/workspace-space.ts | 9 +- src/main/ipc/worktree-apfs-clone.ts | 34 +--- ...rktree-base-directory-event-filter.test.ts | 15 +- .../worktree-base-directory-event-filter.ts | 49 ++--- .../worktree-base-directory-marker-poller.ts | 42 ++-- ...ktree-base-directory-notifications.test.ts | 3 +- .../worktree-base-directory-notifications.ts | 10 +- ...ase-directory-poller-marker-fanout.test.ts | 9 +- .../worktree-base-directory-poller.test.ts | 28 +-- .../ipc/worktree-base-directory-poller.ts | 21 +- .../worktree-base-directory-watch-events.ts | 5 +- ...ktree-base-directory-watch-targets.test.ts | 3 +- .../worktree-base-directory-watch-targets.ts | 7 +- .../worktree-base-directory-watcher.test.ts | 17 +- .../ipc/worktree-base-directory-watcher.ts | 12 +- src/main/ipc/worktree-branch-name.ts | 23 +-- src/main/ipc/worktree-create-lineage.test.ts | 2 +- src/main/ipc/worktree-display-name.ts | 7 +- src/main/ipc/worktree-folder-rename-target.ts | 20 +- .../ipc/worktree-git-common-entry-snapshot.ts | 20 +- .../ipc/worktree-git-common-metadata-files.ts | 4 +- .../ipc/worktree-git-common-narrow-watch.ts | 34 +--- ...worktree-git-common-native-upgrade-poll.ts | 3 +- .../ipc/worktree-git-common-polling.test.ts | 24 +-- src/main/ipc/worktree-git-common-polling.ts | 50 ++--- .../ipc/worktree-git-common-primary-watch.ts | 24 +-- .../ipc/worktree-git-common-snapshot-diff.ts | 4 +- ...orktree-git-common-watch-reconciliation.ts | 13 +- .../ipc/worktree-git-common-watch.test.ts | 45 ++--- src/main/ipc/worktree-git-common-watch.ts | 7 +- ...ee-git-status-attribution-real-git.test.ts | 6 +- src/main/ipc/worktree-git-status-ref-watch.ts | 5 +- .../worktree-head-identity-cached-paths.ts | 6 +- ...e-head-identity-reader-concurrency.test.ts | 2 - ...e-head-identity-reader-incremental.test.ts | 47 ++--- .../ipc/worktree-head-identity-reader.test.ts | 3 +- src/main/ipc/worktree-head-identity-reader.ts | 80 ++------ .../worktree-head-identity-refresh.test.ts | 41 +--- .../ipc/worktree-head-identity-refresh.ts | 70 ++----- src/main/ipc/worktree-head-identity-scope.ts | 11 +- .../ipc/worktree-include-copy-budget.test.ts | 35 +--- src/main/ipc/worktree-include-copy-budget.ts | 66 ++----- src/main/ipc/worktree-logic-wsl.test.ts | 13 +- src/main/ipc/worktree-logic.test.ts | 8 +- src/main/ipc/worktree-logic.ts | 87 ++------- src/main/ipc/worktree-metadata-merge.ts | 5 - .../ipc/worktree-push-target-cleanup.test.ts | 9 +- src/main/ipc/worktree-push-target-cleanup.ts | 30 +-- ...ush-target-reconciliation-real-git.test.ts | 19 +- ...orktree-push-target-reconciliation.test.ts | 10 +- .../worktree-push-target-reconciliation.ts | 26 +-- ...tree-push-target-refspec-migration.test.ts | 17 +- .../worktree-push-target-refspec-migration.ts | 77 ++------ ...ktree-push-target-refspec-real-git.test.ts | 44 +---- .../worktree-push-target-remote-scan.test.ts | 7 +- .../ipc/worktree-push-target-setup.test.ts | 13 +- src/main/ipc/worktree-push-target-setup.ts | 63 ++---- ...remote-push-target-materialization.test.ts | 29 +-- src/main/ipc/worktree-remote.ts | 179 +++++------------- src/main/ipc/worktree-symlinks.test.ts | 44 +---- src/main/ipc/worktree-symlinks.ts | 109 ++--------- src/main/ipc/worktree-watcher-removal.ts | 14 +- ...thoritative-local-metadata-pruning.test.ts | 11 +- ...ktrees-automation-create-terminals.test.ts | 1 - .../ipc/worktrees-background-removal.test.ts | 5 +- .../worktrees-canonical-ssh-listing.test.ts | 2 - .../ipc/worktrees-create-event-timing.test.ts | 4 +- ...ktrees-create-metadata-persistence.test.ts | 20 +- .../ipc/worktrees-create-registry.test.ts | 2 - .../ipc/worktrees-delete-pty-teardown.test.ts | 15 +- .../ipc/worktrees-detected-scan-cache.test.ts | 13 +- ...ktrees-discovery-metadata-backfill.test.ts | 6 +- ...worktrees-existing-branch-checkout.test.ts | 4 +- src/main/ipc/worktrees-failed-removal.test.ts | 11 +- src/main/ipc/worktrees-forget-local.test.ts | 27 +-- .../worktrees-issue-command-overrides.test.ts | 7 +- .../ipc/worktrees-lineage-hydration.test.ts | 1 - ...orktrees-local-base-ref-resolution.test.ts | 2 - .../ipc/worktrees-local-create-flow.test.ts | 13 +- ...ees-mutation-scan-generation-order.test.ts | 12 +- ...worktrees-orphan-directory-cleanup.test.ts | 1 - .../ipc/worktrees-removal-recovery.test.ts | 18 +- .../worktrees-remove-archive-hooks.test.ts | 12 +- ...trees-setup-launch-sparse-checkout.test.ts | 3 +- .../ipc/worktrees-ssh-pr-head-fetch.test.ts | 1 - ...orktrees-ssh-repo-owner-resolution.test.ts | 15 +- src/main/ipc/worktrees-test-fixtures.ts | 1 - src/main/ipc/worktrees-test-harness.ts | 9 +- src/main/ipc/worktrees-test-ipc-surface.ts | 7 +- src/main/ipc/worktrees-test-module-mocks.ts | 13 +- src/main/ipc/worktrees-test-runtime-stub.ts | 6 +- src/main/ipc/worktrees-windows.test.ts | 12 +- src/main/ipc/worktrees-wsl-move-fence.test.ts | 2 +- .../ipc/worktrees-wsl-runtime-routing.test.ts | 23 +-- src/main/ipc/worktrees.ts | 4 +- .../create/register-review-base-handlers.ts | 5 +- .../register-worktree-create-handlers.ts | 18 +- .../register-worktree-prefetch-handler.ts | 2 +- src/main/ipc/worktrees/ipc-context-schemas.ts | 3 +- ...itative-local-worktree-metadata-pruning.ts | 10 +- ...d-provider-listing-catalog-version.test.ts | 6 +- ...tected-provider-listing-meta-index.test.ts | 5 +- ...ed-provider-listing-overtaken-scan.test.ts | 7 +- .../listing/detected-provider-listing.ts | 17 +- .../detected-scan-failure-authority.test.ts | 7 +- .../detected-worktree-classification.test.ts | 12 +- .../listing/detected-worktree-scan-cache.ts | 39 ++-- ...etected-worktree-scan-hygiene-gate.test.ts | 5 +- .../detected-worktree-scan-superseded.test.ts | 6 +- .../listing/folder-workspace-catalog.ts | 4 +- .../worktrees/listing/overtaken-scan-rerun.ts | 17 +- .../listing/register-host-catalog-handlers.ts | 26 +-- ...ster-sparse-checkout-cache-invalidation.ts | 6 +- .../listing/ssh-worktree-fallback.ts | 14 +- .../listing/worktree-discovery-metadata.ts | 8 +- .../listing/worktree-listing-diagnostics.ts | 5 +- .../register-worktree-metadata-handlers.ts | 8 +- .../removal/execute-worktree-removal.ts | 8 +- .../register-worktree-forget-handlers.ts | 11 +- .../register-worktree-removal-handlers.ts | 12 +- .../removal/remove-folder-workspace.ts | 10 +- .../remove-registered-local-worktree.ts | 24 +-- .../removal/remove-unregistered-worktree.ts | 4 +- .../retry-failed-local-worktree-removal.ts | 5 +- .../removal/worktree-removal-coordinator.ts | 6 +- .../removal/worktree-removal-ownership.ts | 23 +-- src/main/ipc/worktrees/repo-host-ownership.ts | 27 +-- .../sleep/register-worktree-sleep-handlers.ts | 5 +- .../ipc/worktrees/worktree-ipc-context.ts | 4 +- .../ipc/wsl-guest-windows-drive-path.test.ts | 1 - src/main/ipc/wsl-guest-windows-drive-path.ts | 18 +- src/main/ipc/wsl-host-gate-off.test.ts | 1 - src/main/ipc/wsl-host-handlers.ts | 9 +- src/main/ipc/wsl-move-handlers.test.ts | 2 +- src/main/ipc/wsl-move-handlers.ts | 5 - src/main/ipc/zcode-plan-credentials.test.ts | 2 +- src/main/lib/abort-signal-with-timeout.ts | 1 - src/main/lib/html-to-pdf.ts | 24 +-- .../lib/unread-response-body.test-fixtures.ts | 2 - src/main/lib/unread-response-body.ts | 6 +- src/main/linear/client.test.ts | 11 +- src/main/linear/client.ts | 21 +- src/main/linear/issue-context/client.test.ts | 3 +- src/main/linear/issue-context/errors.ts | 2 +- .../linear/issue-context/includes.test.ts | 6 +- src/main/linear/issue-context/includes.ts | 6 +- src/main/linear/issue-context/pagination.ts | 3 +- .../linear/issue-context/read-context.test.ts | 3 +- src/main/linear/issue-context/read-context.ts | 3 +- src/main/linear/issue-context/relations.ts | 3 +- src/main/linear/issue-list-filter.ts | 11 +- src/main/linear/issue-relation-mutation.ts | 2 +- src/main/linear/issues.test.ts | 1 - src/main/linear/linear-issue-comments.ts | 2 - src/main/linear/linear-issue-listing.ts | 8 +- src/main/linear/linear-issue-lookups.ts | 4 +- src/main/linear/linear-issue-mutations.ts | 5 +- .../linear/linear-issue-query-documents.ts | 6 +- src/main/linear/linear-issue-query-support.ts | 9 +- src/main/linear/linear-issue-write-support.ts | 3 +- src/main/linear/linear-legacy-viewer-store.ts | 5 +- .../linear/linear-project-issue-queries.ts | 3 +- src/main/linear/linear-request-concurrency.ts | 1 - src/main/linear/linear-sdk.ts | 15 +- src/main/linear/linear-token-store.ts | 5 +- src/main/linear/linear-workspace-registry.ts | 5 +- src/main/linear/mappers.ts | 14 +- src/main/linear/mcp-issue-list-cursor.ts | 5 +- src/main/linear/mcp-issue-list.test.ts | 1 - src/main/linear/mcp-issue-list.ts | 10 +- src/main/macos-keychain/generic-password.ts | 6 +- .../credential-capture.ts | 4 +- .../launch-environment.ts | 2 +- .../managed-data-accounts/profile-removal.ts | 4 +- .../managed-data-accounts/service.test.ts | 2 +- src/main/managed-data-accounts/service.ts | 4 +- .../orcad-activation-crash-recovery.test.ts | 21 +- .../orcad-activation-fence-generation.test.ts | 14 +- .../orcad-activation-fence-hold.test.ts | 2 - .../activation/orcad-activation-fence-hold.ts | 15 +- .../orcad-activation-fence-scope.ts | 18 +- .../activation/orcad-activation-gate.test.ts | 2 +- .../activation/orcad-activation-gate.ts | 38 +--- .../orcad-activation-host-test-harness.ts | 23 +-- .../activation/orcad-activation-lock.ts | 64 ++----- .../orcad-activation-outcome-log.ts | 5 +- .../orcad-activation-record-store.ts | 13 +- .../orcad-activation-record.test.ts | 3 +- .../activation/orcad-activation-record.ts | 78 ++------ .../activation/orcad-activation-recovery.ts | 10 +- .../orcad-activation-transaction-schema.ts | 7 +- .../orcad-activation-transaction-store.ts | 7 +- .../orcad-activation-transaction.test.ts | 5 +- .../orcad-activation-transaction.ts | 26 +-- .../host-state/orcad-machine-claim-command.ts | 26 +-- .../host-state/orcad-machine-home.test.ts | 52 ++--- .../host-state/orcad-machine-home.ts | 74 ++------ .../orcad-state-mutation-exec.test.ts | 3 +- .../host-state/orcad-state-mutation-exec.ts | 19 +- ...cad-state-mutation-fence-heartbeat.test.ts | 9 +- .../orcad-state-mutation-owner-record.ts | 17 +- .../orcad-state-mutation-timeout.test.ts | 14 +- .../host-state/orcad-state-newest-mtime.ts | 9 +- .../orcad-state-snapshot-members.ts | 27 +-- .../orcad-state-snapshot-shell.test.ts | 15 +- .../orcad-state-snapshot-space.test.ts | 7 +- .../host-state/orcad-state-snapshot-space.ts | 37 +--- .../host-state/orcad-state-snapshot.test.ts | 7 +- .../host-state/orcad-state-snapshot.ts | 108 +++-------- .../orcad-windows-host-fence-ops.test.ts | 13 +- .../orcad-windows-host-fence-ops.ts | 7 +- .../orcad-windows-host-lane.test.ts | 19 +- .../orcad-windows-host-preparation.ts | 10 +- .../host-state/orcad-windows-host-script.ts | 17 +- .../orcad-windows-host-state-ops.test.ts | 13 +- .../orcad-windows-host-state-ops.ts | 13 +- .../host-state/remote-filesystem-space.ts | 4 +- .../install/orcad-remote-build-hash.ts | 3 +- .../install/orcad-remote-context.ts | 17 +- .../install/orcad-remote-decommission.test.ts | 2 +- .../install/orcad-remote-decommission.ts | 19 +- .../install/orcad-remote-gc.test.ts | 11 +- .../managed-server/install/orcad-remote-gc.ts | 40 +--- .../install/orcad-remote-host-support.ts | 34 +--- .../orcad-remote-install-termination.test.ts | 1 - .../install/orcad-remote-install.ts | 19 +- .../orcad-remote-launch-file-modes.test.ts | 7 +- .../orcad-remote-launch-setsid.test.ts | 8 +- .../install/orcad-remote-launch-windows.ts | 25 +-- .../install/orcad-remote-launch.ts | 111 +++-------- .../orcad-remote-lifecycle-windows.test.ts | 12 +- .../install/orcad-remote-log-tail.ts | 12 +- ...-remote-node-runtime-host-fallback.test.ts | 2 +- .../orcad-remote-node-runtime-noexec.test.ts | 2 +- ...remote-node-runtime-promotion-lock.test.ts | 4 +- ...remote-node-runtime-recorded-probe.test.ts | 4 +- ...rcad-remote-node-runtime-recorded-probe.ts | 11 +- ...rcad-remote-node-runtime-slow-hash.test.ts | 10 +- .../orcad-remote-node-runtime-windows.test.ts | 5 +- .../orcad-remote-node-runtime-windows.ts | 41 ++-- .../install/orcad-remote-node-runtime.test.ts | 1 - .../install/orcad-remote-node-runtime.ts | 80 ++------ .../install/orcad-remote-preflight.ts | 8 +- .../install/orcad-remote-process-control.ts | 26 +-- .../orcad-remote-readiness-wait.test.ts | 5 +- .../install/orcad-remote-readiness-wait.ts | 28 +-- .../install/orcad-remote-record-file.ts | 14 +- .../install/orcad-remote-rollback.test.ts | 11 +- .../install/orcad-remote-rollback.ts | 28 +-- .../orcad-remote-runtime-control.test.ts | 2 - .../install/orcad-remote-runtime-control.ts | 14 +- .../install/orcad-remote-runtime.test.ts | 5 +- .../install/orcad-remote-runtime.ts | 18 +- ...-remote-shell-commands.integration.test.ts | 40 ++-- .../orcad-remote-windows-commands.test.ts | 5 +- .../orcad-remote-windows-decommission.test.ts | 6 - .../install/orcad-remote-windows-node.ts | 31 +-- .../orcad-remote-windows-scripts.test.ts | 14 +- .../install/remote-deploy-timing.ts | 3 +- .../install/remote-install-gc-claim.ts | 9 +- .../remote-install-gc-termination.test.ts | 1 - .../install/remote-install-gc-tombstone.ts | 3 +- .../install/remote-install-gc.ts | 46 +---- .../install/remote-install-lock-commands.ts | 16 +- .../remote-install-lock-exited-owner.test.ts | 4 - .../remote-install-lock-exited-owner.ts | 28 +-- .../install/remote-install-lock.ts | 30 +-- .../install/remote-install-model.test.ts | 3 +- .../install/remote-install-model.ts | 56 +----- .../remote-install-noisy-sftp-wire.test.ts | 6 +- .../remote-install-out-of-space.test.ts | 4 +- .../install/remote-install-out-of-space.ts | 14 +- .../install/remote-install-transfers.ts | 16 +- .../install/remote-install-transport.ts | 11 +- .../remote-node-runtime-host-download.test.ts | 6 +- .../remote-node-runtime-host-download.ts | 48 ++--- .../remote-node-runtime-store-gc.test.ts | 4 +- .../install/remote-node-runtime-store-gc.ts | 38 +--- .../remote-node-runtime-store-inventory.ts | 23 +-- ...de-runtime-store-lock-host-release.test.ts | 5 +- .../install/remote-node-runtime-store-lock.ts | 27 +-- .../remote-node-runtime-store-windows.test.ts | 5 +- .../remote-node-runtime-store-windows.ts | 23 +-- .../install/remote-sha256-command.ts | 2 +- ...mote-versioned-install-termination.test.ts | 1 - .../install/remote-versioned-install.test.ts | 33 ++-- .../install/remote-versioned-install.ts | 28 +-- .../lifecycle/orcad-decommission-recovery.ts | 6 +- ...ecommission-released-peer-recovery.test.ts | 13 +- .../lifecycle/orcad-decommission-stop.ts | 12 +- .../orcad-decommission-transaction.test.ts | 2 +- .../orcad-decommission-transaction.ts | 28 +-- .../orcad-managed-auto-update.test.ts | 1 - .../lifecycle/orcad-managed-auto-update.ts | 23 +-- .../lifecycle/orcad-managed-bound-port.ts | 16 +- .../orcad-managed-launch-session.test.ts | 1 - .../lifecycle/orcad-managed-launch-session.ts | 39 ++-- .../orcad-managed-lifecycle-test-fixture.ts | 2 - .../orcad-managed-migration-status.ts | 6 +- .../orcad-managed-pairing-repair-failure.ts | 1 - ...cad-managed-pairing-repair-restart.test.ts | 6 +- .../orcad-managed-pairing-repair.test.ts | 8 +- .../lifecycle/orcad-managed-pairing-repair.ts | 53 ++---- ...orcad-managed-pairing-repaired-listener.ts | 1 - .../lifecycle/orcad-managed-remote-stop.ts | 18 +- .../orcad-managed-runtime-context.ts | 18 +- .../lifecycle/orcad-managed-serving-verify.ts | 2 +- .../lifecycle/orcad-managed-serving.test.ts | 4 - .../lifecycle/orcad-managed-serving.ts | 36 +--- .../lifecycle/orcad-managed-tunnel-active.ts | 13 +- .../orcad-managed-tunnel-bridge-port.test.ts | 2 - .../orcad-managed-tunnel-close-target.test.ts | 1 - ...ad-managed-tunnel-identity-restart.test.ts | 5 +- .../orcad-managed-tunnel-identity.ts | 28 +-- .../lifecycle/orcad-managed-tunnel-manager.ts | 23 +-- .../orcad-managed-tunnel-pairing-recovery.ts | 15 +- .../orcad-managed-tunnel-peer-restart.test.ts | 4 +- .../lifecycle/orcad-managed-tunnel-resume.ts | 6 +- .../lifecycle/orcad-managed-tunnel-serving.ts | 8 +- .../orcad-managed-tunnel-target.test.ts | 2 +- .../lifecycle/orcad-managed-tunnel-target.ts | 33 +--- ...aged-tunnel-transport-real-openssh.test.ts | 18 +- .../orcad-managed-tunnel-transport.test.ts | 9 +- .../orcad-managed-tunnel-transport.ts | 33 ++-- .../lifecycle/orcad-managed-tunnel.test.ts | 3 +- .../lifecycle/orcad-managed-tunnel.ts | 15 +- .../orcad-managed-update-decision.ts | 18 +- .../orcad-managed-update-deferrals.ts | 6 +- .../orcad-managed-update-failure-memory.ts | 5 +- .../orcad-managed-update-on-restore.test.ts | 2 +- .../orcad-managed-update-on-restore.ts | 14 +- .../orcad-managed-version-gc.test.ts | 2 +- .../lifecycle/orcad-managed-version-gc.ts | 15 +- .../lifecycle/orcad-managed-wake.test.ts | 14 +- .../lifecycle/orcad-managed-wake.ts | 38 +--- .../lifecycle/orcad-rollback-admission.ts | 1 - .../orcad-rollback-terminal-barrier.ts | 19 +- .../lifecycle/orcad-rollback-transition.ts | 13 +- .../orcad-runtime-conversion-wiring.ts | 4 - .../orcad-runtime-conversion.test.ts | 18 +- .../lifecycle/orcad-runtime-conversion.ts | 35 ++-- .../orcad-runtime-decommission.test.ts | 2 +- .../lifecycle/orcad-runtime-decommission.ts | 23 +-- .../orcad-runtime-deployment.test.ts | 11 +- .../lifecycle/orcad-runtime-deployment.ts | 17 +- .../lifecycle/orcad-runtime-forget.test.ts | 4 +- .../lifecycle/orcad-runtime-status.ts | 2 +- .../lifecycle/orcad-runtime-target.ts | 4 - .../lifecycle/orcad-stdio-bridge-listener.ts | 16 +- .../orcad-stdio-bridge-provider.test.ts | 1 - .../lifecycle/orcad-stdio-bridge-provider.ts | 16 +- .../lifecycle/orcad-stdio-bridge-script.ts | 23 +-- .../lifecycle/orcad-stdio-bridge-stream.ts | 9 +- .../orcad-stdio-bridge-test-channel.ts | 4 +- .../posix-host-op-byte-budget.test.ts | 16 +- src/main/media/media-preview-protocol.ts | 2 +- .../memory/collector-windows-sweep.test.ts | 9 +- src/main/memory/collector.test.ts | 50 +---- src/main/memory/collector.ts | 76 +------- src/main/memory/host-memory.ts | 4 +- ...cal-pty-registry-folder-workspaces.test.ts | 2 +- .../memory/hydrate-local-pty-registry.test.ts | 52 +---- src/main/memory/hydrate-local-pty-registry.ts | 2 +- src/main/memory/memory-snapshot-buckets.ts | 1 - src/main/memory/memory-snapshot-values.ts | 6 +- src/main/memory/pty-registry.ts | 24 +-- .../verified-local-folder-workspaces.ts | 2 +- .../windows-process-resource-collector.ts | 21 +- .../windows-process-sample-parsing.test.ts | 4 +- .../memory/windows-process-sample-parsing.ts | 30 +-- src/main/menu/app-menu-quit-item.ts | 8 +- src/main/menu/app-menu-selection-item.ts | 5 +- src/main/menu/app-menu-window.ts | 2 +- src/main/menu/register-app-menu.test.ts | 17 +- src/main/menu/register-app-menu.ts | 52 +---- src/main/mimo/hook-service.ts | 2 - .../minimax/minimax-api-key-store.test.ts | 2 - src/main/minimax/minimax-cookie-store.test.ts | 2 - src/main/minimax/minimax-cookie-store.ts | 15 +- .../minimax/minimax-credential-commands.ts | 11 +- .../muse-usage/muse-session-log-discovery.ts | 12 +- src/main/muse-usage/muse-usage-provider.ts | 2 +- .../muse-usage/muse-usage-record-parser.ts | 14 +- .../persisted-file-normalization.ts | 2 +- .../persisted-state-normalization.ts | 2 +- src/main/muse-usage/scanner.test.ts | 9 +- src/main/muse-usage/scanner.ts | 16 +- src/main/muse-usage/snapshot-rollups.test.ts | 1 - src/main/muse-usage/types.ts | 10 +- src/main/muse/hook-config-json.ts | 6 +- src/main/muse/hook-service.test.ts | 14 +- src/main/muse/hook-service.ts | 17 +- src/main/muse/hook-settings.test.ts | 1 - src/main/muse/hook-settings.ts | 12 +- .../agent-cli-installation-error.ts | 1 - .../agent-model-catalog-account-expiry.ts | 9 +- ...-model-catalog-discovery-freshness.test.ts | 10 +- .../agent-model-catalog-discovery.ts | 16 +- .../agent-model-catalog-entry.ts | 58 ++---- .../agent-model-catalog-failures.ts | 24 +-- .../agent-model-catalog-fingerprint.ts | 19 +- ...gent-model-catalog-learned-default.test.ts | 7 +- .../agent-model-catalog-lister.ts | 12 +- .../agent-model-catalog-listing-waiters.ts | 3 +- .../agent-model-catalog-persistence.ts | 15 +- .../agent-model-catalog-prewarm.test.ts | 15 -- .../agent-model-catalog-prewarm.ts | 4 +- .../agent-model-catalog-probe-runner.test.ts | 1 - .../agent-model-catalog-probe-runner.ts | 24 +-- ...gent-model-catalog-saved-only-read.test.ts | 6 +- .../agent-model-catalog-scope.ts | 5 +- .../agent-model-catalog-service-contract.ts | 32 +--- .../agent-model-catalog-service.test.ts | 9 +- .../agent-model-catalog-service.ts | 34 +--- .../agent-model-catalog-store.test.ts | 10 +- .../agent-model-catalog-store.ts | 40 +--- .../agent-model-catalog-unavailable.test.ts | 10 +- .../agent-model-catalog-unavailable.ts | 1 - .../agent-model-catalog-workspace.ts | 19 +- .../agent-project-model-config-keys.ts | 33 +--- .../agent-project-model-override.test.ts | 9 +- .../agent-project-model-override.ts | 39 +--- .../agent-session-attachment-claims.test.ts | 1 - .../agent-session-attachment-claims.ts | 32 +--- .../agent-session-attachment-references.ts | 16 +- ...agent-session-attachment-store-registry.ts | 7 +- .../agent-session-attachment-store.test.ts | 6 +- .../agent-session-attachment-store.ts | 33 +--- .../agent-session-attachment-sweep.test.ts | 5 - .../agent-session-attachment-sweep.ts | 28 +-- .../chunked-upload-registry.ts | 11 +- .../adopted-transcript-source.ts | 2 +- .../command-receipt-schema.ts | 11 +- .../command-receipt-table.ts | 4 +- .../command-receipt-transaction.ts | 2 +- .../journal-approval-subject-kinds.test.ts | 7 +- .../journal-closed-sets.test.ts | 16 +- .../journal-context-clear.ts | 2 +- .../journal-context-rewind.ts | 2 +- .../journal-crash-boundary.spec.ts | 25 +-- .../agent-session-journal/journal-cursor.ts | 32 +--- .../journal-damage.test.ts | 11 +- .../journal-database-schema.ts | 18 +- .../journal-database.spec.ts | 7 +- .../agent-session-journal/journal-database.ts | 37 +--- .../journal-derived-turn-scope.ts | 17 +- .../journal-dispatch-answered-turn.test.ts | 4 - .../journal-dispatch-doubt-reasons.ts | 23 +-- .../journal-dispatch-legacy-reason.test.ts | 3 +- .../journal-dispatch-observation.ts | 1 - .../journal-dispatch-reason-bound.spec.ts | 6 +- .../journal-dispatch-reducer.ts | 16 +- .../journal-dispatch-settlement.test.ts | 3 +- .../journal-dispatch-settlement.ts | 12 +- .../journal-epoch-controller.ts | 10 +- .../journal-epoch-replacement.spec.ts | 7 +- .../journal-epoch-replacement.ts | 25 +-- .../journal-epoch-rollover.ts | 18 +- .../journal-history-origin.ts | 2 +- .../journal-host-database-test-support.ts | 17 +- .../journal-host-database.spec.ts | 13 +- .../journal-host-database.ts | 24 +-- .../journal-item-appender.ts | 4 +- .../journal-item-echo.ts | 3 +- .../journal-item-fold.ts | 16 +- .../journal-item-identity.test.ts | 19 +- .../journal-item-provenance.spec.ts | 10 - .../journal-item-provenance.ts | 10 +- .../journal-legacy-identity.ts | 28 +-- .../journal-legacy-import.test.ts | 27 +-- .../journal-legacy-import.ts | 48 +---- .../journal-lifecycle-batch-appender.ts | 11 +- .../journal-lifecycle-batch-partition.test.ts | 2 +- .../journal-lifecycle-batch-partition.ts | 4 +- .../journal-lifecycle-mutation.ts | 14 +- .../journal-newest-item-window.ts | 4 +- .../journal-open-failure.test.ts | 11 +- .../journal-open-failure.ts | 45 +---- .../agent-session-journal/journal-open.ts | 30 +-- .../journal-payload-bounds.ts | 27 +-- .../journal-pending-submission-recovery.ts | 16 +- .../journal-pi-active-branch.ts | 7 +- .../journal-pi-context-fixtures.ts | 2 +- .../journal-pi-context-import.spec.ts | 1 - .../journal-producer-inheritance.spec.ts | 5 - .../journal-prompt-body-bounds.ts | 7 +- .../journal-provider-activity.ts | 5 +- .../journal-queue-events.ts | 22 +-- .../journal-queue-fold.ts | 32 +--- .../journal-queue-import.ts | 41 ++-- .../journal-queue-retention.ts | 12 +- .../journal-queued-messages.ts | 42 +--- .../journal-queued-rejection-batch.spec.ts | 5 - .../journal-reasoning-row.ts | 6 +- .../journal-reducer-producer-linkage.test.ts | 13 -- .../journal-reducer-rejection-fact.test.ts | 6 +- .../journal-reducer.test.ts | 28 +-- .../agent-session-journal/journal-reducer.ts | 55 ++---- ...journal-rejected-message-placement.test.ts | 4 - .../journal-render-item.ts | 11 +- .../journal-restart-reconciliation.spec.ts | 7 +- .../journal-restart-reconciliation.ts | 45 +---- .../journal-row-builders.ts | 28 +-- .../journal-row-context-usage.spec.ts | 3 - .../journal-row-schema-version.spec.ts | 5 +- .../journal-row-schema.test.ts | 30 +-- .../journal-row-schema.ts | 145 +++----------- .../journal-row-table.ts | 16 +- .../journal-row-unusable-annotations.ts | 22 +-- .../journal-row-writer.spec.ts | 14 +- .../journal-row-writer.ts | 22 +-- .../journal-step-writer.ts | 9 +- .../journal-stop-acceptance.ts | 17 +- .../journal-stop-and-resume-rows.spec.ts | 7 +- .../journal-stop-and-resume-rows.ts | 7 +- .../journal-stop-marks.ts | 17 +- .../journal-stop-note-projection.ts | 2 +- .../journal-stop-turn-end.test.ts | 3 - .../journal-stop-turn-end.ts | 45 ++--- .../journal-store-collaborators.ts | 14 +- .../journal-store-contracts.ts | 52 ++--- .../journal-store-open.ts | 4 +- .../journal-store-restore.ts | 11 +- .../journal-store-schema.spec.ts | 18 +- .../journal-store.spec.ts | 8 +- .../agent-session-journal/journal-store.ts | 76 +++----- .../journal-streamed-text-revision.ts | 7 +- .../journal-streaming-replay.test.ts | 3 +- .../journal-subagent-item.spec.ts | 5 +- .../journal-subagent-item.ts | 13 +- .../journal-subagent-liveness.spec.ts | 11 +- .../journal-subagent-liveness.ts | 33 +--- .../journal-submission-fold.ts | 33 +--- .../journal-submission-hook.ts | 4 +- .../journal-submission-positions.spec.ts | 9 +- .../journal-submission-queued-link.spec.ts | 4 +- .../journal-submission-reconciler.ts | 57 ++---- .../journal-submission-writer.ts | 34 +--- .../journal-terminal-settlement.ts | 15 +- .../journal-turn-scope.test.ts | 10 +- .../journal-unknown-content.spec.ts | 13 +- .../journal-unknown-row-kind.spec.ts | 15 +- .../journal-unsent-send-hold.spec.ts | 18 +- .../journal-unsent-send-hold.ts | 62 ++---- .../journal-write-guards.ts | 13 +- .../journal-write-queue.spec.ts | 7 +- .../journal-write-queue.ts | 28 +-- .../queued-message-edit-leases.ts | 25 +-- .../queued-message-pause.spec.ts | 37 +--- .../queued-message-pause.ts | 72 ++----- .../queued-message-schema.ts | 12 +- .../queued-message-stored-row.ts | 14 +- .../queued-message-table.ts | 36 +--- .../queued-message-update.ts | 6 +- .../agent-session-record-test-fixture.ts | 2 - .../agent-journal-turn-row-revision.ts | 40 ++-- ...der-timeline-assembler-attribution.test.ts | 10 +- ...vider-timeline-assembler-held-rows.test.ts | 7 +- .../provider-timeline-assembler-items.test.ts | 4 +- .../provider-timeline-assembler-order.test.ts | 1 - ...rovider-timeline-assembler-refusal.test.ts | 6 +- ...rovider-timeline-assembler-restart.test.ts | 9 +- .../provider-timeline-assembler-rows.test.ts | 7 +- ...rovider-timeline-assembler-session.test.ts | 6 +- .../provider-timeline-assembler-stop.test.ts | 12 +- ...rovider-timeline-assembler-streams.test.ts | 5 +- ...rovider-timeline-assembler-test-support.ts | 36 +--- ...vider-timeline-assembler-tool-ends.test.ts | 3 +- .../provider-timeline-assembler-turns.test.ts | 9 +- .../provider-timeline-assembler.ts | 35 +--- .../provider-timeline-background-tasks.ts | 6 +- .../provider-timeline-budget.ts | 10 +- .../provider-timeline-context.ts | 12 +- .../provider-timeline-decision.ts | 32 ++-- .../provider-timeline-event.ts | 83 ++------ .../provider-timeline-identity.ts | 31 +-- .../provider-timeline-item-decisions.ts | 16 +- .../provider-timeline-plan.ts | 10 +- .../provider-timeline-rows.ts | 17 +- .../provider-timeline-settlement.ts | 29 +-- .../provider-timeline-state.ts | 44 ++--- .../provider-timeline-text-events.ts | 10 +- .../provider-timeline-text-streams.ts | 63 ++---- .../provider-timeline-transition-layout.ts | 14 +- .../provider-timeline-turn-decisions.ts | 28 +-- .../agent-session-bounded-frame-item.ts | 10 +- .../agent-session-bounded-frames.test.ts | 26 +-- .../agent-session-bounded-frames.ts | 26 +-- .../agent-session-bounded-queue.ts | 5 +- ...agent-session-conversation-outline.test.ts | 6 +- .../agent-session-conversation-outline.ts | 13 +- .../agent-session-delta-coalescer.test.ts | 2 +- .../agent-session-delta-coalescer.ts | 54 ++---- ...ession-history-conversation-window.test.ts | 3 +- .../agent-session-history-page-bounds.ts | 19 +- ...ssion-history-page-grouping-parity.test.ts | 10 +- .../agent-session-history-page.test.ts | 18 +- .../agent-session-history-page.ts | 62 ++---- .../agent-session-history-subagent-roster.ts | 11 +- .../agent-session-item-wire-form.ts | 18 +- .../agent-session-journal-batch.ts | 29 +-- .../agent-session-published-work.ts | 7 +- ...agent-session-stop-note-projection.test.ts | 1 - .../agent-session-stop-note-refresh.test.ts | 4 +- .../agent-session-stop-note-refresh.ts | 4 +- .../agent-session-subscriber-catch-up.ts | 15 +- .../agent-session-subscriber-frame-fields.ts | 33 +--- .../at-rest-command-catalog.ts | 35 ++-- .../claude-stream-json-frame-schema.ts | 4 +- .../hook-outcome-rows.test.ts | 6 +- .../json-string-shortening.ts | 22 +-- .../provider-frame-activity.test.ts | 4 +- .../provider-frame-activity.ts | 19 +- .../provider-frame-disposition.test.ts | 14 +- .../provider-frame-disposition.ts | 44 ++--- .../provider-turn-activity-routing.test.ts | 1 - .../structured-agent-definition.ts | 17 +- .../structured-agent-registry.ts | 13 +- ...-agent-session-accept-then-deliver.test.ts | 52 ++--- ...structured-agent-session-acquire-aborts.ts | 9 +- ...ctured-agent-session-acquisition-errors.ts | 14 +- .../structured-agent-session-acquisition.ts | 11 +- ...ent-session-adapter-router-test-support.ts | 10 +- ...tured-agent-session-adapter-router.test.ts | 5 +- ...structured-agent-session-adapter-router.ts | 20 +- .../structured-agent-session-adapter-stop.ts | 26 +-- .../structured-agent-session-adapter.ts | 173 +++++------------ ...tured-agent-session-adopted-import.test.ts | 11 +- ...structured-agent-session-adopted-import.ts | 13 +- .../structured-agent-session-agent-start.ts | 41 ++-- ...ured-agent-session-append-delivery.test.ts | 12 +- ...red-agent-session-at-rest-commands.test.ts | 12 +- ...ructured-agent-session-at-rest-commands.ts | 5 +- ...structured-agent-session-attach-context.ts | 9 +- ...structured-agent-session-attach-failure.ts | 5 +- .../structured-agent-session-attach-flow.ts | 54 ++---- .../structured-agent-session-attach-lock.ts | 9 +- ...ured-agent-session-attach-orchestration.ts | 47 ++--- ...structured-agent-session-attach-publish.ts | 12 +- ...gent-session-attach-reconciliation.test.ts | 13 +- ...-agent-session-attach-test-conversation.ts | 3 +- .../structured-agent-session-attach.ts | 95 ++-------- ...agent-session-attachment-admission.test.ts | 6 +- ...session-background-stop-unreadable.test.ts | 1 - ...tructured-agent-session-background-stop.ts | 26 +-- ...nt-session-background-task-channel.test.ts | 10 +- ...d-agent-session-background-task-channel.ts | 19 +- ...ructured-agent-session-chat-stop-target.ts | 37 ++-- .../structured-agent-session-chat-stop.ts | 38 ++-- .../structured-agent-session-child-close.ts | 36 ++-- .../structured-agent-session-child-exit.ts | 74 +++----- ...agent-session-child-work-selection.test.ts | 5 - ...-agent-session-claude-compact-stop.test.ts | 15 +- ...-agent-session-claude-echo-working.test.ts | 8 +- ...ent-session-claude-hung-start-stop.test.ts | 14 +- ...t-session-claude-opening-send-hold.test.ts | 23 +-- ...d-agent-session-claude-queued-stop.test.ts | 27 +-- ...red-agent-session-claude-root-exit.test.ts | 3 - ...t-session-claude-stop-ends-session.test.ts | 52 ++--- ...ssion-claude-stop-exit-ends-record.test.ts | 36 +--- ...agent-session-claude-stop-note-end.test.ts | 6 +- ...agent-session-claude-stop-turn-end.test.ts | 15 +- ...-agent-session-claude-subagent-row.test.ts | 22 +-- ...uctured-agent-session-clear-rewind.test.ts | 1 - ...tructured-agent-session-client-delivery.ts | 36 +--- ...d-agent-session-close-aborts-start.test.ts | 7 +- ...ctured-agent-session-close-verdict.test.ts | 28 +-- ...-agent-session-codex-stop-note-end.test.ts | 7 +- ...tured-agent-session-codex-stop-row.test.ts | 30 +-- ...uctured-agent-session-command-admission.ts | 7 +- ...ured-agent-session-command-readers.test.ts | 6 +- ...tructured-agent-session-command-receipt.ts | 7 +- .../structured-agent-session-command-turn.ts | 52 ++--- ...ured-agent-session-continuation-verdict.ts | 9 +- ...red-agent-session-conversation-lifetime.ts | 53 ++---- ...uctured-agent-session-conversation-open.ts | 46 ++--- ...ed-agent-session-conversation-stop.test.ts | 48 ++--- ...ctured-agent-session-conversations.test.ts | 3 +- .../structured-agent-session-conversations.ts | 17 +- ...ured-agent-session-crash-mid-start.test.ts | 34 +--- ...tured-agent-session-crash-turn-end.test.ts | 44 +---- .../structured-agent-session-create.ts | 8 +- ...uctured-agent-session-current-work.test.ts | 16 +- .../structured-agent-session-current-work.ts | 85 ++------- ...session-dead-generation-settlement.test.ts | 9 +- ...gent-session-dead-generation-settlement.ts | 31 +-- ...gent-session-delivery-hold.test-fixture.ts | 10 +- .../structured-agent-session-delivery-loop.ts | 86 +++------ ...tured-agent-session-ended-child-failure.ts | 12 +- ...-agent-session-ended-prompt-cancel.test.ts | 7 +- ...structured-agent-session-event-recovery.ts | 8 +- ...ructured-agent-session-event-sink-queue.ts | 40 ++-- ...tructured-agent-session-event-sink.test.ts | 27 +-- .../structured-agent-session-event-sink.ts | 34 +--- .../structured-agent-session-eviction.test.ts | 5 +- .../structured-agent-session-eviction.ts | 30 +-- ...uctured-agent-session-execution-options.ts | 3 +- ...ctured-agent-session-exit-delivery.test.ts | 10 +- ...tructured-agent-session-exit-turn-scope.ts | 8 +- ...ession-failed-create-owner-verdict.test.ts | 18 +- ...red-agent-session-failed-create-refusal.ts | 25 +-- ...uctured-agent-session-failure-text.test.ts | 8 +- .../structured-agent-session-failure-text.ts | 37 +--- ...ctured-agent-session-forget-status.test.ts | 17 +- ...ructured-agent-session-fork-rewind.test.ts | 4 +- .../structured-agent-session-handing-over.ts | 13 +- .../structured-agent-session-handover.ts | 48 ++--- ...t-session-held-child-lease-renewal.test.ts | 10 +- ...d-agent-session-held-start.test-support.ts | 2 - ...ructured-agent-session-history-catch-up.ts | 29 +-- ...structured-agent-session-history-result.ts | 1 - ...uctured-agent-session-host-current-work.ts | 9 +- .../structured-agent-session-host-delivery.ts | 31 +-- .../structured-agent-session-host-lifetime.ts | 70 ++----- ...structured-agent-session-host-mutations.ts | 35 +--- ...d-agent-session-host-runtime-state.test.ts | 4 +- ...ctured-agent-session-host-runtime-state.ts | 26 +-- ...ructured-agent-session-host-status-feed.ts | 8 +- ...tructured-agent-session-host-stop-event.ts | 33 +--- .../structured-agent-session-host-tabs.ts | 16 +- ...ctured-agent-session-host-teardown.test.ts | 8 +- .../structured-agent-session-host-teardown.ts | 29 +-- ...uctured-agent-session-host-test-abandon.ts | 14 +- ...structured-agent-session-host-test-data.ts | 3 +- ...uctured-agent-session-host-test-harness.ts | 16 +- ...gent-session-host-test-recovery-capsule.ts | 6 +- .../structured-agent-session-host-types.ts | 100 ++++------ .../structured-agent-session-host.test.ts | 34 ++-- .../structured-agent-session-host.ts | 53 ++---- ...tructured-agent-session-idle-sweep.test.ts | 23 +-- .../structured-agent-session-idle-sweep.ts | 43 ++--- ...gent-session-instant-start.test-support.ts | 2 - ...t-session-interrupted-continuation.test.ts | 14 +- ...-agent-session-interrupted-continuation.ts | 25 +-- ...ed-agent-session-journal-append-options.ts | 10 +- ...d-agent-session-journal-corruption.test.ts | 18 +- ...ent-session-journal-double-test-support.ts | 7 +- .../structured-agent-session-journal-views.ts | 8 +- .../structured-agent-session-late-dispatch.ts | 8 +- ...agent-session-late-proof-tool-ends.test.ts | 7 +- ...ured-agent-session-late-settlement.test.ts | 7 +- ...ed-agent-session-launch-permission.test.ts | 3 - ...uctured-agent-session-launch-permission.ts | 7 +- ...t-session-launch-send-after-create.test.ts | 19 +- .../structured-agent-session-lease-release.ts | 11 +- ...ctured-agent-session-lease-renewer.test.ts | 6 +- .../structured-agent-session-lease-renewer.ts | 21 +- ...ession-leftover-settlement.test-fixture.ts | 12 +- ...tured-agent-session-leftover-settlement.ts | 26 +-- ...gent-session-legacy-handoff-record.test.ts | 23 +-- ...red-agent-session-live-turn-window.test.ts | 10 +- .../structured-agent-session-log-repeats.ts | 8 +- ...tured-agent-session-logger-test-support.ts | 5 +- .../structured-agent-session-logger.test.ts | 4 +- .../structured-agent-session-logger.ts | 30 ++- ...ured-agent-session-message-choices.test.ts | 2 +- ...tructured-agent-session-message-options.ts | 4 +- ...ctured-agent-session-mutation-admission.ts | 42 +--- ...tured-agent-session-mutation-admits-now.ts | 3 +- ...ructured-agent-session-mutation-context.ts | 34 ++-- ...structured-agent-session-mutation-plans.ts | 74 +++----- ...red-agent-session-mutation-turn-context.ts | 2 - .../structured-agent-session-never-sent.ts | 4 +- ...d-agent-session-newer-content-host.test.ts | 20 +- ...gent-session-observed-exit.test-fixture.ts | 5 +- ...tructured-agent-session-open-child-work.ts | 9 +- .../structured-agent-session-opening-send.ts | 10 +- ...tructured-agent-session-operation-child.ts | 6 +- ...agent-session-operation-settlement.test.ts | 2 - ...ured-agent-session-operation-settlement.ts | 12 +- ...nt-session-operation-start-refusal.test.ts | 3 - .../structured-agent-session-option-error.ts | 2 +- .../structured-agent-session-option-intent.ts | 10 +- .../structured-agent-session-option-models.ts | 7 +- ...uctured-agent-session-options-read.test.ts | 16 +- .../structured-agent-session-options-read.ts | 32 ++-- ...gent-session-orca-stop-row-readers.test.ts | 5 +- .../structured-agent-session-orca-stop-row.ts | 19 +- ...tured-agent-session-outgrown-child.test.ts | 12 +- ...structured-agent-session-outgrown-child.ts | 22 +-- ...ed-agent-session-owed-work-release.test.ts | 26 +-- .../structured-agent-session-owner-status.ts | 4 +- ...ured-agent-session-permission-mode.test.ts | 4 - .../structured-agent-session-pick-reply.ts | 8 +- ...ent-session-prompt-answer-receipts.test.ts | 13 +- ...agent-session-prompt-cancel-repeat.test.ts | 2 +- ...ctured-agent-session-prompt-cancel.test.ts | 7 +- .../structured-agent-session-prompt-cancel.ts | 36 ++-- .../structured-agent-session-prompt-state.ts | 7 +- ...structured-agent-session-provider-child.ts | 25 +-- ...tured-agent-session-provider-exit-proof.ts | 15 +- ...d-agent-session-provider-history-window.ts | 6 +- ...ed-agent-session-provider-open-contract.ts | 1 - .../structured-agent-session-provider-open.ts | 4 +- ...ructured-agent-session-provider-started.ts | 30 +-- ...ructured-agent-session-provider-support.ts | 16 +- ...ent-session-queued-action-receipts.test.ts | 13 +- ...ed-agent-session-queued-card-holds.test.ts | 61 ++---- ...ed-agent-session-queued-command-refusal.ts | 9 +- ...tured-agent-session-queued-command.test.ts | 5 - ...tured-agent-session-queued-compact.test.ts | 25 +-- ...ructured-agent-session-queued-edit.test.ts | 9 +- .../structured-agent-session-queued-edit.ts | 20 +- ...ructured-agent-session-queued-gate.test.ts | 17 +- ...agent-session-queued-legacy-import.test.ts | 26 +-- ...ueued-message-rig-provider.test-fixture.ts | 22 +-- ...session-queued-message-rig.test-fixture.ts | 31 +-- ...ured-agent-session-queued-messages.test.ts | 70 ++----- ...tructured-agent-session-queued-messages.ts | 109 ++--------- ...ructured-agent-session-queued-mutations.ts | 48 ++--- ...ed-agent-session-queued-newer-orca.test.ts | 7 +- ...ed-agent-session-queued-pause-lift.test.ts | 56 ++---- .../structured-agent-session-queued-pause.ts | 14 +- ...ctured-agent-session-queued-publication.ts | 37 ++-- ...ent-session-queued-published-bytes.test.ts | 6 - ...ed-agent-session-queued-published-bytes.ts | 13 +- ...ructured-agent-session-queued-quit.test.ts | 5 - ...-agent-session-queued-refused-card.test.ts | 13 +- ...-agent-session-queued-repeat-press.test.ts | 4 - ...nt-session-queued-rig-turn.test-fixture.ts | 7 +- ...ctured-agent-session-queued-send-answer.ts | 13 +- .../structured-agent-session-queued-send.ts | 7 +- ...ured-agent-session-queued-stop-row.test.ts | 30 +-- .../structured-agent-session-queued-stop.ts | 25 +-- .../structured-agent-session-queued-wiring.ts | 14 +- ...ent-session-queued-withdrawn-draft.test.ts | 17 +- .../structured-agent-session-ran-options.ts | 23 +-- ...uctured-agent-session-read-restore.test.ts | 3 - .../structured-agent-session-read-restore.ts | 6 +- ...ed-agent-session-readable-restorer.test.ts | 1 - ...tructured-agent-session-ready-operation.ts | 10 +- ...ured-agent-session-reasoning-sweep.test.ts | 2 - ...agent-session-recovered-turn-clock.test.ts | 24 +-- ...tured-agent-session-recovery-exits.test.ts | 18 +- ...-agent-session-recovery-resolution.test.ts | 14 +- ...tured-agent-session-recovery-resolution.ts | 26 +-- ...ured-agent-session-refusal-details.test.ts | 10 +- ...tructured-agent-session-refusal-message.ts | 22 +-- .../structured-agent-session-registry.test.ts | 3 +- .../structured-agent-session-registry.ts | 14 +- ...ctured-agent-session-repeated-stop.test.ts | 5 - ...agent-session-replaced-server-turn.test.ts | 6 +- ...structured-agent-session-replay-outcome.ts | 6 +- ...ession-reported-child-work.test-support.ts | 4 +- ...ctured-agent-session-resend-answer.test.ts | 8 +- ...gent-session-resend-ledger-refusal.test.ts | 7 +- ...ured-agent-session-resolved-append.test.ts | 6 +- ...tructured-agent-session-resolved-append.ts | 3 +- .../structured-agent-session-rest-test-rig.ts | 13 +- ...red-agent-session-restart-action-result.ts | 5 +- ...red-agent-session-restart-audience.test.ts | 7 - ...ctured-agent-session-restart-candidates.ts | 18 +- ...ructured-agent-session-restart-cap.test.ts | 8 +- ...d-agent-session-restart-child-work.test.ts | 8 +- ...t-session-restart-continuation-envelope.ts | 13 +- ...-session-restart-continuation-wait.test.ts | 8 +- ...agent-session-restart-continuation.test.ts | 8 +- ...ured-agent-session-restart-continuation.ts | 92 ++------- ...ent-session-restart-failure-filing.test.ts | 14 +- ...ed-agent-session-restart-failure-ledger.ts | 57 ++---- ...ssion-restart-interruption-test-harness.ts | 28 +-- ...ssion-restart-offer-after-reattach.test.ts | 9 +- ...gent-session-restart-offer-endings.test.ts | 10 - ...t-session-restart-offer-newer-orca.test.ts | 17 +- ...red-agent-session-restart-offer-records.ts | 16 +- ...t-session-restart-offer-withdrawal.test.ts | 43 +---- ...-agent-session-restart-offer-withdrawal.ts | 24 +-- ...ed-agent-session-restart-ownership.test.ts | 39 +--- ...nt-session-restart-provider-output.test.ts | 1 - ...uctured-agent-session-restart-reconcile.ts | 17 +- ...nt-session-restart-recovery-output.test.ts | 2 - ...ured-agent-session-restart-restore.test.ts | 12 +- ...tructured-agent-session-restart-restore.ts | 33 +--- ...d-agent-session-restart-resume-contract.ts | 16 +- ...tured-agent-session-restart-resume-host.ts | 26 +-- ...nt-session-restart-resume-progress.test.ts | 2 +- ...d-agent-session-restart-resume-progress.ts | 4 +- ...red-agent-session-restart-resume-runner.ts | 44 +---- ...ctured-agent-session-restart-resume-set.ts | 60 ++---- ...ent-session-restart-resume-test-harness.ts | 24 +-- ...red-agent-session-restart-resume-wiring.ts | 17 +- ...tured-agent-session-restart-resume.test.ts | 56 +----- ...nt-session-restart-stream-duration.test.ts | 4 +- ...red-agent-session-restart-teardown.test.ts | 12 +- ...d-agent-session-restart-undrivable.test.ts | 5 - ...ed-agent-session-restart-witnesses.test.ts | 2 +- ...uctured-agent-session-restart-witnesses.ts | 14 +- ...ctured-agent-session-resume-eligibility.ts | 17 +- ...-agent-session-resumed-child-owner.test.ts | 6 +- .../structured-agent-session-reveal.test.ts | 13 +- .../structured-agent-session-reveal.ts | 25 +-- ...tured-agent-session-rewind-at-rest.test.ts | 4 - ...red-agent-session-rewind-live-send.test.ts | 4 - ...ed-agent-session-rewind-provenance.test.ts | 9 +- ...t-session-rewind-stop-note.test-fixture.ts | 2 +- .../structured-agent-session-rewind.test.ts | 14 +- .../structured-agent-session-rewind.ts | 7 +- ...tructured-agent-session-send-block.test.ts | 1 - ...red-agent-session-send-idempotency.test.ts | 6 +- ...agent-session-send-open-stale-turn.test.ts | 16 +- ...uctured-agent-session-send-options.test.ts | 7 +- ...ructured-agent-session-send-preparation.ts | 32 +--- ...session-send-restarts-failed-start.test.ts | 28 +-- ...ured-agent-session-send-settlement.test.ts | 1 - ...tructured-agent-session-send-settlement.ts | 10 +- .../structured-agent-session-send.test.ts | 42 ++-- ...-session-settlement-double.test-fixture.ts | 8 +- ...tructured-agent-session-shown-work.test.ts | 3 +- .../structured-agent-session-shown-work.ts | 12 +- ...red-agent-session-shutdown-cut-row.test.ts | 12 +- ...ed-agent-session-stale-state-settlement.ts | 29 +-- ...structured-agent-session-stale-stop-row.ts | 10 +- ...ured-agent-session-stale-turn-open.test.ts | 10 +- ...d-agent-session-stale-turn-verdict.test.ts | 21 +- ...ctured-agent-session-stale-turn-verdict.ts | 48 ++--- ...ured-agent-session-start-admission.test.ts | 25 +-- ...uctured-agent-session-start-eligibility.ts | 5 +- ...uctured-agent-session-start-failure-row.ts | 22 +-- ...agent-session-start-failure-writer.test.ts | 12 +- .../structured-agent-session-start-flight.ts | 21 +- .../structured-agent-session-start-listing.ts | 7 +- ...-agent-session-startup-attempt-contract.ts | 20 +- ...agent-session-startup-attempt-host.test.ts | 11 +- ...sion-startup-attempt-registrations.test.ts | 8 +- ...tructured-agent-session-startup-attempt.ts | 47 ++--- ...agent-session-startup-failure-exit.test.ts | 7 +- ...-session-startup-reconcile-failure.test.ts | 17 +- ...uctured-agent-session-status-child-work.ts | 27 +-- ...ed-agent-session-status-feed-child.test.ts | 1 - ...agent-session-status-feed-children.test.ts | 24 +-- ...ed-agent-session-status-feed-clock.test.ts | 3 - ...ructured-agent-session-status-feed-deps.ts | 20 +- ...agent-session-status-feed-stopping.test.ts | 3 - ...-agent-session-status-feed-test-session.ts | 1 - ...ructured-agent-session-status-feed.test.ts | 29 +-- .../structured-agent-session-status-feed.ts | 74 ++------ ...agent-session-status-journal-projection.ts | 24 +-- ...red-agent-session-status-ownership.test.ts | 9 +- ...ructured-agent-session-status-ownership.ts | 23 +-- ...d-agent-session-status-summary-equality.ts | 8 +- ...structured-agent-session-status-summary.ts | 7 +- ...agent-session-steer-while-stopping.test.ts | 19 +- ...ed-agent-session-stop-aborts-start.test.ts | 16 -- ...tructured-agent-session-stop-acceptance.ts | 25 +-- .../structured-agent-session-stop-cause.ts | 7 +- ...d-agent-session-stop-event-binding.test.ts | 31 +-- ...d-agent-session-stop-event-entries.test.ts | 53 ++---- ...d-agent-session-stop-event-restart.test.ts | 39 +--- ...tructured-agent-session-stop-event.test.ts | 20 +- ...gent-session-stop-note-opened-turn.test.ts | 4 - ...t-session-stop-note-withdrawn-send.test.ts | 6 +- ...ctured-agent-session-stop-receipts.test.ts | 13 -- ...-agent-session-stop-settle-binding.test.ts | 14 +- ...red-agent-session-stop-unconfirmed-note.ts | 8 +- ...structured-agent-session-stop-wind-down.ts | 29 +-- ...agent-session-stopped-history-selection.ts | 7 +- ...ured-agent-session-stopped-history.test.ts | 12 -- ...red-agent-session-stopping-journal.test.ts | 5 - .../structured-agent-session-stopping.test.ts | 43 +---- .../structured-agent-session-stopping.ts | 19 +- ...red-agent-session-subagent-recency.test.ts | 30 +-- ...ctured-agent-session-subagent-rows.test.ts | 7 +- .../structured-agent-session-subagent-rows.ts | 50 ++--- ...ession-subscriber-background-tasks.test.ts | 7 - ...ructured-agent-session-subscribers.test.ts | 14 +- .../structured-agent-session-subscribers.ts | 32 +--- ...uctured-agent-session-taken-stop-settle.ts | 11 +- ...red-agent-session-target-reconcile.test.ts | 1 - ...tructured-agent-session-targeted-cancel.ts | 16 +- .../structured-agent-session-task-queue.ts | 1 - ...ructured-agent-session-thread-goal.test.ts | 6 +- .../structured-agent-session-thread-goal.ts | 23 +-- ...tructured-agent-session-transition.test.ts | 11 +- .../structured-agent-session-transition.ts | 32 +--- ...agent-session-turn-completion-feed.test.ts | 25 +-- ...ured-agent-session-turn-completion-feed.ts | 64 ++----- ...tructured-agent-session-turn-stop-notes.ts | 11 +- .../structured-agent-session-turns-cancel.ts | 94 +++------ .../structured-agent-session-turns-prompt.ts | 16 +- .../structured-agent-session-turns.test.ts | 16 +- .../structured-agent-session-turns.ts | 50 ++--- ...ured-agent-session-unexpected-exit.test.ts | 11 +- ...tructured-agent-session-unfinished-work.ts | 11 +- ...d-agent-session-unnamed-stop-races.test.ts | 9 +- ...t-session-unopened-send-withdrawal.test.ts | 12 +- ...-agent-session-unopened-send-withdrawal.ts | 21 +- ...uctured-agent-session-unopened-tab.test.ts | 7 +- ...ured-agent-session-unrun-send-hold.test.ts | 11 -- ...red-agent-session-unsent-send-hold.test.ts | 64 +------ ...sion-view-start-after-failed-start.test.ts | 21 +- ...t-session-wedged-profile-migration.test.ts | 63 ++---- ...tured-agent-session-wire-admission.test.ts | 8 +- ...tured-agent-session-working-at-teardown.ts | 56 ++---- ...t-session-workspace-sleep-lifetime.test.ts | 8 +- ...ured-agent-session-workspace-sleep-stop.ts | 10 +- ...tructured-agent-session-workspace-sleep.ts | 11 +- ...red-conversation-command-admission.test.ts | 14 +- ...ructured-conversation-command-admission.ts | 27 +-- ...ed-conversation-command-child-work.test.ts | 14 +- ...uctured-conversation-command-controller.ts | 9 +- ...structured-conversation-command-outcome.ts | 14 +- .../structured-conversation-command.test.ts | 6 +- .../structured-conversation-command.ts | 13 +- ...structured-conversation-compaction.test.ts | 44 +---- .../structured-conversation-compaction.ts | 24 +-- .../structured-provider-session-ownership.ts | 3 +- .../structured-rewind-cut.test.ts | 8 +- .../structured-rewind-cut.ts | 38 ++-- .../structured-rewind-recovery.ts | 22 +-- .../structured-rewind-refusal.ts | 3 +- .../unhandled-provider-frame.test.ts | 2 - .../unhandled-provider-frame.ts | 25 +-- .../native-chat/codex-adoption-preparation.ts | 1 - ...t-readable-transcript-path-fs-gate.test.ts | 3 +- .../host-readable-transcript-path.test.ts | 11 +- .../host-readable-transcript-path.ts | 61 ++---- ...native-chat-inline-visuals-setting.test.ts | 1 - ...tive-chat-scratch-folder-migration.test.ts | 2 - .../native-chat-scratch-folder-migration.ts | 2 +- .../native-chat/native-chat-scratch-folder.ts | 13 +- .../native-chat-visual-file-read.test.ts | 4 +- .../native-chat-visual-file-read.ts | 32 +--- .../native-chat-visuals-delivery.test.ts | 2 - .../native-chat-visuals-delivery.ts | 16 +- .../native-chat-visuals-skill-location.ts | 13 +- .../native-chat-visuals-sweep.test.ts | 1 - .../native-chat/native-chat-visuals-sweep.ts | 22 +-- .../native-chat/session-file-id-layouts.ts | 13 +- ...session-file-resolver-claude-roots.test.ts | 10 +- .../session-file-resolver-codex-roots.test.ts | 6 +- ...ession-file-resolver-wsl-scan-gate.test.ts | 3 +- .../session-file-resolver-wsl.test.ts | 8 +- .../native-chat/session-file-resolver.test.ts | 21 +- src/main/native-chat/session-file-resolver.ts | 82 ++------ .../native-chat/ssh-transcript-fs-access.ts | 3 +- .../native-chat/ssh-transcript-path.test.ts | 1 - src/main/native-chat/ssh-transcript-path.ts | 8 +- .../structured-agent-arguments-error.ts | 3 +- ...ed-agent-cli-conversation-adoption.test.ts | 9 +- .../structured-agent-cli-conversations.ts | 50 ++--- .../structured-agent-cli-source.ts | 5 +- ...tructured-agent-command-guest-path.test.ts | 2 +- .../structured-agent-command-resolution.ts | 20 +- .../structured-agent-configured-args.ts | 2 +- ...structured-agent-session-create-support.ts | 6 +- ...red-agent-session-history-adoption.test.ts | 4 +- ...ructured-agent-session-history-adoption.ts | 42 +--- ...red-agent-session-source-admission.test.ts | 1 - .../native-chat/structured-chat-naming.ts | 19 +- .../native-chat/subagent-entry-id-bounds.ts | 15 +- .../native-chat/transcript-file-order.test.ts | 4 +- src/main/native-chat/transcript-file-order.ts | 7 +- .../transcript-incremental-reader.ts | 6 +- ...ine-decoders-claude-command-output.test.ts | 3 +- ...ne-decoders-claude-image-companion.test.ts | 7 +- .../transcript-line-decoders-claude.ts | 38 +--- .../transcript-line-decoders-codex.ts | 7 +- .../transcript-line-decoders-grok.ts | 22 +-- .../transcript-line-decoders-omp.ts | 59 ++---- .../transcript-line-decoders.omp.test.ts | 17 +- .../native-chat/transcript-line-decoders.ts | 11 +- .../transcript-opencode-part-blocks.ts | 6 +- .../transcript-opencode-sqlite-query.test.ts | 36 +--- .../transcript-opencode-sqlite-query.ts | 10 +- .../transcript-opencode-subscribe.ts | 26 +-- .../native-chat/transcript-opencode.test.ts | 6 +- src/main/native-chat/transcript-opencode.ts | 4 +- .../native-chat/transcript-path-admission.ts | 3 +- ...script-read-cache-refusal-recovery.test.ts | 7 +- .../transcript-read-cache-wsl-stall.test.ts | 7 +- .../native-chat/transcript-read-cache.test.ts | 38 +--- src/main/native-chat/transcript-read-cache.ts | 62 +----- .../native-chat/transcript-reader.test.ts | 9 - src/main/native-chat/transcript-reader.ts | 18 +- .../native-chat/transcript-record-blocks.ts | 8 +- .../native-chat/transcript-tail-boundary.ts | 9 +- .../transcript-tail-reader-wsl-gate.test.ts | 6 +- .../native-chat/transcript-tail-reader.ts | 23 +-- .../transcript-turn-lifecycle.test.ts | 2 - .../native-chat/transcript-turn-lifecycle.ts | 24 +-- .../native-chat/transcript-turn-markers.ts | 14 +- .../native-chat/transcript-watch-contract.ts | 8 +- .../native-chat/transcript-watch-engine.ts | 20 +- .../transcript-watch-error.test.ts | 7 +- ...ranscript-watch-inplace-drain-race.test.ts | 1 - .../transcript-watch-liveness.test.ts | 5 +- .../transcript-watch-resolve-poll.test.ts | 5 +- .../native-chat/transcript-watch-scheduler.ts | 3 +- .../transcript-watch-unflushed-settle.test.ts | 12 +- .../transcript-watch-wsl-stall.test.ts | 17 +- src/main/native-chat/transcript-watch.test.ts | 35 +--- src/main/native-chat/transcript-watch.ts | 84 ++------ ...transcript-window-tool-attribution.test.ts | 7 - src/main/native-chat/wsl-session-path-scan.ts | 5 +- .../wsl-transcript-fs-access.test.ts | 18 +- .../native-chat/wsl-transcript-fs-access.ts | 52 ++--- .../native-chat/wsl-transcript-fs-error.ts | 2 - .../wsl-transcript-fs-gate.test.ts | 30 +-- .../native-chat/wsl-transcript-fs-gate.ts | 52 ++--- .../wsl-transcript-fs-process-client.test.ts | 13 +- .../wsl-transcript-fs-process-client.ts | 20 +- .../wsl-transcript-fs-process-decode.ts | 4 +- .../wsl-transcript-fs-process-dispatch.ts | 11 +- .../wsl-transcript-fs-process-entry.ts | 5 +- .../wsl-transcript-fs-process-protocol.ts | 8 +- .../wsl-transcript-fs-process-slot.ts | 12 +- .../wsl-transcript-fs-process-spawn.ts | 9 +- ...wsl-transcript-fs-route-quarantine.test.ts | 10 - .../wsl-transcript-fs-route-quarantine.ts | 23 +-- .../native-chat/wsl-transcript-fs-route.ts | 11 +- .../wsl-transcript-running-observer.ts | 4 +- .../wsl-transcript-watcher-running-guard.ts | 2 +- 1650 files changed, 6363 insertions(+), 18970 deletions(-) diff --git a/src/main/browser/browser-managed-session-profiles.ts b/src/main/browser/browser-managed-session-profiles.ts index 98353d78278..923f4fbbd08 100644 --- a/src/main/browser/browser-managed-session-profiles.ts +++ b/src/main/browser/browser-managed-session-profiles.ts @@ -15,12 +15,11 @@ function findManagedProfile(owner: ManagedBrowserProfileOwner): BrowserSessionPr .find((profile) => isManagedBrowserProfileOf(profile, owner)) } -/** A repo's or workspace's own profile, created on first use; concurrent callers share one creation. */ +/** Coalesce first-use profile creation for each owner. */ export async function ensureManagedBrowserProfile( owner: ManagedBrowserProfileOwner, label: string ): Promise { - // Why: this is an IPC boundary, so the renderer's values are checked before they are persisted. if ( (owner?.kind !== 'repo' && owner?.kind !== 'worktree') || typeof owner.id !== 'string' || diff --git a/src/main/external-conversations/external-conversation-events.ts b/src/main/external-conversations/external-conversation-events.ts index 89bb257a11c..3b745a2f41a 100644 --- a/src/main/external-conversations/external-conversation-events.ts +++ b/src/main/external-conversations/external-conversation-events.ts @@ -30,7 +30,7 @@ export type ConversationEvent = | (ConversationPane & { kind: 'turn-started'; at: number }) | (ConversationPane & { kind: 'turn-ended' - /** The host's recorded verdict on the turn; null when it recorded none. */ + /** Host verdict; null means none was recorded. */ stopReason: AgentTurnOutcome | null lastAssistantMessage?: string at: number @@ -51,14 +51,13 @@ export type ConversationEventsDeps = { store: ExternalConversationLinkStore readHookRow: (paneKey: string) => AgentStatusIpcPayload | null readBinding: (paneKey: string) => AgentPromptExpectedBinding | null - /** The detector's pane changes; returns the release. */ onAgentPaneChange: (listener: (change: AgentPaneChange) => void) => () => void pendingPrompt: (link: ExternalConversationLink) => PendingPrompt | null setTimer: (run: () => void, delayMs: number) => { cancel: () => void } now: () => number } -/** The real clock and an unref'd timer, so a pending finish never holds the process open. */ +// Pending finishes must not keep the process alive. export const HOST_CLOCK: Pick = { setTimer: (run, delayMs) => { const timer = setTimeout(run, delayMs) @@ -68,11 +67,7 @@ export const HOST_CLOCK: Pick = { now: Date.now } -/** - * Linked agents' turns and prompts for connector watchers. Turn news is the phone push's own - * (`TerminalAgentTurnNews`), and a finish counts only when the idle verdict automations use agrees. - * Subscribed only while someone watches. - */ +/** Watch-scoped turn news shares mobile push detection and automation completion verdicts. */ export class ConversationEvents { private readonly listeners = new Set() private readonly turnStartedAt = new Map() @@ -105,7 +100,7 @@ export class ConversationEvents { } } this.listeners.add(listener) - // After subscribing, so a change while the snapshot is read is not missed. + // Subscribe before reading the snapshot to avoid losing intervening changes. listener(this.snapshot()) return () => { this.listeners.delete(listener) @@ -235,7 +230,7 @@ export class ConversationEvents { } return } - // A hook-reported `done` since this turn began; a reused pane's earlier one is not this turn's. + // Only done signals since this turn began count; reused panes can retain earlier verdicts. const verdict = judgeIdleAgentTurn( { rows: [item.row], @@ -277,7 +272,7 @@ export class ConversationEvents { private emit(event: ConversationEvent): void { for (const listener of this.listeners) { - // One watcher's failure must not cost the others the event. + // Isolate listener failures so other watchers still receive the event. try { listener(event) } catch (error) { diff --git a/src/main/external-conversations/external-conversation-prompts.ts b/src/main/external-conversations/external-conversation-prompts.ts index 4d3364c0baa..efb4c98c2a0 100644 --- a/src/main/external-conversations/external-conversation-prompts.ts +++ b/src/main/external-conversations/external-conversation-prompts.ts @@ -16,10 +16,7 @@ import type { Delivery } from './external-conversation-types' export type PromptTerminal = TerminalAgentPromptAnswerRuntime & LaunchedAgentWriteGuardRuntime -/** - * A linked agent's pending prompt and its answer, through the host's shared prompt answers. Not on - * the conversation's queue: a follow-up held behind an approval waits for exactly this answer. - */ +/** Answer outside the conversation queue: a queued follow-up can itself be waiting on this approval. */ export class ConversationPrompts { private readonly answers: TerminalAgentPromptAnswers diff --git a/src/main/ipc/filesystem-watcher-lifecycle-model.test.ts b/src/main/ipc/filesystem-watcher-lifecycle-model.test.ts index e339e47596a..dbc45a1629a 100644 --- a/src/main/ipc/filesystem-watcher-lifecycle-model.test.ts +++ b/src/main/ipc/filesystem-watcher-lifecycle-model.test.ts @@ -1,4 +1,3 @@ -// Randomized model test for local watcher listener bookkeeping across watch churn and worktree removals. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createWatcherSender, fireWatcherSenderEvent } from './filesystem-watcher-test-sender' @@ -30,9 +29,9 @@ import { type Handler = (event: unknown, args: unknown) => unknown type Sender = ReturnType & { destroyed: boolean } type SubscribeOutcome = 'ok' | 'capacity' -// Why 'wedged': a stuck watcher child ignores abort, so removals must fall back to their drain deadline. +// Wedged children ignore abort; removal must still meet its deadline. type SubscribeDelay = 'none' | 'abortable' | 'wedged' -// Why 'rejected': a watch refused by an active removal may still come back from that removal's restore. +// A removal-refused watch can return through that removal's restore. type Intent = 'yes' | 'no' | 'rejected' type Removal = { path: string @@ -42,7 +41,7 @@ type Removal = { const ROOTS = ['/repo', '/other'] const OUTCOMES: SubscribeOutcome[] = ['ok', 'ok', 'ok', 'capacity'] const DELAYS: SubscribeDelay[] = ['none', 'none', 'abortable', 'abortable', 'abortable', 'wedged'] -// Why: CI keeps this quick; raise ORCA_WATCH_MODEL_RUNS locally for deeper searches. +// Increase ORCA_WATCH_MODEL_RUNS for deeper local searches. const RUNS = Number(process.env.ORCA_WATCH_MODEL_RUNS ?? 1000) const MIN_STEPS = 20 const MAX_STEPS = 60 @@ -89,7 +88,6 @@ describe('local watcher lifecycle (randomized model)', () => { } } - // Like the watcher process, a pending subscribe rejects as soon as its install is aborted. function waitForRelease(signal: AbortSignal | undefined, delay: SubscribeDelay): Promise { return new Promise((resolve, reject) => { const done = (): void => { @@ -170,7 +168,7 @@ describe('local watcher lifecycle (randomized model)', () => { return sender } const senders: Sender[] = [makeSender(), makeSender(), makeSender()] - // The renderer's last request per sender+root; missing = never asked. + // Last requested intent per sender/root; absent means never requested. const wanted = new Map() const lastWatchOp = new Map() const want = (senderId: number, key: string): string => `${senderId}|${key}` @@ -181,7 +179,7 @@ describe('local watcher lifecycle (randomized model)', () => { const failed = (message: string): string => `${message}\n${trace.join('\n')}` const liveSenders = (): Sender[] => senders.filter((candidate) => !candidate.destroyed) - // Removals go through the real coordinator so the install fence and gate release order apply. + // Use the real coordinator to exercise install fences and removal gate ordering. const startRemoval = (path: string): void => { const removal: Removal = { path, finish: null } removals.push(removal) @@ -200,12 +198,11 @@ describe('local watcher lifecycle (randomized model)', () => { const finishRemoval = (removal: Removal, removed: boolean): void => { removals.splice(removals.indexOf(removal), 1) trace.push(`finish removal ${removal.path} (${removed ? 'succeeds' : 'fails, restore'})`) - // Why not awaited: the restore re-subscribes, which may itself be a deferred install. + // Restore may start another deferred subscription; keep it in flight. inFlight.push(removal.finish!(removed)) if (!removed) { return } - // The renderer drops the deleted workspace's watches. const key = rootKey(removal.path) for (const owner of liveSenders()) { if (wanted.has(want(owner.id, key))) { @@ -281,7 +278,7 @@ describe('local watcher lifecycle (randomized model)', () => { } else if (roll < 0.91 && removals.some((removal) => removal.finish)) { finishRemoval(pick(removals.filter((removal) => removal.finish)), rand() < 0.5) } else if (roll < 0.97) { - // A reload keeps the WebContents id but starts a new page lifetime with no watches. + // Reload preserves WebContents ID but retires the page's watch lifetime. const closes = rand() < 0.5 trace.push(`window s${sender.id} ${closes ? 'closes' : 'reloads'}`) sender.destroyed = closes @@ -331,7 +328,6 @@ describe('local watcher lifecycle (randomized model)', () => { } } - // Unwatching everything and waiting out the grace leaves no roots or native handles. for (const sender of liveSenders()) { for (const worktreePath of ROOTS) { wanted.set(want(sender.id, rootKey(worktreePath)), 'no') diff --git a/src/main/ipc/filesystem-watcher-lifecycle-state.ts b/src/main/ipc/filesystem-watcher-lifecycle-state.ts index d975dade3bc..f9081ac8f42 100644 --- a/src/main/ipc/filesystem-watcher-lifecycle-state.ts +++ b/src/main/ipc/filesystem-watcher-lifecycle-state.ts @@ -1,11 +1,6 @@ import type { WebContents } from 'electron' import type { WatchedRoot } from './filesystem-watcher-wsl' -// ── Per-root watcher state ─────────────────────────────────────────── -// WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. - -// ── Module state ───────────────────────────────────────────────────── - export type LocalWatcherInstallToken = { cancelled: boolean listeners: Map @@ -19,37 +14,35 @@ export type LocalWatcherCapacityRetry = { cancelWait: () => void } -// Why: cache roots that failed watcher creation (e.g. WSL UNC paths) so we don't retry every worktree switch and spam the console with errors. +// Cache failed roots to suppress repeated creation attempts and error spam on switches. export const UNWATCHABLE_ROOT_CACHE_MAX = 256 -// Why: recreating @parcel/watcher on Windows is expensive (ReadDirectoryChangesW + AV, 500ms+); a 30s grace lets rapid switches reuse the watcher. +// Windows watcher setup can cost 500ms+; retain a grace window for rapid switches. export const WATCHER_TEARDOWN_GRACE_MS = 30_000 -// Why: a sender waiting out a cancelled install is in no listener map; unwatch and removal still need to reach it. +// Cancelled-install waiters need separate ownership while absent from listener maps. export type CancelledInstallWaiter = { token: symbol; sender: WebContents } export type CancelledInstallWaiters = Map> export const watcherLifecycleState = { watchedRoots: new Map(), unwatchableRoots: new Set(), - // Why: key cleanup by sender WebContents (not per root) to avoid MaxListeners warnings when a workspace has many worktrees open. + // One lifetime per WebContents avoids per-root MaxListeners warnings. senderLifetimes: new Map void }>(), pendingTeardowns: new Map>(), - // Why: @parcel/watcher unsubscribe does native async work that sender-destroy can start before shutdown, so will-quit must still await it. + // Shutdown must await native unsubscribe work already started by destroyed senders. pendingLocalUnsubscribes: new Set>(), pendingLocalUnsubscribesByRoot: new Map>>(), suspendedLocalWatcherListeners: new Map< string, { worktreePath: string; listeners: Map } >(), - // Why: an install cancelled by shutdown can't be revived by a waiter that resumes after a later call reopens the subsystem. + // Generation fencing prevents reopened subsystems from reviving retired waiters. localWatchersClosed: false, localWatcherLifecycleGeneration: 0, failedLocalUnsubscribes: new Map(), - // Why: a drain that timed out no longer gates the delete — Git removal already proceeded past it. Its - // late failure must not fail-close a *later* close of the same root, which would leave that path - // undeletable until the watcher process physically exits. + // A timed-out drain no longer owns deletion; late failures must not make later removal impossible. abandonedLocalUnsubscribes: new WeakSet>(), - // Why: watcher creation is async; concurrent watch requests for the same root must share one install or later resolves orphan listeners. + // Share async installs so concurrent resolutions cannot orphan listeners. inFlightLocalInstalls: new Map(), pendingLocalInstallPromises: new Map>(), pendingLocalCapacityRetries: new Map(), diff --git a/src/main/ipc/filesystem-watcher-listener-lifecycle.ts b/src/main/ipc/filesystem-watcher-listener-lifecycle.ts index c573534e42a..b6525eefc41 100644 --- a/src/main/ipc/filesystem-watcher-listener-lifecycle.ts +++ b/src/main/ipc/filesystem-watcher-listener-lifecycle.ts @@ -13,7 +13,7 @@ import { captureWatcherSenderLifetime } from './filesystem-watcher-sender-lifeti export function rememberUnwatchableRoot(rootKey: string): void { const { unwatchableRoots } = watcherLifecycleState - // Why: deleted worktrees churn through unique paths; cap the set so retry suppression stays useful without retaining every failed path. + // Bound failed-path history as deleted worktrees churn through unique roots. unwatchableRoots.delete(rootKey) unwatchableRoots.add(rootKey) while (unwatchableRoots.size > UNWATCHABLE_ROOT_CACHE_MAX) { @@ -52,7 +52,7 @@ export function trackDetachedLocalUnsubscribe(rootKey: string, root: WatchedRoot }) watcherLifecycleState.pendingLocalUnsubscribes.add(unsubscribePromise) rootUnsubscribes.add(unsubscribePromise) - // Why: swallow here to avoid unhandled rejections, but keep the original promise rejected so later destructive cleanup can fail closed. + // Handle background rejection while preserving failure for destructive cleanup. void unsubscribePromise.catch((error: unknown) => { if (!watcherLifecycleState.abandonedLocalUnsubscribes.has(unsubscribePromise)) { retainLocalWatcherPhysicalFailure(rootKey, error) @@ -62,7 +62,7 @@ export function trackDetachedLocalUnsubscribe(rootKey: string, root: WatchedRoot return unsubscribePromise } -/** Mark unsubscribes whose drain timed out so their late failures stay out of failedLocalUnsubscribes. */ +/** Exclude late timed-out drain failures from later removal attempts. */ export function abandonLocalUnsubscribes(promises: Iterable>): void { for (const promise of promises) { watcherLifecycleState.abandonedLocalUnsubscribes.add(promise) @@ -86,7 +86,6 @@ function cleanupInFlightLocalInstallsForSender(senderId: number): void { token.listeners.delete(senderId) if (token.listeners.size === 0) { token.cancelled = true - // Why: abort so a pending native/forked subscription stops early (matches closeLocalWatcherForWorktreePath / closeAllWatchers). token.abortController.abort() } } @@ -136,7 +135,7 @@ export function addCancelledInstallWaiter( return token } -/** Removes the waiter and reports whether it was still `token`'s, i.e. not withdrawn meanwhile. */ +/** Remove only a waiter still owned by this token. */ export function takeCancelledInstallWaiter( waitersByKey: CancelledInstallWaiters, key: string, @@ -185,7 +184,6 @@ function cleanupLocalWatchersForSender(senderId: number): void { } watchedRoot.listeners.delete(senderId) if (watchedRoot.listeners.size === 0) { - // Cancel any pending grace-period teardown for this root. const pending = watcherLifecycleState.pendingTeardowns.get(key) if (pending) { clearTimeout(pending) diff --git a/src/main/ipc/filesystem-watcher-local-capacity.ts b/src/main/ipc/filesystem-watcher-local-capacity.ts index ed806c7c9f5..9860abe2767 100644 --- a/src/main/ipc/filesystem-watcher-local-capacity.ts +++ b/src/main/ipc/filesystem-watcher-local-capacity.ts @@ -43,7 +43,7 @@ export function scheduleLocalCapacityRetry( console.error(`[filesystem-watcher] capacity retry failed for ${rootKey}:`, error) } }) - // Why: the IPC call returned before this install, so writes in between were never reported. + // Writes after IPC returned but before installation require a resync. const root = watcherLifecycleState.watchedRoots.get(rootKey) if (root?.listeners.has(listener.id) && !listener.isDestroyed()) { listener.send('fs:changed', { diff --git a/src/main/ipc/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem-watcher-local-events.test.ts index a6a4bbb7e6d..082f0c027c8 100644 --- a/src/main/ipc/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem-watcher-local-events.test.ts @@ -265,7 +265,7 @@ describe('local filesystem watcher flush serialization', () => { paths.map((path) => ({ type: 'update' as const, path })) ) vi.advanceTimersByTime(WATCH_BATCH_INTERVAL_MS) - // Why a loop, not a fixed microtask count: 5,000 stats through 8 lanes take many turns. + // Bounded stat lanes take many microtasks; wait on the observable send. for (let i = 0; i < eventCount * 4 && sender.send.mock.calls.length === 0; i++) { await Promise.resolve() } @@ -333,7 +333,6 @@ describe('local filesystem watcher flush serialization', () => { expect(sender.send).toHaveBeenCalledTimes(1) expect(statMock).toHaveBeenCalledTimes(1) - // The still-open window coalesces the create away instead of emitting a transient one. watcherCallback?.(null, [ { type: 'delete', path: transientPath }, { type: 'update', path: otherPath } @@ -350,7 +349,7 @@ describe('local filesystem watcher flush serialization', () => { }) it('re-arms the flush window after a re-subscribe inside the teardown grace period', async () => { - // Why real timers: fake-timers' refresh() revives a cleared handle, but Node's is a no-op — the bug only shows on real Timeouts. + // Fake refresh revives cleared handles; this regression needs real Node Timeout behavior. vi.useRealTimers() statMock.mockResolvedValue({ isDirectory: () => true }) const listener = { ...sender, id: 7, removeListener: vi.fn(), once: vi.fn() } diff --git a/src/main/ipc/filesystem-watcher-local-events.ts b/src/main/ipc/filesystem-watcher-local-events.ts index 274e9e5b051..ef6fb69effa 100644 --- a/src/main/ipc/filesystem-watcher-local-events.ts +++ b/src/main/ipc/filesystem-watcher-local-events.ts @@ -3,7 +3,7 @@ import type { Event as WatcherEvent } from '@parcel/watcher' import type { FsChangeEvent, FsChangedPayload } from '../../shared/filesystem-entry-types' import { watchBatchFlushDelayMs } from '../../shared/filesystem-watch-batch-window' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' -// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). +// Filter high-churn events at source; Explorer display filters only hide rows. import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' import type { WatchedRoot } from './filesystem-watcher-wsl' import { subscribeViaWatcherProcess } from './parcel-watcher-process' @@ -20,11 +20,10 @@ import { notifyLocalWatcherInterrupted } from './local-watcher-event-observers' -// Why: matches the watcher subprocess budget in parcel-watcher-event-delivery.ts. +// Match the watcher subprocess stat budget. const DIRECTORY_STAT_CONCURRENCY = 8 -// ── Event coalescing ───────────────────────────────────────────────── -// Why: keep the last event per path in a flush window; delete→create emits both (delete cleans the subtree, create refreshes the parent), create→delete is dropped (§4.4). +// delete→create retains both for subtree/parent refresh; create→delete cancels within the window. function coalesceEvents( raw: WatcherEvent[] ): { type: 'create' | 'update' | 'delete'; path: string }[] { @@ -37,11 +36,9 @@ function coalesceEvents( const prev = lastByPath.get(p) if (prev) { - // delete followed by create → emit both if (prev.type === 'delete' && evt.type === 'create') { deleteBeforeCreate.add(p) } - // create followed by delete → net no-op, remove both if (prev.type === 'create' && evt.type === 'delete') { lastByPath.delete(p) deleteBeforeCreate.delete(p) @@ -51,7 +48,7 @@ function coalesceEvents( lastByPath.set(p, { type: evt.type, index: i }) - // Why: drop the stale delete when a later event supersedes delete→create, else output has a spurious delete for a file that still exists (§4.4). + // Later events can supersede delete→create; do not retain its stale delete. if (evt.type !== 'create' && deleteBeforeCreate.has(p)) { deleteBeforeCreate.delete(p) } @@ -59,12 +56,10 @@ function coalesceEvents( const result: { type: 'create' | 'update' | 'delete'; path: string }[] = [] - // Emit delete events first for paths that have delete→create for (const p of deleteBeforeCreate) { result.push({ type: 'delete', path: p }) } - // Emit the last event for each path for (const [p, entry] of lastByPath) { result.push({ type: entry.type, path: p }) } @@ -72,20 +67,16 @@ function coalesceEvents( return result } -// ── Stat helper for isDirectory ────────────────────────────────────── - async function tryStatIsDirectory(filePath: string): Promise { try { const s = await stat(filePath) return s.isDirectory() } catch { - // Why: stat failure (EPERM, vanished file) → undefined; renderer treats it as a file event, the safe default (§4.4). + // Failed stat leaves the renderer's conservative file-event default. return undefined } } -// ── Flush and emit ─────────────────────────────────────────────────── - function emitOverflowPayload(root: WatchedRoot): void { const { rootPath } = root const payload: FsChangedPayload = { @@ -124,7 +115,7 @@ async function flushBatch(root: WatchedRoot): Promise { } if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) { - // Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh. + // A large deletion storm needs one conservative refresh instead of per-path coalescing and stat. if (!root.batch.cancelled) { emitOverflowPayload(root) } @@ -133,13 +124,12 @@ async function flushBatch(root: WatchedRoot): Promise { const coalesced = coalesceEvents(rawEvents) - // Why: a full batch is up to MAX_BATCHED_WATCHER_EVENTS paths; unbounded stat() would swamp - // libuv's 4-thread pool, which also serves git reads and persistence writes. + // Bound stat concurrency so Git and persistence can still use libuv's shared pool. const events: FsChangeEvent[] = await mapWithConcurrency( coalesced, DIRECTORY_STAT_CONCURRENCY, async (evt) => { - // Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache. + // Deleted paths cannot be statted; let the renderer infer from dirCache. const isDirectory = root.batch.cancelled || evt.type === 'delete' ? undefined @@ -173,12 +163,11 @@ async function flushBatch(root: WatchedRoot): Promise { root.batch.flushQueued = false if ( !root.batch.cancelled && - // Why: an armed timer still owns its flush window; draining here would split related events across payloads. + // An armed flush still owns its window; draining early would split related events. !root.batch.timer && (root.batch.events.length > 0 || root.batch.overflowed) ) { - // Drain the queued batch only after the current payload has settled, - // preserving watcher event ordering without dropping a storm tail. + // Drain after the current payload settles to preserve event order and the storm tail. void flushBatch(root) } } @@ -193,11 +182,11 @@ export function scheduleLocalBatchFlush(root: WatchedRoot): void { root.batch.flushQueued = true } - // Why: never push an armed flush back; resetting on each event starved delivery under continuous writes. + // Resetting armed timers would starve delivery under continuous writes. if (root.batch.timer) { return } - // Why: clear the handle as it fires so `batch.timer` means "a flush window is still open", which gates the queued drain. + // Null the fired timer so queued drain sees that the flush window closed. root.batch.timer = setTimeout( () => { root.batch.timer = null @@ -207,8 +196,6 @@ export function scheduleLocalBatchFlush(root: WatchedRoot): void { ) } -// ── Watcher creation ───────────────────────────────────────────────── - export async function createLocalWatcher( rootKey: string, rootPath: string, @@ -222,12 +209,12 @@ export async function createLocalWatcher( } try { - // Why: if the error callback cleaned up before subscribe() resolved, its returned subscription is orphaned and leaks a native handle. + // Errors can precede subscribe resolution; retain cleanup evidence to retire the orphan handle. let errorCleanedUp = false const watcherOptions = { ...buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS), - // Why: Parcel probes Watchman first, which prints a shell-level "watchman not recognized" error on Windows; pin the backend to suppress it. + // Pin Windows backend to avoid Parcel's noisy Watchman probe. ...(process.platform === 'win32' ? { backend: 'windows' as const } : {}) } @@ -237,18 +224,18 @@ export async function createLocalWatcher( scheduleLocalBatchFlush(root) } - // Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap. + // Isolate watcher.node teardown crashes in a child; interruptions require overflow refresh (#7547). root.subscription = await subscribeViaWatcherProcess( rootPath, (err, events) => { if (err) { - // Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3). + // Errors invalidate path precision; force a conservative renderer refresh. console.error(`[filesystem-watcher] error for ${rootKey}:`, err) notifyLocalWatcherInterrupted(root) emitOverflowPayload(root) - // Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3). + // Retire native subscriptions that may be invalid after errors or root deletion. cancelLocalBatchFlush(root) - // Why: error callback can fire before subscribe() assigns root.subscription; guard against null so cleanup doesn't crash. + // The error callback can precede assignment of the subscription. if (root.subscription) { retainLocalWatcherPhysicalFailure(rootKey, err) void trackDetachedLocalUnsubscribe(rootKey, root) @@ -275,13 +262,13 @@ export async function createLocalWatcher( } ) - // Why: error callback already cleaned up watchedRoots before subscribe() resolved; unsubscribe this orphaned subscription so it doesn't leak. + // subscribe resolved after error cleanup; retire its orphaned native handle. if (errorCleanedUp) { void trackDetachedLocalUnsubscribe(rootKey, root) throw new Error(`Watcher for ${rootKey} errored during subscribe`) } } catch (err) { - // Why: watcher backend can throw synchronously on a deleted root/permission error; log rather than crash the main process (§7.3). + // Surface synchronous subscription failures through the caller rather than crashing main. console.error(`[filesystem-watcher] failed to subscribe ${rootKey}:`, err) throw err } diff --git a/src/main/ipc/filesystem-watcher-local-install.ts b/src/main/ipc/filesystem-watcher-local-install.ts index da0106cbb1f..b9ee94f9e85 100644 --- a/src/main/ipc/filesystem-watcher-local-install.ts +++ b/src/main/ipc/filesystem-watcher-local-install.ts @@ -48,7 +48,7 @@ export async function installLocalWatcher( } try { - // Why: WSL paths use one snapshot subprocess inside the distro so `wsl --shutdown` can kill it; native Windows uses @parcel/watcher. + // Run WSL snapshots inside the distro so shutdown kills them; native Windows uses Parcel. root = isWslPath(worktreePath) ? await createWslWatcher( rootKey, @@ -62,7 +62,7 @@ export async function installLocalWatcher( ) : await createLocalWatcher(rootKey, rootPath, cancelToken.abortController.signal) } catch (error) { - // Why: setup can fail after its child misses the exit deadline; retain that owner even when the renderer-facing error is swallowed. + // Retain physical ownership if setup failed before the child proved exit. retainLocalWatcherPhysicalFailure(rootKey, error) if (cancelToken.cancelled) { if (isWatcherProcessFailure(error) && error.code === 'process_unavailable') { @@ -70,13 +70,12 @@ export async function installLocalWatcher( } return 'cancelled' } - // Why: capacity is transient — allow retry once another child exits instead of caching this root as permanently failed. + // Capacity failures are retryable when a child exits, not permanently unwatchable roots. if (error instanceof WatcherChildCapacityError) { scheduleCapacityRetry(cancelToken.listeners) return 'capacity' } - // Why: the crash fuse half-opens after its cooldown; caching these would - // keep the root unwatched until shutdown. + // Crash fuses can half-open after cooldown; do not permanently cache them. if (!isCrashFuseFailure(error)) { rememberUnwatchableRoot(rootKey) } diff --git a/src/main/ipc/filesystem-watcher-local-removal.ts b/src/main/ipc/filesystem-watcher-local-removal.ts index 2b3484df4be..e9901330136 100644 --- a/src/main/ipc/filesystem-watcher-local-removal.ts +++ b/src/main/ipc/filesystem-watcher-local-removal.ts @@ -40,7 +40,7 @@ export async function closeLocalWatcherForWorktreePath( } } } - // Why: a waiter's retry would install after this close; the restore re-subscribes it instead. + // Restore owns retries after this close; waiters must not install independently. watcherLifecycleState.cancelledLocalInstallWaiters.delete(rootKey) if (suspended.listeners.size > 0) { watcherLifecycleState.suspendedLocalWatcherListeners.set(rootKey, suspended) @@ -54,12 +54,12 @@ export async function closeLocalWatcherForWorktreePath( const inFlight = watcherLifecycleState.inFlightLocalInstalls.get(rootKey) if (inFlight) { - // Why: Windows locks watched directories; deletion must cancel an in-flight subscription before Git removes the tree. + // Windows locks watched directories; cancel installs before Git deletes their roots. inFlight.listeners.clear() inFlight.cancelled = true inFlight.abortController.abort() } - // Why: abort alone is not enough if the native subscribe never settles; bound so delete cannot hang the app. + // Abort cannot settle wedged native subscriptions; bound removal drains. const pendingInstall = watcherLifecycleState.pendingLocalInstallPromises.get(rootKey) const installDrain = await drainBeforeWatcherRemoval( pendingInstall, @@ -68,9 +68,7 @@ export async function closeLocalWatcherForWorktreePath( { reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS } ) if (installDrain === 'timeout') { - // Why: an abandoned install never runs its own cleanup, so leaving these entries would make every - // later watch of this root queue behind the same wedged promise. Identity-checked so a late settle - // can't evict a newer install. + // Retire abandoned install slots by identity so later watches do not inherit a wedge or lose newer installs. if (watcherLifecycleState.pendingLocalInstallPromises.get(rootKey) === pendingInstall) { watcherLifecycleState.pendingLocalInstallPromises.delete(rootKey) } @@ -82,8 +80,7 @@ export async function closeLocalWatcherForWorktreePath( if (pendingUnsubscribes) { const draining = Array.from(pendingUnsubscribes) const unsubscribeDrain = await drainBeforeWatcherRemoval( - // Why the per-promise catch: an already-abandoned unsubscribe belongs to a delete that finished - // without it; re-raising its rejection here would fail a later close on stale news. + // Timed-out prior drains must not fail a later close with stale rejections. Promise.all( draining.map((unsubscribe) => watcherLifecycleState.abandonedLocalUnsubscribes.has(unsubscribe) @@ -109,9 +106,7 @@ export async function closeLocalWatcherForWorktreePath( } cancelLocalBatchFlush(root) watcherLifecycleState.watchedRoots.delete(rootKey) - // Why: the in-process Parcel fallback has no unsubscribe timeout of its own, so an unbounded await - // here would hang delete forever and hold the removal gate. The promise stays tracked in - // pendingLocalUnsubscribesByRoot, so a later close still observes its failure. + // Bound fallback unsubscribe too; retain its promise so later closes can observe non-abandoned failures. const finalUnsubscribe = trackDetachedLocalUnsubscribe(rootKey, root) const finalDrain = await drainBeforeWatcherRemoval( finalUnsubscribe, @@ -140,12 +135,11 @@ export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string })) const suspendedByKey = watcherLifecycleState.suspendedLocalWatcherListeners for (const { sender, signal } of owners) { - // Why: a forgotten or replaced snapshot belongs to a newer removal; this restore must not consume it. + // Replaced snapshots belong to a newer removal. if (suspendedByKey.get(rootKey) !== suspended) { break } - // Why: owners stay suspended until their turn, so an unwatch during an earlier owner's re-subscribe still withdraws them. - // Why lifetime first: a reloaded window keeps its id, and its new entry is not this restore's to take. + // Withdraw owners only at their turn; reject old page lifetimes before consuming reloaded-window entries. if (!isCurrentWatcherSender(sender, signal) || !suspended.listeners.delete(sender.id)) { continue } @@ -166,7 +160,7 @@ export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string failures.push({ sender, signal, error }) } } - // Why: an overlapping retried removal may have re-suspended owners into this same entry. + // A retried removal may have added owners back into this snapshot. if (suspendedByKey.get(rootKey) === suspended && suspended.listeners.size === 0) { suspendedByKey.delete(rootKey) } diff --git a/src/main/ipc/filesystem-watcher-local-subscription.ts b/src/main/ipc/filesystem-watcher-local-subscription.ts index dcd4b838c20..395c172378d 100644 --- a/src/main/ipc/filesystem-watcher-local-subscription.ts +++ b/src/main/ipc/filesystem-watcher-local-subscription.ts @@ -23,8 +23,6 @@ import { scheduleLocalCapacityRetry } from './filesystem-watcher-local-capacity' import { installLocalWatcher } from './filesystem-watcher-local-install' import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' -// ── Subscribe / Unsubscribe ────────────────────────────────────────── - const cancelledInstallWaiters = watcherLifecycleState.cancelledLocalInstallWaiters export async function subscribeLocalWatcher( @@ -66,7 +64,6 @@ async function subscribeWhileRemovalAllowed( return } - // Don't retry roots that already failed — avoids repeated error spam. if (watcherLifecycleState.unwatchableRoots.has(rootKey)) { rememberUnwatchableRoot(rootKey) return @@ -74,7 +71,6 @@ async function subscribeWhileRemovalAllowed( const root = watcherLifecycleState.watchedRoots.get(rootKey) - // Cancel any pending grace-period teardown — a new listener arrived. const pendingTeardown = watcherLifecycleState.pendingTeardowns.get(rootKey) if (pendingTeardown) { clearTimeout(pendingTeardown) @@ -98,7 +94,7 @@ async function subscribeWhileRemovalAllowed( const inFlight = watcherLifecycleState.inFlightLocalInstalls.get(rootKey) const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted if (canJoinInstall) { - // Why: an unwatch may cancel an install while another renderer awaits the same root; a new live listener keeps it alive. + // Another renderer may keep an install alive after one listener unwatches. addInFlightLocalInstallListener(inFlight, sender) for (const listener of capacityRetryListeners) { addInFlightLocalInstallListener(inFlight, listener) @@ -121,7 +117,7 @@ async function subscribeWhileRemovalAllowed( !watcherLifecycleState.localWatchersClosed && generation === watcherLifecycleState.localWatcherLifecycleGeneration ) { - // Why: AbortSignal can't be revived; listeners arriving after cancellation wait out that generation, then own a fresh install. + // Aborted signals cannot revive; new listeners wait for the retired generation before installing. if (watcherLifecycleState.pendingLocalInstallPromises.get(rootKey) === pendingInstall) { watcherLifecycleState.pendingLocalInstallPromises.delete(rootKey) } @@ -216,7 +212,7 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number): if (inFlight) { inFlight.listeners.delete(senderId) inFlight.cancelled = inFlight.listeners.size === 0 - // Why: last normal disconnect must abort the pending native/forked install (same early-cancel as closeLocalWatcherForWorktreePath). + // Last-listener removal also aborts pending native subscription work. if (inFlight.cancelled) { inFlight.abortController.abort() } @@ -229,21 +225,20 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number): root.listeners.delete(senderId) - // Defer teardown when the last subscriber leaves so rapid worktree switches reuse the native watcher. + // Grace teardown lets rapid workspace switches reuse native watchers. if (root.listeners.size === 0) { if (root.batch.timer) { clearTimeout(root.batch.timer) - // Why: a cleared handle can't be refresh()ed; null it so a grace-window re-subscribe arms a fresh window. + // Clear retired handles so grace-window re-subscribe arms a fresh timer. root.batch.timer = null } - // Why: duplicate unwatch calls for a root would leak overwritten grace timers; keep just one. + // Duplicate unwatch calls must not overwrite and leak existing grace timers. if (watcherLifecycleState.pendingTeardowns.has(rootKey)) { return } const teardownTimer = setTimeout(() => { watcherLifecycleState.pendingTeardowns.delete(rootKey) - // Re-check: a new listener may have arrived during the grace period. const currentRoot = watcherLifecycleState.watchedRoots.get(rootKey) if (!currentRoot || currentRoot.listeners.size > 0) { return diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 2bb614fd6c3..9091fb8f1f7 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -511,8 +511,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { { sender }, { worktreePath: '/tmp/repo' } ) as Promise - // Why: unwatch must abort the in-flight install so watchWorktree settles - // without waiting for the native subscribe crawl to finish. + // Unwatch must settle before the native subscribe crawl finishes. const watchSettledEarly = await Promise.race([ watchPromise.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 20)) diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index ea12dd8f9a4..c9a7b198977 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -76,7 +76,7 @@ describe('native filesystem watcher capacity recovery', () => { releases.pop()?.() await vi.waitFor(() => expect(subscribeViaWatcherProcessMock).toHaveBeenCalledTimes(2)) - // Why: the IPC call returned before this install, so a write in between needs a resync. + // Writes between IPC completion and delayed installation require a resync. await vi.waitFor(() => expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/tmp/native-capacity-root', diff --git a/src/main/ipc/filesystem-watcher-paths.ts b/src/main/ipc/filesystem-watcher-paths.ts index 25fb06b6a59..03c8d4cfc7a 100644 --- a/src/main/ipc/filesystem-watcher-paths.ts +++ b/src/main/ipc/filesystem-watcher-paths.ts @@ -4,13 +4,11 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -// ── Path normalization ─────────────────────────────────────────────── - function normalizeWatcherRootPath(rootPath: string): string { let resolved = isWindowsAbsolutePathLike(rootPath) ? path.win32.resolve(rootPath) : path.resolve(rootPath) - // Why: Windows watcher events may use lowercase drive letters vs stored uppercase; normalize so renderer casing stays consistent (§4.4). + // Normalize drive casing so watcher events match stored renderer paths. if (/^[a-zA-Z]:/.test(resolved)) { resolved = resolved.charAt(0).toUpperCase() + resolved.slice(1) } @@ -20,7 +18,7 @@ function normalizeWatcherRootPath(rootPath: string): string { export function getLocalWatcherRoot(rootPath: string): { key: string; path: string } { const normalizedPath = normalizeWatcherRootPath(rootPath) return { - // Why: Windows drive/UNC paths are case-insensitive; cleanup must match the owner even when Git returns a different spelling. + // Case-insensitive Windows/UNC keys must match Git's alternate spellings during cleanup. key: normalizeRuntimePathForComparison(normalizedPath), path: normalizedPath } diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts index cd2a5382064..8f7abd9d790 100644 --- a/src/main/ipc/filesystem-watcher-real.test.ts +++ b/src/main/ipc/filesystem-watcher-real.test.ts @@ -1,19 +1,5 @@ import { createWatcherSender } from './filesystem-watcher-test-sender' -/* - * Real (unmocked) @parcel/watcher integration test. - * - * Why: every other filesystem-watcher test mocks '@parcel/watcher', so the - * suite stays green even when the native module is absent from the packaged - * runtime — the exact failure behind the directory tree not auto-refreshing - * (#4684; root cause #4851). A mocked subscribe() always "exists", so those - * tests cannot see a missing-from-bundle watcher. This test loads the real - * watcher, subscribes to a temp directory, mutates a file, and asserts a - * 'fs:changed' event actually fires end-to-end. Only 'electron' is mocked - * (it cannot load under vitest); the watcher itself is real. - * - * On a Linux host isWslPath() is always false, so a native /tmp path routes to - * createWatcher() -> real @parcel/watcher (not the inotifywait WSL fallback). - */ +/* Use the real native watcher to catch missing packaged addons and verify end-to-end delivery (#4684, #4851). */ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -37,8 +23,7 @@ type HandlerMap = Record unknown> type FsChangedCall = { worktreePath: string; events: { kind: string; absolutePath: string }[] } -// Why: real inotify + the 150ms trailing debounce means the event arrives -// asynchronously after the file write; poll rather than assume a fixed delay. +// Poll native delivery and batching rather than assuming a fixed delay. async function waitFor(predicate: () => boolean, timeoutMs = 8_000, stepMs = 50): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { @@ -78,19 +63,15 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { vi.clearAllMocks() }) - // Why: this integration targets the Linux native watcher path described - // above; macOS developer sandboxes can load the addon while suppressing - // subscribe callbacks, which makes this an environment check instead. + // Target Linux native delivery; macOS sandboxes can suppress callbacks despite loading the addon. it.runIf(process.platform === 'linux')( 'emits fs:changed for a file created in a watched directory', async () => { - // Why: macOS reports temp watcher events under /private/var while - // tmpdir() returns /var, so compare canonical paths instead of aliases. + // Canonicalize macOS /var temp aliases before comparing event paths. tempDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-fswatch-real-'))) const sendMock = vi.fn() const sender = createWatcherSender(1, sendMock) - // Subscribe resolves only after the native watcher is installed. await handlers['fs:watchWorktree']({ sender }, { worktreePath: tempDir }) const createdFile = join(tempDir, 'new-file.txt') @@ -116,11 +97,7 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { 15_000 ) - // Why: this is the end-to-end repro, and it fails differently per platform - // without the fix — Linux cannot inotify-watch the alias at all (IN_ONLYDIR), - // macOS watches it but reports paths under the resolved root. Both leave the - // renderer with events outside the root it subscribed with. One assertion - // covers both: the emitted paths must stay under the subscribed spelling. + // Alias watchers must preserve subscribed path spelling even when native events report resolved roots. it('reports events under an aliased worktree root, not its resolved path', async () => { const root = await createAliasedWatcherRoot('orca-fswatch-alias-') aliasedRoot = root diff --git a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts index bfa6b862fce..1a948e721cc 100644 --- a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts +++ b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts @@ -74,9 +74,7 @@ describe('local filesystem watcher removal deadline', () => { vi.useFakeTimers() try { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) - // Why mock the process-backed subscribe: the in-process fallback rejects on abort, so only a - // subscribe that ignores the abort signal exercises the deadline rather than the cancel path. - // Once, so the wedge cannot leak into the next test. + // Ignore abort once to test the drain deadline; the fallback's abort rejection would bypass it. vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {})) const sender = createWatcherSender(1) @@ -111,8 +109,7 @@ describe('local filesystem watcher removal deadline', () => { it('bounds a wedged live unsubscribe so worktree deletion cannot hang forever', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) - // The in-process Parcel fallback has no unsubscribe timeout of its own, so only the shared - // removal deadline can stop this from hanging delete forever. + // Fallback unsubscribe has no native deadline; the shared removal budget must bound it. let resolveUnsubscribe: () => void = () => {} const unsubscribeMock = vi.fn( () => @@ -159,7 +156,7 @@ describe('local filesystem watcher removal deadline', () => { expect(subscribeViaWatcherProcess).toHaveBeenCalled() }) - // Half the budget is already gone before the close starts; the drain may only spend the rest. + // A close starting halfway through a shared budget may spend only its remainder. const deadline = createWatcherRemovalDeadline() await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2) @@ -167,7 +164,7 @@ describe('local filesystem watcher removal deadline', () => { const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo', deadline).then(() => { closed = true }) - // Why assert mid-drain: without this a fresh (unshared) budget would also pass the final check. + // Check mid-drain so a fresh unshared budget cannot pass vacuously. await vi.advanceTimersByTimeAsync( WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2 - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1 ) @@ -205,8 +202,7 @@ describe('local filesystem watcher removal deadline', () => { vi.useRealTimers() } - // The delete this drain guarded already finished; a late native failure is stale news, and - // retaining it would leave this root permanently undeletable until the watcher process exits. + // Late failure after a completed delete must not permanently block later removal. rejectUnsubscribe( new WatcherProcessFailure( 'file watcher process did not exit after termination deadline', diff --git a/src/main/ipc/filesystem-watcher-removal-restore.test.ts b/src/main/ipc/filesystem-watcher-removal-restore.test.ts index b7940f1e015..2b9152180c4 100644 --- a/src/main/ipc/filesystem-watcher-removal-restore.test.ts +++ b/src/main/ipc/filesystem-watcher-removal-restore.test.ts @@ -1,4 +1,3 @@ -// Restores after a failed worktree removal must not resurrect unwatched windows or lose a newer removal's owners. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createWatcherSender, fireWatcherSenderEvent } from './filesystem-watcher-test-sender' @@ -39,7 +38,6 @@ describe('watcher restore after a failed worktree removal', () => { const handlers: Record = {} const deferred: (() => void)[] = [] let deferSubscribe = false - // A wedged watcher child ignores abort until it finally answers. let wedged = false const coordinator = createRuntimeFileWatcherRemoval({ closeFileExplorerWatchersForPath: async () => {}, @@ -135,7 +133,7 @@ describe('watcher restore after a failed worktree removal', () => { await handlers['fs:watchWorktree']({ sender: second }, args) const firstRemoval = await coordinator.acquire(args.worktreePath) - // The first restore is mid-way through re-subscribing window 1 when the retry starts. + // Retry removal while the first restore is re-subscribing window 1. deferSubscribe = true const firstRestore = firstRemoval.finish(false) await settle() @@ -156,7 +154,7 @@ describe('watcher restore after a failed worktree removal', () => { it('keeps a window that reloads (same id) while overlapping restores run', async () => { const first = createWatcherSender(1) const second = createWatcherSender(2) - // Window 3 keeps the first restore's snapshot non-empty after window 2's old page is cleaned up. + // A third owner keeps the old snapshot nonempty after window 2 reloads. const third = createWatcherSender(3) for (const sender of [first, second, third]) { await handlers['fs:watchWorktree']({ sender }, args) @@ -166,7 +164,7 @@ describe('watcher restore after a failed worktree removal', () => { deferSubscribe = true const firstRestore = firstRemoval.finish(false) await settle() - // The first restore captured window 2's old lifetime; the reloaded page watches again. + // Reload preserves window ID but creates a new watch lifetime. fireWatcherSenderEvent(second, 'did-navigate') const rewatch = Promise.resolve(handlers['fs:watchWorktree']({ sender: second }, args)) await settle() @@ -192,7 +190,7 @@ describe('watcher restore after a failed worktree removal', () => { await settle() handlers['fs:unwatchWorktree']({ sender }, args) await settle() - // The re-watch can't join the aborted install, so it waits for it in no listener map. + // Re-watch waits outside listener maps until the aborted install retires. deferSubscribe = false const second = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args)) await settle() diff --git a/src/main/ipc/filesystem-watcher-shutdown.ts b/src/main/ipc/filesystem-watcher-shutdown.ts index 390e0232276..9fb2c28a01e 100644 --- a/src/main/ipc/filesystem-watcher-shutdown.ts +++ b/src/main/ipc/filesystem-watcher-shutdown.ts @@ -3,7 +3,6 @@ import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { trackDetachedLocalUnsubscribe } from './filesystem-watcher-listener-lifecycle' import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control' -/** Tear down all watchers on app shutdown. */ export async function closeAllWatchers(): Promise { for (const lifetime of watcherLifecycleState.senderLifetimes.values()) { lifetime.dispose() @@ -17,13 +16,12 @@ export async function closeAllWatchers(): Promise { } watcherLifecycleState.pendingLocalCapacityRetries.clear() - // Cancel any pending grace-period teardowns — we're tearing down everything. for (const timer of watcherLifecycleState.pendingTeardowns.values()) { clearTimeout(timer) } watcherLifecycleState.pendingTeardowns.clear() - // Why: latch the subsystem shut so late installs can't register; the generation bump rejects older-lifecycle waiters. + // Close and bump generation before late installs or waiters can revive retired state. watcherLifecycleState.localWatchersClosed = true watcherLifecycleState.localWatcherLifecycleGeneration += 1 for (const token of watcherLifecycleState.inFlightLocalInstalls.values()) { @@ -39,6 +37,6 @@ export async function closeAllWatchers(): Promise { watcherLifecycleState.watchedRoots.clear() await Promise.allSettled(Array.from(watcherLifecycleState.pendingLocalUnsubscribes)) watcherLifecycleState.failedLocalUnsubscribes.clear() - // Why: kill the forked watcher process instead of watcher.node's crash-prone async teardown; process death frees native handles. + // Process exit frees native handles without crash-prone watcher.node async teardown. disposeWatcherProcess() } diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 84d8d0a2306..fb174a3d31e 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -96,9 +96,7 @@ describe('createWslWatcher', () => { expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - // Why a concrete directory (#16463): the watched path rides in argv, so - // an omitted cwd only means CreateProcessW inherits Orca's -- a worktree - // that can be deleted, after which every watcher start is ENOENT. + // Explicit cwd survives deletion of Orca's inherited worktree (#16463). cwd: expect.any(String) }) ) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index a444113c1cf..1bf3979147f 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -1,10 +1,4 @@ -/** - * WSL-native file watcher for WSL paths. - * - * Why: polling \\wsl.localhost from Windows keeps waking the distro after - * `wsl --shutdown`, which can make WSL look wedged. Keep the polling process - * inside the distro so shutdown kills it instead of Orca restarting WSL. - */ +/** Poll inside WSL so distro shutdown kills the watcher instead of Windows UNC polling repeatedly waking it. */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' import type { WebContents } from 'electron' @@ -24,8 +18,7 @@ export type WatchedRoot = { subscription: WatcherSubscription listeners: Map batch: DebouncedBatch - // Why: the real on-disk path. Never substitute the watcher's rootKey — that is - // a comparison key (case/Unicode folded) and would reach the renderer as a path. + // Renderer paths must use real spelling, never folded comparison keys. rootPath: string } @@ -151,7 +144,6 @@ export async function createWslWatcher( deps: WslWatcherDeps, signal?: AbortSignal ): Promise { - // Why: honor local-install cancellation before spawning a WSL snapshot process. if (signal?.aborted) { throw new DOMException('WSL watcher subscription aborted', 'AbortError') } @@ -246,9 +238,7 @@ export async function createWslWatcher( child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', linuxPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - // Why explicit (#16463): the watched directory rides in argv, and an - // inherited cwd is a worktree that can be deleted -- after which every - // watcher start fails `spawn wsl.exe ENOENT`. + // Inherited cwd may be a deleted worktree, causing spawn wsl.exe ENOENT (#16463). cwd: resolveWslInteropSpawnCwd() }) } catch (error) { @@ -256,8 +246,7 @@ export async function createWslWatcher( throw error instanceof Error ? error : new Error(String(error)) } - // Why: the physical-exit owner releases the shared process reservation on - // exactly the same close/error proof that settles destructive cleanup. + // Release process capacity only on the same physical-exit proof required for destructive cleanup. const processExit = createWslWatcherProcessExit(child, worktreePath, releaseChildReservation) const onAbort = (): void => { @@ -272,8 +261,7 @@ export async function createWslWatcher( }, STARTUP_TIMEOUT_MS) child.stdin.on('error', (error) => { - // Why: WSL can exit before reading the script; handle EPIPE here so the - // startup failure rejects the watcher instead of crashing on a stream error. + // Early WSL exit can break stdin; reject setup on EPIPE instead of crashing on a stream error. if (!startup.settled) { startup.settle(error) } @@ -306,8 +294,7 @@ export async function createWslWatcher( }) child.once('error', (error) => { - // Why: a child that never acquired a pid has no OS owner to await. Kill - // errors for an already-spawned child still require the close event. + // Failed spawns without a PID need no exit wait; spawned children still require close proof. if (child.pid === undefined) { processExit.markPhysicalExit() } @@ -338,8 +325,7 @@ export async function createWslWatcher( child.stdin.end(buildSnapshotScript(deps.ignoreDirs)) await startup.ready } catch (error) { - // Why: cancellation is part of destructive removal. Do not let the failed - // install disappear from ownership until WSL confirms the child exited. + // Destructive cancellation must retain ownership until WSL confirms physical exit. startup.settle(error instanceof Error ? error : new Error(String(error))) await startup.ready.catch(() => undefined) await processExit.stopAndWait() diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 865f731c2f0..fd49e7c6567 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -5,8 +5,6 @@ import { restoreLocalWatcherAfterFailedRemoval } from './filesystem-watcher-local-removal' -// ── Public API ─────────────────────────────────────────────────────── - /** The desktop binding for {@link WorktreeWatcherRemoval}. Installed during startup. */ export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = { closeLocal: (worktreePath, deadline) => diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 1a357d0fe65..27ad35ceb97 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -68,8 +68,7 @@ import { describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) @@ -90,14 +89,12 @@ describe('registerFilesystemHandlers', () => { ) }) - // Why: handler-level WSL UNC authorization depends on native Windows path - // resolution; path-shape classification has separate cross-platform coverage. + // Handler UNC authorization needs native Windows resolution; shape tests run cross-platform. it.runIf(process.platform === 'win32')( 'records a redacted breadcrumb when fs:readDir throws on a WSL UNC path', async () => { registerFilesystemHandlers(store as never) const wslPath = path.win32.join('\\\\wsl.localhost\\Ubuntu', 'home', 'user', 'repo') - // resolveAuthorizedPath authorizes the path, then readdir fails (distro stopped). realpathMock.mockResolvedValue(wslPath) registerWorktreeRootsForRepo(store as never, 'repo-1', [wslPath]) readdirMock.mockRejectedValue(Object.assign(new Error('EIO: i/o error'), { code: 'EIO' })) @@ -112,7 +109,6 @@ describe('registerFilesystemHandlers', () => { isUNC: true, isWsl: true }) - // The raw path must never appear in the breadcrumb payload. const [, breadcrumbData] = recordCrashBreadcrumbMock.mock.calls[0] expect(JSON.stringify(breadcrumbData)).not.toContain('user') } @@ -452,17 +448,9 @@ describe('registerFilesystemHandlers', () => { expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(targetPath, { recursive: undefined }) }) - // Regression for #6415: WSL UNC paths have no Recycle Bin, so shell.trashItem - // throws. The handler must hard-delete via the distro instead of surfacing an - // error popup. + // WSL UNC has no Recycle Bin; delete inside the distro instead (#6415). it('hard-deletes a WSL UNC path instead of trashing it', async () => { - // Why: build the UNC-style root with path.join so it resolves as a real - // parent/child pair under the host's path semantics. A literal - // '\\wsl.localhost\...' string only resolves correctly under win32 path - // rules — on the Linux CI runner POSIX treats the backslashes as filename - // characters, so the target would not be a descendant of the root and auth - // would deny it before the WSL hard-delete ran (the real production path is - // Windows-only). + // Build with host path semantics so root/child containment also holds on POSIX CI. const wslUncRoot = path.join( `${path.sep}${path.sep}wsl.localhost`, 'Ubuntu', @@ -479,14 +467,11 @@ describe('registerFilesystemHandlers', () => { await handlers.get('fs:deletePath')!(null, { targetPath, recursive: true }) expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(targetPath, { recursive: true }) - // Critical: we must NOT call trashItem for WSL UNC paths — that is exactly - // the call that throws and produced the user-facing error. expect(trashItemMock).not.toHaveBeenCalled() }) it('propagates a WSL hard-delete failure instead of swallowing it', async () => { - // Why: see sibling test — path.join keeps the UNC root/target a real - // parent/child pair under both win32 and POSIX (Linux CI) path semantics. + // Build a host-valid root/child pair as in the sibling test. const wslUncRoot = path.join( `${path.sep}${path.sep}wsl.localhost`, 'Ubuntu', diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index ffd95e42e47..15ef200f1d1 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -30,7 +30,6 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon async (_event, args: { filePath?: string; connectionId?: string }): Promise => { validateRequiredString(args?.filePath, 'filePath') const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - // Why: only SSH targets downloaded through here, and this process serves none. throw new SshHostNotServedError(connectionId) } ) diff --git a/src/main/ipc/filesystem/filesystem-download-promotion.ts b/src/main/ipc/filesystem/filesystem-download-promotion.ts index 9fbc8acddd2..6995ad3bb6e 100644 --- a/src/main/ipc/filesystem/filesystem-download-promotion.ts +++ b/src/main/ipc/filesystem/filesystem-download-promotion.ts @@ -8,7 +8,7 @@ export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | } export function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + // Keep staging beside destination so promotion rename stays on one volume. return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) } diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index c0328f43bb2..ab87ca5699f 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -8,10 +8,9 @@ import { readLocalFilePrefix } from './local-regular-file-read' -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +// Monaco degrades features on large files; a 5MB cap would reject ordinary logs and JSON. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.png': 'image/png', '.avif': 'image/avif', @@ -48,12 +47,12 @@ export async function readLocalFileContent(filePath: string): Promise BINARY_PROBE_BYTES && isBinaryBuffer(await readLocalFilePrefix(handle, BINARY_PROBE_BYTES)) diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts index d1b96b04e70..57f2571d6a9 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -14,7 +14,7 @@ export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerCo _event, args: { worktreePath: string; message: string; connectionId?: string } ): Promise<{ success: boolean; error?: string }> => { - // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + // Validate at IPC so users receive a clear error before process launch. if (typeof args.message !== 'string' || args.message.trim().length === 0) { throw new Error('Commit message is required') } diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts index 71b6fdf66a0..ec4c0508de9 100644 --- a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -72,8 +72,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo try { requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - // Why: one registered-worktree lookup feeds both — status polls this - // handler, and the scan walks every repo's worktree meta. + // Reuse one registered-worktree lookup for frequent status polls and repo metadata reads. const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) const gitOptions = getLocalGitOptionsForRepo(store, repo) const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] @@ -98,7 +97,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo applyGitStatusUpstreamRefWatchRequest(store, args) ) - // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. + // Parent status exposes only gitlinks; read inner changes from each submodule worktree. ipcMain.handle( 'git:submoduleStatus', async ( @@ -142,7 +141,6 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo } ) - // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. ipcMain.handle( 'git:findHugeFoldersToIgnore', async (_event, args: { worktreePath: string }): Promise => { @@ -182,7 +180,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo } ) - // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. + // Filesystem probes clear inactive-worktree conflict badges without a full Git status. ipcMain.handle( 'git:conflictOperation', async ( diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts index 85e4380bd0f..1b658d7ab6c 100644 --- a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -15,7 +15,7 @@ export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerConte _event, args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } ): Promise => { - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + // Remote URL construction needs the owning SSH host's .git/config. requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) @@ -30,7 +30,7 @@ export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerConte args: { worktreePath: string; sha: string; connectionId?: string } ): Promise => { const sha = validateFullGitObjectId(args.sha, 'sha') - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + // Remote URL construction needs the owning SSH host's .git/config. requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 8ed2a0b65f2..ffc354460ad 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -40,7 +40,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext _event, args: { dirPath: string; connectionId?: string; followSymlinks?: boolean } ): Promise => { - // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. + // Record throw site and redacted path shape because fs:readDir IPC errors otherwise lack context. let throwSite: ReadDirThrowSite = 'authorize' try { if (args.connectionId) { diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index f6c18e30b87..216c25b3457 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -33,7 +33,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte ): Promise => { const controller = listFilesCancellations.begin(event, args.requestToken) try { - // Why: an SSH target is not served here; quick-open shows "No matching files", as it did when disconnected. + // This process serves no SSH targets; preserve the disconnected empty-list contract. if (args.connectionId) { return [] } diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 6a9e5dcd940..c6a57b5b313 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -79,7 +79,7 @@ async function localRepoOwnsWorktree( return true } try { - // Why the project runtime: WSL Git names a drive worktree differently than host Git does. + // WSL Git names drive-backed worktrees differently from host Git. const worktrees = await listRepoWorktreeGraph( repo, getLocalProjectWorktreeGitOptions(store, repo) @@ -112,13 +112,13 @@ export async function getRepoForSourceControlAi( if (repo.connectionId !== args.connectionId) { return null } - // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. + // Shared SSH connections can host several repos; apply overrides only to the verified owner. return remoteRepoOwnsWorktree(store, repo, args.worktreePath) ? repo : null } if (repo.connectionId) { return null } - // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. + // Renderer repoId is advisory until local worktree ownership is verified. return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null } diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index edf0891936d..81d3962a997 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -77,15 +77,15 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex if (args.connectionId) { throw new SshHostNotServedError(args.connectionId) } - // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). + // Delete the symlink entry, not a realpath target that may be outside allowed roots. const targetPath = await resolveDesktopAuthorizedPath(args.targetPath, store, { preserveSymlink: true }) - // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). + // WSL UNC has no Recycle Bin; hard-delete inside the distro (#6415). if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { return } - // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). + // Concurrent external deletion must leave UI deletion idempotent. try { await shell.trashItem(targetPath) } catch (error) { diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts index 6b2ad0a5c94..3ea3bff39b5 100644 --- a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -25,7 +25,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl pushTarget?: GitPushTarget } ): Promise => { - // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. + // Require literal true so malformed values such as string false cannot enable --set-upstream. const publish = args.publish === true requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) diff --git a/src/main/ipc/filesystem/local-disk-write-precondition.test.ts b/src/main/ipc/filesystem/local-disk-write-precondition.test.ts index 463d0839359..011535c85fb 100644 --- a/src/main/ipc/filesystem/local-disk-write-precondition.test.ts +++ b/src/main/ipc/filesystem/local-disk-write-precondition.test.ts @@ -50,14 +50,14 @@ vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - // Why: some volumes (RAM disks, subst and network drives, WSL UNC) fail realpath outright. + // Some RAM/subst/network/WSL volumes cannot resolve realpath. realpath: async (path: string) => { if (path === realpathFailure.path) { throw Object.assign(new Error(`EIO: i/o error, realpath '${path}'`), { code: 'EIO' }) } return actual.realpath(path) }, - // Why: 9P and some SMB mounts report inode 0 for every file. + // 9P and some SMB mounts expose inode zero for every file. stat: async (path: string, options?: { bigint?: boolean }) => { if (options?.bigint !== true) { return actual.stat(path) @@ -65,7 +65,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { const stats = await actual.stat(path, { bigint: true }) return zeroInodes.enabled ? Object.assign(stats, { ino: 0n }) : stats }, - // Why: holds one save between its passed check and its write, where a competing save must wait. + // Hold a save after comparison so competing saves must wait before checking. writeFile: vi.fn(async (...args: Parameters) => { const gate = typeof args[1] === 'string' ? writeGates.get(args[1]) : undefined if (gate) { @@ -80,7 +80,7 @@ vi.mock('./local-regular-file-read', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - // Why: reports the size from before a concurrent append, as a stat racing the writer would. + // Model stat observing size just before a concurrent append. openLocalRegularFile: async (filePath: string) => { if (fileRead.openError) { throw fileRead.openError @@ -394,7 +394,7 @@ describe('local provider guarded write', () => { expect(await readFile(filePath, 'utf8')).toBe(winner === 0 ? 'client one' : 'client two') }) - // Why: creating symlinks needs elevated rights on Windows runners. + // Windows symlink creation requires elevated rights or Developer Mode. it.skipIf(process.platform === 'win32').each([ ['a symlink', (target: string, alias: string) => symlink(target, alias)], ['a hardlink', (target: string, alias: string) => link(target, alias)] diff --git a/src/main/ipc/filesystem/local-disk-write-precondition.ts b/src/main/ipc/filesystem/local-disk-write-precondition.ts index e9c2d9d99e5..7d229826e77 100644 --- a/src/main/ipc/filesystem/local-disk-write-precondition.ts +++ b/src/main/ipc/filesystem/local-disk-write-precondition.ts @@ -27,7 +27,7 @@ import { const HASH_SLICE_BYTES = 1024 * 1024 -/** Why sliced: hashing tens of MB in one go stalls the main process; yielding keeps windows responsive. */ +/** Yield between hash slices so large editor saves cannot monopolize the main loop. */ export async function diskSignatureOfUtf8(buffer: Buffer): Promise { if (buffer.length <= HASH_SLICE_BYTES) { return getDiskBaselineSignature(buffer.toString('utf8')) @@ -51,7 +51,7 @@ export async function checkLocalDiskWritePrecondition( try { opened = await openLocalRegularFile(filePath) } catch (error) { - // Why the code alone: the message quotes the path, which can spell any error. + // Error messages contain user paths that can spell arbitrary error codes. if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return compareDiskWritePrecondition(precondition, null, content) } @@ -61,7 +61,7 @@ export async function checkLocalDiskWritePrecondition( try { const written = Buffer.from(content, 'utf8') const expectedLength = diskBaselineSignatureLength(precondition.expectedSignature) - // Why: the signature pins the decoded length, so a size neither it nor the save can match skips reading a huge file. + // Impossible decoded lengths reject before reading large files. const baselineFits = expectedLength !== null && utf8ByteSizeCanDecodeToLength(stats.size, expectedLength) if (!baselineFits && stats.size !== written.length) { @@ -75,7 +75,7 @@ export async function checkLocalDiskWritePrecondition( stats.size ) } catch (error) { - // Why: grown past what the baseline or the save could match since the size check, so it changed. + // Growth past the bounded read proves neither baseline nor save can match. if (error instanceof FileTooLargeError) { return 'changed' } @@ -87,7 +87,7 @@ export async function checkLocalDiskWritePrecondition( if (!baselineFits || (await diskSignatureOfUtf8(buffer)) !== precondition.expectedSignature) { return 'changed' } - // Why bytes: a decoded U+FFFD may be a real character or replaced bytes; only the bytes tell. + // U+FFFD alone cannot distinguish literal text from replaced invalid bytes. return precondition.requireLosslessUtf8 && !isUtf8(buffer) ? 'not-utf8' : null } finally { await handle.close() @@ -97,13 +97,10 @@ export async function checkLocalDiskWritePrecondition( const editorWritesByPath = new Map>() const editorWritesByInode = new Map>() -// Why 40: Linux's own limit, so any chain the OS can open resolves here too. +// Match Linux's symlink-hop limit. const MAX_SYMLINK_HOPS = 40 -/** - * Follows a symlink chain by hand, since realpath fails once its final target is missing. Null - * for a chain that never ends (a loop), whose writes could not share one lock. - */ +/** Follow dangling-target chains without realpath; loops cannot share a proven write lock. */ async function followSymlinks(filePath: string): Promise { let current = filePath for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop += 1) { @@ -113,17 +110,14 @@ async function followSymlinks(filePath: string): Promise { } catch { return current } - // Why the real folder: a relative `..` resolves against where the link really lives. + // Relative link targets resolve against the link's real parent. const linkFolder = await realpath(dirname(current)).catch(() => dirname(current)) current = resolve(linkFolder, target) } return null } -/** - * A file's canonical path: real parent plus a case- and Unicode-folded name (folding only - * over-serializes). Symlinks are followed first, so a dangling link and its future target share it. - */ +/** Lock real-parent/folded-name paths after following links; folding may over-serialize, and missing targets keep a stable key. */ async function editorWritePathKey(requestedPath: string): Promise { const filePath = await followSymlinks(requestedPath) if (filePath === null) { @@ -133,37 +127,30 @@ async function editorWritePathKey(requestedPath: string): Promise try { parentKey = await serializedParentDirectoryKey(filePath) } catch { - // Why: the key only orders saves; a volume that can't resolve real paths must still save. + // Lock ordering must not prevent saves on volumes lacking realpath support. parentKey = normalize(dirname(filePath)) } return join(parentKey, basename(filePath).normalize('NFC').toLowerCase()) } -/** The file's inode (stat follows symlinks), or null when missing or unknown. */ +/** Null for missing/unknown inodes; stat follows symlinks. */ async function editorWriteInodeKey(filePath: string): Promise { try { const stats = await stat(filePath, { bigint: true }) - // Why: 9P and some SMB mounts report inode 0 for every file. + // Ignore inode zero, shared by unrelated files on some 9P/SMB mounts. return stats.ino === 0n ? null : `${stats.dev}:${stats.ino}` } catch { return null } } -/** - * Compare→write for editor saves, serialized per file across this process's IPC and runtime - * writers so two clients holding one baseline can't both pass. Two locks, always in this order: - * the canonical path (stable even while a queued save creates the file) and then, if it exists, - * the inode (shared by symlink and hardlink aliases). Per file, so a read stuck on a dead mount - * holds up only that file. A write by another process between the compare and the write is not - * prevented; the window is one read long. - */ +/** Serialize compare/write by canonical path then inode across IPC/runtime writers; external-process races remain possible during the read. */ export async function writeLocalTextFileWithDiskPrecondition( filePath: string, content: string, precondition: FileWriteDiskPrecondition | undefined ): Promise { - // Why: an unguarded write has nothing to compare, so it skips the locks a stuck guarded save holds. + // Unguarded writes bypass locks held by stalled guarded saves. if (!precondition) { await assertLocalWriteTargetIsRegularFile(filePath) await writeFile(filePath, content, 'utf-8') @@ -183,7 +170,7 @@ export async function writeLocalTextFileWithDiskPrecondition( } const pathKey = await editorWritePathKey(filePath) if (pathKey === null) { - // Why refuse: a link loop can't be compared or locked, and disk can't hold the baseline there. + // Refuse symlink loops whose baseline and shared lock cannot be proven. return { ok: false, diskConflict: 'changed' } } return runKeyedSerializedOperation(editorWritesByPath, pathKey, async () => { diff --git a/src/main/ipc/filesystem/local-regular-file-read.test.ts b/src/main/ipc/filesystem/local-regular-file-read.test.ts index 995e40ed09e..aa745ef573a 100644 --- a/src/main/ipc/filesystem/local-regular-file-read.test.ts +++ b/src/main/ipc/filesystem/local-regular-file-read.test.ts @@ -55,7 +55,6 @@ describe('local regular-file reads', () => { await expect(readLocalFileBounded(handle, 1024 * 1024, stats.size)).resolves.toEqual( Buffer.from('hello') ) - // One read sized past the reported length, then a 1-byte probe confirming EOF. expect(read.mock.calls.map((call) => call.at(2))).toEqual([6, 1]) } finally { await handle.close() @@ -74,7 +73,6 @@ describe('local regular-file reads', () => { } }) - // Why: device files are POSIX-only. describe.skipIf(process.platform === 'win32')('non-regular files', () => { it.each(['/dev/zero', '/dev/urandom'])( 'refuses %s before reading any of it', diff --git a/src/main/ipc/filesystem/local-regular-file-read.ts b/src/main/ipc/filesystem/local-regular-file-read.ts index b861c32fadc..600ac8b026a 100644 --- a/src/main/ipc/filesystem/local-regular-file-read.ts +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -4,8 +4,7 @@ import { isENOENT } from '../filesystem-path-containment' export const NOT_A_REGULAR_FILE_MESSAGE = 'Not a regular file' -// Why O_NONBLOCK: opening a FIFO would otherwise wait for a writer forever; regular files ignore -// the flag and Windows has none. +// O_NONBLOCK prevents FIFO-open hangs; regular files ignore it and Windows omits it. export const LOCAL_READ_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) const LOCAL_WRITE_PROBE_FLAGS = constants.O_WRONLY | (constants.O_NONBLOCK ?? 0) const READ_CHUNK_BYTES = 1024 * 1024 @@ -18,7 +17,6 @@ export function fileTooLargeError(size: number, limit: number): Error { ) } -/** Opens a path for reading, refusing anything but a regular file (FIFO, device, socket, directory). */ export async function openLocalRegularFile( filePath: string ): Promise<{ handle: FileHandle; stats: Stats }> { @@ -35,11 +33,7 @@ export async function openLocalRegularFile( } } -/** - * Reads from the start of the handle, sized from its fstat so a small file costs a small buffer. - * The size is only a hint: a file that grows past it is read on in bounded chunks, and the cap holds - * even when a file reports a false size. - */ +/** Size from fstat is a hint; bounded chunks handle growth and false sizes without exceeding the cap. */ export async function readLocalFileBounded( handle: FileHandle, limit: number, @@ -59,7 +53,7 @@ export async function readLocalFileBounded( if (total > limit) { throw fileTooLargeError(total, limit) } - // Why a 1-byte probe after a short read: it confirms EOF without another full-size buffer. + // Confirm EOF after short reads without allocating another full-size chunk. nextChunkBytes = bytesRead < chunk.length ? 1 : READ_CHUNK_BYTES } } @@ -70,7 +64,6 @@ export async function readLocalFilePrefix(handle: FileHandle, bytes: number): Pr return probe.subarray(0, bytesRead) } -/** Refuses writing over an existing non-regular file, e.g. a device or FIFO. */ export async function assertLocalWriteTargetIsRegularFile(filePath: string): Promise { let handle: FileHandle try { diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index bceee27cd11..305d8fb287d 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -24,7 +24,7 @@ function expandHomePath(input: string, home: string): string { } function resolveFloatingWorkspaceInput(input: string): string { - // Why the environment port: the headless runtime resolves this too, and it has no Electron app. + // Headless runtime uses this path resolver without an Electron app. const home = getAppEnvironment().getPath('home') const expanded = expandHomePath(input, home) return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(home, expanded) @@ -70,9 +70,9 @@ function isTrustedFloatingWorkspaceDirectory( return getTrustedFloatingWorkspaceDirectories(settings).has(path.resolve(canonicalDirPath)) } -/** The app-owned folder floating markdown documents are created in; a desktop-only root. */ +/** Default folder for floating Markdown documents. */ export function getDefaultFloatingWorkspacePath(): string { - // Why the environment port: the headless runtime resolves this too, and it has no Electron app. + // Headless runtime uses this resolver too. return path.join(getAppEnvironment().getPath('userData'), FLOATING_WORKSPACE_DIRNAME) } @@ -99,7 +99,7 @@ export async function resolveFloatingTerminalCwd( return ensureDefaultFloatingWorkspacePath() } - // Why: only picker-approved directories may become the cwd, so arbitrary settings text can't. + // Only picker-approved directories may become terminal cwd. if (isTrustedFloatingWorkspaceDirectory(canonicalCwd, store.getSettings())) { return canonicalCwd } diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 7f8e8607d67..9b69f569f52 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -8,14 +8,12 @@ import type * as FsPromises from 'node:fs/promises' import type * as GitRepo from '../git/repo' import type { Repo } from '../../shared/repo-types' -// Why: the watch must stay one stat per folder project per tick — counting the real -// calls is what keeps a directory-listing fan-out from creeping back in. +// Count real stats to prevent directory-listing fan-out in polling. const { statCalls, readdirSpy, gitProbes, pendingGitChecks } = vi.hoisted(() => ({ statCalls: [] as string[], readdirSpy: vi.fn(), pendingGitChecks: new Set>(), - // Why: the rejected-marker cache exists to stop git respawning every tick; counting the - // real probes is the only way that guarantee stays true. + // Count Git probes to ensure rejected markers cannot respawn every tick. gitProbes: [] as string[] })) @@ -149,8 +147,7 @@ function gitInit(repoPath: string): void { } describe('folder repo git upgrade watch', () => { - // Why: `realpathSync` because macOS TMPDIR is itself a symlink; `symlinkedRoot` is an - // explicit link so the spelling-mismatch test runs the same way on Linux and Windows CI. + // Canonicalize macOS temp aliases and add an explicit link for portable spelling-mismatch coverage. let root: string let symlinkedRoot: string @@ -170,13 +167,13 @@ describe('folder repo git upgrade watch', () => { await rm(root, { recursive: true, force: true }) }) - // Real timers: the tick awaits real filesystem stats, which fake timers cannot flush. + // Real filesystem stats do not advance with fake timers. async function tick(times = 1): Promise { await new Promise((resolve) => setTimeout(resolve, POLL_MS * times + POLL_MS)) await drainGitChecks() } - /** Why: a tick that spawns git can outrun a fixed wait on a loaded machine. */ + /** Poll observable stats because Git-spawning ticks can outrun fixed waits under load. */ async function waitForStats(count: number): Promise { const deadline = Date.now() + 5_000 while (statCalls.length < count && Date.now() < deadline) { @@ -207,14 +204,13 @@ describe('folder repo git upgrade watch', () => { }) expect(prepareLocalWorktreeRootForRepo).toHaveBeenCalledTimes(1) expect(invalidateAuthorizedRootsCache).toHaveBeenCalledTimes(1) - // Why: the shared notifier is what also reaches paired clients (#11994). + // Shared repo notifications must reach paired clients too (#11994). expect(notifyReposChanged).toHaveBeenCalledWith(window) expect(notifyWorktreesChanged).toHaveBeenCalledWith(window, 'folder-repo') }) it("keeps external worktrees visible when the stored path is not git's own root", async () => { - // Why: a symlinked parent makes git report a different toplevel; hiding external - // worktrees there would hide the project's only workspace (found in Electron QA). + // Git reports the real root; hiding external worktrees could hide this symlinked project's only workspace. const repoPath = join(symlinkedRoot, 'symlinked-project') await mkdir(repoPath) gitInit(repoPath) @@ -264,8 +260,7 @@ describe('folder repo git upgrade watch', () => { }) it('refuses a folder that is inside another repo rather than its own root', async () => { - // Why: git accepts any path inside a work tree, so a stray marker in a subdirectory - // would otherwise flip a project whose path is not a repository root. + // Git accepts subdirectories; a stray marker must not upgrade a non-root folder project. const outer = join(root, 'outer') await mkdir(outer) gitInit(outer) @@ -410,8 +405,7 @@ describe('folder repo git upgrade watch', () => { }) await tick(5) - // Why: at the fast interval this window would tick ~6 times; on the idle interval it - // ticks once. Anything above 2 means the backoff was skipped. + // Fast cadence would tick about six times; the idle cadence should stay at one or two. expect(listCalls).toBeGreaterThanOrEqual(1) expect(listCalls).toBeLessThanOrEqual(2) expect(store.getRepo).not.toHaveBeenCalled() @@ -494,7 +488,6 @@ describe('folder repo git upgrade watch', () => { const paths = ['a', 'b', 'c'].map((name) => join(root, name)) for (const repoPath of paths) { await mkdir(repoPath) - // Sibling dirs a parent-directory scan would have to stat on every tick. await mkdir(join(repoPath, 'nested')) } const store = makeStore( @@ -508,9 +501,7 @@ describe('folder repo git upgrade watch', () => { statCalls.length = 0 await tick(4) - // Why: assert the shape, not the tick count — wall-clock slack decides how many ticks - // land, but every project must be stat'd the same number of times (a per-tick - // multiplication breaks that) and a directory listing must never appear. + // Count per-project work and forbid listings independently of wall-clock tick totals. const perPath = paths.map( (repoPath) => statCalls.filter((call) => call === join(repoPath, '.git')).length ) diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index 68cdb213569..ed31c23e8b2 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -21,8 +21,7 @@ import { } from './worktree-base-directory-poller' import { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller' -// Why: with no folder project registered there is nothing to stat, so back off until -// the next catalog mutation wakes the fast cadence. +// Empty catalogs idle until mutations wake fast polling. const IDLE_POLL_INTERVAL_MS = WORKTREE_BASE_POLL_INTERVAL_MS * WORKTREE_BASE_BACKSTOP_TICKS type UpgradeWatch = { @@ -40,13 +39,10 @@ type UpgradeWatch = { let activeWatch: UpgradeWatch | null = null -// Why: git's verdict on a marker only changes when the marker does, and probing spawns git. -// A rejected marker must not respawn git every tick forever. +// Re-probe rejected Git markers only after their identity changes. const rejectedMarkers = new Map() -// Why: `git init` on an SSH host or behind a WSL UNC root is not observable with a -// local stat, and those roots are exactly the ones the base watcher already refuses -// to poll natively. Desktop-local folder projects are the reported case (#11477). +// Local stat cannot observe SSH roots; WSL UNC roots follow their existing runtime watch policy. function isUpgradeCandidate(repo: Repo): boolean { return ( isFolderRepo(repo) && @@ -56,8 +52,7 @@ function isUpgradeCandidate(repo: Repo): boolean { ) } -// Why a quiet 'blocked' here: the repo write refuses this upgrade anyway (it would hide those -// workspaces); checking first keeps the watch from treating a kept folder project as an error. +// The repo write would refuse upgrades hiding extra folder workspaces; skip without logging an error. function hasExtraFolderWorkspaces(store: Store, repo: Repo): boolean { return findExtraFolderWorkspaceIds(repo, store.getAllWorktreeMeta()).length > 0 } @@ -72,13 +67,7 @@ async function readGitMarkerSignature(repoPath: string): Promise } } -/** - * Only hide external worktrees when the stored spelling also matches. Add Project stores the root - * as `rev-parse --show-toplevel` spells it while a folder project keeps the path the user picked; - * when a symlinked parent makes those differ, the root reads as an *external* worktree, and hiding - * those would hide the project's only workspace. This is the watch's import policy, not part of - * proving the conversion, so a manual type change keeps the user's own visibility setting. - */ +/** Hide external worktrees only when stored and Git root spellings match, or symlinked projects could lose their only workspace. */ async function resolveUpgrade( repoPath: string ): Promise<{ folderUpgradeGitRootPath?: string; externalWorktreeVisibility?: 'hide' } | null> { @@ -121,23 +110,19 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise { - // Why: a closed window on macOS leaves the app running with nothing to notify, and the - // poller's visibility helper reports a destroyed window as visible on purpose so a - // window-recreation gap cannot park it forever. Idle out instead of probing git. + // Closed macOS windows leave the app running; idle rather than probing Git during recreation. const candidates = watch.mainWindow.isDestroyed() ? [] : watch.store.getRepos().filter(isUpgradeCandidate) @@ -196,7 +181,6 @@ async function runTick(watch: UpgradeWatch): Promise { return } if (!watch.visibility.isWindowVisible()) { - // Parked: the visibility listener resumes the loop, so no timer is rescheduled. watch.parkedWhileHidden = true return } @@ -210,13 +194,7 @@ async function runTick(watch: UpgradeWatch): Promise { } } -/** - * Polls `/.git` for every local folder project and upgrades it to a git repo - * once an external `git init` lands, so git affordances appear without a restart. - * Idempotent: a re-attached main window replaces the one the running watch holds. - * Parks while the window is hidden — one stat per folder project per tick, never a - * directory listing. - */ +/** Poll one .git stat per local folder project, park when hidden, and replace window ownership on reattachment. */ export function startFolderRepoGitUpgradeWatch( store: Store, mainWindow: BrowserWindow, @@ -234,8 +212,7 @@ export function startFolderRepoGitUpgradeWatch( const watch: UpgradeWatch = { store, mainWindow, - // Why: assume work on the first tick rather than reading the store on the attach - // path; the tick itself settles the cadence once it has seen the repo list. + // Avoid store reads on attach; the first tick resolves the appropriate cadence. hasCandidates: true, visibility: createWorktreePollerWindowVisibility(() => watch.mainWindow), unsubscribeVisibility: () => {}, diff --git a/src/main/ipc/github-ipc-module-mocks.ts b/src/main/ipc/github-ipc-module-mocks.ts index 6fec0fc5fab..96905ab9c4d 100644 --- a/src/main/ipc/github-ipc-module-mocks.ts +++ b/src/main/ipc/github-ipc-module-mocks.ts @@ -1,7 +1,6 @@ import { type Mock, vi } from 'vitest' -// Mocked module shapes for the GitHub IPC route tests. Built inside `vi.hoisted` -// so each test file's own `vi.mock` factories can hand the objects back verbatim. +// Build hoisted module shapes for each route test's vi.mock factories. const CLIENT_EXPORTS = [ 'getPRForBranch', diff --git a/src/main/ipc/github-ipc-test-harness.ts b/src/main/ipc/github-ipc-test-harness.ts index f505907a2f4..fea06d937c3 100644 --- a/src/main/ipc/github-ipc-test-harness.ts +++ b/src/main/ipc/github-ipc-test-harness.ts @@ -86,10 +86,7 @@ export function createGitHubIpcHarness(mocks: GitHubIpcMocks): GitHubIpcHarness delete handlers[key] } - // Reset fixture repos to the default single-repo fixture each test, so - // individual tests can mutate the list without leaking preferences across - // tests (e.g. a preference-threading test could otherwise shadow the - // default-undefined assertions in sibling tests). + // Reset repo preferences between tests so one routing fixture cannot shadow another. harness.repos = defaultRepos() harness.projects = [] diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts index 8936e2eb15a..dbf441a0ef2 100644 --- a/src/main/ipc/github-issue-mutation-handlers.ts +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -77,7 +77,7 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { getGitHubRepoConnectionId(repo), args.prRepo ?? null, getGitHubLocalGitOptionArgs(store, repo)[0], - // Why: the issue source selector only scopes issues; PR comments keep their resolution. + // Issue-source preference must not change PR comment resolution. args.type === 'pr' ? undefined : repo.issueSourcePreference ) if (result.ok) { diff --git a/src/main/ipc/github-issue-source-preference.test.ts b/src/main/ipc/github-issue-source-preference.test.ts index eb51af7773e..4b5103dabf5 100644 --- a/src/main/ipc/github-issue-source-preference.test.ts +++ b/src/main/ipc/github-issue-source-preference.test.ts @@ -30,13 +30,7 @@ describe('registerGitHubHandlers', () => { beforeEach(harness.reset) it('drops the error field from listIssues envelope at the IPC boundary', async () => { - // Why: src/main/ipc/github.ts intentionally unwraps the { items, error? } - // envelope to just `items` to preserve the pre-feature-1 - // `Promise` contract for `gh:listIssues`. Feature 1's UI - // consumes the richer envelope through `gh:listWorkItems` instead. This - // test locks in that intentional drop so a future change that starts - // propagating the error through this channel (or that throws when an - // error is present) is caught. + // Preserve legacy gh:listIssues arrays; only gh:listWorkItems exposes the richer items/error envelope. listIssuesMock.mockResolvedValue({ items: [], error: { @@ -58,10 +52,7 @@ describe('registerGitHubHandlers', () => { }) it('threads issueSourcePreference through gh:listIssues', async () => { - // Why: repo.issueSourcePreference must reach listIssues so the upstream - // repo is queried when configured. A regression that drops the arg would - // pass the default-fixture tests (which assert `undefined`) silently, so - // this test pins the non-undefined preference-threading contract. + // Use a nondefault preference so missing argument forwarding cannot pass silently. harness.repos[0].issueSourcePreference = 'upstream' listIssuesMock.mockResolvedValue({ items: [] }) @@ -76,8 +67,6 @@ describe('registerGitHubHandlers', () => { }) it('threads issueSourcePreference through gh:listWorkItems', async () => { - // Why: gh:listWorkItems must also forward repo.issueSourcePreference - // (5th arg) so the work-items view honors the per-repo source selector. harness.repos[0].issueSourcePreference = 'origin' listWorkItemsMock.mockResolvedValue({ items: [] }) @@ -102,8 +91,7 @@ describe('registerGitHubHandlers', () => { ) }) - // Why: open-by-number must pin the same source the list uses, else a fork and - // its upstream sharing PR #42 open different PRs from the same click. + // Open-by-number must use the listed source when origin/upstream share an item number. it('pins the repo origin source preference on work item and details IPC', async () => { harness.repos = [ { diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts index c82949cbe94..6aba04f4eba 100644 --- a/src/main/ipc/github-repo-routing.ts +++ b/src/main/ipc/github-repo-routing.ts @@ -26,7 +26,6 @@ type RegisteredRepoValidationResult = | { kind: 'ok'; repo: Repo } | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } -/** A registered-repo lookup: the caller's path and id, plus the host it believes owns the row. */ type RegisteredRepoQuery = { repoPath: string repoId?: string | null @@ -49,7 +48,7 @@ function validateRegisteredRepo( message: 'Access denied: unknown repository path' } } - // Why id and path together: a twin's rows share the id, so the first id match can be the other host. + // Same-ID twins can live on different hosts; match path as well as ID. const pathRows = (repoId ? idRows : repos).filter( (candidate) => resolve(candidate.path) === resolvedRepoPath ) @@ -115,7 +114,6 @@ export function assertRegisteredGitHubRepo( return assertRegisteredRepo(registeredRepoQuery(args), store) } -/** A manual PR refresh resolves its twin the way the automatic queue does. */ export function assertRegisteredPRRefreshCandidateRepo( candidate: GitHubPRRefreshCandidate, store: Store @@ -123,7 +121,7 @@ export function assertRegisteredPRRefreshCandidateRepo( return assertRegisteredRepo(prRefreshCandidateQuery(candidate), store) } -/** Pure: a project is still registered at the candidate's path on its execution host (the queue key's identity). */ +/** Match the queue identity: registered path plus execution host. */ export function isPRRefreshCandidateRegistered( candidate: GitHubPRRefreshCandidate, repos: readonly Repo[] diff --git a/src/main/ipc/github-work-item-args.test.ts b/src/main/ipc/github-work-item-args.test.ts index 8e253bc95cc..670e5013c85 100644 --- a/src/main/ipc/github-work-item-args.test.ts +++ b/src/main/ipc/github-work-item-args.test.ts @@ -19,7 +19,7 @@ describe('dispatchWorkItem', () => { it('rejects non-number values coming across IPC', () => { const fn = vi.fn() - // Renderer can send anything; simulate a string that slips past TS. + // Model malformed renderer input despite the static type. const bogus = { repoPath: '/r', number: 'abc' as unknown as number } expect(dispatchWorkItem(bogus, repo, fn)).toBeNull() expect(fn).not.toHaveBeenCalled() diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index 2497c8e93f9..38f9192f206 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -19,10 +19,7 @@ type LocalGitExecOptions = { wslDistro?: string } -// Why: renderer input crosses the IPC boundary and is untrusted. Reject -// non-integer or < 1 numbers, and coerce unrecognised `type` values to -// undefined so getWorkItem falls through to its issue-then-PR probe rather -// than silently dispatching to the wrong branch. +// Reject invalid item numbers; unknown types fall back to issue-then-PR probing. export function dispatchWorkItem( args: WorkItemArgs, repo: RegisteredRepoContext, @@ -41,8 +38,7 @@ export function dispatchWorkItem( return null } const safeType = type === 'issue' || type === 'pr' ? type : undefined - // Why: open-by-number must pin the same source the list and start-point use, - // else a fork and its upstream sharing a PR number resolve to different PRs. + // Open-by-number must use the same origin/upstream source as listing and launch. return fn( repo.path, number, diff --git a/src/main/ipc/gitlab-issue-handlers.ts b/src/main/ipc/gitlab-issue-handlers.ts index d9d5ac2c4a9..447ce5abeb6 100644 --- a/src/main/ipc/gitlab-issue-handlers.ts +++ b/src/main/ipc/gitlab-issue-handlers.ts @@ -62,9 +62,7 @@ export function registerGitLabIssueHandlers(store: Store): void { localGitOptionArgs(store, repo)[0] ?? {}, page ) - // Why: Tasks page expects GitLabWorkItem[] so it can share row - // rendering with MRs. Map IssueInfo → WorkItem here so the renderer - // doesn't need a separate code path. + // Tasks shares MR/issue row rendering through the WorkItem shape. const workItems: GitLabWorkItem[] = result.items.map((issue) => ({ id: `gitlab-issue-${repo.id}-${issue.number}`, type: 'issue' as const, diff --git a/src/main/ipc/gitlab-repo-access.ts b/src/main/ipc/gitlab-repo-access.ts index 539ae2168fa..f50ffd86c03 100644 --- a/src/main/ipc/gitlab-repo-access.ts +++ b/src/main/ipc/gitlab-repo-access.ts @@ -40,10 +40,7 @@ function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): R return store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath) } -// Why: mirror github.ts assertRegisteredRepo — main-process handlers -// must never operate on a path the user hasn't explicitly registered as -// a repo (filesystem-auth boundary). Source context adds a host check so a -// task fetched from one machine cannot mutate a same-path repo on another. +// Require registered paths and the captured host so same-path twins cannot receive cross-host mutations. export function assertRegisteredRepo(args: GitLabRepoSelectorArgs, store: Store): Repo { const repo = findRegisteredGitLabRepo(args, store) if (!repo) { diff --git a/src/main/ipc/gitlab-work-item-handlers.ts b/src/main/ipc/gitlab-work-item-handlers.ts index 5ce25367d0a..f366f102aa2 100644 --- a/src/main/ipc/gitlab-work-item-handlers.ts +++ b/src/main/ipc/gitlab-work-item-handlers.ts @@ -14,9 +14,6 @@ import type { GitLabRepoSelectorArgs } from './gitlab-repo-access' import { assertRegisteredRepo, localGitOptionArgs, repoConnectionId } from './gitlab-repo-access' export function registerGitLabWorkItemHandlers(store: Store): void { - // Why: combined MR + issue list — Tasks screen and any future picker - // that wants a unified view. Centralizes the merge / sort logic so - // callers don't have to re-implement it. ipcMain.handle( 'gitlab:listWorkItems', async ( @@ -45,8 +42,6 @@ export function registerGitLabWorkItemHandlers(store: Store): void { } ) - // Why: aggregated dialog payload — body + discussions + pipeline jobs. - // Powers GitLabItemDialog's tabs. ipcMain.handle( 'gitlab:workItemDetails', async ( @@ -71,10 +66,7 @@ export function registerGitLabWorkItemHandlers(store: Store): void { } ) - // Why: paste-URL flow in the picker. The user pastes a GitLab URL that - // may target a project different from the local checkout's remote, so - // the call carries the parsed project path explicitly rather than - // resolving from cwd. + // Pasted URLs can name another project; use the parsed path instead of cwd. ipcMain.handle( 'gitlab:workItemByPath', async ( @@ -96,9 +88,7 @@ export function registerGitLabWorkItemHandlers(store: Store): void { repoConnectionId(repo), ...localGitOptionArgs(store, repo) ) - // Why: only persist a recent entry when the lookup actually - // produced an item. A 404 / auth failure shouldn't pollute the - // user's recents list with project paths they can't read. + // Failed lookups must not add unreadable projects to recents. if (result) { recordGitLabProjectRecent(store, args.host, args.path) } diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index 7ab7f77e6e4..1d5679669a1 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -233,8 +233,7 @@ describe('GitLab IPC handlers', () => { query: 'fix login' }) - // Why (#6263): the trimmed query must land in the 6th positional arg — - // previously the slot was hardcoded to `undefined`, so search never worked. + // Preserve the query's sixth argument slot; omitting it silently disables search (#6263). expect(listMergeRequestsMock).toHaveBeenCalledWith( '/local/orca', 'opened', @@ -696,8 +695,7 @@ describe('GitLab IPC handlers', () => { ) }) - // Regression for #7732: raw CI traces routinely exceed the 1 MB runtime transport - // frame cap, so the Checks panel opts into a main-side excerpt. + // Bound raw CI traces before they exceed the runtime frame cap (#7732). it('bounds the job trace in main when the caller asks for a log excerpt', async () => { const noisyTrace = [ 'section_start:1699000000:build\r\u001b[0K$ pnpm build', diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index b492e66ef7a..42d03705bc0 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -44,9 +44,7 @@ export function registerGitLabHandlers(store: Store): void { registerGitLabMergeRequestMutationHandlers(store) registerGitLabCiJobHandlers(store) - // Why: My Todos surface — cross-project, user-scoped. The repoPath is - // only used for the registered-repo guard; `glab api todos` doesn't - // care about cwd because the endpoint is user-scoped. + // Todos are user-scoped; repoPath is only the registration guard. ipcMain.handle('gitlab:todos', async (_event, args: GitLabRepoSelectorArgs) => { const repo = assertRegisteredRepo(args, store) return listTodos(repo.path, repoConnectionId(repo), ...localGitOptionArgs(store, repo)) diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts index 1fa22fe9afa..64296ccb068 100644 --- a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -14,7 +14,7 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) if (!repo) { const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) - // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. + // Ambiguous/requested hosts must report error, not hook-free success, so callers cannot skip inspection. return { status: args.hostId || repoIdExists ? 'error' : 'ok', hasHooks: false, @@ -27,7 +27,6 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v } if (getRepoDialableSshTargetId(repo)) { - // Why: this process serves no SSH filesystem. return { status: 'error', hasHooks: false, @@ -38,7 +37,7 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v const has = hasHooksFile(repo.path) const hooks = has ? loadHooks(repo.path) : null - // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". + // Well-formed unknown keys may come from a newer Orca; suggest updating instead of parse repair. const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) return { status: 'ok', diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts index b9145a5d7c0..689f2cc9538 100644 --- a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -28,7 +28,6 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v } const connectionId = getRepoDialableSshTargetId(repo) if (connectionId) { - // Why: this process serves no SSH filesystem. return { status: 'error', localContent: null, diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts index 9b70b06d10e..c0abeb95230 100644 --- a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -15,7 +15,7 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex 'hooks:inspectSetupScriptImports', async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) - // Why: this process serves no SSH filesystem. + // SSH ownership must never fall through to local filesystem inspection. if (!repo || isFolderRepo(repo) || getRepoDialableSshTargetId(repo)) { return [] } diff --git a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts index c4a705f6b3b..3df607f4ba5 100644 --- a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts +++ b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts @@ -133,7 +133,7 @@ const localCalls = [ mocks.effectiveHooks ] -// Why: this process serves no SSH filesystem, so an SSH owner is neither read remotely nor locally. +// This process must inspect SSH-owned repos neither remotely nor locally. function expectRoute(target: string | null): void { if (target) { for (const method of localCalls) { diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 2977fdfa02f..d78b759971e 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -176,9 +176,7 @@ describe('registerHostedReviewHandlers', () => { ) }) - // Why: without this the dirty preflight counts Orca's own shared symlinks as - // user work and Create Review tells the user to commit a link they cannot - // commit (issue #10451). Nothing else asserts the handler supplies them. + // Shared symlinks belong to Orca and must not fail the user's dirty preflight (#10451). it('passes the repo shared link paths through local review creation', async () => { const localRepo = { id: 'repo-local', @@ -220,8 +218,7 @@ describe('registerHostedReviewHandlers', () => { ) }) - // Why: remote creation never materializes these links, and `repo.path` names a - // path on the remote host — resolving it locally would read a stranger's file. + // Remote paths must not resolve unrelated local orca.yaml files. it('does not resolve shared link paths for an SSH repo', async () => { store.getRepo.mockImplementation((repoId: string) => repoId === repo.id ? { ...repo, symlinkPaths: ['node_modules'] } : null @@ -319,8 +316,7 @@ describe('registerHostedReviewHandlers', () => { force: true }) - // Why: the right sidebar renders only the selected worktree, so its lookup - // earns the per-minute tier instead of the card-list interval (#11532). + // Selected-worktree lookups use the faster active tier, unlike O(N) card polling (#11532). expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( expect.objectContaining({ branch: 'feature/selected', active: true, force: true }) ) @@ -505,8 +501,7 @@ describe('registerHostedReviewHandlers', () => { expect(createHostedReviewMock).not.toHaveBeenCalled() }) - // Why: these handlers are the Electron path for every hosted-review gh call — without - // the binding they silently run as the ambient login while the RPC path honours it. + // Electron and RPC routes must both honor the repo's bound gh account. describe('gh account binding', () => { const boundRepo = { ...repo, ghAccount: { host: 'github.com', user: 'octocat' } } diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index a4941163fff..5c3692939fe 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -46,8 +46,7 @@ function assertRegisteredRepoForBranch(args: HostedReviewForBranchArgs, store: S return assertRegisteredRepo(args.repoPath, store, args.repoId) } const matches = store.getRepos().filter((candidate) => { - // Which host holds the files, not which this client may dial: a remote path is POSIX and - // `resolve()` would rewrite it, and a row can name its SSH owner in either spelling. + // Check filesystem ownership, not dialability, before applying local path rules. const samePath = getRepoSshConnectionId(candidate) ? normalizeRemoteHostedReviewPath(candidate.path) === normalizeRemoteHostedReviewPath(args.repoPath) @@ -100,8 +99,7 @@ function normalizeRemoteHostedReviewPath(remotePath: string): string { if (!remotePath || remotePath.includes('\0')) { throw new Error('Access denied: invalid worktree path') } - // Why: SSH worktree paths belong to the remote POSIX host. Local path.resolve - // rewrites them on Windows and cannot authorize remote-only paths. + // Remote paths use POSIX rules; Windows resolve would rewrite their identity. const normalized = posix.normalize(remotePath) return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized } @@ -161,10 +159,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ...getLocalProjectGhExecOptions(store, repo), admissionTier: 'interactive' as const } - // Why: the dirty preflight must not count Orca's own shared symlinks as user work (issue #10451). - // Remote creation never materializes them, and `repo.path` is a path on the - // remote host — reading it locally would resolve an unrelated `orca.yaml`. - // Not dead code: SSH ignores these, so this only prevents that read and a poisoned cache entry. + // Exclude Orca-owned symlinks from local dirty checks; remote paths must never read or cache unrelated local orca.yaml files (#10451). const sharedLinkPaths = getRepoSshConnectionId(repo) ? [] : getWorktreeSharedLinkPaths(repo) const executionOptions = Object.keys(localGitOptions).length > 0 || sharedLinkPaths.length > 0 diff --git a/src/main/ipc/jira/cancellable-requests.ts b/src/main/ipc/jira/cancellable-requests.ts index aa10bc5b504..0c5a7581a2f 100644 --- a/src/main/ipc/jira/cancellable-requests.ts +++ b/src/main/ipc/jira/cancellable-requests.ts @@ -4,13 +4,7 @@ function normalizeRequestId(value: unknown): string | undefined { const CANCEL_TOMBSTONE_TTL_MS = 30_000 -/** - * Tracks one live AbortController per renderer-supplied request id so a superseded Jira call - * releases its slot in the shared request pool instead of draining it. - * - * Cancel can race ahead of run() (renderer abort before IPC registers). Tombstones keep that - * cancel sticky until run starts, so a late registration aborts immediately. - */ +/** Cancel superseded work to release queue slots; tombstones preserve cancellation arriving before IPC registration. */ export class JiraCancellableRequests { private readonly controllers = new Map() private readonly cancelTombstones = new Map() @@ -46,14 +40,12 @@ export class JiraCancellableRequests { live.abort() return } - // Re-delete first: Map.set keeps an existing key's original position, which would - // strand a stale expiry ahead of newer ones and stall the front-to-back sweep. + // Refresh insertion order so expiry sweeping cannot stop at an older renewed tombstone. this.cancelTombstones.delete(id) this.cancelTombstones.set(id, Date.now() + CANCEL_TOMBSTONE_TTL_MS) } - // Why: a cancel whose run() never arrives would otherwise leak its tombstone forever. - // Expiries only increase and Map keeps insertion order, so stop at the first live entry. + // Bound cancels whose runs never arrive; insertion order matches increasing expiry times. private pruneExpiredTombstones(): void { const now = Date.now() for (const [id, expiresAt] of this.cancelTombstones) { diff --git a/src/main/ipc/jira/register-handlers.ts b/src/main/ipc/jira/register-handlers.ts index 9c42ec566d3..bd802ce12a1 100644 --- a/src/main/ipc/jira/register-handlers.ts +++ b/src/main/ipc/jira/register-handlers.ts @@ -32,7 +32,6 @@ const VALID_FILTERS = new Set(['assigned', 'reported', 'all', ' const issueSummaryRequests = new JiraCancellableRequests() const searchRequests = new JiraCancellableRequests() -/** Registers every `jira:*` IPC handler on the main process. */ export function registerJiraHandlers(): void { ipcMain.handle('jira:connect', async (_event, args: JiraConnectArgs) => { if ( diff --git a/src/main/ipc/jira/user-search.ts b/src/main/ipc/jira/user-search.ts index 30138100429..385c8cdab14 100644 --- a/src/main/ipc/jira/user-search.ts +++ b/src/main/ipc/jira/user-search.ts @@ -9,7 +9,6 @@ function normalizeSiteId(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } -/** Registers the `jira:*` user-search IPC handlers (assignable and site-wide). */ export function registerJiraUserSearchHandlers(): void { ipcMain.handle( 'jira:listAssignableUsers', diff --git a/src/main/ipc/linear/issue-handlers.ts b/src/main/ipc/linear/issue-handlers.ts index 5f11073eadf..5d0bda0865d 100644 --- a/src/main/ipc/linear/issue-handlers.ts +++ b/src/main/ipc/linear/issue-handlers.ts @@ -41,8 +41,7 @@ export function registerLinearIssueHandlers(): void { ? (args!.filter as LinearListFilter) : undefined const limit = clampLinearIssueListLimit(args?.limit) - // Why: reject malformed filters at the trust boundary instead of - // normalizing them to empty (which would silently broaden results). + // Reject malformed filters; normalizing to empty would silently broaden results. const attributeFilter = args && 'attributeFilter' in args && args.attributeFilter !== undefined ? optionalParsedLinearIssueAttributeFilter(args.attributeFilter) @@ -119,9 +118,7 @@ export function registerLinearIssueHandlers(): void { if (typeof args?.id !== 'string' || !args.id.trim()) { return { ok: false, error: 'Issue ID is required' } } - // Why: IPC args are untyped at runtime — validate the updates object and - // individual fields to prevent the Linear SDK from receiving unexpected - // primitives that would produce confusing API errors. + // Validate untyped IPC updates before the SDK sees malformed primitives. if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: 'Updates object is required' } } diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index b2340146526..0fcb9706e93 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -3,8 +3,7 @@ import type * as NodeFs from 'node:fs' import type * as NodePath from 'node:path' import type { Store } from '../persistence' -// Why win32 paths on every host: a UNC image in a document is a Windows credential leak, and the -// guarantee is that its path text is refused before any filesystem call can reach the network. +// Use Windows paths on every host to prove UNC credential-leak rejection happens before filesystem access. vi.mock('node:path', async () => { const actual = await vi.importActual('node:path') return { ...actual.win32, default: actual.win32 } @@ -22,9 +21,9 @@ vi.mock('node:fs/promises', () => { if (name !== 'realpath') { return { isFile: () => true, isDirectory: () => false } } - // A project image that is really a link to a device name. + // A project-local name resolving to a device. if (String(target).endsWith('share-link.png')) { - // A local image that is really a link onto a network share. + // A local image resolving to a network share. return '\\\\nas\\pics\\shot.png' } return String(target).endsWith('dev-link.png') ? 'C:\\repo\\CON.png' : target @@ -338,8 +337,7 @@ describe('automatic image loads on a network share', () => { expect(fsCalls.filter((call) => call.includes('other'))).toEqual([]) }) - // Why beside a share document too: a host spelled with a look-alike (U+212A KELVIN SIGN, an NFD - // accent) can pass a case-folded folder comparison, so no share is loaded outside a project. + // Unicode look-alike hosts must not bypass path-text rejection beside share documents. it.each([ '\\\\nas\\notes\\x.png', '\\\\NAS\\Notes\\img\\y.png', diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts index 23f53aec7e3..4b338079d66 100644 --- a/src/main/ipc/local-file-access-resolution.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -68,7 +68,7 @@ let project: string let outside: string beforeEach(async () => { - // The floating folder resolves through the app environment, as the headless runtime does too. + // Floating paths use AppEnvironment in desktop and headless runtimes. installFakeAppEnvironment({ getPath: () => userData.path }) invalidateAuthorizedRootsCache() base = await mkdtemp(join(await realpath(tmpdir()), 'orca-file-access-')) @@ -234,7 +234,7 @@ describe('requests with no declared access (roots only)', () => { }) }) -// Why: creating a symlink on Windows needs elevation or Developer Mode. +// Windows symlinks require elevation or Developer Mode. describe.skipIf(process.platform === 'win32')('project symlinks under every spelling', () => { let real: string let alias: string diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts index 94054a0ddf2..db9c56f768c 100644 --- a/src/main/ipc/local-file-access-resolution.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -23,7 +23,6 @@ const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' -/** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ export async function resolveDesktopAuthorizedPath( targetPath: string, store: Store, @@ -35,19 +34,16 @@ export async function resolveDesktopAuthorizedPath( }) } -/** A file the user named is used where it is; no root applies, and nothing is remembered. */ +/** User-named absolute paths require no root and grant no persistent access. */ function resolveUserNamedLocalPath(targetPath: string): string { - // Why isAbsolute on the raw input: resolve() would anchor `notes.txt` or `C:notes` to main's cwd. + // Reject raw relative and drive-relative paths before resolve can anchor them to main's cwd. if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { throw new Error(USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE) } return resolve(targetPath) } -/** - * A user-named path that must be an existing regular file, e.g. a notebook or a log being tailed. - * Inside a project it resolves as the default check does, to the real file. - */ +/** Require an existing regular user-named file; project paths retain normal canonicalization. */ export async function resolveUserNamedRegularFile( targetPath: string, store: Store @@ -59,23 +55,18 @@ export async function resolveUserNamedRegularFile( return filePath } -// Why every previewable type but PDF: chat shows these in an , which renders no PDF. +// Chat uses img rendering; PDF previews need a different surface. function isChatImage(filePath: string): boolean { const extension = extname(filePath).toLowerCase() return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extension]) && extension !== '.pdf' } -// Why the resolved path: `NUL.png\.` and `COM1.png\x\..` resolve to a device name. +// Resolve path text first because devices can hide behind dot and parent segments. function isRefusedAutomaticLoadPath(filePath: string): boolean { return isDeviceNamespacePath(filePath) || isWindowsReservedDeviceName(filePath) } -/** - * A file a document references (an image, typically) beyond what the default check allows: one in - * the document's own folder, like the common markdown-preview rule. A target outside the folder, - * or a network share, is refused by its path text before any filesystem call; a symlink inside the - * folder is still resolved by the folder check. - */ +/** Allow document-local resources; reject outside-folder or network paths before filesystem access and contain symlink targets. */ async function resolveDocumentResourcePath( targetPath: string, documentPath: string @@ -106,12 +97,7 @@ async function resolveDocumentResourcePath( return realTarget } -/** - * An image shown in a chat transcript, whoever's turn named it: any absolute local image file, - * typed by its real target. Transcripts load as they scroll into view, so a network share is read - * only inside a project the user added from it (the default check); anywhere else its path text, - * like a device path, is refused before any filesystem call. - */ +/** Chat accepts absolute images by real target type; network shares are readable only inside registered project roots. */ async function resolveChatImagePath(targetPath: string, store: Store): Promise { if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -128,20 +114,17 @@ async function resolveChatImagePath(targetPath: string, store: Store): Promise ~/.ssh/id_rsa` must not be read as an image. + // A .png symlink to a secret is not an image. if (!isChatImage(realTarget)) { throw new Error(CHAT_IMAGE_TYPE_MESSAGE) } return realTarget } -/** - * Adding a file beside a document the user opened: the target must stay inside the document's own - * folder, symlinks included. - */ +/** Writes beside an opened document must remain within its real folder. */ async function resolveDocumentFolderPath( targetPath: string, documentPath: string @@ -179,7 +162,7 @@ function isOpenedDocument(targetPath: unknown, documentPath: string): boolean { ) } -// Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. +// Unknown IPC access kinds fall back to ordinary project-root authorization. function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { if (typeof access !== 'object' || access === null || !('kind' in access)) { return undefined @@ -207,7 +190,6 @@ function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { return undefined } -/** What a desktop request does with its path; each declared kind adds access to only some. */ export type LocalRequestOperation = 'read' | 'write' | 'rename-from' | 'rename-to' | 'import-into' type KindRule = (targetPath: string) => Promise @@ -219,7 +201,7 @@ function declaredKindRule( ): KindRule | undefined { switch (fileAccess.kind) { case 'user-file': - // Why renames: resolveLocalRenamePaths declares this only for the opened document itself. + // Only the opened document itself receives this rename access. return operation !== 'import-into' ? async (targetPath) => resolveUserNamedLocalPath(targetPath) : undefined @@ -238,17 +220,14 @@ function declaredKindRule( } } -/** - * The one resolver for desktop local file requests. A declared kind never refuses what the default - * project check allows; its own rule only adds paths outside every project. - */ +/** Declared access adds paths beyond project roots without refusing paths the default check allows. */ export async function resolveLocalRequestPath( targetPath: string, access: unknown, store: Store, operation: LocalRequestOperation ): Promise { - // Why the leaf is kept: a rename acts on a link itself, never on what it points to. + // Rename operates on link entries, not their resolved targets. const options = { preserveSymlink: operation === 'rename-from' || operation === 'rename-to' } const fileAccess = parseLocalFileAccess(access) const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store) @@ -258,12 +237,12 @@ export async function resolveLocalRequestPath( if (typeof targetPath !== 'string') { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - // Why device text first: a device is never a file to load, even inside a project. + // Device names must be refused before any automatic load, even inside projects. const automaticLoad = fileAccess.kind === 'document-resource' || fileAccess.kind === 'chat-image' if (automaticLoad && isRefusedAutomaticLoadPath(resolve(targetPath))) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - // Why the default check refuses an outside path by its text first: no share is contacted here. + // The default textual root check refuses outside paths without contacting shares. const insideRoots = await resolveDesktopAuthorizedPath(targetPath, store, options).catch( () => undefined ) @@ -276,11 +255,7 @@ export async function resolveLocalRequestPath( return insideRoots } -/** - * Both paths of a desktop rename. Renaming the opened document (its document-folder access) follows - * the user-file rule: the user typed the new path, so it may go anywhere, and its Undo, declared - * from the moved file, may come back from there. Any other rename gets the default check only. - */ +/** Opened documents may rename anywhere and undo from there; other renames keep ordinary root checks. */ export async function resolveLocalRenamePaths( oldPath: string, newPath: string, @@ -298,7 +273,6 @@ export async function resolveLocalRenamePaths( } } -/** A desktop read/stat request; no declared access means roots only. */ export function resolveLocalFileRequestPath( targetPath: string, access: unknown, @@ -307,7 +281,6 @@ export function resolveLocalFileRequestPath( return resolveLocalRequestPath(targetPath, access, store, 'read') } -/** A desktop save; user-file access adds the open file the user named. */ export function resolveLocalWriteRequestPath( targetPath: string, access: unknown, diff --git a/src/main/ipc/local-watcher-event-observers.ts b/src/main/ipc/local-watcher-event-observers.ts index ad7b87a10ec..a7148629c48 100644 --- a/src/main/ipc/local-watcher-event-observers.ts +++ b/src/main/ipc/local-watcher-event-observers.ts @@ -5,10 +5,7 @@ import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { getLocalWatcherRoot } from './filesystem-watcher-paths' import type { WatchedRoot } from './filesystem-watcher-wsl' -/** - * Main-process consumers of the renderer-driven local watchers. Observers ride - * an existing subscription; they never start or keep a watcher alive. - */ +/** Observe existing renderer-driven subscriptions without starting or retaining watchers. */ export type LocalWatcherEventObserver = { onEvents(root: WatchedRoot, events: readonly WatcherEvent[]): void /** Overflow, child restart, or watcher error: path precision was lost. */ @@ -34,7 +31,6 @@ export function notifyLocalWatcherInterrupted(root: WatchedRoot): void { } } -/** The live local watcher whose root is `targetPath` or its nearest watched ancestor. */ export function findCoveringLocalWatchRoot(targetPath: string): WatchedRoot | null { if (watcherLifecycleState.watchedRoots.size === 0) { return null diff --git a/src/main/ipc/local-worktree-runtime-options.test.ts b/src/main/ipc/local-worktree-runtime-options.test.ts index f7a1b0430e1..ea70581bdd7 100644 --- a/src/main/ipc/local-worktree-runtime-options.test.ts +++ b/src/main/ipc/local-worktree-runtime-options.test.ts @@ -4,8 +4,7 @@ import type * as WorktreeIdModule from '../../shared/worktree/id' const counter = vi.hoisted(() => ({ splitCalls: 0 })) -// Why: `splitWorktreeId` (and the `Object.keys` snapshot around it) is the per-row work the repo -// loop used to repeat once per repo. Counting it makes the O(repos x rows) regression observable. +// Count per-row parsing to expose O(repos × rows) rescans. vi.mock('../../shared/worktree/id', async (importOriginal) => { const actual = await importOriginal() return { @@ -46,7 +45,7 @@ beforeEach(() => { describe('getLocalRepoForRegisteredWorktree', () => { it('walks the worktree meta table once, not once per repo', () => { - // Worst case: the owning repo is last, so every earlier repo used to force a full rescan. + // Put the owner last to expose scans repeated for earlier repos. const repoCount = 10 const rowCount = 200 const repos = Array.from({ length: repoCount }, (_, i) => ({ diff --git a/src/main/ipc/local-worktree-runtime-options.ts b/src/main/ipc/local-worktree-runtime-options.ts index a16fe2aae55..199bd2ab40d 100644 --- a/src/main/ipc/local-worktree-runtime-options.ts +++ b/src/main/ipc/local-worktree-runtime-options.ts @@ -19,7 +19,6 @@ function getCandidateLocalWorktreePaths( return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) } -/** Repos owning a registered worktree at one of `candidatePaths`, in one pass over the meta table. */ function collectRepoIdsWithRegisteredWorktreeMeta( store: RuntimeStore, candidatePaths: Set @@ -46,8 +45,7 @@ export function getLocalRepoForRegisteredWorktree( } const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) - // Built at most once, and only when a repo actually needs it, so the meta table is never - // rescanned per repo — 59 IPC call sites hit this, some per keystroke. + // Lazily scan meta once, not once per repo, on frequent IPC paths. let repoIdsWithMeta: Set | undefined return store .getRepos() @@ -61,8 +59,7 @@ export function getLocalRepoForRegisteredWorktree( ) } -/** Git options for a repo already resolved by `getLocalRepoForRegisteredWorktree`, - * so a caller needing both does not walk every repo's worktree meta twice. */ +/** Reuse a resolved repo without scanning worktree metadata again. */ export function getLocalGitOptionsForRepo( store: RuntimeStore, repo: Repo | undefined @@ -70,8 +67,7 @@ export function getLocalGitOptionsForRepo( if (!repo || typeof store.getProjects !== 'function' || typeof store.getSettings !== 'function') { return {} } - // Why: file discovery must use the same resolved runtime as project git, - // terminals, and agents even when the worktree path is a Windows path. + // Match project Git/terminal runtime even for Windows-spelled paths. return getLocalProjectWorktreeGitOptions(store, repo) } diff --git a/src/main/ipc/localhost-worktree-labels.ts b/src/main/ipc/localhost-worktree-labels.ts index 77c3c3d6503..c7cf47df4cc 100644 --- a/src/main/ipc/localhost-worktree-labels.ts +++ b/src/main/ipc/localhost-worktree-labels.ts @@ -18,9 +18,7 @@ export function registerLocalhostWorktreeLabelHandlers(store: Store): void { 'localhostWorktreeLabels:register', async (_event, rawArgs: unknown): Promise => { const route = parseRegisterArgs(rawArgs) - // Why: the proxy will forward to any host it's given, so we restrict the - // target to loopback or a host:port that matches a live workspace port — - // otherwise this IPC is an open proxy / SSRF vector. + // Restrict proxy targets to loopback or live workspace ports to prevent arbitrary forwarding. await assertAllowedTarget(store, route.targetUrl) return localhostWorktreeLabelProxy.registerRoute(route) } @@ -39,11 +37,9 @@ async function assertAllowedTarget(store: Store, targetUrl: string): Promise | undefined - // Why: killing only the shell orphans pipeline stages; detached spawn lets one negative-pid SIGKILL reap the whole group. + // Kill the detached process group so pipeline children cannot outlive the shell. const killTree = (): void => { if (!child?.pid) { return @@ -30,7 +30,7 @@ export function readCommandStdout( } } - // Why: short timeout so a wedged macOS probe never hangs; this timer owns the process-group kill. + // Bound the probe and own the whole process-group kill. const timer = setTimeout(() => { if (settled) { return @@ -60,7 +60,7 @@ export function readCommandStdout( killTree() settle(() => reject(error)) } - // Why: an unhandled Readable 'error' would crash the main process; treat stdout errors like spawn errors. + // Treat stdout errors as probe failure instead of unhandled errors that crash main. child.stdout?.on('error', failWith) child.on('error', failWith) child.on('close', (code, signal) => { diff --git a/src/main/ipc/macos-notification-center.ts b/src/main/ipc/macos-notification-center.ts index 6cc61dd0673..540f26e51fb 100644 --- a/src/main/ipc/macos-notification-center.ts +++ b/src/main/ipc/macos-notification-center.ts @@ -9,20 +9,19 @@ function paneGroupId(paneKey: string): string { export function macosNotificationIdentity( request: NotificationDispatchRequest ): Pick { - // Why darwin only: Windows maps these to toast Tag/Group, which have their own replace rules. + // Windows toast Tag/Group has different replacement rules. if (process.platform !== 'darwin' || !request.paneKey) { return {} } - // Why: only a read covering its journal position retires it, so it leaves by its once-only id. + // Structured alerts retire only on reads covering their journal position; keep once-only IDs. if (request.structuredOrigin) { return request.notificationId ? { id: request.notificationId } : {} } - // Why no id: Electron routes a click to the first object shown with an id, even a released one. + // Reused IDs bind clicks to the first shown Electron object, even after release. return { groupId: paneGroupId(request.paneKey) } } -/** Removes a read pane's thread and the structured alerts this read retired. */ -// Why: close() needs the live object (released at 5 min, gone after restart); this needs none. +// Remove without a live object; close cannot work after retention expiry or restart. export function removeReadMacosNotifications( paneKey: string, retired: readonly AnnouncedNotification[] diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts index d2fef525304..f5731630d48 100644 --- a/src/main/ipc/minimax-credentials.test.ts +++ b/src/main/ipc/minimax-credentials.test.ts @@ -128,7 +128,6 @@ describe('registerMiniMaxCredentialsHandlers', () => { cookieConfigured: true, apiKeyConfigured: false, cookieProtection: 'sealed', - // Null, not 'sealed': nothing is stored, so there is nothing to make a claim about. apiKeyProtection: null, sessionCookieSupported: true, cookieUnreadable: undefined, @@ -172,8 +171,7 @@ describe('registerMiniMaxCredentialsHandlers', () => { const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() registerMiniMaxCredentialsHandlers(service as RateLimitService) await invoke('minimaxCredentials:saveCookie', '_token=abc') - // Why: the save handler is fire-and-forget — wait a microtask cycle so - // the queued `void rateLimits?.refresh()` resolves before we assert. + // Drain fire-and-forget credential refresh before asserting invalidation. await new Promise((resolve) => setImmediate(resolve)) expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) expect(refresh).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/mobile-pairing-listener-family.test.ts b/src/main/ipc/mobile-pairing-listener-family.test.ts index e9cf311dd6a..c99738570da 100644 --- a/src/main/ipc/mobile-pairing-listener-family.test.ts +++ b/src/main/ipc/mobile-pairing-listener-family.test.ts @@ -31,7 +31,7 @@ import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' const GLOBAL_IPV6 = '2605:340:cd51:2a01:0:2b13:f279:c096' -// Why #31161: Node's 0.0.0.0 listener refuses every IPv6 connection, so a v6 entry mints a dead link. +// IPv4-only 0.0.0.0 binds cannot serve advertised IPv6 links (#31161). describe('pairing addresses follow the runtime listener family', () => { const handlers = new Map unknown>() diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 36aed831399..8e49dea2cf0 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -21,8 +21,7 @@ vi.mock('qrcode', () => ({ } })) -// Why: only the interface enumeration is faked; the real `os` stays available for the integration -// test below, which needs tmpdir() for a real runtime's user data directory. +// Mock interface enumeration only; integration fixtures still need real os.tmpdir. vi.mock('os', async (importOriginal) => ({ ...(await importOriginal()), networkInterfaces: networkInterfacesMock @@ -41,7 +40,7 @@ import { createMobileRpcSurfaceRuntime } from '../runtime/runtime-rpc-mobile-met import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { WebSocketTransport } from '../runtime/rpc/ws-transport' -// Why: production widens to 0.0.0.0, an IPv4-only listener, so address-listing stubs report that bind. +// Match production's IPv4-only widened listener. function registerWithListener(stub: Record, bindHost = '0.0.0.0'): void { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each stub implements every server member its test's handlers call. registerMobileHandlers({ getNetworkExposureBindHost: () => bindHost, ...stub } as never) @@ -122,8 +121,7 @@ describe('registerMobileHandlers', () => { }) it('ranks container and VM bridges below every reachable address', async () => { - // Why: a phone can never reach docker0, so advertising it makes the direct - // path lose the pairing race and silently relays every session. + // Container bridges should not become unreachable automatic direct candidates. networkInterfacesMock.mockReturnValue({ docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], 'vEthernet (Default Switch)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }], @@ -136,7 +134,7 @@ describe('registerMobileHandlers', () => { registerWithListener({}, '::') - // Real IPv6 outranks a bridge IPv4: the phone can reach one, never the other. + // Prefer a reachable external IPv6 candidate over container bridge IPv4. await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'en0', address: '192.168.1.24' }, @@ -201,8 +199,7 @@ describe('registerMobileHandlers', () => { }) it('keeps a real LAN address that merely overlaps a container subnet', async () => { - // Why: Docker's 172.16/12 pool overlaps genuine corporate LANs, so the - // bridge check keys on interface name — a subnet test would demote this. + // Container subnets overlap corporate LANs; identify bridges by interface name. networkInterfacesMock.mockReturnValue({ eth0: [{ family: 'IPv4', internal: false, address: '172.17.4.9' }], docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] @@ -219,8 +216,7 @@ describe('registerMobileHandlers', () => { }) it('never auto-advertises a bridge: a bridge-only host pairs over Relay with no address', async () => { - // Why: a bridge address the phone provably cannot reach must not become the default, and Relay - // needs no local address — so the QR ships without a direct path instead of an unreachable one. + // Relay can pair without advertising an unreachable local interface. networkInterfacesMock.mockReturnValue({ docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], 'vEthernet (WSL)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }] @@ -238,14 +234,12 @@ describe('registerMobileHandlers', () => { await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ available: true, connectionMode: 'automatic', - // Why: the offer's loopback fallback points at the scanning phone, not this host — reporting it - // would print a direct endpoint under the QR that nothing can dial. + // Loopback points at the scanning phone; omit the unusable direct endpoint. endpoint: null }) expect(createMobilePairingOffer).toHaveBeenCalledWith( expect.objectContaining({ address: null }) ) - // The bridges stay pickable, just never automatically. await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'docker0', address: '172.17.0.1' }, @@ -255,8 +249,7 @@ describe('registerMobileHandlers', () => { }) it('refuses a LAN-only QR on a bridge-only host instead of advertising the bridge', async () => { - // Why: LAN has no Relay to fall back on, so a dead direct endpoint is worse than saying so — - // the guidance points at the picker, where the bridge is still selectable on purpose. + // LAN-only has no relay fallback; guide manual interface selection instead of minting a dead endpoint. networkInterfacesMock.mockReturnValue({ docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] }) @@ -274,8 +267,7 @@ describe('registerMobileHandlers', () => { }) it('honors an explicitly picked bridge address', async () => { - // Why: exclusion is about the automatic default only — a user who knows their bridge is routable - // (a VM guest pairing with the host) must still be able to advertise it. + // Keep bridges manually selectable for users with deliberately routable VM networking. networkInterfacesMock.mockReturnValue({ docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] @@ -481,9 +473,7 @@ describe('registerMobileHandlers', () => { scope: 'runtime', reach: 'network' }) - // Why: STA-2370 — generating a runtime offer must widen the listener BEFORE advertising its endpoint, - // or a client could read the URL and connect before the LAN bind exists. Assert call ORDER, not just - // that the widen ran, so a regression that widens after minting the offer is caught. + // Widen before minting the offer so clients cannot race an unbound LAN endpoint (STA-2370). expect(ensureNetworkExposure).toHaveBeenCalled() expect(ensureNetworkExposure.mock.invocationCallOrder[0]).toBeLessThan( createPairingOffer.mock.invocationCallOrder[0] @@ -509,8 +499,7 @@ describe('registerMobileHandlers', () => { }) it('reports runtime pairing unavailable rather than advertising a bridge', async () => { - // Why: runtime clients have no Relay fallback, so a bridge-only host has nothing reachable to - // advertise — and no widen should happen for a link that would be dead anyway. + // Runtime clients lack relay fallback; do not widen or mint when no automatic address is usable. networkInterfacesMock.mockReturnValue({ docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] }) @@ -525,9 +514,7 @@ describe('registerMobileHandlers', () => { expect(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() }) - // Why: every loopback form the address field accepts must behave identically once the user declared - // "This computer only" — `splitHostPort`/`ws://` overrides all reach the handler verbatim, so gating on - // the raw string alone treated `localhost:8443` and `ws://127.0.0.1:6768` as off-host. + // All accepted loopback spellings must honor an explicit this-computer reach. it.each([ '127.0.0.1', 'localhost', @@ -549,17 +536,14 @@ describe('registerMobileHandlers', () => { }) ).resolves.toMatchObject({ available: true, deviceId: 'runtime-local' }) - // Why: "This computer only" exists so nothing is exposed off-host — a loopback link is already - // served by the loopback listener, and the widen is one-way for the life of the process. + // Widening is one-way; local-only intent must leave the listener on loopback. expect(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( expect.objectContaining({ reach: 'this-computer' }) ) }) - // Why: the Custom field is documented as "SSH tunnel, reverse proxy, or custom hostname", so a loopback - // front-end there still needs the listener open behind it — gating the widen on the address SHAPE broke - // `ssh -L 8443::6768` (sshd dials the LAN address on this host and gets refused). + // Custom loopback frontends can tunnel to this host's LAN address; do not infer local-only intent from their shape. it.each(['127.0.0.1:8443', 'localhost', 'ws://127.0.0.1:6768'])( 'widens the listener for a custom %s address that fronts a tunnel', async (address) => { @@ -591,8 +575,7 @@ describe('registerMobileHandlers', () => { handlers.get('mobile:getRuntimePairingUrl')?.(null, { address: '127.0.0.1', rotate: true }) ).resolves.toMatchObject({ available: true }) - // Why: only an explicit "This computer only" declines remote reach; an undeclared caller keeps the - // pre-STA-2370 opt-in behavior rather than silently minting a link the widen never backed. + // Only explicit this-computer intent declines widening; undeclared callers keep legacy opt-in behavior. expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() }) @@ -609,8 +592,7 @@ describe('registerMobileHandlers', () => { }) ).resolves.toMatchObject({ available: true }) - // Why: the declared reach and the advertised address disagree; honoring the declaration would mint a - // LAN link with no LAN listener behind it, so the address it actually publishes wins. + // Published LAN addresses require a LAN bind even if declared reach disagrees. expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( expect.objectContaining({ reach: 'network' }) @@ -619,9 +601,7 @@ describe('registerMobileHandlers', () => { it('reports the runtime pairing url unavailable and mints no offer when the widen fails', async () => { const createPairingOffer = vi.fn() - // Why: STA-2370 — a failed widen leaves the listener on loopback, so the handler must NOT advertise a - // LAN endpoint. A regression that swallows the rejection and mints an offer against a loopback-only - // listener is caught here: createPairingOffer must never run. + // Failed widening must stop offer creation rather than advertise a dead LAN endpoint (STA-2370). const ensureNetworkExposure = vi.fn().mockRejectedValue(new Error('bind refused')) const rpcServer = { createPairingOffer, ensureNetworkExposure } @@ -856,8 +836,7 @@ describe('runtime pairing bind host', () => { reach: 'this-computer' }) ).resolves.toMatchObject({ available: true }) - // Why: the exposure regression — picking "This computer only" used to rebind the listener to - // 0.0.0.0 and leave it there, publishing the runtime to the whole LAN for the rest of the process. + // This-computer pairing must not permanently expose the runtime to the LAN. expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') await expect( @@ -867,15 +846,14 @@ describe('runtime pairing bind host', () => { reach: 'network' }) ).resolves.toMatchObject({ available: true }) - // Why: the LAN/Tailscale choice still opts in — a client off this host cannot reach a loopback bind. + // Off-host reach still requires widening. expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') } finally { await server.stop() } }) - // Why: the whole guarantee is worthless if it lasts one process — the local web client authenticating - // marks its grant lastSeenAt > 0, which used to make the NEXT launch bind every interface. + // Authenticated local links must not cause the next launch to bind every interface. it('still binds loopback on the next launch after the local link has been used', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) const server = await startServer(userDataPath) @@ -889,7 +867,7 @@ describe('runtime pairing bind host', () => { })) as { available: true; deviceId: string } expect(offer.available).toBe(true) deviceId = offer.deviceId - // Exactly what MobileSocketWiring does for every authenticated socket, local browser included. + // Model authentication updating grant lastSeenAt, including local clients. server.getDeviceRegistry()?.updateLastSeen(deviceId) } finally { await server.stop() @@ -927,8 +905,7 @@ describe('runtime pairing bind host', () => { const relaunched = await startServer(userDataPath) try { - // Why: the reconnect widen must survive — a client that really did connect from off-host has to - // find the listener at launch without the user re-pairing. + // Genuine off-host grants must preserve reconnect reach after restart. expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') } finally { await relaunched.stop() diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 237d126fdd2..a8b9288a916 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -23,10 +23,7 @@ import { type WindowsMobileFirewallEnvironment } from '../runtime/windows-mobile-firewall' -// Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it -// advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise -// mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address -// that fronts an SSH tunnel or reverse proxy, still opts in. +// Only explicit this-computer intent plus a loopback address avoids widening; custom tunnel endpoints still need remote reach. function servesThisComputerOnly(reach: RuntimePairingReach | undefined, address: string): boolean { if (reach !== 'this-computer') { return false @@ -44,10 +41,6 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { } } -// Why: the mobile IPC handlers provide the renderer with QR code pairing data, -// device management, and WebSocket readiness status. They depend on the -// OrcaRuntimeRpcServer because it owns the device registry and TLS state. - export type MobileHandlerDependencies = { firewallEnvironment?: WindowsMobileFirewallEnvironment openWindowsNetworkSettings?: () => Promise @@ -89,19 +82,14 @@ export function registerMobileHandlers( rotate?: boolean } ) => { - // Why: allow the caller to specify which network interface address to - // embed in the QR code. This supports overlay networks (Tailscale, - // ZeroTier) where the default LAN IP isn't reachable from the phone. + // Explicit interfaces support overlay networks unreachable through the default LAN candidate. const ip = args?.address ?? (await getDefaultPairingAddress( rpcServer.getNetworkExposureBindHost(), getDefaultRouteInterfaceNames )) - // Why: the local address is optional under Relay — the QR carries the relay invite, so a host - // with nothing auto-advertisable (only container bridges, or no interface at all) still pairs. - // The offer's endpoint then falls back to loopback, which is the phone's own device: the direct - // candidate loses the race by construction. LAN-only has no relay to fall back on, so it fails closed. + // Relay works without a direct candidate; LAN-only must refuse when none is usable. if (!ip && args?.connectionMode === 'local-only') { return { available: false as const, @@ -111,13 +99,7 @@ export function registerMobileHandlers( } } - // Why: coalesce repeated QR regenerations onto a single never-scanned - // pending token so the copy-button flow doesn't accumulate orphaned - // device credentials forever. The token graduates to a real entry when - // a phone actually connects (lastSeenAt > 0). When the caller passes - // `rotate: true` (explicit "Regenerate" intent because the prior token - // may have been exposed), we discard any pending token and mint a fresh - // one so the new QR carries a different credential. + // Reuse never-scanned tokens until explicit rotation; authenticated entries remain independent. const offer = await rpcServer.createMobilePairingOffer({ address: ip, connectionMode: args?.connectionMode, @@ -125,8 +107,7 @@ export function registerMobileHandlers( name: `Mobile ${new Date().toLocaleDateString()}` }) if (!offer.available) { - // Why: surface Relay mint failures (and other pairing unavailability) - // so the UI can refuse a silent LAN QR under the Relay label. + // Preserve relay mint failures instead of silently presenting a LAN offer under the Relay label. return { available: false as const, reason: offer.reason, @@ -143,9 +124,7 @@ export function registerMobileHandlers( qrSize: qr.ok ? qr.qrSize : null, ...(!qr.ok ? { qrError: qr.reason } : {}), pairingUrl: offer.pairingUrl, - // Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at - // whichever device scans the QR — never this host. Report no endpoint so the UI omits it - // instead of printing an address the phone can't reach. + // Unadvertised loopback fallback points at the scanning device; omit it from UI endpoints. endpoint: ip ? offer.endpoint : null, deviceId: offer.deviceId, connectionMode: offer.connectionMode @@ -166,11 +145,7 @@ export function registerMobileHandlers( return { available: false as const } } - // Why: STA-2370 — generating a runtime pairing offer is the user's explicit opt-in to remote - // reach, so widen the loopback listener before advertising its LAN endpoint. If the widen fails the - // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. - // "This computer only" is the opposite opt-in: the loopback listener already serves it, and the widen - // never narrows back, so that pick alone must not expose the runtime off-host. + // Widen before advertising remote reach; this-computer intent must not trigger the irreversible widening (STA-2370). const thisComputerOnly = servesThisComputerOnly(args?.reach, ip) if (!thisComputerOnly) { try { @@ -180,8 +155,7 @@ export function registerMobileHandlers( '[mobile] Network exposure failed while creating a runtime pairing offer:', error ) - // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so - // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. + // Preserve actionable widening failure guidance rather than generic unavailability. return { available: false as const, reason: 'network_exposure_failed' as const, @@ -190,15 +164,13 @@ export function registerMobileHandlers( } } - // Why: web/desktop runtime clients need full runtime access, not the - // mobile allowlist used by phone QR pairing. + // Web/desktop runtime clients need full access instead of the phone allow-list. const offer = rpcServer.createPairingOffer({ address: ip, rotate: args?.rotate, name: `Runtime ${new Date().toLocaleDateString()}`, scope: 'runtime', - // Why: a grant that only ever pointed at loopback must not make the next launch bind every - // interface when its local client reconnects (that would restore the exposure one restart later). + // Persist local reach so local authentication cannot expose the next launch to the LAN. reach: thisComputerOnly ? 'this-computer' : 'network' }) if (!offer.available) { @@ -220,9 +192,7 @@ export function registerMobileHandlers( if (!registry) { return { devices: [] } } - // Why: devices with lastSeenAt === 0 were created during QR generation - // but never actually scanned/connected. Showing them as "paired" is - // misleading, so we filter them out. + // Never-scanned QR tokens are not paired devices. return { devices: registry .listDevices() @@ -241,8 +211,7 @@ export function registerMobileHandlers( if (!registry) { return { grants: [] } } - // Why: generated web/runtime links are bearer credentials even before a - // client first connects, so pending runtime grants must stay revocable. + // Runtime links are bearer credentials immediately, so unconnected grants must remain revocable. return { grants: registry .listDevices() @@ -277,7 +246,7 @@ export function registerMobileHandlers( if (!isWindowRenderer(event)) { return { ok: false as const, reason: 'unsupported' as const } } - // Why: elevated inputs come from the running runtime, never the renderer. + // Firewall repair derives privileged inputs from runtime state, never renderer payloads. const port = getWebSocketPort(rpcServer.getWebSocketEndpoint()) return repairWindowsMobileFirewall(port, firewallEnvironment) }) diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 071a3c6ddb6..3b39cc8c033 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -113,8 +113,7 @@ type InitialSnapshotCallback = ( lifecycle?: NativeChatTurnLifecycle ) => void -// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript -// call; transcript-watch fires it during setup, so tests invoke it directly. +/** Capture the real setup callback so tests can drive initial transcript snapshots. */ function initialSnapshot(callIndex: number): InitialSnapshotCallback { const call = subscribeTranscript.mock.calls[callIndex] if (!call) { @@ -264,8 +263,7 @@ describe('nativeChat subscribe lifecycle', () => { const renderer = createSender(5) subscribe(renderer.sender, 'drain-error') - // transcript-watch delivers the drain error synchronously via onInitialSnapshot; - // invoke the captured callback to exercise the handler's forwarding closure. + // Initial drain errors arrive synchronously through this handler-owned callback. initialSnapshot(0)([], false, 0, 'Transcript unavailable') expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4892990c4ac..9fea67f63b9 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -105,8 +105,7 @@ describe('nativeChat:readSession handler', () => { ]) ) - // Point homedir-derived Claude root at our fixture via HOME so the resolver - // (which reads homedir() internally) finds the transcript. + // Point homedir-based Claude transcript resolution at the temporary fixture. const previousHome = process.env.HOME process.env.HOME = root try { @@ -130,8 +129,6 @@ describe('nativeChat:readSession handler', () => { tempRoots.push(root) const projectDir = join(root, '.claude', 'projects', '-repo') await mkdir(projectDir, { recursive: true }) - // Five user turns; reading with limit 2 returns only the last two, and a - // larger limit pages in older ones (chronological order preserved). const records = [1, 2, 3, 4, 5].map((n) => ({ type: 'user', uuid: `u-${n}`, @@ -207,8 +204,7 @@ describe('nativeChat:readSession handler', () => { } ) - // The listener dispatches handleSubscribe fire-and-forget; give it a beat - // to resolve the path and install the watcher before we append. + // Wait for fire-and-forget path resolution and watcher setup before appending. await new Promise((resolve) => setTimeout(resolve, 100)) await appendFile( @@ -221,8 +217,7 @@ describe('nativeChat:readSession handler', () => { })}\n` ) - // The first frame is a bounded snapshot and later frames are appends. - // Collect ids across both and assert the new turn shows up. + // Collect turn IDs across the initial bounded snapshot and later appends. const appendedIds = (): string[] => sent .filter((s) => s.channel === 'nativeChat:appended') @@ -235,7 +230,6 @@ describe('nativeChat:readSession handler', () => { expect(payload.subscriptionId).toBe('sub-1') expect(appendedIds()).toContain('a-1') - // Destroyed window tears down the watcher without error. expect(sender.listenerCount('destroyed')).toBe(1) sender.emit('destroyed') } finally { @@ -248,8 +242,7 @@ describe('nativeChat:readSession handler', () => { }) it('settles the view with a pending frame while the transcript is unflushed', async () => { - // The user-visible bug: a session that has not been prompted never writes - // its JSONL, so with no frame at all the chat view spins indefinitely. + // Unprompted sessions lack JSONL; send an empty pending snapshot instead of leaving the view spinning. const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-unflushed-')) tempRoots.push(root) await mkdir(join(root, '.claude', 'projects', '-repo'), { recursive: true }) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 5f581b61041..9f54fdab980 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -18,27 +18,21 @@ import { agentHookServer } from '../agent-hooks/server' import { nativeChatTranscriptPathOnExecutionHost } from '../native-chat/ssh-transcript-path' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -// Re-export so existing test imports of `clearNativeChatTranscriptCache` from -// this module keep working after the cache moved to transcript-read-cache.ts. export { clearNativeChatTranscriptCache } export type NativeChatReadSessionArgs = { agent: AgentType sessionId: string - /** How many of the most-recent turns to return. The renderer starts at the - * default window and raises this to page in older history as it scrolls up. */ + /** Recent-turn window; increase it to page backward. */ limit?: number - /** Authoritative transcript path from the agent hook (providerSession), used to - * locate the file when the session id no longer names it (recent Claude Code). */ + /** Hook-authoritative path when session IDs no longer identify transcript filenames. */ transcriptPath?: string } -// Why: render and parse only the recent window so long transcripts do not stall -// either the main process or the message list. Pagination raises this limit. +// Bound parsing and rendering to the recent window; pagination raises the limit. async function readSession(args: NativeChatReadSessionArgs): Promise { const { agent, sessionId } = args - // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW return readNativeChatTranscriptTail({ agent, @@ -53,8 +47,7 @@ async function readSession(args: NativeChatReadSessionArgs): Promise>() -// Why: unsubscribe and renderer destruction must invalidate async watcher setup -// before it can publish a late subscription into the live map. +// Retire pending setup on unsubscribe/destruction before it can publish a late watcher. const pendingSubscriptions = new Map>() const senderLifetimes = new Map>() @@ -191,7 +182,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } const { subscriptionId, agent, sessionId, transcriptPath } = args const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - // Replace any prior subscription under the same id (session change/resubscribe). const pending = beginPendingSubscription(sender.id, subscriptionId) const rendererSignal = registerSenderCleanup(sender) const canPublish = (): boolean => @@ -210,8 +200,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg if (!canPublish()) { return } - // `pending` marks a window with no transcript behind it yet; clients that - // don't know the flag still stop spinning on the empty snapshot. + // Legacy clients ignore pending but still stop spinning on the empty snapshot. const payload: NativeChatAppendedPayload = { subscriptionId, frame: { type: 'snapshot', messages: [], hasMore: false, pending: true } @@ -222,8 +211,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg if (!canPublish()) { return } - // Forward an initial-drain error so a watching client's first frame carries it - // instead of stranding the view at 'loading' when the read keeps throwing. + // Put initial drain errors on the first frame so clients cannot remain stuck loading. const payload: NativeChatAppendedPayload = { subscriptionId, frame: { @@ -273,15 +261,13 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg return } - // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup - // while path resolution is pending; only the owning generation may publish its watcher. + // Publish only the generation still owning this subscription after async path resolution. const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pending) if (!canPublish() || !stillCurrent) { subscription.unsubscribe() return } const bySubId = liveSubscriptions.get(sender.id) ?? new Map() - // A concurrent subscribe with the same id beat us here; honor the latest. const existing = bySubId.get(subscriptionId) if (existing) { existing.subscription.unsubscribe() @@ -337,7 +323,7 @@ export function registerNativeChatHandlers(): void { ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => { teardownSubscription(event.sender.id, args.subscriptionId) }) - // Local chats only: a paired host never sends its agent output, so its chats show the message. + // Failure output is local-only because paired hosts do not transmit agent output. ipcMain.handle('nativeChat:conversationNameFailureOutput', (_event, sessionId: unknown) => { const naming = getStructuredAgentSessionHost()?.deps.conversationNaming return typeof sessionId === 'string' ? (naming?.failureOutput(sessionId) ?? null) : null diff --git a/src/main/ipc/native-notification-delivery.ts b/src/main/ipc/native-notification-delivery.ts index 6cd733afdb4..901f2179a4e 100644 --- a/src/main/ipc/native-notification-delivery.ts +++ b/src/main/ipc/native-notification-delivery.ts @@ -28,7 +28,7 @@ export function deliverNativeNotification( if (getEffectiveNotificationSoundId(settings) !== 'system') { notificationOptions.silent = true } else if (process.platform === 'darwin') { - // Why: macOS treats an unset sound as silent, so request Electron's default when using the OS sound. + // macOS treats unset sound as silent; request the OS default explicitly. notificationOptions.sound = 'default' } const notification = new Notification({ @@ -43,7 +43,7 @@ export function deliverNativeNotification( } } - // Why: prevent GC from collecting the notification and its click handler while it's still visible. + // Retain the notification and click handler through display lifetime. let clickHandler: (() => void) | null = null let failedHandler: ((_event: unknown, error?: string) => void) | null = null const entryForId: { notification: Notification; release: () => void } | null = args.notificationId @@ -68,9 +68,9 @@ export function deliverNativeNotification( } failedHandler = (_event, error) => { - // Why: Electron 42's macOS backend reports unsigned/delivery failures here; release now, not after the fallback timer. + // Release immediately on macOS delivery/signing failure instead of waiting for retention expiry. logNativeNotificationFailure(args.source, error) - // Why: feeds the permission card's evidence. + // Feed permission-card fallback evidence. recordNotificationDeliveryOutcome('failed') release() } @@ -78,12 +78,9 @@ export function deliverNativeNotification( const worktreeId = args.worktreeId const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null - // Why: a structured chat has no PTY pane. Its pane key's leaf is a synthetic id minted from - // the session, so focusTerminal would hunt a split-layout leaf that does not exist; the - // unified tab id in the same key is what reveals the chat. + // Structured chat keys have synthetic leaves; reveal their unified tab instead of a nonexistent PTY pane. const chatTarget = args.surface === 'agent-session' ? paneTarget : null - // Why: only a "repoId::worktreePath" id has a repo to activate. Folder and floating workspaces have - // none; a pane reveal shows its workspace where it lives on its own, so it still binds the click. + // Repo-less folder/floating workspaces still bind clicks through their pane target. const repoId = worktreeId?.includes('::') ? getRepoIdFromWorktreeId(worktreeId) : null if (worktreeId && (repoId !== null || paneTarget)) { clickHandler = () => { @@ -110,7 +107,7 @@ export function deliverNativeNotification( if (!paneTarget) { return } - // Why: focusTerminal targets the pane by stable leafId so split-pane notifications land on the exact pane. + // Stable leaf IDs select the exact split pane. win.webContents.send('ui:focusTerminal', { tabId: paneTarget.tabId, worktreeId, diff --git a/src/main/ipc/native-notification-lifecycle.ts b/src/main/ipc/native-notification-lifecycle.ts index f3984a340a2..0ebb1021d84 100644 --- a/src/main/ipc/native-notification-lifecycle.ts +++ b/src/main/ipc/native-notification-lifecycle.ts @@ -3,7 +3,7 @@ import type { Notification } from 'electron' const NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS = 2500 const NOTIFICATION_RELEASE_FALLBACK_MS = 5 * 60 * 1000 -// Why: keep a strong reference so GC can't collect notifications (and their click handlers) before the user interacts with them. +// Keep strong references so GC cannot discard displayed notifications and click handlers. export const activeNotifications = new Set() export const activeNotificationsById = new Map< string, diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index f8bd1ddc567..0a3f711746c 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -23,7 +23,7 @@ vi.mock('./local-file-access-resolution', () => ({ resolveDesktopAuthorizedPath: resolveAuthorizedPathMock })) vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock })) -// Why: the mocked resolver returns made-up `/real/...` paths, which stand for real files already. +// Authorizer fixtures already represent canonical real files. vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index f5ec3109beb..764c4c2f2b8 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -38,7 +38,7 @@ function cancelPendingStarts(owner: WebContents, filePath: string): void { } } -// Why: a reloaded, crashed or closed renderer has lost its sessions, so its kernels go with it. +// Renderer page teardown must also retire its notebook kernels. function kernelsOf(owner: WebContents): Map { let kernels = kernelsByOwner.get(owner) if (!kernels) { @@ -61,8 +61,7 @@ function kernelsOf(owner: WebContents): Map { return kernels } -// Why the notebook path is user-named: it is an open tab, and it only picks the kernel's cwd and -// the venv folder. Inside a project it resolves to the real file, so the cwd is its real folder. +// Open notebook paths select cwd/venv; project paths canonicalize to their real file. export function registerNotebookHandlers(store: Store): void { ipcMain.handle( 'notebook:listPythonEnvironments', @@ -71,7 +70,7 @@ export function registerNotebookHandlers(store: Store): void { args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean } ): Promise => { await resolveUserNamedRegularFile(args.filePath, store) - // Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp. + // Environment listing needs the same unresolved spelling as rootPath. return listPythonEnvironments(args.filePath, args.rootPath, { runWorkspaceInterpreters: args.runWorkspaceInterpreters === true }) @@ -90,7 +89,6 @@ export function registerNotebookHandlers(store: Store): void { if (owner.isDestroyed()) { return { status: 'failed', detail: 'The notebook closed before its kernel started.' } } - // Each start stays independent until the notebook or issuing document closes. const requestToken = randomUUID() const controller = startCancellations.begin(event, requestToken) if (!controller) { @@ -102,8 +100,7 @@ export function registerNotebookHandlers(store: Store): void { starts.set(args.filePath, pending) pending.add(controller) try { - // Why the real file's folder: relative imports and data paths resolve as on disk, even when - // the notebook was opened through a link. + // Use the real notebook folder so relative imports and data paths follow disk layout. const cwd = dirname(await realpath(await resolveUserNamedRegularFile(args.filePath, store))) if (controller.signal.aborted || owner.isDestroyed()) { return { status: 'failed', detail: 'The notebook closed before its kernel started.' } diff --git a/src/main/ipc/notification-authorization-status.ts b/src/main/ipc/notification-authorization-status.ts index d51f97ede17..874d3330b79 100644 --- a/src/main/ipc/notification-authorization-status.ts +++ b/src/main/ipc/notification-authorization-status.ts @@ -9,15 +9,7 @@ const HELPER_TIMEOUT_MS = 4000 let cachedHelperPath: string | null | undefined -/** - * Resolves the bundled notification-status helper binary. - * - * Why: the helper must live in Contents/MacOS next to the Electron executable - * — NSBundle resolves the process's bundle by walking up from the executable - * path, macOS keys notification records to that identity, and macOS 26 aborts - * UNUserNotificationCenter for executables run from Contents/Resources - * (#7929). Dev copies and packaged builds (extraFiles) both place it there. - */ +/** Place the helper beside Electron in Contents/MacOS: bundle identity controls notification records, and macOS 26 aborts helpers under Resources (#7929). */ function resolveHelperPath(): string | null { if (cachedHelperPath !== undefined) { return cachedHelperPath @@ -31,16 +23,7 @@ function resolveHelperPath(): string | null { return cachedHelperPath } -/** - * Reads the app's real macOS notification authorization via a helper binary - * calling UNUserNotificationCenter.getNotificationSettings. Returns null when - * the helper is unavailable or fails, so callers can fall back to weaker - * delivery-probe evidence. - * - * Why a helper at all: Electron exposes no API for notification authorization - * (scheduling silently succeeds even while macOS is suppressing display), so - * the only truthful signal is the native settings read. - */ +/** Read native authorization absent from Electron APIs; return null on helper failure so callers can use weaker delivery evidence. */ let readInFlight: Promise | null = null export function readNotificationAuthorizationStatus(): Promise { @@ -48,8 +31,7 @@ export function readNotificationAuthorizationStatus(): Promise | null = null -// Why: firing one probe instantiates Electron's presenter and pops the macOS permission dialog; once per session is enough. +// The first probe triggers Electron's permission presenter; avoid repeating it this session. let permissionDialogTriggeredThisSession = false export function recordNotificationDeliveryOutcome(outcome: 'delivered' | 'failed'): void { @@ -23,20 +23,13 @@ export function hasTriggeredPermissionDialogThisSession(): boolean { return permissionDialogTriggeredThisSession } -// Why: handler registration marks a fresh session; permission evidence from a previous one must not leak in. export function resetNotificationPermissionEvidence(): void { lastObservedDeliveryOutcome = null deliveryProbeInFlight = null permissionDialogTriggeredThisSession = false } -/** - * Fallback for hosts without the native helper: schedules a silent probe and reports whether macOS accepted it. - * On a fresh install the probe also instantiates Electron's presenter, which pops the macOS permission dialog. - * - * Known ambiguity (verified macOS 26): while undecided, or when notifications are toggled off after being - * authorized, macOS silently swallows accepted requests, so 'delivered' can over-report; only 'failed' is definitive. - */ +/** Fallback scheduling probe may trigger first-use permission UI; accepted requests can be hidden, so only failure proves blocked delivery. */ export function probeNotificationDelivery(): Promise { if (deliveryProbeInFlight) { return deliveryProbeInFlight @@ -76,7 +69,7 @@ export function probeNotificationDelivery(): Promise { if (!settled) { settled = true diff --git a/src/main/ipc/notification-sound-ipc.ts b/src/main/ipc/notification-sound-ipc.ts index 84ca5534a81..8fb327e58b9 100644 --- a/src/main/ipc/notification-sound-ipc.ts +++ b/src/main/ipc/notification-sound-ipc.ts @@ -11,7 +11,7 @@ import { const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 export function registerNotificationSoundHandlers(store: Store): void { - // Why: return the path so the preload's path-keyed cache skips the 10MB IPC round-trip on repeat dispatches. + // Return paths so preload caching avoids repeat 10MB sound transfers. ipcMain.removeHandler('notifications:resolveSoundPath') ipcMain.handle( 'notifications:resolveSoundPath', diff --git a/src/main/ipc/notifications-delivery-gating.test.ts b/src/main/ipc/notifications-delivery-gating.test.ts index c7fb4fe3c01..e8cb5bae25d 100644 --- a/src/main/ipc/notifications-delivery-gating.test.ts +++ b/src/main/ipc/notifications-delivery-gating.test.ts @@ -344,8 +344,7 @@ describe('registerNotificationHandlers', () => { delivered: false, reason: 'blocked-by-system' }) - // Why: a swallowed native notification would still pile up in the - // Notification Center delivered list — skip creating it entirely. + // Skip creation entirely so suppressed alerts cannot accumulate in Notification Center. expect(notificationCtorMock).not.toHaveBeenCalled() } finally { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) diff --git a/src/main/ipc/notifications-permission-onboarding.test.ts b/src/main/ipc/notifications-permission-onboarding.test.ts index d7645e5cf21..fa4ae1e9034 100644 --- a/src/main/ipc/notifications-permission-onboarding.test.ts +++ b/src/main/ipc/notifications-permission-onboarding.test.ts @@ -47,8 +47,7 @@ describe('notifications:probeDelivery', () => { } function getProbeOnceEventHandler(eventName: string): (...args: unknown[]) => void { - // Why: findLast — a test may run several probes, and only the newest - // probe's listeners can settle the pending promise. + // Only the latest probe's listeners can settle the current pending request. const call = notificationOnceMock.mock.calls.findLast((c: unknown[]) => c[0] === eventName) if (!call) { throw new Error(`Probe notification ${eventName} once handler not registered`) @@ -123,7 +122,6 @@ describe('notifications:probeDelivery', () => { readAuthorizationStatusMock.mockResolvedValue('denied') expect(await handler({})).toEqual({ state: 'blocked', authoritative: true }) - // No probe notifications were needed for either readout. expect(notificationCtorMock).not.toHaveBeenCalled() }) @@ -139,7 +137,6 @@ describe('notifications:probeDelivery', () => { }) expect(notificationCtorMock).toHaveBeenCalledTimes(1) - // Polling again while pending must not spam more probe notifications. expect(await handler({}, { force: true })).toEqual({ state: 'awaiting-decision', authoritative: true @@ -169,7 +166,7 @@ describe('notifications:probeDelivery', () => { getProbeOnceEventHandler('show')() await expect(result).resolves.toEqual({ state: 'delivered', authoritative: false }) - // No persisted confirmation on purpose: OS permission changes between runs. + // Permission evidence cannot persist across OS permission changes between runs. expect(store.updateUI).not.toHaveBeenCalledWith({ notificationDeliveryConfirmed: true }) }) @@ -184,11 +181,9 @@ describe('notifications:probeDelivery', () => { await expect(probeResult).resolves.toEqual({ state: 'delivered', authoritative: false }) expect(notificationCtorMock).toHaveBeenCalledTimes(1) - // Cached session evidence answers non-force calls with no new probe. expect(await handler({})).toEqual({ state: 'delivered', authoritative: false }) expect(notificationCtorMock).toHaveBeenCalledTimes(1) - // Force bypasses the cache and schedules a fresh probe. const forced = handler({}, { force: true }) as Promise await vi.advanceTimersByTimeAsync(0) expect(notificationCtorMock).toHaveBeenCalledTimes(2) @@ -220,7 +215,7 @@ describe('notifications:probeDelivery', () => { await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false }) expect(notificationCloseMock).toHaveBeenCalledTimes(1) - // A timeout is ambiguous evidence, so the next non-force call probes again. + // Ambiguous timeout must permit another non-force probe. const secondResult = handler({}) as Promise await vi.advanceTimersByTimeAsync(0) expect(notificationCtorMock).toHaveBeenCalledTimes(2) diff --git a/src/main/ipc/notifications-retention-lifecycle.test.ts b/src/main/ipc/notifications-retention-lifecycle.test.ts index 2dbd9e6d378..e53ac94c473 100644 --- a/src/main/ipc/notifications-retention-lifecycle.test.ts +++ b/src/main/ipc/notifications-retention-lifecycle.test.ts @@ -137,7 +137,7 @@ describe('registerNotificationHandlers', () => { }) } as never) - // The real minting, so the synthetic leaf this asserts against is the one the chat uses. + // Use real pane-key minting to cover the chat's actual synthetic leaf. const paneKey = structuredAgentSessionPaneKey('chat-tab', 'session-abc') const handler = getDispatchHandler() expect( @@ -301,8 +301,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() const result = handler({}, { source: 'test', requireDisplayConfirmation: true }) - // Why: the darwin authorization gate resolves before the notification is - // created, so flush microtasks before grabbing its event listeners. + // Resolve async authorization before reading notification listeners. await vi.advanceTimersByTimeAsync(0) const showHandler = getNotificationOnceEventHandler('show') const failedHandler = getNotificationOnceEventHandler('failed') diff --git a/src/main/ipc/notifications-subject-retirement.test.ts b/src/main/ipc/notifications-subject-retirement.test.ts index c8508fd71a3..7b7e1067513 100644 --- a/src/main/ipc/notifications-subject-retirement.test.ts +++ b/src/main/ipc/notifications-subject-retirement.test.ts @@ -102,7 +102,7 @@ describe('notifications:dismiss by acknowledged subject', () => { ) ).toEqual({ delivered: true }) - // The renderer's rebuilt id no longer matches (the row's start moved); the subject still does. + // Rebuilt row IDs can change with start position; subject identity stays stable. expect(getDismissHandler()({}, ['agent:rebuilt'], [PANE])).toEqual({ dismissed: 1 }) expect(notificationCloseMock).toHaveBeenCalledTimes(1) expect(dismissMobileNotification.mock.calls.map(([id]) => id).sort()).toEqual([ @@ -152,7 +152,6 @@ describe('notifications:dismiss by acknowledged subject', () => { notificationId: 'agent:first' } ) - // Inside the first one's per-workspace cooldown: turned away on both paths. expect( await dispatch( {}, @@ -168,7 +167,7 @@ describe('notifications:dismiss by acknowledged subject', () => { getDismissHandler()({}, [], [PANE]) expect(dismissMobileNotification).not.toHaveBeenCalled() }) - // Positioned alerts outlive a no-read or earlier-bounded dismiss: a view ack can run before the transcript shows the edge. + // Positioned alerts survive reads before their journal boundary; pane-wide views cannot retire them. it('pane dismissal retires older-host structured alerts but not positioned ones past the read', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-pane-boundary-')) const controller = new RuntimeMobileNotificationController() @@ -189,7 +188,7 @@ describe('notifications:dismiss by acknowledged subject', () => { journalCursor: { epoch: 'remote-journal', sequence } }) const dispatch = getDispatchHandler() - // A host older than journal cursors sends its completion with no position. + // Older hosts lack journal positions on completions. for (const [id, cause] of [ ['agent-attention:older-host', undefined], ['agent-attention:A', origin('A', 1)], diff --git a/src/main/ipc/notifications-test-harness.ts b/src/main/ipc/notifications-test-harness.ts index 40823aeed07..edf956e93b3 100644 --- a/src/main/ipc/notifications-test-harness.ts +++ b/src/main/ipc/notifications-test-harness.ts @@ -1,8 +1,7 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' -// Why: mock state lives in this module so each split suite's `vi.mock` factory can -// pull it in with a lazy dynamic import (hoisted factories cannot close over imports). +// Split suites load mock state dynamically because hoisted factories cannot capture imports. /** Unconstrained spy; annotated explicitly so declaration emit never names @vitest/spy internals. */ export type NotificationSpy = Mock<(...args: never[]) => unknown> @@ -66,9 +65,7 @@ export function createTrustedUIRendererModuleMock(): Record { return { getTrustedUIRendererWindow: getTrustedUIRendererWindowMock } } -// Why: notifications.ts pulls in the tray module (for the minimized attention -// dot), which transitively loads app-icon/electron-toolkit; stub it so these -// suites stay focused on notification dispatch and avoid that import chain. +// Stub tray imports to keep notification suites independent of app-icon/toolkit setup. export function createSystemTrayModuleMock(): Record { return { setTrayAttention: setTrayAttentionMock } } diff --git a/src/main/ipc/notifications-workspace-click-binding.test.ts b/src/main/ipc/notifications-workspace-click-binding.test.ts index 2db606871de..fed6d2248fd 100644 --- a/src/main/ipc/notifications-workspace-click-binding.test.ts +++ b/src/main/ipc/notifications-workspace-click-binding.test.ts @@ -66,8 +66,7 @@ function clickHandlerBound(): boolean { return notificationOnMock.mock.calls.some((call: unknown[]) => call[0] === 'click') } -// A terminal pane in a workspace with no repo (floating or folder) is revealed by focusTerminal -// alone; its notification click used to be dropped because no repo id could be extracted. +// Repo-less folder/floating panes still need notification clicks routed to their workspace. describe('terminal notification clicks in repo-less workspaces', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index db03885dc93..fac9d64d9f3 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -42,7 +42,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime openNotificationSystemSettings() }) - // Why: Electron's main process can't read macOS auth status; expose only what we can observe (platform support + whether we've prompted). + // Keep the legacy status surface observational; native authorization is read by onboarding below. const getPermissionStatus = (): NotificationPermissionStatusResult => ({ supported: Notification.isSupported(), platform: process.platform, @@ -53,15 +53,15 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime ipcMain.handle( 'notifications:probeDelivery', async (_event, args?: { force?: boolean }): Promise => { - // Why: macOS-only — Windows/Linux have no first-use permission dialog, so the onboarding card never renders there. + // First-use permission onboarding is macOS-only. if (process.platform !== 'darwin' || !Notification.isSupported()) { return { state: 'unsupported', authoritative: false } } - // Why: probes surface the macOS permission dialog, so mark startup registration done to avoid a second prompt later. + // Record prompting intent so startup registration cannot trigger a second prompt. if (store.getUI().notificationPermissionRequested !== true) { store.updateUI({ notificationPermissionRequested: true }) } - // Preferred source: the bundled helper reads real auth silently, so polling tracks System Settings changes without banners. + // Prefer silent native reads so System Settings changes are visible without probe banners. const authorization = await readNotificationAuthorizationStatus() if (authorization === 'authorized') { recordNotificationDeliveryOutcome('delivered') @@ -72,13 +72,13 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime return { state: 'blocked', authoritative: true } } if (authorization === 'not-determined') { - // Why: the dialog only appears once something asks; fire one probe per session to trigger it, then report pending. + // Trigger the first-use presenter once, then report awaiting-decision. if (!hasTriggeredPermissionDialogThisSession()) { void probeNotificationDelivery() } return { state: 'awaiting-decision', authoritative: true } } - // Helper unavailable or 'unknown': fall back to scheduling-based probes with session caching to avoid repeated banners. + // Without native evidence, cache scheduling probes within the session to avoid repeat banners. const lastObservedDeliveryOutcome = getLastObservedDeliveryOutcome() if (!args?.force && lastObservedDeliveryOutcome !== null) { return { @@ -168,7 +168,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } }) - // A remote host reported no pending prompt: its alerts relayed to this desktop's phones are over. + // A cleared remote prompt must also retire alerts forwarded to this desktop's phones. ipcMain.removeHandler('notifications:settleStructuredPrompts') ipcMain.handle( 'notifications:settleStructuredPrompts', diff --git a/src/main/ipc/onboarding.ts b/src/main/ipc/onboarding.ts index 0ee539a88e2..c617a909e14 100644 --- a/src/main/ipc/onboarding.ts +++ b/src/main/ipc/onboarding.ts @@ -9,9 +9,7 @@ export function registerOnboardingHandlers(store: Store): void { ipcMain.removeHandler('onboarding:getNativeChatUpgradeTipVariant') ipcMain.handle('onboarding:get', (): OnboardingState => store.getOnboarding()) - // Why: never trust renderer input — a compromised/buggy caller could send - // unknown keys or wrong-typed values that would poison persisted state. - // Run every update through the shared whitelist sanitizer. + // Sanitize untyped IPC keys and values before persisting onboarding state. ipcMain.handle('onboarding:update', (_event, updates: unknown): OnboardingState => { return store.updateOnboarding(sanitizeOnboardingUpdate(updates)) }) diff --git a/src/main/ipc/opencode-go-credentials.test.ts b/src/main/ipc/opencode-go-credentials.test.ts index a90438127b7..77e85360cd5 100644 --- a/src/main/ipc/opencode-go-credentials.test.ts +++ b/src/main/ipc/opencode-go-credentials.test.ts @@ -60,7 +60,7 @@ describe('OpenCode Go write-only credentials', () => { expect(invoke(action, 'fake-key')).toEqual({ apiKeyConfigured: action === 'saveApiKey' }) expect(action === 'saveApiKey' ? mocks.save : mocks.clear).toHaveBeenCalledOnce() expect(refresh).toHaveBeenCalledOnce() - // Why: only clearing the saved key may hide the chip; another key source can still exist after a save. + // Saves may leave another key source; only explicit clearing can hide the chip. expect(invalidate).toHaveBeenCalledWith({ apiKeyCleared: action === 'clearApiKey' }) }) diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts index e16d949dc1a..7b8b1d3732f 100644 --- a/src/main/ipc/orca-profile-auth-handlers.test.ts +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -59,8 +59,7 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p describe('registerOrcaProfileHandlers auth channels', () => { beforeEach(() => { - // Why the port and per-test: userData resolves through AppEnvironment now, and - // the global setup's beforeEach reinstates its own fake before this runs. + // Reinstall AppEnvironment per test after global beforeEach resets its fake. installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) handlers.clear() createCloudLinkedOrcaProfileMock.mockReset() diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 2a8b24851d3..185b98877ba 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -89,8 +89,7 @@ const ipcEvent = { sender: { isDestroyed: () => false, send: vi.fn() } } describe('registerOrcaProfileHandlers', () => { beforeEach(() => { - // Why the port and per-test: userData resolves through AppEnvironment now, and - // the global setup's beforeEach reinstates its own fake before this runs. + // Reinstall AppEnvironment per test after global beforeEach resets its fake. installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() @@ -194,8 +193,7 @@ describe('registerOrcaProfileHandlers', () => { expect(appRelaunchMock).toHaveBeenCalledOnce() expect(relaunchAppMock).toHaveBeenCalledWith('profile-switch') - // Why quit, not exit: before-quit/will-quit teardown (scrollback capture, - // PTY kill, daemon checkpoints) must run on a profile switch. + // Profile switches must run normal quit teardown for scrollback, PTYs and daemon checkpoints. expect(appQuitMock).toHaveBeenCalledOnce() expect(appExitMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 389def8d61a..ad69c8d9af6 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -152,9 +152,7 @@ function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebConte } setTimeout(() => { relaunchApp(reason) - // Why: app.quit() (not app.exit) so before-quit/will-quit still run — - // renderer scrollback capture, PTY kill, stats flush, and daemon final - // checkpoints must not be skipped on a profile switch. + // Normal quit preserves renderer scrollback, PTY teardown, stats flush and final daemon checkpoints. quitProcess() }, 150) } @@ -172,10 +170,7 @@ export function registerOrcaProfileHandlers( getCurrentOrcaProfileAuthStatus(getProfileUserDataPath()) ) - // Why: a background refresh can revoke the session with no renderer request in - // flight, so push the change instead of waiting for the next pane to ask. - // Why not options.onAuthMutation: that hook drives the relay coordinator, which - // is the caller that just failed the refresh — re-entering it here would be a loop. + // Push background invalidation; re-entering onAuthMutation would loop into the relay coordinator that just failed. onOrcaCloudSessionInvalidated(broadcastOrcaProfileAuthStatusChanged) onOrcaCloudSignInProgressChanged(broadcastOrcaProfileSignInProgressChanged) @@ -197,9 +192,7 @@ export function registerOrcaProfileHandlers( return { status: 'already-active' } } - // Why: the current profile must be persisted before the global index - // points startup at the target profile. - // Switching leaves source files intact; relaunch cleanup still needs its live writer. + // Flush the source profile before switching the startup index; keep its writer alive through relaunch cleanup. await flushActiveProfileBeforeRelaunch(store) setActiveOrcaProfile(profileId) await runBeforeProfileRelaunch(options.onBeforeRelaunch) @@ -222,8 +215,7 @@ export function registerOrcaProfileHandlers( throw new Error('active_target_orca_profile_transfer_requires_relaunch') } if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { - // Why: transfer before any relaunch side effect so a duplicate-target - // or validation failure cannot strand the app in a quitting state. + // Finish transfer before relaunch side effects so validation failure cannot strand the app quitting. const result = await transferActiveProfileProject( args, getProfileUserDataPath(), diff --git a/src/main/ipc/parcel-watcher-canary-directory.ts b/src/main/ipc/parcel-watcher-canary-directory.ts index 28e7ec6518c..0ed8ebcd72f 100644 --- a/src/main/ipc/parcel-watcher-canary-directory.ts +++ b/src/main/ipc/parcel-watcher-canary-directory.ts @@ -6,8 +6,7 @@ export function createWatcherCanaryDirectory(): string | null { try { return mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) } catch (error) { - // Why: the canary is diagnostic only; read-only temp storage must not - // disable the crash-isolated watcher itself. + // Read-only temp storage must not disable the watcher; the canary is diagnostic. console.warn('[parcel-watcher-process] canary directory unavailable:', error) return null } diff --git a/src/main/ipc/parcel-watcher-cancellation-tracker.ts b/src/main/ipc/parcel-watcher-cancellation-tracker.ts index 78f39e3bbee..d7632c90943 100644 --- a/src/main/ipc/parcel-watcher-cancellation-tracker.ts +++ b/src/main/ipc/parcel-watcher-cancellation-tracker.ts @@ -8,8 +8,7 @@ type CancelledSubscribe = { timer: ReturnType } -// Why: cancellation may queue behind a legitimate crawl, but must not let a -// wedged native unsubscribe block destructive worktree cleanup forever. +// Allow legitimate crawls without letting wedged teardown block destructive cleanup indefinitely. export const WATCHER_PROCESS_CANCEL_TIMEOUT_MS = RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS export class WatcherCancellationTracker { diff --git a/src/main/ipc/parcel-watcher-child-launch.ts b/src/main/ipc/parcel-watcher-child-launch.ts index 325bbed2d10..8bb2dab9e3d 100644 --- a/src/main/ipc/parcel-watcher-child-launch.ts +++ b/src/main/ipc/parcel-watcher-child-launch.ts @@ -22,8 +22,7 @@ export function launchWatcherChild( const releaseReservation = reserveWatcherChild() if (!releaseReservation) { console.error('[parcel-watcher-process] physical watcher child limit reached') - // Why: capacity is transient and has an event-driven retry owner. Preserve - // its type so the desktop root is not cached permanently unavailable. + // Preserve transient capacity errors so callers retry instead of caching the root as unavailable. throw new WatcherChildCapacityError() } const canaryDir = currentCanaryDir ?? createWatcherCanaryDirectory() @@ -47,8 +46,7 @@ export function launchWatcherChild( const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE if (faultHarnessPidFile && child.pid) { try { - // Why: exclusive creation lets the harness identify the child without a - // leaked test-only environment variable clobbering an existing file. + // Exclusive creation keeps a leaked harness path from overwriting an existing file. writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' }) } catch { // Fault-injection observability must never affect watcher availability. @@ -68,12 +66,10 @@ export function launchWatcherChild( signalPhysicalExit() releaseReservation() } - // Why: Node can close IPC before emitting exit. Recover at disconnect so a - // concurrent subscribe cannot replace the child without restoring old roots. + // Recover at disconnect, before a concurrent subscribe can replace the child and lose old roots. child.on('disconnect', () => onGone(child)) child.on('error', () => onGone(child)) - // Why: asynchronous spawn failures emit error then close without exit; close - // must release the physical-process budget even though no child was created. + // Spawn failure can emit close without exit; release its physical-process reservation. child.once('close', finalizePhysicalExit) child.on('exit', (code, signal) => { finalizePhysicalExit() diff --git a/src/main/ipc/parcel-watcher-child-registry.ts b/src/main/ipc/parcel-watcher-child-registry.ts index 82d8011ba5a..8a3ed6b3177 100644 --- a/src/main/ipc/parcel-watcher-child-registry.ts +++ b/src/main/ipc/parcel-watcher-child-registry.ts @@ -1,5 +1,4 @@ -// Why: stuck retiring children still consume RSS even after their logical pool -// slot is retired, so they must count against one process-wide hard cap. +// Retiring children still consume RSS and count against the process-wide cap. export const MAX_PHYSICAL_WATCHER_CHILDREN = 8 export class WatcherChildCapacityError extends Error { @@ -42,8 +41,7 @@ export function onWatcherChildCapacityAvailable(listener: () => void | Promise void | Promise | null = null waitFor(operation: () => Promise): Promise | null { - // Why: an idle child may miss its exit deadline without poisoning the - // reusable supervisor; queued work must re-check state after either result. + // Either idle-termination outcome can leave this supervisor reusable; queued work must recheck state. return this.current?.then(operation, operation) ?? null } @@ -78,8 +77,7 @@ export function terminateWatcherChild(child: ChildProcess): Promise { resolve(exited) } const onExit = (): void => finish(true) - // Why: an asynchronous spawn failure can close without emitting exit; the - // close event is still definitive proof that the child owns no OS handles. + // Spawn failures may close without exit; close still proves all child handles are released. const onClose = (): void => finish(true) child.once('exit', onExit) child.once('close', onClose) diff --git a/src/main/ipc/parcel-watcher-crash-fuse.ts b/src/main/ipc/parcel-watcher-crash-fuse.ts index 246d38caaac..29c3b6e8443 100644 --- a/src/main/ipc/parcel-watcher-crash-fuse.ts +++ b/src/main/ipc/parcel-watcher-crash-fuse.ts @@ -5,12 +5,7 @@ export const CRASH_FUSE_COOLDOWN_MAX_MS = 60 * 60_000 // A trial child whose canary confirms delivery this long resets the escalation. export const CRASH_FUSE_TRIAL_STABLE_MS = 10 * 60_000 -/** - * Opens after repeated child crashes, then half-opens after an exponential - * cooldown so native watching is retried instead of staying off until restart. - * Native watching is paused while the fuse is open; only callers with their - * own polling fallback keep seeing changes. - */ +/** Retry native watching after exponential cooldown; callers need their own polling while the fuse is open. */ export class WatcherProcessCrashFuse { private crashTimes: number[] = [] private openUntil: number | null = null @@ -23,8 +18,7 @@ export class WatcherProcessCrashFuse { if (this.openUntil !== null) { return } - // Why: a crash during the trial means the fault persists; don't spend - // another three restarts rediscovering it. + // A failed trial trips immediately instead of spending three more restarts. if (this.trialStartedAt !== null) { this.trip(now) return @@ -86,8 +80,7 @@ export class WatcherProcessCrashFuse { '[parcel-watcher-process] crash fuse cooldown elapsed; retrying native file watching' ) } - // Why: surviving is not enough; a trial child whose canary never armed or - // delivered has not shown the fault is gone. + // Survival alone is insufficient: an unarmed or silent canary has not proved recovery. if ( this.trialStartedAt !== null && this.healthySince !== null && diff --git a/src/main/ipc/parcel-watcher-delivery-canary.ts b/src/main/ipc/parcel-watcher-delivery-canary.ts index 414cf3cf934..dedda4bb2ef 100644 --- a/src/main/ipc/parcel-watcher-delivery-canary.ts +++ b/src/main/ipc/parcel-watcher-delivery-canary.ts @@ -1,16 +1,5 @@ -// Canary self-check for the watcher child. Why: @parcel/watcher can wedge -// silently — a lock-order inversion between Debounce::notify (debounce mutex → -// watcher mutex) and Watcher teardown (watcher mutex → debounce mutex in -// ~Watcher) deadlocks the single process-wide debounce thread, after which -// every subscription still acks but no events are ever delivered. The canary -// watches a private temp dir and touches a file in it; consecutive missed -// deliveries mean event delivery is dead, so exit and let the host respawn. -// -// A missed canary can also mean the OS event source itself is stalled (an -// overloaded macOS fseventsd delays every client). A restart cannot fix that -// and recreating every stream adds load, so before exiting the canary checks -// whether an independent fs.watch on the same dir also stays silent, and -// reports the stall to the host so callers can rescan instead of going stale. +// Parcel debounce/teardown lock inversion can silently deadlock delivery across subscriptions. +// Probe a private directory; independent fs.watch distinguishes a child fault from an OS event stall. import { type FSWatcher, mkdtempSync, rmSync, watch, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -27,8 +16,7 @@ const INDEPENDENT_PROBE_WINDOW_MS = 5_000 const INDEPENDENT_PROBE_WRITE_INTERVAL_MS = 1_000 export const CANARY_STALL_BACKOFF_INITIAL_MS = 30_000 export const CANARY_STALL_BACKOFF_MAX_MS = 5 * 60_000 -// Why: a deadlock can look like a stall (e.g. fs.watch muted by the same -// fault); bound it with a restart that the host's crash fuse still rate-limits. +// Even an apparent OS stall may hide a deadlock; bound it with a crash-fuse-limited restart. export const CANARY_STALL_RESTART_AFTER_MS = 15 * 60_000 export type WatcherCanaryActivity = { @@ -49,10 +37,7 @@ export function armedCanaryRevision( platform: NodeJS.Platform ): number | null { if (activity.pendingCrawlStartedAt !== null) { - // Why: FSEvents subscribe does not crawl, so a pending subscribe there is - // most likely stuck behind the deadlock the canary exists to catch. Crawls - // elsewhere (huge or network trees) hold Parcel's backend mutex for as long - // as they take, and a restart would only re-crawl from scratch. + // macOS subscribe does not crawl; elsewhere a legitimate crawl holds the backend mutex, so restarting would repeat it. return platform === 'darwin' ? activity.revision : null } return activity.liveSubscriptions > 0 ? activity.revision : null @@ -77,8 +62,7 @@ export async function startWatcherDeliveryCanary( try { canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) const watcher = await loadParcelWatcher() - // Why: pin the Windows backend like the main subscriptions do, so the - // canary never probes for Watchman. + // Match the Windows subscriptions so the canary never probes for Watchman. const opts: ParcelWatcher.Options = process.platform === 'win32' ? { backend: 'windows' } : {} await watcher.subscribe( canaryDir, @@ -104,9 +88,7 @@ export async function startWatcherDeliveryCanary( } }) - // One long-lived fs.watch on the canary dir: canary writes keep proving it - // works, and a stall probe only checks whether it delivered since time T. - // Why: opening one per probe makes macOS rebuild the shared FSEventStream. + // Keep one fs.watch: opening one per probe makes macOS rebuild the shared FSEventStream. let independentLastEventAt = 0 let independentWatch: FSWatcher | null = null const ensureIndependentWatch = (): boolean => { @@ -156,8 +138,7 @@ export async function startWatcherDeliveryCanary( return 'inconclusive' } await sleep(INDEPENDENT_PROBE_WRITE_INTERVAL_MS) - // Why: any native callback (not just the canary's) proves the shared - // debounce thread is alive, so the child is not deadlocked. + // Any native callback proves the shared debounce thread is alive. if (nativeDeliveredSince(startedAt)) { return 'native-delivered' } @@ -203,8 +184,7 @@ export async function startWatcherDeliveryCanary( const activityRevision = stableRevision() if (activityRevision === null) { misses = 0 - // Why: a disarmed canary cannot confirm a stall; end it so the host stops - // rescanning, and re-detect it once re-armed. + // Disarming invalidates stall evidence; stop host rescans until the canary can probe again. if (health === 'stalled') { stallBackoffMs = null stallStartedAt = null @@ -221,8 +201,7 @@ export async function startWatcherDeliveryCanary( return } await sleep(CANARY_EVENT_TIMEOUT_MS) - // A root may start crawling after this probe was written. Invalidate the - // probe instead of misclassifying lifecycle work as a delivery deadlock. + // A crawl starting after the write invalidates this probe; lifecycle work is not a delivery deadlock. if (stableRevision() !== activityRevision) { misses = 0 schedule(nextProbeDelay()) @@ -258,8 +237,7 @@ export async function startWatcherDeliveryCanary( return } if (verdict === 'silent') { - // Why: a host where fs.watch never delivered cannot tell a stall from a - // deadlock; any earlier delivery in this process proves it can. + // fs.watch must have delivered before it can distinguish OS stalls from child deadlock. if (!hasShallowWatchDelivered()) { restart('canary starved; fs.watch never delivered in this process') return diff --git a/src/main/ipc/parcel-watcher-delivery-health.ts b/src/main/ipc/parcel-watcher-delivery-health.ts index 58db4e3f9be..2b40ff90f07 100644 --- a/src/main/ipc/parcel-watcher-delivery-health.ts +++ b/src/main/ipc/parcel-watcher-delivery-health.ts @@ -6,13 +6,7 @@ import { } from './parcel-watcher-delivery-canary' import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' -/** - * Host side of the child's canary health reports. Why: during an OS event - * stall the child keeps its streams, so subscriptions would go silently stale. - * Once a stall outlasts one canary backoff period, every caller gets an - * overflow (rescan), backing off like the canary so a stalled system is not - * loaded further, and one more when the stall ends. - */ +/** Rescan roots during OS event stalls with backoff, then once on recovery; the child keeps its streams. */ export class WatcherDeliveryHealthMonitor { private timer: ReturnType | null = null private stalled = false diff --git a/src/main/ipc/parcel-watcher-entry-path.test.ts b/src/main/ipc/parcel-watcher-entry-path.test.ts index 1331cb2b7a9..bc8cd819c7a 100644 --- a/src/main/ipc/parcel-watcher-entry-path.test.ts +++ b/src/main/ipc/parcel-watcher-entry-path.test.ts @@ -42,8 +42,7 @@ describe('resolveWatcherProcessEntryPath', () => { }) it('uses the adjacent entry for a packaged host whose app root is not an asar', () => { - // orcad: a packaged Node bundle that ships this child beside orcad.js. Gating the - // adjacent probe on isPackaged sent it to a desktop out/main that never exists here. + // orcad ships this child beside orcad.js without Electron isPackaged or an out/main directory. const orcadRoot = path.join(path.sep, 'opt', 'orca') const adjacentEntry = path.join(orcadRoot, 'parcel-watcher-process-entry.js') diff --git a/src/main/ipc/parcel-watcher-event-delivery.ts b/src/main/ipc/parcel-watcher-event-delivery.ts index c190e2ee045..f51f9562d3c 100644 --- a/src/main/ipc/parcel-watcher-event-delivery.ts +++ b/src/main/ipc/parcel-watcher-event-delivery.ts @@ -41,8 +41,7 @@ async function mapWatcherEvent( isDirectory = await statWatcherEventPath(event.path, signal) } catch { signal?.throwIfAborted() - // Why: a path can vanish between the native event and metadata lookup. - // Treat unknown metadata as a file-like event so parent invalidation still runs. + // Vanished paths still need parent invalidation; unknown metadata remains file-like. } return { type: event.type, path: event.path, isDirectory } } diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts index e41893098c2..acb9a0aa74a 100644 --- a/src/main/ipc/parcel-watcher-host-subscriptions.ts +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -172,8 +172,7 @@ export function createHostWatcherSubscription({ if (pendingUnsubscribes.get(record.id) !== settle) { return } - // Why: canary setup itself can fail, so native teardown needs an - // independent host deadline that exits the child and releases handles. + // Canary setup may fail; an independent host deadline must still release native handles. void terminateUnavailableChild(child).catch(() => undefined) } const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) diff --git a/src/main/ipc/parcel-watcher-in-process-fallback.ts b/src/main/ipc/parcel-watcher-in-process-fallback.ts index 1807ba603a5..2235a7b4621 100644 --- a/src/main/ipc/parcel-watcher-in-process-fallback.ts +++ b/src/main/ipc/parcel-watcher-in-process-fallback.ts @@ -9,10 +9,7 @@ import type { } from './parcel-watcher-process-subscription' import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' -/** - * Use Parcel directly when Vitest mocks it. Production builds fail closed if - * the crash-isolated child entry is unavailable. - */ +/** Vitest uses mocked Parcel directly; production fails closed when the child entry is missing. */ export async function subscribeWithInProcessWatcher( dir: string, callback: WatcherProcessCallback, @@ -63,8 +60,7 @@ export async function subscribeWithInProcessWatcher( } let watcher: typeof ParcelWatcher try { - // Why: setup ownership starts before module loading; an abort or timeout - // during the import must settle the caller just like one during the crawl. + // Abort/timeout owns setup before module loading as well as during crawling. watcher = await Promise.race([loadParcelWatcher(), cancellation]) } catch (error) { clearPendingControls() @@ -106,9 +102,7 @@ export async function subscribeWithInProcessWatcher( active = false eventDelivery.close() clearPendingControls() - // Why: Parcel cannot cancel a crawl in-process. If it eventually finishes, - // release the late native handle instead of leaking it into the test host. - // Swallow unsubscribe failures so they never surface as unhandled rejections. + // In-process crawls cannot be cancelled; release late handles without unhandled teardown rejections. void subscriptionPromise .then((lateSubscription) => lateSubscription.unsubscribe()) .catch(() => undefined) diff --git a/src/main/ipc/parcel-watcher-interrupted-cancellation.ts b/src/main/ipc/parcel-watcher-interrupted-cancellation.ts index af23240cae1..05b6f28eb17 100644 --- a/src/main/ipc/parcel-watcher-interrupted-cancellation.ts +++ b/src/main/ipc/parcel-watcher-interrupted-cancellation.ts @@ -14,8 +14,7 @@ export function cancelInterruptedWatcherSubscribe(args: { return } resetPendingSubscribeAttempt(record) - // Why: recovery may replace this root synchronously from the terminal hook; - // publish failure only after its predecessor releases native handles. + // A terminal hook may synchronously replace this root; release old native handles before publishing failure. void args.restartChild().then( () => args.reportTerminalError(record, args.error), (terminationError: unknown) => diff --git a/src/main/ipc/parcel-watcher-pending-cancellation.ts b/src/main/ipc/parcel-watcher-pending-cancellation.ts index 9fd37d19a8d..877f85d275a 100644 --- a/src/main/ipc/parcel-watcher-pending-cancellation.ts +++ b/src/main/ipc/parcel-watcher-pending-cancellation.ts @@ -33,8 +33,7 @@ export function cancelPendingWatcherSubscribe({ return } if (!child?.connected) { - // Why: disconnect starts physical termination before cancellation sees the - // child; destructive callers must join that exact termination generation. + // Disconnect already started termination; destructive callers must join that exact generation. void onChildUnavailable(child).then( () => pending.reject(error), (terminationError: unknown) => @@ -44,8 +43,7 @@ export function cancelPendingWatcherSubscribe({ ) return } - // Why: only the child knows whether native setup is queued, active, or - // resolved, so its physical teardown must precede caller settlement. + // Only the child knows native setup state; prove its teardown before settling the caller. cancelledSubscribes.begin(record.id, child, error, pending.reject, () => restartChild(child)) sendCancel(child, record.id) } diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 8915a6c2874..cb07e10e2ad 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -34,7 +34,7 @@ describe('parcel watcher process canary', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! beforeEach(() => { - // Why: crawl-gating differs on macOS (no crawl in subscribe); pin a crawling backend. + // Pin a crawling backend; macOS subscribe does not crawl. Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) detectShallowWatchDeliveryMock.mockReset() vi.useFakeTimers() @@ -72,9 +72,7 @@ describe('parcel watcher process canary', () => { }) it('fails the shallow subscribe when the host never delivers fs.watch events', async () => { - // A real macOS 26.3.1 machine registers the watch and stays mute forever. - // Reporting failure is what lets the caller fall back to polling instead of - // showing indefinitely stale Git metadata. + // Registration can succeed with permanently silent delivery; callers must fall back to polling. detectShallowWatchDeliveryMock.mockResolvedValue(false) watchMock.mockImplementation(() => { const watcher = new EventEmitter() as EventEmitter & { close: () => void } @@ -414,8 +412,7 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'subscribe', id: 2, dir: '/queued', opts: {} }, undefined) process.emit('message', { op: 'cancel-subscribe', id: 2 }, undefined) - // Why: non-restart cancel reuses async unsubscribe teardown; drain the - // exclusive lifecycle queue after the active crawl finishes. + // Non-restart cancellation queues unsubscribe after the active crawl. finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index be3150364f6..c1325ba20ae 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -1,9 +1,4 @@ -// Forked (ELECTRON_RUN_AS_NODE) child that hosts its assigned @parcel/watcher -// subscriptions. Why: watcher.node has native teardown races that fail-fast -// the hosting process (issue #7547, 0xc0000409 on Windows; same class as -// #5377/#6635). Running the native module here turns a watcher fault into a -// contained child crash the host can recover from, instead of killing the app -// and every agent session in it. +// Isolate native watcher teardown crashes so they cannot kill the app and its agent sessions (#7547). import type * as ParcelWatcher from '@parcel/watcher' import { loadParcelWatcher } from './parcel-watcher-module-loader' import { startShallowWatcher } from './parcel-watcher-shallow-subscription' @@ -54,9 +49,7 @@ function main(): void { }) } - // Subscribe promises are kept (not just subscriptions) so an unsubscribe - // that races a still-crawling subscribe awaits it instead of leaking the - // native handle — on Windows a leaked handle keeps the worktree dir locked. + // Keep pending subscribes so racing unsubscribe waits for the handle and releases Windows directory locks. const subscriptions = new Map>() const liveSubscriptionIds = new Set() const eventDeliveries = new Map() @@ -115,9 +108,7 @@ function main(): void { send({ op: 'subscribe-started', id }) try { if (opts.mode === 'shallow') { - // Why: registration succeeding proves nothing on a host whose - // notification path is dead. Fail the subscribe so the caller falls - // back to polling rather than going quietly stale. + // Successful registration does not prove delivery; fail so callers can poll. if (!(await detectShallowWatchDelivery())) { throw new Error('Shallow watcher delivery unavailable on this host') } @@ -155,8 +146,7 @@ function main(): void { finishSubscriptionCrawl() } }) - // An unsubscribe can remove the record while subscribe() is crawling. - // Only advertise it as live if it is still owned by this process. + // Unsubscribe may have removed ownership while subscribe was crawling. const stillOwned = subscriptions.has(id) if (stillOwned) { liveSubscriptionIds.add(id) @@ -177,8 +167,7 @@ function main(): void { } const handleUnsubscribe = async (id: number): Promise => { - // Why: invalidate a probe already in flight, but keep future probes active. - // A native teardown deadlock is the failure the canary exists to recover. + // Invalidate this probe, but keep future probes armed to catch teardown deadlocks. subscriptionActivityRevision++ const pending = subscriptions.get(id) subscriptions.delete(id) @@ -186,8 +175,7 @@ function main(): void { eventDeliveries.get(id)?.close() eventDeliveries.delete(id) try { - // Why: Parcel already serializes crawl and teardown on one backend mutex. - // Mirror that ordering here so neither operation can mask a canary miss. + // Mirror Parcel's crawl/teardown mutex ordering so neither masks a canary miss. await runNativeWatcherLifecycleExclusive(async () => { const subscription = await pending await subscription?.unsubscribe() @@ -204,8 +192,7 @@ function main(): void { } const handleCancelSubscribe = (id: number): void => { - // Why: an in-flight native crawl cannot be cancelled safely; ask the host to - // kill this child instead of risking the Parcel teardown deadlock. + // In-flight native crawls cannot be cancelled safely; have the host kill this child. if (activeSubscriptionCrawlId === id) { subscriptions.delete(id) liveSubscriptionIds.delete(id) @@ -214,9 +201,7 @@ function main(): void { send({ op: 'cancel-requires-restart', id }) return } - // Why: cancel can race a crawl that already finished. Reuse unsubscribe - // teardown so a live native handle is released instead of leaked (Windows - // keeps the worktree locked while the handle stays open). + // A completed crawl still owns a handle; reuse unsubscribe to release it and any Windows directory lock. void handleUnsubscribe(id) } @@ -250,9 +235,7 @@ function main(): void { (state) => send({ op: 'delivery-health', state }) ) - // Why: if the host dies (or kills us during shutdown), exit immediately — - // process death releases every native watcher handle without running the - // crash-prone napi teardown at all. + // Process death releases watcher handles without crash-prone native teardown. process.on('disconnect', () => { process.exit(0) }) diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 3ebc06a1039..6c23440ff80 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -1,6 +1,3 @@ -// Regression tests for issue #7547: local @parcel/watcher subscriptions run -// in a forked watcher process, and a native watcher crash must be contained — -// respawn, resubscribe, notify interruption — instead of killing the app. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WATCHER_PROCESS_CANCEL_TIMEOUT_MS } from './parcel-watcher-cancellation-tracker' import { @@ -48,8 +45,7 @@ describe('subscribeViaWatcherProcess', () => { beforeEach(() => { resetWatcherProcessForTest() resetRuntimeWatcherProcessForTest() - // Why: the client deliberately runs in-process under vitest; these tests - // exercise the forked-process mode, so hide the vitest marker. + // Hide the Vitest marker to exercise forked mode instead of the in-process fallback. vi.stubEnv('VITEST', '') existsSyncMock.mockReturnValue(true) forkMock.mockImplementation(() => new FakeChild()) @@ -179,8 +175,7 @@ describe('subscribeViaWatcherProcess', () => { const child = currentChild() const id = ackSubscribe(child, 0) const subscription = await promise - // Why: hold a second subscription so this unsubscribe exercises the ack - // path instead of the last-subscriber idle kill. + // A second root exercises unsubscribe acknowledgement instead of last-subscriber idle termination. const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) ackSubscribe(child) await keepAlivePromise @@ -706,8 +701,7 @@ describe('subscribeViaWatcherProcess', () => { force: true }) - // Why: the deliberate idle kill must not count as a crash — no respawn - // and no crash-fuse advance when the killed child's exit event lands. + // Deliberate idle termination must not advance the crash fuse or trigger recovery. first.connected = false first.emit('exit', null, 'SIGTERM') await expect(unsubscribe).resolves.toBeUndefined() @@ -766,8 +760,7 @@ describe('subscribeViaWatcherProcess', () => { expect(child.kill).not.toHaveBeenCalled() expect(child.sent.at(-1)).toMatchObject({ op: 'unsubscribe' }) - // The last unsubscribe kills the idle child; both callers wait for its - // physical exit because either may gate destructive worktree cleanup. + // Both callers may gate destructive cleanup, so both must await physical exit. const secondUnsub = second.unsubscribe() expect(child.kill).toHaveBeenCalledTimes(1) child.emit('exit', 0, null) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 303c68230bc..1e2fa20f1cc 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -1,5 +1,3 @@ -// Host API for crash-isolated @parcel/watcher children. Desktop uses one -// supervisor; runtime roots use a bounded pool with independent crash fuses. import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' import type { @@ -10,8 +8,7 @@ import type { import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' const sharedWatcherProcessSupervisor = new WatcherProcessSupervisor() -// Why: healthy roots share one child; only fault quarantine scales to four, -// containing a failed shard without paying that RSS cost during normal use. +// Healthy roots share a child; quarantine adds isolation only after faults to limit RSS. const runtimeWatcherProcessPool = new RuntimeWatcherProcessPool() export function createWatcherProcessSupervisor(): WatcherProcessSupervisor { @@ -36,22 +33,17 @@ export function subscribeViaRuntimeWatcherProcess( return runtimeWatcherProcessPool.subscribe(dir, callback, opts, hooks) } -/** Kill shared desktop and runtime watcher children at app/runtime shutdown. */ export function disposeWatcherProcess(): void { - // Why: Vitest reuses this module singleton after closeAllWatchers(), while - // production shutdown must remain final and reject any later subscription. + // Vitest reuses this singleton; production shutdown must reject later subscriptions. if (process.env.VITEST) { sharedWatcherProcessSupervisor.resetForTest() runtimeWatcherProcessPool.resetForTest() } else { sharedWatcherProcessSupervisor.dispose() - // Why: the runtime pool owns independent children that otherwise outlive a - // shutdown sequence that does not immediately exit the main process. runtimeWatcherProcessPool.dispose() } } -/** Dispose both watcher hosts and wait for every child they own to exit. */ export async function disposeWatcherProcessAndWait(): Promise { const results = await Promise.allSettled([ sharedWatcherProcessSupervisor.disposeAndWait(), diff --git a/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts b/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts index 67f74faa363..ed23652194e 100644 --- a/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts +++ b/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts @@ -1,13 +1,4 @@ -/* - * macOS reports OS-canonical paths (symlinks resolved, on-disk casing), which - * land outside the root the caller subscribed with. Consumers derive a - * worktree-relative path from that root and drop anything outside it, so an - * agent's edit under a symlinked worktree never reloaded the editor tab, never - * refreshed the File Explorer, and never re-ran Source Control status. - * - * The rewrite lives in the supervisor, the one boundary every desktop, runtime, - * and relay watch passes, so assert it there for both subscription entry points. - */ +/* Canonicalized events must map back to the subscribed alias, or desktop/runtime/relay consumers drop them as outside the worktree. */ import type * as NodeFs from 'node:fs' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -34,8 +25,7 @@ const { forkMock, existsSyncMock, mkdtempSyncMock, parcelSubscribeMock, rmSyncMo vi.mock('node:child_process', () => ({ fork: forkMock })) vi.mock('node:fs', async (importOriginal) => { - // Why: the supervisor resolves the watched root with realpathSync.native, so - // keep the real implementation while stubbing the child-launch surface. + // Preserve native realpath while mocking child launch. const actual = await importOriginal() return { existsSync: existsSyncMock, @@ -61,8 +51,7 @@ describe('watcher-process root path rewrite', () => { beforeEach(() => { resetWatcherProcessForTest() resetRuntimeWatcherProcessForTest() - // Why: these assertions target the forked-process mode that ships, so hide - // the vitest marker that would route them to the in-process fallback. + // Hide the Vitest marker to exercise forked mode instead of the in-process fallback. vi.stubEnv('VITEST', '') existsSyncMock.mockReturnValue(true) forkMock.mockImplementation(() => new FakeWatcherChild()) diff --git a/src/main/ipc/parcel-watcher-shallow-file-subscription.ts b/src/main/ipc/parcel-watcher-shallow-file-subscription.ts index 36b901b5398..fb9346af220 100644 --- a/src/main/ipc/parcel-watcher-shallow-file-subscription.ts +++ b/src/main/ipc/parcel-watcher-shallow-file-subscription.ts @@ -4,16 +4,12 @@ import type { Event as ParcelWatcherEvent } from '@parcel/watcher' import type { ShallowWatcherSubscription } from './parcel-watcher-shallow-subscription' import { watchFileAcrossReplacement, type RebindingFileWatch } from './rebinding-file-watch' -// Why: bindings that missed a replacement are found by this sweep. Primary git -// metadata also has its own backstop poll, so this bounds staleness, not latency. +// Rebind missed replacements; Git metadata polling provides the shorter staleness backstop. const REBIND_SWEEP_INTERVAL_MS = 30_000 -// Why: a file created later with no sibling write (packed-refs from pack-refs or -// gc) is only found by a sweep. Re-statting just the missing paths is one ENOENT -// each, so they get the git poll cadence instead of waiting for the full sweep. +// Newly created files may have no sibling event; check only missing paths at the Git poll cadence. export const MISSING_FILE_SWEEP_INTERVAL_MS = 2_000 -/** One watch per included file, so a `.git` root never streams objects/, refs/ - * or worktrees/ churn. Used on macOS, where a directory watch is recursive. */ +/** Watch files individually on macOS to avoid recursive .git object/ref/worktree churn. */ export function startShallowFileWatcher( rootPath: string, relativePaths: readonly string[], @@ -68,8 +64,7 @@ export function startShallowFileWatcher( path, () => { emitUpdate(path) - // Git writes these files together, so one event is a cheap moment to - // look for a sibling that has just been created. + // Git writes sibling metadata together; use the event to discover newly created files. refreshAll(true) }, reportError diff --git a/src/main/ipc/parcel-watcher-shallow-subscription.ts b/src/main/ipc/parcel-watcher-shallow-subscription.ts index ade5184348a..c6cfdf03737 100644 --- a/src/main/ipc/parcel-watcher-shallow-subscription.ts +++ b/src/main/ipc/parcel-watcher-shallow-subscription.ts @@ -10,11 +10,7 @@ export type ShallowWatcherSubscription = { unsubscribe: () => Promise } -// Why: fs.watch binds to an inode, not a path, and reports nothing when that -// inode is replaced — no error, no events, permanently (verified on Linux and -// Windows). Git replaces `.git/logs` on some maintenance paths, and a repo -// re-clone replaces the common dir itself, so the binding is re-checked on a -// bounded cadence. Two stats per interval is the whole steady-state cost. +// fs.watch can silently stay bound to a replaced inode; periodically check root and nested directory identity. const REBIND_CHECK_INTERVAL_MS = 30_000 export function startShallowWatcher( @@ -23,8 +19,7 @@ export function startShallowWatcher( onEvents: (events: ParcelWatcherEvent[]) => void, onError: (error: Error) => void ): ShallowWatcherSubscription { - // Why: on macOS fs.watch(dir) is a recursive FSEvents stream, so a `.git` - // root would stream every object, ref and linked-worktree write to fseventsd. + // macOS directory watches stream recursive .git churn; watch individual metadata files instead. return process.platform === 'darwin' ? startShallowFileWatcher(rootPath, relativePaths, onEvents, onError) : startShallowDirectoryWatcher(rootPath, relativePaths, onEvents, onError) @@ -97,8 +92,7 @@ export function startShallowDirectoryWatcher( if (parent === '' && pathsByDirectory.has(name)) { const nestedNames = pathsByDirectory.get(name) if (nestedNames) { - // 'rename' is a create/delete of the nested dir itself, so the - // existing binding (if any) is stale and must be replaced. + // Rename may replace the directory inode; discard the old binding. watchDirectory(name, nestedNames, eventType === 'rename') emitUpdates(name, nestedNames) } @@ -153,8 +147,7 @@ export function startShallowDirectoryWatcher( if (bound === identity) { return } - // Either the directory appeared after we started, or it was replaced while - // watched. Both leave the old binding deaf, so rebind and resync. + // Appeared or replaced directories need a fresh binding and resync. if (!watchDirectory(parent, fileNames, true)) { return } @@ -173,10 +166,7 @@ export function startShallowDirectoryWatcher( rebindTimer.unref?.() for (const [parent, fileNames] of pathsByDirectory) { - // Why: read identity BEFORE binding. If the directory is replaced in the gap, - // the recorded identity is stale and the first sweep rebinds — the harmless - // direction. Reading after would pin the dead inode's watcher to the new - // identity, and the sweep would then never rebind it. + // Read identity before binding: a racing replacement then causes a safe rebind instead of pinning a dead watch to the new inode. const identityBeforeBind = directoryIdentitySync(parent) watchDirectory(parent, fileNames) if (watchers.has(parent) && identityBeforeBind !== null) { diff --git a/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts b/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts index bda018da6b0..3f4ea6292ed 100644 --- a/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts +++ b/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts @@ -33,8 +33,7 @@ export class WatcherSupervisorCapacityWait { if (lifecycleGeneration !== this.lifecycleGeneration) { throw watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') } - // Why: subscribe() wraps each retry in a fresh capacity wait, so a slot - // reclaimed by crash recovery remains pending without polling or failure. + // subscribe() owns a fresh capacity wait for each retry, including slots reclaimed by crash recovery. return retry() }) } @@ -49,8 +48,7 @@ export class WatcherSupervisorCapacityWait { createAbortError: createCapacityAbortError, onAbandon: () => { if (state.waiters.waiterCount === 0 && this.state === state) { - // Why: one cancelled root must not strand a shared listener after all - // same-supervisor capacity waiters have left. + // Release the shared listener when the last same-supervisor waiter cancels. this.state = null state.cancel() state.reject(createCapacityAbortError()) @@ -90,8 +88,7 @@ export class WatcherSupervisorCapacityWait { cancel: () => undefined } this.state = state - // Why: one physical slot launches the shared supervisor child; every root - // awaiting this barrier can then subscribe through that same child. + // One physical slot launches the shared child for all roots awaiting this barrier. state.cancel = onWatcherChildCapacityAvailable(() => { if (this.state === state) { this.state = null diff --git a/src/main/ipc/parcel-watcher-supervisor-message.ts b/src/main/ipc/parcel-watcher-supervisor-message.ts index f39fc20985e..25ff7c92535 100644 --- a/src/main/ipc/parcel-watcher-supervisor-message.ts +++ b/src/main/ipc/parcel-watcher-supervisor-message.ts @@ -50,14 +50,12 @@ export function handleWatcherSupervisorMessage( if (message.op === 'watch-error' && record) { const error = new WatcherProcessFailure(message.message, 'subscription', 'subscribe_failed') if (record.pendingSubscribe) { - // Why: an error before readiness means native setup failed. Cancel the - // physical crawl so a later subscribed ack cannot expose a dead root. + // Setup failed before readiness; cancel the crawl so a late ack cannot expose a dead root. context.cancelPendingSubscribe(record, error) return } if (record.interrupted && record.crawlStarted) { - // Why: crash recovery has no pending caller promise, but its replacement - // crawl is equally unready and must not accept a later subscribed ack. + // Recovery has no pending caller, but its unready replacement must also reject late acks. context.cancelInterruptedSubscribe(record, error) return } diff --git a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts index 84db3eabb39..dcc42e4b3a7 100644 --- a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts +++ b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts @@ -97,15 +97,11 @@ export function subscribeThroughWatcherSupervisor({ ) ) } - // Why: a symlinked or differently-cased root is unwatchable on Linux - // (IN_ONLYDIR) and misreported on macOS (FSEvents canonicalizes). Watch the - // resolved directory, then restore the caller's spelling on the way out -- - // this is the one boundary every desktop, runtime, and relay watch passes. + // Watch the resolved directory, then restore caller spelling for desktop/runtime/relay paths; native backends reject or canonicalize aliases. const { watchRoot, rewriteEventPath } = resolveWatcherRootPaths(dir) const callback: WatcherProcessCallback = (error, events) => rawCallback(error, rewriteWatcherEvents(events, rewriteEventPath)) - // Why: under Vitest we cannot fork a real watcher child, so exercise the - // subscription path in-process (against mocked @parcel/watcher) instead. + // Vitest mocks Parcel in-process instead of forking a watcher child. if (process.env.VITEST && useInProcessVitestFallback) { return subscribeWithInProcessWatcher(watchRoot, callback, opts, hooks) } diff --git a/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts b/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts index 2aad68d54b7..f49bc5717a9 100644 --- a/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts +++ b/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts @@ -95,15 +95,14 @@ describe('watcher native unsubscribe timeout', () => { expect(first.kill).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) expect(first.kill).toHaveBeenLastCalledWith('SIGKILL') - // Why: removal callers still fail closed on the unproven exit. + // Destructive removal must fail closed when physical exit is unproved. const error = (await unsubscribe) as Error & { physicalExit?: Promise } expect(error).toMatchObject({ message: 'file watcher process did not exit after termination deadline', physicalExit: expect.any(Promise) }) - // Why: the idle child owned zero records, so its zombie must not poison - // the shared supervisor — the next subscribe gets a fresh child. + // The idle child owned no records; its zombie must not prevent a fresh supervisor child. const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) expect(forkMock).toHaveBeenCalledTimes(2) const second = currentChild() diff --git a/src/main/ipc/pet-bundle-import.ts b/src/main/ipc/pet-bundle-import.ts index a9e897298b1..deaf3de3491 100644 --- a/src/main/ipc/pet-bundle-import.ts +++ b/src/main/ipc/pet-bundle-import.ts @@ -16,7 +16,7 @@ export async function importPetBundle( ): Promise { const senderWindow = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() - // Why: the bundle is a folder, but Finder may let users pick `pet.json` inside it — post-pick logic walks up to the parent. + // Finder may select pet.json inside the bundle; accept it and use its parent. const options: Electron.OpenDialogOptions = { title: 'Pick a .codex-pet bundle', properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory'] @@ -53,7 +53,7 @@ export async function importPetBundle( let manifest: ResolvedPetManifest try { const raw = await readFile(manifestPath, 'utf8') - // Why: defend against TOCTOU — the file may have grown between the stat check and this read. + // Recheck the read size because the file may have grown after stat. if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) { throw new Error('pet.json exceeded the manifest size limit.') } @@ -66,14 +66,14 @@ export async function importPetBundle( const sprite = await buildBundleSprite(manifest, sheetSrc) - // Why: always a fresh UUID (not the manifest's display-hint id) to avoid collisions, unsafe ids, and re-import clobbering. + // Ignore manifest IDs to prevent collisions, unsafe paths and re-import overwrites. const id = randomUUID() const root = getPetsDir() await mkdir(root, { recursive: true }) const destDir = join(root, id) const sheetExt = sheetClass.ext const sheetFileName = `spritesheet${sheetExt}` - // Why: stage into a sibling .tmp then atomically rename, so a mid-copy failure can't leave a half-imported bundle. + // Stage beside the destination so failed copies cannot publish a partial bundle. const tmpDir = `${destDir}.tmp` try { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) @@ -95,7 +95,6 @@ export async function importPetBundle( mimeType: sheetClass.mimeType, kind: 'bundle', sprite, - // Why: renderer falls back to spriteFps when sprite is undefined (detected-frame bundles). ...(manifest.fps !== undefined ? { spriteFps: manifest.fps } : {}) } } diff --git a/src/main/ipc/pet-bundle-manifest-schema.ts b/src/main/ipc/pet-bundle-manifest-schema.ts index 28d638a1916..b0905e62e7e 100644 --- a/src/main/ipc/pet-bundle-manifest-schema.ts +++ b/src/main/ipc/pet-bundle-manifest-schema.ts @@ -10,7 +10,7 @@ export const PetManifestSchema = z .string() .min(1) .max(255) - // Why: belt-and-suspenders vs malicious manifests — downstream resolve+prefix check still runs as defense in depth. + // Reject unsafe manifest paths before the later resolved-path containment check. .refine( (p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'), 'invalid spritesheetPath' @@ -30,13 +30,13 @@ export const PetManifestSchema = z z.object({ row: z.number().int().min(0).max(256), frames: z.number().int().positive().max(512), - // Why: cap each hold at 60s so a bad manifest can't freeze the overlay. + // Bound frame holds so malformed manifests cannot freeze the overlay. frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional() }) ) .optional() }) - // Why: .loose() ignores unknown manifest fields — generators emit metadata we don't consume; strict would reject imports. + // Accept generator metadata beyond the fields Orca consumes. .loose() export type PetManifest = z.infer & PetManifestLike diff --git a/src/main/ipc/pet-bundle-sprite-metadata.ts b/src/main/ipc/pet-bundle-sprite-metadata.ts index 310f21e91bb..09d70858a5c 100644 --- a/src/main/ipc/pet-bundle-sprite-metadata.ts +++ b/src/main/ipc/pet-bundle-sprite-metadata.ts @@ -8,13 +8,12 @@ import type { PetManifestLike, ResolvedPetManifest } from './pet-bundle' async function readSheetDimensions( buffer: Buffer ): Promise<{ width: number; height: number } | null> { - // Why: nativeImage can fail on some valid WebP that Chromium renders — read WebP dims from the header before native decode. + // Chromium renders some WebP variants nativeImage cannot decode; prefer header dimensions. const webpDims = readWebpDimensionsFromBuffer(buffer) if (webpDims) { return webpDims } - // Why: nativeImage can't decode SVG (vector → no pixel grid) — pet bundles must use a raster sheet. const image = nativeImage.createFromBuffer(buffer) if (image.isEmpty()) { return null @@ -26,16 +25,16 @@ async function readSheetDimensions( return { width: size.width, height: size.height } } -/** Undefined when the manifest declares no frame layout — the renderer then falls back to spriteFps. */ +/** Without a frame layout, the renderer uses spriteFps. */ export async function buildBundleSprite( manifest: ResolvedPetManifest, sheetSrc: string ): Promise | undefined> { let sprite: NonNullable | undefined if (manifest.frame) { - // Why: only decode when a frame layout needs validating — nativeImage can fail on some WebP variants in headless contexts. + // Decode only for layout validation; nativeImage may reject otherwise renderable WebP. const sheetBuf = await readFile(sheetSrc) - // Why: defend against TOCTOU — file may have grown between stat and read. + // Recheck the read size because the file may have grown after stat. if (sheetBuf.byteLength > MAX_BYTES) { throw new Error('Spritesheet exceeded the size limit.') } diff --git a/src/main/ipc/pet-bundle-spritesheet-source.ts b/src/main/ipc/pet-bundle-spritesheet-source.ts index b7d6213e2b8..5885d1a5d92 100644 --- a/src/main/ipc/pet-bundle-spritesheet-source.ts +++ b/src/main/ipc/pet-bundle-spritesheet-source.ts @@ -5,12 +5,11 @@ import { MAX_BYTES } from './pet-import-size-limits' import { isSymlink } from './pet-symlink-safe-copy' import type { PetManifestLike, ResolvedPetManifest } from './pet-bundle' -/** Validated spritesheet source inside a bundle: an absolute path plus its allowlisted format. */ export async function resolveBundleSpritesheetSource( manifest: ResolvedPetManifest, bundleDir: string ): Promise<{ sheetSrc: string; sheetClass: { mimeType: string; ext: string } }> { - // Why: spritesheetPath is bundle-relative and attacker-controlled — reject absolute/escaping paths (and symlinks) so a bundle can't reach outside. + // Manifest paths are untrusted; require a relative path before the containment and symlink checks. const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\/]+/g, sep) if ( isAbsolute(manifest.spritesheetPath) || @@ -19,14 +18,14 @@ export async function resolveBundleSpritesheetSource( ) { throw new Error('spritesheetPath must be relative to the bundle.') } - // Why: bundles exported on Windows may be imported on macOS/Linux; normalize separators before resolving. + // Accept Windows-exported bundles on macOS/Linux. const sheetSrc = resolve(bundleDir, normalizedSpritePath) const bundleResolved = resolve(bundleDir) if (sheetSrc === bundleResolved) { throw new Error('spritesheetPath must point to a file, not the bundle root.') } const bundleRoot = bundleResolved + sep - // Why: Windows volumes are case-insensitive; lowercase the prefix compare so case differences can't bypass the escape check. + // Match paths case-insensitively on Windows. const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) { throw new Error('spritesheetPath escapes the bundle.') @@ -36,7 +35,7 @@ export async function resolveBundleSpritesheetSource( } const sheetClass = classifyFile(sheetSrc) if (!sheetClass || sheetClass.ext === '.svg') { - // SVG can't be used as a sprite sheet (no pixel grid). + // Sprite layouts require raster dimensions. throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.') } let sheetStat: Awaited> diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index 096dd37e853..ad30462db0b 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -65,13 +65,10 @@ describe('applyCodexPetDefaults', () => { }) it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => { - // The requested fps is baked as durations so it is honored rather than - // overridden by the timed table, and so the sprite never enters the - // no-durations legacy retiming path. + // Baked durations honor explicit fps and avoid legacy retiming. const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 }) expect(manifest.fps).toBe(4) - // 4 fps → 250ms per frame; idle keeps its six frames. expect(manifest.animations?.idle).toEqual({ row: 0, frames: 6, @@ -80,8 +77,7 @@ describe('applyCodexPetDefaults', () => { }) it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => { - // fps=8 matches the Codex default and the legacy geometry, but baking - // durations makes it a non-match so the uniform pacing is preserved. + // Even the default fps needs baked durations to preserve uniform pacing instead of legacy retiming. const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 }) const sprite = { frameWidth: 192, diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 6a07647cfae..606003f9e3a 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -8,8 +8,7 @@ import { codexAnimationsAtUniformFps } from '../../shared/codex-pet-sprite-defaults' -// Re-exported so main-side consumers keep their import path. The tables live -// in shared/ so the renderer can reuse the same fingerprint for upgrades. +// Preserve main import paths while sharing upgrade fingerprints with the renderer. export { CODEX_PET_ANIMATIONS, CODEX_PET_DEFAULT_ANIMATION, @@ -62,9 +61,7 @@ export function applyCodexPetDefaults( frame: manifest.frame ?? CODEX_PET_FRAME, fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS, defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION, - // Why: with no fps, bake Codex's intended uneven pacing; with an explicit - // fps, bake that as uniform durations so it is honored instead of being - // overridden by the timed table (and stays out of the legacy retiming path). + // Explicit fps overrides Codex timing with uniform durations and avoids legacy retiming. animations: manifest.animations ?? (manifest.fps === undefined diff --git a/src/main/ipc/pet-image-formats.ts b/src/main/ipc/pet-image-formats.ts index cf404dd2c3e..63d86268f5e 100644 --- a/src/main/ipc/pet-image-formats.ts +++ b/src/main/ipc/pet-image-formats.ts @@ -1,6 +1,5 @@ import { extname } from 'node:path' -// Why: pets are image-only — render natively via (no 3D engine); main owns this format allowlist. const IMAGE_FORMATS: Record = { '.png': 'image/png', '.apng': 'image/apng', diff --git a/src/main/ipc/pet-import-size-limits.ts b/src/main/ipc/pet-import-size-limits.ts index c37c967fac1..8c1453b49b8 100644 --- a/src/main/ipc/pet-import-size-limits.ts +++ b/src/main/ipc/pet-import-size-limits.ts @@ -1,2 +1,2 @@ -export const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. -export const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest. +export const MAX_BYTES = 64 * 1024 * 1024 // Bound image bytes before the renderer builds a Blob URL. +export const MAX_MANIFEST_BYTES = 64 * 1024 diff --git a/src/main/ipc/pet-storage-paths.ts b/src/main/ipc/pet-storage-paths.ts index 6a0dbd03794..09ae32d99df 100644 --- a/src/main/ipc/pet-storage-paths.ts +++ b/src/main/ipc/pet-storage-paths.ts @@ -1,13 +1,13 @@ import { app } from 'electron' import { basename, join, normalize, sep } from 'node:path' -// Why: keep the legacy `sidekicks` folder so existing user-uploaded pets keep rendering after the product rename. +// Preserve uploaded pets across the sidekicks-to-pets rename. export function getPetsDir(): string { return join(app.getPath('userData'), 'sidekicks', 'custom') } export function isSafeId(id: string): boolean { - // UUIDs only — canonical path-traversal gate; storage ids are always main-generated, never from manifest.id. + // Storage IDs are main-generated UUIDs, never manifest IDs. return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) } @@ -23,7 +23,7 @@ export function resolvePetFile( const root = normalize(getPetsDir()) let filePath: string if (kind === 'bundle') { - // Bundle layout custom//; fileName is the spritesheet basename — pet.json is main-only, never served. + // Serve only the spritesheet from bundle storage; pet.json remains main-only. filePath = normalize(join(root, id, safeName)) const bundleDir = normalize(join(root, id)) + sep if (!filePath.startsWith(bundleDir)) { @@ -31,7 +31,7 @@ export function resolvePetFile( } return filePath } - // Legacy image layout custom/.; filename must start with the id so the prefix check backstops the regex. + // Legacy images use custom/.; the prefix check reinforces filename validation. if (!safeName.startsWith(`${id}.`)) { return null } diff --git a/src/main/ipc/pet-symlink-safe-copy.ts b/src/main/ipc/pet-symlink-safe-copy.ts index 3c1ce663efa..a42fa3c37d4 100644 --- a/src/main/ipc/pet-symlink-safe-copy.ts +++ b/src/main/ipc/pet-symlink-safe-copy.ts @@ -2,7 +2,7 @@ import { copyFile, lstat, open } from 'node:fs/promises' import { constants as fsConstants, createWriteStream } from 'node:fs' import { pipeline } from 'node:stream/promises' -// Why: TOCTOU symlink-swap defense — O_NOFOLLOW makes open() fail on a symlink; Windows lacks it, so fall back to copyFile. +// O_NOFOLLOW rejects source symlink swaps where available; otherwise copyFile provides no such protection. export async function copyFileNoFollow(src: string, dest: string): Promise { const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0 if (noFollow === 0) { diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 3521482ef41..9c7c4d479fb 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -9,7 +9,7 @@ import { classifyFile } from './pet-image-formats' import { MAX_BYTES } from './pet-import-size-limits' import { getPetsDir, isSafeId, resolvePetFile } from './pet-storage-paths' -// Why: renderer IPC inputs are untrusted — validate shape here; resolvePetFile still gates the actual filesystem path. +// Validate untrusted IPC shape before resolvePetFile applies path constraints. const PetFileRequestSchema = z.object({ id: z.string(), fileName: z.string(), @@ -23,7 +23,7 @@ export function registerPetHandlers(): void { const options: Electron.OpenDialogOptions = { title: 'Pick pet', properties: ['openFile'], - // Why: omit `apng` — macOS maps dialog extensions to UTIs, and apng's missing UTI can drop siblings like webp (APNG uses .png anyway). + // APNG uses .png; its missing macOS UTI can hide other formats when listed separately. filters: [ { name: 'Pet image', @@ -60,7 +60,7 @@ export function registerPetHandlers(): void { const dir = getPetsDir() await mkdir(dir, { recursive: true }) const id = randomUUID() - // Why: keep the original extension in the on-disk name so pet:read can rebuild the Blob MIME without a separate lookup. + // Keep the extension so pet:read can recover MIME type without another lookup. const fileName = `${id}${classified.ext}` const dest = join(dir, fileName) try { @@ -93,14 +93,13 @@ export function registerPetHandlers(): void { fileName: string, kind?: 'image' | 'bundle' ): Promise => { - // Why: renderer inputs are untrusted; validate shape before any path logic. let parsed: z.infer try { parsed = PetFileRequestSchema.parse({ id, fileName, kind }) } catch { throw new Error('Invalid pet:read arguments') } - // Why: default 'image' for backwards compat with pre-bundle persisted state. + // Pre-bundle persisted pets omit kind. const filePath = resolvePetFile(parsed.id, parsed.fileName, parsed.kind ?? 'image') if (!filePath) { return null @@ -118,7 +117,6 @@ export function registerPetHandlers(): void { ipcMain.handle( 'pet:delete', async (_event, id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => { - // Why: validate IPC inputs before any path logic. let parsed: z.infer try { parsed = PetFileRequestSchema.parse({ id, fileName, kind }) @@ -129,7 +127,7 @@ export function registerPetHandlers(): void { return } if ((parsed.kind ?? 'image') === 'bundle') { - // Why: defense in depth — verify path stays under pets root before recursive removal. + // Recheck containment before recursive removal. const root = normalize(getPetsDir()) const target = normalize(join(root, parsed.id)) if (!target.startsWith(root + sep)) { diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 95ab73556df..166f9ed46a2 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -67,8 +67,7 @@ const removeArgsSchema = z.object({ }) const logsArgsSchema = z.object({ pluginKey: z.string().min(1) }) -// Why re-exported: moved to ../plugins/plugin-client-list so the runtime RPC can reach -// it without ipcMain. Existing importers of this path keep working. +// Preserve import compatibility; runtime RPC uses the Electron-free client-list module. import { listPluginsForClients } from '../plugins/plugin-client-list' export function canRemoveInstalledPlugin( @@ -92,24 +91,21 @@ export function registerPluginHandlers( runtime: OrcaRuntimeService | null, marketplaceServices?: PluginMarketplaceHandlerServices ): void { - // The runtime IS the delegate: the structural PluginRuntimeDelegate type - // keeps the facade electron-free while main binds the real service. + // A structural delegate binds the real runtime without importing Electron into the facade. if (runtime) { pluginService.setRuntimeDelegate(runtime) } store.onSettingsChanged((updates) => { if ('pluginSystemEnabled' in updates || 'devPluginPaths' in updates) { - // Main owns plugin lifecycle. Renderer follow-up refreshes are UX only; - // a crashed or remote caller must not leave old workers authoritative. + // Main owns lifecycle refresh even when a caller crashes before its follow-up refresh. void pluginService.refresh().catch((error) => { console.warn('[plugins] failed to apply plugin settings change:', error) }) } }) - // Why: startup discovery is fire-and-forget; every handler awaits it so an - // early renderer fetch can't observe the empty pre-discovery list. + // All handlers must await startup discovery to avoid returning a pre-discovery empty list. ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService)) ipcMain.handle('plugins:listLanguagePacks', async () => { await pluginService.whenReady() @@ -142,8 +138,7 @@ export function registerPluginHandlers( return listPluginsForClients(pluginService) }) - // Why: the renderer renders panel HTML via a sandboxed iframe srcdoc, so it - // needs (CSP-wrapped) file contents — never a file:// path — across IPC. + // Sandboxed iframe srcdoc needs CSP-wrapped HTML contents across IPC. ipcMain.handle( 'plugins:readPanelEntry', async (event, args: unknown): Promise => { @@ -162,8 +157,7 @@ export function registerPluginHandlers( } ) - // Panel-originated actions relayed by the renderer's postMessage bridge - // host. Capability enforcement happens in main, never in the renderer. + // Renderer forwards panel actions; main enforces capabilities. ipcMain.handle( 'plugins:panelAction', async (event, args: unknown): Promise => { @@ -221,7 +215,6 @@ export function registerPluginHandlers( pluginKey: parsed.pluginKey }) ) - // Drop the stale consent so a later reinstall re-prompts from scratch. const settings = store.getSettings() const consents = { ...settings.pluginConsents } delete consents[parsed.pluginKey] @@ -241,8 +234,6 @@ export function registerPluginHandlers( return pluginService.getLogs(parsed.pluginKey) }) - // Re-discover after settings edits (feature flag, dev paths) — the - // renderer calls this right after updating those settings. ipcMain.handle('plugins:refresh', async () => { await pluginService.refresh() return listPluginsForClients(pluginService) diff --git a/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts b/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts index 7507eff6d87..fe716ce3afa 100644 --- a/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts +++ b/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts @@ -1,9 +1,4 @@ -/* Regression pin for #9297: local agent detection must resolve executables - * against PATH with fs and spawn ZERO where/which subprocesses. On unfixed - * code this fails because detectInstalledAgents spawns one `where`/`which` - * process per probe command (>=20). See the read-only repro in comment-scan: - * repro-9297-where-per-agent-probe.test.ts (which pins the OLD, buggy count). - */ +/* PATH detection must use filesystem lookup: per-agent where/which spawns can each be stalled by privilege-management software (#9297). */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -76,14 +71,12 @@ describe('#9297: local agent detection spawns zero where/which subprocesses', () beforeEach(() => { execFileAsyncMock.mockReset() execFileMock.mockReset() - // Any spawn attempt is the bug; make it explode so a regression is loud. execFileAsyncMock.mockImplementation(async (command: string) => { throw new Error(`unexpected subprocess spawn: ${command}`) }) detectCommandsInInstallDirsMock.mockReset() detectCommandsInInstallDirsMock.mockReturnValue(new Set()) - // Empty PATH -> deterministic "no agents found" regardless of the host's - // real installed CLIs, so the assertion is stable on any dev machine. + // Empty PATH makes results independent of installed host CLIs. process.env.PATH = '' _resetPreflightCache() }) @@ -101,16 +94,11 @@ describe('#9297: local agent detection spawns zero where/which subprocesses', () KNOWN_TUI_AGENT_DETECTION_COMMANDS, platform ) - // Guardrail: the candidate list is large, so the old one-spawn-per-probe - // path multiplied a gated where.exe across dozens of startups. expect(probeCommands.length).toBeGreaterThanOrEqual(20) const agents = await detectInstalledAgents() - // Perf-win lock: before the fix this was probeCommands.length (>=20); - // after the fix it is exactly 0. expect(execFileAsyncMock).toHaveBeenCalledTimes(0) - // Behavior preserved: empty PATH resolves nothing. expect(agents).toEqual([]) }) } diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index fc6370fefaa..1a54a853031 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -32,8 +32,7 @@ const { })) const runWslProcessMock = vi.hoisted(() => vi.fn()) -// Why the runner and not child_process: WSL agent detection goes through -// runWslProcess now, so a child_process mock never sees it. +// WSL detection uses runWslProcess, so child_process mocks cannot observe it. vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) vi.mock('electron', () => ({ @@ -61,9 +60,7 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommands: resolveCliCommandsMock })) -// Why (#9297): local PATH resolution is now fs-based (no where/which spawn). -// These tests express "which commands are on PATH" via the where/which mock, -// so route the resolver through that same mock to preserve their intent. +// Route filesystem PATH resolution through the existing command-lookup mock. vi.mock('./command-path-resolver', async (importOriginal) => ({ ...(await importOriginal()), isCommandOnLocalPath: isCommandOnLocalPathMock, @@ -249,8 +246,7 @@ describe('preflight', () => { }) it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => { - // Why: cold GUI launches can run detection before shell-PATH hydration - // adds user install dirs, so `which` can miss runnable CLIs. + // Cold GUI detection can precede shell-PATH hydration; install directories remain a fallback. execFileAsyncMock.mockImplementation(async (command) => { if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) @@ -280,7 +276,6 @@ describe('preflight', () => { }) it('does not double-count an agent already found on PATH via the install-dir resolver', async () => { - // Why: the fallback should not duplicate ids when PATH already finds a CLI. execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) @@ -306,7 +301,6 @@ describe('preflight', () => { }) it('treats an agent as not installed when the install-dir resolver throws', async () => { - // Why: transient fs errors in the fallback must not crash detection. execFileAsyncMock.mockImplementation(async (command) => { if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) @@ -494,11 +488,9 @@ describe('preflight', () => { await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) expect(runWslProcessMock).toHaveBeenCalledTimes(1) - // Why: the local fallback must not report host binaries as WSL binaries. + // Local install-directory fallback must never supply WSL results. expect(resolveCliCommandsMock).not.toHaveBeenCalled() - // Why assert the lane, not the argv: argv is the runner's contract and is - // pinned by its own tests. What this suite owns is that detection asks the - // right distro on the lane that carries the user's PATH. + // The runner owns argv; this suite checks distro and PATH lane. expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', loginPath: 'preferred' }) ) @@ -524,9 +516,7 @@ describe('preflight', () => { await expect(detectInstalledAgents({ wslDefault: true })).resolves.toEqual(['codex']) expect(runWslProcessMock).toHaveBeenCalledTimes(1) - // Why: the local fallback must not leak into WSL detection. expect(resolveCliCommandsMock).not.toHaveBeenCalled() - // No distro named: the runner resolves the default. expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: undefined, loginPath: 'preferred' }) ) diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index 8e8eae11489..fd44c133772 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -33,8 +33,7 @@ const { })) const runWslProcessMock = vi.hoisted(() => vi.fn()) -// Why the runner and not child_process: WSL agent detection goes through -// runWslProcess now, so a child_process mock never sees it. +// WSL detection uses runWslProcess, so child_process mocks cannot observe it. vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) vi.mock('electron', () => ({ @@ -64,9 +63,7 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommands: resolveCliCommandsMock })) -// Why (#9297): local PATH resolution is now fs-based (no where/which spawn). -// These tests express "which commands are on PATH" via the where/which mock, -// so route the resolver through that same mock to preserve their intent. +// Route filesystem PATH resolution through the existing command-lookup mock. vi.mock('./command-path-resolver', async (importOriginal) => ({ ...(await importOriginal()), isCommandOnLocalPath: isCommandOnLocalPathMock, @@ -180,9 +177,7 @@ describe('preflight', () => { }) it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { - // Why: the Agents settings Refresh button calls this path. It must (1) ask - // the shell hydrator for a fresh PATH, (2) merge any new segments, then - // (3) re-run `which` so newly-installed CLIs appear without a restart. + // Refresh must hydrate and merge PATH before detecting newly installed CLIs. hydrateShellPathMock.mockResolvedValueOnce({ segments: ['/Users/test/.opencode/bin'], ok: true, @@ -260,13 +255,9 @@ describe('preflight', () => { expect(result.shellHydrationOk).toBe(false) expect(result.addedPathSegments).toEqual([]) expect(result.agents).toEqual(['claude']) - // Why: drives the agent_picks `on_path:false` triage in dashboard 1562016. - // Without these fields we cannot distinguish "hydration failed" from - // "user genuinely doesn't have the binary." + // Diagnostics must distinguish failed hydration from an absent binary. expect(result.pathSource).toBe('sync_seed_only') expect(result.pathFailureReason).toBe('timeout') - // Why: when hydration fails, we must not call merge — nothing to merge — - // otherwise we'd log a no-op "added 0 segments" event on every refresh. expect(mergePathSegmentsMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts index 7466e9cfc6d..970538974fa 100644 --- a/src/main/ipc/preflight-command-exec.test.ts +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -30,8 +30,7 @@ vi.mock('./command-path-resolver', async (importOriginal) => ({ listLocalCommandPaths: listLocalCommandPathsMock })) -// Why: `findRunnableLocalCommand` decides what to spawn next from the error -// `execFile` puts on a failed probe, so the shapes below have to be its own. +// Probe recovery branches on promisified execFile errors; retain their real shape. vi.mock('child_process', () => { const execFileWithPromisify = Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock @@ -344,7 +343,7 @@ describe('findRunnableLocalCommand', () => { }) it('gives an explicit binary the full timeout without trying another copy', async () => { - // A frozen clock: the budget is deadline - Date.now(), so a ticking clock reads 4999. + // Freeze the clock so remaining deadline budget is deterministic. vi.spyOn(Date, 'now').mockReturnValue(1000) execFileAsyncMock.mockRejectedValue( Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' }) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index d97b3bc49c0..197556b539c 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -49,10 +49,7 @@ async function withPreflightTimeout( } } -/** Rejects on non-zero exit, spawn failure, or timeout — it never reports - * "absent" as a value. A caller that collapses that rejection into `false` - * makes "not installed" and "could not run it" the same answer; see - * docs/reference/wsl-probe-failure-semantics.md before doing so. */ +/** Reject execution failures; callers must distinguish them from absence (docs/reference/wsl-probe-failure-semantics.md). */ export async function execLocalPreflightCommandOrThrow( command: string, args: string[], @@ -86,8 +83,7 @@ export async function execLocalPreflightCommandOrThrow( const commandPromise = execFileAsync(command, args, { encoding: 'utf-8', timeout: timeoutMs, - // Preflight probes console-subsystem binaries (git, gh, node); without this - // each one flashes a console and steals foreground on Windows (#10488). + // Hide Windows console probes so startup cannot steal focus (#10488). windowsHide: true, ...(env ? { env } : {}) }) @@ -95,17 +91,13 @@ export async function execLocalPreflightCommandOrThrow( return withPreflightTimeout(command, commandPromise, timeoutMs) } -// Throws on any failure — a distro that is booting/unreachable throws the -// same way a command that genuinely doesn't exist does. Callers must not -// collapse both into "absent"; see docs/reference/wsl-probe-failure-semantics.md. +// Unreachable distros and absent commands both reject; see docs/reference/wsl-probe-failure-semantics.md. export async function execCommandInWslOrThrow( target: WslPreflightTarget, command: string ): Promise { const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) - // Label only (runPreflightCommandInWsl owns the actual wsl.exe invocation) — - // not the literal 'wsl.exe' so the wsl-invocation-boundary guard doesn't - // mistake this string for a spawn site. + // Diagnostic label only; runPreflightCommandInWsl owns process invocation. return withPreflightTimeout('wsl command', commandPromise) } @@ -235,21 +227,15 @@ export async function isCommandOnPath( wslTarget?: WslPreflightTarget ): Promise { if (!wslTarget) { - // Why (#9297): resolve against PATH with fs instead of spawning one - // where/which subprocess per probe — privilege-management software gates - // each spawn and stalls startup. buildLocalPreflightEnv() supplies the same - // registry-merged PATH the child process previously saw (undefined = posix - // process.env), so the found/not-found result is identical. + // Filesystem PATH lookup avoids privilege-gated per-agent where/which spawns (#9297). return isCommandOnLocalPath(command, { env: buildLocalPreflightEnv() }) } try { - // Why: preflight must validate the executable on PATH, not a shell alias or function. + // Require an executable, excluding shell aliases and functions. const { stdout } = await execCommandInWslOrThrow( wslTarget, [ - // Same skip as agent detection: without it this branch answers "yes" - // for a Windows binary reached through interop, so preflight and the - // detector disagree about the same distro. + // Match agent detection by skipping Windows interop binaries during the PATH walk. buildPosixCommandPathLookupScript( { kind: 'literal', value: command }, { skipWindowsMountDirs: true } diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index 3fdeed0d9a4..7376d5dda24 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -63,7 +63,6 @@ vi.mock('@orca/process-host', () => ({ }) })) -// WSL commands now route through the runner, not execFile('wsl.exe', ...). vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) vi.mock('../startup/hydrate-shell-path', () => ({ @@ -75,9 +74,7 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommands: resolveCliCommandsMock })) -// Why (#9297): local PATH resolution is now fs-based (no where/which spawn). -// These tests express "which commands are on PATH" via the where/which mock, -// so route the resolver through that same mock to preserve their intent. +// Route filesystem PATH resolution through the existing command-lookup mock. vi.mock('./command-path-resolver', async (importOriginal) => ({ ...(await importOriginal()), isCommandOnLocalPath: isCommandOnLocalPathMock, @@ -144,9 +141,7 @@ describe('preflight', () => { }) }) - // Why: every preflight run probes (in order) `git --version`, `gh --version`, - // `glab --version`, then in parallel `gh auth status` + `glab auth status` — - // five execFile calls per cycle. Tests below provide values for all five. + // Sequential version probes precede concurrent gh/glab auth checks; fixtures supply all five responses. it('marks gh as authenticated when gh auth status exits successfully', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) @@ -215,10 +210,7 @@ describe('preflight', () => { expect(status.gh).toEqual({ installed: true, authenticated: true }) }) - // Why (#22975): these two cases key the spawn mock on (command, args) instead - // of call order, because the fallback probes exactly which copy runs is the - // behaviour under test — and `gh auth status` runs in parallel with - // `glab auth status`, so a fixed sequence would not be the real one. + // Match command/args because fallback version probes and concurrent auth checks vary call order. it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => { const shim = '/Users/octocat/.asdf/shims/gh' const working = '/opt/homebrew/bin/gh' @@ -307,8 +299,7 @@ describe('preflight', () => { const status = await runPreflightCheck() expect(status.glab).toEqual({ installed: false, authenticated: false }) - // Why: with glab uninstalled, glab auth status must not run — that - // would surface a misleading "command not found" error in logs. + // An absent glab must not generate a misleading auth command failure. expect(execFileAsyncMock).toHaveBeenCalledTimes(4) }) @@ -425,8 +416,7 @@ describe('preflight', () => { }) describe('WSL preflight caching', () => { - // Why win32 + these stubs: a WSL target probes git/gh/glab through the - // runner, so every case below counts runner spawns rather than execFile. + // WSL probes use the runner, so count runner calls rather than local execFile calls. function stubWslProbes(): void { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) execFileAsyncMock.mockImplementation(async () => { @@ -489,21 +479,13 @@ describe('preflight', () => { await runPreflightCheck(true, { wslDistro: 'Ubuntu' }) expect(first).toEqual(second) - // One shared run, not two: a second full set would double the spawn count. expect(singleRunSpawns).toBe(runWslProcessMock.mock.calls.length) }) - // Why this matters: an unreachable distro reports the same `installed: - // false` as a distro with no tooling, so a session-lifetime cache would pin - // "git not installed" until relaunch. The TTL must let it recover. - // Why these two: a slower run settling last must not overwrite a newer - // answer, or a forced refresh silently returns to the status it replaced. + // A superseded probe must not overwrite the forced refresh even when it settles last. it('does not let a superseded probe overwrite a newer forced refresh', async () => { stubWslProbes() - // Why gate on phase captured at call time: the stale run's three - // --version probes all start before the refresh, and all report nothing - // installed, so it never issues auth calls. The two runs are then - // trivially distinguishable in the cached result. + // Capture phase at issue time so the held stale probes remain distinguishable from refreshed probes. let phase: 'stale' | 'fresh' = 'stale' let releaseStale!: () => void const staleGate = new Promise((resolve) => { @@ -536,8 +518,6 @@ describe('preflight', () => { const staleResult = await stalePending expect(staleResult.git).toEqual({ installed: false }) - // The superseded run still returns its own answer to its own caller, but - // must not have become the cached one. const cachedNow = await runPreflightCheck(false, { wslDistro: 'Ubuntu' }) expect(cachedNow.git).toEqual({ installed: true }) }) diff --git a/src/main/ipc/preflight-local-env.ts b/src/main/ipc/preflight-local-env.ts index cbd3353208b..ccbbc49f8ac 100644 --- a/src/main/ipc/preflight-local-env.ts +++ b/src/main/ipc/preflight-local-env.ts @@ -27,8 +27,7 @@ export function buildLocalPreflightEnv( return env } const env = stringOnlyProcessEnv(process.env) - // Why: newly installed CLIs update persisted Windows Path, but the running - // Electron process keeps its old environment until we merge it explicitly. + // The running app must merge persisted Windows PATH to see newly installed CLIs. mergePersistedWindowsPath(env) return env } diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index 9897e23ee7f..623266f6cc0 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -18,7 +18,7 @@ vi.mock('./preflight-command-exec', async (importOriginal) => ({ import { registerPreflightHandlers } from './preflight' import { detectAgentCommandsOnHost } from '../preflight/agent-detection' -// Why: this process serves no SSH target; each probe must reject, never answer for this computer. +// This process cannot answer for the SSH host; never substitute local results. describe('preflight on an SSH target', () => { it('refuses Qoder command selection without probing the local host', async () => { await expect( @@ -27,7 +27,7 @@ describe('preflight on an SSH target', () => { expect(isCommandOnPathMock).not.toHaveBeenCalled() }) - // Why (#25523): an empty list would be cached as "no agents installed" on that host. + // Empty results would be cached as no agents installed on that host (#25523). it.each(['preflight:detectRemoteAgents', 'preflight:detectRemoteWindowsTerminalCapabilities'])( '%s rejects instead of answering', async (channel) => { diff --git a/src/main/ipc/preflight-test-harness.ts b/src/main/ipc/preflight-test-harness.ts index 68a6ea468c4..da2a737c35b 100644 --- a/src/main/ipc/preflight-test-harness.ts +++ b/src/main/ipc/preflight-test-harness.ts @@ -38,7 +38,6 @@ export const defaultGiteaStatus = { tokenConfigured: false } -/** Shared per-test reset: mock defaults, cleared cache, darwin platform, fresh handler map. */ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap): void { const { handleMock, @@ -62,9 +61,7 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap) hydrateShellPathMock.mockReset() hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' }) mergePathSegmentsMock.mockReset() - // Why empty by default: with no fs candidates the local probe keeps its - // historical bare-name spawn, so cases that stub only where/which and execFile - // still assert on the command names they were written against. + // No filesystem candidates keeps bare command names for tests using the existing lookup/exec mocks. listLocalCommandPathsMock.mockReset() listLocalCommandPathsMock.mockResolvedValue([]) getBitbucketAuthStatusMock.mockReset() @@ -73,15 +70,12 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap) mergePersistedWindowsPathAsyncMock.mockReset() mergePersistedWindowsPathAsyncMock.mockResolvedValue(undefined) mergePersistedWindowsPathMock.mockReset() - // Why: existing tests should keep treating `which` as the only source - // unless a case explicitly exercises the install-dir fallback. + // Keep install-directory fallback opt-in for cases that otherwise use only the command lookup mock. resolveCliCommandsMock.mockReset() resolveCliCommandsMock.mockImplementation( (commands: string[]) => new Map(commands.map((command) => [command, command])) ) - // Why: reproduce the pre-#9297 local PATH check (spawn where/which, keep - // only absolute resolutions) so cases that stub the where/which mock still - // drive detection identically without a real subprocess. + // Preserve existing lookup fixtures while production uses filesystem PATH resolution (#9297). isCommandOnLocalPathMock.mockReset() isCommandOnLocalPathMock.mockImplementation(async (command: string) => { const finder = process.platform === 'win32' ? 'where' : 'which' diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 0f9cdb0d350..3da026a1a19 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -46,8 +46,7 @@ describe('detectWslCommandsOnPath', () => { await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) const { script } = lastSpec() - // Why: zsh aborts on `fi done` — the loop body and `done` must be separated - // by a newline. Regression guard for issue #5325. + // POSIX shells need a separator before done (#5325). expect(script).not.toContain('fi done') expect(script).toContain('fi\ndone') }) @@ -96,12 +95,7 @@ describe('detectWslCommandsOnPath', () => { }) it('finds a real command even with rc/motd-shaped noise ahead of it in stdout (regression)', async () => { - // Before this migration the site ran an uncaptured login shell - // (buildWslLoginShellCommand) and parsed raw stdout, so the distro's - // "run a command as administrator" rc/motd banner shared the stream with - // the payload (#11327, #11823 class). The probe lane runs no shell at all, - // so a banner has no way to appear; this proves the payload line still - // parses correctly even when banner-shaped text precedes it. + // Banner-shaped text must not corrupt parsing even though the probe lane itself avoids shell startup. runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, @@ -134,10 +128,7 @@ describe('detectWslCommandsOnPath', () => { }) it('does not veto a guest binary on an ordinary Linux mount under /mnt', async () => { - // The walk skips PATH components the guest reports as Windows drives. A name - // rule on top of that could only do harm: /mnt/d can be an ext4 volume, and - // vetoing a path the walk deliberately kept turns the false positive back - // into the #9725 false negative. + // Only mount evidence identifies Windows drives; /mnt/d may be an ext4 guest volume. runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, @@ -164,8 +155,7 @@ it('still counts a genuine guest install', async () => { }) describe('the detection script itself, run by a real POSIX shell', () => { - // Not a mock: the fallback is shell globbing and `[ -x ]`, which only the - // shell can be trusted about. Skipped on Windows, where /bin/sh is absent. + // Execute real shell globbing and executable checks; Windows has no /bin/sh. const itPosix = process.platform === 'win32' ? it.skip : it let home: string @@ -201,8 +191,7 @@ describe('the detection script itself, run by a real POSIX shell', () => { } itPosix('finds an nvm-installed binary the login PATH would have shown', async () => { - // #9725: without the login PATH this resolves to nothing and preflight - // reports a working install as "not installed". + // Version-manager fallback must recover a CLI absent from login PATH (#9725). plant('.nvm/versions/node/v20.1.0/bin', 'orca-fake-cli') const out = await runScript() expect(out).toContain('__ORCA_AGENT_PATH__orca-fake-cli') @@ -220,8 +209,7 @@ describe('the detection script itself, run by a real POSIX shell', () => { }) itPosix('finds a CLI when $HOME contains a space', async () => { - // The dir list is globbed, so an unquoted $HOME word-split into a relative - // path and every CLI read as absent -- the #9725 symptom, from the fix. + // HOME with spaces must remain one path throughout fallback glob expansion. const spaced = mkdtempSync(join(tmpdir(), 'orca has space-')) const previous = home home = spaced @@ -235,8 +223,7 @@ describe('the detection script itself, run by a real POSIX shell', () => { }) itPosix('does not report a directory as an installed CLI', async () => { - // Directories are mode 755 and pass -x. Preflight would say installed and - // the launch would fail later with EISDIR. + // Directories pass -x but would fail to launch with EISDIR. mkdirSync(join(home, '.local/bin/orca-fake-cli'), { recursive: true }) expect(await runScript()).not.toContain('__ORCA_AGENT_PATH__orca-fake-cli') }) @@ -256,11 +243,7 @@ describe('the PATH walk, run by a real POSIX shell', () => { const itPosix = process.platform === 'win32' ? it.skip : it itPosix('finds the guest install behind a Windows binary that shadows it', async () => { - // WSL appends the Windows PATH, so a Windows `claude` sits ahead of an - // nvm one. Rejecting the resolved path afterwards cannot resume the walk, - // so it reports "not installed" for a user who has both -- turning the - // false positive into the #9725 false negative. Skipping the component - // mid-walk is what actually finds the guest binary. + // Skip Windows components during the walk so an interop binary cannot hide a later guest install. const root = mkdtempSync(join(tmpdir(), 'orca-walk-')) try { const win = join(root, 'winmnt/c/npm') @@ -276,7 +259,7 @@ describe('the PATH walk, run by a real POSIX shell', () => { const script = buildPosixCommandPathLookupScript( { kind: 'literal', value: 'claude' }, { skipWindowsMountDirs: true } - // Stand in for /proc/mounts, which a test host does not have. + // Substitute synthetic Windows-mount evidence for /proc/mounts. ).replace(/_orca_win_mounts=\$\([^)]*\)/, `_orca_win_mounts=${join(root, 'winmnt')}`) const options: ExecFileSyncOptions = { encoding: 'utf8', @@ -328,9 +311,7 @@ describe('the mount table read, counted against a real shell', () => { } itPosix('reads /proc/mounts once, not once per probed command', async () => { - // The prelude is embedded in the lookup script, which the caller wraps in - // `for cmd in `. An unconditional assignment forked awk once - // per CLI -- 36 of them inside the distro against a 10s budget. + // Cache mount discovery across the per-agent loop to avoid repeated awk forks. expect(await runWithCountingAwk(['claude', 'codex', 'gemini', 'opencode'])).toBe(1) }) @@ -339,11 +320,7 @@ describe('the mount table read, counted against a real shell', () => { }) itPosix('applies the memoised mount list to every command, not just the first', async () => { - // Counting forks with a stub that reports NO mounts cannot see the thing - // the hoist trades correctness for: a refactor that empties or clobbers - // the variable inside the walk keeps the fork count at 1 and keeps the - // single-command test green, while every agent after the first stops - // skipping /mnt. A surviving mutant proved that gap. + // Fork counts alone miss a clobbered mount cache; all later agents must still skip Windows mounts. const root = mkdtempSync(join(tmpdir(), 'orca-memo-')) try { const win = join(root, 'winmnt/c/npm') @@ -375,7 +352,6 @@ describe('the mount table read, counted against a real shell', () => { env: { PATH: `${win}:${guest}:/usr/bin:/bin`, HOME: root } } const out = String(execFileSync('/bin/sh', ['-c', script], options)) - // BOTH must resolve behind the mount, not just the first. expect(out).toContain(`__ORCA_AGENT_PATH__claude\t${join(guest, 'claude')}`) expect(out).toContain(`__ORCA_AGENT_PATH__codex\t${join(guest, 'codex')}`) } finally { diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index 214ec0a10e9..3b89e1b4df8 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -23,18 +23,12 @@ export async function detectWslCommandsOnPath( const commandList = uniqueCommands.map(quotePosixShell).join(' ') const lookupScript = buildPosixCommandPathLookupScript( { kind: 'shell-variable', name: 'cmd' }, - // Skip Windows mounts DURING the walk, not after it: WSL appends the - // Windows PATH, so a Windows `claude` can shadow a real guest install, and - // discarding the result afterwards reports "not installed" for a user who - // has both -- the #9725 population the fallback dirs exist to serve. + // Skip Windows mounts while walking so an interop binary cannot hide a later guest install (#9725). { skipWindowsMountDirs: true } ) // Newlines keep the loop valid in every POSIX shell used here. const script = [ - // The same fallback the preflight command runner uses: append the - // version-manager dirs to PATH and let the ordinary lookup find them. A - // second bespoke `[ -x ]` walk here duplicated the lookup script's own - // `! -d` guard, which is how a directory once read as an installed CLI. + // Reuse the common version-manager fallback and executable lookup, including directory rejection. buildPosixFallbackPathPrelude(), `for cmd in ${commandList}; do`, lookupScript, @@ -45,8 +39,7 @@ export async function detectWslCommandsOnPath( ].join('\n') try { - // Why probe: the cached login PATH gives the user's real nvm/mise/asdf PATH - // with no shell in the loop, so there is no rc/motd banner to land in stdout. + // Use cached login PATH without shell startup banners. const result = await runWslProcess({ distro: wslTarget.distro, loginPath: 'preferred', @@ -55,8 +48,7 @@ export async function detectWslCommandsOnPath( shell: 'sh', timeoutMs: WSL_AGENT_DETECTION_TIMEOUT_MS }) - // runProcess resolves on a timeout and on a non-zero exit, so partial - // stdout would otherwise read as a complete answer. + // Timeout/nonzero results may contain partial stdout, not a complete inventory. if (result.timedOut || result.code !== 0) { return new Set() } @@ -80,8 +72,7 @@ function parseWslDetectedCommands(stdout: string): Set { } const command = payload.slice(0, separatorIndex) const resolvedPath = payload.slice(separatorIndex + 1) - // Why: a real guest executable always resolves to a POSIX-absolute path, so - // a Windows-style C:\ path here is spoofed/non-guest output, not an install. + // Guest executables resolve to POSIX-absolute paths; reject Windows-style output. if (path.posix.isAbsolute(resolvedPath)) { found.add(command) } diff --git a/src/main/ipc/preflight-wsl-command.test.ts b/src/main/ipc/preflight-wsl-command.test.ts index f5842f6afd8..456823ff0be 100644 --- a/src/main/ipc/preflight-wsl-command.test.ts +++ b/src/main/ipc/preflight-wsl-command.test.ts @@ -22,9 +22,7 @@ beforeEach(() => { describe('runPreflightCommandInWsl', () => { it('prefers the login PATH but never lets its absence decide the answer', async () => { - // Every caller collapses a throw into a verdict: isCommandAvailable returns - // false ("not installed"), isGhAuthenticated reads an empty payload as - // "not authenticated". A missing login PATH must therefore never be fatal. + // Callers can interpret rejected probes as absent/unauthenticated; degraded login PATH must remain usable. await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'gh --version', 5_000) expect(runWslProcessMock).toHaveBeenCalledWith( @@ -33,8 +31,7 @@ describe('runPreflightCommandInWsl', () => { }) it('still rejects with stdout and stderr attached on a non-zero exit', async () => { - // isGhAuthenticated reads these off the caught error as an auth-success - // fallback, so dropping them would break gh-in-WSL detection. + // Auth callers inspect captured error output for success markers. runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 1, @@ -53,12 +50,7 @@ describe('the PATH fallback, run by a real POSIX shell', () => { const itPosix = process.platform === 'win32' ? it.skip : it itPosix('finds an nvm-installed CLI a degraded probe would have missed', async () => { - // #9725: the caller turns a throw into "not installed", so a degraded probe - // told the user to install a CLI their own terminal already runs. - // - // A fabricated name, not `gh`: CI has a real /usr/bin/gh, and the fallback - // APPENDS, so the PATH lookup correctly found the real one and the planted - // stub was never reached. The test was asserting the host, not the code. + // Use a fabricated CLI: appended fallback PATH must not lose to a real host gh binary (#9725). const home = mkdtempSync(join(tmpdir(), 'orca-wsl-cmd-')) try { const bin = join(home, '.nvm/versions/node/v20.1.0/bin') diff --git a/src/main/ipc/preflight-wsl-command.ts b/src/main/ipc/preflight-wsl-command.ts index 0e8f86f2d0c..0ec44997e1b 100644 --- a/src/main/ipc/preflight-wsl-command.ts +++ b/src/main/ipc/preflight-wsl-command.ts @@ -9,21 +9,15 @@ export async function runPreflightCommandInWsl( command: string, timeoutMs: number ): Promise { - // Why probe: the cached login PATH gives the user's real nvm/mise/asdf PATH - // with no shell in the loop, so there is no rc/motd banner to strip. + // Use cached login PATH without shell startup banners. const result = await runWslProcess({ distro: target.distro, loginPath: 'preferred', - // Appending the version-manager dirs keeps a resolved login PATH - // authoritative while stopping a degraded probe from turning an installed - // CLI into "not installed" (#9725), the same fallback the native branch has. + // Append version-manager fallback directories while keeping resolved login PATH authoritative (#9725). script: `${buildPosixFallbackPathPrelude()}\n${command}`, timeoutMs }) - // runWslProcess resolves on a timeout and on a non-zero exit; the caller's - // try/catch (isCommandAvailable/isCommandOnPath, and isGhAuthenticated's - // stdout/stderr marker fallback) expects a rejection carrying stdout/stderr, - // matching what execFile's promisified callback put on a non-zero-exit error. + // Match execFile rejection semantics so callers retain captured output for auth-success fallback. if (result.timedOut || result.code !== 0) { throw Object.assign(new Error(`WSL command failed: ${command}`), { code: result.code, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index b056a5dff19..879e003ea89 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -27,8 +27,6 @@ export function registerPreflightHandlers(): void { detectAgentsOnHost({ kind: 'local', context: args }) ) - // Why here: this is the one place that already answers "what can the installed agent CLIs - // do", and the probe is cached, so a repeat launch costs nothing. ipcMain.handle('preflight:zcodeInteractiveCapability', async () => readZCodeInteractiveCapability() ) diff --git a/src/main/ipc/pty-absent-pane-respawn.test.ts b/src/main/ipc/pty-absent-pane-respawn.test.ts index 99dfb508040..9c3897ee928 100644 --- a/src/main/ipc/pty-absent-pane-respawn.test.ts +++ b/src/main/ipc/pty-absent-pane-respawn.test.ts @@ -88,7 +88,6 @@ function watchLayout(store: Harness['store']): { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) -/** The frames as records, keeping only what each workspace change carries. */ const recordsOf = (events: WorkspaceLayoutStreamEvent[]) => events.map((event) => event.type === 'records' @@ -131,7 +130,7 @@ describe('pty:spawn for a restored pane whose process is gone', () => { expect(boundAtFreshLaunch).toEqual(['pty-old']) expect(durableBindings).toEqual(['pty-old']) expect(retire).not.toHaveBeenCalled() - // Its generation rises, so a reader tells the fresh process from the dead one. + // A higher generation distinguishes the replacement from its dead predecessor. expect(recordsOf(layout.events)).toEqual([ [{ change: 'records', bindings: { set: [{ leafId, ptyId: 'pty-old', ptyGeneration: 1 }] } }] ]) diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts index a31850a7a76..316914c19dc 100644 --- a/src/main/ipc/pty-agent-launch-pane-attach.test.ts +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -108,7 +108,6 @@ describe('a pane whose process belongs to an agent launch', () => { ) } - /** Returns the runtime the spawn reports to. */ function registerWithRuntime(providerSpawn: ReturnType) { setLocalPtyProvider(localProvider(providerSpawn)) const runtime = { @@ -182,7 +181,7 @@ describe('a pane whose process belongs to an agent launch', () => { await mounted.catch(() => {}) expect(providerSpawn).toHaveBeenCalled() - // Settled for the window: a later close reaches the launch through main's commit of it. + // Main commits the settled launch so later pane closure can reach it. expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { kind: 'proceed' }) @@ -212,7 +211,7 @@ const DAEMON_ANSWERS = [ ['daemon gone: the terminal host is unavailable, as for any tab', 'gone'] ] as const -// What a launch pane is after a restart: nothing about its launch is restored, so it spawns as any pane. +// Restored panes have no launch state and follow ordinary spawn semantics. describe('a restored pane after a restart', () => { const { handlers, mainWindow } = setupPtyIpcSuite() diff --git a/src/main/ipc/pty-agent-pane-launch.test.ts b/src/main/ipc/pty-agent-pane-launch.test.ts index f633fa0a629..dd81c908681 100644 --- a/src/main/ipc/pty-agent-pane-launch.test.ts +++ b/src/main/ipc/pty-agent-pane-launch.test.ts @@ -145,8 +145,7 @@ describe('a pane that starts its own agent launch', () => { }) }) - // The command was quoted for the plan's runtime, so the PTY must run that runtime's shell. The tab's - // shell is inferred, not asked for, and would make the planner refuse a WSL-share repo. + // Planned command quoting requires the planner's runtime shell; an inferred tab shell can reject WSL-share repos. it("runs the plan's runtime shell, not the tab's, as a host launch does", async () => { const providerSpawn = vi.fn(async (_options: { shellOverride?: string }) => ({ id: 'pty-agent' diff --git a/src/main/ipc/pty-applied-size-reporting.test.ts b/src/main/ipc/pty-applied-size-reporting.test.ts index d87ca56055e..fc7ed03ac5c 100644 --- a/src/main/ipc/pty-applied-size-reporting.test.ts +++ b/src/main/ipc/pty-applied-size-reporting.test.ts @@ -51,7 +51,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => describe('registerPtyHandlers', () => { const { handlers, mainWindow, mainWindowIpcEvent } = setupPtyIpcSuite() - // Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync). describe('pty:getSize reports applied size, not requested size', () => { function setupProviderWithAppliedSize(args: { applied: { cols: number; rows: number } | null @@ -109,7 +108,7 @@ describe('registerPtyHandlers', () => { } it('returns the applied (wide) size after a dropped narrow resize', async () => { - // The daemon keeps the PTY at its wide spawn size; the narrow resize is silently dropped (fire-and-forget). + // Simulate a daemon silently dropping fire-and-forget resize requests. setupProviderWithAppliedSize({ applied: { cols: 200, rows: 50 } }) handlers.clear() registerPtyHandlers(mainWindow as never) @@ -119,12 +118,12 @@ describe('registerPtyHandlers', () => { // Renderer forwards a corrective narrow resize; it is dropped daemon-side. resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 }) - // getSize must surface the applied wide size so the renderer detects drift and re-asserts — NOT requested 80. + // Report applied size so the renderer detects drift and retries. const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 200, rows: 50 }) }) it('falls back to the requested size when the provider cannot report applied size', async () => { - // No getAppliedSize (e.g. SSH relay): requested-size cache is the only signal, so getSize returns it. + // Providers without getAppliedSize fall back to the requested-size cache. setupProviderWithAppliedSize({ applied: null, getAppliedSize: undefined }) setLocalPtyProvider({ spawn: vi.fn(async (opts: { sessionId?: string }) => ({ @@ -294,7 +293,6 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => null), registerPty: vi.fn(), getDriver: vi.fn(() => ({ kind: 'idle' })), - // The fix: a PTY with a remote viewer reports true even though driver state stays idle/desktop. isRemoteDesktopResizeDriven: vi.fn(() => true), isResizeSuppressed: vi.fn(() => false), onPtySpawned: vi.fn(), @@ -312,7 +310,6 @@ describe('registerPtyHandlers', () => { // Host's own safeFit tries to widen the viewed PTY back to its window. resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 }) - // It must not reach the PTY while the viewer owns the width. expect(resizeSpy).not.toHaveBeenCalled() expect(runtime.recordRemoteDesktopHostReclaimTarget).toHaveBeenCalledWith(id, 125, 48) expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index d300c857e87..b503581a20d 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -1,5 +1,3 @@ -// Layout-owner build: a window view that attaches while main starts its pane takes main's start, -// with no second provider attach (one round trip, as the window's own cold restore was). import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers } from './pty/register-handlers' @@ -69,7 +67,6 @@ const PANE: PaneArgs = { describe('a window view of a pane main is starting', () => { const { handlers, mainWindow } = setupPtyIpcSuite() - /** Main starts the pane while the window's view of it waits; resolves the view's attach. */ async function viewDuringMainStart(startResult: Record) { const providerSpawn = vi.fn(async (opts: { sessionId?: string }) => ({ id: opts.sessionId ?? 'pty-main', @@ -136,7 +133,7 @@ describe('a window view of a pane main is starting', () => { }) it("attaches as today when main's start carries no payload to paint", async () => { - // A fresh start: nothing to paint, so the view's own attach takes the provider's snapshot. + // No main-start snapshot exists; use the view attach snapshot. const { view, providerSpawn } = await viewDuringMainStart({}) expect(view).toMatchObject({ id: 'pty-main' }) expect(providerSpawn).toHaveBeenCalledTimes(2) diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index 48cb2a5adf9..7f2d77cc8b7 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -58,8 +58,6 @@ describe('registerPtyHandlers', () => { } = setupPtyIpcSuite() describe('agent_started telemetry', () => { - // Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event. - // Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event. it('emits agent_started after a successful spawn when telemetry is supplied', async () => { handlers.clear() registerPtyHandlers(mainWindow as never) @@ -99,7 +97,7 @@ describe('registerPtyHandlers', () => { expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) }) it('does not emit agent_started when provider.spawn throws', async () => { - // Why: agent_started fires only on confirmed launch — inject a throwing provider to hit the catch path with no race against the real LocalPtyProvider. + // A throwing provider proves failed spawns cannot emit agent_started. setLocalPtyProvider({ spawn: vi.fn(async () => { throw new Error('spawn boom') @@ -189,7 +187,7 @@ describe('registerPtyHandlers', () => { controller.serializeBuffer('pty-11'), controller.serializeBuffer('pty-12') ] - // Why: the bug registered one listener per request, so 12 concurrent calls would trip Node's MaxListeners. + // Per-request listeners would cross Node's limit with these 12 concurrent requests. const responseChannelRegistrations = onMock.mock.calls.filter( (call: unknown[]) => call[0] === 'pty:serializeBuffer:response' ) @@ -328,7 +326,7 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) - // Why pendingDeliveryStartSeq === seq: pending delivery queue is empty, so low-seq live chunks must not be dropped against the snapshot baseline. + // An empty pending queue must not make the snapshot baseline discard low-sequence live chunks. expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index fd2c3ec3790..dfd9aaf66ca 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -215,8 +215,7 @@ describe('registerPtyHandlers', () => { } }) - // Why: the resume deliberately overrides the selection, so the pane really - // is on account-a. Recording that is what makes the restart prompt appear. + // Resume overrides account selection; record the actual origin so restart notices target the right pane. expect(recordCodexPaneAccountMock.mock.calls).toEqual([ ['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }] ]) @@ -266,14 +265,11 @@ describe('registerPtyHandlers', () => { } }) - // Why: an unowned home cannot be named, so guessing here would raise a - // restart notice that blocks a correctly-signed-in pane's input. + // Guessing an unowned home could block a correctly authenticated pane with a false restart notice. expect(recordCodexPaneAccountMock).not.toHaveBeenCalled() expect(forgetCodexPaneAccountMock).toHaveBeenCalledWith('pty-resumed') }) - // Why: the runtime controller is the CLI/relay resume path, and it repeats the - // same recording call the ipc handler makes. Without its own coverage a revert - // there is invisible. + // Cover the independent CLI/relay controller path as well as renderer IPC. it('records the origin account for a resumed Codex pane spawned by the runtime controller', async () => { type RuntimeSpawnController = { spawn(args: Record): Promise<{ id: string }> diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index ff88379b501..880be132007 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -102,8 +102,7 @@ describe('registerPtyHandlers', () => { }) it('answers unknown for a probe-less provider that is not the in-process one', async () => { - // Why: only the in-process provider is its own sole owner; any other - // probe-less provider's ignorance is doubt, not absence. + // Only the in-process provider can prove sole ownership without a probe. setLocalPtyProvider(createAgentClaimProvider({}) as never) const controller = registerAgentClaimController() @@ -111,8 +110,7 @@ describe('registerPtyHandlers', () => { }) it('answers unknown for remote-scoped ids without consulting local providers', async () => { - // Why: a locally routed provider would answer confidently — and wrongly — - // for a PTY that lives on a remote Orca host. + // A local provider cannot prove ownership of a PTY on another host. setLocalPtyProvider(new LocalPtyProvider()) const controller = registerAgentClaimController() @@ -140,20 +138,16 @@ describe('registerPtyHandlers', () => { const ownedSshPtyId = 'owned-remote-pty' setPtyOwnership(ownedSshPtyId, 'ssh:ssh-attach') try { - // Local daemon session: attach flows to the provider. await expect(controller.attach(daemonPtyId)).resolves.toBe(true) expect(localProvider.attach).toHaveBeenCalledWith(daemonPtyId) - // SSH-scoped sessions are excluded and never fall through to the local provider. await expect(controller.attach(ownedSshPtyId)).resolves.toBe(false) await expect(controller.attach('ssh:ssh-attach@@relay-pty')).resolves.toBe(false) expect(localProvider.attach).toHaveBeenCalledTimes(1) - // Provider refusal (absent/unprovable session) answers false, not throw. localProvider.attach.mockRejectedValueOnce(new Error('Session not found')) await expect(controller.attach(daemonPtyId)).resolves.toBe(false) - // The in-process local provider streams without attach; never called. const inProcess = new LocalPtyProvider() const inProcessAttach = vi.spyOn(inProcess, 'attach') setLocalPtyProvider(inProcess) diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index 86e640788e6..374aef8e086 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -57,10 +57,7 @@ import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' import type { IPtyProvider, PtyProcessInfo } from '../providers/pty-provider-contract' -// The runtime's worktree.ps liveness refresh calls the aggregate inventory (no connectionId) -// under a 3s budget, and only a returned inventory can retire an exited PTY. STA-517: one -// unreachable relay made the whole aggregate fail, so no PTY was ever proven dead and every -// retained pane — the SSH ones above all — kept reporting "active" to mobile indefinitely. +// An unreachable relay must not invalidate all host inventories and leave exited panes active on mobile (STA-517). type ListCall = { opts: { deadlineMs?: number } | undefined } @@ -119,8 +116,7 @@ describe('aggregate PTY process inventory', () => { setLocalPtyProvider(local.provider) const controller = captureController() - // A local failure is a real controller fault, not one unreachable host: the runtime must - // keep treating it as "no inventory" rather than proving every local PTY dead. + // Local controller failure must preserve uncertainty instead of proving every local PTY dead. await expect(controller.listProcesses()).rejects.toThrow('relay unreachable') }) }) diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index f4332263a92..5da6cb30df7 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -57,7 +57,6 @@ describe('registerPtyHandlers', () => { describe('spawn environment', () => { describe('daemon-active provider (parity with LocalPtyProvider)', () => { - // Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead. it('runtime controller stopAndWait fails when keepHistory allows the PTY to revive', async () => { vi.useFakeTimers() const shutdown = vi.fn(async () => undefined) @@ -362,8 +361,7 @@ describe('registerPtyHandlers', () => { kill: (ptyId: string) => boolean } - // The lease is tombstoned, but nothing observed the detached relay PTY - // stop, so the stop itself is reported as unconfirmed. + // Tombstoning the lease does not prove its detached relay PTY stopped. expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(false) expect(localShutdown).not.toHaveBeenCalled() @@ -577,12 +575,8 @@ describe('PTY SSH lease lifecycle', () => { ]) }) it('splits the teardown budget so the liveness RPC gets only what shutdown left', async () => { - // Why: sequential RPCs must share one absolute deadline; otherwise both get - // the full ~9.5s bound and their sum overruns the 10s sweep deadline (Finding 1). - // Fake timers freeze Date.now() at entry, then let the shutdown RPC burn a - // deterministic slice of the budget so the leaf-observed remainders are provable. + // Sequential shutdown/liveness RPCs must share one absolute deadline; freeze time to measure consumed budget. vi.useFakeTimers() - // Each provider call records the budget an RPC leaf would see at issue time. const remainingAtLeaf: number[] = [] const shutdown = vi.fn(async (_id: string, opts?: { deadlineMs?: number }) => { remainingAtLeaf.push((opts?.deadlineMs ?? 0) - Date.now()) @@ -632,14 +626,11 @@ describe('PTY SSH lease lifecycle', () => { await vi.advanceTimersByTimeAsync(1000) await expect(stopPromise).resolves.toBe(true) - // Both calls carry the same absolute deadline... expect(shutdown).toHaveBeenCalledWith( 'local-pty', expect.objectContaining({ immediate: true, deadlineMs }) ) - // ...so at the leaves the shutdown RPC sees the full 4321ms budget, while the - // SUBSEQUENT liveness list RPC sees only what shutdown left: the 1000ms it - // consumed is gone, so 4321 - 1000 = 3321 remain until the shared deadline. + // Liveness receives only the budget remaining after shutdown. expect(remainingAtLeaf).toEqual([4321, 3321]) vi.useRealTimers() }) diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 0ed74283181..f147e7e1b0e 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -67,7 +67,7 @@ describe('registerPtyHandlers', () => { daemonSpawnAndGetEnv } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) - // Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead. + // Daemon spawns bypass LocalPtyProvider.buildSpawnEnv; main must inject host-local environment. it('overrides an unmarked custom home for an authoritative daemon resume', async () => { const daemonSpawn = setupDaemonAdapter() const selectedHome = vi.fn(() => '/managed/current/home') @@ -329,9 +329,7 @@ describe('registerPtyHandlers', () => { spy.mockRestore() } }) - // Why: the guest materializes one overlay per OpenCode major, each holding only its - // own plugin file. Asking for the wrong one hands the guest the other variant's - // plugin, whose agent gate then registers no hooks at all. + // Guest overlays are OpenCode-major-specific; the wrong variant silently disables hooks. const guestOverlayCases: { launchAgent?: TuiAgent expectedAgent: 'opencode' | 'opencode2' @@ -396,9 +394,7 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete ?? []).not.toEqual(expect.arrayContaining(['CODEX_HOME'])) }) it('strips inherited Claude child-session stamps from daemon spawns', async () => { - // Why: a daemon forked from inside a Claude Code session inherits these - // stamps and would mark every terminal as a nested Claude child, which - // silently disables transcript persistence for real user sessions. + // Inherited Claude child stamps disable transcript persistence in otherwise independent sessions. const spawnOptions = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { CLAUDE_CODE_CHILD_SESSION: '1', CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', @@ -413,8 +409,7 @@ describe('registerPtyHandlers', () => { ) }) it('strips an inherited agent session id', async () => { - // Why: a daemon forked by an Orca launched inside a structured session inherits its id, - // and every daemon pane would present that session as its orchestration caller. + // Inherited structured-session IDs must not identify independent panes as the parent orchestration caller. const inherited = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', ORCA_STRUCTURED_SESSION: '1' @@ -424,8 +419,7 @@ describe('registerPtyHandlers', () => { ) }) it('preserves an explicitly requested Claude child-session stamp', async () => { - // Why: only inherited values are poison; a caller deliberately spawning a - // nested Claude child passes the stamp in args.env and must keep it. + // Preserve child stamps explicitly supplied by a caller creating a nested session. const spawnOptions = await daemonSpawnAndGetOptions( { CLAUDE_CODE_CHILD_SESSION: '1' }, undefined, @@ -444,13 +438,13 @@ describe('registerPtyHandlers', () => { value: 'linux' }) try { - // Why: overriding process.platform doesn't change the loaded node:path dialect; keep this synthetic PATH consistent. + // Mocking process.platform does not change the loaded node:path delimiter. const env = await daemonSpawnAndGetEnv({ PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) }) const entries = env.PATH.split(delimiter) const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim') - // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). + // Orca terminals must resolve this CLI before the GNOME screen reader (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) expect(env.ORCA_CLI_COMMAND).toBeUndefined() @@ -572,9 +566,7 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH') }) it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => { - // Why: the runtime controller is the `orca` CLI / automation spawn path and - // assembles envToDelete separately from the renderer's pty:spawn handler; - // without its own case the two paths can silently drift apart. + // Cover the independent CLI/automation envToDelete path as well as renderer IPC. type RuntimeSpawnController = { spawn(args: { cols: number diff --git a/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts b/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts index ec1b2a1a0bf..aaeefa00c28 100644 --- a/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts +++ b/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts @@ -73,7 +73,7 @@ describe('registerPtyHandlers', () => { mainWindow }) - // Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead. + // Daemon spawns bypass LocalPtyProvider.buildSpawnEnv; main must inject host-local environment. it('waits for managed Codex auth before spawning a daemon PTY', async () => { vi.useFakeTimers() let authReady = false @@ -500,7 +500,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP status metadata', async () => { - // Why: mirror of the local-spawn OMP threading assertion; the daemon path's `command` forwarding could silently regress otherwise. + // Cover OMP forwarding in daemon spawns as well as local spawns. const env = await daemonSpawnAndGetEnv( { PI_CODING_AGENT_DIR: '/user/.omp/agent' }, undefined, diff --git a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts index 62316e29cf8..aac591f44c0 100644 --- a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts +++ b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts @@ -68,7 +68,7 @@ describe('registerPtyHandlers', () => { mainWindow }) - // Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead. + // Daemon spawns bypass LocalPtyProvider.buildSpawnEnv; main must inject host-local environment. it('materializes the full guard for a legacy daemon host', async () => { const env = await daemonSpawnAndGetEnv( { @@ -124,7 +124,7 @@ describe('registerPtyHandlers', () => { }) }) it('prefixes a minted sessionId with the worktreeId when provided', async () => { - // Why: daemon reconnect keys live-shell survival on the sessionId; prefixing with worktreeId scopes sessions by worktree with a unique tail. + // Scope daemon reconnect identity by worktree while retaining a unique session tail. const daemonSpawn = setupDaemonAdapter() handlers.clear() registerPtyHandlers(mainWindow as never) @@ -207,7 +207,7 @@ describe('registerPtyHandlers', () => { expect(daemonSpawn).not.toHaveBeenCalled() }) it('falls back to process.env.PI_CODING_AGENT_DIR when baseEnv lacks it on the daemon path', async () => { - // Why: buildPtyHostEnv reads `baseEnv.X ?? process.env.X` so the agent-dir guard works whether Pi's env came over IPC or via daemon fork. + // Ambient Pi configuration must receive the same guard as explicit IPC environment. const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { PI_CODING_AGENT_DIR: '/ambient/pi/agent' }) @@ -222,7 +222,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/ambient/pi/agent') }) it('does not mutate the caller-provided args.env on the daemon path', async () => { - // Why: the handler clones baseEnv so IPC-provided env stays pristine; a regression would leak Orca host env back into the renderer's reused copy. + // Host injection must not mutate a renderer's reusable environment object. const daemonSpawn = setupDaemonAdapter() const argsEnv: Record = { FOO: 'bar' } handlers.clear() @@ -233,13 +233,12 @@ describe('registerPtyHandlers', () => { env: argsEnv }) expect(argsEnv).toEqual({ FOO: 'bar' }) - // Sanity: the spawn did receive the injected env, so the test isn't passing vacuously. const spawnEnv = daemonSpawn.mock.calls.at(-1)![0].env expect(spawnEnv.ORCA_AGENT_HOOK_PORT).toBe('5678') expect(spawnEnv).not.toBe(argsEnv) }) it('rejects a caller-supplied sessionId that escapes userData via ..', async () => { - // Why: effectiveSessionId reaches filesystem side-effects, so a traversal payload must be refused before they run. + // Reject traversal before session identity reaches filesystem side effects. const daemonSpawn = setupDaemonAdapter() handlers.clear() registerPtyHandlers(mainWindow as never) @@ -255,7 +254,7 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).not.toHaveBeenCalled() }) it('sweeps per-PTY state when provider.spawn fails for a MINTED sessionId', async () => { - // Why: buildPtyHostEnv has filesystem side-effects, so a minted id's per-PTY state must be cleared if provider.spawn later fails. + // Failed spawns must clear filesystem state for newly minted IDs. const daemonSpawn = vi.fn(async () => { throw new Error('spawn boom') }) @@ -290,7 +289,7 @@ describe('registerPtyHandlers', () => { ) }) it('does NOT sweep per-PTY state on provider.spawn failure for CALLER-supplied sessionId', async () => { - // Why: a caller-supplied sessionId may refer to an existing PTY whose state must survive a retry/attach failure; only minted ids get swept. + // Caller-supplied IDs may own live PTYs; failed retries must not sweep them. const daemonSpawn = vi.fn(async () => { throw new Error('spawn boom') }) @@ -332,8 +331,6 @@ describe('registerPtyHandlers', () => { }) }) - // Pins main's host-env step (row 4, C5c-1): where the window (pty:spawn) and host (runtime - // controller) lanes build a daemon spawn's env differently today. describe('daemon host env: where the lanes differ today', () => { const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) @@ -387,8 +384,7 @@ describe('registerPtyHandlers', () => { expect(piClearPtyMock).toHaveBeenCalledTimes(c.swept ? 1 : 0) }) - // TEMPORARY (row 7): an empty id (the host trims a blank one to it) skips the host's host env; the - // window refuses it. + // Current host/window difference: the host accepts an empty ID without host env; the window rejects it. it('an empty session id: the window refuses it; the host spawns without host env', async () => { const lanes = startLanes() await expect(lanes.spawn('window', { env: {}, sessionId: '' })).rejects.toThrow( diff --git a/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts b/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts index 4cffc574dbc..9a7f8a00760 100644 --- a/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts +++ b/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts @@ -66,11 +66,8 @@ describe('registerPtyHandlers', () => { daemonSpawnAndGetEnv } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) - // Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead. it('strips inherited Claude child-session stamps from a local runtime-created PTY', async () => { - // Why: the runtime strip is deliberately not gated on isDaemonHostSpawn, so - // the local provider — which spreads main's own process.env — needs its own - // case; a daemon-only test would still pass if someone added that gate. + // Local providers inherit main's env too; daemon-only coverage would miss an incorrectly gated strip. type RuntimeSpawnController = { spawn(args: { cols: number @@ -139,7 +136,7 @@ describe('registerPtyHandlers', () => { leafId }) - // Why: runtime-created spawns must thread {tabId, leafId} so the catch-path rescue can keep their live PTY (#7587). + // Runtime spawns need tab/leaf identity for catch-path live-PTY rescue (#7587). expect(runtime.registerPty).toHaveBeenCalledWith( expect.any(String), 'wt-runtime', @@ -454,8 +451,7 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController - // Why: dev mode makes buildPtyHostEnv prepend its own CLI shim on every platform, so - // the promotion below has something to beat instead of passing trivially. + // Development mode guarantees a CLI shim exists for this ordering check. const { app } = await import('electron') const mockedApp = app as unknown as { isPackaged: boolean } const prevPackaged = mockedApp.isPackaged @@ -501,8 +497,7 @@ describe('registerPtyHandlers', () => { } }) it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => { - // Why: dev mode makes buildPtyHostEnv prepend its own CLI shim on every platform, so - // the promotion below has something to beat instead of passing trivially. + // Development mode guarantees a CLI shim exists for this ordering check. const { app } = await import('electron') const mockedApp = app as unknown as { isPackaged: boolean } const prevPackaged = mockedApp.isPackaged @@ -534,7 +529,6 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['TERM_PROGRAM'])) }) it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => { - // Why: the mocked `app` is a plain object, so we can flip isPackaged for the test's scope. const { app } = await import('electron') const mockedApp = app as unknown as { isPackaged: boolean } const prev = mockedApp.isPackaged @@ -565,8 +559,7 @@ describe('registerPtyHandlers', () => { } }) it('drops a legacy shim PATH entry inherited from the host process on the daemon path', async () => { - // Why: the daemon path passes a sparse env, so the prepends re-read PATH from - // process.env — the scrub must outlive that fallback (pre-upgrade host or parent pane). + // Sparse daemon env rereads ambient PATH; scrubbing must survive that fallback. const { app } = await import('electron') const mockedApp = app as unknown as { isPackaged: boolean } const prev = mockedApp.isPackaged diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 2465d2378bb..1e57495b21e 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -170,8 +170,7 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume proven-absent-session' }) - // The registry that owns the PTY answered, so this exit is confirmed — but the code stays the - // -1 sentinel; a synthesized zero would be indistinguishable from a clean shell exit. + // Proven owner absence keeps the -1 sentinel; synthesizing zero would misreport a clean shell exit. expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-proven-absent-owner', -1, @@ -308,10 +307,7 @@ describe('registerPtyHandlers', () => { { hostExitConfirmed: true } ) }) - // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while - // persistence has already dropped it. Reading "nothing left to retire" as a competing owner - // aborted materialization *after* signalling the exit, which destroyed the pane instead of - // rebuilding it — the reconnect path then had no surface to attach to (#11541). + // keepHistory may retire persistence before the runtime binding; that absence must permit replacement instead of aborting after exit (#11541). it('respawns a proven-dead owner whose persisted binding was already retired', async () => { const worktreeId = 'repo-1::/tmp/already-retired-owner' const cwd = '/tmp/already-retired-owner' @@ -351,8 +347,7 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) - // Persistence kept the tab but already dropped this leaf's PTY binding, exactly as an - // earlier keep-history stop leaves it. + // Model persistence after keepHistory: tab retained, leaf binding retired. let session = { tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: null }] @@ -404,8 +399,7 @@ describe('registerPtyHandlers', () => { noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), - // Why: the real runtime drops its pane binding on exit; the guard after retirement must - // see that release rather than a resurrected owner. + // Mirror real exit cleanup so retirement observes release instead of a resurrected runtime owner. onPtyExit: vi.fn(() => { runtimeOwnsPane = false }), diff --git a/src/main/ipc/pty-delivery-health-heal.test.ts b/src/main/ipc/pty-delivery-health-heal.test.ts index 3d646e975a1..eb4d0176535 100644 --- a/src/main/ipc/pty-delivery-health-heal.test.ts +++ b/src/main/ipc/pty-delivery-health-heal.test.ts @@ -181,7 +181,6 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ rendererPtyDataListenerCount: 1 }) ) - // Delivery is unwedged: fresh output flows to the renderer again. mockProc.emitData('after-heal') vi.advanceTimersByTime(2) expect(getPtyDataSendCalls()).toHaveLength(33) @@ -310,7 +309,7 @@ describe('registerPtyHandlers', () => { handleRendererLoading() - // Why: reload kills the dispatcher that would ACK, so stale counters would gate PTYs in the fresh renderer forever. + // Reload destroys the ACK dispatcher; stale debt must not gate PTYs in the replacement page. expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightPtyCount: 0, rendererInFlightChars: 0 diff --git a/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts b/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts index 977ee815219..83ac92e99aa 100644 --- a/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts +++ b/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts @@ -84,7 +84,7 @@ describe('registerPtyHandlers', () => { const rawReadyListener = readyCall[1] as (event: unknown) => void vi.advanceTimersByTime(1) - // Why: a straggler handshake from a dying window must not reopen the gate (or trigger the destructive reconcile) for the new page. + // A dying page's handshake must not reopen or reconcile the replacement page's gate. handleRendererLoading() mainWindow.webContents.send.mockClear() rawReadyListener({ sender: { isDestroyed: () => false } }) @@ -95,7 +95,6 @@ describe('registerPtyHandlers', () => { rendererPtyDispatcherReady: false }) - // The genuine main-window handshake still opens the gate and drains. rawReadyListener({ sender: mainWindow.webContents }) vi.advanceTimersByTime(8) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -121,7 +120,7 @@ describe('registerPtyHandlers', () => { const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() vi.advanceTimersByTime(1) - // A timely handshake must cancel the reload watchdog so no orphaned ~10s timer lingers (forced-count guard can't catch it — the watchdog no-ops once ready). + // Assert timer cleanup too: an orphaned watchdog can no-op without incrementing forced-ready. handleRendererLoading() handleRendererDispatcherReady() vi.advanceTimersByTime(0) @@ -131,7 +130,6 @@ describe('registerPtyHandlers', () => { rendererDispatcherReadyForcedCount: 0 }) - // Advancing well past the watchdog window leaves the forced counter at zero. vi.advanceTimersByTime(20_000) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererPtyDispatcherReady: true, @@ -154,15 +152,13 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) expect(vi.getTimerCount()).toBe(0) - // A reload closes the gate and arms the ~10s self-heal watchdog on THIS registration's closure. handleRendererLoading() expect(vi.getTimerCount()).toBe(1) - // Re-registering must cancel the prior closure's watchdog (cross-registration bridge) or it later force-opens a dead window's gate. + // Re-registration must cancel the outgoing closure's watchdog before it can reopen the old gate. registerPtyHandlers(mainWindow as never) expect(vi.getTimerCount()).toBe(0) - // And no orphaned ~10s watchdog fires later (removing the bridge cancel turns this red). vi.advanceTimersByTime(20_000) expect(vi.getTimerCount()).toBe(0) } finally { @@ -266,9 +262,7 @@ describe('registerPtyHandlers', () => { const sourceData = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' mockProc.emitData(sourceData) - // Answered in the query's own turn. A cooked tty echoes the reply as well as - // delivering it, and the echo is contained by the output-side projections (#12112); - // withholding the write is what let replies overtake each other (#15559). + // Reply in the query's turn to preserve order; output projections contain cooked-tty echo (#12112, #15559). expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') vi.advanceTimersByTime(2) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') @@ -306,7 +300,6 @@ describe('registerPtyHandlers', () => { const sourceData = '\x1b]10;?;?\x1b\\ready' mockProc.emitData(sourceData) - // Both slots of a duplicate-slot query are answered in that same turn. expect(mockProc.proc.write).toHaveBeenCalledTimes(2) vi.advanceTimersByTime(2) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') diff --git a/src/main/ipc/pty-global-renderer-credit.test.ts b/src/main/ipc/pty-global-renderer-credit.test.ts index f33a686953c..0899f65b1ff 100644 --- a/src/main/ipc/pty-global-renderer-credit.test.ts +++ b/src/main/ipc/pty-global-renderer-credit.test.ts @@ -286,7 +286,7 @@ describe('registerPtyHandlers', () => { setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true }) vi.advanceTimersByTime(2) - // Why: the fully gated arrival also emits a delivery resync probe, so count pty:data sends not raw webContents.send. + // Gated arrivals also send resync probes; count only pty:data delivery. expect(getPtyDataSendCalls()).toHaveLength(513) expect(getPtyDataSendCalls()[512]).toEqual([ 'pty:data', diff --git a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts index 0904f47fab3..b8bfce7cb2c 100644 --- a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts @@ -6,7 +6,7 @@ import { isHiddenRendererPty } from './pty-hidden-delivery-gate' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { registerPtyHandlers } from './pty/register-handlers' import { join } from 'node:path' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +// Wrapper roots are content-addressed. import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -64,7 +64,6 @@ describe('registerPtyHandlers', () => { } = setupPtyIpcSuite() describe('hidden-at-spawn mark (initiallyHidden)', () => { - // terminal-query-authority.md §races: hidden-at-spawn marks the PTY before byte one, closing the spawn-time DA1-loss window. function createRuntimeMock() { return { setPtyController: vi.fn(), @@ -101,7 +100,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() mainWindow.webContents.send.mockClear() - // Daemon PTYs can emit prompt bytes before spawn() resolves, so the pre-spawn mark must already gate them. + // Daemon output can precede spawn resolution; the hidden mark must already apply. expect(isHiddenRendererPty('daemon-session')).toBe(true) daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c') vi.advanceTimersByTime(50) @@ -198,7 +197,7 @@ describe('registerPtyHandlers', () => { } }) it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => { - // End-to-end through a REAL runtime: spawn-marked → first chunk dropped → emulator parses query → replies; main answers, the renderer never saw the bytes. + // Exercise real runtime parsing/reply while renderer delivery drops the first query chunk. const daemon = installObservableDaemonTestProvider() const runtime = new OrcaRuntimeService({ getRepo: () => undefined, @@ -251,7 +250,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } mainWindow.webContents.send.mockClear() - // 3 MB in one entry: the scrollback-scaled cap (2 MB default) drops to O(1) memory; one restore marker fires, droppedOutput routes to the snapshot repaint. + // A single oversized entry must become a bounded drop marker and snapshot restore. mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024)) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) @@ -260,7 +259,6 @@ describe('registerPtyHandlers', () => { reason: 'pending-cap' }) - // A second overflow before the entry drains must not re-mark. mockProc.emitData('z'.repeat(64 * 1024)) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) @@ -281,7 +279,7 @@ describe('registerPtyHandlers', () => { ])( 'keeps the pending cap active without a restore marker when the %s kill switch is off', async (_name, settings) => { - // Why: the pending cap ships independently of the gate (#7150) — droppedOutput repaint survives kill switches; only the restore marker is switch-scoped. + // Pending caps apply independently of the hidden-delivery kill switch (#7150). vi.useFakeTimers() const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts index bf32562bc8d..8110d94e68b 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -53,7 +53,6 @@ describe('pty hidden delivery gate', () => { consumeHiddenRendererPtyDropMemory(PTY_ID, drop.hiddenDropEpisode) expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false) - // The reveal painted the snapshot: the next episode reports again. unmarkHiddenRendererPty(PTY_ID) consumeHiddenRendererPtyDropMemory(PTY_ID, drop.hiddenDropEpisode) markHiddenRendererPty(PTY_ID) @@ -64,8 +63,7 @@ describe('pty hidden delivery gate', () => { markHiddenRendererPty(PTY_ID) const { hiddenDropEpisode } = recordHiddenRendererPtyDataDrop(PTY_ID, 10) - // A pane retiring while hidden unhides after unregistering its marker - // handler; every later unhide must still be able to re-emit. + // A retiring pane may unhide after unregistering its handler; later reveals must still request restore. expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) @@ -90,7 +88,6 @@ describe('pty hidden delivery gate', () => { // That snapshot may predate the new drop, so its ack must not retire the latch. consumeHiddenRendererPtyDropMemory(PTY_ID, first) expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) - // Nor after the second reveal: the older restore still covers only episode one. consumeHiddenRendererPtyDropMemory(PTY_ID, first) expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) @@ -129,8 +126,7 @@ describe('pty hidden delivery gate', () => { }) it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => { - // Why: a hidden remount or renderer reload re-marks without an unhide in - // between — clearing the latch there would make reveal skip the restore. + // Hidden remounts and reloads re-mark without revealing; preserve the unrestored gap. markHiddenRendererPty(PTY_ID) recordHiddenRendererPtyDataDrop(PTY_ID, 10) markHiddenRendererPty(PTY_ID) @@ -155,16 +151,14 @@ describe('pty hidden delivery gate', () => { resetRendererScopedHiddenPtyDeliveryState() - // Hidden marks and interest holds died with the old renderer process. expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ hiddenDeliveryGatedPtyCount: 0, deliveryInterestPtyCount: 0 }) - // pty-2's leaked interest is gone: re-marking gates it again. markHiddenRendererPty('pty-2') expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true) - // Drop memory survives so the new renderer's first unhide still restores. + // Preserve drop memory so the replacement renderer can still request restore. markHiddenRendererPty(PTY_ID) expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) }) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index 79086652b10..f3d51127093 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -1,14 +1,4 @@ -/** - * Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of - * the terminal model/view architecture). - * - * The renderer marks a PTY hidden when no visible view consumes its bytes; - * main then drops renderer-bound delivery AFTER model ingestion — the runtime - * already parsed the chunk, and reveal restores from the model snapshot via - * the existing seq-guarded machinery. Any renderer party that still needs raw - * bytes (dispatcher sidecars) registers delivery - * interest, which suppresses the gate for that PTY. - */ +/** Drop hidden renderer delivery after model ingestion; reveal restores a snapshot, while sidecar interest keeps raw bytes flowing. */ import type { GlobalSettings } from '../../shared/global-settings-types' export type HiddenPtyDeliveryGateSettings = Pick< @@ -17,33 +7,21 @@ export type HiddenPtyDeliveryGateSettings = Pick< > const hiddenRendererPtys = new Set() -// Why: sidecar consumers (paste-draft pacing, background agent launches, -// automation observers) need live bytes even while no visible view exists. Any -// registered interest suppresses the gate for that PTY. +// Sidecar consumers need live bytes even without a visible terminal view. const deliveryInterestRendererPtys = new Set() -// Why: reveal must restore from the model only when bytes were actually -// dropped. Doubles as the marker latch: the first gated drop emits a restore -// marker and every unhide re-emits one. It retires only when the renderer acks -// the restore painted, or on PTY teardown — a retiring hidden pane unhides after -// unregistering its marker handler, so spending it on unhide loses the gap. -// 'awaiting-paint' = an unhide re-emitted the marker; a drop after that starts a -// new episode the in-flight restore's snapshot may predate, so its ack must not -// retire it. +// Keep drop memory until painted-restore ACK or teardown; reveal markers may reach no handler. +// Drops after reveal start a new episode so an older snapshot ACK cannot retire a newer gap. const droppedSinceHiddenPtys = new Map< string, { state: 'unrevealed' | 'awaiting-paint'; episode: number } >() let nextHiddenDropEpisode = 1 -// Why: a runtime background spawn has no renderer party to re-mark it after a -// reload/crash, so its hidden mark must outlive renderer-scoped resets until a -// renderer unmarks it (visible mount) or the PTY is torn down. +// Runtime spawns have no view to re-mark them after reload; retain their mark until visible mount or teardown. const runtimeOwnedHiddenRendererPtys = new Set() let droppedHiddenDeliveryChars = 0 let droppedHiddenDeliveryChunks = 0 -/** Gate kill switches, both read main-side: the gate only operates under main - * side-effect authority AND the gate-specific setting (both default on). */ export function isHiddenPtyDeliveryGateEnabled( settings: HiddenPtyDeliveryGateSettings | null | undefined ): boolean { @@ -53,17 +31,12 @@ export function isHiddenPtyDeliveryGateEnabled( ) } -/** Renderer-reported "no visible view needs bytes" bit. Never clears drop - * memory: a hidden remount or renderer reload re-marks an already-dropped - * PTY, and erasing the latch there would make the eventual reveal skip the - * restore. */ +/** Re-marking must preserve drop memory across hidden remounts and renderer reloads. */ export function markHiddenRendererPty(id: string): void { hiddenRendererPtys.add(id) } -/** Clears the hidden bit. Returns whether bytes were dropped while hidden so - * the caller can emit a restore marker. Reads the latch without spending it: - * the marker may reach no handler (see droppedSinceHiddenPtys). */ +/** Reveal reads the latch without retiring it: the marker may reach no handler. */ export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean hiddenDropEpisode?: number @@ -78,9 +51,7 @@ export function unmarkHiddenRendererPty(id: string): { return { droppedWhileHidden: true, hiddenDropEpisode: latch.episode } } -/** Retires drop memory once the renderer reports painting a restore requested - * for this episode. An older restore's ack must not retire a newer episode, - * and an ack naming no episode retires nothing (fails toward recovery). */ +/** Only a painted-restore ACK for the revealed episode can retire its drop memory. */ export function consumeHiddenRendererPtyDropMemory(id: string, hiddenDropEpisode?: number): void { const latch = droppedSinceHiddenPtys.get(id) if (latch?.state === 'awaiting-paint' && latch.episode === hiddenDropEpisode) { @@ -92,7 +63,6 @@ export function isHiddenRendererPty(id: string): boolean { return hiddenRendererPtys.has(id) } -/** Marks a PTY hidden on behalf of the runtime (no renderer view exists). */ export function markRuntimeOwnedHiddenRendererPty(id: string): void { hiddenRendererPtys.add(id) runtimeOwnedHiddenRendererPtys.add(id) @@ -102,8 +72,7 @@ export function isRuntimeOwnedHiddenRendererPty(id: string): boolean { return runtimeOwnedHiddenRendererPtys.has(id) } -/** For freeze diagnostics only: hidden ptys must appear in the per-pty report - * table even when the gate dropped every byte before any send/accounting. */ +/** Include hidden PTYs in diagnostics even when all bytes were dropped before send accounting. */ export function getHiddenRendererPtyIds(): string[] { return [...hiddenRendererPtys] } @@ -128,8 +97,7 @@ export function shouldDropHiddenRendererPtyData( ) } -/** Record one gated drop. Returns whether the caller should emit the one-shot - * empty restore-marker chunk (first drop since the latch was retired or last revealed). */ +/** Request a restore marker on the first drop after retirement or reveal. */ export function recordHiddenRendererPtyDataDrop( id: string, chars: number @@ -145,18 +113,11 @@ export function recordHiddenRendererPtyDataDrop( return { shouldEmitRestoreMarker: true, hiddenDropEpisode: episode } } -/** The open drop episode, if bytes were dropped since the last restore painted. */ export function hiddenRendererPtyDropEpisode(id: string): number | undefined { return droppedSinceHiddenPtys.get(id)?.episode } -/** Renderer process replaced (reload / crash): its ref-counted interest - * holds and hidden marks died with it, so keeping them would gate (or - * force-feed) PTYs no live renderer party asked about. Runtime-owned marks - * survive: no renderer party exists to re-mark them. Drop memory is - * preserved — surviving daemon/SSH PTYs may have dropped bytes the old - * renderer never restored; the new renderer's first hidden/visible sync - * re-marks or unmarks and the unmark path re-emits the restore marker. */ +/** Reload clears renderer-owned marks/interest; runtime marks and unrestored drop episodes must survive. */ export function resetRendererScopedHiddenPtyDeliveryState(): void { hiddenRendererPtys.clear() deliveryInterestRendererPtys.clear() @@ -165,8 +126,6 @@ export function resetRendererScopedHiddenPtyDeliveryState(): void { } } -/** Full per-PTY teardown — wired into clearProviderPtyState so every exit - * path (local, daemon, SSH, connection teardown) releases gate state. */ export function clearHiddenRendererPtyDeliveryState(id: string): void { hiddenRendererPtys.delete(id) runtimeOwnedHiddenRendererPtys.delete(id) @@ -195,7 +154,6 @@ export function resetHiddenRendererPtyDeliveryDebugCounters(): void { droppedHiddenDeliveryChunks = 0 } -/** Test seam: reset all module state between tests. */ export function _resetHiddenRendererPtyDeliveryGateForTest(): void { hiddenRendererPtys.clear() runtimeOwnedHiddenRendererPtys.clear() diff --git a/src/main/ipc/pty-host-bindings.ts b/src/main/ipc/pty-host-bindings.ts index e1eb0dde552..835ff2ab1e4 100644 --- a/src/main/ipc/pty-host-bindings.ts +++ b/src/main/ipc/pty-host-bindings.ts @@ -1,24 +1,8 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' -/** - * The host facilities the PTY handlers register against. - * - * Why injected rather than imported: `registerPtyHandlers` owns the PTY controller - * that `terminal.create` actually spawns through, and a Node-only backend needs that - * controller. Everything else in the module is already host-agnostic — the only thing - * pinning it to Electron was a static `ipcMain` / `powerMonitor` import used purely to - * register renderer handlers that no headless host will ever receive. - * - * The desktop passes the real Electron objects. A headless host passes nothing and - * gets no-ops, which is honest: there is no renderer to answer, so registering is a - * no-op rather than a lie about having registered. - */ +/** Inject desktop surfaces so the same PTY controller can also serve Node-only hosts. */ -/** - * Deliberately `any[]` on the rest args, matching Electron's own `IpcMain` signature: - * a narrower type here would not accept the real object, and widening at the call site - * would need a cast that hides genuine mismatches. - */ +/** Match Electron's any[] listener arguments so the real IPC surface remains assignable without casts. */ export type PtyIpcSurface = { handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => unknown): void on(channel: string, listener: (event: IpcMainEvent, ...args: any[]) => void): void @@ -30,7 +14,6 @@ export type PtyPowerSurface = { on(event: 'suspend' | 'resume', listener: () => void): void } -/** Why not optional-chaining at 75 call sites: one object keeps the call sites unchanged. */ export const noopPtyIpcSurface: PtyIpcSurface = { handle: () => {}, on: () => {}, @@ -45,12 +28,7 @@ export const noopPtyPowerSurface: PtyPowerSurface = { let currentIpc: PtyIpcSurface = noopPtyIpcSurface let currentPower: PtyPowerSurface = noopPtyPowerSurface -/** - * Install the host surfaces once at startup. Defaults are no-ops rather than a throw, - * unlike AppEnvironment/SecretStore: a host with no renderer legitimately has nothing to - * register against, and silently not registering handlers nobody can call is correct - * rather than a hidden downgrade. - */ +/** Headless hosts have no renderer; omitted IPC/power surfaces intentionally use no-op registration. */ export function setPtyHostBindings(bindings: { ipc?: PtyIpcSurface power?: PtyPowerSurface diff --git a/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts b/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts index c2068320dde..aff863f72e7 100644 --- a/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts +++ b/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts @@ -14,7 +14,6 @@ export type DaemonSpawnCall = { terminalWindowsPowerShellImplementation?: string } -/** Daemon-backed provider doubles for the "daemon-active provider" spawn-env suites. */ export function createDaemonActiveProviderFixtures(ctx: { handlers: IpcHandlerMap mainWindow: unknown @@ -104,7 +103,6 @@ export function createDaemonActiveProviderFixtures(ctx: { httpProxyBypassRules?: string }, processEnvOverrides?: Record, - // Why: daemon spawn tests exercise both WSL launch metadata from main and PR #2662 command threading for OMP. spawnArgs?: { cwd?: string worktreeId?: string diff --git a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts index 03be77d113d..edbffc61459 100644 --- a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts @@ -103,7 +103,6 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow as never, nextRuntime as never) nextRuntime.registerRawTerminalViewSubscriber(result.id) - // Repeated presence signals must remain deduplicated. nextRuntime.onRemoteTerminalViewPresenceChanged?.(result.id) expect(daemon.setPtyBackgrounded).toHaveBeenCalledOnce() @@ -138,7 +137,6 @@ describe('registerPtyHandlers', () => { daemon.emitData(result.id, 'hidden output') vi.advanceTimersByTime(50) - // Model ingestion still ran — only renderer delivery was dropped. expect(runtime.onPtyData).toHaveBeenCalledWith( result.id, 'hidden output', @@ -147,7 +145,7 @@ describe('registerPtyHandlers', () => { undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) - // Why out-of-band: an in-band empty pty:data chunk is ambiguous with chunks fully consumed by renderer OSC-9999 stripping. + // In-band empty data also occurs after OSC-9999 stripping; restore markers need a distinct channel. expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { id: result.id, hiddenDropEpisode: expect.any(Number), @@ -155,7 +153,6 @@ describe('registerPtyHandlers', () => { markerSeq: 42 }) - // Subsequent gated chunks drop silently — the marker is one-shot. daemon.emitData(result.id, 'more hidden output') vi.advanceTimersByTime(50) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) @@ -173,7 +170,6 @@ describe('registerPtyHandlers', () => { } }) it('surfaces the hidden-yet-visible contradiction in the snapshot and warns on drop', async () => { - // Why: field snapshot v1.4.124-rc.2.perf — aggregates couldn't tell if the visible pane was hidden-gated; overlap counter + warn makes it decisive. vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const daemon = installObservableDaemonTestProvider() @@ -187,7 +183,6 @@ describe('registerPtyHandlers', () => { const setHidden = getPtySetHiddenRendererPtyListener() const setVisible = getPtySetRendererPtyVisibleListener() - // The two visibility signals contradict: pane reports visible while the hidden-delivery gate still holds it. setVisible(null, { id: result.id, visible: true }) setHidden(null, { id: result.id, hidden: true }) daemon.emitData(result.id, 'starved visible output') @@ -203,7 +198,6 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ id: redactPtyIdForDiagnostics(result.id), visible: true }) ) - // Unhiding resolves the contradiction. setHidden(null, { id: result.id, hidden: false }) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ hiddenDeliveryGatedPtyCount: 0, @@ -245,7 +239,7 @@ describe('registerPtyHandlers', () => { inFlightChars: 0, pendingChars: 0 }) - // Why redaction is pinned: daemon session ids embed worktree paths, so the report must never carry the raw id. + // Daemon session IDs contain worktree paths; diagnostics must redact them. expect(diagnostics.perPty.some((candidate) => candidate.id === result.id)).toBe(false) const breadcrumbKinds = diagnostics.breadcrumbs.map((crumb) => crumb.kind) expect(breadcrumbKinds).toContain('gate-mark') @@ -476,7 +470,6 @@ describe('registerPtyHandlers', () => { expect(mainWindow.webContents.send).not.toHaveBeenCalled() setHidden(null, { id: spawnResult.id, hidden: true }) - // The queued bytes are model-owned; only the restore marker goes out. expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { id: spawnResult.id, @@ -510,7 +503,7 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) - // Why: a renderer reload can replace the view that latched restore-needed; unhide repeats the marker so the live view heals. + // Reload can replace the view that missed the marker; reveal must repeat it for the live view. setHidden(null, { id: spawnResult.id, hidden: false }) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { @@ -600,7 +593,7 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) - // Why: a hidden remount (tab move, parking handoff) re-marks without an unhide, so re-marking must NOT erase drop memory. + // Hidden remounts re-mark without revealing; preserve the unrestored gap. setHidden(null, { id: spawnResult.id, hidden: true }) setHidden(null, { id: spawnResult.id, hidden: false }) @@ -631,7 +624,7 @@ describe('registerPtyHandlers', () => { const daemon = installObservableDaemonTestProvider() try { registerPtyHandlers(mainWindow as never, runtime as never) - // Why daemon provider: survives reloads and keeps orphan-kill off this webContents, so 'did-finish-load' means gate reset only. + // Daemon PTYs survive reload; without local orphan cleanup this listener tests only gate reset. const reloadHandlers = mainWindow.webContents.on.mock.calls .filter((call: unknown[]) => call[0] === 'did-finish-load') .map((call: unknown[]) => call[1] as () => void) @@ -649,13 +642,12 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(50) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) - // Renderer reload: hidden marks die with the old renderer, but dropped bytes were never restored — memory must survive. + // Reload must preserve dropped bytes the old renderer never restored. reloadHandlers[0]() expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ hiddenDeliveryGatedPtyCount: 0 }) - // The reloaded pane's first sync re-marks hidden, then reveals. setHidden(null, { id: result.id, hidden: true }) setHidden(null, { id: result.id, hidden: false }) expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { @@ -698,7 +690,6 @@ describe('registerPtyHandlers', () => { const setInterest = getPtySetDeliveryInterestListener() mainWindow.webContents.send.mockClear() - // A sidecar holds interest, so hidden bytes still flow. setInterest(null, { id: result.id, interested: true }) setHidden(null, { id: result.id, hidden: true }) daemon.emitData(result.id, 'sidecar bytes') @@ -708,7 +699,7 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ id: result.id, data: 'sidecar bytes' }) ) - // Why: the renderer reload killed the sidecar's ref count without a release IPC — the leaked hold must not force-feed the PTY forever. + // Reload kills sidecar references without release IPC; clear their holds to avoid permanent force-feeding. reloadHandlers[0]() mainWindow.webContents.send.mockClear() setHidden(null, { id: result.id, hidden: true }) diff --git a/src/main/ipc/pty-ipc-hidden-restore-ack.test.ts b/src/main/ipc/pty-ipc-hidden-restore-ack.test.ts index bf1a560a141..2fe9c9f3978 100644 --- a/src/main/ipc/pty-ipc-hidden-restore-ack.test.ts +++ b/src/main/ipc/pty-ipc-hidden-restore-ack.test.ts @@ -2,12 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers } from './pty/register-handlers' -// STA-4869 — main's hidden-delivery drop latch retires on the renderer's -// applied-ack, never on serving the snapshot. Serving proves only that main -// answered: the pane can dispose across the multi-MB serialize + IPC round trip -// (tab/split/worktree switch, renderer reload), and a snapshot nobody painted -// heals nothing. Anything that retires the latch earlier spends the last -// recovery signal on a marker no view will ever receive. +// Retire drop memory only after a painted-restore ACK; a pane can disappear after requesting its snapshot (STA-4869). vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -83,7 +78,6 @@ describe('registerPtyHandlers hidden-output restore ack', () => { } } - /** Registers the handlers against the suite's mock window and spawns one daemon PTY. */ async function registerAndSpawnDaemonPty(runtime: Record): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's mock window and this runtime stub implement every member the spawn path touches. registerPtyHandlers(mainWindow as never, runtime as never) @@ -153,9 +147,7 @@ describe('registerPtyHandlers hidden-output restore ack', () => { const getSnapshot = handlers.get('pty:getMainBufferSnapshot')! const ackApplied = getPtyHiddenOutputRestoreAppliedListener() - // A pane retiring while hidden unhides after unregistering its marker - // handler, so the first unhide marker reaches nobody. The replacement's - // own unhide must still be able to ask for the restore. + // A retiring view may miss the first reveal marker; the replacement must still be able to request restore. setHidden(null, { id, hidden: false }) setHidden(null, { id, hidden: false }) expect(restoreMarkers()).toBe(3) @@ -166,13 +158,11 @@ describe('registerPtyHandlers hidden-output restore ack', () => { markerSeq: 42 }) - // Serving the snapshot proves only that main answered — the asking pane - // can still die before it paints. + // Serving a snapshot does not prove the requesting pane survived to paint it. await getSnapshot(null, { id }) setHidden(null, { id, hidden: false }) expect(restoreMarkers()).toBe(4) - // The ack says the bytes are on screen: no marker storm afterwards. ackApplied(null, { id, hiddenDropEpisode: lastMarkerEpisode() }) setHidden(null, { id, hidden: false }) setHidden(null, { id, hidden: false }) @@ -201,7 +191,6 @@ describe('registerPtyHandlers hidden-output restore ack', () => { daemon.emitData(result.id, 'hidden again') vi.advanceTimersByTime(50) - // hidden-drop, unhide, then the fresh episode's hidden-drop. expect( mainWindow.webContents.send.mock.calls.filter( (call: unknown[]) => call[0] === 'pty:modelRestoreNeeded' @@ -218,7 +207,6 @@ describe('registerPtyHandlers hidden-output restore ack', () => { const { id, setHidden, restoreMarkers } = await armDropLatch() const ackApplied = getPtyHiddenOutputRestoreAppliedListener() - // Another pane's ack must never retire this PTY's drop memory. ackApplied(null, { id: 'some-other-pty' }) ackApplied(null, {}) ackApplied(null, { id: '' }) @@ -226,7 +214,7 @@ describe('registerPtyHandlers hidden-output restore ack', () => { setHidden(null, { id, hidden: false }) expect(restoreMarkers()).toBe(2) - // An ack naming no episode, or a stale one, cannot prove it covered this gap. + // Missing/stale episode IDs cannot prove this gap was restored. ackApplied(null, { id }) ackApplied(null, { id, hiddenDropEpisode: lastMarkerEpisode() - 1 }) setHidden(null, { id, hidden: false }) @@ -256,8 +244,7 @@ describe('registerPtyHandlers hidden-output restore ack', () => { daemon.emitData(result.id, 'hidden output') vi.advanceTimersByTime(50) - // Why: a refused snapshot painted nothing, so the next reveal still owes - // the renderer a restore signal. + // A refused snapshot painted nothing; the next reveal still needs a restore signal. expect( await handlers.get('pty:getMainBufferSnapshot')!(null, { id: result.id diff --git a/src/main/ipc/pty-ipc-listener-accessors.ts b/src/main/ipc/pty-ipc-listener-accessors.ts index ee1340f19bb..74237482ab7 100644 --- a/src/main/ipc/pty-ipc-listener-accessors.ts +++ b/src/main/ipc/pty-ipc-listener-accessors.ts @@ -5,7 +5,6 @@ type MainWindowDouble = { webContents: { on: { mock: { calls: unknown[][] } }; send: { mock: { calls: unknown[][] } } } } -/** Accessors for the ipcMain listeners and renderer sends the pty IPC suites assert against. */ export function createPtyIpcListenerAccessors(ctx: { handlers: IpcHandlerMap mainWindow: MainWindowDouble @@ -67,7 +66,7 @@ export function createPtyIpcListenerAccessors(ctx: { throw new Error('missing pty:rendererDispatcherReady listener') } const listener = readyCall[1] as (event: unknown) => void - // Why: the production handler sender-guards its destructive reconcile, so tests must present as the main window. + // Destructive reconcile accepts only the main-window sender. return () => listener(mainWindowIpcEvent) } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index cd82bcc7129..703c154f1aa 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -3,9 +3,7 @@ import type { Mock } from 'vitest' import type * as Wsl from '../wsl' import type * as FsPromises from 'node:fs/promises' -// Why: every split pty IPC suite must mock the same module graph; the vi.mock calls stay -// per-file (hoisting is per-file) but delegate their factories here so the vi.fn instances -// are one shared set the tests can assert on. +// vi.mock hoists per file; shared factories keep every split suite on the same spy instances. export const handleMock: Mock = vi.fn() export const onMock: Mock = vi.fn() export const removeHandlerMock: Mock = vi.fn() @@ -58,7 +56,7 @@ export type ElectronModuleMock = { } export const electronModuleMock = (): ElectronModuleMock => ({ - // Why defined-but-undefined: the real OrcaRuntimeService guards BrowserWindow with `?.`; vitest throws on reading exports the mock omits. + // Vitest throws for omitted exports even when production uses optional chaining. BrowserWindow: undefined, app: { isPackaged: true, @@ -100,7 +98,7 @@ export const nodePtyModuleMock = () => ({ spawn: spawnMock }) -// Why: these suites force darwin on non-macOS hosts; isolate the PAM probe while preserving other child_process APIs. +// Suites force darwin on other hosts; isolate PAM probing while retaining other process APIs. export const childProcessModuleMock = (original: Record) => ({ ...original, execFile: loginPreflightExecFileMock @@ -115,8 +113,7 @@ export const openCodeHookServiceModuleMock = () => ({ refreshLegacySharedPlugin: vi.fn<() => void>(), clearPty: openCodeClearPtyMock }, - // Separate mock per variant: assembly.ts picks the service by variant, and a shared - // mock would hide a regression that hands an OpenCode 2 pane the v1 plugin. + // Independent variant spies expose accidental v1 plugin injection into OpenCode 2 panes. openCode2HookService: { buildPtyEnv: openCode2BuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), @@ -151,7 +148,7 @@ export const pwshModuleMock = () => ({ isPwshAvailableAsync: isPwshAvailableMock }) -// Why: async stats read the same fake filesystem the suites stage through existsSync/statSync. +// Async stats must observe the same fake filesystem as sync stats. export const fsPromisesModuleMock = (original: typeof FsPromises) => ({ ...original, stat: async (path: string) => { @@ -175,7 +172,7 @@ export const classifyErrorModuleMock = () => ({ classifyError: classifyErrorMock }) -// Why: the real ensure writes to process.resourcesPath (absent under vitest); env assembly only needs the returned dir path. +// Avoid writes to resourcesPath; environment assembly needs only the returned shim directory. export const linuxCliShimModuleMock = () => ({ ensureLinuxTerminalOrcaCliShimDir: linuxCliShimMock }) diff --git a/src/main/ipc/pty-ipc-process-env-scope.ts b/src/main/ipc/pty-ipc-process-env-scope.ts index 2f6e6bd7c40..2dd6e01754b 100644 --- a/src/main/ipc/pty-ipc-process-env-scope.ts +++ b/src/main/ipc/pty-ipc-process-env-scope.ts @@ -1,5 +1,4 @@ -// Why: the pty IPC suites force darwin and rewrite a dozen agent-home env vars per test; -// this scope captures the real values once and puts them back afterwards. +// Split suites rewrite platform and agent-home environment; capture and restore the host values. export function createPtyIpcProcessEnvScope() { const savedOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR @@ -20,12 +19,12 @@ export function createPtyIpcProcessEnvScope() { const savedOrcaUserDataPath = process.env.ORCA_USER_DATA_PATH function applyTestEnvDefaults() { - // Why: most PTY spawn tests assert POSIX shell behavior; Windows cases opt into win32 explicitly below. + // POSIX is the default fixture; Windows cases explicitly opt in. Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - // Why: forced darwin makes the TCC login(1) wrapper rewrite every asserted argv; its own test below re-enables it. + // Disable the TCC argv wrapper except in its dedicated tests. process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1' delete process.env.OPENCODE_CONFIG_DIR delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR diff --git a/src/main/ipc/pty-ipc-provider-fixtures.ts b/src/main/ipc/pty-ipc-provider-fixtures.ts index b0cf76d3a57..babd7e18796 100644 --- a/src/main/ipc/pty-ipc-provider-fixtures.ts +++ b/src/main/ipc/pty-ipc-provider-fixtures.ts @@ -5,14 +5,12 @@ import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' import { makeDisposable } from './pty-ipc-test-constants' -/** The node-pty process double plus the emitters that drive its registered handlers. */ export type MockPtyProcess = { proc: { onData: Mock; onExit: Mock; write: Mock; resize: Mock; kill: Mock } emitData: (data: string) => void emitExit: (exitCode?: number) => void } -/** Daemon provider double whose stream handlers stay reachable through emitters. */ export type ObservableDaemonProviderDouble = { spawn: Mock write: Mock @@ -26,7 +24,6 @@ export type ObservableDaemonProviderDouble = { emitDataGap: (id: string, droppedChars: number) => void } -/** Provider double used by agent-claim/ownership suites; every member is a bare spy. */ export type AgentClaimProviderDouble = Record< | 'spawn' | 'write' @@ -53,7 +50,6 @@ export type AgentClaimProviderDouble = Record< Mock > -/** Provider doubles + the runtime-controller registration shared by every pty IPC suite file. */ export function createPtyIpcProviderFixtures(ctx: { mainWindow: unknown }) { const { mainWindow } = ctx function createMockProc(): MockPtyProcess { diff --git a/src/main/ipc/pty-ipc-spawn-drivers.ts b/src/main/ipc/pty-ipc-spawn-drivers.ts index 1043bd4551e..3981f83b018 100644 --- a/src/main/ipc/pty-ipc-spawn-drivers.ts +++ b/src/main/ipc/pty-ipc-spawn-drivers.ts @@ -6,7 +6,6 @@ import { BUNDLED_CLI_PATH, BUNDLED_RESOURCES_PATH } from './pty-ipc-test-constan type IpcHandlerMap = Map unknown> -/** The mocked BrowserWindow the suites hand these drivers; only webContents is exercised. */ type MockMainWindow = { webContents: { on: ReturnType @@ -15,14 +14,12 @@ type MockMainWindow = { } } -/** pty:spawn drivers shared by the split pty IPC suites. */ export function createPtyIpcSpawnDrivers(ctx: { handlers: IpcHandlerMap mainWindow: MockMainWindow createMockProc: () => { emitData: (data: string) => void } }) { const { handlers, mainWindow, createMockProc } = ctx - /** Helper: trigger pty:spawn and return the env passed to node-pty. */ async function spawnAndGetEnv( argsEnv?: Record, processEnvOverrides?: Record, @@ -37,7 +34,6 @@ export function createPtyIpcSpawnDrivers(ctx: { httpProxyUrl?: string httpProxyBypassRules?: string }, - // Why: PR #2662 finding 2 — accept an optional `command` so callers can exercise OMP target resolution (was untested). command?: string, launchAgent?: TuiAgent, cwd?: string, @@ -56,7 +52,6 @@ export function createPtyIpcSpawnDrivers(ctx: { } try { - // Clear previously registered handlers so re-registration doesn't accumulate stale state. handlers.clear() registerPtyHandlers( mainWindow as never, @@ -105,8 +100,7 @@ export function createPtyIpcSpawnDrivers(ctx: { ] } - // Why: the Codex launch preflight now carries the bundled CLI's verified absolute - // path, so these cases need a resources root whose launcher passes the exec check. + // Codex preflight needs a bundled launcher that passes the executable check. async function withBundledCli( run: () => Promise, options?: { launcherExecutable?: boolean } @@ -117,8 +111,7 @@ export function createPtyIpcSpawnDrivers(ctx: { configurable: true, value: BUNDLED_RESOURCES_PATH }) - // Why: only teach the launcher path to look like an executable file; every other - // stat/access keeps the permissive default the rest of the harness relies on. + // Make only the launcher executable; preserve permissive stat/access defaults for other fixtures. statSyncMock.mockImplementation((target: string) => ({ isDirectory: () => target !== BUNDLED_CLI_PATH, isFile: () => target === BUNDLED_CLI_PATH, diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 0e8f607c5f1..9974cc83d80 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -56,19 +56,15 @@ import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-deliver import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { _resetWslCachesForTests } from '../wsl' -/** The mocked webContents each suite asserts sends against. */ export type PtyIpcTestWebContents = { id: number on: Mock send: Mock removeListener: Mock - // Why real Electron has this: webContents can be destroyed a beat before its BrowserWindow - // during close, so renderer-liveness guards check both. Omitting it here let those guards - // pass vacuously in every suite (STA-2373 / STA-5373). + // WebContents may die before its window; model that liveness check explicitly (STA-2373, STA-5373). isDestroyed: Mock } -/** The mocked BrowserWindow handed to registerPtyHandlers. */ export type PtyIpcTestMainWindow = { isDestroyed: () => boolean isFocused: () => boolean @@ -84,7 +80,6 @@ export type PtyIpcSuiteEnvironment = { foreignWindowIpcEvent: { sender: PtyIpcTestWebContents } } -/** Registers the shared beforeEach/afterEach every pty IPC suite file relies on. */ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { const handlers = new Map unknown>() const mainWindow = { @@ -113,15 +108,9 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { const envScope = createPtyIpcProcessEnvScope() beforeEach(() => { - // Why here: pty.ts registers against injected surfaces now, so the mocked ipcMain - // must be installed for the shared `handlers` map to keep capturing registrations. + // Capture registrations through the injected host port. setPtyHostBindings({ ipc: testPtyIpcSurface() }) - // Why here: pty.ts reads app paths and the packaged flag through the AppEnvironment - // port now, so the shared vi.mock('electron') app object alone is inert. Back the - // port with the same mocks so every suite's existing expectations still hold. - // Why read through the electron mock instead of hardcoding: suites toggle - // `app.isPackaged` mid-test to exercise dev-mode spawn paths, so the port must - // observe the same mutable field rather than freeze a value at install time. + // Back AppEnvironment with the mutable Electron mock so mid-test isPackaged changes remain visible. const electronAppMock = ( vi.mocked(electron) as unknown as { app: { isPackaged: boolean; getPath: (name: string) => string; getVersion: () => string } @@ -180,15 +169,14 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() - // Why re-stub, not just reset: a bare mockReset returns undefined, which reads as "alive" - // by accident rather than by intent, and hides a test that forgot to restore liveness. + // Reset to explicitly alive; bare mockReset returns undefined and can hide missing liveness setup. mainWindow.webContents.isDestroyed.mockReset() mainWindow.webContents.isDestroyed.mockReturnValue(false) - // Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases. + // Module-level PTY gate state must not leak between cases. _resetHiddenRendererPtyDeliveryGateForTest() __resetShellStartupEnvCache() - // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel, catching re-registration that forgot removeHandler. + // Mirror Electron's duplicate-channel rejection to catch missing removeHandler calls. handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { if (handlers.has(channel)) { throw new Error(`Attempted to register a second handler for '${channel}'`) @@ -198,21 +186,21 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { removeHandlerMock.mockImplementation((channel: string) => { handlers.delete(channel) }) - // Why: production gates PTY sends on pty:rendererDispatcherReady; model a live page by firing the handshake as soon as it registers. + // Model a live page by firing dispatcher-ready when the listener registers. onMock.mockImplementation((channel: string, listener: (...args: unknown[]) => void) => { if (channel === 'pty:rendererDispatcherReady') { listener(mainWindowIpcEvent) - // Drain the handshake's empty flush so it can't later perturb send-timing assertions. + // Clear the handshake flush before send-timing assertions. if (vi.isFakeTimers()) { vi.advanceTimersByTime(0) } } }) getPathMock.mockReturnValue('/tmp/orca-user-data') - // Why: wrapper roots resolve from ORCA_USER_DATA_PATH; mirror the mocked userData so ZDOTDIR/wrapper assertions match. + // Wrapper roots must use the same userData path as the mocked app. process.env.ORCA_USER_DATA_PATH = '/tmp/orca-user-data' existsSyncMock.mockReturnValue(true) - // size: the shell wrapper writer verifies each generated file is non-empty. + // Generated shell wrapper validation rejects empty files. statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755, size: 1 }) readFileSyncMock.mockReturnValue('') openCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingConfigDir?: string) => ({ @@ -247,7 +235,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { ) => { const materializeDefaultHome = options?.materializeDefaultHome !== false if (kind === 'omp') { - // Why: bare shells no longer create ~/.omp; only a userData status path is set (#10196). + // Bare shells keep OMP status under userData without creating ~/.omp (#10196). if (!existingAgentDir && !materializeDefaultHome) { return { ORCA_OMP_STATUS_EXTENSION: diff --git a/src/main/ipc/pty-ipc-test-constants.ts b/src/main/ipc/pty-ipc-test-constants.ts index e3eae546ca6..5b7527de163 100644 --- a/src/main/ipc/pty-ipc-test-constants.ts +++ b/src/main/ipc/pty-ipc-test-constants.ts @@ -4,7 +4,6 @@ import { join, posix } from 'node:path' import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' -/** The narrow slice of vitest's test API these suites use; keeps `it`/`it.skip` interchangeable. */ export type PlatformGatedTest = ( name: string, fn: () => void | Promise, @@ -15,22 +14,20 @@ export const isWindowsHost = process.platform === 'win32' export const posixOnlyIt: PlatformGatedTest = isWindowsHost ? it.skip : it export const TEST_MANAGED_ROOT = isWindowsHost ? 'C:\\managed' : '/managed' export const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources') -// Why: this suite forces darwin before every test, including on Linux CI. +// Split suites force darwin even on Linux CI. export const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string -// Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196). +// Bare shells keep OMP status under userData without creating ~/.omp (#10196). export const expectedOmpStatusExtension = posix.join( '/tmp/orca-user-data', 'omp-managed-status-extension', 'orca-agent-status.ts' ) -// Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161). +// ConPTY needs a resolved PowerShell path; a bare name can fail with CreateProcessW error 5 (#5161). export const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' export const RESOLVED_PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' -// Why: default spawn cwd in the Windows UTF-8 suite is USERPROFILE; derive shell -// args from the production resolver so expectations stay in lockstep when the -// PowerShell bootstrap grows (e.g. cwd restore after profiles load). +// Use the production resolver to include cwd restore and any PowerShell bootstrap additions. export const DEFAULT_WINDOWS_PTY_CWD = 'C:\\Users\\test' export function powerShellOsc133ArgsForCwd(cwd: string = DEFAULT_WINDOWS_PTY_CWD): string[] { return resolveWindowsShellLaunchArgs(RESOLVED_WINDOWS_POWERSHELL, cwd, cwd).shellArgs @@ -50,7 +47,6 @@ export const TEST_CODEX_AUTH_JSON = JSON.stringify({ last_refresh: '2026-07-31T00:00:00Z' }) -/** What node-pty's onData/onExit registrations hand back. */ export type MockDisposable = { dispose: Mock } export function makeDisposable(): MockDisposable { diff --git a/src/main/ipc/pty-ipc-test-harness.ts b/src/main/ipc/pty-ipc-test-harness.ts index d5bdceceb48..b32671098ac 100644 --- a/src/main/ipc/pty-ipc-test-harness.ts +++ b/src/main/ipc/pty-ipc-test-harness.ts @@ -3,16 +3,11 @@ import { createPtyIpcProviderFixtures } from './pty-ipc-provider-fixtures' import { createPtyIpcListenerAccessors } from './pty-ipc-listener-accessors' import { createPtyIpcSpawnDrivers } from './pty-ipc-spawn-drivers' -/** Everything a split pty IPC suite file gets from one `setupPtyIpcSuite()` call. */ export type PtyIpcSuiteFixtures = ReturnType & ReturnType & ReturnType & ReturnType -/** - * One call per split pty IPC suite file: registers the shared hooks and hands back every - * fixture the moved test bodies reference by bare name. - */ export function setupPtyIpcSuite(): PtyIpcSuiteFixtures { const environment = createPtyIpcSuiteEnvironment() const providers = createPtyIpcProviderFixtures({ mainWindow: environment.mainWindow }) diff --git a/src/main/ipc/pty-ipc-test-surface.ts b/src/main/ipc/pty-ipc-test-surface.ts index 12f21e8fb37..a16f307f452 100644 --- a/src/main/ipc/pty-ipc-test-surface.ts +++ b/src/main/ipc/pty-ipc-test-surface.ts @@ -6,11 +6,7 @@ import { removeHandlerMock } from './pty-ipc-mock-registry' -/** - * The registration surface pty suites drive. Production injects Electron's `ipcMain`; - * suites inject this so the existing `handlers` map keeps capturing registrations - * exactly as it did when the module imported `ipcMain` directly. - */ +/** Inject the same registration map used by split PTY suites. */ export function testPtyIpcSurface(): PtyIpcSurface { return { handle: handleMock as unknown as PtyIpcSurface['handle'], diff --git a/src/main/ipc/pty-launch-parity-caller.test-cases.ts b/src/main/ipc/pty-launch-parity-caller.test-cases.ts index 92064cf1b20..7fca5cf5b9e 100644 --- a/src/main/ipc/pty-launch-parity-caller.test-cases.ts +++ b/src/main/ipc/pty-launch-parity-caller.test-cases.ts @@ -1,5 +1,3 @@ -// Test-only: host-lane parity cases whose caller already built the launch (row 8, run verbatim), -// or sends a paired host its intent (row 7) or a model pick (row 4). import { POSIX_PATH, WIN_PATH } from '../../shared/launch-parity-window-cases.test-fixture' import { AGENT_PHONE, @@ -30,9 +28,7 @@ const rpc = ( const POSIX_CLAUDE = "claude '--dangerously-skip-permissions'" const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" -// Row 7: the terminal.createAgentSession params a paired desktop's new tab sends a current host. -// main today: command, env, launchConfig and startupCommandDelivery never reach the host; agentArgs -// is the window's default args. +// Paired new-tab requests carry intent; host planning determines command and environment. const PAIRED_TAB_PATH = '/worktree' const PAIRED_TAB = { worktree: `id:repo-1::${PAIRED_TAB_PATH}`, @@ -68,8 +64,7 @@ const PAIRED_TAB_CASES: [string, Record, HostProviderFacts][] = ] ] -// createAgentSession plans with the host planner. main today types every prompt on the line, -// even one too long for that planner's carry rule, and starts a stdin-prompt agent clean. +// Preserve host prompt-carry behavior for long prompts and stdin-prompt agents. const LONG_PROMPT = 'review the change on this branch and list what breaks. '.repeat(10).trim() const POWERSHELL = { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null } type PairedPlanCase = { @@ -127,7 +122,6 @@ export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ phone: { title: 'Terminal', launchAgent: c.agent, isActive: false } } }), - // C1 changes the host planner; these are its only draft (--prefill) and startupCwd-from-intent rows. ...PAIRED_TAB_CASES.map(([name, params, provider]): HostLaunchCase => ({ name: `paired new tab: ${name}`, row: '7', @@ -135,7 +129,7 @@ export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ workspace: repo(PAIRED_TAB_PATH), call: rpc('terminal.createAgentSession', params), provider: { ...provider, ...ZSH }, - // main today: a paired client's launch_source never reaches the host. + // The paired client's launch_source is not forwarded to the host. telemetry: { agent_kind: params.agent === 'claude' ? 'claude-code' : String(params.agent), launch_source: 'unknown', @@ -144,8 +138,6 @@ export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ phone: { title: 'Terminal', launchAgent: String(params.agent), isActive: false } })), { - // C4/C5: worktree-remote.ts spawnLocalStartupAndSetupTerminals runs the caller's command and env - // without re-quoting them, and keeps telemetry. name: 'local-git worktrees:create startup, verbatim', row: '8', os: 'linux', @@ -175,7 +167,7 @@ export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ telemetry: NEW_WORKSPACE, phone: { ...AGENT_PHONE, launchAgent: 'codex' } }, - // C2 keeps model validation on the host while moving the spawn to the pane; the command must survive. + // Seeded model-pick commands must survive host validation and pane spawning. ...( [ ['macOS repo', 'darwin', POSIX_PATH, "opencode '--standalone'", ZSH], @@ -187,7 +179,6 @@ export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ os, workspace: repo(path), settings: CMD, - // spawnIpcPty's terminal.createAgentSession for a seeded model-pick pane. call: rpc('terminal.createAgentSession', { worktree: `id:repo-1::${path}`, agent: 'opencode', @@ -199,7 +190,7 @@ export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ ...(path === WIN_PATH ? {} : { terminalKittyKeyboardProtocol: true }) }), provider: { command, ...shell }, - // main today: the pane's own launch_source never reaches the host. + // The pane's launch_source is not forwarded to the host. telemetry: { agent_kind: 'opencode', launch_source: 'unknown', request_kind: 'new' }, phone: { title: 'Terminal', launchAgent: 'opencode', isActive: false } })) diff --git a/src/main/ipc/pty-launch-parity-host.test-cases.ts b/src/main/ipc/pty-launch-parity-host.test-cases.ts index 2b20115f26e..a63f842c767 100644 --- a/src/main/ipc/pty-launch-parity-host.test-cases.ts +++ b/src/main/ipc/pty-launch-parity-host.test-cases.ts @@ -1,5 +1,3 @@ -// Test-only: the host-lane launch parity table (rows 3, 4, 7, 8, 10). Each case is the call a host -// producer makes and the facts main hands the provider, the phone and telemetry for it. import type { TerminalCreateOptions } from '../runtime/runtime-terminal-contracts' import type { LaunchWorkspace } from '../../shared/launch-parity-window-request.test-fixture' import { POSIX_PATH, WSL_PATH } from '../../shared/launch-parity-window-cases.test-fixture' @@ -44,7 +42,6 @@ export type HostLaunchCase = { phone: { title: string; launchAgent?: string; isActive: boolean } } -// rpc/methods/agent-launch-surfaces.ts createTerminalAgent options for an AI button's reserved pane. const agentLaunch = (extra: Record = {}): HostLaunchCall => ({ kind: 'create', options: { @@ -58,7 +55,6 @@ const agentLaunch = (extra: Record = {}): HostLaunchCall => ({ ...extra } }) -// worker-topology.ts createExistingWorktreeWorkerTerminal options. const worker: HostLaunchCall = { kind: 'create', options: { @@ -71,8 +67,7 @@ const worker: HostLaunchCall = { export const repo = (path: string): LaunchWorkspace => ({ kind: 'repo', path }) const folder = (path: string): LaunchWorkspace => ({ kind: 'folder', path }) -// The same factory for a mobile quick command (agent.launchReplay with a reserved pane and a -// prompt): a paired phone never moves the host window. +// Mobile replay reserves a pane without moving the host window. const mobileQuickCommand = (startupPrompt: string): HostLaunchCall => ({ kind: 'create', options: { @@ -87,25 +82,22 @@ const mobileQuickCommand = (startupPrompt: string): HostLaunchCall => ({ } }) const TASK_PAGE = { agent_kind: 'claude-code', launch_source: 'task_page', request_kind: 'new' } -// The AI button that reaches each workspace kind: fix checks needs a repo with a PR (task_page); -// in a folder, review notes' "New agent" (also on browser and markdown annotations, notes_send). +// Repo AI actions use task_page; folder review-note actions use notes_send. const aiButtonSource = (workspace: LaunchWorkspace): string => workspace.kind === 'repo' ? 'task_page' : 'notes_send' const QUICK_COMMAND = { agent_kind: 'codex', launch_source: 'quick_command', request_kind: 'new' } export const AGENT_PHONE = { title: 'Terminal', launchAgent: 'claude', isActive: true } const POSIX_CLAUDE = "claude '--dangerously-skip-permissions'" -// A local POSIX host hands the provider the settings shell as stored, which settings load trims. export const ZSH = { shellOverride: '/bin/zsh', terminalWindowsWslDistro: null } export const CMD_EXE = { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null } export const CMD = { terminalWindowsShell: 'cmd.exe' } -// A saved default argument with an apostrophe: the byte that tells POSIX and PowerShell quoting apart. +// Apostrophes distinguish POSIX quoting from PowerShell quoting. const BOB_ARGS = { agentDefaultArgs: { claude: `--dangerously-skip-permissions --name "Bob's box"` } } const BOB_POSIX = `${POSIX_CLAUDE} '--name' 'Bob'"'"'s box'` const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" -/** Row 3: agent.launch with no prompt; `command` + shell are what the provider gets. */ function aiButton( name: string, os: NodeJS.Platform, @@ -126,9 +118,8 @@ function aiButton( } } export const HOST_LAUNCH_CASES: HostLaunchCase[] = [ - // C1 (host planner inputs) and C2 (AI buttons onto the pane trigger) must keep these commands. aiButton('macOS repo', 'darwin', repo(POSIX_PATH), { command: POSIX_CLAUDE, ...ZSH }), - // C1: a `\\wsl$` repo with no saved runtime is quoted for Linux and runs in its distro, as in "+". + // A WSL-share repo without saved runtime must quote for Linux and run in its distro. aiButton( 'Windows wsl$ repo, no saved runtime', 'win32', @@ -144,7 +135,6 @@ export const HOST_LAUNCH_CASES: HostLaunchCase[] = [ BOB_ARGS ), { - // C1-C7 keep the host path's prompt carry. name: 'mobile quick command, macOS repo, codex prompt waits for the shell', row: '10', os: 'darwin', @@ -158,7 +148,6 @@ export const HOST_LAUNCH_CASES: HostLaunchCase[] = [ telemetry: QUICK_COMMAND, phone: { ...AGENT_PHONE, launchAgent: 'codex' } }, - // C5d/C5e (options, presentation) check host callers against these. ...[repo(POSIX_PATH), folder(POSIX_PATH)].map((workspace): HostLaunchCase => ({ name: `orchestration worker, Linux ${workspace.kind}`, row: '10', @@ -180,9 +169,8 @@ const HOST_ENV_KEYS = [ ] const FOLDER_ENV_KEYS = ['ORCA_WORKSPACE_ID', 'ORCA_PROJECT_GROUP_ID', 'ORCA_WORKSPACE_ROOT'] -/** What main hands the provider for a host-lane case. */ export function expectedHostProvider(c: HostLaunchCase): Record { - // Both lanes send the Windows shell facts only to a Windows main, the one that reads them. + // Windows shell metadata is consumed only by Windows main. const { terminalWindowsWslDistro, ...provider } = c.provider return { cwd: c.workspace.path.replaceAll('\\', '/'), @@ -193,7 +181,6 @@ export function expectedHostProvider(c: HostLaunchCase): Record env: {}, ...provider, ...(c.os === 'win32' ? { terminalWindowsWslDistro } : {}), - // Like the window lane, the host lane stamps ORCA_WORKSPACE_ID for every workspace kind. orcaEnv: c.workspace.kind === 'folder' ? [...FOLDER_ENV_KEYS, ...HOST_ENV_KEYS] diff --git a/src/main/ipc/pty-launch-parity.test-fixture.ts b/src/main/ipc/pty-launch-parity.test-fixture.ts index ab141a427fd..48a0bd53292 100644 --- a/src/main/ipc/pty-launch-parity.test-fixture.ts +++ b/src/main/ipc/pty-launch-parity.test-fixture.ts @@ -1,7 +1,5 @@ import { createPaneBindingStore } from './pty-launch-race-test-fixture' -// Test-only: one real OrcaRuntimeService wired into the real pty handlers, so both launch lanes -// reach the same fake provider. Window lane: pty:spawn. Host lane: createTerminal / createAgentSession -// through the runtime controller registerPtyHandlers installs. Suites still declare the IPC vi.mock block. +// Real runtime and IPC handlers share one fake provider; suites declare their own hoisted module mocks. import { vi } from 'vitest' import type { Mock } from 'vitest' import { registerPtyHandlers } from './pty/register-handlers' @@ -29,7 +27,6 @@ const ORIGINAL_PLATFORM = process.platform const savedEnv = new Map() let remoteSpawns = 0 -/** Main's process.platform for a case; Windows also gets the env and .exe probes its shells read. */ export function setMainPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) if (platform !== 'win32') { @@ -64,7 +61,6 @@ export function restoreMainPlatform(): void { savedEnv.clear() } -/** Settings both lanes read on main's current OS; the default shell is stored trimmed, as settings load leaves it. */ function parityMainSettings(overrides: Record = {}): Record { return { // Windows hides the default-shell setting and defaults it to ''. @@ -76,7 +72,7 @@ function parityMainSettings(overrides: Record = {}): Record runtime: OrcaRuntimeService - /** The local daemon provider's spawn mock. */ provider: Mock persistPtyBinding: Mock reveal: Mock spawnWindow: (request: Record) => Promise } -/** Wires one runtime + handlers for a workspace (its saved project runtime included). */ export function startParityLanes( suite: Pick, args: { @@ -151,7 +145,7 @@ export function startParityLanes( const bindingStore = createPaneBindingStore() const persistPtyBinding = bindingStore.bindingWrites const { repo: scopeRepo, folderWorkspace } = launchScope(args.workspace) - // A folder's contained repo is saved too, so the host can borrow its runtime if it would. + // Persist a folder's contained repo too so runtime borrowing is exercised. const repo = scopeRepo ?? (args.workspace.repoPath @@ -183,7 +177,7 @@ export function startParityLanes( if (args.spawnError) { throw args.spawnError } - // A fresh id per spawn, so one case's hidden mark never reads into the next. + // Unique IDs keep hidden marks from leaking across cases. return { id: options.sessionId ?? `remote-pty-${++remoteSpawns}`, startupCwdSource: 'request' as const @@ -241,13 +235,9 @@ const PROVIDER_FACT_KEYS = [ 'startupCommandDelivery' ] as const -// The agent and caller env keys the tables save; their values must reach the provider. const CASE_ENV_KEYS = ['A', 'CUSTOM_FLAG'] -/** - * The launch facts the plan names at the provider boundary: `orcaEnv` lists identity keys present, - * `env` the case's own env values, `hidden` whether main marked the PTY hidden before a view. - */ +/** Capture provider identity, requested environment and pre-view hidden state at the spawn boundary. */ export async function providerFacts(provider: Mock): Promise> { const options = recordOf(provider.mock.calls.at(-1)?.[0]) const env = recordOf(options.env) diff --git a/src/main/ipc/pty-launch-parity.test.ts b/src/main/ipc/pty-launch-parity.test.ts index 3700c763696..5f96ae2a790 100644 --- a/src/main/ipc/pty-launch-parity.test.ts +++ b/src/main/ipc/pty-launch-parity.test.ts @@ -107,7 +107,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => vi.mock('../codex/codex-pane-account-registry', () => import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) ) -// Why: an OpenCode v2 install with the picked model available; the probes would run the binary. +// Model an OpenCode v2 install with the selected model without running its binary. vi.mock('../managed-data-accounts/launch-environment', () => ({ applyManagedDataAccountEnvironment: vi.fn() })) @@ -121,19 +121,19 @@ vi.mock('../opencode/opencode-model-availability', () => ({ vi.mock('../opencode/opencode-launch-model-context', () => ({ probeOpenCodeLaunchModelContext: vi.fn() })) -// Why: every folder in the table exists on its host; main checks that before it spawns. +// Every fixture folder exists on its execution host. vi.mock('../project-groups/folder-workspace-path-status', async (importOriginal) => ({ ...(await importOriginal>()), getFolderWorkspacePathStatus: vi.fn(async () => ({ path: '', exists: true })) })) -// Why: no Antigravity account is selected on this machine, so the account switch before an agy launch is a no-op. +// No selected Antigravity account means launch preparation requires no account switch. vi.mock('../antigravity/native-account-launch', () => ({ prepareAntigravityAccountForLaunch: vi.fn(async () => {}) })) vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) -// Why: the OpenSSH DefaultShell registry read; only a managed SSH host's case answers it. +// Only managed Windows SSH-host cases supply OpenSSH DefaultShell. vi.mock('../../shared/openssh-default-shell', () => ({ readOpenSshDefaultShell: vi.fn(() => '') })) type WindowLaneCase = { @@ -143,7 +143,6 @@ type WindowLaneCase = { settings?: Record request: Record provider: WindowProviderFacts - /** The agent env the case saved, as the provider must receive it. */ env: Record } @@ -162,10 +161,7 @@ const WINDOW_LANE_CASES: WindowLaneCase[] = [ })) ] -// Pins main's current launch behaviour as the convergence parity baseline (rows 1, 2, 5, 6 and 8, main -// half): each table request, fed to pty:spawn, reaches the provider with these facts (the pane's -// measured size, never hidden), one agent_started, and a pane binding. -// C2 (row 1), C5a-e and C6 check the merged spawn pipeline against it. +// Exercise real window-lane spawning through provider, telemetry and pane binding. describe('window lane: pty:spawn request -> provider', () => { const suite = setupPtyIpcSuite() afterEach(() => restoreMainPlatform()) @@ -261,9 +257,7 @@ async function runHostCall(lanes: ParityLanes, c: HostLaunchCase): Promise runtime controller -> provider', () => { const suite = setupPtyIpcSuite() beforeEach(() => stubOpenCodeV2()) @@ -291,8 +285,6 @@ describe('host lane: producer call -> runtime controller -> provider', () => { const POSIX_REPO = { kind: 'repo', path: POSIX_PATH } as const const WORKER = HOST_LAUNCH_CASES.find((c) => c.name.startsWith('orchestration worker'))! -// Pins main's current launch behaviour as the convergence parity baseline (rows 2 and 10; failed -// spawn, reveal, pane identity): the facts no single table row carries. describe('launch facts outside the tables', () => { const suite = setupPtyIpcSuite() afterEach(() => restoreMainPlatform()) @@ -561,7 +553,6 @@ describe('launch facts outside the tables', () => { } }) - // C5a (one agent_error place): main today reports a failed window-lane spawn. it.each([ { lane: 'telemetry kind wins over the claude sniff', @@ -589,7 +580,7 @@ describe('launch facts outside the tables', () => { expect(trackMock.mock.calls.filter(([event]) => event === 'agent_error')).toEqual(c.calls) }) - // C5a (one execute) keeps this on both lanes; C5a-2 flips only the host's agent_error. + // Current lane difference: window failures emit agent_error; host failures do not. const FAILED_START_AGENT_ERROR: Record<'window' | 'host', unknown[]> = { window: [['agent_error', { agent_kind: 'claude-code', error_class: 'binary_not_found' }]], host: [['agent_error', { agent_kind: 'claude-code', error_class: 'binary_not_found' }]] @@ -652,7 +643,6 @@ describe('launch facts outside the tables', () => { } ) - // C5a-2: requests that join one claimed start share its failure; only the request that started it counts it. it('host lane: concurrent resumes of one Claude session count one failed start once', async () => { setMainPlatform('darwin') classifyErrorMock.mockReturnValue({ error_class: 'binary_not_found' }) @@ -700,7 +690,6 @@ describe('launch facts outside the tables', () => { expect(trackMock.mock.calls.filter(([event]) => event === 'agent_error')).toHaveLength(1) }) - // C5e (presentation merge): a worker asks the window to reveal its titled tab without activating it. it('row 10: an orchestration worker requests a titled, inactive reveal', async () => { setMainPlatform(WORKER.os) const lanes = startParityLanes(suite, { workspace: WORKER.workspace }) @@ -713,7 +702,6 @@ describe('launch facts outside the tables', () => { expect(lanes.reveal.mock.calls[0]?.[1]).not.toHaveProperty('presentation') }) - // createAgentSession on the host planner keeps this refusal when no launch can be built. it('row 7: a paired agent session with no buildable launch refuses before any spawn', async () => { setMainPlatform('linux') const lanes = startParityLanes(suite, { workspace: POSIX_REPO }) @@ -737,8 +725,6 @@ describe('launch facts outside the tables', () => { expect(lanes.provider).not.toHaveBeenCalled() }) - // C5c (pane identity built once in main): main rewrites stale pane keys from the request's tab and - // leaf; workspace keys pass. it('window lane: the provider gets the pane keys main derives, not the stale ones sent', async () => { setMainPlatform('darwin') const tabId = '55555555-5555-4555-8555-555555555555' @@ -763,7 +749,6 @@ describe('launch facts outside the tables', () => { }) const WSL_UBUNTU = { kind: 'wsl', distro: 'Ubuntu' } as const -// What the renderer sends for a WSL project (it reads a never-saved \\wsl$ path the same way). const WSL_PROJECT_SENT = { status: 'resolved', runtime: { @@ -777,8 +762,6 @@ const WSL_PROJECT_SENT = { } const LOGIN_SHELL = String.raw`C:\Program Files\PowerShell\7\pwsh.exe` -// Pins main's current spawn preflight (row 3, C5b): where the two lanes resolve a plain terminal's -// start differently today. Row 13b removes the runtime split. describe('spawn preflight: where the lanes differ today', () => { const suite = setupPtyIpcSuite() afterEach(() => { @@ -852,8 +835,7 @@ describe('spawn preflight: where the lanes differ today', () => { expect(facts.host).toMatchObject(c.host) }) - // One resolution reads it for both lanes. Only an SSH-launched orcad has this env, and it has no - // window (no pty:spawn), so the window lane never meets it in practice. + // Managed SSH hosts expose this shell environment; the shared resolver serves both lanes. it('Windows managed SSH host: both lanes open the OpenSSH login shell', async () => { setMainPlatform('win32') vi.stubEnv('ORCA_ORCAD_MANAGED_ACTIVATION_ROOT', '/activation') diff --git a/src/main/ipc/pty-launch-race-parity.test.ts b/src/main/ipc/pty-launch-race-parity.test.ts index 70141af2ccb..c503c9809a9 100644 --- a/src/main/ipc/pty-launch-race-parity.test.ts +++ b/src/main/ipc/pty-launch-race-parity.test.ts @@ -72,7 +72,7 @@ vi.mock('../codex/codex-pane-account-registry', () => vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) -// Why: the host lane's real mkdir is thread-pool I/O, so a joiner could miss flushMacrotasks' turns under load. +// Real mkdir uses thread-pool I/O and could outlast the fixture's fixed macrotask drain under load. vi.mock('../../shared/jcode-runtime-dir', async (importOriginal) => { const actual = await importOriginal() return { @@ -84,7 +84,6 @@ vi.mock('../../shared/jcode-runtime-dir', async (importOriginal) => { const mintedFor = (worktreeId: string): string[] => getHiddenRendererPtyIds().filter((id) => id.startsWith(`${worktreeId}@@`)) -// Pins main's window lane (pty:spawn) and host lane (runtime controller) as the spawn-lane merge's baseline. describe('launch race parity, both spawn lanes', () => { const suite = setupPtyIpcSuite() const { handlers, mainWindow, installDaemonTestProvider } = suite @@ -175,7 +174,6 @@ describe('launch race parity, both spawn lanes', () => { } ) - // C6 spreads joinPaneSpawn to both lanes: a slept-worktree refusal means "nothing to join". it('runtime lane: a spawn joining an automatic spawn refused for a slept worktree spawns itself', async () => { const pane = launchRacePane('race-slept-runtime', '73737373-7373-4373-8373-737373737373') let refused = false @@ -207,7 +205,6 @@ describe('launch race parity, both spawn lanes', () => { expect(spawnedAfterRefusal).toEqual([true]) }) - // C2 and C6 rely on this: createTerminal's claim makes the window wait out the refusal, then wake it. it('a window mount racing a claimed automatic spawn refused for a slept worktree spawns itself', async () => { const pane = launchRacePane('race-slept-claimed', '77777777-7777-4777-8777-777777777777') const spawn = vi.fn(async () => ({ id: 'pty-user-mount' })) @@ -236,7 +233,6 @@ describe('launch race parity, both spawn lanes', () => { }) }) - // C1b: a desktop window's restore, a phone's first view and a CLI send of one restored pane. it('starts a restored pane once for a window restore, a client first view and a CLI send', async () => { const pane = launchRacePane('race-three-starters', '73737373-7373-4373-8373-737373737373') let finishSpawn!: () => void @@ -284,7 +280,7 @@ describe('launch race parity, both spawn lanes', () => { }) describe('cleanup when a start fails', () => { - // C5d and C6 pick one: the host overwrites a cached size while building, then restores it. + // Host preparation temporarily overwrites cached size before restoring it. const CACHED_SIZE_WHILE_BUILDING: Record = { ipc: { cols: 100, rows: 30 }, runtime: { cols: 80, rows: 24 } @@ -344,7 +340,6 @@ describe('launch race parity, both spawn lanes', () => { }) }) - // C5a and C5e rely on this: a throw after the provider spawn rolls the pre-spawn mark back. it.each(LAUNCH_RACE_LANES)( '%s lane: a throw after the provider spawn rolls the pre-spawn hidden mark back', async (lane) => { @@ -372,7 +367,6 @@ describe('launch race parity, both spawn lanes', () => { } ) - // C5e relies on this: the hidden mark follows the id the provider returned. it.each(LAUNCH_RACE_LANES)( '%s lane: the hidden mark moves to the id the provider returned', async (lane) => { @@ -389,7 +383,7 @@ describe('launch race parity, both spawn lanes', () => { } ) - // C5e and C6 change one cell: the window keeps the hidden mark on a reattach; the host skips it. + // Current reattach difference: window preserves the hidden mark; host skips it. const HIDDEN_REATTACH: Record = { ipc: true, runtime: false } it.each(LAUNCH_RACE_LANES)('%s lane: a hidden-requested reattach', async (lane) => { const sessionId = `race-hidden-reattach-${lane}` @@ -411,7 +405,7 @@ describe('launch race parity, both spawn lanes', () => { onTestFinished(() => clearProviderPtyState(ptyId)) } - // C2 and C5e rely on this: a failed save discards the PTY, reports "unknown" and cancels registration. + // Failed binding saves discard the PTY and cancel registration. const SAVE_BINDING_ARGS: Record> = { ipc: {}, runtime: { persistHostSessionBinding: true } @@ -452,7 +446,7 @@ describe('launch race parity, both spawn lanes', () => { } ) - // C5e/C6: the pipeline returns an incarnation that exits mid-registration; the host adapter fails it. + // Current lane difference: host rejects an incarnation that exits during registration. const EXITED_DURING_REGISTRATION: Record = { ipc: 'resolves', runtime: 'rejects' @@ -489,7 +483,6 @@ describe('launch race parity, both spawn lanes', () => { } ) - // C5e relies on this: the model is resized to the grid only after the process is registered. it.each(LAUNCH_RACE_LANES)( '%s lane: an admitted spawn resizes the model to its grid after registering it', async (lane) => { diff --git a/src/main/ipc/pty-launch-race-test-fixture.ts b/src/main/ipc/pty-launch-race-test-fixture.ts index 607fe6a5f97..98eaa8d6818 100644 --- a/src/main/ipc/pty-launch-race-test-fixture.ts +++ b/src/main/ipc/pty-launch-race-test-fixture.ts @@ -46,7 +46,6 @@ type LaunchRaceRuntimeBase = Record< Mock > -/** The two spawn lanes: the window's `pty:spawn` IPC and the host's runtime controller. */ export type LaunchRaceLane = 'ipc' | 'runtime' export const LAUNCH_RACE_LANES: LaunchRaceLane[] = ['ipc', 'runtime'] @@ -121,7 +120,6 @@ export function createLaunchRaceRuntime> return Object.assign(base, overrides) } -/** Registers both lanes against one runtime, as main does. */ export function registerLaunchRaceLanes(args: { handlers: IpcHandlerMap mainWindow: unknown @@ -163,7 +161,6 @@ export function gatedClaudeAuth() { return { prepareClaudeAuth, release } } -/** The host's worktree spawn lock for a slept worktree: refuses only the spawns that ask it to. */ export function sleptWorktreeLock() { let refuse!: () => void const refusal = new Promise((resolve) => { @@ -181,7 +178,6 @@ export function sleptWorktreeLock() { return { acquireWorktreeTerminalSpawn, refuse } } -/** Evaluates the same queued binding input against mutable topology, recording admitted arguments. */ export function createPaneBindingStore() { let session = getDefaultWorkspaceSession() const bindingWrites = vi.fn((_binding: PersistPtyBindingArgs) => true) diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 6302ce489f7..c44797ed2e3 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -11,7 +11,7 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider, getLocalPtyProvider } from './pty/provider/registry' import { join } from 'node:path' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +// Wrapper roots are content-addressed. import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -118,7 +118,6 @@ describe('registerPtyHandlers', () => { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => { - // Simulate node-pty behavior: kill triggers onExit callback exitCb?.({ exitCode: -1 }) }), process: 'zsh', @@ -141,7 +140,7 @@ describe('registerPtyHandlers', () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() let exitCb: ((info: { exitCode: number }) => void) | undefined - // Why: hold a stable ref to the kill spy — destroyPtyProcess reassigns proc.kill to a no-op (docs/fix-pty-fd-leak.md), so reading proc.kill.mock later would crash. + // Keep the original spy: destroyPtyProcess replaces proc.kill with a no-op. const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), @@ -264,7 +263,7 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - // Why both: a reload fires the gate reset AND the orphan cleanup, so invoke every registered listener like a real did-finish-load. + // Fire all load listeners so gate reset cannot hide a missing orphan sweep. const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls .filter(([eventName]) => eventName === 'did-finish-load') .map(([, handler]) => handler as () => void) @@ -276,7 +275,7 @@ describe('registerPtyHandlers', () => { } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) - // First load after spawn only advances generation; the second sees this PTY as from a prior load and kills it as orphaned. + // First load advances generation; the second can identify the prior-load PTY as orphaned. didFinishLoad() didFinishLoad() @@ -313,7 +312,6 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - // Two listeners on the first (LocalPtyProvider) window: the renderer-gate reset and the orphan cleanup. const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' ) @@ -332,21 +330,19 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(secondWindow as never) - // Every first-window load listener was detached from its webContents. for (const [, handler] of firstWindowLoadHandlers) { expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( 'did-finish-load', handler ) } - // The non-Local provider keeps orphan cleanup off the second window — only the renderer-gate reset listener remains. expect( secondWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' ) ).toHaveLength(1) }) - // Why (#5787): a recovery reload re-fires did-finish-load; suppress the orphan sweep so live LOCAL PTYs survive until session restore re-adopts them. + // Recovery reload must preserve live local PTYs until restore re-adopts them (#5787). it('does not sweep local PTYs during a recovery reload', async () => { const killSpy = vi.fn() const proc = { @@ -377,7 +373,7 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - // Fire both did-finish-load listeners as a real reload does, else the suppression assertion passes vacuously without reaching the sweep. + // Fire all load listeners so this assertion actually reaches orphan cleanup. const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls .filter(([eventName]) => eventName === 'did-finish-load') .map(([, handler]) => handler as () => void) @@ -389,7 +385,6 @@ describe('registerPtyHandlers', () => { incarnationId: string } - // Without the guard the second load would sweep this PTY as a prior-generation orphan; under recovery-in-flight neither load may touch it. didFinishLoad() didFinishLoad() @@ -398,7 +393,6 @@ describe('registerPtyHandlers', () => { const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(true) }) - // Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { let exitCb: ((info: { exitCode: number }) => void) | undefined const killSpy = vi.fn(() => { @@ -435,7 +429,6 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - // Fire both did-finish-load listeners (gate reset + orphan sweep) as a real reload does. const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls .filter(([eventName]) => eventName === 'did-finish-load') .map(([, handler]) => handler as () => void) @@ -446,7 +439,7 @@ describe('registerPtyHandlers', () => { incarnationId: string } - // First load only advances generation; the second sees this PTY as a prior-load orphan — with the flag false the guard must NOT suppress the sweep. + // Normal second load must reclaim the prior-generation orphan. didFinishLoad() didFinishLoad() await Promise.resolve() @@ -459,7 +452,6 @@ describe('registerPtyHandlers', () => { const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(false) }) - // Why (#5787): two PTYs in different load generations must BOTH survive a recovery reload — even the older one a normal sweep would reclaim. it('keeps local PTYs from different generations alive across recovery reloads', async () => { const killSpyA = vi.fn() const killSpyB = vi.fn() @@ -481,7 +473,6 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - // Fire ALL did-finish-load listeners (gate reset + orphan sweep) as a real reload does; the sweep listener is under test. const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls .filter(([eventName]) => eventName === 'did-finish-load') .map(([, handler]) => handler as () => void) @@ -498,7 +489,7 @@ describe('registerPtyHandlers', () => { }) const ptyA = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string } - // Advance the generation without sweeping (recovery-in-flight), then spawn a second PTY so the two live in different generations. + // Recovery advances generation without sweeping; a second spawn creates mixed-generation live PTYs. didFinishLoad() spawnMock.mockReturnValue({ diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts index c75268e1109..e69b0efc35a 100644 --- a/src/main/ipc/pty-login-shell-startup-commands.test.ts +++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts @@ -11,7 +11,7 @@ import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-lo import { registerPtyHandlers } from './pty/register-handlers' import { join } from 'node:path' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +// Wrapper roots are content-addressed. import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -96,7 +96,6 @@ describe('registerPtyHandlers', () => { '/bin/zsh', '-l' ]) - // The spawn env keeps the real shell so identity/name logic is intact. expect(options.env.SHELL).toBe('/bin/zsh') } finally { resetMacosLoginShellPreflightForTests() diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 17787a3c5a0..6dfb62708eb 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' -// Mirrors DaemonFolderAccessResetResult; declared here so the mock is not typed by the module it -// replaces. +// Keep mock result types independent of the replaced module. type FolderAccessResetResult = { outcome: string mismatch?: { daemonScope: string; cwdClass: string; freshDaemonAccess: string } | null @@ -53,13 +52,7 @@ vi.mock('../daemon/daemon-provider-state', () => ({ getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock })) -// Why: the handler uses `provider instanceof DaemonPtyRouter` to branch -// between "plain adapter" and "router with current + legacy adapters". -// Mock the class here so tests can construct real instances via `new -// DaemonPtyRouter(...)` and the instanceof check returns true. The real -// router's constructor is side-effect heavy (subscribes to adapter events), -// so we only keep the accessors that pty-management touches — enough to -// satisfy the runtime type check without pulling in all the wiring. +// Preserve instanceof routing without the real router constructor's event subscriptions. vi.mock('../daemon/daemon-pty-router', () => { class DaemonPtyRouter { private allAdapters: unknown[] @@ -71,7 +64,6 @@ vi.mock('../daemon/daemon-pty-router', () => { getAllAdapters() { return this.allAdapters } - // Why: the folder-access read asks the *current* adapter for the daemon identity. getCurrentAdapter() { return this.current } @@ -82,9 +74,7 @@ vi.mock('../daemon/daemon-pty-router', () => { return { DaemonPtyRouter } }) -// Why: the handler also branches on `provider instanceof DegradedDaemonPtyProvider` -// (for getAllAdapters) and reports `degraded` from it. The real constructor -// subscribes to adapter events, so keep only the accessors pty-management uses. +// Preserve degraded-provider instanceof routing without constructor event subscriptions. vi.mock('../daemon/degraded-daemon-pty-provider', () => { class DegradedDaemonPtyProvider { private allAdapters: unknown[] @@ -156,10 +146,7 @@ function makeAdapter( sessions: DaemonSessionInfo[], shutdownImpl?: (id: string, immediate: boolean) => Promise ): MockAdapter { - // Why: collectSessions calls adapter.listSessions() (the daemon-side RPC) - // and then annotates with adapter.protocolVersion. The mock returns the - // *internal* SessionInfo shape (no protocolVersion) since the adapter adds - // it. Stripping it here mirrors production behavior. + // listSessions returns internal SessionInfo; collectSessions adds the adapter protocol version. return { protocolVersion, listSessions: vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest)), @@ -290,10 +277,7 @@ describe('pty:management IPC handlers', () => { }) describe('killAll', () => { - // Why: the handler sleeps POLL_INTERVAL_MS between listSessions polls. - // Fake timers let the tests drive that loop deterministically — without - // them, a happy-path test that converges in ≥1 poll would take 100ms+ - // of real wall time and the "refuses to die" test would take ~1s. + // Drive polling deterministically without waiting through the shutdown ladder. beforeEach(() => { vi.useFakeTimers() }) @@ -310,9 +294,7 @@ describe('pty:management IPC handlers', () => { remainingCount: number killedSessionIds: string[] }> - // Why: advance the loop's sleeps one at a time. Between each sleep the - // handler awaits collectSessions (a microtask), so we need to flush - // pending microtasks before advancing the next timer. + // Flush listSessions microtasks between polling sleeps. for (let i = 0; i < pollCount; i += 1) { await Promise.resolve() await Promise.resolve() @@ -326,9 +308,7 @@ describe('pty:management IPC handlers', () => { const legacySessions = [makeSession('old-1', { protocolVersion: 3 })] const current = makeAdapter(5, []) const legacy = makeAdapter(3, []) - // Why: shutdown removes the session from the adapter's backing list so - // the next poll observes the shrinking set — mirrors a daemon that - // actually reaped the processes. + // Model successful shutdown by removing the session from the next inventory. const removeFrom = (list: DaemonSessionInfo[], id: string): void => { const idx = list.findIndex((s) => s.sessionId === id) if (idx !== -1) { @@ -359,7 +339,6 @@ describe('pty:management IPC handlers', () => { remainingCount: 0, killedSessionIds: ['new-1', 'new-2', 'old-1'] }) - // Each initial session receives exactly one shutdown — no retries. expect(current.shutdown).toHaveBeenCalledTimes(2) expect(current.shutdown).toHaveBeenCalledWith('new-1', { immediate: true }) expect(current.shutdown).toHaveBeenCalledWith('new-2', { immediate: true }) @@ -373,10 +352,7 @@ describe('pty:management IPC handlers', () => { current.listSessions = vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest) ) - // Why: shutdown resolves but the session never leaves listSessions — - // simulates a process wedged in uninterruptible sleep. After the poll - // window (≈6.5s, past the daemon's 5s SIGTERM→SIGKILL ladder) the - // handler must return remainingCount=1 rather than loop forever. + // Resolved shutdown does not prove exit; keep the session listed through the bounded poll window. current.shutdown = vi.fn(async () => {}) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current)) @@ -386,29 +362,20 @@ describe('pty:management IPC handlers', () => { const result = await runKillAllWithPolls(handlers['pty:management:killAll']) expect(result).toEqual({ killedCount: 0, remainingCount: 1, killedSessionIds: [] }) - // One shutdown fired — no per-session retry. Initial-snapshot - // accounting means the stuck session is counted once. expect(current.shutdown).toHaveBeenCalledTimes(1) }) it('does not count respawned sessions with fresh IDs against remainingCount', async () => { - // Why: mounted panes may re-call pty:spawn with brand-new session IDs - // while killAll is polling (tab remount, navigate-back). Those fresh - // IDs are not part of the initial snapshot and must not inflate the - // "refused to exit" count — the user asked to kill what was alive - // when the button was pressed, not to chase new spawns. + // Pane remounts can create new IDs during polling; count only sessions in the initial kill snapshot. const liveSessions = [makeSession('a'), makeSession('b')] const current = makeAdapter(5, []) let pollCalls = 0 current.listSessions = vi.fn(async () => { pollCalls += 1 if (pollCalls === 1) { - // Initial snapshot: a and b are alive. return liveSessions.map(({ protocolVersion: _pv, ...rest }) => rest) } - // First poll onward: a and b have been reaped, but the renderer - // respawned a fresh pane with id 'c'. 'c' was never in the initial - // snapshot, so it must not count as remaining. + // Original sessions exited; a newly spawned pane must not count as remaining. return [makeSession('c')].map(({ protocolVersion: _pv, ...rest }) => rest) }) current.shutdown = vi.fn(async () => {}) @@ -432,9 +399,7 @@ describe('pty:management IPC handlers', () => { current.listSessions = vi.fn(async () => sessionsList.map(({ protocolVersion: _pv, ...rest }) => rest) ) - // Why: a rejecting shutdown for 'a' must not block the shutdown of 'b'. - // Since shutdowns fire in parallel (Promise.allSettled), both must be - // invoked regardless of 'a' throwing. + // One rejecting shutdown must not prevent other sessions from being stopped. const removeFrom = (id: string): void => { const idx = sessionsList.findIndex((s) => s.sessionId === id) if (idx !== -1) { @@ -456,7 +421,6 @@ describe('pty:management IPC handlers', () => { expect(current.shutdown).toHaveBeenCalledWith('a', { immediate: true }) expect(current.shutdown).toHaveBeenCalledWith('b', { immediate: true }) - // 'a' rejected and is still alive → counts as remaining; 'b' reaped. expect(result).toEqual({ killedCount: 1, remainingCount: 1, @@ -569,7 +533,7 @@ describe('pty:management IPC handlers', () => { cwdClass: 'documents', freshDaemonAccess: 'allowed' }) - // Why: evidence belongs to the daemon spawning terminals now, never a legacy adapter's. + // Folder-access evidence belongs to the current spawning daemon, not a legacy adapter. expect(getDaemonFolderAccessMismatchMock).toHaveBeenCalledWith({ pid: 1530, startedAtMs: 1_700_000, @@ -586,8 +550,7 @@ describe('pty:management IPC handlers', () => { return { daemonScope: 'abc123def4567890', cwdClass: 'documents', freshDaemonAccess } } - // Why re-probe on the poll: the fix dialog's first step completes in System Settings, and - // this is the only moment anything can notice that it landed. + // Focus-time polling must notice System Settings changes made during repair. it.each([['denied'], ['unknown']])( 'reports the verdict the re-probe leaves behind, not the %s one it started from', async (initial) => { @@ -680,7 +643,6 @@ describe('pty:management IPC handlers', () => { freshDaemonAccess: 'allowed' } }) - // Why the current adapter: a legacy daemon's denial is not the one the user is looking at. expect(resetFolderAccessForDaemonMock).toHaveBeenCalledWith({ pid: 1530, startedAtMs: 1_700_000, diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index a6d5e6c8ff1..117f855f6c3 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -27,7 +27,7 @@ import type { DaemonEndpointIdentity } from '../daemon/daemon-hello-protocol' import type { DaemonSessionInfo } from '../daemon/types' import type { DaemonAsyncIoStatus } from '../../shared/daemon-async-io-health' -// Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused". +// Poll beyond the daemon's 5s SIGTERM-to-SIGKILL ladder to avoid false refusal reports. const MAX_POLL_ATTEMPTS = 65 const POLL_INTERVAL_MS = 100 @@ -40,7 +40,7 @@ function getDaemonAdapters(): DaemonPtyAdapter[] { return provider ? [...getAllDaemonAdapters(provider)] : [] } -// Why: surface degraded mode (daemon alive but cannot spawn fresh PTYs) so the UI can warn new terminals lack persistence. +// A live daemon unable to spawn means new terminals lack persistence. function isDaemonDegraded(): boolean { const provider = getDaemonProvider() return ( @@ -49,8 +49,7 @@ function isDaemonDegraded(): boolean { ) } -// Why the current adapter only: evidence is keyed to the daemon now spawning terminals, so a -// legacy adapter's daemon must never satisfy the identity match that keeps the notice up. +// Match folder-access evidence against the current spawning daemon, never a legacy adapter. function readCurrentDaemonIdentity(): DaemonEndpointIdentity | null { const provider = getDaemonProvider() return provider ? getCurrentDaemonAdapter(provider).getDaemonIdentity() : null @@ -94,8 +93,7 @@ export function registerDaemonManagementHandlers(): void { : { state: 'unverifiable' } }) - // Why: a Linux AppImage daemon kept for its live terminals after its files vanished; the - // banner polls this and offers the verified restart below. + // Live terminals may outlast deleted AppImage runtime files; offer verified restart recovery. ipcMain.handle('pty:management:severedDaemon', async () => ({ severed: await readSeveredDaemonStatus().catch(() => null) })) @@ -110,29 +108,25 @@ export function registerDaemonManagementHandlers(): void { } ) - // Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491), - // and carries the folder-access evidence the notice needs (STA-7948) on the same focus-time poll. + // The focus-time poll carries macOS attribution and folder-access evidence (STA-3491, STA-7948). ipcMain.handle( 'pty:management:macTccAttribution', async (): Promise<{ health: MacDaemonTccAttributionHealth folderAccessMismatch: DaemonFolderAccessMismatchNotice | null }> => { - // Why two guards: the two answers are independent evidence, and a failed health read must - // not present as "the folder evidence is gone". + // Independent evidence reads must not erase folder-access state when health probing fails. const health = await getCurrentDaemonMacTccAttributionHealth().catch( (): MacDaemonTccAttributionHealth => 'unknown' ) const identity = readCurrentDaemonIdentity() - // Why re-probe on the poll: the fix dialog's first step completes in System Settings, and - // returning to Orca is the only moment anything can notice. The refresh owns when to skip. + // Returning from System Settings is the opportunity to detect completed repair. await refreshDaemonFolderAccessProbe(identity).catch(() => {}) return { health, folderAccessMismatch: getDaemonFolderAccessMismatch(identity) } } ) - // Why a separate channel from the poll: this one has a side effect — it clears Orca's TCC row and - // makes the app touch the folder so macOS re-prompts — and only a user click may trigger it. + // Clearing TCC state and prompting require an explicit user action; keep them out of polling. ipcMain.handle( 'pty:management:resetFolderAccess', async (): Promise => { @@ -152,7 +146,7 @@ export function registerDaemonManagementHandlers(): void { } ) - // Why: tears down sessions across all adapters (current + legacy); daemon processes survive. See docs/daemon-staleness-ux.md §Phase 1. + // Stop sessions across current and legacy adapters while leaving daemon processes alive. ipcMain.handle( 'pty:management:killAll', async (): Promise<{ @@ -161,7 +155,7 @@ export function registerDaemonManagementHandlers(): void { killedSessionIds: string[] }> => { const adapters = getDaemonAdapters() - // Why: snapshot session IDs up front so mid-kill respawns aren't counted as "remaining". + // Exclude panes respawned after the user requested killAll. const initial = await collectSessions(adapters) const initialIds = new Set(initial.map((s) => s.sessionId)) const initialCount = initial.length @@ -170,20 +164,19 @@ export function registerDaemonManagementHandlers(): void { return { killedCount: 0, remainingCount: 0, killedSessionIds: [] } } - // Why: no retry — session.kill() is idempotent and runs its own kill ladder; allSettled so one rejection doesn't abort the rest. + // Each session owns its kill ladder; one rejection must not abort the others. await Promise.allSettled( initial.map(async (session) => { - // Why: assumes PROTOCOL_VERSION stays distinct from PREVIOUS_DAEMON_PROTOCOL_VERSIONS (types.ts), else legacy sessions misroute here. + // Current and legacy protocol versions must remain distinct to route to the owning adapter. const owner = adapters.find((a) => a.protocolVersion === session.protocolVersion) if (!owner) { return } - // Why: immediate=true only matters to legacy/future adapters; swallow rejections since remainingCount reports stuck sessions. + // Legacy/future adapters may use immediate; stuck sessions are reported by remainingCount. await owner.shutdown(session.sessionId, { immediate: true }).catch(() => {}) }) ) - // Why: count only the initial-snapshot intersection so renderer respawns mid-kill aren't counted as remaining. let remainingOriginalCount = initialCount let remainingOriginalIds = initialIds for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { diff --git a/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts b/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts index 1b8971f4574..adbc3f49168 100644 --- a/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts +++ b/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts @@ -48,7 +48,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => ) vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) -// Layout-owner build: a view's painted-restore ack is main's to apply, for the pane's current terminal. const PANE = { worktreeId: 'repo::/wt', tabId: 'tab-1', leafId: 'leaf-1' } describe('a painted cold-restore ack in the layout-owner build', () => { diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 2b243d667ce..882095b5306 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -502,7 +502,7 @@ describe('registerPtyHandlers', () => { }) await Promise.resolve() - // Why: SSH can strip ORCA_PANE_KEY before spawn; tab/leaf metadata must still dedupe against runtime materialization. + // SSH may strip ORCA_PANE_KEY; tab/leaf identity must still deduplicate runtime materialization. const rendererSpawn = handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index b04e02587eb..46ee9142210 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -146,7 +146,6 @@ describe('registerPtyHandlers', () => { releaseClaim() await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) expect(providerSpawn).toHaveBeenCalledOnce() - // C6 keeps "the joiner prepares before": the window prepares its launch only after the claim settles. expect(preparedAfterRelease).toEqual([true]) }) it('adopts a runtime reservation created during renderer preflight', async () => { diff --git a/src/main/ipc/pty-pane-owner-swap.test-fixture.ts b/src/main/ipc/pty-pane-owner-swap.test-fixture.ts index 1121b2b69cc..7ff505622d3 100644 --- a/src/main/ipc/pty-pane-owner-swap.test-fixture.ts +++ b/src/main/ipc/pty-pane-owner-swap.test-fixture.ts @@ -1,5 +1,4 @@ -// The pane-owner swap harness: a real binding store over a fake session, a fake provider, and the -// real IPC spawn pipeline. The caller's test file declares the module mocks (see the restart test). +// Exercise the real binding store and IPC spawn pipeline over a fake session/provider; suites declare module mocks. import { vi, type Mock } from 'vitest' import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence' import { ptyBindingGenerationOf } from '../persistence/loading-store/pty-binding-generations' @@ -12,7 +11,7 @@ import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' -/** A named type, so the exported harness's inferred type stays portable. */ +// An explicit Mock return type keeps the exported fixture type portable. function stub(): Mock<(...args: unknown[]) => unknown> { return vi.fn() } diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 78a0c2fdfde..bc305ecca44 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -177,7 +177,7 @@ describe('registerPtyHandlers', () => { expect(providerSpawn).toHaveBeenCalledTimes(2) }) it('settles the pane reservation when a post-spawn step throws so later spawns do not hang', async () => { - // Why: reservation-leak regression — a post-spawn throw after provider.spawn resolves must reject/clear the reservation, else later spawns for the same pane key hang forever. + // Post-spawn throws must release the reservation or later spawns for this pane hang. registerPtyHandlers(mainWindow as never) const leafId = '44444444-4444-4444-8444-444444444444' const spawnArgs = { cols: 80, rows: 24, tabId: 'tab-reservation', leafId } @@ -188,7 +188,6 @@ describe('registerPtyHandlers', () => { await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow('boom') - // A second spawn for the same pane must run a fresh spawn rather than await the leaked (never-settled) reservation promise. let hangTimer: ReturnType | undefined const second = handlers.get('pty:spawn')!(null, spawnArgs) as Promise<{ id: string }> const result = await Promise.race([ @@ -205,7 +204,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledTimes(2) }) it('settles the runtime-owned pane reservation when a post-spawn step throws so later spawns do not hang', async () => { - // Why: like the renderer-path regression, the runtime spawn path must clear its own reservation when a post-spawn step throws, else the next materialization hangs forever. + // Runtime post-spawn throws need the same reservation cleanup as renderer spawns. type RuntimeSpawnController = { spawn(args: { cols: number @@ -286,7 +285,6 @@ describe('registerPtyHandlers', () => { await expect(spawnController.spawn(spawnArgs)).rejects.toThrow('boom') - // The reservation must be gone, so a second materialization runs a fresh provider.spawn instead of awaiting the leaked promise. let hangTimer: ReturnType | undefined const second = spawnController.spawn(spawnArgs) const result = await Promise.race([ diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index 11dfea26dd7..184eff9f533 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -143,12 +143,11 @@ describe('pty:spawn replacing a pane owner', () => { ).rejects.toThrow('daemon unreachable') expect(providerSpawn).not.toHaveBeenCalled() expect(exitPayloads('pty-old')).toEqual([]) - // The pane is released: a later spawn still reaches the surviving owner instead of hanging. await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ id: 'pty-old', isReattach: true }) - // The failed restart left no label behind: a later close of the same PTY reads as a close. + // Failed restarts must not leave a reason label that misclassifies later closure. control.shutdownFails = false await handlers.get('pty:kill')!(null, { id: 'pty-old' }) expect(exitPayloads('pty-old')).toHaveLength(1) diff --git a/src/main/ipc/pty-pending-data-drain-contract.ts b/src/main/ipc/pty-pending-data-drain-contract.ts index f8ac2945a18..1f30e27dbb4 100644 --- a/src/main/ipc/pty-pending-data-drain-contract.ts +++ b/src/main/ipc/pty-pending-data-drain-contract.ts @@ -10,8 +10,7 @@ export type PendingPtyData = { droppedOutput?: true droppedMode2031Data?: string droppedMode2031ScanState?: Mode2031ReplyScanState - /** Latch state across bytes the renderer never received, so a drop that swallowed - * a closing \x1b[?2026l can still release xterm's render hold. */ + /** Preserve synchronized-output state across dropped bytes, including a lost closing escape that would hold rendering. */ droppedSynchronizedOutputState?: SynchronizedOutputLatchState } diff --git a/src/main/ipc/pty-pending-data-drain-queue.ts b/src/main/ipc/pty-pending-data-drain-queue.ts index 11f60444cf0..b2aa3cdcbb4 100644 --- a/src/main/ipc/pty-pending-data-drain-queue.ts +++ b/src/main/ipc/pty-pending-data-drain-queue.ts @@ -93,7 +93,7 @@ export class PtyPendingDataDrainQueue { this.pendingChars += pending.data.length - existing.pending.data.length existing.pending = pending if (this.openRound) { - // Why: renderer notification can synchronously append; defer the whole ID past this round's frontier. + // Renderer notification can synchronously append; defer this ID beyond the current round's frontier. this.unlink(existing) existing.eligibleRound = this.openRound.round + 1 this.appendToLane(existing, this.classify(id)) diff --git a/src/main/ipc/pty-pending-output-cap.test.ts b/src/main/ipc/pty-pending-output-cap.test.ts index fd64f69f236..d8ac6fbf467 100644 --- a/src/main/ipc/pty-pending-output-cap.test.ts +++ b/src/main/ipc/pty-pending-output-cap.test.ts @@ -73,28 +73,26 @@ describe('registerPtyHandlers', () => { const ackData = getPtyAckDataListener() mainWindow.webContents.send.mockClear() - // Saturate the renderer in-flight window (512 KB) with no ACKs — the frozen/starved-renderer shape from field reports. + // Model a frozen renderer: saturate its in-flight window without ACKs. mockProc.emitData('x'.repeat(600 * 1024)) vi.advanceTimersByTime(2) for (let index = 0; index < 32; index++) { vi.advanceTimersByTime(1) } - // Keep flooding past the 2 MB per-PTY pending cap; main must not buffer unboundedly (previously: unbounded string concat). mockProc.emitData('y'.repeat(3 * 1024 * 1024)) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 0 }) - // Later output while dropped must stay O(1), not start re-accumulating. mockProc.emitData('z'.repeat(64 * 1024)) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 0 }) - // On recover+ACK, the flush must deliver the droppedOutput sentinel so the pane repaints from the main-owned snapshot. + // Restore from the main snapshot after the pending cap drops bytes. mainWindow.webContents.send.mockClear() ackData(null, { id: spawn.id, charCount: 512 * 1024 }) vi.advanceTimersByTime(2) @@ -104,7 +102,6 @@ describe('registerPtyHandlers', () => { droppedOutput: true }) - // Fresh output after the sentinel flows normally again. mainWindow.webContents.send.mockClear() mockProc.emitData('back to normal') vi.advanceTimersByTime(2) @@ -174,7 +171,7 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) mainWindow.webContents.send.mockClear() - // Saturate the in-flight window with no ACKs, then buffer 3 MB — over the floor, under the scaled cap: retain, don't drop. + // Retain output above the floor but below the scrollback-scaled cap. mockProc.emitData('x'.repeat(600 * 1024)) vi.advanceTimersByTime(2) for (let index = 0; index < 32; index++) { @@ -183,7 +180,6 @@ describe('registerPtyHandlers', () => { mockProc.emitData('y'.repeat(3 * 1024 * 1024)) expect(getPtyRendererDeliveryDebugSnapshot().pendingChars).toBeGreaterThan(3 * 1024 * 1024) - // The scaled cap still bounds a runaway flood. mockProc.emitData('z'.repeat(4 * 1024 * 1024)) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, @@ -201,7 +197,7 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow as never) mainWindow.webContents.send.mockClear() - // Flood in 64KB chunks like a `yes`-style producer honoring pause — node-pty pause() stops the fd read, so it stops emitting. + // Model node-pty pause stopping reads by withholding further chunks while paused. const chunk = 'x'.repeat(64 * 1024) let chunks = 0 while (provider.pauseProducer.mock.calls.length === 0 && chunks < 100) { @@ -209,18 +205,15 @@ describe('registerPtyHandlers', () => { chunks++ } - // Pause fires exactly once, on the first chunk past the 256KB high watermark (the 5th 64KB chunk), not per chunk. expect(provider.pauseProducer).toHaveBeenCalledTimes(1) expect(provider.pauseProducer).toHaveBeenCalledWith('flood-pty') expect(chunks).toBe(5) - // Bounded: main buffered at most HIGH + one chunk while paused. expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 320 * 1024, peakPendingChars: 320 * 1024 }) - // Resume must fire exactly once at the 32KB low watermark, with no flapping across the 32-256KB hysteresis band. vi.runAllTimers() expect(provider.resumeProducer).toHaveBeenCalledTimes(1) expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') @@ -247,7 +240,7 @@ describe('PTY producer flow control', () => { provider.emitData('flood-pty', finalPendingData) expect(provider.pauseProducer).toHaveBeenCalledTimes(1) - // Exit while pending is above the low watermark: the exit path must release the pause, not leave a stale mark. + // Exit must release pause even when pending output never drained. provider.emitExit('flood-pty', 0) expect(provider.resumeProducer).toHaveBeenCalledTimes(1) expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') @@ -265,8 +258,7 @@ describe('PTY producer flow control', () => { provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) expect(provider.pauseProducer).toHaveBeenCalledTimes(1) - // Re-registration hands delivery to a new window; the outgoing session must run its real - // lifecycle reset before the bridge is neutralized or this shell stays paused forever. + // Re-registration must reset the outgoing session before neutralizing its bridge, or the shell remains paused. registerPtyHandlers(mainWindow as never) expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') } finally { diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 2fc824a83a9..a87a05d1ebd 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -380,7 +380,6 @@ describe('registerPtyHandlers', () => { command: 'codex resume exact-dead-provider-session' }) if (ORCA_LAYOUT_OWNER_IS_MAIN) { - // The dead binding stays until the fresh spawn's one bind swaps it. expect(store.setWorkspaceSession).not.toHaveBeenCalled() const [swap] = vi.mocked(store.persistPtyBinding).mock.calls[0] ?? [] expect(typeof swap === 'function' ? swap() : swap).toMatchObject({ @@ -506,7 +505,6 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow('terminal_pane_owner_unverified') - // The live PTY keeps its pane binding, gets no synthetic exit, and is not duplicated. expect(providerSpawn).toHaveBeenCalledOnce() expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ attachOnly: true }) expect(runtime.onPtyExit).not.toHaveBeenCalled() diff --git a/src/main/ipc/pty-producer-flow-control.test.ts b/src/main/ipc/pty-producer-flow-control.test.ts index ae7fcb6b655..dd469f00db9 100644 --- a/src/main/ipc/pty-producer-flow-control.test.ts +++ b/src/main/ipc/pty-producer-flow-control.test.ts @@ -51,7 +51,6 @@ describe('PtyProducerFlowController', () => { expect(resumeProducer).toHaveBeenCalledTimes(1) expect(resumeProducer).toHaveBeenCalledWith('pty-1') expect(controller.isPaused('pty-1')).toBe(false) - // A second drain report on the now-unpaused pty must not resume again. controller.update('pty-1', 0) expect(resumeProducer).toHaveBeenCalledTimes(1) }) @@ -59,14 +58,12 @@ describe('PtyProducerFlowController', () => { it('holds hysteresis: no flapping while pending sits between the watermarks', () => { controller.update('pty-1', HIGH + 1) expect(pauseProducer).toHaveBeenCalledTimes(1) - // Draining but still above LOW: stay paused, no extra calls either way. controller.update('pty-1', HIGH - 16 * 1024) controller.update('pty-1', 128 * 1024) controller.update('pty-1', LOW) expect(pauseProducer).toHaveBeenCalledTimes(1) expect(resumeProducer).not.toHaveBeenCalled() expect(controller.isPaused('pty-1')).toBe(true) - // An unpaused pty hovering in the same band must not pause. controller.update('pty-2', LOW + 1) controller.update('pty-2', HIGH) expect(pauseProducer).toHaveBeenCalledTimes(1) @@ -76,17 +73,15 @@ describe('PtyProducerFlowController', () => { controller.update('pty-1', HIGH + 1) expect(pauseProducer).toHaveBeenCalledTimes(1) - // Within the failsafe window: no re-assert even far above HIGH. vi.advanceTimersByTime(PRODUCER_PAUSE_REASSERT_INTERVAL_MS - 1) controller.update('pty-1', HIGH * 4) expect(pauseProducer).toHaveBeenCalledTimes(1) - // After the window (daemon failsafe has auto-resumed by now): re-pause. + // Reassert after the daemon's lost-resume failsafe releases the pause. vi.advanceTimersByTime(1) controller.update('pty-1', HIGH * 4) expect(pauseProducer).toHaveBeenCalledTimes(2) - // The re-assert re-stamps the clock — no immediate third pause. controller.update('pty-1', HIGH * 4) expect(pauseProducer).toHaveBeenCalledTimes(2) }) diff --git a/src/main/ipc/pty-producer-flow-control.ts b/src/main/ipc/pty-producer-flow-control.ts index 48a9fc0b678..23cb3a2f125 100644 --- a/src/main/ipc/pty-producer-flow-control.ts +++ b/src/main/ipc/pty-producer-flow-control.ts @@ -1,15 +1,8 @@ -// Producer-side PTY flow control (notes/terminal-performance-initiative.md §5). -// Main tracks per-PTY renderer-pending chars; past HIGH it asks the provider to -// pause the actual PTY read (node-pty pause() → kernel backpressure → the -// flooding shell blocks on write), and below LOW it resumes. The wide -// HIGH/LOW gap is deliberate hysteresis so a draining queue cannot flap -// pause/resume once per flush slice. +// Pause producer reads to backpressure flooding shells; separate high/low watermarks prevent flush-time flapping. export const PRODUCER_FLOW_HIGH_WATERMARK_CHARS = 256 * 1024 export const PRODUCER_FLOW_LOW_WATERMARK_CHARS = 32 * 1024 -// Why: the daemon auto-resumes a pause after its 5s lost-resume failsafe. If -// pending is still above HIGH after that window, the pause must be re-asserted -// or a sustained flood would run unthrottled after the first failsafe fires. +// Reassert sustained backpressure after the daemon's lost-resume failsafe auto-resumes reads. export const PRODUCER_PAUSE_REASSERT_INTERVAL_MS = 5_000 export type ProducerFlowControlTransport = { @@ -38,9 +31,6 @@ export class PtyProducerFlowController { this.reassertIntervalMs = opts.reassertIntervalMs ?? PRODUCER_PAUSE_REASSERT_INTERVAL_MS } - /** Reports the current pending chars for a PTY. Fires pause exactly once at - * the HIGH crossing (re-asserted only after the failsafe interval) and - * resume exactly once when pending drains below LOW. */ update(id: string, pendingChars: number): void { const pausedAt = this.pausedAtByPty.get(id) if (pausedAt === undefined) { @@ -64,18 +54,15 @@ export class PtyProducerFlowController { } } - /** Resumes a PTY if it was paused. For teardown paths (exit, kill) where - * the pending bookkeeping is being dropped rather than drained. */ + /** Teardown discards pending accounting; release any pause even without a normal drain. */ release(id: string): void { if (this.pausedAtByPty.delete(id)) { this.safeResume(id) } } - /** Resumes every paused PTY. For wholesale bookkeeping wipes (window - * destroyed) — a local PTY left paused here would stay wedged forever. */ + /** Window teardown must resume all producers before discarding delivery state. */ releaseAll(): void { - // Deleting the visited entry during Map key iteration is spec-safe. for (const id of this.pausedAtByPty.keys()) { this.release(id) } @@ -85,8 +72,7 @@ export class PtyProducerFlowController { return this.pausedAtByPty.has(id) } - // Why swallow: pause/resume are optimizations riding the terminal data - // path — a provider throw must never break delivery or exit handling. + // Flow-control failures must not interrupt delivery or exit handling. private safePause(id: string): void { try { this.transport.pauseProducer(id) diff --git a/src/main/ipc/pty-renderer-inflight-credit.test.ts b/src/main/ipc/pty-renderer-inflight-credit.test.ts index 79107ad7392..61d1c809b2a 100644 --- a/src/main/ipc/pty-renderer-inflight-credit.test.ts +++ b/src/main/ipc/pty-renderer-inflight-credit.test.ts @@ -105,7 +105,7 @@ describe('registerPtyHandlers', () => { data: 'remote-output' }) - // Why: stale/duplicated renderer ACKs must not over-credit SSH/relay flow control beyond bytes main sent. + // Stale/duplicate renderer ACKs must not over-credit SSH/relay transport. ackData(null, { id: 'remote-like-pty', charCount: 1024 }) ackData(null, { id: 'remote-like-pty', charCount: 1024 }) @@ -182,7 +182,7 @@ describe('registerPtyHandlers', () => { data: 'a' }) interactiveProc.emitData(reserveChunk) - // Why: reserve-exhausted send stays gated, and the fully gated arrival emits one delivery resync probe (not pty:data). + // Reserve exhaustion sends a resync probe without additional pty:data delivery. expect(getPtyDataSendCalls()).toHaveLength(529) expect(getDeliveryResyncProbeCalls()).toHaveLength(1) } finally { @@ -307,7 +307,7 @@ describe('PTY cumulative acknowledgement accounting', () => { rendererInFlightPtyCount: 1 }) - // Every per-chunk ACK was lost, but the next ACK carries the full cumulative total — the debt clears without any timer or reset. + // Cumulative ACKs recover all debt even when individual chunk ACKs were lost. const ackData = getPtyAckDataListener() ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) @@ -337,13 +337,11 @@ describe('PTY cumulative acknowledgement accounting', () => { maxRendererInFlightCharsByPty: 256 * 1024 }) - // Replayed duplicate credits nothing further. ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightChars: 256 * 1024 }) - // A stale reordered total can never move accounting backwards. ackData(null, { id: spawnResult.id, processedChars: 128 * 1024 }) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightChars: 256 * 1024 @@ -446,7 +444,7 @@ describe('PTY cumulative acknowledgement accounting', () => { mockProc.emitData('remote-output') vi.advanceTimersByTime(8) - // Why: cumulative totals clamp to what main sent; a replayed total credits SSH/relay flow control 0, not duplicate bytes. + // Clamp cumulative transport credit to bytes sent; duplicate totals add no credit. ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) @@ -472,7 +470,6 @@ describe('PTY cumulative acknowledgement accounting', () => { expect(getDeliveryResyncProbeCalls()).toHaveLength(1) const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } - // Only one probe may be outstanding at a time. mockProc.emitData('still-stuck') expect(getDeliveryResyncProbeCalls()).toHaveLength(1) @@ -487,7 +484,6 @@ describe('PTY cumulative acknowledgement accounting', () => { rendererInFlightPtyCount: 0 }) - // Reconciled gate lets held pendingData flush again (one 2ms batch window = one 16KB slice). vi.advanceTimersByTime(2) expect(getPtyDataSendCalls()).toHaveLength(33) } finally { @@ -541,14 +537,13 @@ describe('PTY cumulative acknowledgement accounting', () => { pendingPtyCount: 1 }) ) - // No blind reset: counters and pending output are untouched. expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightChars: 512 * 1024, pendingChars: 88 * 1024 + 'stuck-output'.length }) expect(getPtyDataSendCalls()).toHaveLength(32) - // Cleared flag lets the next gated arrival probe again, but a still-silent renderer won't spam a second warn. + // Timeout permits a new probe without repeating warnings for the same silent renderer. mockProc.emitData('still-stuck') expect(getDeliveryResyncProbeCalls()).toHaveLength(2) vi.advanceTimersByTime(5_000) diff --git a/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts b/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts index 92472a6c8bb..41b88f9c0d3 100644 --- a/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts +++ b/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts @@ -147,7 +147,6 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) handleRendererLoading() - // Reloaded page's dispatcher re-registers, releasing held sends (§1b). handleRendererDispatcherReady() mockProc.emitData('reload-gap output') vi.advanceTimersByTime(2) @@ -185,7 +184,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } const handleRendererLoading = getMainFrameNavigationListener() const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() - // Drain the initial dispatcher-ready flush (beforeEach fires the handshake to model a live page) so flood timing starts clean. + // Drain the fixture's ready flush before measuring flood timing. vi.advanceTimersByTime(1) mainWindow.webContents.send.mockClear() @@ -196,7 +195,6 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } - // Gate closed: sends stop at the cap and the remainder accrues as pending. expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) @@ -209,7 +207,7 @@ describe('registerPtyHandlers', () => { lastLifecycleResetClearedChars: 0 }) - // Renderer reload: the dead page never ACKs, so its in-flight/pending accounting must clear or the surviving PTY stays gated forever. + // The dead page cannot ACK; clear its debt so surviving PTYs do not remain gated. handleRendererLoading() expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightChars: 0, @@ -220,7 +218,7 @@ describe('registerPtyHandlers', () => { lastLifecycleResetClearedChars: 512 * 1024 }) - // Boot window (§1b): dispatcher not re-registered, so sends must be held — bytes into the listener-less page drop yet count in-flight, re-pinning the gate. + // Hold bytes until the replacement dispatcher exists, or lost sends recreate permanent debt. mainWindow.webContents.send.mockClear() mockProc.emitData('post-reload output') vi.advanceTimersByTime(8) @@ -231,7 +229,7 @@ describe('registerPtyHandlers', () => { pendingPtyCount: 1 }) - // The dispatcher-ready handshake releases the held backlog; assert delivery actually resumes and pending drains, not just counters-zero. + // Verify the handshake restores delivery, beyond merely clearing counters. handleRendererDispatcherReady() vi.advanceTimersByTime(8) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -261,7 +259,6 @@ describe('registerPtyHandlers', () => { })) as { id: string } const handleRendererNavigation = getMainWindowWebContentsListener('did-start-navigation') const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() - // Drain the initial dispatcher-ready flush (beforeEach fires the handshake). vi.advanceTimersByTime(1) mainWindow.webContents.send.mockClear() @@ -286,7 +283,6 @@ describe('registerPtyHandlers', () => { rendererPtyDispatcherReady: true }) - // Gate remains open: output after the iframe navigation reaches the fresh page without waiting for the watchdog. mainWindow.webContents.send.mockClear() mockProc.emitData('post-subframe output') vi.advanceTimersByTime(8) @@ -316,7 +312,6 @@ describe('registerPtyHandlers', () => { })) as { id: string } const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() const ackData = getPtyAckDataListener() - // Drain the initial dispatcher-ready flush (beforeEach fires the handshake). vi.advanceTimersByTime(1) mainWindow.webContents.send.mockClear() @@ -333,7 +328,7 @@ describe('registerPtyHandlers', () => { rendererPtyDispatcherReady: true }) - // Handshake while the gate is open proves a missed lifecycle reset; reconcile or survivors stay pinned at the cap. + // A handshake on an open gate exposes a missed lifecycle reset; reconcile stale debt. mainWindow.webContents.send.mockClear() handleRendererDispatcherReady() const reconciled = getPtyRendererDeliveryDebugSnapshot() @@ -346,7 +341,6 @@ describe('registerPtyHandlers', () => { }) expect(reconciled.lastLifecycleResetClearedChars).toBeGreaterThan(0) - // Delivery has resumed: fresh output flows immediately instead of piling up behind the stale cap. mockProc.emitData('post-reconcile output') vi.advanceTimersByTime(8) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -354,7 +348,7 @@ describe('registerPtyHandlers', () => { data: 'post-reconcile output' }) - // A straggler ACK from the dead page is clamped and cannot underflow the reconciled counters below zero. + // Clamp straggler ACKs from the dead page to avoid underflow after reconciliation. ackData(null, { id: spawnResult.id, charCount: 512 * 1024 }) expect(getPtyRendererDeliveryDebugSnapshot().rendererInFlightChars).toBe(0) } finally { @@ -376,14 +370,12 @@ describe('registerPtyHandlers', () => { const handleRendererLoading = getMainFrameNavigationListener() const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() const writeListener = getPtyWriteListener() - // Drain the initial ready-flush the beforeEach handshake schedules. vi.advanceTimersByTime(1) - // Reload closes the gate; the reloaded page's dispatcher has not re-registered. handleRendererLoading() mainWindow.webContents.send.mockClear() - // With shouldSendInteractiveOutputNow() true, only the `&& rendererPtyDispatcherReady` guard keeps the interactive echo out of the still-listener-less page. + // Interactive bypass must still wait for the replacement page's dispatcher. const redraw = '\x1b[20;2Hredraw' writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) mockProc.emitData(redraw) @@ -394,7 +386,6 @@ describe('registerPtyHandlers', () => { pendingPtyCount: 1 }) - // The handshake releases the held echo (drained via the batch flush). handleRendererDispatcherReady() vi.advanceTimersByTime(8) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -420,7 +411,7 @@ describe('registerPtyHandlers', () => { const handleRendererLoading = getMainFrameNavigationListener() vi.advanceTimersByTime(1) - // Reload closes the gate and arms the ~10s watchdog; the reloaded page never sends the handshake (dropped IPC), so output stays held. + // A missing reload handshake must recover through the bounded readiness watchdog. handleRendererLoading() mainWindow.webContents.send.mockClear() mockProc.emitData('post-reload output') @@ -431,7 +422,6 @@ describe('registerPtyHandlers', () => { rendererDispatcherReadyForcedCount: 0 }) - // Past the 10s watchdog window the gate self-heals (ready forced, backlog drains) instead of freezing permanently. vi.advanceTimersByTime(10_000) vi.advanceTimersByTime(8) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { diff --git a/src/main/ipc/pty-renderer-liveness-guard.test.ts b/src/main/ipc/pty-renderer-liveness-guard.test.ts index 80922af4ce1..4c16f7567f4 100644 --- a/src/main/ipc/pty-renderer-liveness-guard.test.ts +++ b/src/main/ipc/pty-renderer-liveness-guard.test.ts @@ -49,15 +49,7 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) -/** - * A destroyed WebContents under a still-alive BrowserWindow is the STA-2373 state: - * `webContents` can die a beat before its window during close (see createMainWindow.ts - * forceRepaint), and `webContents.send` throws `Object has been destroyed` there. An - * uncaught throw in the main process is fatal — the app exits with every terminal in it, - * clean exit code and no crash report — so both `pty:writeUnavailable` senders must check - * the WebContents, not just the window. #15927 briefly reduced these to a window-only - * check; these tests fail against that regression (STA-5373). - */ +/** WebContents can die before its BrowserWindow; unguarded sends then throw in main and kill every terminal (STA-2373, STA-5373). */ describe('pty:writeUnavailable renderer-liveness guard', () => { const { handlers, @@ -90,11 +82,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { id }) }) - // Why a chunked write: the single-chunk path is already fenced by - // isPtyWriteEventFromMainWindow, which rejects the event outright once the WebContents - // is gone. A multi-chunk write awaits a macrotask between chunks, so the sender check - // passes while alive and the renderer dies mid-write — the only way this sender is - // reached with a destroyed WebContents, and what makes its own guard load-bearing. + // Multi-chunk writes yield after the sender check, allowing WebContents to die before a later write fails. it('stays silent when the WebContents dies mid-write under a live window', async () => { let writeCount = 0 installDaemonTestProvider({ @@ -126,7 +114,6 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { }) describe('the daemon-death fan-out', () => { - /** Registers handlers and returns the fan-out callback the provider hands the session. */ function installProviderCapturingFanout(): (payload: { id: string }) => void { let fanout: ((payload: { id: string }) => void) | null = null installDaemonTestProvider({ @@ -166,7 +153,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { }) it('reaches remote terminal streams without a live window', () => { - // Why: a headless host has no window; its paired clients only hear through the streams. + // Headless clients depend on streams even when no renderer can receive the event. const fanout = installProviderCapturingFanout() mainWindow.webContents.isDestroyed.mockReturnValue(true) const streamListener = vi.fn() diff --git a/src/main/ipc/pty-restore-record-seeding.test.ts b/src/main/ipc/pty-restore-record-seeding.test.ts index 300aafde9ab..440d0b5de7c 100644 --- a/src/main/ipc/pty-restore-record-seeding.test.ts +++ b/src/main/ipc/pty-restore-record-seeding.test.ts @@ -159,18 +159,14 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) - // Why: without this seed main's model would be a strict suffix of what the renderer painted, and a later park-reveal would restore the fragment. + // Seed full history so a later reveal cannot restore only the live-output suffix. expect(runtime.seedHeadlessTerminal).toHaveBeenCalledTimes(1) expect(runtime.seedHeadlessTerminal).toHaveBeenCalledWith( 'pty-ssh-reattach', 'relay history\r\n' ) }) - // STA repro (post-restart blind orchestrator): reattach restore payloads - // arrive as spawn RPC results, never through onPtyData, so without record - // seeding `terminal list` reported connected terminals with empty - // title/preview/lastOutputAt after every relaunch and `terminal read` - // returned a zero-line tail for a running session. + // Reattach restores arrive outside onPtyData; seed list/read records to avoid empty terminal metadata after restart. it('leaves the runtime reporting preview and title after a reattach spawn (restart restore)', async () => { const worktreeId = 'repo-restore::/tmp/restore-records' const tabId = 'tab-restore-records' @@ -282,9 +278,7 @@ describe('registerPtyHandlers', () => { env: { ORCA_PANE_KEY: paneKey } }) - // The renderer owns the emulator snapshot here — but the list/read records - // are main-side only, so the record seed must still run. Under main's layout, main's copy is - // the history every view repaints from, so it is seeded too. + // Record seeding is always main-owned; main-owned layout also needs its emulator history seeded. if (ORCA_LAYOUT_OWNER_IS_MAIN) { expect(runtime.seedHeadlessTerminal).toHaveBeenCalledWith( 'pty-gated-reattach', @@ -343,8 +337,6 @@ describe('registerPtyHandlers', () => { lastTitle: 'checkpoint-title' }) }) - // Why windowless: `orca serve`/CLI runtime creation is the topology that most - // needs informative records — its controller.spawn path must seed them too. it('seeds restore records for a runtime-controller created terminal (headless reattach)', async () => { const worktreeId = 'repo-restore::/tmp/restore-records' const ptyId = `${worktreeId}@@session-headless-1` @@ -369,12 +361,7 @@ describe('registerPtyHandlers', () => { getProjects: () => [], persistPtyBinding: vi.fn() } as never) - // Why: selector resolution shells out to git for real repos; prime the - // resolved-worktree cache so this headless fixture resolves offline. - // - // Why through getSnapshot and not a hand-written `resolved` entry: the cache decides freshness - // from fields it stamps itself, so a literal that mirrors them is a second copy of that - // contract and goes stale the moment a field is added. Let the cache stamp its own entry. + // Prime selector resolution through the real cache API to avoid Git I/O and hand-copied freshness fields. const worktreeResolutionInternals = runtime as unknown as { buildResolvedWorktreeFromId(id: string): ResolvedWorktree resolvedWorktrees: RuntimeResolvedWorktreeCache @@ -595,7 +582,7 @@ describe('registerPtyHandlers', () => { forgetAgentExit: true }) }) - // Why: a disconnect that can reconnect is no death, so the hook store keeps the agent's proven exit. + // A reconnectable disconnect must preserve the agent's proven exit evidence. it('keeps the proven agent exit when a PTY is cleared for a disconnect', async () => { registerPtyHandlers(mainWindow as never) const stablePaneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') diff --git a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts index 876cc005a91..49c4d2bd74f 100644 --- a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts @@ -161,7 +161,6 @@ describe('runtime-controller spawn: hidden until a renderer view mounts', () => daemon.setPtyBackgrounded.mockClear() simulateRendererReload() - // A reload must not resurrect a runtime mark on a PTY a renderer view already owned. expect(isHiddenRendererPty(result.id)).toBe(false) expect(daemon.setPtyBackgrounded).not.toHaveBeenCalledWith(result.id, true) }) @@ -180,7 +179,6 @@ describe('runtime-controller spawn: hidden until a renderer view mounts', () => const spawnPromise = controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) await vi.waitFor(() => expect(mintedSessionId).toBeDefined()) - // The visible mount reports visible and releases the pre-spawn mark. getPtySetRendererPtyVisibleListener()(null, { id: mintedSessionId!, visible: true }) getPtySetHiddenRendererPtyListener()(null, { id: mintedSessionId!, hidden: false }) spawnGate.resolve() diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index 84d2e2594e3..fa883d6d5ed 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -322,7 +322,7 @@ describe('registerPtyHandlers', () => { expect(result.id).toBe(daemonSpawn.mock.calls[0]?.[0].sessionId) expect(spawnMock).not.toHaveBeenCalled() }) - // Why: cold-start teardown must select the daemon after startup, else fallback shutdown orphans the restored daemon PTY (#7742). + // Cold-start kills must await daemon selection or fallback shutdown orphans restored PTYs (#7742). it('waits for the desktop startup barrier before renderer local kills resolve the provider', async () => { const barrier = makeDeferred() const awaitLocalPtyStartup = vi.fn(() => new Promise(() => {})) diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index f3c41d9285c..b55f98615ab 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -314,7 +314,7 @@ describe('registerPtyHandlers', () => { 'ORCA_WSL_CLI_DIR/p', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', - // Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196). + // Bare WSL shells expose OMP status without creating ~/.omp (#10196). 'ORCA_OMP_STATUS_EXTENSION/u', 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' ]) diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index 65dea10d0e5..4a76e5f3c43 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -81,8 +81,7 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(mainWindow as never) - // The local provider would happily report `false` here — it just doesn't - // hold the id. Callers read that as "the shell died" (STA-2830). + // Local absence cannot prove a remote PTY dead (STA-2830). await expect( handlers.get('pty:hasPty')!(null, { id: 'remote:env-1@@terminal-1' }) ).resolves.toBe(null) @@ -106,8 +105,7 @@ describe('registerPtyHandlers', () => { ) it('reports agent ownership through pty:listSessions so the renderer cannot guess it', async () => { registerPtyHandlers(mainWindow as never) - // Why: the renderer's binding map is empty during restore, so agent ownership is the only - // positive liveness evidence it has. Dropping it here force-killed live sessions (#8459). + // During restore the binding map may be empty; owner evidence must protect live sessions (#8459). const owner = { claim: { digestVersion: AGENT_SESSION_CLAIM_DIGEST_VERSION, @@ -164,8 +162,7 @@ describe('registerPtyHandlers', () => { }) it('reports unknown ownership when the provider cannot serialize claims', async () => { registerPtyHandlers(mainWindow as never) - // Why: a legacy daemon generation or older SSH relay lists no owners for a session that may - // have one. Reporting that silence as 'absent' is what let live agent sessions be killed (#8459). + // Legacy daemons/relays may omit ownership listings; silence must remain unknown (#8459). setLocalPtyProvider({ spawn: vi.fn(), write: vi.fn(), diff --git a/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts index a76b37305b0..193e008ba81 100644 --- a/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts +++ b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts @@ -72,14 +72,7 @@ type RuntimeSpawnController = { spawn(args: RuntimeSpawnRequest): Promise<{ id: string }> } -/** - * #STA-4422: launch prep refuses an unreadable managed home by throwing, because - * `null` already means "launch the system default". A refusal that reached - * either spawn path as `null` would start the pane on the user's real ~/.codex - * while the UI still shows the managed account, so every assertion here pairs - * the rejection with a zero-spawn check — existing suites prove a `null` - * re-resolution deliberately launches with no CODEX_HOME. - */ +/** Unreadable managed homes must reject with no spawn; null selects the system account and could misattribute the pane (STA-4422). */ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home', () => { const { handlers, mainWindow } = setupPtyIpcSuite() const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) @@ -187,8 +180,7 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home await vi.advanceTimersByTimeAsync(2_000) await rejection - // Why: the first resolution must have succeeded, or the rejection would - // prove nothing about the re-resolution this test exists for. + // Prove the initial resolution succeeded before exercising failed re-resolution. expect(resolveHome).toHaveBeenCalledTimes(2) expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({ unavailableManagedHomePath: TEST_CODEX_HOME @@ -233,10 +225,7 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home expect(daemonSpawn).not.toHaveBeenCalled() }) - // Why (#STA-4422 P1g): the eager selected-home gate rejects the awaited - // resume preparation. That rejection has to reach the spawn, not be - // downgraded to "no resume" — a resume that proceeds picks its CODEX_HOME - // from whichever account's alias stayed readable. + // Eager resume refusal must propagate; treating it as no resume could launch another account's readable alias (STA-4422). it('refuses the spawn when the eager resume gate rejects', async () => { readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) const daemonSpawn = setupDaemonAdapter() @@ -363,8 +352,7 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home }) }) - // Both lanes: only a launch whose caller named no session waits on a managed login. A host - // create operation's derived id is not caller-named, so it waits too. + // Derived operation IDs are not caller-selected sessions; those fresh launches still await managed login. it.each([ { lane: 'host', @@ -449,7 +437,6 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home } ) - // Why: only an Orca-launched Codex may wait for Codex's hook hashes; a plain terminal never does. describe('which spawns launch prep may make wait', () => { it('marks a Codex launch, and not a plain terminal, on the local spawn path', async () => { readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) diff --git a/src/main/ipc/pty-spawn-cwd-fallback-hung-folder.test.ts b/src/main/ipc/pty-spawn-cwd-fallback-hung-folder.test.ts index 229d82dfb93..680ea7e25a0 100644 --- a/src/main/ipc/pty-spawn-cwd-fallback-hung-folder.test.ts +++ b/src/main/ipc/pty-spawn-cwd-fallback-hung-folder.test.ts @@ -80,7 +80,7 @@ const { handlers, mainWindow } = setupPtyIpcSuite() beforeEach(() => { statMock.mockReset() - // The provider still validates through the suite's statSync fake; the folder check reads stat. + // Provider validation uses sync stat; folder-workspace validation uses async stat. statMock.mockImplementation(async () => directoryStats) }) diff --git a/src/main/ipc/pty-spawn-cwd-fallback.test.ts b/src/main/ipc/pty-spawn-cwd-fallback.test.ts index c6b93f3dafb..216a6cc7ad0 100644 --- a/src/main/ipc/pty-spawn-cwd-fallback.test.ts +++ b/src/main/ipc/pty-spawn-cwd-fallback.test.ts @@ -64,7 +64,7 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() it('passes floating terminal cwds through to the spawned shell', async () => { - // Why: the floating sentinel has no worktree root; its cwd is validated against trusted-directory grants before reaching pty:spawn. + // Floating terminals have no worktree root; trusted-directory grants validate cwd before IPC. registerPtyHandlers(mainWindow as never) await handlers.get('pty:spawn')!(null, { @@ -155,7 +155,7 @@ describe('registerPtyHandlers', () => { }) it('falls back to the worktree root when a saved local cwd no longer exists', async () => { registerPtyHandlers(mainWindow as never) - // Why: issue #7239 reproduced in a Japanese-named worktree; the fallback must return the selected worktree path verbatim. + // Preserve Unicode worktree paths verbatim when falling back (#7239). const worktreePath = '/Users/motoki/orca/workspaces/nakamuramotoki/Fableと議論' const missingCwd = `${worktreePath}/deleted-folder` statSyncMock.mockImplementation((target: string) => { @@ -533,7 +533,7 @@ describe('PTY WSL working-directory validation', () => { }) process.env.USERPROFILE = 'C:\\Users\\jinwo' - // Why: the startup-cwd guard normalizes separators, so the provider sees the forward-slash UNC form. + // Startup cwd normalization converts UNC separators before provider validation. existsSyncMock.mockImplementation((targetPath: string) => { if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') { return false @@ -578,7 +578,7 @@ describe('PTY WSL working-directory validation', () => { configurable: true, value: 'darwin' }) - // Why: this test simulates macOS even when Vitest runs on a Windows host. + // The fixture forces macOS even on Windows test hosts. process.env.HOME = '/Users/test' delete process.env.ORCA_ORIG_ZDOTDIR process.env.SHELL = '/bin/zsh' @@ -592,9 +592,7 @@ describe('PTY WSL working-directory validation', () => { expect(shell).toBe('/bin/zsh') expect(args).toEqual(['-l']) expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) - // Why absent: this HOME holds no zsh startup file, so there is no user - // config dir to hand back and Orca must not invent one — the wrapper - // leaves ZDOTDIR unset, exactly as an unwrapped login zsh would. + // Without user zsh startup files, leave ZDOTDIR unset as an ordinary login shell would. expect(options.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index 2941cb01ab5..afc492c6220 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -187,7 +187,6 @@ describe('registerPtyHandlers', () => { ) }) it('injects the OpenCode hook env into Orca terminal PTYs', async () => { - // Why: clear any ambient OPENCODE_CONFIG_DIR so the mock's value is used const env = await spawnAndGetEnv(undefined, { OPENCODE_CONFIG_DIR: undefined }) expect(openCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) expect(openCodeBuildPtyEnvMock.mock.calls[0]?.[0]).toEqual(expect.any(String)) @@ -319,7 +318,7 @@ describe('registerPtyHandlers', () => { posixOnlyIt( 'reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => { - // Why: the reporter's app didn't inherit OPENCODE_CONFIG_DIR; their interactive zsh later exported a company config repo. + // Model config exported by interactive zsh but absent from the GUI environment. readFileSyncMock.mockImplementation((path: string) => { if (path.endsWith('.zshrc')) { return [ @@ -411,7 +410,7 @@ describe('registerPtyHandlers', () => { withFreshOmpLaunch('omp', 'powershell'), withFreshOmpLaunch('omp', 'cmd') ])('threads OMP command %s through to the host integration', async (command) => { - // Why: OMP launches emit ORCA_OMP_* shadow vars, not Pi-named ones; only PI_CODING_AGENT_DIR stays (OMP's own binary reads it). + // OMP needs ORCA_OMP shadows; retain only the public Pi-named variable its binary reads. const env = await spawnAndGetEnv( undefined, { PI_CODING_AGENT_DIR: '/tmp/user-omp-agent' }, @@ -431,7 +430,6 @@ describe('registerPtyHandlers', () => { '/tmp/user-omp-agent/extensions/orca-agent-status.ts' ) expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/user-omp-agent') - // CRITICAL: a Pi-named shadow MUST NOT leak into an OMP PTY env. expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) diff --git a/src/main/ipc/pty-spawn-env-codex-home-routing.test.ts b/src/main/ipc/pty-spawn-env-codex-home-routing.test.ts index 1082a7a3d72..b8e0f5f2d31 100644 --- a/src/main/ipc/pty-spawn-env-codex-home-routing.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-home-routing.test.ts @@ -158,7 +158,6 @@ describe('registerPtyHandlers', () => { ) it('injects the agent hook receiver env into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv() - // Why: buildAgentHookEnv must run exactly once per local spawn (inside shared buildPtyHostEnv); the old ad-hoc double-call is gone. expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(1) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') @@ -336,9 +335,7 @@ describe('registerPtyHandlers', () => { }) }) it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => { - // Why: flag OFF must stay byte-identical to today. With no managed home - // selected (resolver null) and the real-home flag off, no CODEX_HOME - // injection or strip happens; an inherited value survives as before. + // With real-home routing disabled and no managed selection, preserve inherited CODEX_HOME. const env = await spawnAndGetEnv( undefined, { CODEX_HOME: '/tmp/system-codex-home' }, diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index 1cecd3a2b37..999a62c9717 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -70,10 +70,7 @@ describe('registerPtyHandlers', () => { `rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` ) - // Why: main's real provenance rule via the same prepareCodexSessionResume that - // index.ts calls, so the outcome wiring is exercised rather than restated. This - // suite mocks fs, so the rollout is declared present — the only variable left is - // whether its home is trusted, which is exactly the case the guard exists for. + // Use real resume preparation with a present rollout; trust is the only rejection variable. const prepareResumeWithTrustedHomes = (trustedHomes: readonly string[]): PrepareCodexSessionResume => ({ providerSession }) => @@ -81,8 +78,7 @@ describe('registerPtyHandlers', () => { sessionId: providerSession.id, transcriptPath: providerSession.transcriptPath, trustedCodexHomes: trustedHomes, - // Why: these cases assert the argv drop, never the legacy rescan's home ranking - // (#10801) — each passes a single trusted home, so no ranking can move the winner. + // One trusted home removes legacy rescan ranking from these argv-removal cases (#10801). getSelectedAccountCodexHome: () => null, systemCodexHomePath: null, sharedRuntimeCodexHomePath: null, @@ -123,8 +119,7 @@ describe('registerPtyHandlers', () => { })) as { agentResumeUnavailable?: true } } - /** A daemon-shaped provider: the only local path that reports reattach results and - * the only one that surfaces the resolved spawn options this guard rewrites. */ + /** Daemon-shaped provider exposes rewritten options and reattach results. */ function setupResumeDaemonProvider(spawnResult: { isReattach?: true } = {}) { const daemonSpawn = vi.fn( async (options: { @@ -156,8 +151,7 @@ describe('registerPtyHandlers', () => { posixOnlyIt( 'launches plain codex when a REAL rollout sits under a home Orca no longer trusts', async () => { - // Why: the discriminating case — the rollout exists, so only the trust check can - // reject it. Falling through would resume it under the selected account. + // The rollout exists; only provenance trust can reject cross-account resume. const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) vi.useFakeTimers() @@ -176,7 +170,6 @@ describe('registerPtyHandlers', () => { expect.stringContaining(RESUME_SESSION_ID) ) expect(spawned.agentResumeUnavailable).toBe(true) - // The pane still runs under the selected account — but with nothing to resume. const env = spawnMock.mock.calls.at(-1)![2].env as Record expect(env.CODEX_HOME).toBe(OTHER_HOME) expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex') @@ -226,8 +219,7 @@ describe('registerPtyHandlers', () => { } }) posixOnlyIt('reports a dropped resume that carried no transcript path at all', async () => { - // Why: legacy sleeping-agent and relay records persist only the session id. The - // argv still gets stripped, so silence would leave an empty pane unexplained. + // Legacy records may lack transcript paths; stripped resume arguments still need an explanation. const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) vi.useFakeTimers() @@ -250,7 +242,7 @@ describe('registerPtyHandlers', () => { } }) it('refuses an unstrippable resume whose metadata claimed Codex layout', async () => { - // Why: the locator survives in the command, so launching could still cross accounts. + // A retained locator could still resume across accounts. registerWithTrustedHomes([OTHER_HOME], OTHER_HOME) await expect( @@ -262,8 +254,7 @@ describe('registerPtyHandlers', () => { posixOnlyIt( 'launches an unstrippable resume unchanged when metadata never claimed Codex layout', async () => { - // Why: a pane mislabeled "codex" carrying ~/.claude metadata launched fine before - // this guard existed; refusing its spawn would be a new hard failure. + // Other-agent metadata must not turn a mislabeled Codex pane into a new hard failure. const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) vi.useFakeTimers() @@ -288,8 +279,7 @@ describe('registerPtyHandlers', () => { } ) it('strips the resume argv from the sequenced startup command too', async () => { - // Why: buildPtyHostEnv prefers this env var over the launch command and the - // sequenced wrapper `eval`s it, so leaving it intact resumes under the wrong account. + // Sequenced startup overrides the launch command; strip its resume arguments too. const daemonSpawn = setupResumeDaemonProvider() registerWithTrustedHomes([OTHER_HOME], OTHER_HOME) @@ -315,9 +305,7 @@ describe('registerPtyHandlers', () => { posixOnlyIt( 'strips the sequenced startup command on the local-provider spawn path too', async () => { - // Why: the daemon branch is the only one that re-derives the spawn env from - // baseEnv, so a local spawn is where a strip that lands on the wrong variable - // silently survives — and the wrapper would `eval` the resume anyway. + // Local spawning must use the stripped environment even though only daemon spawning rebuilds it. const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) registerWithTrustedHomes([OTHER_HOME], OTHER_HOME) @@ -355,8 +343,7 @@ describe('registerPtyHandlers', () => { expect(spawned.agentResumeUnavailable).toBeUndefined() }) it('drops the resume argv on the runtime controller spawn path', async () => { - // Why: the runtime/relay controller is a second spawn entry point; the invariant - // has to hold there too even though it has no channel for the notice. + // Runtime/relay spawning needs the same guard despite having no notice channel. const daemonSpawn = setupResumeDaemonProvider() const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 1c5c8cff903..c91125c218f 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -465,9 +465,7 @@ describe('registerPtyHandlers', () => { } ) it('strips inherited Claude child-session stamps from a local spawn env', async () => { - // Why: the local provider spreads main's process.env, so a GUI launched from - // inside a Claude session would stamp every pane as a nested child and Claude - // would silently disable transcript persistence. Not gated on isDaemonHostSpawn. + // Local providers inherit main's env too; Claude child stamps would disable independent-pane transcript persistence. const env = await spawnAndGetEnv(undefined, { CLAUDE_CODE_CHILD_SESSION: '1', CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', @@ -478,8 +476,7 @@ describe('registerPtyHandlers', () => { expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() }) it('strips an inherited agent session id so a pane never claims that session', async () => { - // Why: an Orca launched inside a structured session inherits its id; every pane would then - // present that session as its orchestration caller instead of its own terminal. + // Inherited structured-session IDs must not make every pane claim the parent orchestration caller. const env = await spawnAndGetEnv(undefined, { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', ORCA_STRUCTURED_SESSION: '1' @@ -524,7 +521,7 @@ describe('registerPtyHandlers', () => { expect(env.GCM_INTERACTIVE).toBe('never') }) it('advertises OSC 8 hyperlink support via FORCE_HYPERLINK', async () => { - // Why: supports-hyperlinks allowlists TERM_PROGRAM and reports false for Orca, so FORCE_HYPERLINK=1 forces detection on (xterm.js handles OSC 8 natively). + // Force OSC 8 support because supports-hyperlinks does not recognize Orca's TERM_PROGRAM. const env = await spawnAndGetEnv() expect(env.FORCE_HYPERLINK).toBe('1') }) @@ -542,7 +539,6 @@ describe('registerPtyHandlers', () => { ) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) - // Why: the app prepares a native pane's Codex home; only WSL panes run the preflight. expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() }) it('does not install the Codex launch preflight when Codex hooks are disabled', async () => { diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index e8f7e3de44b..be5f79f911c 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -170,7 +170,7 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1' }) - // Why: #7587 — the runtime can only back a stalled mobile create if the spawn threads {tabId, leafId}. + // Mobile-create recovery needs tab/leaf identity at runtime registration (#7587). expect(runtime.registerPty).toHaveBeenCalledWith( expect.any(String), 'wt-1', @@ -309,7 +309,7 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1' }) - // Why: legacy numeric pane ids (`pane:N`) aren't leaf ids, so the seam passes a clean `undefined` (no fabricated binding). + // Legacy pane:N IDs are not leaf identities; do not fabricate a binding. expect(runtime.registerPty).toHaveBeenCalledWith( expect.any(String), 'wt-1', @@ -441,8 +441,7 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(mainWindow as never, runtime as never) - // Why: no worktreeId, so the reservation key only resolves after env assembly — the - // window where a concurrent winner's reservation appears mid-preflight. + // Without worktreeId, reservation identity arrives only after environment assembly, allowing a concurrent winner. const spawnArgs = { cols: 80, rows: 24, @@ -476,7 +475,6 @@ describe('registerPtyHandlers', () => { const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2) expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle) - // The losing request must not grow the team count it can never bind to a PTY. expect(teams.getActiveTeamCount()).toBe(1) }) it('releases the Agent Teams leader when its provider spawn fails', async () => { diff --git a/src/main/ipc/pty-spawn-timing.ts b/src/main/ipc/pty-spawn-timing.ts index 3fcc12a9639..ab2086c7ee5 100644 --- a/src/main/ipc/pty-spawn-timing.ts +++ b/src/main/ipc/pty-spawn-timing.ts @@ -1,12 +1,6 @@ import { startSpan } from '../observability/tracer' -// Why: pty:spawn latency has several very different suspects (startup barrier, -// Claude auth prep, Codex resume/hook prep, account resolution, buildPtyHostEnv -// filesystem work, provider/daemon spawn). A single opt-in log line per spawn -// lets benchmarks attribute the cost without a tracing dependency. Each phase -// must name what it actually spans — `host_env` once covered the whole Codex -// preamble and pinned 2s of hook-install cost on the env builder that ran last. -// Enabled via ORCA_PTY_SPAWN_TIMING=1. +// Opt-in phase timing attributes spawn latency without tracing; mark actual spans to avoid blaming env assembly for auth/hook prep. export type PtySpawnTiming = { mark(phase: string): void diff --git a/src/main/ipc/pty-ssh-stop-verdict.test.ts b/src/main/ipc/pty-ssh-stop-verdict.test.ts index 497869511ef..628d9289f8f 100644 --- a/src/main/ipc/pty-ssh-stop-verdict.test.ts +++ b/src/main/ipc/pty-ssh-stop-verdict.test.ts @@ -54,8 +54,7 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) -// A detached relay PTY is designed to outlive the provider that addressed it, so -// "the SSH provider is gone" is never evidence that the remote process stopped. +// Detached relay PTYs outlive their provider; missing providers cannot prove remote exit. describe('stopping a PTY whose SSH provider is unregistered', () => { const { handlers, mainWindow, installObservableDaemonTestProvider } = setupPtyIpcSuite() @@ -96,7 +95,6 @@ describe('stopping a PTY whose SSH provider is unregistered', () => { const { controller, runtime } = installController() expect(controller.kill('ssh-detached-pty')).toBe(false) - // The local lease is still tombstoned so reconnect cannot revive the pane. expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-pty', -1, undefined) expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( 'ssh-detached-pty', diff --git a/src/main/ipc/pty-startup-barrier-and-listing.test.ts b/src/main/ipc/pty-startup-barrier-and-listing.test.ts index e1272ad4a7f..43ec1b6d346 100644 --- a/src/main/ipc/pty-startup-barrier-and-listing.test.ts +++ b/src/main/ipc/pty-startup-barrier-and-listing.test.ts @@ -54,7 +54,6 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, installDaemonTestProvider, installObservableDaemonTestProvider } = setupPtyIpcSuite() - // Why: the cap/flag must never fire in the common case (renderer keeps up), so small output carries no droppedBacklog. it('does not flag droppedBacklog for ordinary small output under the cap', async () => { vi.useFakeTimers() const runtime = { diff --git a/src/main/ipc/pty-startup-swap-window-presence.test.ts b/src/main/ipc/pty-startup-swap-window-presence.test.ts index 8b073c03da1..12a8743aa60 100644 --- a/src/main/ipc/pty-startup-swap-window-presence.test.ts +++ b/src/main/ipc/pty-startup-swap-window-presence.test.ts @@ -47,13 +47,7 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) -// During the cold-start daemon swap the installed local provider is still the plain -// in-process LocalPtyProvider; it does not own daemon-restored PTY ids, so its -// "no PTY" is fabricated, not observed. A confident false here tears down live -// panes (shouldReconcileMissingSession reconciles ONLY on false) and blocks -// input-undeliverable remount recovery. These suites pin: while the swap is in -// flight the presence answer is deferred (IPC) or unverifiable-null (sync), and -// the post-swap owner's answer is the one that lands. +// During daemon startup, local-provider absence cannot prove restored PTYs dead; await the owning provider or report unknown. describe('registerPtyHandlers daemon-swap-window presence', () => { const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() @@ -105,9 +99,7 @@ describe('registerPtyHandlers daemon-swap-window presence', () => { await Promise.resolve() await Promise.resolve() - // Pre-fix this has already resolved false — the pre-swap LocalPtyProvider - // answered for a PTY it does not own, and the renderer reconciler treats - // exactly that false as authority to tear the pane down. + // Defer absence until the restored session's owning provider is installed. expect(settled).toBe(false) installDaemonTestProvider({ hasPty: (id: string) => id === 'daemon-restored-pty' }) @@ -120,9 +112,7 @@ describe('registerPtyHandlers daemon-swap-window presence', () => { const barrier = makeDeferred() const controller = installRuntimeControllerWithBarrier(barrier.promise) - // Pre-fix: the pre-swap LocalPtyProvider's ptyProcesses.has() answers a - // confident false for a daemon-owned id. terminal.list then records an - // observed absence (verdict forgotten) instead of unverifiable. + // The pre-swap local provider cannot authoritatively answer for a daemon-owned PTY. expect(controller.hasPty('daemon-restored-pty')).toBe(null) installDaemonTestProvider({ hasPty: (id: string) => id === 'daemon-restored-pty' }) @@ -134,11 +124,7 @@ describe('registerPtyHandlers daemon-swap-window presence', () => { }) it('runtime controller hasPty never answers a paired-runtime handle from the local registry', () => { - // No startup barrier: the remote-handle guard must hold on its own, not - // ride on the swap-window gate. Same routing hazard the async probe and - // pty:hasPty already guard — no locally routed provider can - // authoritatively answer for a remote host's PTY, so remote-scoped ids - // stay unknown, never absent. + // Exercise remote ownership without a startup barrier; local-provider silence must still remain unknown. let controller: { hasPty: (ptyId: string) => boolean | null } | undefined registerPtyHandlers( mainWindow as never, @@ -171,11 +157,7 @@ describe('registerPtyHandlers daemon-swap-window presence', () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() - // Pre-fix this has already resolved — the pre-swap LocalPtyProvider was - // consulted about a PTY it does not own. Its non-ownership happens to read - // as unavailable today only because the inspection funnel consults hasPty - // before the provider's own inspection; completion-sensitive evidence must - // come from the post-swap owner, not from that internal ordering. + // Completion-sensitive inspection must wait for the owning provider, regardless of local hasPty ordering. expect(settled).toBe(false) installDaemonTestProvider({ @@ -194,9 +176,7 @@ describe('registerPtyHandlers daemon-swap-window presence', () => { }) it('keeps the in-process provider authoritative when no startup barrier is configured', async () => { - // Headless/orcad installs the daemon before registerPtyHandlers and passes - // no barrier; the installed provider is then the sole owner (#12393) and - // its false stays an observed absence. + // Headless hosts install their sole provider before registration; its absence is authoritative (#12393). let controller: { hasPty: (ptyId: string) => boolean | null } | undefined registerPtyHandlers( mainWindow as never, @@ -215,8 +195,6 @@ describe('registerPtyHandlers daemon-swap-window presence', () => { await expect(handlers.get('pty:hasPty')!(null, { id: 'never-spawned-pty' })).resolves.toBe( false ) - // The sole owner's inspection answer stays immediate too: with no swap in - // flight there is no window in which its word could be fabricated. await expect( handlers.get('pty:inspectProcess')!(null, { id: 'never-spawned-pty' }) ).resolves.toEqual({ diff --git a/src/main/ipc/pty-windows-shell-selection.test.ts b/src/main/ipc/pty-windows-shell-selection.test.ts index 1581148343a..37d1aa1723b 100644 --- a/src/main/ipc/pty-windows-shell-selection.test.ts +++ b/src/main/ipc/pty-windows-shell-selection.test.ts @@ -73,7 +73,7 @@ describe('registerPtyHandlers', () => { value: 'win32' }) process.env.USERPROFILE = 'C:\\Users\\test' - // Why: the spawn path resolves a bare PowerShell name to an absolute exe (PR #6537 / issue #5161); pin the probed install roots for deterministic resolution across host OS. + // Pin PowerShell probe roots so bare-name resolution is deterministic across test hosts (#5161). for (const key of ['SystemRoot', 'ProgramW6432', 'ProgramFiles', 'ProgramFiles(x86)']) { savedWindowsResolutionEnv[key] = process.env[key] } @@ -81,7 +81,7 @@ describe('registerPtyHandlers', () => { process.env.ProgramW6432 = 'C:\\Program Files' process.env.ProgramFiles = 'C:\\Program Files' delete process.env['ProgramFiles(x86)'] - // Why: .exe candidates must report isFile()/size (default mock omits them, collapsing every PowerShell candidate to cmd.exe); dirs must still report isDirectory(). + // Shell candidates need file/size evidence; directory probes must still report directories. statSyncMock.mockImplementation((target: string) => { const isExe = /\.exe$/i.test(String(target)) return { @@ -186,7 +186,7 @@ describe('registerPtyHandlers', () => { ) }) it('uses terminalWindowsShell setting over COMSPEC when provided', async () => { - // Why: COMSPEC always points to cmd.exe on stock Windows, so without the setting the terminal would ignore the user's shell preference. + // Stock COMSPEC selects cmd.exe; the saved shell preference must override it. process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' registerPtyHandlers( diff --git a/src/main/ipc/pty-write-ipc-validation.test.ts b/src/main/ipc/pty-write-ipc-validation.test.ts index be18fa855b6..55f7fcd4b26 100644 --- a/src/main/ipc/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty-write-ipc-validation.test.ts @@ -148,12 +148,12 @@ describe('registerPtyHandlers', () => { write(mainWindowIpcEvent, { id: result.id, data: 'alive' }) expect(mockProc.proc.write).toHaveBeenCalledWith('alive') - // Field failure (Discord #performance / #2836): a pane can render with a ptyId whose ownership is gone while the PTY lives, so keystrokes vanish silently. + // Missing ownership can silently drop keystrokes even while the PTY remains alive (#2836). deletePtyOwnership(result.id) write(mainWindowIpcEvent, { id: result.id, data: 'dropped' }) expect(mockProc.proc.write).not.toHaveBeenCalledWith('dropped') - // pty:listSessions rebuilds ownership from provider sessions — the revival lever the frozen-pane e2e probes depend on. + // Session listing rebuilds ownership from the provider and restores input routing. await handlers.get('pty:listSessions')!(null, undefined) write(mainWindowIpcEvent, { id: result.id, data: 'revived' }) expect(mockProc.proc.write).toHaveBeenCalledWith('revived') @@ -329,8 +329,7 @@ describe('registerPtyHandlers', () => { snapshotKittyKeyboardFlags?: number } - // The reconciled boundary covers bytes the daemon's flags were proven - // BEFORE — publishing both would erase a negotiation the pane scanned live. + // Reconciled sequence is newer than proven flags; publishing both could erase later live negotiation. expect(reply.snapshotKittyKeyboardFlags).toBeUndefined() expect(reply.snapshotSeq).toBeUndefined() }) @@ -362,16 +361,13 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-reattached' }) - // Why: a reattached shell keeps the CODEX_HOME baked in at its original - // spawn, so re-recording it under the current selection would erase the only - // evidence that the pane is stale. + // Reattach retains its original CODEX_HOME; recording current selection would hide a stale-account pane. expect(recordCodexPaneAccountMock.mock.calls).toEqual([ ['pty-fresh', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'real-home' }] ]) }) it('refreshes the WSL hook relay for the distro a reattached pane already owns', async () => { - // Why here and not only in the helper's unit test: nothing else catches pty.ts dropping the - // reattach call — the manager owns the hooks/platform gating this spy stands in for. + // Cover reattach wiring here; the relay manager's unit tests cannot detect a dropped call. const ensureForDistro = vi .spyOn(wslHookRelayManager, 'ensureForDistro') .mockImplementation(async () => {}) diff --git a/src/main/ipc/pty/configured-terminal-shell-args.ts b/src/main/ipc/pty/configured-terminal-shell-args.ts index c3ba4e989c3..5d4d0889483 100644 --- a/src/main/ipc/pty/configured-terminal-shell-args.ts +++ b/src/main/ipc/pty/configured-terminal-shell-args.ts @@ -1,7 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' -/** Why: two spawn paths (renderer IPC and runtime controller) must agree, or the - * configured profile silently applies to only one of them. */ +/** Keep configured shell profiles consistent between renderer IPC and runtime-controller spawns. */ export function resolveConfiguredTerminalShellArgs(params: { connectionId: string | null | undefined requestedShellOverride: string | undefined diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts index 4b266a7df52..557780f12bc 100644 --- a/src/main/ipc/pty/delivery/accept.ts +++ b/src/main/ipc/pty/delivery/accept.ts @@ -89,9 +89,7 @@ export function acceptPtyDataForRenderer( pending.droppedOutput === true && !overflowMarkedBeforeAppend && session.pendingOverflowMarkedPtys.has(payload.id) - // Why the 2026 release goes BEFORE the 2031 data: that payload ends with a - // deliberately-retained INCOMPLETE private-mode sequence (extractPrivateModeScanTail), - // and an ESC after it would abort the dangling CSI and lose the carried mode. + // Emit DEC 2026 release first; an ESC after the salvaged partial mode-2031 CSI would abort its continuation. const nextData = pending.data + getDroppedSynchronizedOutputRendererData(pending) + diff --git a/src/main/ipc/pty/delivery/accounting.ts b/src/main/ipc/pty/delivery/accounting.ts index 54e900e441f..faacf06643d 100644 --- a/src/main/ipc/pty/delivery/accounting.ts +++ b/src/main/ipc/pty/delivery/accounting.ts @@ -63,7 +63,7 @@ export function canSendPtyDataToRenderer( const totalLimit = PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) - // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. + // Reserve extra credit per PTY so one active pane stays responsive without enabling every background pane to burst. const ptyLimit = PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) @@ -135,7 +135,7 @@ export function requestDeliveryResyncForGatedPty(session: PtyIpcSession): void { return } clearDeliveryResyncProbe(session) - // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. + // An unanswered probe does not justify resetting accounting; warn once per silent streak. if (session.deliveryResyncUnansweredWarnLogged) { return } @@ -162,7 +162,7 @@ export function writeOffLostRendererDelivery( const received = report.receivedCharsByPty?.[id] const receivedChars = typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 - // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. + // Unparsed renderer bytes still owe deferred ACKs; do not write off their debt. if (receivedChars > accounting.ackedChars) { continue } @@ -171,7 +171,7 @@ export function writeOffLostRendererDelivery( continue } tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. + // The snapshot covers pre-marker pending bytes; flushing them would paint twice. const pending = session.pendingData.get(id) if (pending) { session.pendingDroppedChars += pending.data.length diff --git a/src/main/ipc/pty/delivery/attached-pty-size.ts b/src/main/ipc/pty/delivery/attached-pty-size.ts index 0ff548a6107..14816e28fa9 100644 --- a/src/main/ipc/pty/delivery/attached-pty-size.ts +++ b/src/main/ipc/pty/delivery/attached-pty-size.ts @@ -14,10 +14,7 @@ function positiveGrid(cols: unknown, rows: unknown): PtyGrid | undefined { : undefined } -/** Pre-attach seed for `ptySizes`. Daemon PTYs can emit before spawn() resolves, so a genuinely - * fresh session must record its geometry now or early bytes parse at xterm's 80x24 default. - * An attach must not seed: a pane that mounted while hidden reports xterm's unmeasured default, - * and the live PTY's real grid is either already cached or arrives with the attach result. */ +/** Seed fresh geometry before early bytes arrive; unmeasured attaches must preserve the live PTY grid. */ export function shouldSeedPreAttachPtySize(args: { isFreshSessionId: boolean hasCachedSize: boolean @@ -26,9 +23,7 @@ export function shouldSeedPreAttachPtySize(args: { return args.isFreshSessionId || (!args.hasCachedSize && !args.requestIsUnmeasured) } -/** Grid to record for a settled spawn. Daemon and relay attach never resize the session they hand - * back, so on a reattach the requested grid describes the pane, not the live process — take the - * provider's proven grid, then the size main already held, before trusting the request. */ +/** Attach does not resize the PTY; prefer proven provider/cached geometry over the pane request. */ export function resolveCommittedPtySize(args: { result: Pick requested: PtyGrid @@ -47,10 +42,7 @@ export function resolveCommittedPtySize(args: { type HeadlessReflow = ((ptyId: string, cols: number, rows: number) => void) | undefined -/** Reflow main's model onto the committed grid, whatever the spawn was. Why unconditional: live - * bytes can lazily create the model at the 80x24 default before the reply arrives, a seed skips an - * existing model, and the pre-attach seed is now withheld for unmeasured attaches, so a session the - * daemon re-created instead of attaching would otherwise keep the default forever. */ +/** Early bytes may create an 80x24 model; always reflow because seeding skips an existing model. */ export function reflowHeadlessTerminalToCommittedGrid(args: { result: Pick committedSize: PtyGrid @@ -63,8 +55,7 @@ export function reflowHeadlessTerminalToCommittedGrid(args: { ) } -/** Record the settled grid, then reflow the model onto it. Spawn commits call it only after - * registration admits the incarnation; the model was seeded before their first await. */ +/** Commit geometry only after registration admits this incarnation. */ export function commitAttachedPtySize(args: { result: Pick< PtySpawnResult, diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts index 3388ef153f7..b1a406fe1e8 100644 --- a/src/main/ipc/pty/delivery/debug-snapshot.ts +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -21,7 +21,7 @@ import type { PtyIpcSession } from '../session' export function buildMainDeliveryDiagnostics(session: PtyIpcSession): PtyMainDeliveryDiagnostics { const now = Date.now() - // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. + // Include PTYs gated before their first byte even when send accounting never started. const ids = new Set([ ...session.rendererDeliveryAccountingByPty.keys(), ...session.pendingData.keys(), @@ -81,7 +81,7 @@ export function readCurrentPtyRendererDeliveryDebugSnapshot( } maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) } - // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). + // Hidden gating on a renderer-reported visible pane indicates starvation. let hiddenDeliveryGatedVisiblePtyCount = 0 for (const id of visibleRendererPtys) { if (isHiddenRendererPty(id)) { @@ -149,7 +149,7 @@ export function warnIfDroppingHiddenBytesForVisiblePty( if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { return } - // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. + // Record even rate-limited warnings in freeze reports; the ring coalesces repeats. mainDeliveryBreadcrumbs.record('hidden-drop-visible', { id: redactPtyIdForDiagnostics(id), droppedChars diff --git a/src/main/ipc/pty/delivery/debug.ts b/src/main/ipc/pty/delivery/debug.ts index f9c11c19d97..3f327ae1f29 100644 --- a/src/main/ipc/pty/delivery/debug.ts +++ b/src/main/ipc/pty/delivery/debug.ts @@ -20,30 +20,29 @@ export type PtyRendererDeliveryDebugSnapshot = { peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number hiddenDeliveryGatedPtyCount: number - /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ + /** Hidden gating contradicting renderer-visible/active state should remain zero. */ hiddenDeliveryGatedVisiblePtyCount: number hiddenDeliveryGatedActivePtyCount: number deliveryInterestPtyCount: number hiddenDeliveryDroppedChars: number hiddenDeliveryDroppedChunks: number pendingDroppedChars: number - /** One-paste freeze diagnostics: per-pty delivery table + event history. */ diagnostics: PtyMainDeliveryDiagnostics - // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. + // Cleared debt reveals whether lifecycle reset recovered leaked accounting. rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number - // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. + // Boot holds precede flush-skip accounting; expose readiness and forced watchdog recovery separately. rendererPtyDispatcherReady: boolean rendererDispatcherReadyForcedCount: number } -// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). +// Lifecycle-reset and registration code both write delivery breadcrumbs. export const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() export let lastPowerSuspendAtMs: number | null = null export let lastPowerResumeAtMs: number | null = null let powerSignalBreadcrumbsInstalled = false -// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. +// Suspend/resume timestamps correlate freeze reports with display wake. export function installPowerSignalBreadcrumbs(): void { if (powerSignalBreadcrumbsInstalled) { return @@ -88,13 +87,12 @@ export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebu rendererDispatcherReadyForcedCount: 0 } -// Why null-init + wrapper fns (not `export let fn = noop`): rolldown const-folds the noop into -// call sites and drops the setter's reassignment, so bridged closures never run in the built app (STA-5661). +// Use null-initialized wrapper bridges: Rolldown can fold exported no-op functions and discard later replacements (STA-5661). let readPtyRendererDeliveryDebugSnapshotImpl: (() => PtyRendererDeliveryDebugSnapshot) | null = null let resetPtyRendererDeliveryDebugSnapshotImpl: (() => void) | null = null -// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. +// Module-scope lifecycle reset must reach registration-owned delivery accounting. let resetRendererDeliveryAccountingForLifecycleResetImpl: (() => void) | null = null -// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. +// Re-registration must cancel the outgoing closure's readiness watchdog. let clearRendererDispatcherReadyWatchdogImpl: (() => void) | null = null export function readPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index 6e9a1550172..c8dfa658268 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -80,12 +80,12 @@ function preparePtyExitForRenderer( getRendererInFlightCharsForPty(session, payload.id) > 0 ) } - // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. + // Flush before pty:exit tears down the renderer terminal and loses remaining output. const remaining = session.pendingData.delete(payload.id) clearFlushTimerIfIdle(session) if (remaining) { if (remaining.droppedOutput === true) { - // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. + // Preserve the drop flag even when only salvaged query bytes remain. sendPtyDataToRenderer(session, payload.id, { id: payload.id, data: remaining.data, @@ -125,7 +125,7 @@ function finalizePtyExitForRenderer( session.rendererCreditBeforeExitByPty.get(payload.id) ?? getRendererInFlightCharsForPty(session, payload.id) > 0 session.rendererCreditBeforeExitByPty.delete(payload.id) - // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. + // Release pause bookkeeping so reused IDs cannot inherit it. session.producerFlowControl.release(payload.id) session.pendingOverflowMarkedPtys.delete(payload.id) session.rendererDeliveryRestoreNeededPtys.delete(payload.id) @@ -136,11 +136,11 @@ function finalizePtyExitForRenderer( 0, session.rendererInFlightTotalChars - releasedRendererCredit ) - // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. + // Renderer resets cumulative ACKs on exit; reused IDs must restart aligned at zero. session.rendererDeliveryAccountingByPty.delete(payload.id) if (hadReleasableRendererCredit) { if (session.pendingDataFlushActive) { - // Why: let the open round coalesce this wake into its one post-round continuation. + // Coalesce credit-release wakeups into this round's single continuation. const reactivatedBlocked = session.pendingData.reactivateBlocked() session.pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked } else { diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts index 6c8853440c6..353c1968236 100644 --- a/src/main/ipc/pty/delivery/flush.ts +++ b/src/main/ipc/pty/delivery/flush.ts @@ -49,7 +49,7 @@ export function armDispatcherReadyWatchdog(session: PtyIpcSession): void { if (!session.mainWindow || session.mainWindow.isDestroyed()) { return } - // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. + // A lost readiness handshake must not hold output forever; unref the recovery watchdog. session.dispatcherReadyWatchdogTimer = setTimeout(() => { session.dispatcherReadyWatchdogTimer = null if ( @@ -78,7 +78,7 @@ export function clearFlushTimerIfIdle(session: PtyIpcSession): void { export function flushPendingData(session: PtyIpcSession): void { session.flushTimer = null if (!session.mainWindow || session.mainWindow.isDestroyed()) { - // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. + // Resume before wiping bookkeeping; no later drain can release these producers. session.producerFlowControl.releaseAll() session.clearDeliveryResyncProbe() session.clearPendingPtyData() @@ -103,7 +103,7 @@ export function flushPendingData(session: PtyIpcSession): void { break } const { id, pending } = selection - // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. + // The model already owns hidden bytes; discard delivery and restore from its snapshot on reveal. if (shouldDropHiddenRendererPtyData(id, settings)) { session.pendingData.remove(selection) session.pendingOverflowMarkedPtys.delete(id) @@ -128,12 +128,11 @@ export function flushPendingData(session: PtyIpcSession): void { if (pending.droppedOutput === true) { session.pendingData.remove(selection) session.updateProducerFlowControl(id) - // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). + // A pending-cap gap requires snapshot repaint; only salvaged query bytes remain in data. if ( !sendPtyDataToRenderer(session, id, { id, - // 2026 release before the 2031 data: that payload ends with a retained - // partial private-mode sequence an ESC after it would abort. + // Release DEC 2026 before salvaged partial mode-2031 CSI so a later ESC cannot abort it. data: pending.data + getDroppedSynchronizedOutputRendererData(pending) + @@ -149,9 +148,7 @@ export function flushPendingData(session: PtyIpcSession): void { } const { data } = pending const indivisible = pending.transformed === true - // Why not a blind offset: splitting inside an open DEC 2026 frame strands - // the closing \x1b[?2026l on a later flush, and xterm stops repainting until - // it arrives or its 1000ms timeout fires. + // Keep synchronized frames intact or xterm holds painting until the closing escape or its timeout. const splitAt = indivisible ? data.length : resolveSynchronizedOutputSafeSplit(data, PTY_BATCH_FLUSH_CHUNK_CHARS) @@ -212,7 +209,7 @@ export function flushPendingData(session: PtyIpcSession): void { return } if (session.pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { - // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. + // Yield between slices so background floods do not delay keystroke writes. schedulePendingDataFlush(session, writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) } } diff --git a/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts b/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts index db4922a79b5..3244bf422fa 100644 --- a/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts +++ b/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts @@ -5,28 +5,12 @@ import { interactiveOutputCharsByPty, lastInputAtByPty } from './visibility-stat const PTY_ID = 'pty-typing-burst' -/** - * A Codex composer repaint: one DEC 2026 synchronized frame per keystroke, - * carrying the closing \x1b[?2026l that releases xterm's render latch. Sized - * from a real 204x52 capture of codex-cli 0.158.0, whose per-keystroke frames - * run from a few dozen bytes up to ~1.4KB. - * - * Why the fast path matters for this shape specifically: xterm renders nothing - * while synchronized output is open and force-flushes only after 1000ms, so a - * repaint diverted to the shared batch flush timer — behind every other pane's - * output — leaves the pane visually frozen rather than merely late. - */ +/** Model a Codex synchronized repaint: delaying its closing escape can hold xterm painting until the 1000ms timeout. */ function codexFrame(bytes: number): string { return `\x1b[?2026h\x1b[1;1H${'x'.repeat(Math.max(0, bytes - 20))}\x1b[?2026l` } -/** - * Mirrors the production input path: `noteRendererPtyInput` - * (src/main/ipc/pty/ipc/write-input.ts:152-155) stamps the input time AND zeroes - * the pty's interactive budget on every keystroke. A test that only stamps the - * time would let the budget accumulate across keys and report a fast-path - * divergence that cannot happen in production. - */ +/** Mirror production: each keystroke resets both input time and its interactive-output budget. */ function noteRendererPtyInput(now: number): void { lastInputAtByPty.set(PTY_ID, now) interactiveOutputCharsByPty.set(PTY_ID, 0) @@ -44,8 +28,7 @@ describe('interactive output fast path during a continuous typing burst', () => }) it('keeps every repaint of a sustained burst on the fast path', () => { - // 90ms apart: inside INTERACTIVE_OUTPUT_WINDOW_MS, i.e. a fast typist who - // never pauses long enough to expire the window. + // Type again before the interactive window expires. const cadenceMs = INTERACTIVE_OUTPUT_WINDOW_MS - 10 const frame = codexFrame(1400) const batched: number[] = [] @@ -64,16 +47,13 @@ describe('interactive output fast path during a continuous typing burst', () => }) it('still cuts off a single keystroke that triggers a flood', () => { - // The budget is per keystroke, so it must still bound one key's blast - // radius: a TUI dumping megabytes after one keypress cannot ride the - // immediate path past the budget and starve main's timers. + // One keystroke must not let megabytes bypass batching and starve main timers. const frame = codexFrame(8 * 1024) let now = 1_000 noteRendererPtyInput(now) let admitted = 0 while (shouldSendInteractiveOutputNow(PTY_ID, frame, now + 2)) { admitted += 1 - // No new keystroke: the same key's repaints keep arriving. now += 1 } expect(admitted * frame.length).toBeLessThanOrEqual(INTERACTIVE_OUTPUT_BUDGET_CHARS) @@ -84,7 +64,6 @@ describe('interactive output fast path during a continuous typing burst', () => const frame = codexFrame(1400) const now = 1_000 noteRendererPtyInput(now) - // Unprompted output, well past the input window: throughput work, not echo. expect( shouldSendInteractiveOutputNow(PTY_ID, frame, now + INTERACTIVE_OUTPUT_WINDOW_MS + 1) ).toBe(false) diff --git a/src/main/ipc/pty/delivery/interactive.ts b/src/main/ipc/pty/delivery/interactive.ts index 6a69af426f8..0e2fe3756a9 100644 --- a/src/main/ipc/pty/delivery/interactive.ts +++ b/src/main/ipc/pty/delivery/interactive.ts @@ -10,7 +10,7 @@ export function isLikelyInteractiveRedraw(data: string): boolean { if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { return true } - // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. + // ANSI repaints are latency-sensitive; plain command output should retain throughput batching. return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') } diff --git a/src/main/ipc/pty/delivery/lifecycle-reset.ts b/src/main/ipc/pty/delivery/lifecycle-reset.ts index 475ddcadc0e..b937d8e9281 100644 --- a/src/main/ipc/pty/delivery/lifecycle-reset.ts +++ b/src/main/ipc/pty/delivery/lifecycle-reset.ts @@ -27,13 +27,12 @@ export function clearDidFinishLoadHandler(): void { } export function markRendererPtysHiddenForRendererLifecycleReset(): void { - // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') - // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. + // Visibility hints belong to the dead page; surviving PTYs must wait for fresh reports. const activePriorityChanged = activeRendererPtys.size > 0 activeRendererPtys.clear() visibleRendererPtys.clear() - // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. + // The dead page cannot ACK; retire its accounting so surviving PTYs do not remain gated. resetRendererDeliveryAccountingForLifecycleReset() if (activePriorityChanged) { invalidatePendingPtyDrainPriority() diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts index 28782310346..98af384acc7 100644 --- a/src/main/ipc/pty/delivery/payload.ts +++ b/src/main/ipc/pty/delivery/payload.ts @@ -50,7 +50,7 @@ export function sendModelRestoreNeededMarker( ...(typeof hiddenDropEpisode === 'number' ? { hiddenDropEpisode } : {}) }) } catch (error) { - // Why: a disposed render frame throws synchronously here, and this rides the data path. + // Disposed frames can throw synchronously on this data path. console.error('[pty] renderer model-restore marker send failed', error) return false } @@ -115,7 +115,7 @@ export function sendPtyDataToRenderer( session.runtime?.getPtyOutputSequence(id) ) ) { - // Why cleared only on a successful send: an unsent marker leaves the restore pending. + // Failed sends must leave restore pending. session.rendererDeliveryRestoreNeededPtys.delete(id) } return true diff --git a/src/main/ipc/pty/delivery/pending.ts b/src/main/ipc/pty/delivery/pending.ts index eb4b95dbd0a..305605667cc 100644 --- a/src/main/ipc/pty/delivery/pending.ts +++ b/src/main/ipc/pty/delivery/pending.ts @@ -51,9 +51,7 @@ export function getDroppedMode2031RendererData(pending: PendingPtyData): string return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail } -/** Releases xterm's DEC 2026 render hold when the dropped span left a frame open. - * Without it the pane freezes on its last painted frame until xterm's 1000ms - * forced flush. */ +/** Release DEC 2026 left open by dropped bytes so xterm need not wait for its forced-flush timeout. */ export function getDroppedSynchronizedOutputRendererData(pending: PendingPtyData): string { return pending.droppedSynchronizedOutputState?.active === true ? '\x1b[?2026l' : '' } @@ -72,7 +70,7 @@ export function dropOversizedPendingPtyData( console.error( `[pty] dropped ${pending.data.length} buffered chars for ${redactPtyIdForDiagnostics(id)}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` ) - // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. + // Report cap pressure without session IDs, which can contain workspace paths (#2836, #7017). recordCrashBreadcrumb('terminal_pending_output_dropped', { droppedChars: pending.data.length, capChars @@ -90,7 +88,7 @@ export function dropOversizedPendingPtyData( pending.data, INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE ) - // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). + // A trimmed tail would retain a corrupting gap; restore from snapshot and salvage only bounded query bytes. return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), droppedOutput: true, @@ -111,7 +109,7 @@ export function appendPendingPtyData( rawLength = data.length, transformed = false ): PendingPtyData { - // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. + // Once dropped, keep bounded query/mode state while the restore sentinel supersedes ordinary output. if (existing?.droppedOutput === true) { const mode2031 = scanDroppedMode2031Data( data, diff --git a/src/main/ipc/pty/delivery/producer-sync.ts b/src/main/ipc/pty/delivery/producer-sync.ts index 114b3c4f390..07a77aaed2a 100644 --- a/src/main/ipc/pty/delivery/producer-sync.ts +++ b/src/main/ipc/pty/delivery/producer-sync.ts @@ -20,8 +20,7 @@ export function syncPtyBackgroundedDelivery( id: string, caller: string ): void { - // Why runtime-owned: a runtime background spawn has no pane to report visibility, but must pace - // like a renderer hidden-at-spawn PTY; any visible report still wins. + // Runtime spawns have no visibility reporter; pace them as hidden unless a renderer reports visible. const knownHidden = session.rendererPtyIsKnownHidden(id) || (isRuntimeOwnedHiddenRendererPty(id) && !visibleRendererPtys.has(id)) @@ -45,7 +44,7 @@ export function syncPtyBackgroundedDelivery( try { provider.setPtyBackgrounded(id, background) } catch (error) { - // Why: a recorded state the provider never took would short-circuit every later sync for this PTY. + // Roll back failed provider changes so later syncs retry instead of trusting an unapplied state. if (previous === undefined) { session.backgroundedDeliverySyncByPty.delete(id) } else { diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts index ee27617a195..2aca6692fb3 100644 --- a/src/main/ipc/pty/delivery/visibility-state.ts +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -1,18 +1,16 @@ export const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 -// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. export const PRODUCER_FLOW_CONTROL_ENABLED = true -// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). +// Mobile needs PTY geometry before a renderer snapshot exists. export const ptySizes = new Map() -// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. +// Clear PTY-scoped input state on every teardown, including SSH/daemon paths without local exit callbacks. export const lastInputAtByPty = new Map() export const interactiveOutputCharsByPty = new Map() export const activeRendererPtys = new Set() export const visibleRendererPtys = new Set() export const rendererVisibilityKnownPtys = new Set() -// Layout-owner build: terminals visible window views are attaching to, so main's start of them -// stays visible; `skippedHide` records that it did, so a failed attach hides it again. +// Visible attaches suppress main-start hiding; remember it so failed attaches can hide the PTY again. export const attachingRendererViewPtys = new Map() -// Why null-init + wrapper fns: see debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661). +// Keep null-initialized wrapper bridges to avoid Rolldown no-op folding (STA-5661; see debug.ts). let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null @@ -25,7 +23,7 @@ export function invalidatePendingPtyDrainPolicy(id?: string, schedule?: boolean) } export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 export const KEEP_HISTORY_STOP_POLL_MS = 100 -// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. +// Keep using provider snapshots after daemon tail thinning; main's mirror no longer holds full history. export const providerSnapshotRequiredPtys = new Set() export function setInvalidatePendingPtyDrainPriority( diff --git a/src/main/ipc/pty/delivery/wire-session.ts b/src/main/ipc/pty/delivery/wire-session.ts index c424abb1e4e..9421e771560 100644 --- a/src/main/ipc/pty/delivery/wire-session.ts +++ b/src/main/ipc/pty/delivery/wire-session.ts @@ -118,10 +118,10 @@ export function wirePtyIpcSession(session: PtyIpcSession): void { seedPtyRendererDeliveryPeaksFromCurrentState(session) }) setResetRendererDeliveryAccountingForLifecycleReset(() => { - // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. + // Replacement views restore from main's authoritative history, so dead-page pending delivery can be discarded. session.lastLifecycleResetClearedChars = session.rendererInFlightTotalChars session.rendererLifecycleResetCount += 1 - // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. + // Resume producers before discarding the dead page's pending bytes and credits. session.producerFlowControl.releaseAll() session.clearDeliveryResyncProbe() session.deliveryResyncUnansweredWarnLogged = false @@ -130,12 +130,12 @@ export function wirePtyIpcSession(session: PtyIpcSession): void { session.clearPendingPtyData() session.pendingOverflowMarkedPtys.clear() session.rendererDeliveryRestoreNeededPtys.clear() - // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. + // Hold sends until the replacement dispatcher exists, or dropped sends recreate permanent debt. session.rendererPtyDispatcherReady = false - // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. + // Recover if the readiness handshake never arrives; a real handshake cancels the watchdog. session.armDispatcherReadyWatchdog() }) - // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). + // Re-registration must be able to cancel this closure's readiness watchdog. setClearRendererDispatcherReadyWatchdog(session.clearDispatcherReadyWatchdog) setInvalidatePendingPtyDrainPriority((id, schedule) => invalidatePendingPtyDrainClassification(session, id, schedule) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 62b576a5031..9f6f8ade87b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -35,12 +35,7 @@ import { applyManagedDataAccountEnvironment } from '../../../managed-data-accoun import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' import { applyCodexRemoteViewerClipboardEnv } from './codex-terminal-clipboard' -/** - * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. - * - * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback - * or references local filesystem paths meaningless to a remote shell. - */ +/** Mutate host-local PTY environment only for local execution; SSH cannot use these loopback endpoints or filesystem paths. */ export function buildPtyHostEnv( id: string, baseEnv: Record, @@ -71,7 +66,7 @@ export function buildPtyHostEnv( const hasLaunchCommand = typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. + // Unattended agents must fail instead of waiting on OS credential prompts. applyTerminalGitCredentialPromptGuard(baseEnv, { launchCommand: launchCommandHint, isUnattended: opts.launchAgent !== undefined, @@ -85,7 +80,7 @@ export function buildPtyHostEnv( opts.agentStatusHooksEnabled && isTuiAgentEnabled('omp', opts.disabledTuiAgents) const shouldInstallPrimeAgentExtensions = opts.agentStatusHooksEnabled && isTuiAgentEnabled('prime-agent', opts.disabledTuiAgents) - // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. + // Pi/OMP source shadows are agent-scoped to avoid cross-agent extension state. const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') const preexistingOmpAgentDir = piAgentKind === 'omp' @@ -124,14 +119,14 @@ export function buildPtyHostEnv( }) } - // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. + // Remove inherited hook coordinates before injecting the owning pane's receiver. for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { delete baseEnv[key] } if (opts.agentStatusHooksEnabled) { Object.assign(baseEnv, agentHookServer.buildPtyEnv()) if (opts.isWsl === true) { - // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. + // Windows loopback is outside WSL's NAT namespace; use the guest relay endpoint. const distro = opts.wslDistro ?? null const wslLaunchKind = explicitPiAgentKind === 'pi' || explicitPiAgentKind === 'omp' @@ -142,7 +137,7 @@ export function buildPtyHostEnv( if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint } - // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. + // OpenCode must load status hooks from its guest-resident overlay. const opencodeOverlayDir = openCodeAgent ? wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) : null @@ -159,16 +154,12 @@ export function buildPtyHostEnv( } } - // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. + // Preserve the user's Pi config/session root; install only Orca-owned extension files. if (opts.agentStatusHooksEnabled) { clearPiAgentShadowEnv(baseEnv, 'pi') clearPiAgentShadowEnv(baseEnv, 'omp') clearPiAgentShadowEnv(baseEnv, 'prime-agent') - // Why: bare shells historically defaulted to Pi + OMP shadow prep and - // created ~/./agent even when the user never launches those agents - // (#10196). Only create default homes on an explicit Pi/OMP launch; - // otherwise install only into an existing agent dir (or userData for OMP - // status so a typed `omp` still gets the shell wrapper extension). + // Bare shells must not create agent homes; use existing Pi/OMP directories or userData for OMP status (#10196). if (shouldInstallPiExtensions && piAgentKind === 'pi') { const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { materializeDefaultHome: explicitPiAgentKind === 'pi' @@ -206,7 +197,7 @@ export function buildPtyHostEnv( exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) } } else { - // Why: nested PTYs must not inherit stale source or overlay state from another agent. + // Nested PTYs must not inherit another agent's source/overlay state. restoreOrStripOverlayEnv(baseEnv, { primary: 'PI_CODING_AGENT_DIR', overlay: 'ORCA_PI_CODING_AGENT_DIR', @@ -240,14 +231,14 @@ export function buildPtyHostEnv( } } - // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. + // Keep managed Codex home selection PTY-scoped across dev/prod instances. if (opts.skipCodexHomeEnv) { delete baseEnv.CODEX_HOME delete baseEnv.ORCA_CODEX_HOME delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT } else if (opts.selectedCodexHomePath) { baseEnv.CODEX_HOME = opts.selectedCodexHomePath - // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. + // Startup files may re-export CODEX_HOME; wrappers restore this pane's selected runtime home. baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath const preflightCommand = resolveCodexShellLaunchPreflightCommand({ hooksEnabled: @@ -270,17 +261,17 @@ export function buildPtyHostEnv( } if (!opts.isWsl) { - // Why each native spawn: a codex typed in the pane reads ~/.codex, and Codex may have updated since. + // Native panes may run Codex later by typing it; recheck hooks against any updated Codex install. scheduleCodexHookReconcile() } - // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. + // An inherited WSL CLI path belongs to another launch. delete baseEnv.ORCA_WSL_CLI_DIR - // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + // WSL wrapper state and development CLI routing require the owning instance's userData root. if (opts.isWsl) { wslInteropHealthMonitor.noteActivity(opts.wslDistro ?? null) baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. + // Advertise the managed WSL CLI name for agent guidance. baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) const managedCliDir = getManagedWslCliDir(opts) if (managedCliDir) { @@ -306,14 +297,13 @@ export function buildPtyHostEnv( const cliCommand = opts.isWsl ? getWslCliCommandName(opts.isPackaged) : 'orca' baseEnv.BROWSER = `${cliCommand} open-url --url %s` } - // Why the browser-relay flag: both mean a headless runtime whose viewer is another machine. + // Browser-relayed and SSH-managed headless hosts both have viewers on another machine. applyCodexRemoteViewerClipboardEnv(baseEnv, { launchAgent: opts.launchAgent, viewerIsRemote: opts.routeBrowserOpensToClient === true }) - // Why: must run after the prepends above — they re-read PATH from the unscrubbed - // process.env when baseEnv carries none, which is the daemon path's normal shape. + // Scrub after prepends: sparse daemon env may make them reread unsanitized ambient PATH. stripLegacyTerminalShimEnv(baseEnv, process.platform) if (!opts.isWsl) { diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index 3b79c0e7876..759184aa781 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -24,9 +24,7 @@ export function shouldSkipCodexHomeEnvForWindowsShell( return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) } -// Why: with the real-home flag ON, a host system-default launch resolves to a -// null managed home. Signal the env builder to strip a nested-Orca-inherited -// override instead of injecting one, so Codex runs on the user's own ~/.codex. +// System-default real-home launches must remove inherited Orca overrides when no managed home is selected. export function shouldStripInheritedOrcaCodexHome(args: { target: CodexAccountSelectionTarget selectedCodexHomePath: string | null @@ -40,19 +38,14 @@ export function shouldStripInheritedOrcaCodexHome(args: { export const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const -// Why: system-default real-home routing runs Codex on the user's own ~/.codex. -// Nested Orca panes inherit the parent's Orca-owned override; strip only that -// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop -// the marker so a shell-ready wrapper cannot restore the managed home. A -// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). +// Remove only overrides matching Orca's private marker, then drop the marker so wrappers cannot restore them; preserve user CODEX_HOME (#8606). export function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { delete baseEnv[key] } } -// Why: in-process spawns share main's inherited environment, so equality with -// the private marker is authoritative here. Persistent daemons compare locally. +// Local spawns share this environment; persistent daemons must compare their own inherited values. export function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME @@ -200,12 +193,7 @@ export function codexHomePathsEqual(left: string | null, right: string): boolean return codexHomeSelectionsEqual(left, right) } -// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon -// keeps that shell alive across app restarts, so the launch account is the only -// way to tell later that a pane still runs Codex as the previously selected -// account. A reattach inherits that baked environment rather than choosing one, -// so re-recording it under the current selection would erase the very evidence -// that the pane is stale. +// Reattach retains the shell's original CODEX_HOME; recording the current selection would hide stale-account panes. export function recordCodexPaneAccountForSpawn(args: { ptyId: string | undefined isDaemonHostSpawn: boolean diff --git a/src/main/ipc/pty/host-env/codex-hook-reconcile-spawn.test.ts b/src/main/ipc/pty/host-env/codex-hook-reconcile-spawn.test.ts index 37c707cfb78..240a009503a 100644 --- a/src/main/ipc/pty/host-env/codex-hook-reconcile-spawn.test.ts +++ b/src/main/ipc/pty/host-env/codex-hook-reconcile-spawn.test.ts @@ -33,8 +33,7 @@ vi.mock('../../../codex/codex-hook-reconcile', () => ({ import { buildPtyHostEnv } from './assembly' -// Why this file: a codex typed in a native pane reads ~/.codex, so each native -// spawn rechecks Orca's entry there; a WSL pane's Codex never reads it. +// Typed Codex commands use native ~/.codex; reconcile on native spawns, never Windows-side for WSL panes. let root: string let options: BuildPtyHostEnvOptions diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index 463660e00d7..268f5fca885 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -55,8 +55,7 @@ export function prepareCodexResumeHome( } } -/** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: - * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ +/** Keep non-Codex preparation synchronous: an extra await changes pane-reservation race ordering. */ export function noCodexResumeLaunch(command: string | undefined): CodexResumeLaunch { return { codexResumeHome: null, @@ -67,8 +66,6 @@ export function noCodexResumeLaunch(command: string | undefined): CodexResumeLau } } -/** The command a Codex launch actually runs: unchanged when provenance is verified, - * stripped of `resume ` when it is not. */ export function resolveCodexResumeLaunch( command: string | undefined, preparation: PreparedCodexResumeHome @@ -92,8 +89,7 @@ export function resolveCodexResumeLaunch( return { codexResumeHome: null, command: dropped.command, - // Why: staying silent only makes sense for metadata that positively belongs to - // another agent; a resume with no transcript path at all still owes the user a notice. + // Notify missing provenance too; only positively other-agent metadata can fail silently. notifyResumeUnavailable: dropped.droppedResumeArgv && (prepared.claimedCodexProvenance || !providerSession.transcriptPath), @@ -117,8 +113,7 @@ export async function reconcileSharedRuntimeResumeHome( return resumeHome.codexHomePath } -/** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command - * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ +/** Sequenced startup overrides the launch command and is evaluated by the wrapper; strip its resume arguments too. */ export function stripSequencedStartupResumeArgv | undefined>( env: T, launch: CodexResumeLaunch diff --git a/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts b/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts index 91ecfc523db..bbcac2ed386 100644 --- a/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts +++ b/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts @@ -1,19 +1,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' -/** - * Codex (rust-v0.162.1 `clipboard_copy.rs`) copies to the clipboard of the machine it - * runs on and sends OSC 52 to the terminal only when SSH_TTY, SSH_CONNECTION or TMUX - * is set. SSH_CONNECTION is the least side-effectful trigger: TMUX makes Codex shell - * out to tmux and wrap OSC 52 in tmux passthrough, and SSH_TTY names a tty device. - * Syntactically valid (client ip/port, server ip/port) for tools that parse it. - */ +/** Codex v0.162.1 emits OSC 52 under SSH/TMUX; a valid SSH_CONNECTION avoids tmux subprocess/passthrough and SSH_TTY device semantics. */ export const CODEX_REMOTE_VIEWER_SSH_CONNECTION = '127.0.0.1 0 127.0.0.1 0' -/** - * Makes an Orca-launched Codex copy through the viewer's terminal (OSC 52) when the - * viewer is on another machine, so selection copy and Ctrl+C reach the user's clipboard. - * Scoped to Codex launches: bare shells keep their real SSH posture. (#23530) - */ +/** Route Orca-launched Codex clipboard writes to remote viewers via OSC 52; preserve bare-shell SSH state (#23530). */ export function applyCodexRemoteViewerClipboardEnv( env: Record, options: { @@ -26,7 +16,7 @@ export function applyCodexRemoteViewerClipboardEnv( return } const inherited = options.inheritedEnv ?? process.env - // Why process.env too: daemon spawns merge the host's inherited env after this build. + // Daemons merge their inherited environment after assembly; respect existing SSH state there too. const alreadySsh = ['SSH_CONNECTION', 'SSH_TTY'].some( (key) => env[key] !== undefined || inherited[key] !== undefined ) diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts index 71b62886fa8..25f3d865a41 100644 --- a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -47,7 +47,7 @@ export function beginPtySpawnForWorktree( finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) } } catch (error) { - // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. + // Worktree identity and cwd may name different roots; unwind prior admissions before rejecting. for (let index = finishes.length - 1; index >= 0; index -= 1) { finishes[index]!() } diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 21605465bb8..d389e51c295 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -468,9 +468,7 @@ it.each([true, false])( } ) -// Why: pre-1.4.209 panes exported Orca's retired /opencode-hooks/shared dir. OpenCode 2 -// treats OPENCODE_CONFIG_DIR as the only config dir, so inheriting it loaded a stale plugin and hid -// the user's global config. +// Retired shared overlays can hide user config and load stale plugins in OpenCode 2; clear inherited copies. describe.each([ { name: 'marked', marked: true }, { name: 'unmarked', marked: false } diff --git a/src/main/ipc/pty/host-env/path.ts b/src/main/ipc/pty/host-env/path.ts index e9091864464..4baf393afc1 100644 --- a/src/main/ipc/pty/host-env/path.ts +++ b/src/main/ipc/pty/host-env/path.ts @@ -23,8 +23,7 @@ export function promoteAgentTeamsShimPath( return } const shimPath = firstPathEntry(requestedPath) - // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that - // reached the front would be re-prepended here and outlive the scrub. + // requestedPath predates cleanup; re-prepending a legacy entry would undo the scrub. if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { return } @@ -33,7 +32,7 @@ export function promoteAgentTeamsShimPath( const remaining = currentPath .split(delimiter) .filter((entry) => entry.length > 0 && entry !== shimPath) - // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. + // Agent Teams must resolve Orca's tmux shim before any real tmux, including after host prepends. env[currentPathKey] = [shimPath, ...remaining].join(delimiter) } diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index cdf489740ea..3088f3fe0a2 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -43,7 +43,7 @@ export function resolvePiAgentSourceDir( const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) - // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. + // Source-less inherited overlays must not be mirrored into another agent's tree. if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { return publicDir } @@ -109,7 +109,6 @@ export function exposePiManagedExtensionEnv( } } -// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. export function mergePtyEnvDeletions( existingKeys: string[] | undefined, ...additionalKeyGroups: readonly (readonly string[])[] @@ -121,7 +120,7 @@ export function mergePtyEnvDeletions( } export function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { - // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + // Resume provenance selects the home; late deletions must not fall back to current account. const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') return filtered?.length ? filtered : undefined } @@ -130,7 +129,7 @@ export function getInheritedAgentHookEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} - // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. + // Providers later merge ambient env; delete stale hooks without removing freshly injected coordinates. return [ ...AGENT_HOOK_RUNTIME_ENV_KEYS, 'ORCA_OPENCODE_AGENT', @@ -143,8 +142,7 @@ export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} - // Why: a caller that explicitly provides a Claude stamp (a nested Claude child) keeps it; no - // terminal is a structured session, so the session caller keys always go. + // Explicit nested-Claude stamps survive; structured-session caller keys never belong to terminal panes. return [ ...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined), ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS @@ -183,7 +181,7 @@ export function resolveOpenCodeSourceConfigDir( return sourceDir } - // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. + // Matching public/private paths identify Orca-owned state, not user config to mirror into child Orcas. if (configDir && orcaConfigDir && configDir === orcaConfigDir) { return undefined } diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 75bb7a60cd0..1b90b5f6a2f 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -16,10 +16,7 @@ import { } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' import { listLiveDaemonPtyIds } from '../../../daemon/daemon-provider-state' -/** - * A new PTY needs a workspace no WSL move is taking, and a folder that still exists. Reattaching to - * a session the daemon still runs is not a new PTY: a reload mid-move must not orphan a live pane. - */ +/** WSL moves block new PTYs, but proven daemon reattach must survive reload; folder workspaces still require usable paths. */ export function assertWorkspaceAcceptsNewPty( store: Store | undefined, worktreeId: string | undefined, @@ -71,11 +68,11 @@ export function resolvePtySpawnStartupCwd( } export async function localStartupCwdDirectoryExists(path: string): Promise { - // Why: Win32 stat on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. + // Win32 9P stat can falsely report WSL-share absence; defer to WSL-aware provider validation. if (isWslUncPath(path)) { return true } - // Why async: a statSync on a dead network mount would freeze the whole main process. + // A dead network mount must not block main's event loop. try { return (await stat(path)).isDirectory() } catch { diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index ed070c7d043..64697b300e0 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -8,18 +8,18 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_VERSION', 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT', - // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. + // Remove the reverted inherited path still present in older PTYs (#2778). 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' ] as const -// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +// Inherited Claude child stamps would disable independent-pane transcript persistence. export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID' ] as const -// Why: Orca writes these only into a structured session's own spawn, so an inherited value means a pty host launched from inside one — every pane would claim that session as its orchestration caller. +// Inherited structured-session IDs would make every pane claim the parent orchestration caller. export const ORCA_AGENT_SESSION_CALLER_ENV_KEYS = [ ORCA_AGENT_SESSION_ID_ENV, ORCA_STRUCTURED_SESSION_ENV diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index d7564184e49..46fb305da41 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -13,20 +13,16 @@ export type BuildPtyHostEnvOptions = { userDataPath: string selectedCodexHomePath: string | null skipCodexHomeEnv?: boolean - /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, - * and strip only an inherited Orca-owned override so nested Orca panes do not - * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ + /** Real-home routing strips only inherited Orca-owned Codex overrides, preserving user CODEX_HOME. */ stripInheritedOrcaCodexHome?: boolean - /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent - * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from - * disk presence (cross-agent shadowing when both dirs exist). */ + /** Explicit launch command selects Pi/OMP extension targets; never infer agent identity from directories on disk. */ launchCommand?: string /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ launchAgent?: TuiAgent /** Selected execution shell, which may differ from inherited SHELL. */ shellPath?: string isWsl?: boolean - /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ + /** WSL relay target; null selects the default distro. Read only for WSL spawns. */ wslDistro?: string | null agentStatusHooksEnabled: boolean terminalInlineImages?: boolean @@ -41,13 +37,10 @@ export type BuildPtyHostEnvOptions = { export type CodexHomeLaunchContext = { unavailableManagedHomePath?: string - /** An Orca-launched Codex, which may wait briefly for its status hook; other spawns never do. */ launchesCodex?: boolean } -// Why (#16441): Codex launch prep grants hook trust through a codex app-server -// session. It resolves asynchronously so the Electron main thread stays -// responsive; every consumer already runs inside an async spawn path. +// Codex hook-trust preparation may await app-server I/O without blocking main (#16441). export type GetSelectedCodexHomePath = ( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, diff --git a/src/main/ipc/pty/ipc/claude-diff-panel-notice.ts b/src/main/ipc/pty/ipc/claude-diff-panel-notice.ts index a67151351b1..b92119273f0 100644 --- a/src/main/ipc/pty/ipc/claude-diff-panel-notice.ts +++ b/src/main/ipc/pty/ipc/claude-diff-panel-notice.ts @@ -11,8 +11,7 @@ import { tryGetProviderForPty } from '../provider/registry' type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } -/** The CLAUDE_CONFIG_DIR a host pane's `claude` sees: the selected account's home, else the - * user's own; undefined when it runs on the CLI default. */ +/** Resolve the host account home, preserving CLI defaults when no config override exists. */ function hostPaneClaudeConfigDirEnv(): string | undefined { const router = getClaudeProfileRouter() return router @@ -20,13 +19,12 @@ function hostPaneClaudeConfigDirEnv(): string | undefined { : process.env.CLAUDE_CONFIG_DIR?.trim() || undefined } -/** Whether Claude in a local host pane may open its diff panel unasked; false for any other pane. */ export function installPtyClaudeDiffPanelNoticeIpcHandler(deps: Deps): void { getPtyIpc().handle( 'pty:claudeDiffPanelMayAutoOpen', async (_event, args: { id: unknown; target?: unknown }): Promise => { const id = args?.id - // Why host panes only: a WSL or SSH pane's Claude keeps its config on that other host. + // WSL/SSH Claude config belongs to the execution host. if ( typeof id !== 'string' || !isHostTarget(args?.target) || @@ -37,7 +35,7 @@ export function installPtyClaudeDiffPanelNoticeIpcHandler(deps: Deps): void { return false } try { - // Why: the pre-swap provider does not own restored daemon ids. + // Restored daemon IDs are not owned by the pre-swap provider. await deps.getLocalPtyProviderStartupPromise() const [version, size] = await Promise.all([ probeRecentClaudeCliVersion(resolveClaudeCommand()), diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts index 0164b4a7952..1459e59a51a 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts @@ -63,7 +63,6 @@ it('answers true only for a pane whose daemon lacks the claude function', async expect(await ask({}, { id: 'old-1', target: HOST })).toBe(true) expect(await ask({}, { id: 'v43-1', target: HOST })).toBe(true) expect(await ask({}, { id: 'v42-1', target: HOST })).toBe(false) - // A pane on this build's daemon, or an SSH pane, which no local daemon owns. expect(await ask({}, { id: 'new-1', target: HOST })).toBe(false) expect(await ask({}, { id: 'ssh:conn-1:pty-1', target: HOST })).toBe(false) expect(await ask({}, { id: 42, target: HOST })).toBe(false) @@ -74,12 +73,10 @@ it("judges a pane by its own runtime's selection, and hides it when that is unkn const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! mocks.runsAnotherAccount = false expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) - // A WSL pane asks its distro's router, not the host selection. const ubuntu = { runtime: 'wsl', wslDistro: 'Ubuntu' } expect(await ask({}, { id: 'old-1', target: ubuntu })).toBe(true) mocks.wslRunsAnotherAccount = false expect(await ask({}, { id: 'old-1', target: ubuntu })).toBe(false) - // No account selected for the pane's runtime, no router, or no runtime at all: hidden. mocks.runsAnotherAccount = null expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) expect(await ask({}, { id: 'old-1', target: { runtime: 'wsl', wslDistro: 'Debian' } })).toBe( diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts index 04ad8c58a02..2ef2456ba50 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -9,7 +9,7 @@ import { } from '../../../daemon/daemon-protocol-version' import { isTerminalOnLegacyDaemon } from '../../../daemon/daemon-provider-state' -// Why not every older daemon: v42 shipped the same claude function and pointer; only v43, the revert, lacked them. +// v42 shipped this function; only earlier protocols and the v43 revert lack it. function lacksClaudeFunction(protocolVersion: number): boolean { return ( protocolVersion < CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION || @@ -19,7 +19,6 @@ function lacksClaudeFunction(protocolVersion: number): boolean { type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } -/** Whether a pane's daemon predates the claude function, so its claude runs another account. */ export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { getPtyIpc().handle( 'pty:openedBeforeClaudeAccounts', @@ -28,12 +27,12 @@ export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { if (typeof args?.id !== 'string' || !target) { return false } - // Why: the pre-swap provider does not own restored daemon ids. + // Restored daemon IDs are not owned by the pre-swap provider. await deps.getLocalPtyProviderStartupPromise() if (!isTerminalOnLegacyDaemon(args.id, lacksClaudeFunction)) { return false } - // Why the pane's own runtime: claude there runs that runtime's System default. + // Use the pane's runtime account selection, including its WSL distro. const runsAnother = target.runtime === 'wsl' ? await getClaudeWslProfileRouter() diff --git a/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts b/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts index ab594040552..9e87f3e9bf4 100644 --- a/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts +++ b/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts @@ -2,17 +2,13 @@ import { isClosedPaneFenced } from '../../../persistence/terminal-topology/close import type { Store } from '../../../persistence' import { layoutContainsLeafId } from '../../../../shared/workspace-layout/terminal-pane-tree' -/** - * Layout-owner build: a spawn for a pane main closed or took back, which main's layout no longer - * holds, starts nothing. Its binding would be refused anyway (pty-binding-refusals.ts); refusing - * first means its startup command never runs. - */ +/** Refuse closed/reclaimed panes before spawning; a later binding refusal cannot undo a startup command. */ export function refuseClosedPaneSpawn( store: Store | undefined, tabId: string | undefined, leafId: string | null | undefined ): void { - // The fence's own key: legacy non-UUID leaf ids are matched, never thrown on. + // Match the fence's legacy non-UUID keys without throwing. if (!tabId || !leafId || !isClosedPaneFenced(`${tabId}:${leafId}`)) { return } diff --git a/src/main/ipc/pty/ipc/codex-shared-server.test.ts b/src/main/ipc/pty/ipc/codex-shared-server.test.ts index c15b1bf49b9..fa29459a14a 100644 --- a/src/main/ipc/pty/ipc/codex-shared-server.test.ts +++ b/src/main/ipc/pty/ipc/codex-shared-server.test.ts @@ -41,7 +41,6 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership } from '../provider/ownership-state' import { installPtyCodexSharedServerIpcHandler } from './codex-shared-server' -// Each channel with its answer for a local pane and its answer when it refuses one. const CHANNELS = [ ['pty:isCodexOnSharedServer', { joined: true, openedBeforeWrapper: false }, { joined: false }], ['pty:disableCodexSharedServerAutoStart', true, false], diff --git a/src/main/ipc/pty/ipc/codex-shared-server.ts b/src/main/ipc/pty/ipc/codex-shared-server.ts index b106fe21dbb..2b3b749cb4c 100644 --- a/src/main/ipc/pty/ipc/codex-shared-server.ts +++ b/src/main/ipc/pty/ipc/codex-shared-server.ts @@ -19,8 +19,7 @@ import { ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, hasPtyProviderForInspection } from '../provider/registry' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -// Why per shell: a new terminal fixes Codex only where this build's daemon gives that shell Orca's -// codex function; cmd.exe and unrecognized shells never get one. +// Wrapper availability depends on shell and daemon version; cmd.exe and unknown shells have none. const CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL: ReadonlyMap = new Map([ ['zsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], ['bash', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], @@ -31,9 +30,8 @@ const CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL: ReadonlyMap = new type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } -/** The pane's root pid when it is a local, non-WSL pane; otherwise null. */ async function findLocalPaneRootPid(deps: Deps, id: unknown): Promise { - // Why local only: SSH and WSL panes run Codex on another host, which must answer for itself. + // WSL/SSH Codex state belongs to the execution host. if ( typeof id !== 'string' || isRemoteRuntimePtyId(id) || @@ -42,7 +40,7 @@ async function findLocalPaneRootPid(deps: Deps, id: unknown): Promise Promise) { } } -// Why its own read: only a pane already showing Codex asks, so no cadence poll pays for argv. +// Only visible Codex panes request this argv read; keep it off cadence polling. export function installPtyCodexSharedServerIpcHandler(deps: Deps): void { handleLocalPane(deps, 'pty:isCodexOnSharedServer', readPaneSharedServerStatus, { joined: false }) handleLocalPane( diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 1e03af6a877..f2451de5de2 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -27,10 +27,7 @@ export function installPtyInspectIpcHandlers(deps: { const ipcMain = getPtyIpc() const { getLocalPtyProviderStartupPromise } = deps - // Why: wait for daemon startup before selecting the local provider for an id - // the swap may re-own (#7742); ids owned by an SSH connection never wait. - // renderer-kill.ts inlines this — pty:kill's listener teardown is - // ordering-sensitive and must not gain even a no-barrier microtask. + // Await daemon ownership handoff for local IDs; renderer-kill inlines this to preserve same-turn teardown without a barrier. const awaitSwapWindow = async (id: string): Promise => { await getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(id)) } @@ -64,8 +61,7 @@ export function installPtyInspectIpcHandlers(deps: { } seen.add(value) const provider = tryGetProviderForPty(value) - // Resolved providers without the optional method, and hosts no provider here can serve, are - // definitively non-authoritative; an unattached SSH relay remains retryable. + // Missing inspection support is non-authoritative; unattached SSH relays remain retryable. capabilities.push({ id: value, authoritative: !provider @@ -83,13 +79,10 @@ export function installPtyInspectIpcHandlers(deps: { ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { if (typeof args?.id !== 'string' || isRemoteRuntimePtyId(args.id)) { - // Why before the swap wait: a runtime terminal handle runs on another host, so only - // "unverifiable" is honest and the local daemon startup is irrelevant to it. + // Remote runtime handles need no local startup wait and cannot be inspected authoritatively here. return null } - // Why: the pre-swap LocalPtyProvider does not own restored daemon ids, and - // its "no PTY" is exactly the false the renderer reconciler is allowed to - // close panes on. + // Pre-swap local absence could falsely authorize the renderer to close restored panes. await awaitSwapWindow(args.id) const provider = tryGetProviderForPty(args.id) if (!provider?.hasPty) { @@ -99,10 +92,10 @@ export function installPtyInspectIpcHandlers(deps: { if (provider.hasPty(args.id)) { return true } - // Why: hasPty only knows sessions attached this run, so a session that survived a restart reads absent until the host answers. + // hasPty sees only this run's attachments; probe the host for sessions surviving restart. return provider.probePtyLiveness ? await provider.probePtyLiveness(args.id) : false } catch { - // Why: liveness is only allowed to close panes on an authoritative false. + // Only authoritative false may close a pane. return null } }) @@ -138,13 +131,11 @@ export function installPtyInspectIpcHandlers(deps: { steadyState?: boolean } ) => { - // Why: as pty:hasPty — another host's PTY reads as client-only unverifiable, not a raised IPC error. + // Another host's PTY remains unverifiable here. if (typeof args?.id !== 'string' || !args.id || isRemoteRuntimePtyId(args.id)) { return clientOnlyUnverifiableInspection('terminal_gone') } - // Why: the pre-swap LocalPtyProvider does not own restored daemon ids, so - // nothing it reports about one is an observation; the post-swap owner must - // answer completion-sensitive inspection. + // Completion-sensitive inspection must wait for the provider that owns restored daemon sessions. await awaitSwapWindow(args.id) if (!hasPtyProviderForInspection(args.id)) { return clientOnlyUnverifiableInspection('terminal_gone') @@ -169,12 +160,12 @@ export function installPtyInspectIpcHandlers(deps: { return null } const provider = getProviderForPty(args.id) - // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. + // Cached foreground identity cannot authorize a command-boundary decision. return provider.confirmForegroundProcess?.(args.id) ?? null } ) - // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. + // Split panes inherit live shell cwd; empty means unknown and lets the renderer fall back. ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { try { return await getProviderForPty(args?.id).getCwd(args.id) @@ -183,26 +174,24 @@ export function installPtyInspectIpcHandlers(deps: { } }) - // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. + // Report applied geometry so the renderer can detect and retry a dropped resize. ipcMain.handle( 'pty:getSize', async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { const provider = tryGetProviderForPty(args?.id) try { if (provider?.getAppliedSize) { - // Why: a provider-owned null means it could not verify the applied - // grid; preserve null so the renderer re-forwards instead of trusting - // the requested-size cache that may describe a dropped resize. + // Provider-owned null preserves uncertainty; a requested-size cache may describe a dropped resize. return await provider.getAppliedSize(args.id) } } catch { - // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. + // Provider failure must not escape IPC; retain requested size as a fallback. } return ptySizes.get(args?.id) ?? null } ) - // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. + // Predeclare serializer ownership before spawn; see docs/mobile-prefer-renderer-scrollback.md. ipcMain.handle( 'pty:declarePendingPaneSerializer', async (event, args: { paneKey?: unknown }): Promise => { @@ -221,7 +210,7 @@ export function installPtyInspectIpcHandlers(deps: { } const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. + // Keep generation-to-PTY binding through late teardown; reverse pane maps may already be gone. pendingPtyIdBySerializerGeneration.delete(args.gen) if (settledCurrentGeneration && ptyId) { rendererSerializerReadiness.markReady(ptyId) @@ -250,7 +239,7 @@ export function installPtyInspectIpcHandlers(deps: { ) { return } - // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. + // Remote-runtime panes bypass local spawn coordination; readiness must use their exact PTY ID. rendererSerializerReadiness.markReady(args.ptyId) } ) diff --git a/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts b/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts index 162604b1a9e..6442f3b1de9 100644 --- a/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts +++ b/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts @@ -1,5 +1,4 @@ -// A view's painted-restore ack releases the terminal service's in-memory copy. Layout-owner builds -// release it only for the terminal main binds the named pane to now; no view, no ack. +// Painted-restore ACK releases only the terminal currently bound to the named pane in main-owned layout. import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PaintedRestoreAck } from '../../../../shared/painted-restore-ack' diff --git a/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts b/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts index cf0482a7c8a..618618fd120 100644 --- a/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts +++ b/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts @@ -14,10 +14,7 @@ function isPane(value: unknown): value is PaintedRestorePane { ) } -/** - * A view painted a cold restore: drop the terminal service's in-memory copy (checkpoint.json - * keeps the history across restarts). No view, no ack. - */ +/** Release in-memory cold restore only after painting; checkpoint.json retains restart history. */ export function acknowledgePaintedColdRestore( args: Partial | null | undefined, isPaneBoundTo: ((pane: PaintedRestorePane, ptyId: string) => boolean) | undefined @@ -26,7 +23,7 @@ export function acknowledgePaintedColdRestore( if (typeof id !== 'string' || !id) { return } - // Switch point: main releases it only for the pane's current terminal; a stale view's ack keeps it. + // A stale view cannot release the current pane terminal's restore cache. if (ORCA_LAYOUT_OWNER_IS_MAIN && !(isPane(args?.pane) && isPaneBoundTo?.(args.pane, id))) { return } diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index f0993286d06..c9a2a700545 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -35,8 +35,6 @@ export type PtyKillIpcDeps = { sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void } -/** `pty:kill` — the route the renderer takes when the user closes a terminal tab, and a separate - * implementation from `killPtyFromRuntimeController`. */ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { getPtyIpc().handle('pty:kill', (_event, args: { id: string; keepHistory?: boolean }) => stopRendererOwnedPty(deps, args) @@ -46,18 +44,17 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { ) } -/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the - * renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */ +/** Label successful replacement stops as handoff; failed stops leave no label. */ export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise { await stopRendererOwnedPtyAs(deps, { id }, 'replaced') } -/** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */ +/** Settle only after shutdown is observed or synthesized. */ export async function stopRendererOwnedPty( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } ): Promise { - // Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding. + // Hibernation must retain the pane's wake binding. await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null) } @@ -67,7 +64,7 @@ async function stopRendererOwnedPtyAs( intentionalStop: TerminalIntentionalStopKind | null ): Promise { if (typeof args?.id !== 'string' || !args.id || isRemoteRuntimePtyId(args.id)) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + // Runtime handles belong to terminal.close; unowned routing could target a local provider. throw new Error('Invalid PTY provider id') } const settleStop = intentionalStop @@ -102,16 +99,14 @@ async function stopRendererOwnedPtyProcess( await bridgedStop return } - // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). + // Await daemon ownership handoff before shutdown or fallback can orphan restored PTYs (#7742). const startupPromise = getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(args.id)) if (startupPromise) { await startupPromise } const provider = tryGetProviderForPty(args.id) if (!provider && connectionId) { - // Why: detached SSH PTYs intentionally keep ownership after their - // provider is unregistered; hydrated app-scoped ids can also arrive - // before ownership is rebuilt. Tombstone instead of falling back local. + // Detached/unhydrated SSH ownership must be tombstoned, never routed to local fallback. const unproven = PtyRetirementWithoutProof.because(SSH_PROVIDER_UNREGISTERED_REASON) const incarnationId = finishPtyShutdown(args.id, connectionId, store, unproven) publishSyntheticExit(deps, args.id, -1, incarnationId, unproven) @@ -131,13 +126,12 @@ async function stopRendererOwnedPtyProcess( ) } catch (err) { if (!isPtyAlreadyGoneError(err)) { - // Why: a failed shutdown can leave the local daemon's process alive; keep ownership so a retry can reach it. + // Failed shutdown may leave a live process; keep ownership so retry can reach it. throw err } proof = PtyExitProof.from('absent', stoppingIncarnationId) } - // Why: some shutdown paths do not emit onExit through the provider listener. - // Explicit cleanup is idempotent and covers already-dead PTYs. + // Some shutdown paths omit onExit; idempotent cleanup must cover them too. const incarnationId = finishPtyShutdown(args.id, connectionId, store, proof) if (proof.source !== 'exit-observed') { publishSyntheticExit(deps, args.id, -1, incarnationId, proof) diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index eb75483c6c5..1b4fcee5b3d 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -37,14 +37,14 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { const ipcMain = getPtyIpc() const { runtime, mainWindow } = session - // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. + // Fire-and-forget resize avoids an empty acknowledgement round trip. ipcMain.removeAllListeners('pty:resize') ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { - // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. + // Fit overrides remeasure all panes; suppress the cascade so background full-width measurements cannot resize PTYs. if (runtime?.isResizeSuppressed()) { return } - // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. + // Enforce viewer-owned geometry in main too; renderer presence state lags by one IPC hop (docs/mobile-presence-lock.md). const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true if (mobileOwnsResize || remoteDesktopOwnsResize) { @@ -66,13 +66,13 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { runtime?.onExternalPtyResize(args.id, args.cols, args.rows) }) - // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. + // Measurement-only reports update desktop restore geometry while viewer-owned resizing is blocked (docs/mobile-fit-hold.md). ipcMain.removeAllListeners('pty:reportGeometry') ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { runtime?.recordRendererGeometry(args.id, args.cols, args.rows) }) - // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. + // Painted-restore acknowledgement clears daemon cold-restore cache without a response. ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.on('pty:ackColdRestore', (_event, args: Partial | undefined) => { acknowledgePaintedColdRestore(args, (pane, ptyId) => @@ -80,8 +80,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ) }) - // Why the ack and not the serve: the pane can dispose across the serialize + IPC round trip, - // so the drop latch stays armed until the restored bytes are actually painted (#15504). + // Snapshot requests may outlive their pane; retire drop memory only after painted restore (#15504). ipcMain.removeAllListeners('pty:hiddenOutputRestoreApplied') ipcMain.on( 'pty:hiddenOutputRestoreApplied', @@ -95,19 +94,19 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } ) - // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. + // Bound renderer delivery independently of provider/runtime ingestion. ipcMain.removeAllListeners('pty:ackData') ipcMain.on( 'pty:ackData', (_event, args: { id: string; charCount?: number; processedChars?: number }) => { session.lastAckReceivedAtMs = Date.now() - // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. + // A live ACK ends the prior silent diagnostic streak. session.deliveryResyncUnansweredWarnLogged = false let acknowledged = 0 if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { acknowledged = applyCumulativeAck(session, args.id, Math.max(0, args.processedChars)) } else { - // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. + // Hot reload can pair a legacy delta-ACK renderer with a newer main. const accounting = session.rendererDeliveryAccountingByPty.get(args.id) const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 acknowledged = accounting @@ -131,7 +130,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } session.clearDeliveryResyncProbe() session.deliveryResyncUnansweredWarnLogged = false - // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. + // Max-merge cumulative processed totals to repay debt from lost ACKs. let creditedAny = false for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { @@ -147,12 +146,12 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } ) - // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. + // Renderer invoke can survive broken push channels; use it for delivery resync and explicit repair. ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.handle( 'pty:reportRendererDeliveryState', (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { - // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. + // Merge cumulative totals before deciding whether lost delivery needs write-off. let creditedAny = false for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { @@ -165,7 +164,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } } let writtenOff: PtyDeliveryWriteOff[] = [] - // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. + // Require main-observed ACK silence before allowing a caller to write off delivery. if ( args?.heal === true && session.rendererInFlightTotalChars > 0 && @@ -192,18 +191,17 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } ) - // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. + // Hold sends until the replacement renderer confirms its data dispatcher exists. ipcMain.removeAllListeners('pty:rendererDispatcherReady') ipcMain.on('pty:rendererDispatcherReady', (event) => { - // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. + // Reconcile clears accounting; reject dying-window handshakes that could reset the new page. if (!isMainWindowPtyIpcEvent(event, mainWindow)) { return } - // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. + // A handshake on an open gate reveals missed lifecycle reset; clear stale debt. if (session.rendererPtyDispatcherReady) { resetRendererDeliveryAccountingForLifecycleReset() } - // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. session.clearDispatcherReadyWatchdog() session.rendererPtyDispatcherReady = true session.pendingData.reactivateBlocked() @@ -215,7 +213,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { if (typeof args.id !== 'string' || !args.id) { return } - // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. + // Active panes get scheduling priority; inactive ingestion, state and notifications continue. if (args.active) { if (activeRendererPtys.has(args.id)) { return @@ -232,7 +230,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { if (typeof args.id !== 'string' || !args.id) { return } - // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. + // Preserve hidden-at-production origin through batching even if visibility changes before flush. rendererVisibilityKnownPtys.add(args.id) if (args.visible) { visibleRendererPtys.add(args.id) @@ -257,7 +255,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ) if (args.hidden === true) { closeStartupQueryAuthorityForPty(args.id) - // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. + // Newly hidden queued bytes can drop despite ACK starvation; reveal restores their model snapshot. const pending = session.pendingData.get(args.id) if (pending && transition.droppable) { session.pendingData.delete(args.id) @@ -284,7 +282,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { invalidatePendingPtyDrainPolicy(args.id) } session.syncPtyBackgroundedDelivery(args.id, 'gate-unmark') - // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. + // Reload/remount can replace the marker consumer; repeat on reveal rather than lose recovery. if (transition.droppedWhileHidden) { sendModelRestoreNeededMarker( session, @@ -303,7 +301,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { if (typeof args.id !== 'string' || !args.id) { return } - // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. + // Raw-byte interests bypass hidden gating without changing daemon pacing during interest churn. const settings = session.getSettings?.() const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) setRendererPtyDeliveryInterest(args.id, args.interested === true) @@ -321,7 +319,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ipcMain.removeAllListeners('pty:clearBuffer') ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { - // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. + // Clear provider-side state so prompt repaint cannot reuse a stale cursor row. tryGetProviderForPty(args.id) ?.clearBuffer(args.id) .catch(() => {}) @@ -330,7 +328,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ipcMain.removeAllListeners('pty:resetInputModes') ipcMain.on('pty:resetInputModes', (_event, args: { id: string }) => { - // Why: an older daemon or relay rejects the request; its model keeps the modes until reattach. + // Older daemon/relay rejection leaves its model modes until reattach. tryGetProviderForPty(args.id) ?.resetInputModes(args.id) .catch(() => {}) diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts index 1c314875745..fae54a0f4ab 100644 --- a/src/main/ipc/pty/ipc/serialize-buffer.ts +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -20,7 +20,7 @@ export function settleSerializeRequest( export function installPtySerializeBufferIpc(session: PtyIpcSession): void { const ipcMain = getPtyIpc() - // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. + // One persistent listener dispatches by request ID so concurrent serialization cannot exceed listener limits. ipcMain.on( 'pty:serializeBuffer:response', ( @@ -37,7 +37,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { } | null } ) => { - // Why: the snapshot seeds terminal restore state, so only the main window may settle it. + // Only the main window may provide state used to seed terminal restore. if ( !isMainWindowPtyIpcEvent(event, session.mainWindow) || typeof args?.requestId !== 'string' @@ -69,8 +69,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { result.lastTitle = snapshot.lastTitle } - // Why gated on seq: without a boundary the flags cannot be reconciled - // against live bytes, so they prove nothing. + // Kitty flags require their sequence boundary to reconcile against live bytes. const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { result.kittyKeyboardFlags = kittyKeyboardFlags diff --git a/src/main/ipc/pty/ipc/session-listing.ts b/src/main/ipc/pty/ipc/session-listing.ts index a6120c2453f..6a4e1197b34 100644 --- a/src/main/ipc/pty/ipc/session-listing.ts +++ b/src/main/ipc/pty/ipc/session-listing.ts @@ -80,7 +80,7 @@ export function installPtySessionListingIpcHandler(deps: { } for (const rawSession of sessions) { const session = admission.admit(rawSession) - // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. + // Rebuild routing for reconnect-discovered sessions; later kill requests carry only the PTY ID. ptyOwnership.set(session.id, hostId) deduped.set(diagnostic ? ptySessionIdentity(session) : session.id, { id: session.id, @@ -103,9 +103,7 @@ export function installPtySessionListingIpcHandler(deps: { provider.supportsFencedTermination?.(session.id) === true, ...(session.worktreeId !== undefined ? { worktreeId: session.worktreeId } : {}), ...(session.exiting === true ? { exiting: true as const } : {}), - // Why: the renderer's binding map is empty during restore, so ownership is the only - // liveness evidence it has. Absence is authoritative only from a provider that - // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). + // During restore, claims provide liveness evidence; only listing-capable providers can prove ownership absent (#8459). agentOwnership: (session.agentSessionOwners?.length ?? 0) > 0 ? 'present' diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts index 3724cf7ccfe..c2b02f8b478 100644 --- a/src/main/ipc/pty/ipc/snapshot.ts +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -57,7 +57,7 @@ export function installPtySnapshotIpcHandlers(deps: { scrollbackRows }) : null - // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. + // Main's retained tail cannot replace full history after a data gap. if (providerSnapshotRequired && !providerSnapshot) { return null } @@ -69,10 +69,9 @@ export function installPtySnapshotIpcHandlers(deps: { if (!snapshot || typeof snapshot.seq !== 'number') { return snapshot } - // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. + // Include the pending-queue boundary so restore deduplication cannot swallow unseen live chunks. const pending = pendingData.get(args.id) if (pending && typeof pending.startSeq !== 'number') { - // Why: a seq-less backlog cannot be bounded — stay conservative. return snapshot } return { @@ -88,7 +87,7 @@ export function installPtySnapshotIpcHandlers(deps: { } ) - // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). + // Restore title only; replay must not repeat historical attention effects (terminal-side-effect-authority.md). ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null diff --git a/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts b/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts index c157facf894..e4a33926144 100644 --- a/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts +++ b/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts @@ -2,12 +2,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switc import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner' import type { PtyIpcSpawnState } from './spawn-state' -/** - * Switch point: a gone process's binding stays until this spawn's bind swaps it, one write, so the - * pane is never published unbound. Stable-pane adoption holds the pane's reservation through its fresh - * launch, which keeps any other spawn off the pane meanwhile. - * A pane on an SSH host keeps the early retire: its dead owner lives on that host. - */ +/** Keep dead bindings until one replacement write; the reservation excludes competing spawns. SSH owners still retire on their host. */ export function keepAbsentBindingForSwap( ctx: PtyIpcSpawnState ): ((dead: ReplacedPaneOwner) => void) | undefined { diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts index 54a41b46384..438db845174 100644 --- a/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts +++ b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts @@ -1,5 +1,3 @@ -// Finishes a pane's own agent launch on every way its spawn ends. - import { AGENT_LAUNCH_PANE_REFUSED_CODE, type AgentLaunchPaneAddress, @@ -17,26 +15,20 @@ export type PaneAgentLaunch = { address: AgentLaunchPaneAddress paneKey: string running: RunningAgentLaunchPane - /** The plan's runtime fact: the PTY's shell comes only from it, as for a host launch. */ + /** The plan chooses the PTY execution shell. */ launchRuntime?: AgentLaunchRuntime - /** The provider was asked for the process: a failure after this proves nothing ran. */ + /** After provider dispatch, a failure cannot prove that nothing ran. */ dispatched: boolean - /** Right before the provider spawn: refuses a launch whose pane the user closed meanwhile. */ refuseIfClosed: () => void } -type PaneAgentLaunchEnd = - | { started: true } - /** The agent's process started, then exited before it registered. */ - | { exitedDuringStart: true } - | { error: unknown } +type PaneAgentLaunchEnd = { started: true } | { exitedDuringStart: true } | { error: unknown } function finish(launch: PaneAgentLaunch, verdict: AgentLaunchPaneVerdict): void { launch.running.finish(verdict) launch.runtime.reportAgentLaunchPaneVerdict(launch.address, verdict) } -/** Ends the pane's launch, once; returns the error its spawn throws, if it failed. */ export function endPaneAgentLaunch(ctx: PtyIpcSpawnState, end: PaneAgentLaunchEnd): Error | null { const launch = ctx.paneAgentLaunch ctx.paneAgentLaunch = null @@ -70,7 +62,7 @@ export function endPaneAgentLaunch(ctx: PtyIpcSpawnState, end: PaneAgentLaunchEn return new Error(AGENT_LAUNCH_PANE_REFUSED_CODE) } -/** The user closed it while it started: its agent, if one spawned, stops, as a closed tab's does. */ +/** A pane closed during launch must stop any agent that may already have spawned. */ function stopClosed(launch: PaneAgentLaunch): void { const handle = launch.runtime.getTerminalHandleForPaneKey(launch.paneKey) if (handle) { diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts index 69757c9851d..b720ecc8746 100644 --- a/src/main/ipc/pty/ipc/spawn-agent-launch.ts +++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts @@ -1,5 +1,3 @@ -// A pane's own agent launch, planned in its reserved spawn by the shared planner; no launch record. - import { randomUUID } from 'node:crypto' import { AgentLaunch } from '../../../../shared/rpc-contract/agent-launch-params' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' @@ -15,7 +13,6 @@ import { resolveEarlyPaneKey } from './spawn-begin' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' -/** Whether the user's close of this pane, or its whole tab, is already committed to main. */ function closedInSession(store: Store | undefined, paneKey: string): boolean { const pane = parsePaneKey(paneKey) const session = store?.getWorkspaceSession() @@ -27,7 +24,6 @@ function closedInSession(store: Store | undefined, paneKey: string): boolean { ) } -/** The planned launch replaces whatever the pane would have run; the pane's own spawn facts stay. */ function withPlannedLaunch( args: PtySpawnIpcArgs, planned: Awaited> @@ -72,7 +68,7 @@ function withPlannedLaunch( export async function planPaneAgentLaunch(ctx: PtyIpcSpawnState): Promise { const { args, deps } = ctx - // A live owner of the pane is adopted below: its agent already runs there. + // Adopting a live pane owner also adopts its already-running agent. if (args.agentLaunch === undefined || ctx.earlyStablePaneOwner) { return } diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 51c153f3d71..022a288fab2 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -20,7 +20,6 @@ import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' -/** The pane key a spawn names before preflight; null when it names no stable pane. */ export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { const leafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null @@ -41,8 +40,7 @@ export async function beginPtyIpcSpawn( ctx: PtyIpcSpawnState ): Promise { const args = ctx.args - // A pane an agent launch laid out attaches to its agent or says why it can't, never runs a shell. - // A replacing spawn already holds the pane: the user restarted it, and that is theirs to run. + // Launch panes adopt their agent instead of starting a shell; explicit restarts already own the reservation. const launchPaneKey = ctx.paneSpawnReservation ? null : resolveEarlyPaneKey(args) const launchVerdict = launchPaneKey && args.worktreeId && args.tabId && args.leafId @@ -58,7 +56,6 @@ export async function beginPtyIpcSpawn( : null if (launchVerdict && args.worktreeId && args.tabId && args.leafId) { const verdict = await launchVerdict - // The window keeps a final verdict on the tab, clears a settled one, takes a withdrawn pane back. ctx.deps.runtime?.reportAgentLaunchPaneVerdict?.( { worktreeId: args.worktreeId, tabId: args.tabId, leafId: args.leafId }, verdict @@ -102,7 +99,7 @@ export async function beginPtyIpcSpawn( ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) const earlyReservationKey = resolveEarlyPaneSpawnReservationKey(args) - // Why: a replacing spawn reserved its pane before stopping the owner; joining itself would deadlock. + // Replacement holds the reservation before stopping; joining itself would deadlock. const heldReservation = ctx.paneSpawnReservation if (!heldReservation) { const pendingRuntimeCreate = earlyReservationKey @@ -110,9 +107,7 @@ export async function beginPtyIpcSpawn( : undefined if (pendingRuntimeCreate) { const created = await pendingRuntimeCreate.promise - // Why: main's start of this pane carries the payload a view paints from; attaching to the - // provider again adds a round trip. A start that carries none, or no longer owns its - // terminal, leaves the view to attach as today. + // Reuse main's current start payload when available to avoid a second provider attach round trip. if ( attachOnlyViewOf(args) && created && @@ -140,7 +135,6 @@ export async function beginPtyIpcSpawn( ) : null ctx.earlyWorktreeId = args.worktreeId - // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. ctx.paneSpawnReservationKey = earlyReservationKey ctx.paneSpawnReservation = heldReservation ?? null ctx.finishTerminalInstall = (): void => {} @@ -148,14 +142,9 @@ export async function beginPtyIpcSpawn( ctx.stablePaneBindingPersisted = false ctx.rejectedRegistrationCandidate = null ctx.pendingRegistrationPtyId = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. + // Carry renderer-domain sequence reconciliation into the spawn reply beside proven kitty flags. ctx.reconciledSnapshotSeq = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. + // Bytes arriving during spawn can postdate proven flags; do not let old flags erase their live negotiation. ctx.snapshotKittyFlagsCoverReconciledSeq = true ctx.preparedProvisionalExecutionContext = false diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts index 45cc8106618..8652384bf59 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -28,7 +28,7 @@ async function commit( const runtime = { terminalRunFacts: facts, intentionalPtyStops: stops, - // Why the real method: the case under test is what the runtime does with each commit. + // Exercise real runtime commit handling through the fixture ports. noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 10909fed91f..9dbdf8a69d2 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -11,7 +11,7 @@ import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-st export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { return commitPtyWithOpenCodePromptIntent(ctx, () => { const args = ctx.args - // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. + // Bound untrusted IPC identity strings before they enter runtime registration maps. const worktreeId = typeof args.worktreeId === 'string' && args.worktreeId.length > 0 && @@ -101,8 +101,7 @@ function ipcSpawnResponse(ctx: PtyIpcSpawnState) { const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result) return { ...providerResult, - // Why both or neither: a pane can only adopt proven kitty flags together - // with the sequence boundary they describe. + // Kitty flags and their proven sequence boundary must be published together. ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && ctx.reconciledSnapshotSeq !== null && ctx.snapshotKittyFlagsCoverReconciledSeq @@ -112,8 +111,7 @@ function ipcSpawnResponse(ctx: PtyIpcSpawnState) { ? { launchConfig: ctx.effectiveLaunchConfig } : {}), ...(startupCwdFallback ? { startupCwdFallback } : {}), - // Why: the pane asked to resume and got a fresh session instead; only the - // renderer can say so, and a reattach never ran this launch command. + // Only a fresh launch can report dropped resume; reattach never ran this command. ...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach ? { agentResumeUnavailable: true as const } : {}) diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index ea666bff047..8447d39db8f 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -17,7 +17,7 @@ import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native- export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. + // Daemon providers bypass LocalPtyProvider environment assembly; inject the same host-local state here. const baseEnvWithAuth = ctx.claudeAuth ? { ...args.env, ...ctx.claudeAuth.envPatch } : args.env const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) @@ -75,7 +75,7 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) } ctx.spawnTiming.mark('options') - // Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach. + // Restart must replace the stopped owner instead of reattaching its retained binding. const resolveOwner = () => excludeReplacedPaneOwner( resolveStablePaneOwner( @@ -99,9 +99,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { if (ctx.result.providerSequence) { const runtimeSequenceBeforeReconcile = ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. + // Preserve the renderer-domain boundary needed to reconcile daemon snapshot kitty flags. ctx.reconciledSnapshotSeq = ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( ctx.result.id, diff --git a/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts b/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts index 0375ca7f88f..025c09d786f 100644 --- a/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts +++ b/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts @@ -45,8 +45,7 @@ async function runtimeSpawnEnv( return ctx.spawnOptions.env } -// Why both hosts: SSH panes reach the relay with this env untouched, local ones reach -// node-pty or the daemon; WSL forwarding is covered by wsl-orca-env.test.ts. +// SSH preserves this env for the relay; local providers consume it directly. WSL has separate forwarding coverage. describe.each([ ['renderer spawn', ipcSpawnEnv], ['runtime spawn', runtimeSpawnEnv] diff --git a/src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts b/src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts index 68b2ae26d57..afb37defe8a 100644 --- a/src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts +++ b/src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts @@ -4,8 +4,7 @@ import { buildPtyIpcSpawnOptions } from './spawn-options' import { createPtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' -/** Why: the renderer's `pty:spawn` handler builds options here, not through the - * runtime controller — the configured profile has to reach both. */ +/** Check renderer option assembly independently of runtime-controller shell-profile coverage. */ async function resolveSpawnShellArgs( args: PtySpawnIpcArgs, settings: { terminalDefaultShell?: string; terminalDefaultShellArgs?: string[] } diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index a7470f521ac..24717b1554f 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -44,7 +44,7 @@ export async function buildPtyIpcSpawnOptions(ctx: PtyIpcSpawnState): Promise