@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.
@@ -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
- const startRef = taskRef ?? (await this.resolveStartRef());
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, "branch", "-D", this.branch])
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, "worktree", "prune"])
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
- if (sandbox)
508
- await sandbox.stop();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",