@sagentlab/navarch-runtime 0.1.1 → 0.1.3

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/dist/session.cjs CHANGED
@@ -14,14 +14,16 @@ const upload_cjs_1 = require("./upload.cjs");
14
14
  const prompt_cjs_1 = require("./prompt.cjs");
15
15
  const mcp_config_cjs_1 = require("./mcp-config.cjs");
16
16
  const logger_cjs_1 = require("./logger.cjs");
17
- /** Filename the generated platform MCP config is written under, inside the session's workDir (host-side; also /workspace inside the Docker sandbox -- see sandbox.cts's bind mount). */
17
+ const git_worktree_cjs_1 = require("./git-worktree.cjs");
18
+ const github_pr_cjs_1 = require("./github-pr.cjs");
19
+ /** Filename the generated platform MCP config is written under inside the session metadata directory. */
18
20
  const MCP_CONFIG_FILENAME = "mcp-config.json";
19
21
  const log = (0, logger_cjs_1.createLogger)("session");
20
22
  /**
21
23
  * Runs one claimed task end to end (implementation-plan.md WP-07):
22
24
  * 1. write the prompt file
23
25
  * 2. fetch secrets from the broker once, at session start
24
- * 3. stand up a Docker sandbox, clone the repo, inject env vars
26
+ * 3. optionally stand up a Docker sandbox when explicitly configured
25
27
  * 4. run the configured agent adapter (Claude Code or Codex, per
26
28
  * NAVARCH_AGENT — adapters/index.cts#selectAdapter), heartbeating the
27
29
  * lease throughout
@@ -29,19 +31,29 @@ const log = (0, logger_cjs_1.createLogger)("session");
29
31
  * lease (recording which agent_type ran it)
30
32
  * 6. wipe the sandbox unconditionally
31
33
  *
32
- * NEEDS LIVE VERIFICATION: the full path requires a real Docker daemon, a
33
- * real `claude` (or `codex`) binary, and a live control-plane API none of
34
- * which are available in this offline build environment. Unit tests exercise
35
- * each collaborator (api.cts, sandbox.cts, exit-conditions.cts, redact.cts,
36
- * adapters/*.cts) in isolation instead; see runtime/README.md.
34
+ * NEEDS LIVE VERIFICATION: the full path requires a real `claude` (or
35
+ * `codex`) binary and a live control-plane API; Docker-backed execution also
36
+ * requires a real Docker daemon. Unit tests exercise each collaborator
37
+ * (api.cts, sandbox.cts, exit-conditions.cts, redact.cts, adapters/*.cts) in
38
+ * isolation instead; see runtime/README.md.
37
39
  */
