@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.
- package/dist/exit-conditions.cjs +29 -0
- package/dist/git-worktree.cjs +88 -5
- package/dist/session.cjs +43 -9
- package/package.json +1 -1
package/dist/exit-conditions.cjs
CHANGED
|
@@ -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
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -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
|
-
|
|
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, "
|
|
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, "
|
|
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:
|
|
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
|
-
|
|
505
|
-
|
|
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