@sagentlab/navarch-runtime 0.1.21 → 0.1.23
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 +74 -4
- package/dist/session.cjs +40 -10
- 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
|
@@ -11,9 +11,9 @@ const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
|
11
11
|
const repositoryLocks = new Map();
|
|
12
12
|
/**
|
|
13
13
|
* Maintains one bare repository cache per project and checks out each session
|
|
14
|
-
* into its own uniquely named worktree.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* into its own uniquely named worktree. Sessions that need a repo-local secret
|
|
15
|
+
* use an isolated bare repository under their session root because linked
|
|
16
|
+
* worktrees share their repository-local config.
|
|
17
17
|
*/
|
|
18
18
|
class GitWorktree {
|
|
19
19
|
sessionRoot;
|
|
@@ -23,16 +23,20 @@ class GitWorktree {
|
|
|
23
23
|
runner;
|
|
24
24
|
cloneUrl;
|
|
25
25
|
githubToken;
|
|
26
|
+
repoLocalGithubToken;
|
|
26
27
|
taskBranchSuffix;
|
|
27
28
|
legacyTaskBranchSuffix;
|
|
28
29
|
taskOwnershipTrailer;
|
|
30
|
+
sessionTokenMarker;
|
|
29
31
|
constructor(options) {
|
|
30
32
|
const projectKey = safePathSegment(options.projectId);
|
|
31
33
|
const sessionKey = safePathSegment(options.sessionId);
|
|
32
34
|
const taskKey = safePathSegment(options.taskId);
|
|
33
35
|
this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
|
|
34
36
|
this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
|
|
35
|
-
this.repositoryPath =
|
|
37
|
+
this.repositoryPath = options.repoLocalGithubToken
|
|
38
|
+
? node_path_1.default.join(this.sessionRoot, "repository.git")
|
|
39
|
+
: node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
|
|
36
40
|
// One delivery task owns one remote branch across every retry. A
|
|
37
41
|
// session-scoped branch lets a failed attempt push useful work, then makes
|
|
38
42
|
// the retry start from main and open a second PR for the same task.
|
|
@@ -40,10 +44,12 @@ class GitWorktree {
|
|
|
40
44
|
this.taskBranchSuffix = `-${normalizedTaskId.slice(0, 8)}`;
|
|
41
45
|
this.legacyTaskBranchSuffix = `-${normalizedTaskId}`;
|
|
42
46
|
this.taskOwnershipTrailer = `Navarch-Task-ID: ${normalizedTaskId}`;
|
|
47
|
+
this.sessionTokenMarker = `navarch-session-github-token:${sessionKey}`;
|
|
43
48
|
this.branch = `navarch/${branchSlug(options)}${this.taskBranchSuffix}`;
|
|
44
49
|
this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
45
50
|
this.cloneUrl = options.cloneUrl;
|
|
46
51
|
this.githubToken = options.githubToken;
|
|
52
|
+
this.repoLocalGithubToken = options.repoLocalGithubToken;
|
|
47
53
|
}
|
|
48
54
|
async prepare() {
|
|
49
55
|
await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.repositoryPath), { recursive: true });
|
|
@@ -63,8 +69,31 @@ class GitWorktree {
|
|
|
63
69
|
`could not resolve the fetched start ref. The shared cache was preserved to avoid invalidating ` +
|
|
64
70
|
`active worktrees; retry after active sessions finish or repair the cache in place: ${errorMessage(error)}`);
|
|
65
71
|
}
|
|
72
|
+
await this.installRepoLocalGithubToken();
|
|
66
73
|
});
|
|
67
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Makes a broker-issued GitHub credential visible to workflows that require
|
|
77
|
+
* `git config --local --get codex.githubToken`.
|
|
78
|
+
*
|
|
79
|
+
* Git ignores command-scope, worktree-scope, and included values when
|
|
80
|
+
* `--local` is requested, so the compatibility key has to live in the bare
|
|
81
|
+
* repository config. Token-bearing sessions use an isolated repository to
|
|
82
|
+
* keep concurrent worktrees from observing the value. The marked block lets
|
|
83
|
+
* cleanup remove exactly this session's value. The secret is written via fs
|
|
84
|
+
* and never passed in argv or emitted by the command runner.
|
|
85
|
+
*/
|
|
86
|
+
async installRepoLocalGithubToken() {
|
|
87
|
+
if (!this.repoLocalGithubToken)
|
|
88
|
+
return;
|
|
89
|
+
if (/[\0\r]/.test(this.repoLocalGithubToken)) {
|
|
90
|
+
throw new Error("Broker-issued GitHub credential contains unsupported control characters.");
|
|
91
|
+
}
|
|
92
|
+
const configPath = node_path_1.default.join(this.repositoryPath, "config");
|
|
93
|
+
const block = repoLocalGithubTokenBlock(this.sessionTokenMarker, this.repoLocalGithubToken);
|
|
94
|
+
await node_fs_1.promises.chmod(configPath, 0o600);
|
|
95
|
+
await node_fs_1.promises.appendFile(configPath, block, { encoding: "utf8", mode: 0o600 });
|
|
96
|
+
}
|
|
68
97
|
/** Clones the bare cache if missing, repoints origin if needed, and fetches all branches. */
|
|
69
98
|
async ensureRepositoryCache() {
|
|
70
99
|
if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
|
|
@@ -231,6 +260,7 @@ class GitWorktree {
|
|
|
231
260
|
}
|
|
232
261
|
async cleanup() {
|
|
233
262
|
await withRepositoryLock(this.repositoryPath, async () => {
|
|
263
|
+
await this.removeRepoLocalGithubToken();
|
|
234
264
|
await this.runner
|
|
235
265
|
.run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", this.worktreePath])
|
|
236
266
|
.catch(() => undefined);
|
|
@@ -242,6 +272,21 @@ class GitWorktree {
|
|
|
242
272
|
.catch(() => undefined);
|
|
243
273
|
});
|
|
244
274
|
}
|
|
275
|
+
async removeRepoLocalGithubToken() {
|
|
276
|
+
if (!this.repoLocalGithubToken)
|
|
277
|
+
return;
|
|
278
|
+
const configPath = node_path_1.default.join(this.repositoryPath, "config");
|
|
279
|
+
try {
|
|
280
|
+
const config = await node_fs_1.promises.readFile(configPath, "utf8");
|
|
281
|
+
const cleaned = removeMarkedConfigBlock(config, this.sessionTokenMarker);
|
|
282
|
+
if (cleaned !== config)
|
|
283
|
+
await node_fs_1.promises.writeFile(configPath, cleaned, { mode: 0o600 });
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (error.code !== "ENOENT")
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
245
290
|
async runGit(args, authenticated) {
|
|
246
291
|
const credentialArgs = authenticated && this.githubToken
|
|
247
292
|
? [
|
|
@@ -263,6 +308,31 @@ class GitWorktree {
|
|
|
263
308
|
}
|
|
264
309
|
}
|
|
265
310
|
exports.GitWorktree = GitWorktree;
|
|
311
|
+
function repoLocalGithubTokenBlock(marker, token) {
|
|
312
|
+
const escaped = token
|
|
313
|
+
.replace(/\\/g, "\\\\")
|
|
314
|
+
.replace(/"/g, '\\"')
|
|
315
|
+
.replace(/\n/g, "\\n")
|
|
316
|
+
.replace(/\t/g, "\\t")
|
|
317
|
+
.replace(/\u0008/g, "\\b");
|
|
318
|
+
return (`\n# ${marker}:begin\n` +
|
|
319
|
+
`[codex]\n` +
|
|
320
|
+
`\tgithubToken = "${escaped}"\n` +
|
|
321
|
+
`# ${marker}:end\n`);
|
|
322
|
+
}
|
|
323
|
+
function removeMarkedConfigBlock(config, marker) {
|
|
324
|
+
const begin = `# ${marker}:begin`;
|
|
325
|
+
const end = `# ${marker}:end`;
|
|
326
|
+
const start = config.indexOf(begin);
|
|
327
|
+
if (start < 0)
|
|
328
|
+
return config;
|
|
329
|
+
const blockStart = start > 0 && config[start - 1] === "\n" ? start - 1 : start;
|
|
330
|
+
const endStart = config.indexOf(end, start + begin.length);
|
|
331
|
+
if (endStart < 0)
|
|
332
|
+
return config;
|
|
333
|
+
const endNewline = config.indexOf("\n", endStart + end.length);
|
|
334
|
+
return config.slice(0, blockStart) + config.slice(endNewline < 0 ? config.length : endNewline + 1);
|
|
335
|
+
}
|
|
266
336
|
async function withRepositoryLock(key, work) {
|
|
267
337
|
const previous = repositoryLocks.get(key) ?? Promise.resolve();
|
|
268
338
|
let release;
|
package/dist/session.cjs
CHANGED
|
@@ -71,9 +71,11 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
71
71
|
await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
|
|
72
72
|
const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
|
|
73
73
|
await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
|
|
74
|
-
// Secrets: initially fetched once
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// Secrets: initially fetched once and held in the registry + child env map.
|
|
75
|
+
// The broker-issued github-pat is additionally installed in the project-local
|
|
76
|
+
// git config after worktree creation for the mandated Codex gh bootstrap;
|
|
77
|
+
// GitWorktree.cleanup removes that session-marked entry before teardown.
|
|
78
|
+
// Docker env injection remains tmpfs-backed (sandbox.cts injectEnv).
|
|
77
79
|
const registry = new redact_cjs_1.SecretRegistry();
|
|
78
80
|
let secrets = {};
|
|
79
81
|
let managedGithubCredential = false;
|
|
@@ -105,7 +107,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
105
107
|
const failureSummary = `Project ${task.project_id} has no GitHub repository URL. Set it in Project settings before dispatching work.`;
|
|
106
108
|
await api.completeLease(leaseId, {
|
|
107
109
|
status: "failed",
|
|
108
|
-
report: failureSummary,
|
|
110
|
+
report: verificationFailureReport(task.task_type, failureSummary),
|
|
109
111
|
failure_summary: failureSummary,
|
|
110
112
|
evidence_urls: [],
|
|
111
113
|
cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
|
|
@@ -127,6 +129,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
127
129
|
sessionId,
|
|
128
130
|
cloneUrl,
|
|
129
131
|
githubToken,
|
|
132
|
+
repoLocalGithubToken: secrets["github-pat"],
|
|
130
133
|
});
|
|
131
134
|
const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
|
|
132
135
|
const deliveredGuidance = [...(bundle.guidance ?? [])];
|
|
@@ -178,7 +181,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
178
181
|
await api
|
|
179
182
|
.completeLease(leaseId, {
|
|
180
183
|
status: "failed",
|
|
181
|
-
report: "Docker sandbox unavailable on this machine.",
|
|
184
|
+
report: verificationFailureReport(task.task_type, "Docker sandbox unavailable on this machine."),
|
|
182
185
|
failure_summary: "Docker sandbox unavailable on this machine.",
|
|
183
186
|
evidence_urls: [],
|
|
184
187
|
cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
|
|
@@ -392,9 +395,12 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
392
395
|
// redact it like every other warn in this block.
|
|
393
396
|
log.warn(`transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
|
|
394
397
|
}
|
|
398
|
+
const redactedReport = (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets);
|
|
395
399
|
const completion = {
|
|
396
400
|
status: mapping.leaseOutcome,
|
|
397
|
-
report:
|
|
401
|
+
report: mapping.leaseOutcome === "failed"
|
|
402
|
+
? verificationFailureReport(task.task_type, redactedReport)
|
|
403
|
+
: redactedReport,
|
|
398
404
|
evidence_urls: mapping.evidenceUrls,
|
|
399
405
|
cost: {
|
|
400
406
|
...(result.tokensIn !== undefined ? { tokens_in: result.tokensIn } : {}),
|
|
@@ -445,9 +451,9 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
445
451
|
await api.completeLease(leaseId, {
|
|
446
452
|
...completion,
|
|
447
453
|
status: "failed",
|
|
448
|
-
report: remediable
|
|
454
|
+
report: verificationFailureReport(task.task_type, remediable
|
|
449
455
|
? redactedRejection
|
|
450
|
-
: `${redactedRejection}\n\n---\n\n${completion.report}
|
|
456
|
+
: `${redactedRejection}\n\n---\n\n${completion.report}`),
|
|
451
457
|
failure_summary: redactedRejection,
|
|
452
458
|
exit_status: "failed",
|
|
453
459
|
});
|
|
@@ -479,9 +485,9 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
479
485
|
await api
|
|
480
486
|
.completeLease(leaseId, {
|
|
481
487
|
status: "failed",
|
|
482
|
-
report: lastCompletion
|
|
488
|
+
report: verificationFailureReport(task.task_type, lastCompletion
|
|
483
489
|
? `${lastCompletion.report}\n\n---\n\n${failureSummary}`
|
|
484
|
-
: failureSummary,
|
|
490
|
+
: failureSummary),
|
|
485
491
|
failure_summary: failureSummary,
|
|
486
492
|
evidence_urls: lastCompletion?.evidence_urls ?? [],
|
|
487
493
|
cost: lastCompletion?.cost ?? { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
|
|
@@ -522,6 +528,24 @@ function sumReportedUsage(attempts, key) {
|
|
|
522
528
|
});
|
|
523
529
|
return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
|
|
524
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* The control plane refuses to release a verify lease whose failed completion
|
|
533
|
+
* does not open with `Verification verdict: FAIL` or `BLOCKED`
|
|
534
|
+
* (verification_verdict_invalid), and that gate applies to runtime-authored
|
|
535
|
+
* failure reports too. When the report carries no usable verdict — the agent
|
|
536
|
+
* crashed, timed out, exited non-zero, or opened with PASS despite the failed
|
|
537
|
+
* outcome — label it BLOCKED: the failure prevented an acceptance decision.
|
|
538
|
+
* Without this, the failure completion is itself rejected, the session
|
|
539
|
+
* crashes, and the lease dangles until expiry.
|
|
540
|
+
*/
|
|
541
|
+
function verificationFailureReport(taskType, report) {
|
|
542
|
+
if (taskType !== "verify")
|
|
543
|
+
return report;
|
|
544
|
+
const verdict = (0, exit_conditions_cjs_1.leadingVerificationVerdict)(report);
|
|
545
|
+
if (verdict === "fail" || verdict === "blocked")
|
|
546
|
+
return report;
|
|
547
|
+
return `Verification verdict: BLOCKED — the session ended before verification reached an acceptance decision.\n\n${report}`;
|
|
548
|
+
}
|
|
525
549
|
/** Rejection codes another agent turn in the same worktree can plausibly fix. */
|
|
526
550
|
const REMEDIABLE_REJECTION_CODES = new Set([
|
|
527
551
|
"pr_required",
|
|
@@ -533,6 +557,12 @@ const REMEDIABLE_REJECTION_CODES = new Set([
|
|
|
533
557
|
// The marker is already on the right head; the reviewer only has to relabel
|
|
534
558
|
// the body, which is exactly what a remediation turn can do.
|
|
535
559
|
"review_evidence_mislabeled",
|
|
560
|
+
// A verify agent that exited zero without the mandated leading
|
|
561
|
+
// `Verification verdict:` line only has to restate its report; the
|
|
562
|
+
// rejection prose spells out the exact format. Failed outcomes never reach
|
|
563
|
+
// remediation (see the leaseOutcome === "completed" gate) — their reports
|
|
564
|
+
// are verdict-labelled by verificationFailureReport before posting.
|
|
565
|
+
"verification_verdict_invalid",
|
|
536
566
|
]);
|
|
537
567
|
/**
|
|
538
568
|
* A 409 the control plane raised to reject *this* completion's contents (as
|
package/package.json
CHANGED