@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.
@@ -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
+ }
@@ -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
- const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
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 (prUrl)
518
- mapping.evidenceUrls.push(prUrl);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
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",