@sagentlab/navarch-runtime 0.1.57 → 0.1.59

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 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.openCodeAdapter = void 0;
7
7
  exports.runOpenCodeAdapter = runOpenCodeAdapter;
8
+ exports.resetOpenCodeLogLevelCache = resetOpenCodeLogLevelCache;
8
9
  const node_child_process_1 = require("node:child_process");
9
10
  const node_fs_1 = require("node:fs");
10
11
  const node_os_1 = __importDefault(require("node:os"));
@@ -36,12 +37,15 @@ async function runOpenCodeAdapter(options) {
36
37
  }
37
38
  const env = { ...options.env };
38
39
  await prepareOpenCodeConfig(options.mcpConfigPath, env, Boolean(options.dockerExec));
40
+ const binaryKey = `${options.dockerExec ? "docker:" : ""}${options.bin ?? "opencode"}`;
41
+ const defaultLogLevel = !hasArg(options.extraArgs, "--log-level");
42
+ const logLevel = defaultLogLevel ? (openCodeLogLevels.get(binaryKey) ?? "ERROR") : null;
39
43
  const args = ["run", options.prompt];
40
44
  // Provider failures may only appear in logs, absent from assistant events.
41
45
  if (!hasArg(options.extraArgs, "--print-logs"))
42
46
  args.push("--print-logs");
43
- if (!hasArg(options.extraArgs, "--log-level"))
44
- args.push("--log-level", "ERROR");
47
+ if (logLevel)
48
+ args.push("--log-level", logLevel);
45
49
  if (!hasArg(options.extraArgs, "--format"))
46
50
  args.push("--format", "json");
47
51
  args.push(...options.extraArgs);
@@ -54,11 +58,33 @@ async function runOpenCodeAdapter(options) {
54
58
  args.push("--model", options.reasoningEffort ? `${options.model}#${options.reasoningEffort}` : options.model);
55
59
  }
56
60
  const runOptions = { ...options, env };
57
- const raw = runOptions.dockerExec
58
- ? await runViaDocker(runOptions, args)
59
- : await runOnHost(runOptions, args);
61
+ const run = (runArgs) => runOptions.dockerExec ? runViaDocker(runOptions, runArgs) : runOnHost(runOptions, runArgs);
62
+ let raw = await run(args);
63
+ if (logLevel && raw.exitCode !== 0 && isLogLevelRejection(raw.stderr)) {
64
+ // The CLI rejected our default before starting any work, so one retry with
65
+ // the other spelling is side-effect free. Remember what this binary takes.
66
+ const alternate = logLevel === "ERROR" ? "error" : "ERROR";
67
+ args[args.indexOf("--log-level") + 1] = alternate;
68
+ raw = await run(args);
69
+ if (!(raw.exitCode !== 0 && isLogLevelRejection(raw.stderr))) {
70
+ openCodeLogLevels.set(binaryKey, alternate);
71
+ }
72
+ }
60
73
  return attachOpenCodeOutput(raw);
61
74
  }
75
+ /**
76
+ * OpenCode V1 (yargs) and 2.0.x up to at least 2.0.6 accept only `ERROR`;
77
+ * later V2 releases (Effect CLI, e.g. 2.0.17) accept only `error`. Neither
78
+ * accepts both, so the adapter learns the spelling per binary.
79
+ */
80
+ const openCodeLogLevels = new Map();
81
+ function isLogLevelRejection(stderr) {
82
+ return /invalid value/i.test(stderr) && /--log-level|argument: log-level/i.test(stderr);
83
+ }
84
+ /** Test hook: forget learned log-level spellings. */
85
+ function resetOpenCodeLogLevelCache() {
86
+ openCodeLogLevels.clear();
87
+ }
62
88
  function hasArg(args, ...flags) {
63
89
  return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
64
90
  }
package/dist/delivery.cjs CHANGED
@@ -54,13 +54,21 @@ async function probeWorktreeDelivery(args) {
54
54
  const parsed = Number(raw.trim());
55
55
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : -1;
56
56
  };
