@gethmy/harness 1.6.0 → 1.7.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/cli.js +376 -219
- package/dist/index.js +293 -146
- package/package.json +2 -2
- package/src/cli.ts +34 -2
- package/src/exec-types.ts +33 -4
- package/src/git-pr.ts +53 -5
- package/src/oracle-collector.ts +38 -8
- package/src/oracle.ts +464 -53
- package/src/repair-sandbox.test.ts +166 -1
- package/src/repair-sandbox.ts +203 -8
- package/src/run-containment.ts +191 -37
- package/src/stage-cli.ts +32 -8
- package/src/verification.ts +200 -21
- package/src/worktree.ts +19 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Execution motor for Harmony playbook stages. Runs exactly one stage per invocation: worktree, role-separated subagents, held oracle, gate evidence. It never routes, never judges, never pushes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@anthropic-ai/claude-agent-sdk": "^0.3.178",
|
|
58
|
-
"@gethmy/mcp": "3.
|
|
58
|
+
"@gethmy/mcp": "3.3.0",
|
|
59
59
|
"@supabase/supabase-js": "2.95.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -241,8 +241,28 @@ async function main(): Promise<void> {
|
|
|
241
241
|
process.stderr.write(`${parsed.message}\n${STAGE_RUN_USAGE}\n`);
|
|
242
242
|
process.exit(2);
|
|
243
243
|
}
|
|
244
|
-
const {
|
|
245
|
-
|
|
244
|
+
const {
|
|
245
|
+
cardId,
|
|
246
|
+
stageId,
|
|
247
|
+
workspaceId,
|
|
248
|
+
repoPath,
|
|
249
|
+
sessionId,
|
|
250
|
+
metricsPath,
|
|
251
|
+
sandboxImage,
|
|
252
|
+
} = parsed.args;
|
|
253
|
+
|
|
254
|
+
// The DRIVER's container for everything this motor runs against the worktree
|
|
255
|
+
// (#1021): the held test, and the `build_green` gate's build and lint. One
|
|
256
|
+
// value, every gate command — an operator who set an image to keep untrusted
|
|
257
|
+
// code off their host did not mean "except this one".
|
|
258
|
+
//
|
|
259
|
+
// Absent ⇒ `undefined`, which is host execution exactly as before. Trimmed and
|
|
260
|
+
// re-checked for emptiness here as well as in the flag parser, because docker
|
|
261
|
+
// reads an empty image name as a missing argument and fails in a way that
|
|
262
|
+
// reads like a broken runtime.
|
|
263
|
+
const gateSandbox = sandboxImage?.trim()
|
|
264
|
+
? { image: sandboxImage.trim() }
|
|
265
|
+
: undefined;
|
|
246
266
|
|
|
247
267
|
// The driver's metric allowlist for `custom` gates (--metrics, optional).
|
|
248
268
|
// Read + validated BEFORE anything runs: a driver that passed the flag wants
|
|
@@ -344,6 +364,12 @@ async function main(): Promise<void> {
|
|
|
344
364
|
worktreePath: req.repoPath,
|
|
345
365
|
buildTimeout: GATE_VERIFICATION_TIMEOUT_MS,
|
|
346
366
|
lintTimeout: GATE_VERIFICATION_TIMEOUT_MS,
|
|
367
|
+
// #1021 closes the residual #1036 recorded here: the motor used to
|
|
368
|
+
// hold no operator config, so this gate re-ran the worktree's own
|
|
369
|
+
// build and lint scripts in the motor process while the daemon's
|
|
370
|
+
// identical gate ran them in a container. The driver passes the image
|
|
371
|
+
// now, so both paths honour one setting.
|
|
372
|
+
sandbox: gateSandbox,
|
|
347
373
|
},
|
|
348
374
|
// The metric allowlist is the DRIVER's, passed via --metrics — the
|
|
349
375
|
// motor still holds no operator config of its own. Without the flag
|
|
@@ -361,6 +387,12 @@ async function main(): Promise<void> {
|
|
|
361
387
|
oracle: {
|
|
362
388
|
repoPath: req.repoPath,
|
|
363
389
|
sessionId: req.sessionId,
|
|
390
|
+
// #1021. The held test is arbitrary code an untrusted author wrote,
|
|
391
|
+
// and this is the only place a container can be put around it — the
|
|
392
|
+
// oracle gates run in the motor and nowhere else (the daemon's own
|
|
393
|
+
// registry wires no oracle collector, by design: it cannot provide
|
|
394
|
+
// the actor separation these gates bind on).
|
|
395
|
+
sandbox: gateSandbox,
|
|
364
396
|
// Only `oracle_red` reads this (its own stage is not the key); the
|
|
365
397
|
// green collector addresses the row by `context.stageId`.
|
|
366
398
|
targetStageId: oracleTargetStageId,
|
package/src/exec-types.ts
CHANGED
|
@@ -122,10 +122,39 @@ export interface VerificationConfig {
|
|
|
122
122
|
* `worktree.ts` installs them at worktree creation with `--ignore-scripts`.
|
|
123
123
|
* Point this at whatever your CI uses.
|
|
124
124
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
125
|
+
* **It covers the HELD TEST too since #1021**, and that is the one command
|
|
126
|
+
* here whose *input* is written by an untrusted author rather than being a
|
|
127
|
+
* script a contained run may have rewritten. The daemon forwards this value
|
|
128
|
+
* to the harness motor (`--sandbox-image`), which is where both held-test
|
|
129
|
+
* gates run, so one setting covers every command the gate machinery executes
|
|
130
|
+
* against a worktree. The image must carry the RUNNER the oracle's
|
|
131
|
+
* `runner_hint` names, not merely a toolchain — measured, `oven/bun:1` has
|
|
132
|
+
* `bun` and `node` but no `npx`, so a `vitest`-hinted held test in it reports
|
|
133
|
+
* the gate `blocked` with a message naming the image.
|
|
134
|
+
*
|
|
135
|
+
* Cost of the held-test half, measured on macOS / Docker Desktop with a warm
|
|
136
|
+
* image: ~0.14 s added for a trivial held test and ~1.4 s for one that
|
|
137
|
+
* resolves a dependency out of a real `node_modules`, twice per card. Far
|
|
138
|
+
* below the ~5.1 s per verification command above, because a held test reads
|
|
139
|
+
* one file and its imports rather than running the repo's whole build.
|
|
140
|
+
*
|
|
141
|
+
* **And the two `dev` servers since #1037** (`runDeepReview`'s and the
|
|
142
|
+
* review worker's) — one key, every command the daemon runs against a
|
|
143
|
+
* worktree, because the image requirement is the same one and an operator
|
|
144
|
+
* who set a second key but not this one would get a half-contained daemon.
|
|
145
|
+
*
|
|
146
|
+
* Their container is the same in every respect but the network: a dev
|
|
147
|
+
* server exists to be connected to, so it publishes `127.0.0.1:<port>`
|
|
148
|
+
* rather than taking `--network=none`, and therefore keeps egress. That is
|
|
149
|
+
* the one place this setting buys less than it does for the four
|
|
150
|
+
* verification steps and the held test, and `devServerSandboxArgs` says why
|
|
151
|
+
* no flag combination avoids it.
|
|
152
|
+
*
|
|
153
|
+
* Setting this makes Docker load-bearing for review, not only for
|
|
154
|
+
* verification: the dev server then starts through `docker run`, and a host
|
|
155
|
+
* that cannot start it fails the same way an unstartable host server
|
|
156
|
+
* already does — the review worker keeps the card in Review and labels it
|
|
157
|
+
* for a human rather than bouncing it back for rework.
|
|
129
158
|
*/
|
|
130
159
|
sandboxImage: string;
|
|
131
160
|
};
|
package/src/git-pr.ts
CHANGED
|
@@ -246,6 +246,19 @@ function isNamedCheck(
|
|
|
246
246
|
* reads `success` rather than `unknown`, because `unknown` means "this repo
|
|
247
247
|
* configures no checks at all" — a repo whose only check is the review has a
|
|
248
248
|
* green build by that repo's own definition, and the review gates it below.
|
|
249
|
+
*
|
|
250
|
+
* ## SKIPPED is a pass HERE, and that is correct — measured, card #903
|
|
251
|
+
*
|
|
252
|
+
* Folding `SKIPPED` in with `SUCCESS` is right for everything this function is
|
|
253
|
+
* still allowed to speak for. After the exclusion above, that is the build and
|
|
254
|
+
* test jobs, where a skip is a **path filter** doing its job: on the 45 PR heads
|
|
255
|
+
* sampled for #903, `test`, `build` and `EAS Build & TestFlight Submit` all
|
|
256
|
+
* report `SKIPPED` on scope-filtered PRs that are green by every definition
|
|
257
|
+
* their repo has. Failing on those would block every docs-only PR.
|
|
258
|
+
*
|
|
259
|
+
* The reason this is safe is the exclusion, NOT the fold: a review that never
|
|
260
|
+
* fired is a skip that must HOLD, and it does — because its check name is taken
|
|
261
|
+
* out of this verdict and answered by the two readers below.
|
|
249
262
|
*/
|
|
250
263
|
export function deriveCiStatus(
|
|
251
264
|
rollup: unknown,
|
|
@@ -289,14 +302,21 @@ export function deriveCiStatus(
|
|
|
289
302
|
* Whether an INDEPENDENT review — one produced outside the daemon that wrote the
|
|
290
303
|
* code — has run against the PR's current head (card #1003).
|
|
291
304
|
*
|
|
292
|
-
* `deriveCiStatus` above cannot answer this,
|
|
293
|
-
* `
|
|
294
|
-
*
|
|
295
|
-
*
|
|
305
|
+
* `deriveCiStatus` above cannot answer this, because it folds `SKIPPED` in with
|
|
306
|
+
* `SUCCESS` (see its `["SUCCESS", "NEUTRAL", "SKIPPED"]` line). A daemon PR on
|
|
307
|
+
* an `agent/` branch has its review job skipped by the workflow's own branch
|
|
308
|
+
* rule, so the rollup carries a real check run reading
|
|
296
309
|
* `{name: "review", conclusion: "SKIPPED"}` — and the collapsed verdict for that
|
|
297
|
-
* PR
|
|
310
|
+
* PR was `"success"`. The merge gate could not tell "reviewed and clean" from
|
|
298
311
|
* "nobody looked".
|
|
299
312
|
*
|
|
313
|
+
* That was a live bug until #1018, and it is **fixed by the exclusion, not by a
|
|
314
|
+
* change to the fold**: `readPrChecks` takes both review check names out of
|
|
315
|
+
* `deriveCiStatus`, so a skipped review no longer reaches the collapsed word at
|
|
316
|
+
* all. The fold stays, and stays correct, for the build/test jobs it still
|
|
317
|
+
* speaks for. Do not "reconcile" the three answers — see #903 on the ruling
|
|
318
|
+
* that they are three different questions and must keep three different answers.
|
|
319
|
+
*
|
|
300
320
|
* `absent` and `skipped` are kept apart on purpose. `skipped` means the workflow
|
|
301
321
|
* exists and declined this branch, which a `ci-review` request can fix. `absent`
|
|
302
322
|
* means no such check is configured on this repo at all — a request would be
|
|
@@ -322,6 +342,28 @@ export type IndependentReviewState =
|
|
|
322
342
|
* independent did read this head, which is the question being asked; a pending
|
|
323
343
|
* re-run outranks the failed run it is retrying, so the gate waits for the
|
|
324
344
|
* answer instead of acting on the stale one.
|
|
345
|
+
*
|
|
346
|
+
* ## Why "several runs share the name" is the normal case (measured, #903)
|
|
347
|
+
*
|
|
348
|
+
* `statusCheckRollup` **retains every check run on a SHA**. It does not collapse
|
|
349
|
+
* to the most recent run of a name, and nothing supersedes anything. Two
|
|
350
|
+
* mechanisms decide whether a re-review adds an entry or overwrites one:
|
|
351
|
+
*
|
|
352
|
+
* - a **new triggering event** (`labeled` for `ci-review`, `reopened`, …)
|
|
353
|
+
* starts a new workflow run, which publishes a NEW check run of the same
|
|
354
|
+
* name. Both entries persist, forever, side by side.
|
|
355
|
+
* - `gh run rerun` REUSES the run's `databaseId` and UPDATES its check run in
|
|
356
|
+
* place, so that one leaves a single entry.
|
|
357
|
+
*
|
|
358
|
+
* Observed on PR 719 (`134d0a62`), where `review` reads `CANCELLED` and
|
|
359
|
+
* `SUCCESS` together — the cancel came from the review workflow's own
|
|
360
|
+
* `cancel-in-progress` concurrency group, and the success is the re-review that
|
|
361
|
+
* followed it. Note it did not REPLACE it, which is the point. This precedence
|
|
362
|
+
* is what makes that PR readable as `passed`
|
|
363
|
+
* instead of `failed`, and the #1018 exclusion is what keeps the stale
|
|
364
|
+
* `CANCELLED` out of `ciStatus`. On the 45 PR heads sampled, PR 719 is the ONE
|
|
365
|
+
* case where the exclusion changes the collapsed verdict — without it that PR
|
|
366
|
+
* reads `failure` on a branch whose review had actually succeeded.
|
|
325
367
|
*/
|
|
326
368
|
export function deriveIndependentReviewState(
|
|
327
369
|
rollup: unknown,
|
|
@@ -433,6 +475,12 @@ export type ReviewVerdictState =
|
|
|
433
475
|
* because a finding does not stop being real when a later pass over the same
|
|
434
476
|
* head fails to repeat it. The remedy for a blocking verdict is a commit that
|
|
435
477
|
* fixes it, which moves the head and asks again with a clean slate.
|
|
478
|
+
*
|
|
479
|
+
* That remedy is not a preference, it is the only one that works. Because the
|
|
480
|
+
* rollup retains every run (measured, #903 — see the note on the reader above),
|
|
481
|
+
* a `blocking` verdict CANNOT be cleared by re-requesting a review on the same
|
|
482
|
+
* head: the re-request publishes a second check run and the failing one stays
|
|
483
|
+
* beside it. Moving the head is the exit.
|
|
436
484
|
*/
|
|
437
485
|
export function deriveReviewVerdictState(
|
|
438
486
|
rollup: unknown,
|
package/src/oracle-collector.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type HeldOracle,
|
|
45
45
|
type OracleDeps,
|
|
46
46
|
type OracleRunSummary,
|
|
47
|
+
OracleSandboxConfigError,
|
|
47
48
|
} from "./oracle.js";
|
|
48
49
|
|
|
49
50
|
const TAG = "oracle-collector";
|
|
@@ -142,6 +143,11 @@ abstract class HeldOracleCollector {
|
|
|
142
143
|
const { exitCode, output, report } = await this.deps.run(
|
|
143
144
|
this.deps.repoPath,
|
|
144
145
|
oracle,
|
|
146
|
+
// #1021. Both polarities take the same container: `oracle_passed` runs
|
|
147
|
+
// the same untrusted file as `oracle_red`, so containing one and not
|
|
148
|
+
// the other would leave the hole under the other gate's name. Only the
|
|
149
|
+
// GRADING differs between them, and that stays where it is.
|
|
150
|
+
{ sandbox: this.deps.sandbox },
|
|
145
151
|
);
|
|
146
152
|
// Full output is for the human operator's local log only — never the
|
|
147
153
|
// persisted evidence. See the class doc comment: oracle_passed is a
|
|
@@ -161,12 +167,31 @@ abstract class HeldOracleCollector {
|
|
|
161
167
|
});
|
|
162
168
|
} catch (err) {
|
|
163
169
|
const message = errText(err);
|
|
164
|
-
|
|
170
|
+
// #823 rule 1: opt in, and ONLY for static config. A sandbox that is not
|
|
171
|
+
// answering, or an image that cannot start or does not carry the runner,
|
|
172
|
+
// is the operator's configuration — re-running the stage cannot change the
|
|
173
|
+
// answer, so an unmarked `blocked` would spend the card's whole attempt
|
|
174
|
+
// budget rediscovering it. Every other throw here (a bad path, a timeout,
|
|
175
|
+
// a runner that died) keeps the retryable default, because a next attempt
|
|
176
|
+
// genuinely can differ.
|
|
177
|
+
const configError = err instanceof OracleSandboxConfigError;
|
|
178
|
+
log.warn(
|
|
179
|
+
TAG,
|
|
180
|
+
`Oracle run threw: ${message} — blocked${configError ? " (config)" : ""}`,
|
|
181
|
+
);
|
|
165
182
|
return {
|
|
166
183
|
result: "blocked",
|
|
167
184
|
structured: {
|
|
168
185
|
oracle: { path: oracle.path, ...identity },
|
|
169
186
|
error: message,
|
|
187
|
+
// `reason` as well as `error`, and only on this branch: it is the
|
|
188
|
+
// field `gateConfigErrorReason` reads, so without it the board renders
|
|
189
|
+
// the generic "the gate cannot be measured as configured" for a hold
|
|
190
|
+
// whose whole value is naming the image the operator has to fix.
|
|
191
|
+
// `error` stays for every existing reader of this shape.
|
|
192
|
+
...(configError
|
|
193
|
+
? { reason: message, ...GATE_CONFIG_ERROR_MARK }
|
|
194
|
+
: {}),
|
|
170
195
|
},
|
|
171
196
|
};
|
|
172
197
|
} finally {
|
|
@@ -293,13 +318,18 @@ export class OracleCollector
|
|
|
293
318
|
* the forgery.
|
|
294
319
|
*
|
|
295
320
|
* HOW FAR THAT GOES: it closes the stream forgeries outright, and it does NOT
|
|
296
|
-
* make the
|
|
297
|
-
* can write
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
321
|
+
* make the report FILE unforgeable — the held test executes inside the runner's
|
|
322
|
+
* process, so it can write that file on the host and in the container alike.
|
|
323
|
+
* What #1021 added is a claim check rather than an authentication one: neither
|
|
324
|
+
* allow-listed runner exits 0 while reporting a failing test, so a report saying
|
|
325
|
+
* `failed > 0` from a run that exited 0 is refused. That removes the free
|
|
326
|
+
* forgery — one file satisfying the red gate and the green gate out of a single
|
|
327
|
+
* run — and leaves a held test that wants both polarities having to exit
|
|
328
|
+
* differently before and after the fix. With `verification.sandboxImage` set the
|
|
329
|
+
* run also happens inside `repair-sandbox.ts`'s container, where the held test
|
|
330
|
+
* cannot reach the motor's host at all and no straggler outlives the run.
|
|
331
|
+
* `oracle.ts` has the full account, measurements included, above
|
|
332
|
+
* `OracleRunnerSpec`.
|
|
303
333
|
*
|
|
304
334
|
* Secrecy is inherited whole. The report is parsed in memory and only its two
|
|
305
335
|
* counts reach the persisted evidence — never the assertion text, which is itself
|