@peakflo/agent-harness 0.2.17-rc.1 → 0.2.17-rc.2

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.
@@ -40,6 +40,7 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
40
40
  private sameRepository;
41
41
  /** Never fetch a different repository into a shared durable bare clone. */
42
42
  private bareRepositoryPath;
43
+ private isUnusableCheckout;
43
44
  /**
44
45
  * A session owns its checkout, including branch changes and detached HEAD.
45
46
  * Check its root and repository identity, never its branch or commit. Keep
@@ -25,8 +25,8 @@ function githubRepositoryIdentity(remote) {
25
25
  try {
26
26
  const url = new URL(ssh ? `https://github.com/${ssh[1]}` : remote);
27
27
  const host = url.hostname.toLowerCase();
28
- // The loopback proxy can also be used as an explicit Git remote. Its
29
- // credentials and port are transport details, not repository identity.
28
+ // Within a harness-owned checkout/cache, loopback owner/repo URLs denote
29
+ // the GitHub proxy. This is not proof of identity for arbitrary local URLs.
30
30
  if (!['github.com', '127.0.0.1', 'localhost', '[::1]'].includes(host)) {
31
31
  return null;
32
32
  }
@@ -114,36 +114,50 @@ class GitHubWorkspaceProvider {
114
114
  }
115
115
  /** Never fetch a different repository into a shared durable bare clone. */
116
116
  async bareRepositoryPath(path, cloneUrl) {
117
- try {
118
- await (0, promises_1.lstat)(path);
119
- }
120
- catch (error) {
121
- if (error.code === 'ENOENT')
122
- return path;
123
- throw error;
124
- }
125
- const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
126
- cwd: path
127
- }).catch(() => null);
128
- if (origin &&
129
- (await this.sameRepository(cloneUrl, origin.stdout.trim(), path))) {
130
- return path;
131
- }
132
- // Leave the old clone and all its linked worktrees intact. A separate
133
- // clone also prevents an unrelated agent/<session> ref from being reused.
134
117
  const identity = githubRepositoryIdentity(cloneUrl) ?? cloneUrl;
135
118
  const digest = (0, node_crypto_1.createHash)('sha256')
136
119
  .update(identity)
137
120
  .digest('hex')
138
121
  .slice(0, 16);
139
- return this.bareRepositoryPath(`${path}.recovered-${digest}`, cloneUrl);
122
+ // Leave old clones and linked worktrees intact. Bound collision handling
123
+ // without appending the same suffix recursively to a file name.
124
+ for (let attempt = 0; attempt < 16; attempt += 1) {
125
+ const candidate = attempt === 0 ? path : `${path}.recovered-${digest}-${attempt}`;
126
+ try {
127
+ const entry = await (0, promises_1.lstat)(candidate);
128
+ if (!entry.isDirectory() || entry.isSymbolicLink())
129
+ continue;
130
+ }
131
+ catch (error) {
132
+ if (error.code === 'ENOENT')
133
+ return candidate;
134
+ throw error;
135
+ }
136
+ const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
137
+ cwd: candidate
138
+ }).catch((error) => {
139
+ if (this.isUnusableCheckout(error) || error.code === 1)
140
+ return null;
141
+ throw new Error('Could not inspect repository cache. Existing files were kept.');
142
+ });
143
+ if (origin &&
144
+ (await this.sameRepository(cloneUrl, origin.stdout.trim(), candidate))) {
145
+ return candidate;
146
+ }
147
+ }
148
+ throw new Error('Could not prepare a repository cache. Existing files were kept.');
149
+ }
150
+ isUnusableCheckout(error) {
151
+ const gitError = error;
152
+ return (gitError.code === 128 &&
153
+ /not a git repository|invalid gitfile format|bad config line/i.test(gitError.stderr ?? ''));
140
154
  }
141
155
  /**
142
156
  * A session owns its checkout, including branch changes and detached HEAD.
143
157
  * Check its root and repository identity, never its branch or commit. Keep
144
158
  * an unrelated or unusable checkout aside before preparing a replacement.
145
159
  */