57
+ // Both counts skip empty commits: `--full-history -- .` keeps every commit
58
+ // that changes a file (merges included) and drops the ones that change
59
+ // nothing. The runtime opens every new task branch with an empty
60
+ // `chore(navarch): claim task branch` commit (git-worktree.cts); counting it
61
+ // failed sessions that correctly delivered nothing — a superseded task, say —
62
+ // as "1 commit never pushed" and pushed their agent to open an empty PR.
57
63
  const [branchRaw, statusRaw, aheadRaw, unpushedRaw] = await Promise.all([
58
64
  git(["rev-parse", "--abbrev-ref", "HEAD"]),
59
65
  git(["status", "--porcelain"]),
60
- args.baseRef ? git(["rev-list", "--count", `${args.baseRef}..HEAD`]) : Promise.resolve(null),
66
+ args.baseRef
67
+ ? git(["rev-list", "--count", "--full-history", `${args.baseRef}..HEAD`, "--", "."])
68
+ : Promise.resolve(null),
61
69
  // Commits no origin ref carries. Independent of the base ref, so it still
62
70
  // answers "was this pushed?" when the base cannot be resolved at all.
63
- git(["rev-list", "--count", "HEAD", "--not", "--remotes=origin"]),
71
+ git(["rev-list", "--count", "--full-history", "HEAD", "--not", "--remotes=origin", "--", "."]),
64
72
  ]);
65
73
  const branch = branchRaw?.trim() ?? "";
