@sagentlab/navarch-runtime 0.1.43 → 0.1.45

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
@@ -630,6 +630,20 @@ contract used by `api.cts`, including:
630
630
  `POST /api/machines/connect` is the project-scoped alternative: it redeems a
631
631
  single-use token minted by an owner through the onboarding or Fleet UI.
632
632
 
633
+ Machine-authenticated routes answer a rejected identity in three distinct
634
+ ways, and the runtime treats them differently:
635
+
636
+ - **503** — the control plane could not complete the token lookup. Transient;
637
+ retried like any other failure. (Before this split a failed lookup came back
638
+ as 401, so one database blip looked exactly like a revoked token.)
639
+ - **401** — the token is not recognized.
640
+ - **410** — this agent was removed from the fleet.
641
+
642
+ Three *consecutive* 401/410 heartbeats end the run: the runtime stops claiming,
643
+ lets active sessions finish, prints how to re-enroll, and exits 78. The
644
+ supervisor does not respawn on that code — a new process cannot fix a
645
+ credential the control plane no longer honors.
646
+
633
647
  ## What needs live verification
634
648
 
635
649
  Most runtime behavior is covered offline. The Codex host adapter was also
package/dist/cli.cjs CHANGED
@@ -20,6 +20,12 @@ const supervisor_cjs_1 = require("./supervisor.cjs");
20
20
  const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
21
21
  const log = (0, logger_cjs_1.createLogger)("cli");
22
22
  const PACKAGE_NAME = "@sagentlab/navarch-runtime";
23
+ /**
24
+ * Exit code for "this machine's identity is gone". Deliberately outside the
25
+ * supervisor's restart codes (75 update, 76 remote restart): respawning cannot
26
+ * fix a revoked or removed credential, so the supervisor must exit too.
27
+ */
28
+ const CREDENTIALS_REJECTED_EXIT_CODE = 78;
23
29
  /** Include a control-plane response's safe error detail in top-level CLI failures. */
24
30
  function describeCliError(err) {
25
31
  const message = err instanceof Error ? err.message : String(err);
@@ -217,6 +223,17 @@ async function startCommand(flags) {
217
223
  readySent = true;
218
224
  process.send({ type: "navarch-ready", boot_id: bootId });
219
225
  }
226
+ }, (status) => {
227
+ claimLoop.stop();
228
+ worktreeJanitor.stop();
229
+ log.error(status === 410
230
+ ? `agent ${identity.name} has been removed from the fleet; it can no longer claim tasks.`
231
+ : `machine credentials for ${identity.name} were rejected by ${identity.api_base}; it can no longer claim tasks.`);
232
+ log.error(`Re-enroll this machine with a fresh "Connect an agent" token, then run ` +
233
+ `\`${superviseInvocation(config.configDir)}\` again.`);
234
+ // Active sessions keep running to completion; exiting mid-task would
235
+ // strand a worktree the control plane can no longer hear about either.
236
+ void capacity.waitForIdle().then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
220
237
  });
221
238
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
222
239
  config,
