@sagentlab/navarch-runtime 0.1.22 → 0.1.24

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.
@@ -5,6 +5,7 @@ exports.extractUsageFromClaudeJson = extractUsageFromClaudeJson;
5
5
  exports.parseCodexJsonEvents = parseCodexJsonEvents;
6
6
  exports.extractUsageFromCodexEvents = extractUsageFromCodexEvents;
7
7
  exports.extractFinalMessageFromCodexEvents = extractFinalMessageFromCodexEvents;
8
+ exports.leadingVerificationVerdict = leadingVerificationVerdict;
8
9
  exports.mapExitCondition = mapExitCondition;
9
10
  /**
10
11
  * Best-effort parse of `claude -p --output-format json` stdout into the
@@ -158,6 +159,21 @@ function extractFinalMessageFromCodexEvents(events) {
158
159
  }
159
160
  return null;
160
161
  }
162
+ /**
163
+ * Parses the leading `Verification verdict:` line convention shared with the
164
+ * control plane (lib/navarch/verification-verdict.ts). Verify completions
165
+ * must open with `Verification verdict: PASS|FAIL|BLOCKED`, and the
166
+ * completion API rejects a completion whose verdict contradicts its lease
167
+ * outcome, keeping the lease active.
168
+ */
169
+ function leadingVerificationVerdict(report) {
170
+ const firstLine = report
171
+ .split(/\r?\n/)
172
+ .map((line) => line.trim())
173
+ .find(Boolean);
174
+ const match = /^Verification verdict:\s*(PASS|FAIL|BLOCKED)\b/i.exec(firstLine ?? "");
175
+ return match?.[1] ? match[1].toLowerCase() : null;
176
+ }
161
177
  function summarize(text, maxLen = 500) {
162
178
  const trimmed = text.trim();
163
179
  if (!trimmed)
@@ -234,6 +250,19 @@ function mapExitCondition(result) {
234
250
  // parseClaudeJsonResult's doc comment).
235
251
  const reportText = result.reportText ?? parsedJson?.result ?? result.stdout;
236
252
  const reportSummary = summarize(reportText) || "Adapter completed with no report text.";
253
+ // A verify agent's FAIL/BLOCKED verdict is a successful agent run whose
254
+ // conclusion is that the delivery did not pass. The completion API only
255
+ // accepts those verdicts with a failed outcome, so map them to a failed
256
+ // lease instead of posting a "completed" the control plane must reject.
257
+ const verdict = leadingVerificationVerdict(reportText);
258
+ if (verdict === "fail" || verdict === "blocked") {
259
+ return {
260
+ leaseOutcome: "failed",
261
+ exitStatus: "failed",
262
+ reportSummary,
263
+ evidenceUrls,
264
+ };
265
+ }
237
266
  // Headless agent CLIs normally exit 0 after producing a final response,
238
267
  // including when that response says the task could not start. Treat an
239
268
  // explicit leading blocked verdict as a failed lease so the dispatcher can
@@ -119,7 +119,20 @@ class GitWorktree {
119
119
  // next session must resume that task-owned branch, not fork again from the
120
120
  // default branch and create a competing delivery PR.
121
121
  const taskRef = await this.resolveTaskBranchRef();
122
- const startRef = taskRef ?? (await this.resolveStartRef());
122
+ // A killed session (timeout, SIGKILL, host crash) cannot guarantee its
123
+ // cleanup ran, and the janitor is age-gated, so a prompt retry on the same
124
+ // machine can meet the task branch still present in the shared cache.
125
+ // `worktree add -b` would fail on it, dooming every remaining attempt.
126
+ const staleTip = await this.reclaimStaleLocalBranch();
127
+ const baseRef = taskRef ?? (await this.resolveStartRef());
128
+ // The stale local tip may hold commits the killed session never pushed;
129
+ // resume it unless the pushed task branch has moved past it (then the
130
+ // remote is the durable record).
131
+ const startRef = staleTip
132
+ ? taskRef && !(await this.isAncestor(taskRef, staleTip))
133
+ ? taskRef
134
+ : staleTip
135
+ : baseRef;
123
136
  if (!startRef) {
124
137
  // Empty remote (no commits yet): there is nothing to base the session on,
125
138
  // so bootstrap an orphan branch the session can push as the first commit.
@@ -137,9 +150,75 @@ class GitWorktree {
137
150
  this.worktreePath,
138
151
  startRef,
139
152
  ], false);
140
- if (!taskRef)
153
+ if (!taskRef && !staleTip)
141
154
  await this.createTaskOwnershipCommit();
142
155
  }
156
+ /**
157
+ * Releases a leftover local task branch (and any dead worktree holding it)
158
+ * so a retry can recreate the branch, and returns the leftover tip for
159
+ * resume. One lease per task means no live session can hold this branch, but
160
+ * a same-named branch without the ownership marker is not ours to delete.
161
+ */
162
+ async reclaimStaleLocalBranch() {
163
+ const localRef = `refs/heads/${this.branch}`;
164
+ const tip = await this.runner.run("git", [
165
+ "--git-dir",
166
+ this.repositoryPath,
167
+ "rev-parse",
168
+ "--verify",
169
+ "--quiet",
170
+ `${localRef}^{commit}`,
171
+ ]);
172
+ const staleSha = tip.stdout.trim();
173
+ if (tip.code !== 0 || !/^[0-9a-f]{40,64}$/i.test(staleSha))
174
+ return null;
175
+ const owned = this.branch.endsWith(this.legacyTaskBranchSuffix) ||
176
+ (await this.hasTaskOwnershipMarker(localRef));
177
+ if (!owned) {
178
+ throw new Error(`Local branch ${this.branch} already exists in the cached repository but does not ` +
179
+ `contain ownership marker ${this.taskOwnershipTrailer}; refusing to reuse or delete it.`);
180
+ }
181
+ await this.removeWorktreesCheckedOutOn(localRef);
182
+ try {
183
+ await this.runGit(["--git-dir", this.repositoryPath, "branch", "-D", this.branch], false);
184
+ }
185
+ catch (error) {
186
+ throw new Error(`Could not release stale task branch ${this.branch} left by an earlier session: ${errorMessage(error)}`);
187
+ }
188
+ return staleSha;
189
+ }
190
+ async removeWorktreesCheckedOutOn(localRef) {
191
+ const list = await this.runGit(["--git-dir", this.repositoryPath, "worktree", "list", "--porcelain"], false);
192
+ let currentPath = null;
193
+ const holders = [];
194
+ for (const line of list.stdout.split("\n")) {
195
+ if (line.startsWith("worktree "))
196
+ currentPath = line.slice("worktree ".length).trim();
197
+ else if (line.startsWith("branch ") && line.slice("branch ".length).trim() === localRef && currentPath)
198
+ holders.push(currentPath);
199
+ }
200
+ for (const holder of holders) {
201
+ // Double --force covers locked and submodule-bearing worktrees.
202
+ await this.runner
203
+ .run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", "--force", holder])
204
+ .catch(() => undefined);
205
+ }
206
+ await this.runner
207
+ .run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
208
+ .catch(() => undefined);
209
+ }
210
+ /** True when `descendant` contains `ancestorRef`; ref-resolution failures count as false. */
211
+ async isAncestor(ancestorRef, descendant) {
212
+ const result = await this.runner.run("git", [
213
+ "--git-dir",
214
+ this.repositoryPath,
215
+ "merge-base",
216
+ "--is-ancestor",
217
+ ancestorRef,
218
+ descendant,
219
+ ]);
220
+ return result.code === 0;
221
+ }
143
222
  /** Returns the fetched task-owned remote branch when an earlier attempt pushed it. */
144
223
  async resolveTaskBranchRef() {
145
224
  const refs = await this.runGit(["--git-dir", this.repositoryPath, "for-each-ref", "--format=%(refname)", "refs/remotes/origin/navarch"], false);
@@ -261,14 +340,18 @@ class GitWorktree {
261
340
  async cleanup() {
262
341
  await withRepositoryLock(this.repositoryPath, async () => {
263
342
  await this.removeRepoLocalGithubToken();
343
+ // Double --force covers locked and submodule-bearing worktrees, and
344
+ // pruning before the branch delete clears the checkout registration when
345
+ // the worktree directory is already gone; otherwise git refuses the
346
+ // delete and the leftover branch blocks the task's next retry.
264
347
  await this.runner
265
- .run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", this.worktreePath])
348
+ .run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", "--force", this.worktreePath])
266
349
  .catch(() => undefined);
267
350
  await this.runner
268
- .run("git", ["--git-dir", this.repositoryPath, "branch", "-D", this.branch])
351
+ .run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
269
352
  .catch(() => undefined);
270
353
  await this.runner
271
- .run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
354
+ .run("git", ["--git-dir", this.repositoryPath, "branch", "-D", this.branch])
272
355
  .catch(() => undefined);
273
356
  });
274
357
  }
package/dist/session.cjs CHANGED
@@ -107,7 +107,7 @@ async function runSession(deps, claimed, sessionId) {
107
107
  const failureSummary = `Project ${task.project_id} has no GitHub repository URL. Set it in Project settings before dispatching work.`;
108
108
  await api.completeLease(leaseId, {
109
109
  status: "failed",
110
- report: failureSummary,
110
+ report: verificationFailureReport(task.task_type, failureSummary),
111
111
  failure_summary: failureSummary,
112
112
  evidence_urls: [],
113
113
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
@@ -181,7 +181,7 @@ async function runSession(deps, claimed, sessionId) {
181
181
  await api
182
182
  .completeLease(leaseId, {
183
183
  status: "failed",
184
- report: "Docker sandbox unavailable on this machine.",
184
+ report: verificationFailureReport(task.task_type, "Docker sandbox unavailable on this machine."),
185
185
  failure_summary: "Docker sandbox unavailable on this machine.",
186
186
  evidence_urls: [],
187
187
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
@@ -395,9 +395,12 @@ async function runSession(deps, claimed, sessionId) {
395
395
  // redact it like every other warn in this block.
396
396
  log.warn(`transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
397
397
  }
398
+ const redactedReport = (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets);
398
399
  const completion = {
399
400
  status: mapping.leaseOutcome,
400
- report: (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets),
401
+ report: mapping.leaseOutcome === "failed"
402
+ ? verificationFailureReport(task.task_type, redactedReport)
403
+ : redactedReport,
401
404
  evidence_urls: mapping.evidenceUrls,
402
405
  cost: {
403
406
  ...(result.tokensIn !== undefined ? { tokens_in: result.tokensIn } : {}),
@@ -448,9 +451,9 @@ async function runSession(deps, claimed, sessionId) {
448
451
  await api.completeLease(leaseId, {
449
452
  ...completion,
450
453
  status: "failed",
451
- report: remediable
454
+ report: verificationFailureReport(task.task_type, remediable
452
455
  ? redactedRejection
453
- : `${redactedRejection}\n\n---\n\n${completion.report}`,
456
+ : `${redactedRejection}\n\n---\n\n${completion.report}`),
454
457
  failure_summary: redactedRejection,
455
458
  exit_status: "failed",
456
459
  });
@@ -482,9 +485,9 @@ async function runSession(deps, claimed, sessionId) {
482
485
  await api
483
486
  .completeLease(leaseId, {
484
487
  status: "failed",
485
- report: lastCompletion
488
+ report: verificationFailureReport(task.task_type, lastCompletion
486
489
  ? `${lastCompletion.report}\n\n---\n\n${failureSummary}`
487
- : failureSummary,
490
+ : failureSummary),
488
491
  failure_summary: failureSummary,
489
492
  evidence_urls: lastCompletion?.evidence_urls ?? [],
490
493
  cost: lastCompletion?.cost ?? { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
@@ -501,8 +504,15 @@ async function runSession(deps, claimed, sessionId) {
501
504
  clearInterval(heartbeatTimer);
502
505
  activeAbortController?.abort();
503
506
  secrets = {};
504
- if (sandbox)
505
- await sandbox.stop();
507
+ // A sandbox teardown failure must not skip worktree cleanup: a leftover
508
+ // task branch in the shared cache blocks the task's next retry.
509
+ try {
510
+ if (sandbox)
511
+ await sandbox.stop();
512
+ }
513
+ catch (stopErr) {
514
+ log.warn(`sandbox stop failed during teardown: ${String(stopErr)}`);
515
+ }
506
516
  await gitWorktree.cleanup();
507
517
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
508
518
  }
@@ -525,6 +535,24 @@ function sumReportedUsage(attempts, key) {
525
535
  });
526
536
  return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
527
537
  }
538
+ /**
539
+ * The control plane refuses to release a verify lease whose failed completion
540
+ * does not open with `Verification verdict: FAIL` or `BLOCKED`
541
+ * (verification_verdict_invalid), and that gate applies to runtime-authored
542
+ * failure reports too. When the report carries no usable verdict — the agent
543
+ * crashed, timed out, exited non-zero, or opened with PASS despite the failed
544
+ * outcome — label it BLOCKED: the failure prevented an acceptance decision.
545
+ * Without this, the failure completion is itself rejected, the session
546
+ * crashes, and the lease dangles until expiry.
547
+ */
548
+ function verificationFailureReport(taskType, report) {
549
+ if (taskType !== "verify")
550
+ return report;
551
+ const verdict = (0, exit_conditions_cjs_1.leadingVerificationVerdict)(report);
552
+ if (verdict === "fail" || verdict === "blocked")
553
+ return report;
554
+ return `Verification verdict: BLOCKED — the session ended before verification reached an acceptance decision.\n\n${report}`;
555
+ }
528
556
  /** Rejection codes another agent turn in the same worktree can plausibly fix. */
529
557
  const REMEDIABLE_REJECTION_CODES = new Set([
530
558
  "pr_required",
@@ -536,6 +564,12 @@ const REMEDIABLE_REJECTION_CODES = new Set([
536
564
  // The marker is already on the right head; the reviewer only has to relabel
537
565
  // the body, which is exactly what a remediation turn can do.
538
566
  "review_evidence_mislabeled",
567
+ // A verify agent that exited zero without the mandated leading
568
+ // `Verification verdict:` line only has to restate its report; the
569
+ // rejection prose spells out the exact format. Failed outcomes never reach
570
+ // remediation (see the leaseOutcome === "completed" gate) — their reports
571
+ // are verdict-labelled by verificationFailureReport before posting.
572
+ "verification_verdict_invalid",
539
573
  ]);
540
574
  /**
541
575
  * A 409 the control plane raised to reject *this* completion's contents (as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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",