66
74
  return {
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.GitWorktree = void 0;
7
7
  exports.branchSlug = branchSlug;
8
+ exports.hostedCheckpointRef = hostedCheckpointRef;
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
10
11
  const node_fs_1 = require("node:fs");
@@ -21,6 +22,10 @@ class GitWorktree {
21
22
  worktreePath;
22
23
  repositoryPath;
23
24
  branch;
25
+ /** Set by prepare() when the session resumed a hosted attempt's checkpoint. */
26
+ restoredHostedCheckpoint = null;
27
+ /** Why an existing hosted checkpoint could not be restored, for the session log. */
28
+ hostedCheckpointRestoreFailure = null;
24
29
  runner;
25
30
  checkpointRoot;
26
31
  cloneUrl;
@@ -30,6 +35,8 @@ class GitWorktree {
30
35
  legacyTaskBranchSuffix;
31
36
  taskOwnershipTrailer;
32
37
  sessionTokenMarker;
38
+ hostedCheckpointRef;
39
+ hostedCheckpointLocalRef;
33
40
  constructor(options) {
34
41
  const projectKey = safePathSegment(options.projectId);
35
42
  const sessionKey = safePathSegment(options.sessionId);
@@ -53,6 +60,8 @@ class GitWorktree {
53
60
  this.cloneUrl = options.cloneUrl;
54
61
  this.githubToken = options.githubToken;
55
62
  this.repoLocalGithubToken = options.repoLocalGithubToken;
63
+ this.hostedCheckpointRef = options.restoreHostedCheckpoint ? hostedCheckpointRef(options.taskId) : null;
64
+ this.hostedCheckpointLocalRef = `refs/navarch/restored-checkpoints/${taskKey}`;
56
65
  }
57
66
  async prepare() {
58
67
  await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.repositoryPath), { recursive: true });
@@ -145,6 +154,10 @@ class GitWorktree {
145
154
  // `worktree add -b` would fail on it, dooming every remaining attempt.
146
155
  const staleTip = await this.reclaimStaleLocalBranch();
147
156
  const baseRef = taskRef ?? (await this.resolveStartRef());
157
+ // A hosted attempt never pushes the allocated branch until it delivers; an
158
+ // interrupted one leaves its work only in a checkpoint ref. Without this a
159
+ // self-hosted retry starts from the default branch and silently discards it.
160
+ const checkpoint = !taskRef && !staleTip ? await this.fetchHostedCheckpoint() : null;
148
161
  // The stale local tip may hold commits the killed session never pushed;
149
162
  // resume it unless the pushed task branch has moved past it (then the
150
163
  // remote is the durable record).
@@ -152,7 +165,9 @@ class GitWorktree {
152
165
  ? taskRef && !(await this.isAncestor(taskRef, staleTip))
153
166
  ? taskRef
154
167
  : staleTip
155
- : baseRef;
168
+ : checkpoint
169
+ ? checkpoint.parent
170
+ : baseRef;
156
171
  if (!startRef) {
157
172
  // Empty remote (no commits yet): there is nothing to base the session on,
158
173
  // so bootstrap an orphan branch the session can push as the first commit.
@@ -172,6 +187,91 @@ class GitWorktree {
172
187
  ], false);
173
188
  if (!taskRef && !staleTip)
174
189
  await this.createTaskOwnershipCommit();
190
+ if (checkpoint)
191
+ await this.applyHostedCheckpoint(checkpoint);
192
+ }
193
+ /**
194
+ * Fetches the hosted checkpoint for this task into the cache, or returns
195
+ * null when there is none. Best-effort: a checkpoint that cannot be read
196
+ * costs only the work a fresh start would have lost anyway.
197
+ */
198
+ async fetchHostedCheckpoint() {
199
+ const ref = this.hostedCheckpointRef;
200
+ if (!ref)
201
+ return null;
202
+ try {
203
+ const listed = await this.runGit(["--git-dir", this.repositoryPath, "ls-remote", "origin", ref], true);
204
+ if (!listed.stdout.trim())
205
+ return null;
206
+ await this.runGit(["--git-dir", this.repositoryPath, "fetch", "--quiet", "--no-tags", "origin", `+${ref}:${this.hostedCheckpointLocalRef}`], true);
207
+ const [sha, parent] = await Promise.all([this.hostedCheckpointLocalRef, `${this.hostedCheckpointLocalRef}^`].map(async (rev) => (await this.runGit(["--git-dir", this.repositoryPath, "rev-parse", "--verify", `${rev}^{commit}`], false))
208
+ .stdout.trim()));
209
+ if (!/^[0-9a-f]{40,64}$/i.test(sha) || !/^[0-9a-f]{40,64}$/i.test(parent)) {
210
+ throw new Error("the checkpoint ref did not resolve to a snapshot commit with a parent");
211
+ }
212
+ return { ref, sha: sha, parent: parent };
213
+ }
214
+ catch (error) {
215
+ this.hostedCheckpointRestoreFailure = errorMessage(error);
216
+ await this.dropHostedCheckpointLocalRef();
217
+ return null;
218
+ }
219
+ }
220
+ /**
221
+ * Puts the snapshot's files in the working tree as uncommitted edits on top
222
+ * of the branch, which already sits on the hosted agent's HEAD: the state the
223
+ * interrupted agent was in. `read-tree -u --reset` rewrites the index and
224
+ * files (deletions included) without moving HEAD; the mixed reset then puts
225
+ * the index back on HEAD so the edits show as unstaged, as they were.
226
+ */
227
+ async applyHostedCheckpoint(checkpoint) {
228
+ try {
229
+ const meta = await this.runGit(["--git-dir", this.repositoryPath, "show", "-s", "--format=%cI%x00%B", checkpoint.sha], false);
230
+ const [savedAt = "", body = ""] = meta.stdout.split("\0");
231
+ const hostedBranch = body
232
+ .split("\n")
233
+ .map((line) => line.match(/^Navarch-Checkpoint-Branch:\s*(\S+)\s*$/)?.[1])
234
+ .find(Boolean) ?? "";
235
+ await this.runGit(["-C", this.worktreePath, "read-tree", "-u", "--reset", checkpoint.sha], false);
236
+ await this.runGit(["-C", this.worktreePath, "reset", "-q"], false);
237
+ const status = await this.runGit(["-C", this.worktreePath, "status", "--porcelain"], false);
238
+ this.restoredHostedCheckpoint = {
239
+ ref: checkpoint.ref,
240
+ sha: checkpoint.sha,
241
+ hostedBranch,
242
+ savedAt: savedAt.trim(),
243
+ changedFiles: status.stdout.split("\n").filter((line) => line.trim()).length,
244
+ };
245
+ }
246
+ catch (error) {
247
+ // The branch still carries the hosted agent's commits; only its
248
+ // uncommitted edits are lost. Leave a clean tree, not a half-applied one.
249
+ this.hostedCheckpointRestoreFailure = errorMessage(error);
250
+ await this.runner
251
+ .run("git", ["-C", this.worktreePath, "reset", "-q", "--hard", "HEAD"])
252
+ .catch(() => undefined);
253
+ await this.runner.run("git", ["-C", this.worktreePath, "clean", "-fdq"]).catch(() => undefined);
254
+ }
255
+ finally {
256
+ await this.dropHostedCheckpointLocalRef();
257
+ }
258
+ }
259
+ async dropHostedCheckpointLocalRef() {
260
+ await this.runner
261
+ .run("git", ["--git-dir", this.repositoryPath, "update-ref", "-d", this.hostedCheckpointLocalRef])
262
+ .catch(() => undefined);
263
+ }
264
+ /**
265
+ * Removes the restored hosted checkpoint from origin once a pull request
266
+ * carries its work, so a later hosted attempt does not resume a stale
267
+ * snapshot. Best-effort, like the hosted side's own delete.
268
+ */
269
+ async deleteRestoredHostedCheckpoint() {
270
+ const restored = this.restoredHostedCheckpoint;
271
+ if (!restored)
272
+ return;
273
+ await this.runGit(["--git-dir", this.repositoryPath, "push", "--quiet", "origin", `:${restored.ref}`], true);
274
+ this.restoredHostedCheckpoint = null;
175
275
  }
176
276
  /**
177
277
  * Releases a leftover local task branch (and any dead worktree holding it)
@@ -602,6 +702,16 @@ function branchSlug(options) {
602
702
  .replace(/^-+|-+$/g, "");
603
703
  return idSlug || "task";
604
704
  }
705
+ /**
706
+ * The hidden per-task ref hosted runs checkpoint to
707
+ * (worker/hosted-agent/checkpoint.ts `checkpointRef`), or null for an id that
708
+ * is unsafe in a refname.
709
+ */
710
+ function hostedCheckpointRef(taskId) {
711
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId) && !taskId.includes("..")
712
+ ? `refs/navarch/checkpoints/${taskId}`
713
+ : null;
714
+ }
605
715
  function safePathSegment(value) {
606
716
  const safe = value.replace(/[^A-Za-z0-9_.-]/g, "-").replace(/^-+|-+$/g, "");
607
717
  if (!safe)
package/dist/prompt.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderPrompt = renderPrompt;
4
4
  exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
5
+ exports.renderHostedCheckpointNote = renderHostedCheckpointNote;
5
6
  const CODE_GRAPH_GUIDANCE = "For JS/TS dependency inspection, the runtime provides `navarch-runtime code-graph --query <symbol-or-file> --mode search|callers|callees|impact --commit HEAD`. If the runtime is not on PATH, use its installed entry point; do not install another copy mid-task. Results describe committed source and include commit/freshness warnings; inspect current files for uncommitted changes and unresolved dependencies.";
6
7
  /**
7
8
  * Renders the four-layer context bundle (project-plan.md §3.8: steering docs,
@@ -63,3 +64,25 @@ function renderGuidanceCorrectionPrompt(originalPrompt, guidance) {
63
64
  "",
64
65
  ].join("\n");
65
66
  }
67
+ /**
68
+ * Tells the agent its worktree starts from a hosted attempt's checkpoint
69
+ * (GitWorktree.restoredHostedCheckpoint), mirroring the hosted side's own
70
+ * resume note (worker/hosted-agent/checkpoint.ts `buildCheckpointRestoreNote`).
71
+ */
72
+ function renderHostedCheckpointNote(restored, deliveryBranch) {
73
+ const changes = restored.changedFiles > 0
74
+ ? `${restored.changedFiles} file${restored.changedFiles === 1 ? "" : "s"} with uncommitted changes are in the working tree.`
75
+ : "There are no uncommitted changes.";
76
+ const hosted = restored.hostedBranch
77
+ ? ` The hosted agent was working on \`${restored.hostedBranch}\`; its commits are already on \`${deliveryBranch}\`, so deliver from \`${deliveryBranch}\` and do not push \`${restored.hostedBranch}\`.`
78
+ : "";
79
+ return [
80
+ "## Resuming a hosted attempt",
81
+ "",
82
+ "A hosted Navarch agent worked on this task before you and was cut off before it delivered.",
83
+ `Navarch checkpointed its checkout${restored.savedAt ? ` at ${restored.savedAt}` : ""}, and this worktree was restored from it. ${changes}${hosted}`,
84
+ "",
85
+ "Start by inspecting that work with `git status`, `git log --oneline`, and `git diff`, then continue from it",
86
+ "rather than starting over. Discard it only if it is clearly wrong for the task.",
87
+ ].join("\n");
88
+ }
package/dist/session.cjs CHANGED
@@ -136,7 +136,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
136
136
  const workDir = node_path_1.default.join(config.workspaceRoot, "sessions", sessionId);
137
137
  await node_fs_1.promises.mkdir(workDir, { recursive: true });
138
138
  await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
139
- const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
139
+ let promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
140
140
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
141
141
  // Secrets: initially fetched once and held in the registry + child env map.
142
142
  // The broker-issued github-pat is additionally installed in the project-local
@@ -188,6 +188,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
188
188
  return;
189
189
  }
190
190
  const githubToken = secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
191
+ // A root task owns one delivery branch, so the pre-push guard and the
192
+ // completion-time reconciliation keep the agent on the allocated
193
+ // `navarch/...` branch. PR-continuation tasks are the exception: their agent
194
+ // must check out and push the reviewed PR's existing head branch.
195
+ const allowBranchRename = Boolean(task.review_of_task_id);
191
196
  const gitWorktree = new git_worktree_cjs_1.GitWorktree({
192
197
  workspaceRoot: config.workspaceRoot,
193
198
  projectId: task.project_id,
@@ -198,12 +203,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
198
203
  cloneUrl,
199
204
  githubToken,
200
205
  repoLocalGithubToken: secrets["github-pat"],
206
+ // Hosted runs skip checkpoints for verify tasks, which deliver a verdict.
207
+ restoreHostedCheckpoint: !allowBranchRename && task.task_type !== "verify",
201
208
  });
202
- // A root task owns one delivery branch, so the pre-push guard and the
203
- // completion-time reconciliation keep the agent on the allocated
204
- // `navarch/...` branch. PR-continuation tasks are the exception: their agent
205
- // must check out and push the reviewed PR's existing head branch.
206
- const allowBranchRename = Boolean(task.review_of_task_id);
207
209
  const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
208
210
  const deliveredGuidance = [...(bundle.guidance ?? [])];
209
211
  let guidanceCursor = bundle.guidance_cursor ?? null;
@@ -406,6 +408,17 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
406
408
  lifecycle.enteredMainSessionTry = true;
407
409
  try {
408
410
  await gitWorktree.prepare();
411
+ if (gitWorktree.hostedCheckpointRestoreFailure) {
412
+ log.warn(`hosted checkpoint for ${leaseId} could not be restored: ` +
413
+ `${(0, redact_cjs_1.redactText)(gitWorktree.hostedCheckpointRestoreFailure, registry.list())}; starting without it.`);
414
+ }
415
+ const restoredCheckpoint = gitWorktree.restoredHostedCheckpoint;
416
+ if (restoredCheckpoint) {
417
+ log.info(`resumed hosted checkpoint ${restoredCheckpoint.sha.slice(0, 12)} for ${leaseId} ` +
418
+ `(${restoredCheckpoint.changedFiles} uncommitted file(s)).`);
419
+ promptText = `${promptText}\n\n${(0, prompt_cjs_1.renderHostedCheckpointNote)(restoredCheckpoint, gitWorktree.branch)}`;
420
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
421
+ }
409
422
  // Preparation may resume an existing task branch with an older slug or ID format.
410
423
  if (!allowBranchRename)
411
424
  sessionEnv.NAVARCH_DELIVERY_BRANCH = gitWorktree.branch;
@@ -432,6 +445,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
432
445
  let nextPrompt = null;
433
446
  let completionRemediationRetries = 0;
434
447
  let deliveryCorrectionRetries = 0;
448
+ let deliveredPullRequest = false;
435
449
  while (true) {
436
450
  // Guidance can arrive while the worktree/sandbox is being prepared.
437
451
  // It is already included in deliveredGuidance, so clear the pending
@@ -561,6 +575,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
561
575
  catch (err) {
562
576
  delivery = { status: "unknown", reason: `the delivery probe failed: ${String(err)}` };
563
577
  }
578
+ deliveredPullRequest = delivery.status === "delivered";
564
579
  if (delivery.status === "unknown") {
565
580
  log.warn(`delivery for ${leaseId} could not be verified: ${delivery.reason}; completing anyway.`);
566
581
  }
@@ -641,6 +656,13 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
641
656
  };
642
657
  try {
643
658
  await api.completeLease(leaseId, completion);
659
+ if (deliveredPullRequest && completion.status === "completed" && gitWorktree.restoredHostedCheckpoint) {
660
+ // The pull request carries the hosted attempt's work now; a later
661
+ // hosted attempt must not resume the stale snapshot.
662
+ await gitWorktree.deleteRestoredHostedCheckpoint().catch((err) => {
663
+ log.warn(`hosted checkpoint cleanup failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
664
+ });
665
+ }
644
666
  break;
645
667
  }
646
668
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
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",