@@ -56,6 +56,7 @@ class GitWorktree {
56
56
  await node_fs_1.promises.mkdir(this.sessionRoot, { recursive: true });
57
57
  await withRepositoryLock(this.repositoryPath, async () => {
58
58
  await this.ensureRepositoryCache();
59
+ await this.installDeliveryBranchGuard();
59
60
  try {
60
61
  await this.addSessionWorktree();
61
62
  }
@@ -94,6 +95,22 @@ class GitWorktree {
94
95
  await node_fs_1.promises.chmod(configPath, 0o600);
95
96
  await node_fs_1.promises.appendFile(configPath, block, { encoding: "utf8", mode: 0o600 });
96
97
  }
98
+ /**
99
+ * Installs the delivery-branch guard shared by every worktree of this cache.
100
+ * The hook is inert unless the session exports `NAVARCH_DELIVERY_BRANCH`, so
101
+ * PR-continuation sessions that must push a reviewed PR's head branch are
102
+ * unaffected. Root tasks export the allocated `navarch/...` branch, which
103
+ * stops an agent from renaming the branch (e.g. Codex's default `codex/...`
104
+ * prefix) and stranding the PR on a branch the task does not own.
105
+ */
106
+ async installDeliveryBranchGuard(repositoryPath = this.repositoryPath) {
107
+ const hooksDir = node_path_1.default.join(repositoryPath, "hooks");
108
+ await node_fs_1.promises.mkdir(hooksDir, { recursive: true });
109
+ await node_fs_1.promises.writeFile(node_path_1.default.join(hooksDir, "pre-push"), DELIVERY_BRANCH_GUARD_HOOK, {
110
+ encoding: "utf8",
111
+ mode: 0o755,
112
+ });
113
+ }
97
114
  /** Clones the bare cache if missing, repoints origin if needed, and fetches all branches. */
98
115
  async ensureRepositoryCache() {
99
116
  if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
@@ -312,6 +329,8 @@ class GitWorktree {
312
329
  if (!/unknown option|usage: git worktree add/i.test(errorMessage(error)))
313
330
  throw error;
314
331
  await this.runGit(["init", "--initial-branch", this.branch, this.worktreePath], false);
332
+ // This fallback is a standalone repository, so it does not share cache hooks.
333
+ await this.installDeliveryBranchGuard(node_path_1.default.join(this.worktreePath, ".git"));
315
334
  await this.runGit(["-C", this.worktreePath, "remote", "add", "origin", this.cloneUrl], false);
316
335
  }
317
336
  }
@@ -337,6 +356,30 @@ class GitWorktree {
337
356
  sha,
338
357
  };
339
358
  }
359
+ /**
360
+ * Resolves the head the session reports to the control plane after an agent
361
+ * turn.
362
+ *
363
+ * Root tasks own exactly one delivery branch. If an agent renamed or switched
364
+ * away from the allocated `navarch/...` branch, the worktree is moved back
365
+ * onto it so completion evidence and the PR land on the branch the task owns.
366
+ * PR-continuation tasks (review-fix, conflict resolution, stale-PR recovery)
367
+ * pass `allowRename` so they can check out and push the reviewed PR's head
368
+ * branch, which is the one case a different branch is expected.
369
+ */
370
+ async resolveDeliveryHead(allowRename) {
371
+ const head = await this.resolveCurrentHead();
372
+ if (allowRename || head.branch === this.branch)
373
+ return head;
374
+ try {
375
+ await this.runGit(["-C", this.worktreePath, "checkout", "-B", this.branch, "HEAD"], false);
376
+ }
377
+ catch (error) {
378
+ throw new Error(`Could not restore task delivery branch ${this.branch} after the agent left ` +
379
+ `${head.branch ?? "a detached HEAD"}: ${errorMessage(error)}`);
380
+ }
381
+ return { branch: this.branch, sha: head.sha };
382
+ }
340
383
  async cleanup() {
341
384
  await withRepositoryLock(this.repositoryPath, async () => {
342
385
  await this.removeRepoLocalGithubToken();
@@ -443,6 +486,32 @@ async function pathExists(value) {
443
486
  return false;
444
487
  }
445
488
  }
489
+ /**
490
+ * Pre-push guard installed in the repository cache's hooks directory. When
491
+ * `NAVARCH_DELIVERY_BRANCH` is present it refuses to create, update, or delete
492
+ * any other branch, so a coding agent cannot move a root task's delivery off
493
+ * the allocated `navarch/...` branch (for example onto Codex's default
494
+ * `codex/...` prefix). `--no-verify` bypasses any hook, so
495
+ * `resolveDeliveryHead` also repairs the worktree before completion.
496
+ */
497
+ const DELIVERY_BRANCH_GUARD_HOOK = `#!/bin/sh
498
+ # Managed by @sagentlab/navarch-runtime (git-worktree.cts).
499
+ allowed="\${NAVARCH_DELIVERY_BRANCH:-}"
500
+ [ -n "$allowed" ] || exit 0
501
+ status=0
502
+ while read -r local_ref local_sha remote_ref remote_sha; do
503
+ case "$remote_ref" in
504
+ refs/heads/*) ;;
505
+ *) continue ;;
506
+ esac
507
+ branch="\${remote_ref#refs/heads/}"
508
+ if [ "$branch" != "$allowed" ]; then
509
+ printf '%s\\n' "navarch: refusing to push '$branch'; this task's delivery branch is '$allowed'." >&2
510
+ status=1
511
+ fi
512
+ done
513
+ exit "$status"
514
+ `;
446
515
  /**
447
516
  * Kebab-case slug for the task branch, derived from the task summary so
448
517
  * branch names read like `navarch/fix-login-redirect-<task-id>` instead of a
@@ -85,7 +85,7 @@ async function findHeadBranchPullRequestUrl(options) {
85
85
  (!normalizedHeadSha ||
86
86
  (typeof candidate.head.sha === "string" &&
87
87
  candidate.head.sha.toLowerCase() === normalizedHeadSha)));
88
- if (branchMatch || !normalizedHeadSha)
88
+ if (branchMatch || !normalizedHeadSha || options.allowShaFallback === false)
89
89
  return branchMatch;
90
90
  const commitUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${normalizedHeadSha}/pulls`);
91
91
  commitUrl.searchParams.set("per_page", "10");
@@ -1,10 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MachineHeartbeatLoop = void 0;
4
+ const api_cjs_1 = require("./api.cjs");
4
5
  const version_cjs_1 = require("./version.cjs");
5
6
  const usage_limits_cjs_1 = require("./usage-limits.cjs");
6
7
  const logger_cjs_1 = require("./logger.cjs");
7
8
  const log = (0, logger_cjs_1.createLogger)("heartbeat");
9
+ /**
10
+ * How many consecutive credential rejections end the loop. A single 401 is not
11
+ * proof of anything — before the control plane learned to answer a failed
12
+ * token lookup with 503, a transient database blip surfaced here as one — so
13
+ * the identity is only declared dead once several beats in a row agree.
14
+ */
15
+ const CREDENTIAL_REJECTION_LIMIT = 3;
16
+ /** 401: the token is not recognized. 410: this agent was removed from the fleet. */
17
+ function credentialRejectionStatus(err) {
18
+ if (!(err instanceof api_cjs_1.NavarchApiError))
19
+ return null;
20
+ return err.status === 401 || err.status === 410 ? err.status : null;
21
+ }
8
22
  /**
9
23
  * Machine-level heartbeat loop (implementation-plan.md WP-07), separate from
10
24
  * the per-lease heartbeat in session.cts: reports this machine's online
@@ -19,13 +33,22 @@ class MachineHeartbeatLoop {
19
33
  bootId;
20
34
  onResult;
21
35
  onHealthy;
36
+ onCredentialsRejected;
22
37
  timer = null;
23
38
  heartbeatInFlight = false;
24
39
  heartbeatPending = false;
25
40
  updateState = "idle";
26
41
  lastUpdateError;
27
42
  draining = false;
28
- constructor(api, machineId, config, capacity, bootId, onResult, onHealthy) {
43
+ credentialRejections = 0;
44
+ constructor(api, machineId, config, capacity, bootId, onResult, onHealthy,
45
+ /**
46
+ * Called once the machine's credentials have been rejected
47
+ * CREDENTIAL_REJECTION_LIMIT times running. The loop has stopped itself by
48
+ * then: without this the runtime would warn every interval forever while
49
+ * claiming nothing, which is indistinguishable from an idle agent.
50
+ */
51
+ onCredentialsRejected) {
29
52
  this.api = api;
30
53
  this.machineId = machineId;
31
54
  this.config = config;
@@ -33,6 +56,7 @@ class MachineHeartbeatLoop {
33
56
  this.bootId = bootId;
34
57
  this.onResult = onResult;
35
58
  this.onHealthy = onHealthy;
59
+ this.onCredentialsRejected = onCredentialsRejected;
36
60
  }
37
61
  start() {
38
62
  if (this.timer)
@@ -88,11 +112,28 @@ class MachineHeartbeatLoop {
88
112
  ...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
89
113
  },
90
114
  });
115
+ this.credentialRejections = 0;
91
116
  this.onHealthy?.();
92
117
  this.onResult?.(result);
93
118
  }
94
119
  catch (err) {
95
- log.warn(`machine heartbeat failed: ${String(err)}`);
120
+ const rejection = credentialRejectionStatus(err);
121
+ if (rejection === null) {
122
+ this.credentialRejections = 0;
123
+ log.warn(`machine heartbeat failed: ${String(err)}`);
124
+ continue;
125
+ }
126
+ this.credentialRejections += 1;
127
+ const reason = rejection === 410
128
+ ? "this agent has been removed from the fleet"
129
+ : "the control plane does not recognize this machine token";
130
+ log.warn(`machine heartbeat rejected (${rejection}): ${reason} ` +
131
+ `(${this.credentialRejections}/${CREDENTIAL_REJECTION_LIMIT})`);
132
+ if (this.credentialRejections >= CREDENTIAL_REJECTION_LIMIT) {
133
+ this.stop();
134
+ this.onCredentialsRejected?.(rejection);
135
+ return;
136
+ }
96
137
  }
97
138
  } while (this.heartbeatPending);
98
139
  }
package/dist/session.cjs CHANGED
@@ -193,6 +193,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
193
193
  githubToken,
194
194
  repoLocalGithubToken: secrets["github-pat"],
195
195
  });
196
+ // A root task owns one delivery branch, so the pre-push guard and the
197
+ // completion-time reconciliation keep the agent on the allocated
198
+ // `navarch/...` branch. PR-continuation tasks are the exception: their agent
199
+ // must check out and push the reviewed PR's existing head branch.
200
+ const allowBranchRename = Boolean(task.review_of_task_id);
196
201
  const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
197
202
  const deliveredGuidance = [...(bundle.guidance ?? [])];
198
203
  let guidanceCursor = bundle.guidance_cursor ?? null;
@@ -344,6 +349,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
344
349
  lifecycle.enteredMainSessionTry = true;
345
350
  try {
346
351
  await gitWorktree.prepare();
352
+ // Preparation may resume an existing task branch with an older slug or ID format.
353
+ if (!allowBranchRename)
354
+ sessionEnv.NAVARCH_DELIVERY_BRANCH = gitWorktree.branch;
347
355
  if (sandbox) {
348
356
  await sandbox.create();
349
357
  // Security-policy refusals (a mount outside the allowlist, an egress
@@ -435,7 +443,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
435
443
  let headBranch = gitWorktree.branch;
436
444
  let headSha;
437
445
  try {
438
- const currentHead = await gitWorktree.resolveCurrentHead();
446
+ const currentHead = await gitWorktree.resolveDeliveryHead(allowBranchRename);
439
447
  headBranch = currentHead.branch ?? headBranch;
440
448
  headSha = currentHead.sha;
441
449
  }
@@ -448,6 +456,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
448
456
  repository: bundle.repository?.full_name ?? task.repo,
449
457
  headBranch,
450
458
  headSha,
459
+ // Keep SHA verification on the exact branch, but do not adopt a
460
+ // foreign-branch PR as delivery evidence for a root task.
461
+ allowShaFallback: allowBranchRename,
451
462
  githubToken,
452
463
  });
453
464
  if (prUrl)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",