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.
- package/dist/commands/work.d.ts +2 -1
- package/dist/commands/work.d.ts.map +1 -1
- package/dist/commands/work.js +44 -4
- package/dist/core/harnesses/types.d.ts +5 -0
- package/dist/core/harnesses/types.d.ts.map +1 -1
- package/dist/core/supervisor/plan-read.js +7 -1
- package/dist/core/supervisor/plan-types.d.ts +10 -0
- package/dist/core/supervisor/plan-types.d.ts.map +1 -1
- package/dist/core/supervisor/planning.d.ts.map +1 -1
- package/dist/core/supervisor/planning.js +18 -0
- package/dist/core/supervisor/state.d.ts.map +1 -1
- package/dist/core/supervisor/state.js +65 -11
- package/dist/core/work/runner.d.ts.map +1 -1
- package/dist/core/work/runner.js +11 -2
- package/dist/core/work/state.d.ts +17 -0
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +102 -3
- package/dist/core/workflow/engine.js +13 -0
- package/dist/core/workflow/index.d.ts +3 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/proof.d.ts +18 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +34 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +19 -2
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +17 -2
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +18 -2
- package/dist/core/workflow/spawn-failure.d.ts +14 -0
- package/dist/core/workflow/spawn-failure.d.ts.map +1 -1
- package/dist/core/workflow/spawn-failure.js +34 -0
- package/dist/core/workflow/types.d.ts +26 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +101 -20
- package/dist/lib/exec.d.ts +7 -0
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +6 -3
- package/package.json +1 -1
- package/src/commands/work.ts +51 -4
- package/src/core/harnesses/types.ts +5 -0
- package/src/core/supervisor/plan-read.ts +11 -2
- package/src/core/supervisor/plan-types.ts +11 -0
- package/src/core/supervisor/planning.ts +21 -0
- package/src/core/supervisor/state.ts +68 -11
- package/src/core/work/runner.ts +11 -2
- package/src/core/work/state.ts +125 -3
- package/src/core/workflow/engine.ts +11 -0
- package/src/core/workflow/index.ts +3 -0
- package/src/core/workflow/proof.ts +36 -0
- package/src/core/workflow/spawn-claude.ts +21 -2
- package/src/core/workflow/spawn-codex.ts +17 -2
- package/src/core/workflow/spawn-cursor.ts +18 -2
- package/src/core/workflow/spawn-failure.ts +40 -0
- package/src/core/workflow/types.ts +27 -0
- package/src/core/workflow/workspaces/local-git.ts +112 -22
- 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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
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
|
}
|