@gethmy/harness 1.1.1 → 1.2.1
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/cli.js +219 -51
- package/dist/index.js +393 -50
- package/package.json +2 -2
- package/src/cli.ts +171 -26
- package/src/confine-to-repo.test.ts +144 -0
- package/src/confine-to-repo.ts +113 -0
- package/src/gate-config-error.ts +19 -69
- package/src/harmony-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/model-tier.test.ts +88 -24
- package/src/model-tier.ts +45 -15
- package/src/motor-stream.ts +120 -0
- package/src/oracle-collector.ts +15 -1
- package/src/oracle.ts +7 -0
- package/src/run-sizing.test.ts +321 -0
- package/src/run-sizing.ts +393 -0
- package/src/runner.ts +16 -0
- package/src/sdk-agent-runner.ts +44 -3
- package/src/stage-cli.ts +94 -2
- package/src/stage-run.ts +32 -4
- package/src/worktree.ts +147 -21
package/src/worktree.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
-
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { log } from "./log.js";
|
|
5
5
|
import { installCommand } from "./pm.js";
|
|
@@ -65,15 +65,21 @@ export function fetchBaseBranch(
|
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
// Prefer git's stderr (captured by `stdio: "pipe"`) over the generic
|
|
69
|
-
// "Command failed: …" message so connectivity failures are legible (#444).
|
|
70
|
-
const e = lastErr as { stderr?: unknown } | null;
|
|
71
|
-
const detail =
|
|
72
|
-
e?.stderr?.toString?.().trim() ||
|
|
73
|
-
(lastErr instanceof Error ? lastErr.message : String(lastErr));
|
|
74
68
|
throw new WorktreeBaseError(
|
|
75
69
|
`Could not fetch origin/${baseBranch} after ${attempts} attempts — ` +
|
|
76
|
-
`refusing to build on a stale base. ${
|
|
70
|
+
`refusing to build on a stale base. ${gitErrorDetail(lastErr)}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Prefer git's stderr (captured by `stdio: "pipe"`) over the generic
|
|
76
|
+
* "Command failed: …" message so connectivity failures are legible (#444).
|
|
77
|
+
*/
|
|
78
|
+
function gitErrorDetail(err: unknown): string {
|
|
79
|
+
const e = err as { stderr?: unknown } | null;
|
|
80
|
+
return (
|
|
81
|
+
e?.stderr?.toString?.().trim() ||
|
|
82
|
+
(err instanceof Error ? err.message : String(err))
|
|
77
83
|
);
|
|
78
84
|
}
|
|
79
85
|
|
|
@@ -110,21 +116,89 @@ export function resolveWorktreeStartRef(
|
|
|
110
116
|
|
|
111
117
|
/**
|
|
112
118
|
* Fetch a branch from origin so `origin/<branchName>` resolves locally, and
|
|
113
|
-
* report whether the branch exists on the remote.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
119
|
+
* report whether the branch exists on the remote.
|
|
120
|
+
*
|
|
121
|
+
* This decides whether a continuation run keeps its branch or rebuilds from
|
|
122
|
+
* `origin/<baseBranch>`, and a rebuild ends in a force-push over the branch —
|
|
123
|
+
* so a wrong "false" here costs the user's pushed work. It therefore fails
|
|
124
|
+
* CLOSED. It used to swallow every error and return false on the reasoning
|
|
125
|
+
* that `fetchBaseBranch()` had just proven connectivity; that holds for the
|
|
126
|
+
* base ref a moment earlier, not for this ref now, so a transient per-ref
|
|
127
|
+
* failure (rate limit, packfile error, auth blip) read as "branch absent" and
|
|
128
|
+
* destroyed the branch.
|
|
129
|
+
*
|
|
130
|
+
* `ls-remote --exit-code` separates the two cases that must not be conflated:
|
|
131
|
+
* exit 2 means the ref genuinely is not on origin (a fresh first run — return
|
|
132
|
+
* false and start a fresh branch), any other failure means the probe itself
|
|
133
|
+
* failed and we must NOT conclude the branch is absent — throw
|
|
134
|
+
* `WorktreeBaseError`, which the worker already treats as an infrastructure
|
|
135
|
+
* failure and requeues without burning the give-up budget.
|
|
136
|
+
*
|
|
137
|
+
* The fetch then uses an explicit refspec: on a single-branch clone a bare
|
|
138
|
+
* `git fetch origin <branch>` succeeds without creating
|
|
139
|
+
* `refs/remotes/origin/<branch>`, so the start ref would not resolve.
|
|
117
140
|
*/
|
|
118
|
-
function fetchExistingBranch(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
export function fetchExistingBranch(
|
|
142
|
+
repoRoot: string,
|
|
143
|
+
branchName: string,
|
|
144
|
+
attempts = 3,
|
|
145
|
+
lsRemoteImpl: (root: string, branch: string) => void = (root, branch) =>
|
|
146
|
+
execFileSync(
|
|
147
|
+
"git",
|
|
148
|
+
["ls-remote", "--exit-code", "origin", `refs/heads/${branch}`],
|
|
149
|
+
{ cwd: root, stdio: "pipe" },
|
|
150
|
+
),
|
|
151
|
+
fetchImpl: (root: string, branch: string) => void = (root, branch) =>
|
|
152
|
+
execFileSync(
|
|
153
|
+
"git",
|
|
154
|
+
[
|
|
155
|
+
"fetch",
|
|
156
|
+
"origin",
|
|
157
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`,
|
|
158
|
+
],
|
|
159
|
+
{ cwd: root, stdio: "pipe" },
|
|
160
|
+
),
|
|
161
|
+
): boolean {
|
|
162
|
+
// Probe with the same retry cushion as fetchBaseBranch — a single-shot
|
|
163
|
+
// probe turned every transient per-ref failure into a WorktreeBaseError,
|
|
164
|
+
// and because that error burns no budget and arms no cooldown, a
|
|
165
|
+
// persistent remote fault hot-looped the requeue (#637 follow-up). Exit
|
|
166
|
+
// code 2 ("no matching refs") is a definitive answer, never retried.
|
|
167
|
+
let probed = false;
|
|
168
|
+
let lastErr: unknown;
|
|
169
|
+
for (let attempt = 1; attempt <= attempts && !probed; attempt++) {
|
|
170
|
+
try {
|
|
171
|
+
lsRemoteImpl(repoRoot, branchName);
|
|
172
|
+
probed = true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if ((err as { status?: number }).status === 2) return false;
|
|
175
|
+
lastErr = err;
|
|
176
|
+
log.warn(
|
|
177
|
+
TAG,
|
|
178
|
+
`ls-remote ${branchName} failed (attempt ${attempt}/${attempts})`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
127
181
|
}
|
|
182
|
+
if (!probed) {
|
|
183
|
+
throw new WorktreeBaseError(
|
|
184
|
+
`Could not determine whether ${branchName} exists on origin after ${attempts} attempts: ${gitErrorDetail(lastErr)}. Refusing to rebuild the branch — that would force-push over any pushed work.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
fetchImpl(repoRoot, branchName);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
lastErr = err;
|
|
193
|
+
log.warn(
|
|
194
|
+
TAG,
|
|
195
|
+
`fetch ${branchName} failed (attempt ${attempt}/${attempts})`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw new WorktreeBaseError(
|
|
200
|
+
`${branchName} exists on origin but could not be fetched after ${attempts} attempts: ${gitErrorDetail(lastErr)}. Refusing to rebuild the branch.`,
|
|
201
|
+
);
|
|
128
202
|
}
|
|
129
203
|
|
|
130
204
|
export interface CreateWorktreeOptions {
|
|
@@ -141,6 +215,29 @@ export interface CreateWorktreeOptions {
|
|
|
141
215
|
* Create a git worktree for the agent to work in.
|
|
142
216
|
* Returns the absolute path to the new worktree.
|
|
143
217
|
*/
|
|
218
|
+
/**
|
|
219
|
+
* The worktree's current HEAD sha, or null if it cannot be read.
|
|
220
|
+
*
|
|
221
|
+
* Recorded right after `createWorktree` so a run can later count only the
|
|
222
|
+
* commits IT made. On a continued branch (`continueExisting`) the checkout
|
|
223
|
+
* starts on the branch's own tip, so a range measured from origin/<base>
|
|
224
|
+
* would be satisfied by the previous attempt's commits and a run that
|
|
225
|
+
* committed nothing would be scored a success (#915).
|
|
226
|
+
*
|
|
227
|
+
* Best-effort by design: a null baseline makes the caller fall back to the
|
|
228
|
+
* origin/<base> range, which is the behaviour that predates this helper.
|
|
229
|
+
*/
|
|
230
|
+
export function readWorktreeHead(worktreePath: string): string | null {
|
|
231
|
+
try {
|
|
232
|
+
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
233
|
+
cwd: worktreePath,
|
|
234
|
+
encoding: "utf-8",
|
|
235
|
+
}).trim();
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
144
241
|
export function createWorktree(
|
|
145
242
|
basePath: string,
|
|
146
243
|
baseBranch: string,
|
|
@@ -259,6 +356,25 @@ export function createWorktree(
|
|
|
259
356
|
return worktreeDir;
|
|
260
357
|
}
|
|
261
358
|
|
|
359
|
+
/**
|
|
360
|
+
* True when `dir` is not itself a git worktree or repository (no `.git`
|
|
361
|
+
* entry) while a direct child directory carries one — i.e. `dir` is a
|
|
362
|
+
* CONTAINER of worktrees, like `.harmony-worktrees/agent-attempts/`.
|
|
363
|
+
* Force-removing such a directory destroys every worktree beneath it, live
|
|
364
|
+
* runs included (#928), so `cleanupWorktree` refuses it outright instead of
|
|
365
|
+
* escalating to `rmSync`.
|
|
366
|
+
*/
|
|
367
|
+
function containsForeignWorktrees(dir: string): boolean {
|
|
368
|
+
if (existsSync(resolve(dir, ".git"))) return false;
|
|
369
|
+
let children: string[];
|
|
370
|
+
try {
|
|
371
|
+
children = readdirSync(dir);
|
|
372
|
+
} catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
return children.some((child) => existsSync(resolve(dir, child, ".git")));
|
|
376
|
+
}
|
|
377
|
+
|
|
262
378
|
/**
|
|
263
379
|
* Remove a git worktree and its branch.
|
|
264
380
|
*/
|
|
@@ -271,6 +387,16 @@ export function cleanupWorktree(
|
|
|
271
387
|
}).trim();
|
|
272
388
|
|
|
273
389
|
if (existsSync(worktreePath)) {
|
|
390
|
+
// Never escalate on a container: `git worktree remove` would fail on it
|
|
391
|
+
// (it is not a worktree), and the old rmSync fallback then deleted every
|
|
392
|
+
// worktree underneath — including live ones with uncommitted work (#928).
|
|
393
|
+
if (containsForeignWorktrees(worktreePath)) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`Refusing to remove ${worktreePath}: it is not a git worktree itself, ` +
|
|
396
|
+
`but git worktrees live directly beneath it. Removing it would ` +
|
|
397
|
+
`destroy them — pass the individual worktree paths instead (#928).`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
274
400
|
try {
|
|
275
401
|
execFileSync("git", ["worktree", "remove", worktreePath, "--force"], {
|
|
276
402
|
cwd: repoRoot,
|