harnery 0.28.0 → 0.30.0

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.
Files changed (59) hide show
  1. package/dist/commands/work.d.ts +2 -1
  2. package/dist/commands/work.d.ts.map +1 -1
  3. package/dist/commands/work.js +44 -4
  4. package/dist/core/harnesses/types.d.ts +5 -0
  5. package/dist/core/harnesses/types.d.ts.map +1 -1
  6. package/dist/core/supervisor/plan-read.js +7 -1
  7. package/dist/core/supervisor/plan-types.d.ts +10 -0
  8. package/dist/core/supervisor/plan-types.d.ts.map +1 -1
  9. package/dist/core/supervisor/planning.d.ts.map +1 -1
  10. package/dist/core/supervisor/planning.js +18 -0
  11. package/dist/core/supervisor/state.d.ts.map +1 -1
  12. package/dist/core/supervisor/state.js +65 -11
  13. package/dist/core/work/runner.d.ts.map +1 -1
  14. package/dist/core/work/runner.js +11 -2
  15. package/dist/core/work/state.d.ts +17 -0
  16. package/dist/core/work/state.d.ts.map +1 -1
  17. package/dist/core/work/state.js +102 -3
  18. package/dist/core/workflow/engine.js +13 -0
  19. package/dist/core/workflow/index.d.ts +3 -2
  20. package/dist/core/workflow/index.d.ts.map +1 -1
  21. package/dist/core/workflow/index.js +2 -1
  22. package/dist/core/workflow/proof.d.ts +18 -1
  23. package/dist/core/workflow/proof.d.ts.map +1 -1
  24. package/dist/core/workflow/proof.js +34 -0
  25. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  26. package/dist/core/workflow/spawn-claude.js +19 -2
  27. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  28. package/dist/core/workflow/spawn-codex.js +17 -2
  29. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  30. package/dist/core/workflow/spawn-cursor.js +18 -2
  31. package/dist/core/workflow/spawn-failure.d.ts +14 -0
  32. package/dist/core/workflow/spawn-failure.d.ts.map +1 -1
  33. package/dist/core/workflow/spawn-failure.js +34 -0
  34. package/dist/core/workflow/types.d.ts +26 -0
  35. package/dist/core/workflow/types.d.ts.map +1 -1
  36. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  37. package/dist/core/workflow/workspaces/local-git.js +101 -20
  38. package/dist/lib/exec.d.ts +7 -0
  39. package/dist/lib/exec.d.ts.map +1 -1
  40. package/dist/lib/exec.js +6 -3
  41. package/package.json +1 -1
  42. package/src/commands/work.ts +51 -4
  43. package/src/core/harnesses/types.ts +5 -0
  44. package/src/core/supervisor/plan-read.ts +11 -2
  45. package/src/core/supervisor/plan-types.ts +11 -0
  46. package/src/core/supervisor/planning.ts +21 -0
  47. package/src/core/supervisor/state.ts +68 -11
  48. package/src/core/work/runner.ts +11 -2
  49. package/src/core/work/state.ts +125 -3
  50. package/src/core/workflow/engine.ts +11 -0
  51. package/src/core/workflow/index.ts +3 -0
  52. package/src/core/workflow/proof.ts +36 -0
  53. package/src/core/workflow/spawn-claude.ts +21 -2
  54. package/src/core/workflow/spawn-codex.ts +17 -2
  55. package/src/core/workflow/spawn-cursor.ts +18 -2
  56. package/src/core/workflow/spawn-failure.ts +40 -0
  57. package/src/core/workflow/types.ts +27 -0
  58. package/src/core/workflow/workspaces/local-git.ts +112 -22
  59. package/src/lib/exec.ts +13 -3
@@ -26,3 +26,43 @@ export function vendorFailureText(
26
26
  const combined = parts.join("\n");
27
27
  return combined.length > maxChars ? `…${combined.slice(-maxChars)}` : combined;
28
28
  }
29
+
30
+ // A 429 or 5xx status, but only where it sits in HTTP-status CONTEXT — not any
31
+ // digits that merely happen to fall in that range. A bare-number match treats a
32
+ // line number ("at line 500"), an item count ("got 429 items"), or a duration
33
+ // ("after 502 seconds") as an upstream refusal, which wrongly withholds a charge
34
+ // for a failure that was entirely our code. Three recognized shapes:
35
+ // 1. after an "HTTP" label: "HTTP 429", "HTTP/1.1 503 Service Unavailable"
36
+ // 2. after a "status" label: "status 500", "status: 429", "status code 502"
37
+ // 3. a bare status line: "503 Service Unavailable" (code at line start,
38
+ // then a reason phrase) — the classic "<code> <Reason-Phrase>" shape.
39
+ // The trailing `(?![0-9])` and the label/line-start anchors also keep a status
40
+ // embedded in a longer number ("4295"/"5031") out. All 5xx are server-side; 429
41
+ // is the only 4xx that means "reached and refused, retry later".
42
+ const STATUS = "(?:429|5[0-9]{2})";
43
+ const UPSTREAM_STATUS = new RegExp(
44
+ `(?:\\bHTTP\\b[/0-9. ]*|\\bstatus(?:\\s*code)?\\b[\\s:=]*)${STATUS}(?![0-9])` +
45
+ `|(?:^|\\n)\\s*${STATUS}\\s+[A-Za-z]`,
46
+ "i",
47
+ );
48
+ // Explicit vendor wording for the same conditions when a numeric status is
49
+ // absent. Deliberately short — see isUpstreamFailureText.
50
+ const UPSTREAM_PHRASES =
51
+ /circuit[ _-]?open|service unavailable|too many requests|rate[ _-]?limit|overloaded|bad gateway|gateway time-?out|internal server error/i;
52
+
53
+ /**
54
+ * Whether failure text names an UPSTREAM refusal — the vendor was reached and
55
+ * refused (a 5xx/429 status or circuit-open wording), as opposed to a work
56
+ * failure (the model ran and produced a wrong or incomplete result).
57
+ *
58
+ * There is no structural upstream signal the way ENOENT structurally marks a
59
+ * missing binary, so this is the one text match in the classifier. It is kept
60
+ * SHORT and documented on purpose: a per-vendor regex zoo would rot, and a
61
+ * false positive here wrongly withholds a charge, so both the phrase list and
62
+ * the numeric match stay tight — the number must sit in HTTP-status context, not
63
+ * merely fall in the 429/5xx range. Anything it does not match falls through to
64
+ * a charged work failure.
65
+ */
66
+ export function isUpstreamFailureText(text: string): boolean {
67
+ return UPSTREAM_STATUS.test(text) || UPSTREAM_PHRASES.test(text);
68
+ }
@@ -99,6 +99,11 @@ export interface WorkflowAgentProof {
99
99
  session_id?: string;
100
100
  result?: ResultDigest;
101
101
  error?: string;
102
+ /** Set on a failed agent whose spawn was classified uninformative about the
103
+ * work (ADR 0046): environment (binary absent) or upstream (vendor refused).
104
+ * Absent ⇒ a work failure. Recorded even when a script's parallel() swallows
105
+ * the rejection, so the run-level class can still be derived from proof. */
106
+ class?: SpawnFailureClass;
102
107
  }
103
108
 
