Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/agent/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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
Expand Down
14 changes: 9 additions & 5 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/proto/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/main/features/fs/bridge/agent-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 30 additions & 2 deletions src/main/features/fs/bridge/agent-watch.ts
Original file line number Diff line number Diff line change
@@ -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),
});
Expand Down Expand Up @@ -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", {
Expand All @@ -75,7 +78,32 @@ export class AgentFsWatcher {
});
});

const subscription = { unsubscribe, watchedRelPaths: new Set<string>() };
// 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<string>(),
};
this.subscriptions.set(workspaceId, subscription);
return subscription;
}
Expand Down
14 changes: 12 additions & 2 deletions src/main/features/fs/bridge/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -46,12 +49,19 @@ export interface FsProvider {
export interface AgentBackedProvider extends FsProvider {
callAgentMethod<TResult = unknown>(method: string, params?: unknown): Promise<TResult>;
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<AgentBackedProvider>).callAgentMethod === "function" &&
typeof (provider as Partial<AgentBackedProvider>).onAgentEvent === "function"
typeof (provider as Partial<AgentBackedProvider>).onAgentEvent === "function" &&
typeof (provider as Partial<AgentBackedProvider>).onAgentLifecycle === "function"
);
}
28 changes: 26 additions & 2 deletions src/main/features/git/bridge/agent-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
Expand Down
24 changes: 19 additions & 5 deletions src/main/features/lsp/agent-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 26 additions & 15 deletions src/main/features/workspace/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
}
}

Expand Down
1 change: 1 addition & 0 deletions src/main/infra/agent/channel/local-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading