@sagentlab/navarch-runtime 0.1.58 → 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.
- package/dist/delivery.cjs +10 -2
- package/dist/git-worktree.cjs +111 -1
- package/dist/prompt.cjs +23 -0
- package/dist/session.cjs +28 -6
- package/package.json +1 -1
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
|
|
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 {
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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