@sagentlab/navarch-runtime 0.1.48 → 0.1.49
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/delivery.cjs +154 -0
- package/dist/git-worktree.cjs +14 -0
- package/dist/session.cjs +64 -3
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Delivery verification for BYO sessions.
|
|
3
|
+
//
|
|
4
|
+
// `mapExitCondition()` decides a lease outcome from how the agent process
|
|
5
|
+
// ended: a zero exit with no error events is reported `completed`. That is
|
|
6
|
+
// evidence the process finished cleanly, not evidence the task was delivered.
|
|
7
|
+
// session.cts already looks up the head-branch pull request, but only to push
|
|
8
|
+
// onto `evidenceUrls` — the outcome was settled before the lookup ran, so a
|
|
9
|
+
// session that committed work and never opened (or never pushed) a pull
|
|
10
|
+
// request still closed its lease as a success.
|
|
11
|
+
//
|
|
12
|
+
// This module reads what the worktree actually holds after a turn and turns it
|
|
13
|
+
// into a verdict. The hosted executor runs the same rules from inside its
|
|
14
|
+
// sandbox (worker/hosted-agent/delivery.ts); the two differ only in how they
|
|
15
|
+
// reach the checkout, which is why the rules and their justifications live in
|
|
16
|
+
// both places rather than in a shared package the Worker cannot import.
|
|
17
|
+
//
|
|
18
|
+
// Direction of failure: only positive evidence of stranded work gates. An
|
|
19
|
+
// unreadable checkout, a failed pull-request lookup, or an unresolvable base
|
|
20
|
+
// ref all produce `unknown`, which behaves exactly as this code did before the
|
|
21
|
+
// gate existed. A verification mechanism that fails good sessions when its own
|
|
22
|
+
// dependency is unavailable is worse than no mechanism.
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.probeWorktreeDelivery = probeWorktreeDelivery;
|
|
25
|
+
exports.evaluateDelivery = evaluateDelivery;
|
|
26
|
+
exports.renderDeliveryCorrectionPrompt = renderDeliveryCorrectionPrompt;
|
|
27
|
+
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
28
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
29
|
+
/**
|
|
30
|
+
* Reads the worktree's delivery state.
|
|
31
|
+
*
|
|
32
|
+
* Every field degrades independently: a git invocation that fails leaves its
|
|
33
|
+
* field "unknown" (-1, or `dirty: false`) rather than throwing, because a
|
|
34
|
+
* probe that cannot describe one property should still report the others.
|
|
35
|
+
* `dirty` is deliberately conservative — an unreadable `git status` reports a
|
|
36
|
+
* clean tree, so the gate under-fires rather than failing a good session.
|
|
37
|
+
*/
|
|
38
|
+
async function probeWorktreeDelivery(args) {
|
|
39
|
+
const runner = args.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
40
|
+
const git = async (gitArgs) => {
|
|
41
|
+
try {
|
|
42
|
+
const result = await runner.run("git", ["-C", args.worktreePath, ...gitArgs], {
|
|
43
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
return result.code === 0 ? result.stdout : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const count = (raw) => {
|
|
52
|
+
if (raw === null)
|
|
53
|
+
return -1;
|
|
54
|
+
const parsed = Number(raw.trim());
|
|
55
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : -1;
|
|
56
|
+
};
|
|
57
|
+
const [branchRaw, statusRaw, aheadRaw, unpushedRaw] = await Promise.all([
|
|
58
|
+
git(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
59
|
+
git(["status", "--porcelain"]),
|
|
60
|
+
args.baseRef ? git(["rev-list", "--count", `${args.baseRef}..HEAD`]) : Promise.resolve(null),
|
|
61
|
+
// Commits no origin ref carries. Independent of the base ref, so it still
|
|
62
|
+
// answers "was this pushed?" when the base cannot be resolved at all.
|
|
63
|
+
git(["rev-list", "--count", "HEAD", "--not", "--remotes=origin"]),
|
|
64
|
+
]);
|
|
65
|
+
const branch = branchRaw?.trim() ?? "";
|
|
66
|
+
return {
|
|
67
|
+
branch: branch && branch !== "HEAD" ? branch : null,
|
|
68
|
+
dirty: statusRaw !== null && statusRaw.trim().length > 0,
|
|
69
|
+
commitsAhead: count(aheadRaw),
|
|
70
|
+
unpushedCommits: count(unpushedRaw),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Maps a probe to a verdict.
|
|
75
|
+
*
|
|
76
|
+
* Dirty files are stranded even if a PR carries the committed HEAD. Once
|
|
77
|
+
* the tree is clean, the SHA-verified PR lookup is stronger evidence than
|
|
78
|
+
* potentially stale remote-tracking refs. Otherwise ordering runs from most to least
|
|
79
|
+
* certain — uncommitted edits, then commits that were never pushed, then
|
|
80
|
+
* pushed commits with no pull request — so the reason a session is failed
|
|
81
|
+
* names the earliest point the delivery chain broke.
|
|
82
|
+
*/
|
|
83
|
+
function evaluateDelivery(probe) {
|
|
84
|
+
if (probe.dirty) {
|
|
85
|
+
return {
|
|
86
|
+
status: "incomplete",
|
|
87
|
+
reason: "the worktree still has uncommitted changes, so the work was never committed or pushed",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (probe.pullRequestUrl)
|
|
91
|
+
return { status: "delivered", pullRequestUrl: probe.pullRequestUrl };
|
|
92
|
+
const hasWork = probe.commitsAhead > 0 || probe.unpushedCommits > 0;
|
|
93
|
+
if (!hasWork) {
|
|
94
|
+
// Both counts unknown: the checkout could not be read at all.
|
|
95
|
+
if (probe.commitsAhead < 0 && probe.unpushedCommits < 0) {
|
|
96
|
+
return { status: "unknown", reason: "the worktree's commit state could not be read" };
|
|
97
|
+
}
|
|
98
|
+
// A base that could not be resolved hides commits that a resumed task
|
|
99
|
+
// branch already carried, so only a confirmed zero means "no work".
|
|
100
|
+
if (probe.commitsAhead < 0 && probe.unpushedCommits === 0) {
|
|
101
|
+
return { status: "unknown", reason: "the branch's base ref could not be resolved" };
|
|
102
|
+
}
|
|
103
|
+
return { status: "no_changes" };
|
|
104
|
+
}
|
|
105
|
+
if (probe.unpushedCommits > 0) {
|
|
106
|
+
const plural = probe.unpushedCommits === 1 ? "commit" : "commits";
|
|
107
|
+
return {
|
|
108
|
+
status: "incomplete",
|
|
109
|
+
reason: `${probe.unpushedCommits} ${plural} on ${describeBranch(probe.branch)} ` +
|
|
110
|
+
"were never pushed, so the work exists only in this session's worktree",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Pushed, but nothing carries it. Only gate when GitHub actually answered.
|
|
114
|
+
if (probe.pullRequestLookupFailed) {
|
|
115
|
+
return { status: "unknown", reason: "the pull request lookup could not run, so delivery cannot be confirmed" };
|
|
116
|
+
}
|
|
117
|
+
const plural = probe.commitsAhead === 1 ? "commit" : "commits";
|
|
118
|
+
return {
|
|
119
|
+
status: "incomplete",
|
|
120
|
+
reason: `${probe.commitsAhead} ${plural} were pushed to ${describeBranch(probe.branch)} ` +
|
|
121
|
+
"but no pull request was opened for that branch",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function describeBranch(branch) {
|
|
125
|
+
return branch ? `\`${branch}\`` : "a detached HEAD";
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Prompt for the turn that follows an `incomplete` verdict.
|
|
129
|
+
*
|
|
130
|
+
* session.cts already re-runs the agent in the same worktree for mid-flight
|
|
131
|
+
* human guidance and for remediable control-plane rejections; this is the same
|
|
132
|
+
* shape of turn for the same reason — the partial work on disk is the state
|
|
133
|
+
* worth continuing from, so the prompt points at it rather than restating the
|
|
134
|
+
* task.
|
|
135
|
+
*/
|
|
136
|
+
function renderDeliveryCorrectionPrompt(args) {
|
|
137
|
+
return [
|
|
138
|
+
args.originalPrompt.trim(),
|
|
139
|
+
"",
|
|
140
|
+
"## Delivery is incomplete",
|
|
141
|
+
"",
|
|
142
|
+
`Your previous turn ended without delivering the task: ${args.reason}.`,
|
|
143
|
+
"",
|
|
144
|
+
`This is delivery attempt ${args.attempt} of ${args.maxAttempts}. You are in the same worktree as before,`,
|
|
145
|
+
"so your earlier edits, commits, and branch are all still here — start with `git status` and",
|
|
146
|
+
"`git log --oneline` and continue from there rather than redoing the work.",
|
|
147
|
+
"",
|
|
148
|
+
"Finish the delivery chain: commit the work, push the branch, and open the pull request.",
|
|
149
|
+
"If part of the task genuinely cannot be completed, still deliver what does work as a pull request",
|
|
150
|
+
"and state plainly in the description what is missing and why.",
|
|
151
|
+
"",
|
|
152
|
+
"This session's worktree is deleted when the session ends. Work that is not pushed is lost.",
|
|
153
|
+
].join("\n");
|
|
154
|
+
}
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -304,6 +304,20 @@ class GitWorktree {
|
|
|
304
304
|
this.taskOwnershipTrailer,
|
|
305
305
|
], false);
|
|
306
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Remote-tracking ref a delivery is measured against: the repository's
|
|
309
|
+
* default branch. Public so completion can count the task's commits without
|
|
310
|
+
* repeating default-branch discovery, and null-safe so an unreachable remote
|
|
311
|
+
* leaves the delivery gate "unknown" rather than failing the session.
|
|
312
|
+
*/
|
|
313
|
+
async resolveDeliveryBase() {
|
|
314
|
+
try {
|
|
315
|
+
return await this.resolveStartRef();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
307
321
|
/**
|
|
308
322
|
* Resolves the remote-tracking ref new session branches start from, or null
|
|
309
323
|
* when the remote has no branches at all (a freshly provisioned empty repo).
|
package/dist/session.cjs
CHANGED
|
@@ -21,6 +21,7 @@ const logger_cjs_1 = require("./logger.cjs");
|
|
|
21
21
|
const git_worktree_cjs_1 = require("./git-worktree.cjs");
|
|
22
22
|
const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
|
|
23
23
|
const github_pr_cjs_1 = require("./github-pr.cjs");
|
|
24
|
+
const delivery_cjs_1 = require("./delivery.cjs");
|
|
24
25
|
const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
|
|
25
26
|
const adapter_watchdog_cjs_1 = require("./adapter-watchdog.cjs");
|
|
26
27
|
const lease_heartbeat_cjs_1 = require("./lease-heartbeat.cjs");
|
|
@@ -29,6 +30,13 @@ const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
|
|
|
29
30
|
const MCP_CONFIG_FILENAME = "mcp-config.json";
|
|
30
31
|
const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
|
|
31
32
|
const COMPLETION_REMEDIATION_RETRIES = 2;
|
|
33
|
+
/**
|
|
34
|
+
* Turns the delivery gate (delivery.cjs) may spend on a session that finished
|
|
35
|
+
* cleanly but left work undelivered. Counted separately from the
|
|
36
|
+
* control-plane remediation retries above: the two failures are independent,
|
|
37
|
+
* and one must not consume the other's budget.
|
|
38
|
+
*/
|
|
39
|
+
const DELIVERY_CORRECTION_RETRIES = 1;
|
|
32
40
|
const log = (0, logger_cjs_1.createLogger)("session");
|
|
33
41
|
/**
|
|
34
42
|
* Runs one claimed task end to end (implementation-plan.md WP-07):
|
|
@@ -417,6 +425,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
417
425
|
const maxRuntimeMs = task.max_runtime_ms ?? config.sessionTimeoutMs;
|
|
418
426
|
let nextPrompt = null;
|
|
419
427
|
let completionRemediationRetries = 0;
|
|
428
|
+
let deliveryCorrectionRetries = 0;
|
|
420
429
|
while (true) {
|
|
421
430
|
// Guidance can arrive while the worktree/sandbox is being prepared.
|
|
422
431
|
// It is already included in deliveredGuidance, so clear the pending
|
|
@@ -504,8 +513,10 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
504
513
|
// Preserve the original exact-branch lookup if HEAD cannot be read.
|
|
505
514
|
log.warn(`worktree HEAD lookup failed for ${leaseId}: ${String(err)}`);
|
|
506
515
|
}
|
|
516
|
+
let pullRequestUrl = null;
|
|
517
|
+
let pullRequestLookupFailed = false;
|
|
507
518
|
try {
|
|
508
|
-
|
|
519
|
+
pullRequestUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
|
|
509
520
|
repository: bundle.repository?.full_name ?? task.repo,
|
|
510
521
|
headBranch,
|
|
511
522
|
headSha,
|
|
@@ -514,14 +525,61 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
514
525
|
allowShaFallback: allowBranchRename,
|
|
515
526
|
githubToken,
|
|
516
527
|
});
|
|
517
|
-
if (
|
|
518
|
-
mapping.evidenceUrls.push(
|
|
528
|
+
if (pullRequestUrl)
|
|
529
|
+
mapping.evidenceUrls.push(pullRequestUrl);
|
|
519
530
|
}
|
|
520
531
|
catch (err) {
|
|
521
532
|
// Evidence discovery is best-effort: a GitHub outage or token scope
|
|
522
533
|
// mismatch must not turn an otherwise valid completion into a crash.
|
|
534
|
+
// The delivery gate below reads this flag and declines to gate rather
|
|
535
|
+
// than failing a session whose pull request it simply could not see.
|
|
536
|
+
pullRequestLookupFailed = true;
|
|
523
537
|
log.warn(`head-branch PR lookup failed for ${leaseId}: ${String(err)}`);
|
|
524
538
|
}
|
|
539
|
+
// -- delivery gate -----------------------------------------------------
|
|
540
|
+
// A clean exit is not delivery. Read what the worktree actually holds
|
|
541
|
+
// and, when work was started but never shipped, spend a correction turn
|
|
542
|
+
// in the same worktree before failing the lease. See delivery.cjs for
|
|
543
|
+
// why only positive evidence gates.
|
|
544
|
+
let deliveryFailureSummary = null;
|
|
545
|
+
if (mapping.leaseOutcome === "completed" && !leaseLost) {
|
|
546
|
+
let delivery;
|
|
547
|
+
try {
|
|
548
|
+
const probe = await (0, delivery_cjs_1.probeWorktreeDelivery)({
|
|
549
|
+
worktreePath: gitWorktree.worktreePath,
|
|
550
|
+
baseRef: await gitWorktree.resolveDeliveryBase(),
|
|
551
|
+
});
|
|
552
|
+
delivery = (0, delivery_cjs_1.evaluateDelivery)({ ...probe, pullRequestUrl, pullRequestLookupFailed });
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
delivery = { status: "unknown", reason: `the delivery probe failed: ${String(err)}` };
|
|
556
|
+
}
|
|
557
|
+
if (delivery.status === "unknown") {
|
|
558
|
+
log.warn(`delivery for ${leaseId} could not be verified: ${delivery.reason}; completing anyway.`);
|
|
559
|
+
}
|
|
560
|
+
if (delivery.status === "incomplete") {
|
|
561
|
+
if (deliveryCorrectionRetries < DELIVERY_CORRECTION_RETRIES) {
|
|
562
|
+
deliveryCorrectionRetries += 1;
|
|
563
|
+
log.warn(`session ${leaseId} finished without delivering (${delivery.reason}); ` +
|
|
564
|
+
`restarting agent turn ${deliveryCorrectionRetries}/${DELIVERY_CORRECTION_RETRIES} in the same worktree.`);
|
|
565
|
+
nextPrompt = (0, delivery_cjs_1.renderDeliveryCorrectionPrompt)({
|
|
566
|
+
originalPrompt: runPrompt,
|
|
567
|
+
reason: delivery.reason,
|
|
568
|
+
attempt: deliveryCorrectionRetries,
|
|
569
|
+
maxAttempts: DELIVERY_CORRECTION_RETRIES,
|
|
570
|
+
});
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
deliveryFailureSummary =
|
|
574
|
+
`Session ended without delivering the task: ${delivery.reason}. ` +
|
|
575
|
+
`${deliveryCorrectionRetries} correction turn(s) did not recover it. ` +
|
|
576
|
+
"The session worktree is deleted at teardown, so the work is gone; the task needs another attempt.";
|
|
577
|
+
log.warn(`failing ${leaseId}: ${deliveryFailureSummary}`);
|
|
578
|
+
mapping.leaseOutcome = "failed";
|
|
579
|
+
mapping.exitStatus = "failed";
|
|
580
|
+
mapping.reportSummary = `${deliveryFailureSummary}\n\n---\n\n${mapping.reportSummary}`;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
525
583
|
const knownSecrets = registry.list();
|
|
526
584
|
if (mapping.leaseOutcome === "failed") {
|
|
527
585
|
log.warn(`adapter failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets)}`);
|
|
@@ -554,6 +612,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
554
612
|
? verificationFailureReport(task.task_type, redactedReport)
|
|
555
613
|
: redactedReport,
|
|
556
614
|
evidence_urls: mapping.evidenceUrls,
|
|
615
|
+
...(deliveryFailureSummary
|
|
616
|
+
? { failure_summary: (0, redact_cjs_1.redactText)(deliveryFailureSummary, knownSecrets) }
|
|
617
|
+
: {}),
|
|
557
618
|
cost: {
|
|
558
619
|
...(result.tokensIn !== undefined ? { tokens_in: result.tokensIn } : {}),
|
|
559
620
|
...(result.tokensOut !== undefined ? { tokens_out: result.tokensOut } : {}),
|
package/package.json
CHANGED