@sagentlab/navarch-runtime 0.1.23 → 0.1.24
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/git-worktree.cjs +88 -5
- package/dist/session.cjs +9 -2
- package/package.json +1 -1
package/dist/git-worktree.cjs
CHANGED
|
@@ -119,7 +119,20 @@ class GitWorktree {
|
|
|
119
119
|
// next session must resume that task-owned branch, not fork again from the
|
|
120
120
|
// default branch and create a competing delivery PR.
|
|
121
121
|
const taskRef = await this.resolveTaskBranchRef();
|
|
122
|
-
|
|
122
|
+
// A killed session (timeout, SIGKILL, host crash) cannot guarantee its
|
|
123
|
+
// cleanup ran, and the janitor is age-gated, so a prompt retry on the same
|
|
124
|
+
// machine can meet the task branch still present in the shared cache.
|
|
125
|
+
// `worktree add -b` would fail on it, dooming every remaining attempt.
|
|
126
|
+
const staleTip = await this.reclaimStaleLocalBranch();
|
|
127
|
+
const baseRef = taskRef ?? (await this.resolveStartRef());
|
|
128
|
+
// The stale local tip may hold commits the killed session never pushed;
|
|
129
|
+
// resume it unless the pushed task branch has moved past it (then the
|
|
130
|
+
// remote is the durable record).
|
|
131
|
+
const startRef = staleTip
|
|
132
|
+
? taskRef && !(await this.isAncestor(taskRef, staleTip))
|
|
133
|
+
? taskRef
|
|
134
|
+
: staleTip
|
|
135
|
+
: baseRef;
|
|
123
136
|
if (!startRef) {
|
|
124
137
|
// Empty remote (no commits yet): there is nothing to base the session on,
|
|
125
138
|
// so bootstrap an orphan branch the session can push as the first commit.
|
|
@@ -137,9 +150,75 @@ class GitWorktree {
|
|
|
137
150
|
this.worktreePath,
|
|
138
151
|
startRef,
|
|
139
152
|
], false);
|
|
140
|
-
if (!taskRef)
|
|
153
|
+
if (!taskRef && !staleTip)
|
|
141
154
|
await this.createTaskOwnershipCommit();
|
|
142
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Releases a leftover local task branch (and any dead worktree holding it)
|
|
158
|
+
* so a retry can recreate the branch, and returns the leftover tip for
|
|
159
|
+
* resume. One lease per task means no live session can hold this branch, but
|
|
160
|
+
* a same-named branch without the ownership marker is not ours to delete.
|
|
161
|
+
*/
|
|
162
|
+
async reclaimStaleLocalBranch() {
|
|
163
|
+
const localRef = `refs/heads/${this.branch}`;
|
|
164
|
+
const tip = await this.runner.run("git", [
|
|
165
|
+
"--git-dir",
|
|
166
|
+
this.repositoryPath,
|
|
167
|
+
"rev-parse",
|
|
168
|
+
"--verify",
|
|
169
|
+
"--quiet",
|
|
170
|
+
`${localRef}^{commit}`,
|
|
171
|
+
]);
|
|
172
|
+
const staleSha = tip.stdout.trim();
|
|
173
|
+
if (tip.code !== 0 || !/^[0-9a-f]{40,64}$/i.test(staleSha))
|
|
174
|
+
return null;
|
|
175
|
+
const owned = this.branch.endsWith(this.legacyTaskBranchSuffix) ||
|
|
176
|
+
(await this.hasTaskOwnershipMarker(localRef));
|
|
177
|
+
if (!owned) {
|
|
178
|
+
throw new Error(`Local branch ${this.branch} already exists in the cached repository but does not ` +
|
|
179
|
+
`contain ownership marker ${this.taskOwnershipTrailer}; refusing to reuse or delete it.`);
|
|
180
|
+
}
|
|
181
|
+
await this.removeWorktreesCheckedOutOn(localRef);
|
|
182
|
+
try {
|
|
183
|
+
await this.runGit(["--git-dir", this.repositoryPath, "branch", "-D", this.branch], false);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
throw new Error(`Could not release stale task branch ${this.branch} left by an earlier session: ${errorMessage(error)}`);
|
|
187
|
+
}
|
|
188
|
+
return staleSha;
|
|
189
|
+
}
|
|
190
|
+
async removeWorktreesCheckedOutOn(localRef) {
|
|
191
|
+
const list = await this.runGit(["--git-dir", this.repositoryPath, "worktree", "list", "--porcelain"], false);
|
|
192
|
+
let currentPath = null;
|
|
193
|
+
const holders = [];
|
|
194
|
+
for (const line of list.stdout.split("\n")) {
|
|
195
|
+
if (line.startsWith("worktree "))
|
|
196
|
+
currentPath = line.slice("worktree ".length).trim();
|
|
197
|
+
else if (line.startsWith("branch ") && line.slice("branch ".length).trim() === localRef && currentPath)
|
|
198
|
+
holders.push(currentPath);
|
|
199
|
+
}
|
|
200
|
+
for (const holder of holders) {
|
|
201
|
+
// Double --force covers locked and submodule-bearing worktrees.
|
|
202
|
+
await this.runner
|
|
203
|
+
.run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", "--force", holder])
|
|
204
|
+
.catch(() => undefined);
|
|
205
|
+
}
|
|
206
|
+
await this.runner
|
|
207
|
+
.run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
|
|
208
|
+
.catch(() => undefined);
|
|
209
|
+
}
|
|
210
|
+
/** True when `descendant` contains `ancestorRef`; ref-resolution failures count as false. */
|
|
211
|
+
async isAncestor(ancestorRef, descendant) {
|
|
212
|
+
const result = await this.runner.run("git", [
|
|
213
|
+
"--git-dir",
|
|
214
|
+
this.repositoryPath,
|
|
215
|
+
"merge-base",
|
|
216
|
+
"--is-ancestor",
|
|
217
|
+
ancestorRef,
|
|
218
|
+
descendant,
|
|
219
|
+
]);
|
|
220
|
+
return result.code === 0;
|
|
221
|
+
}
|
|
143
222
|
/** Returns the fetched task-owned remote branch when an earlier attempt pushed it. */
|
|
144
223
|
async resolveTaskBranchRef() {
|
|
145
224
|
const refs = await this.runGit(["--git-dir", this.repositoryPath, "for-each-ref", "--format=%(refname)", "refs/remotes/origin/navarch"], false);
|
|
@@ -261,14 +340,18 @@ class GitWorktree {
|
|
|
261
340
|
async cleanup() {
|
|
262
341
|
await withRepositoryLock(this.repositoryPath, async () => {
|
|
263
342
|
await this.removeRepoLocalGithubToken();
|
|
343
|
+
// Double --force covers locked and submodule-bearing worktrees, and
|
|
344
|
+
// pruning before the branch delete clears the checkout registration when
|
|
345
|
+
// the worktree directory is already gone; otherwise git refuses the
|
|
346
|
+
// delete and the leftover branch blocks the task's next retry.
|
|
264
347
|
await this.runner
|
|
265
|
-
.run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", this.worktreePath])
|
|
348
|
+
.run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", "--force", this.worktreePath])
|
|
266
349
|
.catch(() => undefined);
|
|
267
350
|
await this.runner
|
|
268
|
-
.run("git", ["--git-dir", this.repositoryPath, "
|
|
351
|
+
.run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
|
|
269
352
|
.catch(() => undefined);
|
|
270
353
|
await this.runner
|
|
271
|
-
.run("git", ["--git-dir", this.repositoryPath, "
|
|
354
|
+
.run("git", ["--git-dir", this.repositoryPath, "branch", "-D", this.branch])
|
|
272
355
|
.catch(() => undefined);
|
|
273
356
|
});
|
|
274
357
|
}
|
package/dist/session.cjs
CHANGED
|
@@ -504,8 +504,15 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
504
504
|
clearInterval(heartbeatTimer);
|
|
505
505
|
activeAbortController?.abort();
|
|
506
506
|
secrets = {};
|
|
507
|
-
|
|
508
|
-
|
|
507
|
+
// A sandbox teardown failure must not skip worktree cleanup: a leftover
|
|
508
|
+
// task branch in the shared cache blocks the task's next retry.
|
|
509
|
+
try {
|
|
510
|
+
if (sandbox)
|
|
511
|
+
await sandbox.stop();
|
|
512
|
+
}
|
|
513
|
+
catch (stopErr) {
|
|
514
|
+
log.warn(`sandbox stop failed during teardown: ${String(stopErr)}`);
|
|
515
|
+
}
|
|
509
516
|
await gitWorktree.cleanup();
|
|
510
517
|
await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
511
518
|
}
|
package/package.json
CHANGED