@sagentlab/navarch-runtime 0.1.13 → 0.1.14
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 +5 -2
- package/dist/adapters/codex.cjs +4 -2
- package/dist/cli.cjs +15 -1
- package/dist/config.cjs +2 -0
- package/dist/exit-conditions.cjs +46 -14
- package/dist/session.cjs +4 -1
- package/dist/worktree-guard.cjs +37 -0
- package/dist/worktree-janitor.cjs +173 -0
- package/package.json +1 -1
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
|
|
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
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
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
|
|
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);
|
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",
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -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.
|
|
93
|
-
*
|
|
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
|
|
97
|
-
let
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
122
|
+
legacyTokensIn = nested
|
|
109
123
|
? (nested.input_tokens ?? 0)
|
|
110
124
|
: (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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) ||
|
|
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))
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -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