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. 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, 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/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/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..05df2ba2 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; @@ -219,9 +265,21 @@ 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; + 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 +300,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 +331,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 +348,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 +377,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 +408,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 +465,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, 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: () => () => {}, }; } 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: () => () => {}, }, }; } 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(); + }); + } +});