104
109
  export interface WorkflowRepoSnapshot {
@@ -186,6 +191,12 @@ export interface WorkflowProof {
186
191
  objective?: string;
187
192
  error?: string;
188
193
  result?: ResultDigest;
194
+ /** Set on a failed run that was uninformative about the work (ADR 0046):
195
+ * environment or upstream. Derived from the agents' classes when no agent
196
+ * produced a result; absent ⇒ the attempt is charged as before. The durable
197
+ * work projection reads this to decide charging, stopping, and the
198
+ * uncharged-attempt bound. */
199
+ class?: SpawnFailureClass;
189
200
  };
190
201
  acceptance: {
191
202
  criteria: AcceptanceResult[];
@@ -298,6 +309,19 @@ export interface WorkflowSpecialistProfile {
298
309
  maxTurns?: number;
299
310
  }
300
311
 
312
+ /**
313
+ * Why a failed run was uninformative about the work (ADR 0046). Absent means
314
+ * the attempt produced information about the work (or succeeded), which is the
315
+ * default and is charged against the attempt budget exactly as before.
316
+ *
317
+ * - `environment`: the run never started — a precondition was missing (the
318
+ * vendor binary was absent). Uncharged AND not retried: retrying an unchanged
319
+ * environment cannot help, so the work item stops and names the precondition.
320
+ * - `upstream`: the vendor was reached and refused (5xx, 429, circuit open).
321
+ * Uncharged, but retry stays available (bounded by max_uncharged_attempts).
322
+ */
323
+ export type SpawnFailureClass = "environment" | "upstream";
324
+
301
325
  /** What a spawn adapter returns for one subagent run. */
302
326
  export interface SpawnResult {
303
327
  ok: boolean;
@@ -309,6 +333,9 @@ export interface SpawnResult {
309
333
  durationMs: number;
310
334
  /** Populated when ok=false. */
311
335
  error?: string;
336
+ /** Set when ok=false and the failure was positively identified as
337
+ * uninformative about the work. Absent ⇒ a work failure (charged). */
338
+ class?: SpawnFailureClass;
312
339
  }
313
340
 
314
341
  export interface SpawnRequest {
@@ -123,16 +123,26 @@ export async function probe(
123
123
  if (input.writable_roots.length === 0) {
124
124
  unsupported.push(reason("writable_roots_required", "explicit writable roots are required"));
125
125
  }
126
- for (const root of input.writable_roots) {
126
+ // Allocation always selects writable_roots[0] (validateRequest pins the selected root
127
+ // to it), so probe's authority-coverage verdict must be judged against that same root.
128
+ let selectedRoot: ValidatedRoot | undefined;
129
+ input.writable_roots.forEach((root, index) => {
127
130
  try {
128
- validateConfiguredRoot(root);
131
+ const validated = validateConfiguredRoot(root);
132
+ if (index === 0) selectedRoot = validated;
129
133
  } catch (error) {
130
134
  unsupported.push(reason("writable_root_invalid", (error as Error).message));
131
135
  }
132
- }
136
+ });
133
137
  if (version.ok) {
134
138
  try {
135
- inspectSourceRepository(input.requested_cwd);
139
+ const repo = inspectSourceRepository(input.requested_cwd);
140
+ if (selectedRoot) {
141
+ const outside = describeAuthorityOutsideRoot(repo, selectedRoot.realpath);
142
+ if (outside) {
143
+ unsupported.push(reason("repository_authority_outside_writable_root", outside));
144
+ }
145
+ }
136
146
  } catch (error) {
137
147
  unsupported.push(reason("repository_unsupported", (error as Error).message));
138
148
  }
@@ -209,12 +219,8 @@ async function allocate(
209
219
  repo.commonDir.realpath,
210
220
  "Git common directory",
211
221
  );
212
- if (
213
- !containsPath(root.realpath, repo.sourceRoot.realpath) ||
214
- !containsPath(root.realpath, repo.commonDir.realpath)
215
- ) {
216
- throw new Error("source repository and Git common dir must be inside the writable root");
217
- }
222
+ const authorityOutside = describeAuthorityOutsideRoot(repo, root.realpath);
223
+ if (authorityOutside) throw new Error(authorityOutside);
218
224
  const workspaceRoot = candidateUnderRoot(root, ["harnery-workspaces", bindingId]);
219
225
  const activeRoot = resolve(workspaceRoot, repo.requestedRelativePath);
220
226
  if (!containsPath(workspaceRoot, activeRoot)) {
@@ -1192,6 +1198,25 @@ async function applyIntegrationUnderLease(
1192
1198
  }
1193
1199
  revalidateClaimResources(claim, validateConfiguredRoot(plan.binding.writable_root.configured));
1194
1200
  inspectIntegrationTarget(current.target_root, plan.binding);
1201
+ // The fast-forward moves the target branch ref, which for a submodule or linked
1202
+ // worktree lives in the common directory outside the checkout tree. allowed_paths is
1203
+ // the real write authority (allocate and cleanup already gate the common directory on
1204
+ // it), so re-authorize both the target checkout and its common directory here rather
1205
+ // than trusting containment alone.
1206
+ const applyCommonDir = plan.binding.repository?.common_dir.realpath;
1207
+ if (!applyCommonDir) {
1208
+ throw new Error("integration binding is missing Git common-dir authority");
1209
+ }
1210
+ assertAllowedPathAuthority(
1211
+ claim.request.allowed_paths,
1212
+ current.target_root,
1213
+ "integration target path",
1214
+ );
1215
+ assertAllowedPathAuthority(
1216
+ claim.request.allowed_paths,
1217
+ applyCommonDir,
1218
+ "integration Git common directory",
1219
+ );
1195
1220
  git(current.target_root, ["merge", "--ff-only", expected.source_commit]);
1196
1221
  const finalCommit = git(current.target_root, ["rev-parse", "HEAD"]);
1197
1222
  const finalTree = git(current.target_root, ["rev-parse", "HEAD^{tree}"]);
@@ -1287,20 +1312,17 @@ function inspectSourceRepository(cwd: string): {
1287
1312
  if (!containsPath(sourceRoot, requestedCwd.realpath)) {
1288
1313
  throw new Error("requested working directory is outside the resolved Git worktree");
1289
1314
  }
1290
- if (lstatSync(join(sourceRoot, ".git")).isSymbolicLink()) {
1291
- throw new Error("symlink Git directories are unsupported");
1292
- }
1293
- if (!lstatSync(join(sourceRoot, ".git")).isDirectory()) {
1294
- throw new Error("linked worktree and submodule source repositories are unsupported");
1295
- }
1296
- const requestedGitDir = realpathSync(join(sourceRoot, ".git"));
1315
+ const pointedGitDir = resolveGitDirPointer(sourceRoot);
1297
1316
  const gitDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-dir"])));
1298
1317
  const commonDir = realpathSync(
1299
1318
  resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-common-dir"])),
1300
1319
  );
1301
- if (gitDir !== requestedGitDir || commonDir !== requestedGitDir) {
1320
+ if (pointedGitDir !== gitDir) {
1302
1321
  throw new Error("resolved Git authority does not match the requested checkout");
1303
1322
  }
1323
+ if (!containsPath(commonDir, gitDir)) {
1324
+ throw new Error("resolved Git directory is not inside its common directory");
1325
+ }
1304
1326
  const branch = gitMaybe(sourceRoot, ["symbolic-ref", "-q", "--short", "HEAD"]);
1305
1327
  if (!branch.ok || !branch.out) throw new Error("detached integration targets are unsupported");
1306
1328
  const targetRef = `refs/heads/${branch.out}`;
@@ -1321,6 +1343,47 @@ function inspectSourceRepository(cwd: string): {
1321
1343
  };
1322
1344
  }
1323
1345
 
1346
+ // Resolve the `.git` entry at a checkout root to the real Git directory it names,
1347
+ // without trusting the ambient environment. `.git` is a real directory in a plain
1348
+ // checkout, and a `gitdir:` pointer file in a linked worktree, submodule, or
1349
+ // worktree-of-a-submodule. A symlink is refused: the identity model pins a path to a
1350
+ // device and inode, and a symlink lets the target move under a pinned pointer.
1351
+ //
1352
+ // The caller cross-checks the result against `rev-parse --git-dir`. That is a
1353
+ // consistency assertion, not spoof detection: Git honours a `gitdir:` pointer, so a
1354
+ // pointer aimed at an unrelated repository agrees with `rev-parse` and is accepted.
1355
+ // It cannot be otherwise, because a doctored pointer and a submodule's pointer are
1356
+ // the same construct. What the equality buys is that this resolution and Git's own
1357
+ // never silently diverge, which is defence in depth over the `GIT_*` stripping in
1358
+ // `isolatedGitEnvironment`. The real authority check is containment plus
1359
+ // `allowed_paths` on the resolved common directory.
1360
+ function resolveGitDirPointer(sourceRoot: string): string {
1361
+ const dotGit = join(sourceRoot, ".git");
1362
+ let stats: ReturnType<typeof lstatSync>;
1363
+ try {
1364
+ stats = lstatSync(dotGit);
1365
+ } catch {
1366
+ throw new Error("requested checkout has no .git entry");
1367
+ }
1368
+ if (stats.isSymbolicLink()) {
1369
+ throw new Error("symlink Git directories are unsupported");
1370
+ }
1371
+ if (stats.isDirectory()) {
1372
+ return realpathSync(dotGit);
1373
+ }
1374
+ if (!stats.isFile()) {
1375
+ throw new Error(".git must be a directory or a gitdir pointer file");
1376
+ }
1377
+ const pointer = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0]?.trim() ?? "";
1378
+ const prefix = "gitdir:";
1379
+ if (!pointer.startsWith(prefix)) {
1380
+ throw new Error(".git file is not a gitdir pointer");
1381
+ }
1382
+ const target = pointer.slice(prefix.length).trim();
1383
+ if (!target) throw new Error(".git gitdir pointer is empty");
1384
+ return realpathSync(resolve(sourceRoot, target));
1385
+ }
1386
+
1324
1387
  function inspectFrozenSourceAuthority(
1325
1388
  sourceRoot: GitRepositoryBinding["source_root"],
1326
1389
  commonDir: GitRepositoryBinding["common_dir"],
@@ -1680,6 +1743,28 @@ function validFrozenFilesystemPath(
1680
1743
  );
1681
1744
  }
1682
1745
 
1746
+ // Explain, in the caller's terms, why a checkout's Git authority falls outside a
1747
+ // writable root — naming the offending path so a submodule or linked-worktree user
1748
+ // who declared only the inner checkout knows exactly what to widen. Returns undefined
1749
+ // when both the source checkout and its common directory are inside the root.
1750
+ function describeAuthorityOutsideRoot(
1751
+ repo: ReturnType<typeof inspectSourceRepository>,
1752
+ rootRealpath: string,
1753
+ ): string | undefined {
1754
+ if (!containsPath(rootRealpath, repo.commonDir.realpath)) {
1755
+ return (
1756
+ `Git keeps this checkout's administrative files in ${repo.commonDir.realpath}, ` +
1757
+ `which is outside the writable root ${rootRealpath}. Linked-worktree and submodule ` +
1758
+ `checkouts keep their Git authority in the enclosing repository, and allocating a ` +
1759
+ `worktree writes there; declare that repository (or a parent of it) as the writable root.`
1760
+ );
1761
+ }
1762
+ if (!containsPath(rootRealpath, repo.sourceRoot.realpath)) {
1763
+ return `the source checkout ${repo.sourceRoot.realpath} is outside the writable root ${rootRealpath}`;
1764
+ }
1765
+ return undefined;
1766
+ }
1767
+
1683
1768
  function assertAllowedPathAuthority(
1684
1769
  allowedPaths: WorkspaceAllocationRequest["allowed_paths"],
1685
1770
  candidate: string,
@@ -1783,10 +1868,15 @@ function capabilityDigestForClaim(claim: WorkspaceClaim): string {
1783
1868
  function acquireRepositoryLease(coordRoot: string, claim: WorkspaceClaim): () => void {
1784
1869
  const leaseDir = join(resolve(coordRoot), ".harnery", "workspaces", PROVIDER_ID, ".leases");
1785
1870
  mkdirSync(leaseDir, { recursive: true, mode: 0o700 });
1786
- const key = stableDigest({
1787
- common: claim.repository.common_dir.identity,
1788
- root: claim.writable_root.identity,
1789
- });
1871
+ // Mutual exclusion keys on the Git common directory alone. `git worktree add`,
1872
+ // `prune`, and the shared-`config` migration all write to the common directory's
1873
+ // administrative area, and once worktrees and submodules are allowed, several
1874
+ // distinct source checkouts (a superproject and its linked worktrees; a submodule
1875
+ // and a worktree of it) share one common directory under different — possibly
1876
+ // nested — writable roots. Including the writable root in the key would hand those
1877
+ // agents different locks and let their admin-area writes race. The writable root
1878
+ // stays in the lease metadata for diagnostics, not in the exclusion key.
1879
+ const key = stableDigest({ common: claim.repository.common_dir.identity });
1790
1880
  return acquireLease(coordRoot, join(leaseDir, `repository-${key}.lock`), claim, "repository");
1791
1881
  }
1792
1882
 
package/src/lib/exec.ts CHANGED
@@ -17,6 +17,13 @@ export interface ExecResult {
17
17
  * child that handles the signal cleanly still exits 0, so the exit code alone
18
18
  * cannot distinguish a kill from an ordinary finish. */
19
19
  timedOut?: boolean;
20
+ /** The `code` from a spawn-level failure (`proc` "error" event), e.g.
21
+ * "ENOENT" when the binary is absent. Absent for an ordinary process exit.
22
+ * We collapse such failures to exitCode 127 so callers' exit-code branches
23
+ * still fire, but a shell can legitimately exit 127 too; only this field
24
+ * distinguishes "the binary was never there" from "the process ran and exited
25
+ * 127". The process that knows reports it (as ADR 0044 did for `timedOut`). */
26
+ spawnErrno?: string;
20
27
  }
21
28
 
22
29
  export interface ExecOpts {
@@ -56,7 +63,7 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
56
63
  proc.kill();
57
64
  }, timeout);
58
65
 
59
- const finish = (exitCode: number, errOverride?: string): void => {
66
+ const finish = (exitCode: number, errOverride?: string, spawnErrno?: string): void => {
60
67
  clearTimeout(timer);
61
68
  const stdout = Buffer.concat(out).toString("utf-8");
62
69
  const stderr = errOverride ?? Buffer.concat(err).toString("utf-8");
@@ -66,13 +73,16 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
66
73
  stderr: shouldTrim ? stderr.trim() : stderr.replace(/\n$/, ""),
67
74
  exitCode,
68
75
  ...(timedOut ? { timedOut: true } : {}),
76
+ ...(spawnErrno ? { spawnErrno } : {}),
69
77
  });
70
78
  };
71
79
 
72
80
  // ENOENT (binary not found) and similar spawn failures surface here rather
73
81
  // than throwing: resolve with 127 + the message so callers' exitCode
74
- // branches handle it instead of crashing on an unhandled rejection.
75
- proc.on("error", (e: Error) => finish(127, e.message));
82
+ // branches handle it instead of crashing on an unhandled rejection. The
83
+ // errno (e.g. "ENOENT") is carried through so a real missing binary is
84
+ // distinguishable from a shell that merely exited 127.
85
+ proc.on("error", (e: Error) => finish(127, e.message, (e as NodeJS.ErrnoException).code));
76
86
  proc.on("close", (code) => finish(code ?? 1));
77
87
  });
78
88
  }