38
40
  async function runSession(deps, claimed, sessionId) {
39
41
  const { api, config } = deps;
40
42
  const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
43
+ const execution = bundle.execution ?? {
44
+ profile: task.execution_profile ?? "standard",
45
+ model: config.agentType === "codex" ? "gpt-5.6" : "best",
46
+ reasoning_effort: "medium",
47
+ };
48
+ const executionReport = {
49
+ model: execution.model,
50
+ execution_profile: execution.profile,
51
+ reasoning_effort: execution.reasoning_effort,
52
+ };
41
53
  // The session's identity is the pre-allocated session id sent at claim time
42
54
  // (recorded on the lease by the dispatcher). Lease-scoped API calls
43
55
  // (heartbeat/complete/issue/transcript) still key on leaseId.
44
- const workDir = node_path_1.default.join(config.workspaceRoot, sessionId);
56
+ const workDir = node_path_1.default.join(config.workspaceRoot, "sessions", sessionId);
45
57
  await node_fs_1.promises.mkdir(workDir, { recursive: true });
46
58
  const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
47
59
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
@@ -56,15 +68,72 @@ async function runSession(deps, claimed, sessionId) {
56
68
  secrets = issued.secrets;
57
69
  registry.registerAll(secrets);
58
70
  }
59
- const abortController = new AbortController();
71
+ const cloneUrl = bundle.repository?.clone_url ??
72
+ (task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
73
+ if (!cloneUrl) {
74
+ const failureSummary = `Project ${task.project_id} has no GitHub repository URL. Set it in Project settings before dispatching work.`;
75
+ await api.completeLease(leaseId, {
76
+ status: "failed",
77
+ report: failureSummary,
78
+ failure_summary: failureSummary,
79
+ evidence_urls: [],
80
+ cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
81
+ exit_status: "crashed",
82
+ agent_type: config.agentType,
83
+ ...executionReport,
84
+ });
85
+ secrets = {};
86
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
87
+ return;
88
+ }
89
+ const githubToken = secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
90
+ const gitWorktree = new git_worktree_cjs_1.GitWorktree({
91
+ workspaceRoot: config.workspaceRoot,
92
+ projectId: task.project_id,
93
+ taskId: task.id,
94
+ sessionId,
95
+ cloneUrl,
96
+ githubToken,
97
+ });
98
+ const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
99
+ const deliveredGuidance = [...(bundle.guidance ?? [])];
100
+ let guidanceCursor = bundle.guidance_cursor ?? null;
101
+ let pendingGuidance = [];
102
+ let activeAbortController = null;
60
103
  let leaseLost = false;
61
- const heartbeatTimer = setInterval(() => {
62
- api.heartbeatLease(leaseId).catch((err) => {
104
+ let heartbeatInFlight = null;
105
+ const pollLease = () => {
106
+ if (heartbeatInFlight)
107
+ return heartbeatInFlight;
108
+ const request = api
109
+ .heartbeatLease(leaseId, { guidance_after: guidanceCursor })
110
+ .then((heartbeat) => {
111
+ guidanceCursor = heartbeat.guidance_cursor ?? guidanceCursor;
112
+ const fresh = (heartbeat.guidance ?? []).filter((entry) => {
113
+ if (knownGuidanceIds.has(entry.id))
114
+ return false;
115
+ knownGuidanceIds.add(entry.id);
116
+ return true;
117
+ });
118
+ if (fresh.length > 0) {
119
+ pendingGuidance.push(...fresh);
120
+ deliveredGuidance.push(...fresh);
121
+ log.info(`received ${fresh.length} guidance update${fresh.length === 1 ? "" : "s"} for ${leaseId}; restarting the agent turn in the same worktree.`);
122
+ activeAbortController?.abort();
123
+ }
124
+ })
125
+ .catch((err) => {
63
126
  log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
64
127
  leaseLost = true;
65
- abortController.abort();
128
+ activeAbortController?.abort();
129
+ })
130
+ .finally(() => {
131
+ heartbeatInFlight = null;
66
132
  });
67
- }, config.leaseHeartbeatIntervalMs);
133
+ heartbeatInFlight = request;
134
+ return request;
135
+ };
136
+ const heartbeatTimer = setInterval(() => void pollLease(), config.leaseHeartbeatIntervalMs);
68
137
  const dockerAvailable = config.sandboxMode === "docker" && (await (0, sandbox_cjs_1.isDockerAvailable)());
69
138
  if (config.sandboxMode === "docker" && !dockerAvailable) {
70
139
  clearInterval(heartbeatTimer);
@@ -73,16 +142,26 @@ async function runSession(deps, claimed, sessionId) {
73
142
  .completeLease(leaseId, {
74
143
  status: "failed",
75
144
  report: "Docker sandbox unavailable on this machine.",
145
+ failure_summary: "Docker sandbox unavailable on this machine.",
76
146
  evidence_urls: [],
77
147
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
78
148
  exit_status: "crashed",
79
149
  agent_type: config.agentType,
150
+ ...executionReport,
80
151
  })
81
152
  .catch((err) => log.warn(`complete() after docker-unavailable also failed: ${String(err)}`));
153
+ secrets = {};
154
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
82
155
  return;
83
156
  }
84
157
  const sandbox = dockerAvailable
85
- ? new sandbox_cjs_1.DockerSandbox({ sessionId, workspaceRoot: config.workspaceRoot, image: config.dockerImage })
158
+ ? new sandbox_cjs_1.DockerSandbox({
159
+ sessionId,
160
+ workspaceRoot: node_path_1.default.join(config.workspaceRoot, "sessions"),
161
+ image: config.dockerImage,
162
+ containerWorkDir: gitWorktree.worktreePath,
163
+ sharedGitDir: gitWorktree.repositoryPath,
164
+ })
86
165
  : null;
87
166
  // Platform MCP config (implementation-plan.md WP-07: "--mcp-config
88
167
  // pointing at the platform MCP server"): generated fresh per session,
@@ -90,8 +169,8 @@ async function runSession(deps, claimed, sessionId) {
90
169
  // auth app/api/mcp/route.ts requires -- see lib/navarch/mcp/context.ts),
91
170
  // unless the operator pinned a static override via NAVARCH_MCP_CONFIG_PATH
92
171
  // (e.g. pointing at a fake MCP server in local testing). Written to
93
- // workDir (host path) so it also lands at /workspace/mcp-config.json
94
- // inside the Docker sandbox (sandbox.cts binds workDir at /workspace) --
172
+ // workDir (host path), which Docker mode mounts at that same absolute path
173
+ // so worktree .git pointers and this config path remain valid --
95
174
  // the selected adapter (adapters/claude.cts or adapters/codex.cts) needs a
96
175
  // path valid in whichever environment it actually runs.
97
176
  let mcpConfigPath = config.mcpConfigPath;
@@ -102,13 +181,13 @@ async function runSession(deps, claimed, sessionId) {
102
181
  leaseId,
103
182
  });
104
183
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, MCP_CONFIG_FILENAME), JSON.stringify(mcpConfig, null, 2), "utf8");
105
- mcpConfigPath = sandbox ? `/workspace/${MCP_CONFIG_FILENAME}` : node_path_1.default.join(workDir, MCP_CONFIG_FILENAME);
184
+ mcpConfigPath = node_path_1.default.join(workDir, MCP_CONFIG_FILENAME);
106
185
  }
107
186
  try {
187
+ await gitWorktree.prepare();
108
188
  if (sandbox) {
109
189
  await sandbox.create();
110
190
  await sandbox.injectEnv(toEnvMap(secrets));
111
- await sandbox.cloneRepo(task.repo, Boolean(secrets["github-pat"]));
112
191
  }
113
192
  // Picks the Claude Code or Codex adapter per NAVARCH_AGENT
114
193
  // (config.cts's `agentType`) — see adapters/index.cts#selectAdapter.
@@ -118,27 +197,78 @@ async function runSession(deps, claimed, sessionId) {
118
197
  const adapter = (0, index_cjs_1.selectAdapter)(config.agentType);
119
198
  const bin = config.agentType === "codex" ? config.codexBin : config.claudeBin;
120
199
  const extraArgs = config.agentType === "codex" ? config.codexExtraArgs : config.claudeExtraArgs;
121
- const result = await adapter.run({
122
- prompt: promptText,
123
- mcpConfigPath,
124
- bin,
125
- extraArgs,
126
- timeoutMs: config.sessionTimeoutMs,
127
- env: secrets,
128
- cwd: sandbox ? undefined : workDir,
129
- dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
130
- signal: abortController.signal,
131
- });
200
+ const attempts = [];
201
+ let result;
202
+ while (true) {
203
+ // Guidance can arrive while the worktree/sandbox is being prepared.
204
+ // It is already included in deliveredGuidance, so clear the pending
205
+ // notification and start the first turn with the corrected prompt.
206
+ pendingGuidance = [];
207
+ activeAbortController = new AbortController();
208
+ if (leaseLost)
209
+ activeAbortController.abort();
210
+ const runPrompt = deliveredGuidance.length > (bundle.guidance?.length ?? 0)
211
+ ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
212
+ : promptText;
213
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), runPrompt, "utf8");
214
+ const turnResult = await adapter.run({
215
+ prompt: runPrompt,
216
+ mcpConfigPath,
217
+ bin,
218
+ extraArgs,
219
+ model: execution.model,
220
+ reasoningEffort: execution.reasoning_effort,
221
+ timeoutMs: config.sessionTimeoutMs,
222
+ env: toEnvMap(secrets),
223
+ cwd: sandbox ? undefined : gitWorktree.worktreePath,
224
+ dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
225
+ signal: activeAbortController.signal,
226
+ });
227
+ activeAbortController = null;
228
+ attempts.push(turnResult);
229
+ // Close the small race between a naturally completed turn and the next
230
+ // scheduled heartbeat. If guidance landed, run another turn before the
231
+ // lease can be completed.
232
+ await pollLease();
233
+ if (!leaseLost && pendingGuidance.length > 0)
234
+ continue;
235
+ result = {
236
+ ...turnResult,
237
+ tokensIn: attempts.reduce((sum, attempt) => sum + (attempt.tokensIn ?? 0), 0),
238
+ tokensOut: attempts.reduce((sum, attempt) => sum + (attempt.tokensOut ?? 0), 0),
239
+ costUsd: attempts.reduce((sum, attempt) => sum + (attempt.costUsd ?? 0), 0),
240
+ };
241
+ break;
242
+ }
132
243
  const mapping = (0, exit_conditions_cjs_1.mapExitCondition)({ ...result, killedByLeaseLoss: leaseLost || result.killedByLeaseLoss });
244
+ try {
245
+ const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
246
+ repository: bundle.repository?.full_name ?? task.repo,
247
+ headBranch: gitWorktree.branch,
248
+ githubToken,
249
+ });
250
+ if (prUrl)
251
+ mapping.evidenceUrls.push(prUrl);
252
+ }
253
+ catch (err) {
254
+ // Evidence discovery is best-effort: a GitHub outage or token scope
255
+ // mismatch must not turn an otherwise valid completion into a crash.
256
+ log.warn(`head-branch PR lookup failed for ${leaseId}: ${String(err)}`);
257
+ }
133
258
  const knownSecrets = registry.list();
