From a582956815d38abb98ebe229bb7952b0e0ba586c Mon Sep 17 00:00:00 2001 From: moreih29 Date: Sat, 6 Jun 2026 23:11:40 +0900 Subject: [PATCH 1/6] feat(agent): log channel lifecycle transitions to main.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent child could die and be transparently respawned with zero trace — the exact failure mode behind the v0.6.0 "push events stop while RPC keeps working" report took two days to localize because nothing recorded when or why a channel replaced its process. Every transition in reconnecting-process-channel now leaves a main.log line under source "agent-channel": spawn (phase, pid), ready (epoch), child close (exit code, signal, wasReady, stderr tail), handshake/pipe/spawn failures, respawn scheduling with backoff delay, epoch-mismatch daemon replacement, fatal-failure escalation, and dispose. Channels are labeled local: / ssh: via a new logLabel option since every workspace owns one. Both the local and SSH channels route through this file, so one logging site covers both transports. The logger is created lazily (same pattern as pipe.ts) so test imports do not initialize electron-log. Co-Authored-By: Claude Opus 4.8 --- src/main/infra/agent/channel/local-channel.ts | 1 + .../channel/reconnecting-process-channel.ts | 65 +++++++++++++++++++ src/main/infra/agent/ssh/channel.ts | 1 + 3 files changed, 67 insertions(+) diff --git a/src/main/infra/agent/channel/local-channel.ts b/src/main/infra/agent/channel/local-channel.ts index 8cf43cdc..7f7def99 100644 --- a/src/main/infra/agent/channel/local-channel.ts +++ b/src/main/infra/agent/channel/local-channel.ts @@ -76,6 +76,7 @@ export function createLocalChannel( // Local stderr is not classified — the binary writes only human-readable // hints (e.g. usage), and terminal failures surface via exit code below. classifyStderr: () => null, + logLabel: `local:${options.rootPath}`, closeError: (wasReady) => createSshError(wasReady ? "transport.unknown" : "server.spawn-failed"), requestTimeoutMs: options.requestTimeoutMs, diff --git a/src/main/infra/agent/channel/reconnecting-process-channel.ts b/src/main/infra/agent/channel/reconnecting-process-channel.ts index d87600f2..bc14eba7 100644 --- a/src/main/infra/agent/channel/reconnecting-process-channel.ts +++ b/src/main/infra/agent/channel/reconnecting-process-channel.ts @@ -1,4 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { createLogger } from "../../../../shared/log/main"; import type { AgentChannel, ChannelEventCallback, @@ -9,6 +10,31 @@ import { ChannelEventRegistry } from "./event-registry"; import type { StderrClassifier } from "../pipe"; import { createNdjsonPipe, type NdjsonPipe, type SshError } from "../pipe"; +/** + * Lazy logger bound to source "agent-channel". Created on first use so the + * module can be imported in test environments without triggering + * electron-log initialization (same pattern as pipe.ts's agent logger). + * + * Why this logging exists: an agent child can die and be transparently + * respawned without any user-visible signal. The respawn wipes agent-side + * state (fs/git watch registrations live in the agent process), so a silent + * respawn is the prime suspect whenever push events stop while RPC keeps + * working. Every lifecycle transition below leaves a main.log line with the + * child pid + exit diagnostics so the "when and why did it respawn" question + * is answerable after the fact. Both the local channel and the SSH channel + * route through this file, so one logging site covers both transports. + */ +let channelLogger: ReturnType | null = null; +function getChannelLogger(): ReturnType { + if (channelLogger === null) { + channelLogger = createLogger("agent-channel"); + } + return channelLogger; +} + +/** Max chars of a child's stderr tail to embed in one close-diagnostic line. */ +const CLOSE_LOG_STDERR_TAIL_CHARS = 300; + const DISPOSE_KILL_GRACE_MS = 100; const DEFAULT_MAX_PENDING_RECONNECT_CALLS = 32; const DEFAULT_RECONNECT_DELAY_MS = 100; @@ -44,6 +70,12 @@ export interface ReconnectingProcessChannelOptions { readonly spawn: () => ChildProcessWithoutNullStreams; readonly classifyStderr: StderrClassifier; readonly closeError: (wasReady: boolean, context?: ChannelCloseContext) => Error; + /** + * Channel identity prefix for lifecycle log lines (e.g. `local:/path/to/ws` + * or `ssh:host`). Multiple workspaces each own a channel, so without this + * the main.log lifecycle entries are unattributable. Defaults to "agent". + */ + readonly logLabel?: string; readonly requestTimeoutMs?: number; readonly expectedProtocolMajor?: string; readonly reconnect?: AgentReconnectOptions; @@ -89,6 +121,7 @@ export function createReconnectingProcessChannel( const events = new ChannelEventRegistry(); const queue: QueuedCall[] = []; const reconnect = normalizeReconnectOptions(options.reconnect, options.requestTimeoutMs); + const label = options.logLabel ?? "agent"; let active: ActiveProcess | null = null; let state: ChannelState = "connecting"; @@ -133,6 +166,9 @@ export function createReconnectingProcessChannel( }, dispose(): void { if (state === "disposed") return; + getChannelLogger().info( + `[${label}] channel disposed (pid=${active?.child.pid}, state=${state})`, + ); state = "disposed"; clearReconnectTimer(); rejectQueuedCalls(createDisposedErrorForChannel()); @@ -149,6 +185,9 @@ export function createReconnectingProcessChannel( try { child = options.spawn(); } catch (error) { + getChannelLogger().warn( + `[${label}] spawn threw (phase=${phase}): ${error instanceof Error ? error.message : String(error)}`, + ); const wrapped = createAgentReconnectError("agent.reconnect-unavailable", error); if (phase === "connecting") { state = "terminal"; @@ -158,6 +197,7 @@ export function createReconnectingProcessChannel( } throw wrapped; } + getChannelLogger().info(`[${label}] agent child spawned (phase=${phase}, pid=${child.pid})`); let attempt!: ActiveProcess; const pipe = createNdjsonPipe({ @@ -196,6 +236,9 @@ export function createReconnectingProcessChannel( consecutiveFatalFailures = 0; const newEpoch = attempt.pipe.agentEpoch ?? 0; + getChannelLogger().info( + `[${label}] agent ready (phase=${phase}, pid=${attempt.child.pid}, epoch=${newEpoch})`, + ); if (phase === "reconnecting" && lastAgentEpoch !== 0 && newEpoch !== 0) { if (newEpoch !== lastAgentEpoch) { // Epoch mismatch: the daemon was replaced during the outage. @@ -205,6 +248,9 @@ export function createReconnectingProcessChannel( // continue making fresh calls to the new agent. const previousEpoch = lastAgentEpoch; lastAgentEpoch = newEpoch; + getChannelLogger().warn( + `[${label}] daemon replaced during outage (epoch ${previousEpoch} -> ${newEpoch}); rejecting reconnect queue`, + ); rejectQueuedCalls(createAgentReconnectError("agent.reconnect-unavailable")); emitLifecycle({ type: "held-then-expired", previousEpoch, newEpoch }); return; @@ -222,6 +268,9 @@ export function createReconnectingProcessChannel( }) .catch((error) => { if (state === "disposed" || active !== attempt) return; + getChannelLogger().warn( + `[${label}] agent handshake failed (phase=${phase}, pid=${attempt.child.pid}): ${error instanceof Error ? error.message : String(error)}`, + ); if (phase === "reconnecting") { scheduleReconnect(); return; @@ -242,6 +291,9 @@ export function createReconnectingProcessChannel( phase: "connecting" | "reconnecting", ): void { if (state === "disposed" || active !== attempt) return; + getChannelLogger().warn( + `[${label}] agent pipe failure (phase=${phase}, pid=${attempt.child.pid}, code=${error.code}): ${error.message}`, + ); if (phase === "reconnecting") { terminateChild(attempt); if (options.isFatalReconnectError?.(error)) { @@ -270,6 +322,9 @@ export function createReconnectingProcessChannel( * cause (e.g. ssh.auth-failed) instead of a queue timeout. */ function escalateReconnectFailure(error: SshError): void { + getChannelLogger().warn( + `[${label}] giving up reconnect after ${consecutiveFatalFailures} consecutive fatal failures (code=${error.code})`, + ); clearReconnectTimer(); state = "terminal"; terminalError = error; @@ -284,6 +339,9 @@ export function createReconnectingProcessChannel( phase: "connecting" | "reconnecting", ): void { if (state === "disposed" || active !== attempt) return; + getChannelLogger().warn( + `[${label}] agent spawn error (phase=${phase}, pid=${attempt.child.pid}): ${error instanceof Error ? error.message : String(error)}`, + ); const wrapped = phase === "connecting" ? options.closeError(false) @@ -310,9 +368,14 @@ export function createReconnectingProcessChannel( clearForceKillTimer(attempt); const { wasReady, stderrTail } = attempt.pipe.notifyClose(); const closeContext: ChannelCloseContext = { code, signal, stderrTail }; + const tail = stderrTail.trim().slice(0, CLOSE_LOG_STDERR_TAIL_CHARS); + getChannelLogger().warn( + `[${label}] agent child closed (phase=${phase}, pid=${attempt.child.pid}, code=${code}, signal=${signal}, wasReady=${wasReady}, state=${state})${tail ? ` stderr=${tail}` : ""}`, + ); if (state === "disposed" || terminalError) return; if (code === 0 && wasReady) { + getChannelLogger().info(`[${label}] clean agent exit; channel is now terminal`); state = "terminal"; terminalError = options.closeError(true, closeContext); emitLifecycle({ type: "exit", code, signal }); @@ -336,6 +399,7 @@ export function createReconnectingProcessChannel( return; } + getChannelLogger().warn(`[${label}] agent closed before first ready; channel failure`); const error = options.closeError(wasReady, closeContext); attempt.pipe.fail(error); state = "terminal"; @@ -392,6 +456,7 @@ export function createReconnectingProcessChannel( /** Schedules the next reconnect attempt with capped exponential backoff. */ function scheduleReconnect(): void { if (state === "disposed" || terminalError || reconnectTimer) return; + getChannelLogger().info(`[${label}] scheduling agent respawn in ${nextReconnectDelayMs}ms`); state = "reconnecting"; reconnectTimer = setTimeout(() => { reconnectTimer = null; diff --git a/src/main/infra/agent/ssh/channel.ts b/src/main/infra/agent/ssh/channel.ts index 831eaf5f..2ee107b4 100644 --- a/src/main/infra/agent/ssh/channel.ts +++ b/src/main/infra/agent/ssh/channel.ts @@ -84,6 +84,7 @@ export function createSshChannel( spawn: dependencies.spawn, }), classifyStderr: classifyAuthLine, + logLabel: `ssh:${options.host}`, closeError: (_wasReady, context) => createSshError("ssh.unknown", formatCloseDiagnostic(context)), requestTimeoutMs: dependencies.requestTimeoutMs, From 98897bb51038d081cb2498b5ebd37558b985b3b0 Mon Sep 17 00:00:00 2001 From: moreih29 Date: Sat, 6 Jun 2026 23:12:06 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(agent):=20stop=20treating=20degraded=20?= =?UTF-8?q?heartbeat=20as=20channel=20death=20=E2=80=94=20v0.6.0=20watch/P?= =?UTF-8?q?TY/LSP=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 56e411a added the degraded / degraded-recovered / ready lifecycle events, but two consumers written before that commit still assumed "anything except reconnecting/disposed = the channel died": - handleLocalChannelLifecycle tore down the local workspace provider on a single late heartbeat: it dropped the channel reference WITHOUT disposing it (orphan agent process keeping every fs/git watch) and the next fs access lazily booted a fresh watch-less agent. Result: fs.changed / git.changed went permanently silent while RPC kept working — git status only refreshed via the 1-minute autofetch tick — and existing PTY sessions were stranded on the abandoned channel. - The LSP host disposed every language server on the channel for the same spurious event. The trigger was chronic: heartbeats are sent every 5s and judged at 5s, so arrivals land at interval+epsilon and the 1-miss degraded check rides the boundary — confirmed live (channel B ready 22:15:08.7, degraded teardown + duplicate agent spawn at +5.1s, matching the two orphan agent processes observed on the same workspace). Both handlers now act only on genuine terminal events. The SSH manager handler and PTY agent-host already handled all eight event types explicitly (audited) and are unchanged. Regression tests: degraded/degraded-recovered/ready must not tear down the local provider or LSP server records; exit/failure (and held-then-expired for LSP) still must. All eight fail on the pre-fix code. Co-Authored-By: Claude Opus 4.8 --- src/main/features/lsp/agent-host.ts | 24 ++- src/main/features/workspace/manager.ts | 41 ++-- .../lsp/agent-host-lifecycle-degraded.test.ts | 196 ++++++++++++++++++ .../manager-local-lifecycle-degraded.test.ts | 188 +++++++++++++++++ 4 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 tests/unit/main/lsp/agent-host-lifecycle-degraded.test.ts create mode 100644 tests/unit/main/workspace/manager-local-lifecycle-degraded.test.ts diff --git a/src/main/features/lsp/agent-host.ts b/src/main/features/lsp/agent-host.ts index 49b04122..52a31173 100644 --- a/src/main/features/lsp/agent-host.ts +++ b/src/main/features/lsp/agent-host.ts @@ -980,11 +980,25 @@ class AgentLspHostHandleImpl implements LspHostHandle { this.handleServerExited(payload); }); const offLifecycle = channel.onLifecycle((event) => { - // `reconnecting` is transient and the channel may yet recover. Leave - // server records intact so queued LSP calls replay onto the new agent - // once the channel completes its reconnect handshake. - if (event.type === "reconnecting") return; - this.disposeChannelServers(channel); + // Dispose server records only on genuine channel death: + // - `exit` / `failure`: terminal transport loss. + // - `disposed`: intentional channel teardown. + // - `held-then-expired`: the SSH daemon was replaced during an + // outage — its LSP server processes died with the old daemon. + // Everything else keeps records intact: `reconnecting` is transient + // (queued LSP calls replay after the handshake), `degraded` / + // `degraded-recovered` are heartbeat-quality signals on a live + // channel, and `ready` is a successful epoch-match recovery. Before + // v0.6.1 those three fell through to disposeChannelServers, so one + // late heartbeat silently killed every language server on the channel. + if ( + event.type === "exit" || + event.type === "failure" || + event.type === "disposed" || + event.type === "held-then-expired" + ) { + this.disposeChannelServers(channel); + } }); this.channelDisposers.set(channel, [ offMessage, diff --git a/src/main/features/workspace/manager.ts b/src/main/features/workspace/manager.ts index 984da37c..9b0361a4 100644 --- a/src/main/features/workspace/manager.ts +++ b/src/main/features/workspace/manager.ts @@ -1525,24 +1525,35 @@ export class WorkspaceManager { return; } - // `reconnecting` is transient — keep the channel reference so the - // internal reconnect path can recover transparently. - if (event.type === "reconnecting") { + // Teardown is reserved for genuine terminal events (`exit`, `failure`). + // Everything else must keep the provider wired: + // - `reconnecting` is transient — the internal reconnect path recovers + // transparently. + // - `degraded` / `degraded-recovered` are heartbeat-quality signals + // (one late heartbeat fires `degraded`) and `ready` is a successful + // recovery — the channel is alive in all three cases. Before v0.6.1 + // these fell into the teardown branch below, which abandoned a live + // agent WITHOUT disposing it (orphan process keeping every fs/git + // watch) and lazily booted a fresh watch-less agent: push events + // (fs.changed / git.changed) went silent while RPC kept working, and + // existing PTY sessions were stranded on the abandoned channel. + // - `held-then-expired` cannot occur on local channels (no epoch). + // - `disposed` is an intentional teardown whose cleanup already ran via + // the setFsProvider dispose callback. + if (event.type !== "exit" && event.type !== "failure") { return; } - if (event.type !== "disposed") { - this.localChannels.delete(workspaceId); - this.localProviderReady.delete(workspaceId); - // Stale hookInfo는 채널이 죽은 시점에 무효 — 다음 boot가 다시 채운다. - // 보존해두면 reconnect 직후 spawn이 죽은 소켓 경로를 env에 박을 수 있다. - this.hookInfoByWorkspace.delete(workspaceId); - ctx.setFsProvider(createInitialFsProvider(ctx.getMeta())); - // PTY shim 디렉터리 정리 — fire-and-forget, error swallow는 warn으로. - this.removeShimDir(workspaceId).catch((err: unknown) => { - log.warn(`shim dir removal failed for ${workspaceId}: ${(err as Error).message}`); - }); - } + this.localChannels.delete(workspaceId); + this.localProviderReady.delete(workspaceId); + // Stale hookInfo는 채널이 죽은 시점에 무효 — 다음 boot가 다시 채운다. + // 보존해두면 reconnect 직후 spawn이 죽은 소켓 경로를 env에 박을 수 있다. + this.hookInfoByWorkspace.delete(workspaceId); + ctx.setFsProvider(createInitialFsProvider(ctx.getMeta())); + // PTY shim 디렉터리 정리 — fire-and-forget, error swallow는 warn으로. + this.removeShimDir(workspaceId).catch((err: unknown) => { + log.warn(`shim dir removal failed for ${workspaceId}: ${(err as Error).message}`); + }); } } diff --git a/tests/unit/main/lsp/agent-host-lifecycle-degraded.test.ts b/tests/unit/main/lsp/agent-host-lifecycle-degraded.test.ts new file mode 100644 index 00000000..83115927 --- /dev/null +++ b/tests/unit/main/lsp/agent-host-lifecycle-degraded.test.ts @@ -0,0 +1,196 @@ +/** + * agent-host-lifecycle-degraded.test.ts + * + * Regression for the v0.6.0 LSP lifecycle bug: 56e411a added the `degraded` + * / `degraded-recovered` / `ready` lifecycle events, but the LSP host's + * lifecycle subscription only special-cased `reconnecting` and disposed every + * server record for anything else. One spuriously late heartbeat (degraded + * threshold = a single 5s interval) then silently killed every language + * server on the channel. + * + * Observable: diagnostics routing. While server records are intact, an + * `lsp.message` publishDiagnostics from the agent reaches the host's + * "diagnostics" listeners; after disposeChannelServers the serverId is + * unknown and the message is dropped. + */ + +import { afterEach, describe, expect, jest, test } from "bun:test"; +import type { + AgentChannel, + ChannelEventCallback, + ChannelLifecycleCallback, +} from "../../../../src/main/infra/agent/channel"; +import { startAgentLspHost } from "../../../../src/main/features/lsp/agent-host"; + +const WORKSPACE_ID = "44444444-4444-4444-8444-444444444444"; + +class LifecycleTestChannel implements AgentChannel { + readonly ready = Promise.resolve(); + readonly eventListeners = new Map>(); + readonly lifecycleListeners = new Set(); + + constructor(public readonly serverId = "srv-lifecycle-degraded") {} + + async call(method: string, params?: unknown): Promise { + if (method === "lsp.spawn") { + const correlationId = (params as { correlationId?: string } | undefined)?.correlationId; + this.emit("lsp.serverAssigned", { + serverId: this.serverId, + ...(correlationId ? { correlationId } : {}), + }); + return { + serverId: this.serverId, + capabilities: { textDocumentSync: { openClose: true, change: 2 } }, + } as TResult; + } + return {} as TResult; + } + + fire(_method: string, _params?: unknown): void {} + + on(event: string, callback: ChannelEventCallback): () => void { + let listeners = this.eventListeners.get(event); + if (!listeners) { + listeners = new Set(); + this.eventListeners.set(event, listeners); + } + listeners.add(callback); + return () => listeners?.delete(callback); + } + + onLifecycle(callback: ChannelLifecycleCallback): () => void { + this.lifecycleListeners.add(callback); + return () => this.lifecycleListeners.delete(callback); + } + + emit(event: string, payload: unknown): void { + for (const listener of this.eventListeners.get(event) ?? []) { + listener(payload); + } + } + + emitLifecycle(event: Parameters[0]): void { + for (const listener of this.lifecycleListeners) { + listener(event); + } + } + + emitDiagnostics(uri: string, version: number): void { + this.emit("lsp.message", { + serverId: this.serverId, + message: { + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri, + diagnostics: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + message: `v${version}`, + }, + ], + }, + }, + }); + } + + dispose(): void {} +} + +afterEach(() => { + jest.useRealTimers(); +}); + +/** Boots a host with one spawned server and a diagnostics counter. */ +async function bootHostWithServer() { + const channel = new LifecycleTestChannel(); + const host = startAgentLspHost({ getAgentChannel: async () => channel }); + const emitted: unknown[] = []; + host.on("diagnostics", (args) => emitted.push(args)); + + const uri = "file:///tmp/ws/lifecycle-degraded.py"; + await host.call("didOpen", { + workspaceId: WORKSPACE_ID, + workspaceRoot: "/tmp/ws", + uri, + languageId: "python", + version: 1, + text: "x = 1\n", + }); + + return { channel, host, emitted, uri }; +} + +/** Steps fake time past the diagnostics debounce so each emit is leading-edge. */ +function stepPastDebounce(base: number, step: number): number { + const next = base + 5_000 * step; + jest.setSystemTime(new Date(next)); + jest.advanceTimersByTime(5_000); + return next; +} + +describe("LSP lifecycle: health signals must not dispose server records", () => { + test("degraded / degraded-recovered / ready keep diagnostics routing alive", async () => { + jest.useFakeTimers(); + const base = 90_000_000; + jest.setSystemTime(new Date(base)); + + const { channel, host, emitted, uri } = await bootHostWithServer(); + + channel.emitDiagnostics(uri, 1); + expect(emitted).toHaveLength(1); + + stepPastDebounce(base, 1); + channel.emitLifecycle({ type: "degraded" }); + channel.emitDiagnostics(uri, 2); + expect(emitted).toHaveLength(2); + + stepPastDebounce(base, 2); + channel.emitLifecycle({ type: "degraded-recovered" }); + channel.emitDiagnostics(uri, 3); + expect(emitted).toHaveLength(3); + + stepPastDebounce(base, 3); + channel.emitLifecycle({ type: "ready" }); + channel.emitDiagnostics(uri, 4); + expect(emitted).toHaveLength(4); + + host.dispose(); + }); + + test("exit still disposes server records (diagnostics stop routing)", async () => { + jest.useFakeTimers(); + const base = 90_000_000; + jest.setSystemTime(new Date(base)); + + const { channel, host, emitted, uri } = await bootHostWithServer(); + + channel.emitDiagnostics(uri, 1); + expect(emitted).toHaveLength(1); + + stepPastDebounce(base, 1); + channel.emitLifecycle({ type: "exit", code: 1, signal: null }); + channel.emitDiagnostics(uri, 2); + expect(emitted).toHaveLength(1); + + host.dispose(); + }); + + test("held-then-expired disposes server records (daemon replaced)", async () => { + jest.useFakeTimers(); + const base = 90_000_000; + jest.setSystemTime(new Date(base)); + + const { channel, host, emitted, uri } = await bootHostWithServer(); + + channel.emitDiagnostics(uri, 1); + expect(emitted).toHaveLength(1); + + stepPastDebounce(base, 1); + channel.emitLifecycle({ type: "held-then-expired", previousEpoch: 1, newEpoch: 2 }); + channel.emitDiagnostics(uri, 2); + expect(emitted).toHaveLength(1); + + host.dispose(); + }); +}); diff --git a/tests/unit/main/workspace/manager-local-lifecycle-degraded.test.ts b/tests/unit/main/workspace/manager-local-lifecycle-degraded.test.ts new file mode 100644 index 00000000..a8929ca3 --- /dev/null +++ b/tests/unit/main/workspace/manager-local-lifecycle-degraded.test.ts @@ -0,0 +1,188 @@ +/** + * manager-local-lifecycle-degraded.test.ts + * + * Regression for the v0.6.0 local-channel teardown bug: 56e411a added the + * `degraded` / `degraded-recovered` / `ready` lifecycle events, but + * handleLocalChannelLifecycle still treated "anything except reconnecting / + * disposed" as terminal. One spuriously late heartbeat then tore down the + * local provider WITHOUT disposing the channel — abandoning a live agent + * (orphan process keeping every fs/git watch) and lazily booting a fresh + * watch-less agent. Push events (fs.changed / git.changed) went silent while + * RPC kept working, and existing PTY sessions were stranded. + * + * Acceptance: + * A. `degraded` / `degraded-recovered` / `ready` do NOT tear down the local + * provider (no removeShimDir, no second channel boot). + * B. `exit` / `failure` still tear it down (removeShimDir fires, and the + * next getAgentChannel boots a fresh channel). + */ + +import { describe, expect, mock, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import os from "node:os"; +import path from "node:path"; + +mock.module("electron", () => ({ + app: { + isPackaged: false, + }, +})); + +const { GlobalStorage } = await import("../../../../src/main/infra/storage/global-storage"); +const { WorkspaceStorage } = await import( + "../../../../src/main/infra/storage/workspace-storage" +); +const { StateService } = await import("../../../../src/main/infra/storage/state-service"); +const { WorkspaceManager } = await import( + "../../../../src/main/features/workspace/manager" +); + +type ChannelLifecycleCallback = (event: { type: string }) => void; + +class StubChannel { + readonly ready: Promise; + private resolveReady!: () => void; + private lifecycleListeners: Set = new Set(); + disposed = false; + + constructor() { + this.ready = new Promise((res) => { + this.resolveReady = res; + }); + } + + async call(method: string, _params?: unknown): Promise { + if (method === "hook.getInfo") { + return { socketPath: "/tmp/hook.sock", token: "tok" }; + } + return {}; + } + + fire(_method: string, _params?: unknown): void {} + + on(_event: string, _cb: unknown): () => void { + return () => {}; + } + + onLifecycle(cb: ChannelLifecycleCallback): () => void { + this.lifecycleListeners.add(cb); + return () => this.lifecycleListeners.delete(cb); + } + + emitLifecycle(event: { type: string }): void { + for (const cb of this.lifecycleListeners) cb(event); + } + + resolveChannel(): void { + this.resolveReady(); + } + + dispose(): void { + this.disposed = true; + } +} + +function makeHarness() { + const removeShimDirMock = mock((_workspaceId: string) => Promise.resolve()); + const writeShimFilesMock = mock((_workspaceId: string) => + Promise.resolve({ + dir: "/stub/shim", + zshrc: "/stub/shim/.zshrc", + zshenv: "/stub/shim/.zshenv", + bashrc: "/stub/shim/bashrc", + }), + ); + + const globalDb = new Database(":memory:"); + const globalStorage = new GlobalStorage(globalDb); + const wsBaseDir = path.join(os.tmpdir(), `nexus-lc-degraded-${Date.now()}`); + const workspaceStorage = new WorkspaceStorage(wsBaseDir, () => new Database(":memory:")); + const stateService = new StateService( + path.join(os.tmpdir(), `nexus-lc-degraded-state-${Date.now()}.json`), + ); + const broadcast = mock((_ch: string, _ev: string, _args: unknown) => {}); + + const manager = new WorkspaceManager( + globalStorage, + workspaceStorage, + stateService, + broadcast, + undefined as never, // sshChannelFactory + undefined as never, // sshBootstrap + undefined as never, // localChannelFactory — overridden below + undefined as never, // localAgentCommandResolver — overridden below + undefined as never, // sshLspBootstrap + writeShimFilesMock, + removeShimDirMock, + ); + + const channels: StubChannel[] = []; + const m = manager as unknown as { + localChannelFactory: unknown; + localAgentCommandResolver: unknown; + }; + m.localAgentCommandResolver = () => ({ bin: "/usr/bin/agent", args: [] }); + m.localChannelFactory = (_opts: unknown) => { + const channel = new StubChannel(); + channels.push(channel); + // Resolve immediately so awaited boots settle without manual pumping. + channel.resolveChannel(); + return channel; + }; + + return { manager, globalDb, channels, removeShimDirMock }; +} + +async function bootLocalWorkspace(harness: ReturnType) { + const meta = harness.manager.create({ + rootPath: path.join(os.tmpdir(), `lc-degraded-${Math.random().toString(36).slice(2)}`), + name: "lc-degraded", + }); + await harness.manager.getAgentChannel(meta.id); + return meta; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("handleLocalChannelLifecycle: health signals must not tear down the provider", () => { + for (const type of ["degraded", "degraded-recovered", "ready"] as const) { + test(`'${type}' keeps the provider and does not boot a second channel`, async () => { + const harness = makeHarness(); + const meta = await bootLocalWorkspace(harness); + expect(harness.channels).toHaveLength(1); + + harness.channels[0].emitLifecycle({ type }); + await settle(); + + // No teardown side effects. + expect(harness.removeShimDirMock).not.toHaveBeenCalled(); + + // The same channel keeps serving — a re-request must NOT boot a new one. + const again = await harness.manager.getAgentChannel(meta.id); + expect(again).toBe(harness.channels[0] as never); + expect(harness.channels).toHaveLength(1); + + harness.globalDb.close(); + }); + } + + for (const type of ["exit", "failure"] as const) { + test(`'${type}' still tears the provider down and reboots on next request`, async () => { + const harness = makeHarness(); + const meta = await bootLocalWorkspace(harness); + expect(harness.channels).toHaveLength(1); + + harness.channels[0].emitLifecycle({ type }); + await settle(); + + expect(harness.removeShimDirMock).toHaveBeenCalledTimes(1); + expect(harness.removeShimDirMock.mock.calls[0][0]).toBe(meta.id); + + // Next request boots a fresh channel. + await harness.manager.getAgentChannel(meta.id); + expect(harness.channels).toHaveLength(2); + + harness.globalDb.close(); + }); + } +}); From 0ff4f7c17391c02dda5464dc0c399ba236d0b5b9 Mon Sep 17 00:00:00 2001 From: moreih29 Date: Sat, 6 Jun 2026 23:12:21 +0900 Subject: [PATCH 3/6] feat(agent): replay fs/git watch registrations after agent respawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch registrations (fsnotify) live in the agent process, so a respawned agent starts with zero watches — and nothing re-issued them. Root and .git watches were registered exactly once per workspace open (ensureRoot / repo-info detection), so any agent replacement silently killed push events until the workspace was reopened. Observed live: an agent with 62 expanded-dir watches (re-added by user navigation) but no root and no .git watch — the two registrations with no natural re-issue point. Three pieces: - The channel now emits the `ready` lifecycle event on a successful no-epoch reconnect too (local agents / legacy remotes). Previously only the epoch-match reattach path emitted it, so a local reconnect completed silently. Existing consumers are safe by audit: the SSH manager broadcasts "connected", PTY restore is a no-op with no held sessions, and the local manager / LSP host ignore `ready` since the previous commit. - AgentBackedProvider gains onAgentLifecycle (channel.onLifecycle passthrough on AgentFsProvider; the type guard now requires it). - AgentFsWatcher replays every tracked relPath and AgentGitWatcher replays its gitDir on `ready`. Re-registering an existing watch is a no-op agent-side, so the replay is safe when the agent actually survived. Co-Authored-By: Claude Opus 4.8 --- src/main/features/fs/bridge/agent-provider.ts | 6 +- src/main/features/fs/bridge/agent-watch.ts | 32 +++++- src/main/features/fs/bridge/provider.ts | 14 ++- src/main/features/git/bridge/agent-watch.ts | 28 ++++- .../channel/reconnecting-process-channel.ts | 9 ++ .../reconnect-channel-ready-lifecycle.test.ts | 100 ++++++++++++++++++ tests/unit/main/fs/agent-watch.test.ts | 58 +++++++++- .../git/agent-executor-stream-leak.test.ts | 1 + tests/unit/main/git/agent-watch.test.ts | 46 +++++++- tests/unit/main/git/git-registry.test.ts | 1 + .../git-repository-status-executor.test.ts | 1 + .../ipc/channels/fs/local-only-guards.test.ts | 1 + .../ipc/channels/fs/search-handlers.test.ts | 2 + .../git/content-handlers-missing.test.ts | 1 + 14 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 tests/unit/main/agent/reconnect-channel-ready-lifecycle.test.ts diff --git a/src/main/features/fs/bridge/agent-provider.ts b/src/main/features/fs/bridge/agent-provider.ts index 79a1dcdc..bfe3192a 100644 --- a/src/main/features/fs/bridge/agent-provider.ts +++ b/src/main/features/fs/bridge/agent-provider.ts @@ -35,7 +35,7 @@ import { WriteFileResultSchema, } from "../../../../shared/fs/types"; import type { SshErrorCode } from "../../../../shared/ssh/errors"; -import type { AgentChannel } from "../../../infra/agent/channel"; +import type { AgentChannel, ChannelLifecycleCallback } from "../../../infra/agent/channel"; import type { AgentBackedProvider } from "./provider"; type ChannelSource = AgentChannel | (() => AgentChannel); @@ -148,6 +148,10 @@ export class AgentFsProvider implements AgentBackedProvider { return this.getChannel().on(event, callback); } + onAgentLifecycle(callback: ChannelLifecycleCallback): () => void { + return this.getChannel().onLifecycle(callback); + } + isAgentAvailable(): boolean { return this.channel !== null || this.source !== undefined; } diff --git a/src/main/features/fs/bridge/agent-watch.ts b/src/main/features/fs/bridge/agent-watch.ts index 8b5dc95d..3ee556c9 100644 --- a/src/main/features/fs/bridge/agent-watch.ts +++ b/src/main/features/fs/bridge/agent-watch.ts @@ -1,8 +1,11 @@ import { z } from "zod"; import { FsChangeSchema } from "../../../../shared/fs/types"; +import { createLogger } from "../../../../shared/log/main"; import type { BroadcastFn, WorkspaceManager } from "../../workspace/manager"; import { isAgentBackedProvider, type AgentBackedProvider } from "./provider"; +const log = createLogger("fs"); + const AgentFsChangedPayloadSchema = z.object({ changes: z.array(FsChangeSchema), }); @@ -66,7 +69,7 @@ export class AgentFsWatcher { const existing = this.subscriptions.get(workspaceId); if (existing) return existing; - const unsubscribe = provider.onAgentEvent("fs.changed", (payload) => { + const unsubscribeChanged = provider.onAgentEvent("fs.changed", (payload) => { const parsed = AgentFsChangedPayloadSchema.safeParse(payload); if (!parsed.success || parsed.data.changes.length === 0) return; this.broadcast("fs", "changed", { @@ -75,7 +78,32 @@ export class AgentFsWatcher { }); }); - const subscription = { unsubscribe, watchedRelPaths: new Set() }; + // Watch registrations live in the agent process, so a respawned agent + // starts with zero watches even though the channel recovered + // transparently. Replay the full set on the `ready` lifecycle event + // (successful reconnect handshake) — re-registering an existing watch is + // a no-op on the agent side, so the replay is safe even when the agent + // actually survived. + const unsubscribeLifecycle = provider.onAgentLifecycle((event) => { + if (event.type !== "ready") return; + const subscription = this.subscriptions.get(workspaceId); + if (!subscription) return; + for (const relPath of subscription.watchedRelPaths) { + provider.callAgentMethod("fs.watch", { relPath }).catch((error: unknown) => { + log.warn( + `fs.watch replay failed (workspace=${workspaceId}, relPath=${relPath}): ${(error as Error).message}`, + ); + }); + } + }); + + const subscription = { + unsubscribe: () => { + unsubscribeChanged(); + unsubscribeLifecycle(); + }, + watchedRelPaths: new Set(), + }; this.subscriptions.set(workspaceId, subscription); return subscription; } diff --git a/src/main/features/fs/bridge/provider.ts b/src/main/features/fs/bridge/provider.ts index 41e167f1..fcb3456b 100644 --- a/src/main/features/fs/bridge/provider.ts +++ b/src/main/features/fs/bridge/provider.ts @@ -6,7 +6,10 @@ import type { FsStat, WriteFileResult, } from "../../../../shared/fs/types"; -import type { ChannelEventCallback } from "../../../infra/agent/channel"; +import type { + ChannelEventCallback, + ChannelLifecycleCallback, +} from "../../../infra/agent/channel"; /** * Filesystem provider bound to one workspace-scoped agent. @@ -46,12 +49,19 @@ export interface FsProvider { export interface AgentBackedProvider extends FsProvider { callAgentMethod(method: string, params?: unknown): Promise; onAgentEvent(event: string, callback: ChannelEventCallback): () => void; + /** + * Subscribes to channel lifecycle transitions. Watch bridges use the + * `ready` event (successful reconnect handshake) to replay agent-side + * fs.watch / git.watch registrations onto the replacement agent process. + */ + onAgentLifecycle(callback: ChannelLifecycleCallback): () => void; isAgentAvailable?(): boolean; } export function isAgentBackedProvider(provider: FsProvider): provider is AgentBackedProvider { return ( typeof (provider as Partial).callAgentMethod === "function" && - typeof (provider as Partial).onAgentEvent === "function" + typeof (provider as Partial).onAgentEvent === "function" && + typeof (provider as Partial).onAgentLifecycle === "function" ); } diff --git a/src/main/features/git/bridge/agent-watch.ts b/src/main/features/git/bridge/agent-watch.ts index 56c8ee1a..3acfc450 100644 --- a/src/main/features/git/bridge/agent-watch.ts +++ b/src/main/features/git/bridge/agent-watch.ts @@ -4,9 +4,12 @@ import { GIT_UNWATCH_METHOD, GIT_WATCH_METHOD, } from "../../../../shared/git/protocol"; +import { createLogger } from "../../../../shared/log/main"; import type { WorkspaceManager } from "../../workspace/manager"; import { isAgentBackedProvider, type AgentBackedProvider } from "../../fs/bridge/provider"; +const log = createLogger("git"); + export type GitDirtyCallback = (workspaceId: string) => void; interface GitWatchEntry { @@ -37,14 +40,35 @@ export class AgentGitWatcher { await this.unwatch(workspaceId).catch(() => {}); } - const unsubscribe = provider.onAgentEvent(GIT_CHANGED_EVENT, (payload) => { + const unsubscribeChanged = provider.onAgentEvent(GIT_CHANGED_EVENT, (payload) => { const parsed = AgentGitChangedPayloadSchema.safeParse(payload); if (!parsed.success || parsed.data.gitDir !== gitDir) return; this.onDirty(workspaceId); }); + // The .git watcher lives in the agent process, so a respawned agent + // starts without it even though the channel recovered transparently. + // Replay the registration on the `ready` lifecycle event (successful + // reconnect handshake) — re-registering is a no-op agent-side when the + // watch already exists, so the replay is safe either way. + const unsubscribeLifecycle = provider.onAgentLifecycle((event) => { + if (event.type !== "ready") return; + if (this.entries.get(workspaceId)?.gitDir !== gitDir) return; + void provider.callAgentMethod(GIT_WATCH_METHOD, { gitDir }).catch((error: unknown) => { + log.warn( + `git.watch replay failed (workspace=${workspaceId}, gitDir=${gitDir}): ${(error as Error).message}`, + ); + }); + }); + await provider.callAgentMethod(GIT_WATCH_METHOD, { gitDir }); - this.entries.set(workspaceId, { gitDir, unsubscribe }); + this.entries.set(workspaceId, { + gitDir, + unsubscribe: () => { + unsubscribeChanged(); + unsubscribeLifecycle(); + }, + }); } async unwatch(workspaceId: string): Promise { diff --git a/src/main/infra/agent/channel/reconnecting-process-channel.ts b/src/main/infra/agent/channel/reconnecting-process-channel.ts index bc14eba7..05df2ba2 100644 --- a/src/main/infra/agent/channel/reconnecting-process-channel.ts +++ b/src/main/infra/agent/channel/reconnecting-process-channel.ts @@ -265,6 +265,15 @@ export function createReconnectingProcessChannel( } lastAgentEpoch = newEpoch; flushQueuedCalls(); + if (phase === "reconnecting") { + // No-epoch reconnect (local agent / legacy remote): the replacement + // agent completed its handshake but the epoch branch above did not + // run, so without this emit the recovery is silent. Stateful + // consumers need the `ready` signal to re-establish agent-side + // registrations (fs.watch / git.watch replay) — a fresh agent + // process starts with zero watches and nothing else re-issues them. + emitLifecycle({ type: "ready" }); + } }) .catch((error) => { if (state === "disposed" || active !== attempt) return; diff --git a/tests/unit/main/agent/reconnect-channel-ready-lifecycle.test.ts b/tests/unit/main/agent/reconnect-channel-ready-lifecycle.test.ts new file mode 100644 index 00000000..00abd16c --- /dev/null +++ b/tests/unit/main/agent/reconnect-channel-ready-lifecycle.test.ts @@ -0,0 +1,100 @@ +/** + * Regression: the `ready` lifecycle event was only emitted on the epoch-match + * reattach path, so local agents (epoch 0) reconnected silently — stateful + * consumers (fs.watch / git.watch replay) had no signal that a replacement + * agent process was now serving the channel. + */ + +import { describe, expect, it, jest } from "bun:test"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { createLocalChannel } from "../../../../src/main/infra/agent/channel/local-channel"; +import type { ChannelLifecycleEvent } from "../../../../src/main/infra/agent/channel/index"; + +class FakeStream extends EventEmitter { + emitData(chunk: string): void { + this.emit("data", Buffer.from(chunk, "utf8")); + } +} + +class FakeStdin { + readonly writes: string[] = []; + writable = true; + destroyed = false; + + write(line: string, callback?: (error?: Error | null) => void): boolean { + this.writes.push(line); + callback?.(); + return true; + } + + end(): void { + this.destroyed = true; + } +} + +class FakeAgentChild extends EventEmitter { + readonly stdout = new FakeStream(); + readonly stderr = new FakeStream(); + readonly stdin = new FakeStdin(); + killed = false; + + kill(_signal?: NodeJS.Signals | number): boolean { + this.killed = true; + return true; + } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function makeHarness() { + const children: FakeAgentChild[] = []; + const channel = createLocalChannel( + { + binaryPath: "/tmp/agent", + rootPath: "/repo", + reconnect: { initialDelayMs: 1, maxDelayMs: 1 }, + }, + { + spawn: () => { + const child = new FakeAgentChild(); + children.push(child); + return child as unknown as ChildProcessWithoutNullStreams; + }, + }, + ); + return { channel, children }; +} + +describe("no-epoch reconnect emits the ready lifecycle event", () => { + it("emits ready after a successful local (epoch-less) reconnect handshake", async () => { + jest.useFakeTimers(); + try { + const { channel, children } = makeHarness(); + const events: ChannelLifecycleEvent[] = []; + channel.onLifecycle((event) => events.push(event)); + + children[0]?.stdout.emitData(`${JSON.stringify({ type: "ready" })}\n`); + await expect(channel.ready).resolves.toBeUndefined(); + // First connect must NOT emit a lifecycle ready (nothing to replay yet). + expect(events.filter((e) => e.type === "ready")).toHaveLength(0); + + children[0]?.emit("close", 137, "SIGKILL"); + jest.advanceTimersByTime(1); + await flushMicrotasks(); + + const reconnectChild = children[1]; + expect(reconnectChild).toBeDefined(); + reconnectChild?.stdout.emitData(`${JSON.stringify({ type: "ready" })}\n`); + await flushMicrotasks(); + + expect(events.filter((e) => e.type === "ready")).toHaveLength(1); + channel.dispose(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/tests/unit/main/fs/agent-watch.test.ts b/tests/unit/main/fs/agent-watch.test.ts index c55ede99..c0961d44 100644 --- a/tests/unit/main/fs/agent-watch.test.ts +++ b/tests/unit/main/fs/agent-watch.test.ts @@ -5,6 +5,7 @@ const WORKSPACE_ID = "123e4567-e89b-12d3-a456-426614174020"; function makeFixture() { const listeners = new Map void>>(); + const lifecycleListeners = new Set<(event: { type: string }) => void>(); const provider = { kind: "local" as const, callAgentMethod: mock(async (_method: string, _params?: unknown) => ({})), @@ -17,6 +18,13 @@ function makeFixture() { callbacks.add(callback); return () => callbacks?.delete(callback); }, + onAgentLifecycle: (callback: (event: { type: string }) => void) => { + lifecycleListeners.add(callback); + return () => lifecycleListeners.delete(callback); + }, + }; + const emitLifecycle = (event: { type: string }) => { + for (const callback of Array.from(lifecycleListeners)) callback(event); }; const manager = { getFs: mock(async (_workspaceId: string) => provider), @@ -25,9 +33,11 @@ function makeFixture() { const watcher = new AgentFsWatcher(manager as never, (channel, event, args) => { broadcasts.push({ channel, event, args }); }); - return { watcher, provider, listeners, broadcasts }; + return { watcher, provider, listeners, broadcasts, emitLifecycle }; } +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe("AgentFsWatcher", () => { it("delegates watch calls to the agent and forwards fs.changed with workspaceId", async () => { const { watcher, provider, listeners, broadcasts } = makeFixture(); @@ -61,4 +71,50 @@ describe("AgentFsWatcher", () => { expect(broadcasts).toEqual([]); }); + + // Regression: a respawned agent process starts with zero fs watches, so the + // watcher must replay every registered relPath when the channel reports a + // successful reconnect handshake (`ready` lifecycle event). + it("replays all watched relPaths on the channel ready lifecycle event", async () => { + const { watcher, provider, emitLifecycle } = makeFixture(); + + await watcher.watch(WORKSPACE_ID, ""); + await watcher.watch(WORKSPACE_ID, "src"); + provider.callAgentMethod.mockClear(); + + emitLifecycle({ type: "ready" }); + await settle(); + + const calls = provider.callAgentMethod.mock.calls; + expect(calls).toHaveLength(2); + expect(calls).toContainEqual(["fs.watch", { relPath: "" }]); + expect(calls).toContainEqual(["fs.watch", { relPath: "src" }]); + }); + + it("does not replay on non-ready lifecycle events", async () => { + const { watcher, provider, emitLifecycle } = makeFixture(); + + await watcher.watch(WORKSPACE_ID, "src"); + provider.callAgentMethod.mockClear(); + + emitLifecycle({ type: "degraded" }); + emitLifecycle({ type: "reconnecting" }); + await settle(); + + expect(provider.callAgentMethod).not.toHaveBeenCalled(); + }); + + it("does not replay unwatched relPaths", async () => { + const { watcher, provider, emitLifecycle } = makeFixture(); + + await watcher.watch(WORKSPACE_ID, "src"); + await watcher.watch(WORKSPACE_ID, "docs"); + await watcher.unwatch(WORKSPACE_ID, "docs"); + provider.callAgentMethod.mockClear(); + + emitLifecycle({ type: "ready" }); + await settle(); + + expect(provider.callAgentMethod.mock.calls).toEqual([["fs.watch", { relPath: "src" }]]); + }); }); diff --git a/tests/unit/main/git/agent-executor-stream-leak.test.ts b/tests/unit/main/git/agent-executor-stream-leak.test.ts index ebaf40c0..72cb780f 100644 --- a/tests/unit/main/git/agent-executor-stream-leak.test.ts +++ b/tests/unit/main/git/agent-executor-stream-leak.test.ts @@ -55,6 +55,7 @@ function makeFixture() { listeners.get(event)?.delete(callback); }; }, + onAgentLifecycle: () => () => {}, isAgentAvailable: () => true, }; const executor = new AgentGitExecutor(provider); diff --git a/tests/unit/main/git/agent-watch.test.ts b/tests/unit/main/git/agent-watch.test.ts index 7e1cc6a7..373e7f42 100644 --- a/tests/unit/main/git/agent-watch.test.ts +++ b/tests/unit/main/git/agent-watch.test.ts @@ -5,6 +5,7 @@ const WORKSPACE_ID = "123e4567-e89b-12d3-a456-426614174020"; function makeFixture() { const listeners = new Map void>>(); + const lifecycleListeners = new Set<(event: { type: string }) => void>(); const provider = { kind: "local" as const, callAgentMethod: mock(async (_method: string, _params?: unknown) => ({})), @@ -17,16 +18,25 @@ function makeFixture() { callbacks.add(callback); return () => callbacks?.delete(callback); }, + onAgentLifecycle: (callback: (event: { type: string }) => void) => { + lifecycleListeners.add(callback); + return () => lifecycleListeners.delete(callback); + }, isAgentAvailable: () => true, }; + const emitLifecycle = (event: { type: string }) => { + for (const callback of Array.from(lifecycleListeners)) callback(event); + }; const manager = { requireContext: () => ({ fs: provider }), }; const dirty = mock((_workspaceId: string) => {}); const watcher = new AgentGitWatcher(manager as never, dirty); - return { watcher, provider, listeners, dirty }; + return { watcher, provider, listeners, dirty, emitLifecycle }; } +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe("AgentGitWatcher", () => { it("delegates git watch calls to the agent and forwards matching git.changed events", async () => { const { watcher, provider, listeners, dirty } = makeFixture(); @@ -54,4 +64,38 @@ describe("AgentGitWatcher", () => { expect(provider.callAgentMethod).toHaveBeenCalledWith("git.unwatch", { gitDir: "/repo/.git" }); expect(dirty).not.toHaveBeenCalled(); }); + + // Regression: a respawned agent process starts without the .git watcher, so + // the registration must be replayed when the channel reports a successful + // reconnect handshake (`ready` lifecycle event). + it("replays git.watch on the channel ready lifecycle event", async () => { + const { watcher, provider, emitLifecycle } = makeFixture(); + + await watcher.watch(WORKSPACE_ID, "/repo/.git"); + provider.callAgentMethod.mockClear(); + + emitLifecycle({ type: "ready" }); + await settle(); + + expect(provider.callAgentMethod.mock.calls).toEqual([ + ["git.watch", { gitDir: "/repo/.git" }], + ]); + }); + + it("does not replay on non-ready lifecycle events or after dispose", async () => { + const { watcher, provider, emitLifecycle } = makeFixture(); + + await watcher.watch(WORKSPACE_ID, "/repo/.git"); + provider.callAgentMethod.mockClear(); + + emitLifecycle({ type: "degraded" }); + await settle(); + expect(provider.callAgentMethod).not.toHaveBeenCalled(); + + watcher.disposeWorkspace(WORKSPACE_ID); + provider.callAgentMethod.mockClear(); + emitLifecycle({ type: "ready" }); + await settle(); + expect(provider.callAgentMethod).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/main/git/git-registry.test.ts b/tests/unit/main/git/git-registry.test.ts index ca78ba2b..ac8ee670 100644 --- a/tests/unit/main/git/git-registry.test.ts +++ b/tests/unit/main/git/git-registry.test.ts @@ -127,6 +127,7 @@ function fakeAgentProvider(kind: "local" | "ssh" = "ssh"): AgentBackedProvider { throw new Error(`unexpected agent method: ${method}`); }, onAgentEvent: () => () => {}, + onAgentLifecycle: () => () => {}, isAgentAvailable: () => true, }; } diff --git a/tests/unit/main/git/git-repository-status-executor.test.ts b/tests/unit/main/git/git-repository-status-executor.test.ts index 648b372c..546d8dd0 100644 --- a/tests/unit/main/git/git-repository-status-executor.test.ts +++ b/tests/unit/main/git/git-repository-status-executor.test.ts @@ -394,6 +394,7 @@ function fakeProvider( rename: fail, callAgentMethod, onAgentEvent: () => () => {}, + onAgentLifecycle: () => () => {}, } as AgentBackedProvider; } diff --git a/tests/unit/main/ipc/channels/fs/local-only-guards.test.ts b/tests/unit/main/ipc/channels/fs/local-only-guards.test.ts index b777ae1f..c99ba4da 100644 --- a/tests/unit/main/ipc/channels/fs/local-only-guards.test.ts +++ b/tests/unit/main/ipc/channels/fs/local-only-guards.test.ts @@ -83,6 +83,7 @@ describe("fs agent-backed SSH workspace delegation", () => { callbacks.add(callback); return () => callbacks?.delete(callback); }, + onAgentLifecycle: () => () => {}, }; const handler = searchTextStream(makeManager([workspace], provider) as never); const generator = handler( diff --git a/tests/unit/main/ipc/channels/fs/search-handlers.test.ts b/tests/unit/main/ipc/channels/fs/search-handlers.test.ts index e2a13c7e..95698f37 100644 --- a/tests/unit/main/ipc/channels/fs/search-handlers.test.ts +++ b/tests/unit/main/ipc/channels/fs/search-handlers.test.ts @@ -34,6 +34,7 @@ interface FakeAgentProvider { kind: "local" | "ssh"; callAgentMethod: (method: string, params?: unknown) => Promise; onAgentEvent: (event: string, callback: AgentEventCallback) => () => void; + onAgentLifecycle: (callback: (event: { type: string }) => void) => () => void; } function makeManager(provider: FakeAgentProvider, workspaces: WorkspaceMeta[] = [makeWorkspace()]) { @@ -97,6 +98,7 @@ function makeAgentProvider(options: { callbacks.add(callback); return () => callbacks?.delete(callback); }, + onAgentLifecycle: () => () => {}, }; } diff --git a/tests/unit/main/ipc/channels/git/content-handlers-missing.test.ts b/tests/unit/main/ipc/channels/git/content-handlers-missing.test.ts index a9d1151d..d19b7c1e 100644 --- a/tests/unit/main/ipc/channels/git/content-handlers-missing.test.ts +++ b/tests/unit/main/ipc/channels/git/content-handlers-missing.test.ts @@ -48,6 +48,7 @@ function makeAgentProvider(returnValue: unknown = {}) { return returnValue; }, onAgentEvent: () => () => {}, + onAgentLifecycle: () => () => {}, }, }; } From f9ed114b2343fe166f55b94a1df48c43c4e2d68b Mon Sep 17 00:00:00 2001 From: moreih29 Date: Sat, 6 Jun 2026 23:12:32 +0900 Subject: [PATCH 4/6] fix(agent): send heartbeats at 4s against the 5s judgment basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heartbeats were sent every 5s and judged at 5s (degraded = 1 missed interval), so arrivals landed at interval+epsilon and the client's degraded check chronically rode the boundary — the heartbeat histogram showed nearly all arrivals in the [1-2x] bucket, and a phase-aligned check fired spurious degraded lifecycle events during normal operation (the trigger for the v0.6.0 local-channel teardown regression). The send cadence and the advertised judgment basis are now two constants in proto.go shared by stdio and daemon modes: HeartbeatSendMs (4s) < HeartbeatAdvertiseMs (5s). The ~1s wire-jitter margin removes the boundary condition while keeping real-outage detection latency at 5s. The TS client is unchanged — it already derives both thresholds from the advertised value. Verified against the built binary: ready frame advertises 5000ms, actual inter-arrival measured at 3999-4000ms. Co-Authored-By: Claude Opus 4.8 --- cmd/agent/daemon.go | 5 +++-- cmd/agent/main.go | 14 +++++++++----- internal/proto/proto.go | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cmd/agent/daemon.go b/cmd/agent/daemon.go index 0ba0878a..3612d015 100644 --- a/cmd/agent/daemon.go +++ b/cmd/agent/daemon.go @@ -363,7 +363,7 @@ func runDaemon(root string) { if writeErr := host.WriteFrame(proto.Ready( d.Methods(), - 5_000, + proto.HeartbeatAdvertiseMs, int(reattachGrace/time.Millisecond), epoch, []string{"reattach"}, @@ -373,7 +373,8 @@ func runDaemon(root string) { return nil, true // keep running; dialer may reconnect } - host.StartHeartbeat(5 * time.Second) + // 송신 4s < 판정 5s — 도착 지터 마진. 근거는 proto.HeartbeatSendMs 주석 참조. + host.StartHeartbeat(proto.HeartbeatSendMs * time.Millisecond) // The idle watchdog fires after reattachGrace of no inbound traffic. // While a live dialer is connected, its pings keep resetting the timer. // A silent zombie dialer (dead TCP, no EOF) trips the watchdog after diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 5710fa00..0c517ea2 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -182,18 +182,22 @@ func main() { // Ready frame must reach the client before any other output so the // channel handshake on the TS side can settle. A write failure here // is unrecoverable — without a Ready, the client will time out. - // methods 목록과 heartbeat 간격(5s), idle watchdog 한도를 함께 전달해 + // methods 목록과 heartbeat 판정 기준(5s), idle watchdog 한도를 함께 전달해 // 클라이언트가 pull 기반 hook.getInfo 호출과 keepalive ping을 결정하도록 한다. // agentEpoch and capabilities are 0/nil in local stdio mode — the TS client // (task 12) uses their presence to detect daemon-mode reattach capability. - if err := host.WriteFrame(proto.Ready(d.Methods(), 5_000, idleWatchdogMs, 0, nil)); err != nil { + if err := host.WriteFrame( + proto.Ready(d.Methods(), proto.HeartbeatAdvertiseMs, idleWatchdogMs, 0, nil), + ); err != nil { agentLogger.Error("failed to write ready frame", "err", err) os.Exit(1) } - // 5초 간격 heartbeat를 시작한다. Ready frame에 광고한 heartbeatIntervalMs와 - // 일치해야 한다(issue 5 decision: 10s→5s). ctx 취소(드레인) 시 자동 정지한다. - host.StartHeartbeat(5 * time.Second) + // Heartbeat 송신은 광고한 판정 기준(5s)보다 짧은 4s 간격 — 도착 지터에 + // ~1s 마진을 줘서 클라이언트의 degraded(1-miss) 체크가 경계에 걸리지 + // 않게 한다. 근거는 proto.HeartbeatSendMs 주석 참조. ctx 취소(드레인) + // 시 자동 정지한다. + host.StartHeartbeat(proto.HeartbeatSendMs * time.Millisecond) // Idle watchdog (SSH only): self-terminate if the client sends nothing for // the limit. The client pings every limit/6 (derived from the advertised diff --git a/internal/proto/proto.go b/internal/proto/proto.go index e8f91067..7dbdf7c5 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -30,6 +30,20 @@ const ( ProtocolVersion = "2" ServerVersion = "0.1.0" + // HeartbeatAdvertiseMs is the judgment basis the Ready frame advertises: + // the client flags "degraded" after 1× this interval without a heartbeat + // and warns terminally after 3×. + // + // HeartbeatSendMs is the cadence at which the agent actually emits + // "agent.heartbeat" — deliberately SHORTER than the advertised judgment + // basis. When send == judge (both 5 s, pre-v0.6.1), every arrival landed + // at interval+ε and the client's degraded check chronically rode the + // boundary, firing spurious "degraded" lifecycle events during normal + // operation. Sending at 4 s against a 5 s judgment leaves ~1 s of wire + // jitter margin while keeping real-outage detection latency at 5 s. + HeartbeatAdvertiseMs = 5_000 + HeartbeatSendMs = 4_000 + // CodeProtocolError signals envelope-level failures: malformed // JSON, missing required fields, version mismatch. The client // must not retry — the request never reached domain logic. From 346677fa667ba9fb9c2530d5cc4206ef5d5efd32 Mon Sep 17 00:00:00 2001 From: moreih29 Date: Sat, 6 Jun 2026 23:19:15 +0900 Subject: [PATCH 5/6] chore: bump version to 0.6.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b689282b..179029d3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nexus-code", "productName": "NexusCode", - "version": "0.6.0", + "version": "0.6.1", "description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.", "license": "MIT", "private": true, From ffbe5f50f99501034aa068d964e5e4c095e9a9c4 Mon Sep 17 00:00:00 2001 From: moreih29 Date: Sat, 6 Jun 2026 23:21:30 +0900 Subject: [PATCH 6/6] test: add onAgentLifecycle to search round-trip provider fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentBackedProvider guard now requires onAgentLifecycle (watch replay, 0ff4f7c); the integration fixture predated it and failed the guard. Unit fixtures were updated in that commit — this integration one was missed because only tests/unit ran locally. Co-Authored-By: Claude Opus 4.8 --- tests/integration/search-round-trip.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/search-round-trip.test.ts b/tests/integration/search-round-trip.test.ts index 54986b0c..aac4bad5 100644 --- a/tests/integration/search-round-trip.test.ts +++ b/tests/integration/search-round-trip.test.ts @@ -287,6 +287,7 @@ function makeTestSearchProvider(rootPath: string) { callbacks.add(callback); return () => callbacks?.delete(callback); }, + onAgentLifecycle: () => () => {}, }; }