@sagentlab/navarch-runtime 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -252,6 +252,8 @@ unchanged across the deployment.
252
252
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
253
253
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
254
254
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
255
+ | `NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS` | `3600000` (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. |
256
+ | `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
255
257
  | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
256
258
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
257
259
  | `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
@@ -260,7 +262,7 @@ unchanged across the deployment.
260
262
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
261
263
  | `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
262
264
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
263
- | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. Runtime sessions default to an empty `--setting-sources` list so machine/user/project hooks cannot leak into temporary checkouts; supply `--setting-sources=<sources>` here to opt in deliberately. |
265
+ | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. Runtime sessions default to an empty `--setting-sources` list so machine/user/project hooks cannot leak into temporary checkouts. The generated host-mode settings retain only the user-level `apiKeyHelper` needed for authentication; supply `--setting-sources=<sources>` here to opt into other settings deliberately. |
264
266
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
265
267
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
266
268
  | `NAVARCH_GEMINI_BIN` | `gemini` | Path/name of the Google Gemini CLI binary. |
@@ -282,7 +284,8 @@ supported coding agents, using each CLI's native enforcement point:
282
284
  `bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
283
285
  User, project, and local settings sources are disabled by default, preventing
284
286
  host-only hooks and plugins from leaking into unattended sessions; the
285
- explicit generated settings file remains active.
287
+ explicit generated settings file remains active and carries forward only a
288
+ user-level `apiKeyHelper` when the machine uses one for authentication.
286
289
  - **Codex:** the runtime passes a one-off native permission profile with
287
290
  `approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
288
291
  Codex's OS sandbox grants read/write access only to the allowed roots and
@@ -149,8 +149,10 @@ function attachUsage(result) {
149
149
  const reportText = (0, exit_conditions_cjs_1.extractFinalMessageFromCodexEvents)(events) ?? undefined;
150
150
  return {
151
151
  ...result,
152
- tokensIn: usage.tokensIn,
153
- tokensOut: usage.tokensOut,
152
+ // A run killed before its first usage event has unknown usage, not a
153
+ // measured zero — leave the fields unset so aggregation skips them.
154
+ ...(usage.tokensIn !== undefined ? { tokensIn: usage.tokensIn } : {}),
155
+ ...(usage.tokensOut !== undefined ? { tokensOut: usage.tokensOut } : {}),
154
156
  ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
155
157
  ...(reportText !== undefined ? { reportText } : {}),
156
158
  };
package/dist/cli.cjs CHANGED
@@ -17,6 +17,7 @@ const sandbox_cjs_1 = require("./sandbox.cjs");
17
17
  const logger_cjs_1 = require("./logger.cjs");
18
18
  const update_coordinator_cjs_1 = require("./update-coordinator.cjs");
19
19
  const supervisor_cjs_1 = require("./supervisor.cjs");
20
+ const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
20
21
  const log = (0, logger_cjs_1.createLogger)("cli");
21
22
  const PACKAGE_NAME = "@sagentlab/navarch-runtime";
22
23
  /** Include a control-plane response's safe error detail in top-level CLI failures. */
@@ -191,7 +192,18 @@ async function startCommand(flags) {
191
192
  const config = { ...baseConfig, agentType };
192
193
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
193
194
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
194
- const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId));
195
+ const activeSessionIds = new Set();
196
+ const worktreeJanitor = new worktree_janitor_cjs_1.WorktreeJanitor({
197
+ workspaceRoot: config.workspaceRoot,
198
+ intervalMs: config.worktreeCleanupIntervalMs,
199
+ staleAfterMs: config.worktreeStaleAfterMs,
200
+ isSessionActive: (sessionId) => activeSessionIds.has(sessionId),
201
+ });
202
+ const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => {
203
+ activeSessionIds.add(sessionId);
204
+ return (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId)
205
+ .finally(() => activeSessionIds.delete(sessionId));
206
+ });
195
207
  const bootId = (0, node_crypto_1.randomUUID)();
196
208
  const updateCoordinatorRef = {};
197
209
  let readySent = false;
@@ -208,6 +220,7 @@ async function startCommand(flags) {
208
220
  heartbeat,
209
221
  });
210
222
  heartbeat.start();
223
+ worktreeJanitor.start();
211
224
  claimLoop.start();
212
225
  log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
213
226
  let shuttingDown = false;
@@ -222,6 +235,7 @@ async function startCommand(flags) {
222
235
  heartbeat.setUpdateState("draining");
223
236
  await claimLoop.drain();
224
237
  await capacity.waitForIdle();
238
+ worktreeJanitor.stop();
225
239
  heartbeat.stop();
226
240
  log.info("shutdown drain complete");
227
241
  process.exit(0);
@@ -233,8 +247,14 @@ async function startCommand(flags) {
233
247
  async function superviseCommand(flags) {
234
248
  const config = configFromFlags(flags);
235
249
  const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
236
- const agentType = agentFromFlag(flags);
237
- const workerArgs = agentType ? ["--agent", agentType] : [];
250
+ // Resolve and pin the adapter before spawning the worker. The supervisor
251
+ // passes machine credentials through the environment, so the child no
252
+ // longer reads machine.json for identity fields (including agent_type).
253
+ // Without an explicit worker argument, a saved Codex/Gemini selection
254
+ // therefore falls back to Claude Code.
255
+ const agentType = agentFromFlag(flags) ??
256
+ (process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
257
+ const workerArgs = ["--agent", agentType];
238
258
  // Pin the identity for this supervisor's lifetime. Without this snapshot, a
239
259
  // replacement worker rereads machine.json after an automatic update and can
240
260
  // silently become a different machine if another terminal reused the same
package/dist/config.cjs CHANGED
@@ -57,6 +57,8 @@ function loadRuntimeConfig(env = process.env) {
57
57
  // interval must stay comfortably under that TTL.
58
58
  leaseHeartbeatIntervalMs,
59
59
  sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
60
+ worktreeCleanupIntervalMs: envInt(env, "NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS", 60 * 60 * 1000),
61
+ worktreeStaleAfterMs: envInt(env, "NAVARCH_WORKTREE_STALE_AFTER_MS", 24 * 60 * 60 * 1000),
60
62
  agentType,
61
63
  runtimes: runtimes.length > 0 ? runtimes : [agentType],
62
64
  claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
@@ -89,34 +89,48 @@ function parseCodexJsonEvents(stdout) {
89
89
  return events;
90
90
  }
91
91
  /**
92
- * Extracts token/cost usage from parsed `codex exec --json` events. The last
93
- * `turn.completed` event wins. Legacy `token_count` events remain supported.
92
+ * Extracts token/cost usage from parsed `codex exec --json` events.
93
+ *
94
+ * `turn.completed.usage` is per-turn (each turn reports only its own tokens),
95
+ * so multi-turn runs sum every turn's usage — keeping only the last event
96
+ * would under-report all earlier turns. Legacy `token_count` envelopes carry
97
+ * cumulative session totals instead, so for those the last event wins; they
98
+ * are only used when no verified `turn.completed` usage was seen.
99
+ *
100
+ * When no usage-bearing event is present at all (e.g. the run was killed
101
+ * before its first `turn.completed`), every field stays undefined so callers
102
+ * report usage as unknown rather than a fabricated measured zero.
94
103
  */
95
104
  function extractUsageFromCodexEvents(events) {
96
- let tokensIn = 0;
97
- let tokensOut = 0;
105
+ let turnTokensIn = 0;
106
+ let turnTokensOut = 0;
107
+ let sawTurnUsage = false;
108
+ let legacyTokensIn;
109
+ let legacyTokensOut;
98
110
  let costUsd;
99
111
  for (const event of events) {
100
112
  if (event.type === "turn.completed" && event.usage) {
101
- tokensIn = event.usage.input_tokens ?? 0;
102
- tokensOut = event.usage.output_tokens ?? 0;
103
- if (typeof event.usage.total_cost_usd === "number")
104
- costUsd = event.usage.total_cost_usd;
113
+ sawTurnUsage = true;
114
+ turnTokensIn += event.usage.input_tokens ?? 0;
115
+ turnTokensOut += event.usage.output_tokens ?? 0;
116
+ if (typeof event.usage.total_cost_usd === "number") {
117
+ costUsd = (costUsd ?? 0) + event.usage.total_cost_usd;
118
+ }
105
119
  }
106
120
  if (event.msg?.type === "token_count") {
107
121
  const nested = event.msg.info?.total_token_usage;
108
- tokensIn = nested
122
+ legacyTokensIn = nested
109
123
  ? (nested.input_tokens ?? 0)
110
124
  : (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
111
- tokensOut = nested?.output_tokens ?? event.msg.output_tokens ?? 0;
125
+ legacyTokensOut = nested?.output_tokens ?? event.msg.output_tokens ?? 0;
112
126
  if (typeof nested?.total_cost_usd === "number")
113
127
  costUsd = nested.total_cost_usd;
114
128
  }
115
129
  if (event.type === "event_msg" && event.payload?.type === "token_count") {
116
130
  const total = event.payload.info?.total_token_usage;
117
131
  if (total) {
118
- tokensIn = total.input_tokens ?? 0;
119
- tokensOut = total.output_tokens ?? 0;
132
+ legacyTokensIn = total.input_tokens ?? 0;
133
+ legacyTokensOut = total.output_tokens ?? 0;
120
134
  if (typeof total.total_cost_usd === "number")
121
135
  costUsd = total.total_cost_usd;
122
136
  }
@@ -125,7 +139,9 @@ function extractUsageFromCodexEvents(events) {
125
139
  costUsd = event.msg.total_cost_usd;
126
140
  }
127
141
  }
128
- return { tokensIn, tokensOut, costUsd };
142
+ if (sawTurnUsage)
143
+ return { tokensIn: turnTokensIn, tokensOut: turnTokensOut, costUsd };
144
+ return { tokensIn: legacyTokensIn, tokensOut: legacyTokensOut, costUsd };
129
145
  }
130
146
  /** The last completed agent message, with legacy `msg.agent_message` fallback. */
131
147
  function extractFinalMessageFromCodexEvents(events) {
@@ -187,10 +203,26 @@ function mapExitCondition(result) {
187
203
  };
188
204
  }
189
205
  if (result.exitCode !== 0) {
206
+ // Claude Code returns provider/auth failures as a structured JSON result
207
+ // on stdout while exiting non-zero. Prefer its human-readable `result`
208
+ // field over the raw metrics envelope (or incidental stderr warnings).
209
+ return {
210
+ leaseOutcome: "failed",
211
+ exitStatus: "failed",
212
+ reportSummary: summarize(parsedJson?.result || result.stderr || result.stdout) ||
213
+ `Adapter exited with code ${result.exitCode}.`,
214
+ evidenceUrls,
215
+ };
216
+ }
217
+ // Some Claude Code releases exit 0 even when their structured result says
218
+ // the provider rejected the turn (for example a session-limit 429). Never
219
+ // report those synthetic zero-token turns as completed work.
220
+ if (parsedJson?.is_error) {
190
221
  return {
191
222
  leaseOutcome: "failed",
192
223
  exitStatus: "failed",
193
- reportSummary: summarize(result.stderr || result.stdout) || `Adapter exited with code ${result.exitCode}.`,
224
+ reportSummary: summarize(parsedJson.result || result.stderr || result.stdout) ||
225
+ "Claude Code reported an unsuccessful result.",
194
226
  evidenceUrls,
195
227
  };
196
228
  }
package/dist/session.cjs CHANGED
@@ -17,6 +17,7 @@ const prompt_cjs_1 = require("./prompt.cjs");
17
17
  const mcp_config_cjs_1 = require("./mcp-config.cjs");
18
18
  const logger_cjs_1 = require("./logger.cjs");
19
19
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
20
+ const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
20
21
  const github_pr_cjs_1 = require("./github-pr.cjs");
21
22
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
22
23
  const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
@@ -67,6 +68,7 @@ async function runSession(deps, claimed, sessionId) {
67
68
  // (heartbeat/complete/issue/transcript) still key on leaseId.
68
69
  const workDir = node_path_1.default.join(config.workspaceRoot, "sessions", sessionId);
69
70
  await node_fs_1.promises.mkdir(workDir, { recursive: true });
71
+ await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
70
72
  const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
71
73
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
72
74
  // Secrets: initially fetched once, held only in memory (registry + env map below),
@@ -136,7 +138,8 @@ async function runSession(deps, claimed, sessionId) {
136
138
  return heartbeatInFlight;
137
139
  const request = api
138
140
  .heartbeatLease(leaseId, { guidance_after: guidanceCursor })
139
- .then((heartbeat) => {
141
+ .then(async (heartbeat) => {
142
+ await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
140
143
  guidanceCursor = heartbeat.guidance_cursor ?? guidanceCursor;
141
144
  const fresh = (heartbeat.guidance ?? []).filter((entry) => {
142
145
  if (knownGuidanceIds.has(entry.id))
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.guardHookScriptPath = guardHookScriptPath;
7
+ exports.readClaudeApiKeyHelper = readClaudeApiKeyHelper;
7
8
  exports.prepareWorktreeGuard = prepareWorktreeGuard;
8
9
  exports.codexToolReadRoots = codexToolReadRoots;
9
10
  exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
@@ -12,6 +13,7 @@ const node_path_1 = __importDefault(require("node:path"));
12
13
  const node_fs_1 = require("node:fs");
13
14
  const node_os_1 = __importDefault(require("node:os"));
14
15
  const CODEX_GUARD_PROFILE = "navarch-worktree";
16
+ const CLAUDE_USER_SETTINGS_FILENAME = "settings.json";
15
17
  /**
16
18
  * Tools the hook screens. Everything else — the lease-scoped Navarch MCP
17
19
  * tools, WebFetch, Task, ... — carries no direct filesystem path and passes
@@ -26,6 +28,37 @@ const GUARDED_TOOL_MATCHER = "^(Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep
26
28
  function guardHookScriptPath() {
27
29
  return node_path_1.default.join(__dirname, "..", "bin", "worktree-guard-hook.cjs");
28
30
  }
31
+ /**
32
+ * Preserve Claude's authentication helper without inheriting the rest of the
33
+ * operator's user settings. Runtime sessions deliberately disable the normal
34
+ * user/project/local setting sources, but apiKeyHelper is a supported auth
35
+ * source and some unattended machines have no keychain/OAuth fallback.
36
+ *
37
+ * Invalid or missing user settings degrade to no helper, matching Claude
38
+ * print mode's tolerant treatment of invalid settings files.
39
+ */
40
+ async function readClaudeApiKeyHelper(settingsPath = defaultClaudeUserSettingsPath()) {
41
+ if (!settingsPath)
42
+ return undefined;
43
+ try {
44
+ const parsed = JSON.parse(await node_fs_1.promises.readFile(settingsPath, "utf8"));
45
+ if (typeof parsed === "object" &&
46
+ parsed !== null &&
47
+ !Array.isArray(parsed) &&
48
+ typeof parsed.apiKeyHelper === "string") {
49
+ const helper = parsed.apiKeyHelper.trim();
50
+ return helper || undefined;
51
+ }
52
+ }
53
+ catch {
54
+ // Missing/invalid user settings are not a runtime startup failure.
55
+ }
56
+ return undefined;
57
+ }
58
+ function defaultClaudeUserSettingsPath() {
59
+ const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || node_path_1.default.join(node_os_1.default.homedir(), ".claude");
60
+ return node_path_1.default.join(configDir, CLAUDE_USER_SETTINGS_FILENAME);
61
+ }
29
62
  /**
30
63
  * Writes the per-session guard config + Claude settings file into workDir and
31
64
  * returns their paths. The settings file is passed to the CLI as `--settings`
@@ -45,7 +78,11 @@ async function prepareWorktreeGuard(options) {
45
78
  // process.execPath rather than a bare `node`: the hook must run with the
46
79
  // same interpreter as the runtime regardless of the agent's PATH.
47
80
  const command = [process.execPath, hookScriptPath, configPath].map(shellQuote).join(" ");
81
+ const apiKeyHelper = await readClaudeApiKeyHelper(options.claudeUserSettingsPath === undefined
82
+ ? defaultClaudeUserSettingsPath()
83
+ : options.claudeUserSettingsPath);
48
84
  const settings = {
85
+ ...(apiKeyHelper ? { apiKeyHelper } : {}),
49
86
  hooks: {
50
87
  PreToolUse: [
51
88
  {
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WorktreeJanitor = exports.SESSION_ACTIVITY_FILENAME = void 0;
7
+ exports.markSessionWorkspaceActive = markSessionWorkspaceActive;
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_fs_1 = require("node:fs");
10
+ const logger_cjs_1 = require("./logger.cjs");
11
+ const sandbox_cjs_1 = require("./sandbox.cjs");
12
+ const log = (0, logger_cjs_1.createLogger)("worktree-janitor");
13
+ exports.SESSION_ACTIVITY_FILENAME = ".navarch-active";
14
+ /**
15
+ * Refreshes the durable activity marker used by janitors in other runtime
16
+ * processes that happen to share a workspace root.
17
+ */
18
+ async function markSessionWorkspaceActive(sessionRoot) {
19
+ await node_fs_1.promises.mkdir(sessionRoot, { recursive: true });
20
+ await node_fs_1.promises.writeFile(node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME), `${new Date().toISOString()}\n`, "utf8");
21
+ }
22
+ /**
23
+ * Periodically removes session worktrees left behind by SIGKILL, a host
24
+ * restart, or another exit that bypassed runSession's finally block.
25
+ *
26
+ * The age threshold is deliberately conservative. A directory must be both
27
+ * absent from this process's active-session registry and stale on disk before
28
+ * it is eligible. Git worktree metadata is removed before the directory.
29
+ */
30
+ class WorktreeJanitor {
31
+ options;
32
+ runner;
33
+ now;
34
+ timer = null;
35
+ sweepInFlight = null;
36
+ constructor(options) {
37
+ this.options = options;
38
+ this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
39
+ this.now = options.now ?? Date.now;
40
+ }
41
+ start() {
42
+ if (this.timer)
43
+ return;
44
+ void this.sweepAndLog();
45
+ this.timer = setInterval(() => void this.sweepAndLog(), this.options.intervalMs);
46
+ this.timer.unref?.();
47
+ }
48
+ stop() {
49
+ if (this.timer)
50
+ clearInterval(this.timer);
51
+ this.timer = null;
52
+ }
53
+ sweep() {
54
+ if (this.sweepInFlight)
55
+ return this.sweepInFlight;
56
+ this.sweepInFlight = this.runSweep().finally(() => {
57
+ this.sweepInFlight = null;
58
+ });
59
+ return this.sweepInFlight;
60
+ }
61
+ async sweepAndLog() {
62
+ try {
63
+ const summary = await this.sweep();
64
+ if (summary.removed > 0 || summary.errors > 0) {
65
+ log.info(`cleanup complete: scanned=${summary.scanned} removed=${summary.removed} ` +
66
+ `active=${summary.skippedActive} fresh=${summary.skippedFresh} errors=${summary.errors}`);
67
+ }
68
+ }
69
+ catch (error) {
70
+ log.warn(`cleanup sweep failed: ${String(error)}`);
71
+ }
72
+ }
73
+ async runSweep() {
74
+ const summary = {
75
+ scanned: 0,
76
+ removed: 0,
77
+ skippedActive: 0,
78
+ skippedFresh: 0,
79
+ errors: 0,
80
+ };
81
+ const sessionsRoot = node_path_1.default.resolve(this.options.workspaceRoot, "sessions");
82
+ const entries = await node_fs_1.promises.readdir(sessionsRoot, { withFileTypes: true }).catch((error) => {
83
+ if (isMissing(error))
84
+ return [];
85
+ throw error;
86
+ });
87
+ for (const entry of entries) {
88
+ // Do not follow symlinks placed in the sessions directory.
89
+ if (!entry.isDirectory() || entry.isSymbolicLink())
90
+ continue;
91
+ summary.scanned += 1;
92
+ const sessionId = entry.name;
93
+ const sessionRoot = node_path_1.default.resolve(sessionsRoot, sessionId);
94
+ if (node_path_1.default.dirname(sessionRoot) !== sessionsRoot) {
95
+ summary.errors += 1;
96
+ continue;
97
+ }
98
+ if (this.options.isSessionActive?.(sessionId)) {
99
+ summary.skippedActive += 1;
100
+ continue;
101
+ }
102
+ try {
103
+ const lastActivity = await this.lastActivityAt(sessionRoot);
104
+ if (this.now() - lastActivity < this.options.staleAfterMs) {
105
+ summary.skippedFresh += 1;
106
+ continue;
107
+ }
108
+ // Close the in-process race with a session claim that started while
109
+ // this directory was being inspected.
110
+ if (this.options.isSessionActive?.(sessionId)) {
111
+ summary.skippedActive += 1;
112
+ continue;
113
+ }
114
+ await this.removeSessionWorktree(sessionRoot);
115
+ summary.removed += 1;
116
+ }
117
+ catch (error) {
118
+ summary.errors += 1;
119
+ log.warn(`could not clean abandoned session ${sessionId}: ${String(error)}`);
120
+ }
121
+ }
122
+ return summary;
123
+ }
124
+ async lastActivityAt(sessionRoot) {
125
+ const marker = node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME);
126
+ try {
127
+ return (await node_fs_1.promises.stat(marker)).mtimeMs;
128
+ }
129
+ catch (error) {
130
+ if (!isMissing(error))
131
+ throw error;
132
+ // Compatibility with worktrees created before activity markers existed.
133
+ return (await node_fs_1.promises.stat(sessionRoot)).mtimeMs;
134
+ }
135
+ }
136
+ async removeSessionWorktree(sessionRoot) {
137
+ const worktreePath = node_path_1.default.join(sessionRoot, "repo");
138
+ const branchResult = await this.runner.run("git", ["-C", worktreePath, "symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null);
139
+ const branch = branchResult?.code === 0 ? branchResult.stdout.trim() : "";
140
+ const repositoriesRoot = node_path_1.default.resolve(this.options.workspaceRoot, "repositories");
141
+ const repositories = await node_fs_1.promises.readdir(repositoriesRoot, { withFileTypes: true }).catch((error) => {
142
+ if (isMissing(error))
143
+ return [];
144
+ throw error;
145
+ });
146
+ for (const repository of repositories) {
147
+ if (!repository.isDirectory() || repository.isSymbolicLink())
148
+ continue;
149
+ const repositoryPath = node_path_1.default.resolve(repositoriesRoot, repository.name);
150
+ if (node_path_1.default.dirname(repositoryPath) !== repositoriesRoot)
151
+ continue;
152
+ const removal = await this.runner.run("git", ["--git-dir", repositoryPath, "worktree", "remove", "--force", worktreePath]).catch(() => null);
153
+ if (removal?.code === 0 && branch.startsWith("navarch/")) {
154
+ await this.runner.run("git", ["--git-dir", repositoryPath, "branch", "-D", branch]).catch(() => undefined);
155
+ }
156
+ }
157
+ await node_fs_1.promises.rm(sessionRoot, { recursive: true, force: true });
158
+ // If worktree removal failed because stale metadata was already damaged,
159
+ // pruning after the directory disappears still clears the registration.
160
+ for (const repository of repositories) {
161
+ if (!repository.isDirectory() || repository.isSymbolicLink())
162
+ continue;
163
+ const repositoryPath = node_path_1.default.resolve(repositoriesRoot, repository.name);
164
+ if (node_path_1.default.dirname(repositoryPath) !== repositoriesRoot)
165
+ continue;
166
+ await this.runner.run("git", ["--git-dir", repositoryPath, "worktree", "prune"]).catch(() => undefined);
167
+ }
168
+ }
169
+ }
170
+ exports.WorktreeJanitor = WorktreeJanitor;
171
+ function isMissing(error) {
172
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",