134
- const transcript = [
135
- "# stdout",
136
- (0, redact_cjs_1.redactText)(result.stdout, knownSecrets),
259
+ if (mapping.leaseOutcome === "failed") {
260
+ log.warn(`adapter failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets)}`);
261
+ }
262
+ const transcript = attempts
263
+ .flatMap((attempt, index) => [
264
+ `# agent turn ${index + 1} stdout`,
265
+ (0, redact_cjs_1.redactText)(attempt.stdout, knownSecrets),
137
266
  "",
138
- "# stderr",
139
- (0, redact_cjs_1.redactText)(result.stderr, knownSecrets),
267
+ `# agent turn ${index + 1} stderr`,
268
+ (0, redact_cjs_1.redactText)(attempt.stderr, knownSecrets),
140
269
  "",
141
- ].join("\n");
270
+ ])
271
+ .join("\n");
142
272
  let transcriptUrl;
143
273
  try {
144
274
  const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
@@ -160,26 +290,32 @@ async function runSession(deps, claimed, sessionId) {
160
290
  transcript_url: transcriptUrl,
161
291
  exit_status: mapping.exitStatus,
162
292
  agent_type: config.agentType,
293
+ ...executionReport,
163
294
  });
164
295
  }
165
296
  catch (err) {
166
297
  log.error(`session ${leaseId} threw before completing: ${String(err)}`);
298
+ const failureSummary = (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, registry.list());
167
299
  await api
168
300
  .completeLease(leaseId, {
169
301
  status: "failed",
170
- report: (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, registry.list()),
302
+ report: failureSummary,
303
+ failure_summary: failureSummary,
171
304
  evidence_urls: [],
172
305
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
173
306
  exit_status: "crashed",
174
307
  agent_type: config.agentType,
308
+ ...executionReport,
175
309
  })
176
310
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
177
311
  }