146
- async canReuseWorktree(worktree, cloneUrl, sessionId) {
160
+ async canReuseWorktree(bare, worktree, cloneUrl, sessionId) {
147
161
  let entry;
148
162
  try {
149
163
  entry = await (0, promises_1.lstat)(worktree);
@@ -155,23 +169,38 @@ class GitHubWorkspaceProvider {
155
169
  }
156
170
  if (entry.isDirectory() && !entry.isSymbolicLink()) {
157
171
  try {
158
- const [topLevel, origin] = await Promise.all([
172
+ const [topLevel, origin, commonDir] = await Promise.all([
159
173
  this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
160
174
  this.runGit(['config', '--get', 'remote.origin.url'], {
161
175
  cwd: worktree
162
- })
176
+ }).catch((error) => {
177
+ if (error.code === 1 && !error.stderr)
178
+ return { stdout: '' };
179
+ throw error;
180
+ }),
181
+ this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree })
163
182
  ]);
164
- const [expectedWorktree, actualWorktree] = await Promise.all([
183
+ const [expectedWorktree, actualWorktree, repositoriesRoot, actualCommon] = await Promise.all([
165
184
  (0, promises_1.realpath)(worktree),
166
- (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim()))
185
+ (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
186
+ (0, promises_1.realpath)((0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), '.repositories')),
187
+ (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
167
188
  ]);
168
189
  const sameRepository = await this.sameRepository(cloneUrl, origin.stdout.trim(), worktree);
169
- if (actualWorktree === expectedWorktree && sameRepository)
190
+ const managedCommon = actualCommon.startsWith(`${repositoriesRoot}/`) ||
191
+ actualCommon === (0, node_path_1.join)(expectedWorktree, '.git');
192
+ if (actualWorktree === expectedWorktree &&
193
+ sameRepository &&
194
+ managedCommon)
170
195
  return true;
171
196
  }
172
- catch {
173
- // A broken Git file is recoverable. Do not echo a remote URL or a Git
174
- // error here: it can contain credentials. Preserve every file below.
197
+ catch (error) {
198
+ // Only a proven broken Git checkout is moved. Resource exhaustion,
199
+ // permission errors and timeouts must leave a healthy checkout in place.
200
+ // Do not echo Git errors: they can contain remote credentials.
201
+ if (!this.isUnusableCheckout(error)) {
202
+ throw new Error('Could not inspect the existing repository workspace. Retry the message. Existing files were not changed.');
203
+ }
175
204
  }
176
205
  }
177
206
  const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
@@ -181,11 +210,17 @@ class GitHubWorkspaceProvider {
181
210
  if (entry.isDirectory() && !entry.isSymbolicLink()) {
182
211
  await this.runGit(['worktree', 'move', worktree, backup], {
183
212
  cwd: worktree
184
- }).catch(() => (0, promises_1.rename)(worktree, backup));
213
+ }).catch(async () => {
214
+ await (0, promises_1.rename)(worktree, backup);
215
+ await this.runGit(['worktree', 'repair', backup], { cwd: bare }).catch(() => undefined);
216
+ });
185
217
  }
186
218
  else {
187
219
  await (0, promises_1.rename)(worktree, backup);
188
220
  }
221
+ // A broken .git file cannot be repaired. Remove only obsolete Git
222
+ // registrations now, before reusing the vacated path; keep all saved files.
223
+ await this.runGit(['worktree', 'prune', '--expire=now'], { cwd: bare });
189
224
  const notes = this.preparationNotes.get(sessionId) ?? [];
190
225
  notes.push(`The previous repository folder could not be reused. All its files ` +
191
226
  `were kept at ${backup}. A fresh checkout was prepared. ` +
@@ -432,6 +467,11 @@ class GitHubWorkspaceProvider {
432
467
  '+refs/heads/*:refs/remotes/origin/*'
433
468
  ], { cwd: bare });
434
469
  }
470
+ // The identity check above permits old proxy/sentinel URL forms. Fetch
471
+ // through the current lease URL so a stale loopback port cannot fail first.
472
+ await this.runGit(['config', 'remote.origin.url', repository.cloneUrl], {
473
+ cwd: bare
474
+ });
435
475
  await this.runGit([...gitArgs, 'fetch', 'origin', '+refs/heads/*:refs/remotes/origin/*'], {
436
476
  cwd: bare,
437
477
  timeout: 10 * 60_000,
@@ -440,7 +480,7 @@ class GitHubWorkspaceProvider {
440
480
  });
441
481
  await this.runGit(['worktree', 'prune'], { cwd: bare });
442
482
  const branch = `agent/${request.sessionId}`;
443
- const reuse = await this.canReuseWorktree(worktree, repository.cloneUrl, request.sessionId);
483
+ const reuse = await this.canReuseWorktree(bare, worktree, repository.cloneUrl, request.sessionId);
444
484
  if (!reuse) {
445
485
  try {
446
486
  await this.runGit(['worktree', 'add', worktree, branch], {
@@ -476,12 +516,15 @@ class GitHubWorkspaceProvider {
476
516
  });
477
517
  // Hooks must be per worktree; a shared bare-repo hook would overwrite
478
518
  // another session's allowed destination when sessions overlap.
519
+ // A reused checkout may belong to a recovered cache or a standalone clone.
520
+ // Configure its actual common directory, rather than the default cache.
479
521
  await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
480
522
  cwd: worktree
481
523
  });
482
524
  await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
483
525
  cwd: worktree
484
526
  });
527
+ await this.runGit(['config', '--worktree', 'remote.origin.url', repository.cloneUrl], { cwd: worktree });
485
528
  const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
486
529
  await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, repository.cloneUrl);
487
530
  await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.17-rc.1",
3
+ "version": "0.2.17-rc.2",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",