178
312
  finally {
179
313
  clearInterval(heartbeatTimer);
314
+ activeAbortController?.abort();
180
315
  secrets = {};
181
316
  if (sandbox)
182
- await sandbox.wipe();
317
+ await sandbox.stop();
318
+ await gitWorktree.cleanup();
183
319
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
184
320
  }
185
321
  }
@@ -191,6 +327,14 @@ function toEnvMap(secrets) {
191
327
  }
192
328
  if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
193
329
  out.GITHUB_TOKEN = secrets["github-pat"];
330
+ // Make ordinary `git push` calls from the agent use the in-memory token.
331
+ // The helper contains only an env-var reference; the token itself never
332
+ // lands in argv, git config, or the worktree.
333
+ out.GIT_CONFIG_COUNT = "1";
334
+ out.GIT_CONFIG_KEY_0 = "credential.helper";
335
+ out.GIT_CONFIG_VALUE_0 =
336
+ '!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f';
337
+ out.GIT_TERMINAL_PROMPT = "0";
194
338
  }
195
339
  return out;
196
340
  }
package/dist/types.cjs CHANGED
@@ -6,14 +6,7 @@
6
6
  // - docs/navarch/implementation-plan.md (WP-07 behavioral contract)
7
7
  // - docs/agent-platform-project-plan.md (§3.8 dispatch/§3.9 adapter contract)
8
8
  //
9
- // The control-plane API is being built in parallel (WP-01/WP-04/WP-05) in
10
- // other worktrees this agent cannot see. Fields/endpoints marked "ASSUMED"
11
- // are not pinned down by an explicit route contract in the docs as written
12
- // and were inferred from the closest analogous shape; see the WP-07 report
13
- // for the full list of assumptions to confirm once those WPs land. Everything
14
- // else is quoted close to verbatim from schema-design.md.
15
- //
16
- // runtime/src/api.cts is the ONLY place that turns these types into HTTP
17
- // calls, so reconciling an assumption against the real contract is a
18
- // same-file edit, not a rewrite.
9
+ // These contracts are implemented by the matching app/api routes and are
10
+ // documented in schema-design.md §7. runtime/src/api.cts is the only place
11
+ // that turns them into HTTP calls.
19
12
  Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.1",
4
- "description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them in a Docker sandbox via the Claude Code or Codex adapter, and reports results back.",
3
+ "version": "0.1.3",
4
+ "description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them via the Claude Code or Codex adapter, and reports results back.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -37,10 +37,10 @@
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest",
39
39
  "prepublishOnly": "npm run build && npm test",
40
- "start": "node dist/cli.cjs start",
41
- "register": "node dist/cli.cjs register",
42
- "connect": "node dist/cli.cjs connect",
43
- "doctor": "node dist/cli.cjs doctor"
40
+ "start": "node bin/navarch.cjs start",
41
+ "register": "node bin/navarch.cjs register",
42
+ "connect": "node bin/navarch.cjs connect",
43
+ "doctor": "node bin/navarch.cjs doctor"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^20.14.0",