harness-everything 0.47.2 → 0.47.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +2 -0
- package/CHANGELOG.md +9 -0
- package/ci/lib/opencode-verification-loader.js +24 -0
- package/ci/mechanism-2p-opencode-v2-entrypoint.test.js +8 -1
- package/ci/mechanism-325-f3-derived-views.test.js +42 -2
- package/ci/mechanism-338-opencode-async.test.js +229 -0
- package/ci/mechanism-341-opencode-lane.test.js +14 -3
- package/ci/mechanism-36-verify-timeout.test.js +7 -168
- package/docs/platform-capabilities.md +8 -0
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-check.js +5 -4
- package/hooks/scripts/lib/doc-reconciliation-provider.js +1 -1
- package/opencode-plugin/README.md +28 -30
- package/opencode-plugin/index.mjs +249 -112
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +5 -4
- package/sbom.cdx.json +7 -7
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* The circuit breaker trips on repeated *verification* failures of the same
|
|
26
26
|
* underlying check (same command + normalized failure evidence: the first
|
|
27
27
|
* test-level failing marker with its suite header as context, or a digest of
|
|
28
|
-
* the
|
|
28
|
+
* the complete streamed stdout+stderr when no test-level marker exists), not on
|
|
29
29
|
* arbitrary tool failures: tool.execute.after
|
|
30
30
|
* gives back `{title, output, metadata}` for every tool with no normalized
|
|
31
31
|
* success/failure field, so there is no reliable cross-tool failure signal to
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import { homedir } from "node:os"
|
|
40
40
|
import { join, dirname, resolve, basename, relative, isAbsolute } from "node:path"
|
|
41
41
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync, statSync } from "node:fs"
|
|
42
|
-
import {
|
|
42
|
+
import { spawn } from "node:child_process"
|
|
43
43
|
import { createHash, randomUUID } from "node:crypto"
|
|
44
44
|
import { createRequire } from "node:module"
|
|
45
45
|
import { fileURLToPath } from "node:url"
|
|
@@ -175,6 +175,7 @@ function defaultEditState() {
|
|
|
175
175
|
lastEditTime: null,
|
|
176
176
|
verificationPending: false,
|
|
177
177
|
editsSinceVerification: 0,
|
|
178
|
+
editRevision: 0,
|
|
178
179
|
followUpPending: false,
|
|
179
180
|
followUpDeliveryPending: false,
|
|
180
181
|
followUpMessage: null,
|
|
@@ -354,22 +355,22 @@ function firstFailureMarker(text) {
|
|
|
354
355
|
// A stable, normalized signature: command + outcome + exit condition + the
|
|
355
356
|
// first test-level failing marker (with a suite/file context line when the
|
|
356
357
|
// runner printed one). Without a test-level marker the signature falls back
|
|
357
|
-
// to a digest of the
|
|
358
|
+
// to a digest of the complete streams (stdout and stderr both), so
|
|
358
359
|
// suite-header-only or banner-only prefixes from *different* failures can
|
|
359
360
|
// never collide (identical byte-for-byte evidence still matches, which is
|
|
360
361
|
// the honest reading: the same observation repeated). The user-visible
|
|
361
|
-
// `error` is a bounded diagnostic and is only the fallback when no
|
|
362
|
-
//
|
|
362
|
+
// `error` is a bounded diagnostic and is only the legacy fallback when no incremental
|
|
363
|
+
// identity was supplied.
|
|
363
364
|
function buildFailureSignature(result) {
|
|
364
365
|
const evidence = String((result && (result.evidence || result.error)) || "");
|
|
365
|
-
const marker = firstFailureMarker(evidence);
|
|
366
|
+
const marker = result && Object.hasOwn(result, "failureMarker") ? result.failureMarker : firstFailureMarker(evidence);
|
|
366
367
|
const exit =
|
|
367
368
|
result && Number.isInteger(result.exitCode) ? `exit=${result.exitCode}`
|
|
368
369
|
: result && result.signal ? `signal=${result.signal}`
|
|
369
370
|
: "exit=unknown";
|
|
370
371
|
const base = `${(result && result.command) || "verification"} [${(result && result.outcome) || "unknown"}] ${exit}`;
|
|
371
372
|
if (marker) return `${base}: ${marker}`;
|
|
372
|
-
const digest = createHash("sha256").update(evidence.replace(/\s+/g, " ").trim()).digest("hex").slice(0, 16);
|
|
373
|
+
const digest = result && result.evidenceDigest || createHash("sha256").update(evidence.replace(/\s+/g, " ").trim()).digest("hex").slice(0, 16);
|
|
373
374
|
return `${base}: unrecognized-output ${digest}`;
|
|
374
375
|
}
|
|
375
376
|
|
|
@@ -387,116 +388,203 @@ function getAvailableScripts(cwd) {
|
|
|
387
388
|
// self-regression suite needs ~2 minutes on a laptop, so the original 60s cap
|
|
388
389
|
// killed healthy runs and reported them as failures (same truncated stdout as
|
|
389
390
|
// the failure signature, three times in a row -> forced reflection).
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// classified as an execution error - never as a failed check - so buffer
|
|
394
|
-
// pressure cannot impersonate a test failure.
|
|
391
|
+
// One wall-clock budget covers the sequence. Output is consumed as it arrives;
|
|
392
|
+
// only bounded excerpts/line prefixes remain in memory, while all bytes feed
|
|
393
|
+
// incremental failure evidence. The manual verification CLI remains separate.
|
|
395
394
|
const VERIFY_TIMEOUT_ENV = "HARNESS_VERIFY_TIMEOUT_MS"
|
|
396
395
|
const DEFAULT_VERIFY_TIMEOUT_MS = 600000
|
|
397
|
-
const VERIFY_MAX_BUFFER = 16 * 1024 * 1024
|
|
398
396
|
const VERIFY_OUTPUT_LIMIT = 2000
|
|
397
|
+
const VERIFY_LINE_LIMIT = 4096
|
|
398
|
+
const VERIFY_KILL_GRACE_MS = 150
|
|
399
399
|
|
|
400
400
|
function verifyTimeoutMs() {
|
|
401
401
|
const raw = Number(process.env[VERIFY_TIMEOUT_ENV])
|
|
402
402
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_VERIFY_TIMEOUT_MS
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
function
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
405
|
+
// Consume every chunk without retaining an unbounded transcript. Marker
|
|
406
|
+
// state is per stream, so asynchronous stdout/stderr interleaving does not
|
|
407
|
+
// change identity. An overlong line keeps its prefix for marker recognition;
|
|
408
|
+
// its complete contents still contribute to the digest.
|
|
409
|
+
function createVerificationCapture() {
|
|
410
|
+
const hash = createHash("sha256")
|
|
411
|
+
let head = "", tail = "", length = 0, bytes = 0, line = "", context = null, consoleIndent = null
|
|
412
|
+
let marker = null, markerContext = null, pendingSpace = false, hashedText = false, lineTruncated = false, unsafeIdentity = false
|
|
413
|
+
function consumeLine(value, truncated) {
|
|
414
|
+
if (marker) return
|
|
415
|
+
const clean = value.replace(/\u001b\[[0-9;]*m/g, "")
|
|
416
|
+
const bullet = /^([ \t]*)●[ \t]+/.exec(clean)
|
|
417
|
+
if (consoleIndent !== null) {
|
|
418
|
+
if (bullet) {
|
|
419
|
+
if (bullet[1].length > consoleIndent) return
|
|
420
|
+
consoleIndent = null
|
|
421
|
+
} else if (clean.trim() === "" || /^[ \t]/.test(clean)) return
|
|
422
|
+
else consoleIndent = null
|
|
423
|
+
}
|
|
424
|
+
if (bullet && /^Console\b/.test(clean.slice(bullet[0].length))) {
|
|
425
|
+
consoleIndent = bullet[1].length
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
// A clipped marker or suite name cannot identify a check safely. A long
|
|
429
|
+
// ordinary progress/banner line need not discard a later complete marker.
|
|
430
|
+
if (truncated && (clean.trim() === "" || /^\u001b\[[0-9;]*$/.test(clean)
|
|
431
|
+
|| [...FAILURE_MARKER_PATTERNS, ...CONTEXT_MARKER_PATTERNS].some(pattern => pattern.test(clean)))) {
|
|
432
|
+
unsafeIdentity = true
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (FAILURE_MARKER_PATTERNS.some(pattern => pattern.test(clean))) {
|
|
436
|
+
marker = normalizeFailureLine(clean)
|
|
437
|
+
markerContext = context
|
|
438
|
+
} else if (CONTEXT_MARKER_PATTERNS.some(pattern => pattern.test(clean))) context = normalizeFailureLine(clean)
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
write(chunk) {
|
|
442
|
+
const text = String(chunk)
|
|
443
|
+
bytes += Buffer.byteLength(text)
|
|
444
|
+
length += text.length
|
|
445
|
+
if (head.length < VERIFY_OUTPUT_LIMIT) head += text.slice(0, VERIFY_OUTPUT_LIMIT-head.length)
|
|
446
|
+
tail = (tail + text).slice(-VERIFY_OUTPUT_LIMIT)
|
|
447
|
+
for (const part of text.split(/(\s+)/)) {
|
|
448
|
+
if (!part) continue
|
|
449
|
+
if (/^\s+$/.test(part)) pendingSpace = hashedText
|
|
450
|
+
else {
|
|
451
|
+
if (pendingSpace) hash.update(" ")
|
|
452
|
+
hash.update(part)
|
|
453
|
+
pendingSpace = false
|
|
454
|
+
hashedText = true
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const [index, part] of text.split("\n").entries()) {
|
|
458
|
+
if (index > 0) { consumeLine(line, lineTruncated); line = ""; lineTruncated = false }
|
|
459
|
+
if (line.length + part.length > VERIFY_LINE_LIMIT) lineTruncated = true
|
|
460
|
+
if (line.length < VERIFY_LINE_LIMIT) line += part.slice(0, VERIFY_LINE_LIMIT-line.length)
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
excerpt(limit) {
|
|
464
|
+
if (length <= limit) return head.slice(0, length)
|
|
465
|
+
const half = Math.floor(limit/2)
|
|
466
|
+
return `${head.slice(0, half)}\n... [${length-half*2} chars omitted] ...\n${tail.slice(-half)}`
|
|
467
|
+
},
|
|
468
|
+
get bytes() { return bytes },
|
|
469
|
+
finish() {
|
|
470
|
+
consumeLine(line, lineTruncated)
|
|
471
|
+
return { digest: hash.digest("hex"), marker, markerContext, context, unsafeIdentity }
|
|
472
|
+
},
|
|
473
|
+
}
|
|
423
474
|
}
|
|
424
475
|
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
function
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
476
|
+
// Kill the whole shell/npm/script group, including children which inherited
|
|
477
|
+
// the output pipes. On POSIX the grace timer still kills the group after the
|
|
478
|
+
// shell exits; otherwise a SIGTERM-ignoring grandchild could survive it.
|
|
479
|
+
async function terminateVerificationChild(child) {
|
|
480
|
+
if (!child || !child.pid) return
|
|
481
|
+
if (process.platform === "win32") {
|
|
482
|
+
await new Promise(resolveKill => {
|
|
483
|
+
try {
|
|
484
|
+
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" })
|
|
485
|
+
killer.once("error", () => { try { child.kill("SIGKILL") } catch {} resolveKill() })
|
|
486
|
+
killer.once("close", resolveKill)
|
|
487
|
+
} catch { try { child.kill("SIGKILL") } catch {} resolveKill() }
|
|
488
|
+
})
|
|
489
|
+
return
|
|
434
490
|
}
|
|
435
|
-
|
|
491
|
+
try { process.kill(-child.pid, "SIGTERM") } catch { /* group already exited */ }
|
|
492
|
+
await new Promise(resolveKill => setTimeout(resolveKill, VERIFY_KILL_GRACE_MS))
|
|
493
|
+
try { process.kill(-child.pid, "SIGKILL") } catch { /* group already exited */ }
|
|
436
494
|
}
|
|
437
495
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
496
|
+
function runVerificationCommand(cwd, command, remainingMs, signal, onProgress) {
|
|
497
|
+
return new Promise(resolveCommand => {
|
|
498
|
+
const stdout = createVerificationCapture(), stderr = createVerificationCapture()
|
|
499
|
+
let child, spawnError = null, termination = null, timedOut = false, cancelled = false
|
|
500
|
+
let timer, progressTimer
|
|
501
|
+
const terminate = reason => {
|
|
502
|
+
if (termination) return
|
|
503
|
+
timedOut = reason === "timeout"
|
|
504
|
+
cancelled = reason === "cancelled"
|
|
505
|
+
termination = terminateVerificationChild(child)
|
|
506
|
+
}
|
|
507
|
+
const abort = () => terminate("cancelled")
|
|
508
|
+
const finish = async (exitCode, childSignal) => {
|
|
509
|
+
clearTimeout(timer)
|
|
510
|
+
clearInterval(progressTimer)
|
|
511
|
+
signal?.removeEventListener("abort", abort)
|
|
512
|
+
if (termination) await termination
|
|
513
|
+
const out = stdout.finish(), err = stderr.finish()
|
|
514
|
+
const both = stdout.bytes > 0 && stderr.bytes > 0
|
|
515
|
+
const errorOutput = both
|
|
516
|
+
? `stdout:\n${stdout.excerpt(VERIFY_OUTPUT_LIMIT/2)}\nstderr:\n${stderr.excerpt(VERIFY_OUTPUT_LIMIT/2)}`
|
|
517
|
+
: (stdout.bytes ? stdout : stderr).excerpt(VERIFY_OUTPUT_LIMIT)
|
|
518
|
+
const outcome = timedOut ? "timeout"
|
|
519
|
+
: cancelled || spawnError || childSignal || !Number.isInteger(exitCode) ? "execution_error"
|
|
520
|
+
: exitCode === 0 ? "pass" : "test_failure"
|
|
521
|
+
const result = { command, success: outcome === "pass", outcome, exitCode: spawnError ? null : exitCode, signal: childSignal || null, timedOut }
|
|
522
|
+
if (outcome !== "pass") {
|
|
523
|
+
if (timedOut) result.error = `timed out after the total verification budget (set ${VERIFY_TIMEOUT_ENV} to raise it); last output: ${errorOutput}`
|
|
524
|
+
else if (outcome === "execution_error") result.error = `could not run to completion (execution error: ${cancelled ? "cancelled" : spawnError?.code || (childSignal ? `signal ${childSignal}` : "no exit status")}); output: ${errorOutput || spawnError?.message || "(none)"}`
|
|
525
|
+
else result.error = errorOutput || `command exited ${exitCode}`
|
|
526
|
+
}
|
|
527
|
+
if (outcome === "test_failure") {
|
|
528
|
+
result.failureMarker = out.unsafeIdentity || err.unsafeIdentity ? null : out.marker
|
|
529
|
+
? (out.markerContext ? `${out.markerContext}: ${out.marker}` : out.marker)
|
|
530
|
+
: err.marker ? `${err.markerContext || out.context ? `${err.markerContext || out.context}: ` : ""}${err.marker}` : null
|
|
531
|
+
result.evidenceDigest = createHash("sha256").update(`stdout:${out.digest}\nstderr:${err.digest}`).digest("hex").slice(0, 16)
|
|
532
|
+
}
|
|
533
|
+
resolveCommand(result)
|
|
534
|
+
}
|
|
535
|
+
if (signal?.aborted) {
|
|
536
|
+
cancelled = true
|
|
537
|
+
void finish(null, null)
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] })
|
|
542
|
+
} catch (error) {
|
|
543
|
+
spawnError = error
|
|
544
|
+
void finish(null, null)
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
child.stdout.setEncoding("utf8")
|
|
548
|
+
child.stderr.setEncoding("utf8")
|
|
549
|
+
child.stdout.on("data", chunk => stdout.write(chunk))
|
|
550
|
+
child.stderr.on("data", chunk => stderr.write(chunk))
|
|
551
|
+
for (const stream of [child.stdout, child.stderr]) stream.once("error", error => {
|
|
552
|
+
spawnError = error
|
|
553
|
+
terminate("stream-error")
|
|
554
|
+
})
|
|
555
|
+
child.once("error", error => { spawnError = error })
|
|
556
|
+
child.once("exit", (_code, signalName) => {
|
|
557
|
+
if (signalName && !termination) terminate("external")
|
|
558
|
+
})
|
|
559
|
+
child.once("close", (code, signalName) => { void finish(code, signalName) })
|
|
560
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
561
|
+
timer = setTimeout(() => terminate("timeout"), Math.max(1, remainingMs))
|
|
562
|
+
if (onProgress) progressTimer = setInterval(() => {
|
|
563
|
+
try { onProgress({ command, outputBytes: stdout.bytes + stderr.bytes }) } catch { /* logging cannot alter a verdict */ }
|
|
564
|
+
}, 15000)
|
|
565
|
+
})
|
|
459
566
|
}
|
|
460
567
|
|
|
461
|
-
function runVerification(cwd) {
|
|
568
|
+
async function runVerification(cwd, { signal, onProgress } = {}) {
|
|
462
569
|
const scripts = getAvailableScripts(cwd)
|
|
463
|
-
const commands = [
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
].filter((c) => scripts[c.script])
|
|
468
|
-
|
|
469
|
-
if (commands.length === 0) {
|
|
470
|
-
return { allPassed: true, skipped: true, results: [] }
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const timeout = verifyTimeoutMs()
|
|
570
|
+
const commands = ["test", "lint", "build"].filter(script => scripts[script])
|
|
571
|
+
.map(script => script === "test" ? "npm test" : `npm run ${script}`)
|
|
572
|
+
if (commands.length === 0) return { allPassed: true, skipped: true, results: [] }
|
|
573
|
+
const deadline = Date.now() + verifyTimeoutMs()
|
|
474
574
|
const results = []
|
|
475
|
-
for (const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
results.push({ command
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const stderr = (e.stderr || "").toString()
|
|
483
|
-
const output = verificationEvidence(stdout, stderr) || String((e && e.message) || "")
|
|
484
|
-
const { outcome, exitCode, signal, timedOut } = classifyVerificationError(e, startedAt, timeout)
|
|
485
|
-
let error
|
|
486
|
-
if (outcome === "timeout") {
|
|
487
|
-
error = `timed out after ${Math.round(timeout / 1000)}s (set ${VERIFY_TIMEOUT_ENV} to raise it); last output: ${output.slice(0, 160).replace(/\s+/g, " ").trim() || "(none)"}`
|
|
488
|
-
} else if (outcome === "execution_error") {
|
|
489
|
-
const reason = String((e && e.code) || (signal ? `signal ${signal}` : "no exit status"))
|
|
490
|
-
error = `could not run to completion (execution error: ${reason}); output: ${output.slice(0, 200).replace(/\s+/g, " ").trim() || "(none)"}`
|
|
491
|
-
} else {
|
|
492
|
-
error = verificationDiagnostic(stdout, stderr) || String((e && e.message) || "")
|
|
493
|
-
}
|
|
494
|
-
const result = { command: cmd, success: false, outcome, exitCode, signal, timedOut, error }
|
|
495
|
-
if (outcome === "test_failure" && output) result.evidence = output
|
|
496
|
-
results.push(result)
|
|
575
|
+
for (const command of commands) {
|
|
576
|
+
if (signal?.aborted) break
|
|
577
|
+
const remainingMs = deadline - Date.now()
|
|
578
|
+
if (remainingMs <= 0) {
|
|
579
|
+
results.push({ command, success: false, outcome: "timeout", exitCode: null, signal: null, timedOut: true,
|
|
580
|
+
error: `timed out after the total verification budget (set ${VERIFY_TIMEOUT_ENV} to raise it); command not started` })
|
|
581
|
+
break
|
|
497
582
|
}
|
|
583
|
+
const result = await runVerificationCommand(cwd, command, remainingMs, signal, onProgress)
|
|
584
|
+
results.push(result)
|
|
585
|
+
if (result.outcome === "timeout") break
|
|
498
586
|
}
|
|
499
|
-
return { allPassed: results.every(
|
|
587
|
+
return { allPassed: !signal?.aborted && results.every(result => result.success), skipped: false, results }
|
|
500
588
|
}
|
|
501
589
|
|
|
502
590
|
/**
|
|
@@ -609,14 +697,17 @@ function appendTelemetry(workspace, input) {
|
|
|
609
697
|
}
|
|
610
698
|
}
|
|
611
699
|
|
|
612
|
-
export const HarnessEnforcement = async ({ client, directory }) => {
|
|
700
|
+
export const HarnessEnforcement = async ({ client, directory, signal }) => {
|
|
613
701
|
// State is resolved per real workspace and per opencode session. The CJS
|
|
614
702
|
// hooks use the same workspaces/<key>/state/sessions/<id> contract, while
|
|
615
703
|
// the old flat plugin state is intentionally left untouched because it is
|
|
616
704
|
// impossible to attribute safely to one workspace or session.
|
|
617
705
|
const workspace = resolve(directory || process.cwd())
|
|
618
706
|
migrateLegacyFlatState(getStateDir(workspace))
|
|
619
|
-
const activeVerifications = new
|
|
707
|
+
const activeVerifications = new Map()
|
|
708
|
+
signal?.addEventListener("abort", () => {
|
|
709
|
+
for (const controller of activeVerifications.values()) controller.abort()
|
|
710
|
+
}, { once: true })
|
|
620
711
|
const telemetryToolStarts = new Map()
|
|
621
712
|
const telemetryPendingSkills = new Map()
|
|
622
713
|
const telemetryActiveSkills = new Map()
|
|
@@ -807,6 +898,13 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
807
898
|
}
|
|
808
899
|
|
|
809
900
|
function resetSession(sessionID) {
|
|
901
|
+
const activeKey = sessionID || "default"
|
|
902
|
+
const controller = activeVerifications.get(activeKey)
|
|
903
|
+
if (controller) {
|
|
904
|
+
controller.reset = true
|
|
905
|
+
controller.abort()
|
|
906
|
+
}
|
|
907
|
+
activeVerifications.delete(activeKey)
|
|
810
908
|
const { stateDir } = pathsFor(sessionID)
|
|
811
909
|
const sessionsRoot = resolve(join(getStateRoot(workspace), "sessions"))
|
|
812
910
|
if (!isWithin(sessionsRoot, stateDir) || resolve(stateDir) === sessionsRoot) return
|
|
@@ -864,6 +962,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
864
962
|
}
|
|
865
963
|
|
|
866
964
|
const state = normalizeEditState(loadJSON(editStateFile, defaultEditState()))
|
|
965
|
+
activeVerifications.get(input.sessionID || "default")?.abort()
|
|
867
966
|
state.lastEditTime = Date.now()
|
|
868
967
|
state.verificationPending = true
|
|
869
968
|
state.followUpPending = false
|
|
@@ -871,6 +970,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
871
970
|
state.followUpMessage = null
|
|
872
971
|
state.lastFollowUpError = null
|
|
873
972
|
state.editsSinceVerification++
|
|
973
|
+
state.editRevision = (Number.isSafeInteger(state.editRevision) ? state.editRevision : 0) + 1
|
|
874
974
|
saveJSON(editStateFile, state)
|
|
875
975
|
recordCompliance(complianceFile, (c) => c.totalEdits++)
|
|
876
976
|
},
|
|
@@ -889,11 +989,21 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
889
989
|
return
|
|
890
990
|
}
|
|
891
991
|
if (event.type !== "session.idle") return
|
|
992
|
+
if (signal?.aborted) return
|
|
892
993
|
telemetryCloseSession(sessionID, "unknown", "opencode-session-idle")
|
|
893
|
-
if (activeVerifications.has(sessionID || "default")) return
|
|
894
|
-
|
|
895
994
|
const activeSession = sessionID || "default"
|
|
896
|
-
activeVerifications.
|
|
995
|
+
const previous = activeVerifications.get(activeSession)
|
|
996
|
+
if (previous) {
|
|
997
|
+
if (!previous.signal.aborted) return
|
|
998
|
+
// An edit has cancelled the old run, but its process group may still
|
|
999
|
+
// be closing. Keep this idle and verify the new revision once reaped.
|
|
1000
|
+
await previous.finished
|
|
1001
|
+
if (signal?.aborted || previous.reset || activeVerifications.has(activeSession)) return
|
|
1002
|
+
}
|
|
1003
|
+
const controller = new AbortController()
|
|
1004
|
+
let finishVerification
|
|
1005
|
+
controller.finished = new Promise(resolveFinished => { finishVerification = resolveFinished })
|
|
1006
|
+
activeVerifications.set(activeSession, controller)
|
|
897
1007
|
try {
|
|
898
1008
|
const { editStateFile, breakerFile, complianceFile, reflectionFile } = pathsFor(activeSession)
|
|
899
1009
|
// This fallback makes the lifecycle complete even if the host omits
|
|
@@ -909,12 +1019,14 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
909
1019
|
path: { id: activeSession },
|
|
910
1020
|
body: { parts: [{ type: "text", text: state.followUpMessage }] },
|
|
911
1021
|
})
|
|
1022
|
+
if (controller.signal.aborted) return
|
|
912
1023
|
state.followUpPending = true
|
|
913
1024
|
state.followUpDeliveryPending = false
|
|
914
1025
|
state.followUpMessage = null
|
|
915
1026
|
state.lastFollowUpError = null
|
|
916
1027
|
saveJSON(editStateFile, state)
|
|
917
1028
|
} catch (error) {
|
|
1029
|
+
if (controller.signal.aborted) return
|
|
918
1030
|
state.lastFollowUpError = String(error && (error.message || error)).slice(0, 300)
|
|
919
1031
|
saveJSON(editStateFile, state)
|
|
920
1032
|
}
|
|
@@ -923,7 +1035,16 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
923
1035
|
|
|
924
1036
|
if (!state.verificationPending || state.editsSinceVerification === 0) return
|
|
925
1037
|
|
|
926
|
-
const
|
|
1038
|
+
const revision = state.editRevision
|
|
1039
|
+
const { allPassed, skipped, results } = await runVerification(workspace, {
|
|
1040
|
+
signal: controller.signal,
|
|
1041
|
+
onProgress: ({ command, outputBytes }) => console.info(`Harness: ${command} still running (${outputBytes} output bytes; shared verification budget).`),
|
|
1042
|
+
})
|
|
1043
|
+
// An edit or reset during await invalidates this snapshot. It must not
|
|
1044
|
+
// clear newer edits, issue a stale follow-up, or resurrect deleted state.
|
|
1045
|
+
if (controller.signal.aborted || activeVerifications.get(activeSession) !== controller) return
|
|
1046
|
+
const latest = normalizeEditState(loadJSON(editStateFile, defaultEditState()))
|
|
1047
|
+
if (latest.editRevision !== revision) return
|
|
927
1048
|
|
|
928
1049
|
if (skipped || allPassed) {
|
|
929
1050
|
state.verificationPending = false
|
|
@@ -979,6 +1100,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
979
1100
|
try {
|
|
980
1101
|
await client.session.prompt({ path: { id: activeSession }, body: { parts: [{ type: "text", text }] } })
|
|
981
1102
|
} catch (error) {
|
|
1103
|
+
if (controller.signal.aborted) return
|
|
982
1104
|
// A failed delivery is retryable on the next idle event, but the
|
|
983
1105
|
// saved message means retrying does not run verification again or
|
|
984
1106
|
// increment the circuit-breaker count.
|
|
@@ -991,9 +1113,10 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
991
1113
|
}
|
|
992
1114
|
} finally {
|
|
993
1115
|
try {
|
|
994
|
-
requireFromPlugin(STOP_GATE_FILE).decide({ session_id: activeSession, cwd: workspace, host_id: "opencode" })
|
|
1116
|
+
if (!controller.signal.aborted) requireFromPlugin(STOP_GATE_FILE).decide({ session_id: activeSession, cwd: workspace, host_id: "opencode" })
|
|
995
1117
|
} catch { /* the optional reconciliation consumer must never block OpenCode idle */ }
|
|
996
|
-
activeVerifications.delete(activeSession)
|
|
1118
|
+
if (activeVerifications.get(activeSession) === controller) activeVerifications.delete(activeSession)
|
|
1119
|
+
finishVerification()
|
|
997
1120
|
}
|
|
998
1121
|
},
|
|
999
1122
|
}
|
|
@@ -1019,7 +1142,9 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
1019
1142
|
export default {
|
|
1020
1143
|
id: "harness.enforcement",
|
|
1021
1144
|
async setup(ctx) {
|
|
1145
|
+
const events = new AbortController()
|
|
1022
1146
|
const hooks = await HarnessEnforcement({
|
|
1147
|
+
signal: events.signal,
|
|
1023
1148
|
client: {
|
|
1024
1149
|
session: {
|
|
1025
1150
|
// V1 shutdown path calls client.session.prompt({path, body}) twice;
|
|
@@ -1045,11 +1170,12 @@ export default {
|
|
|
1045
1170
|
messageID: event.messageID,
|
|
1046
1171
|
}))
|
|
1047
1172
|
|
|
1048
|
-
const
|
|
1173
|
+
const pendingIdles = new Set()
|
|
1049
1174
|
void (async () => {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1175
|
+
try {
|
|
1176
|
+
for await (const raw of ctx.event.subscribe({ signal: events.signal })) {
|
|
1177
|
+
if (events.signal.aborted) break
|
|
1178
|
+
const delivery = hooks.event({
|
|
1053
1179
|
event: {
|
|
1054
1180
|
type: raw.type,
|
|
1055
1181
|
// V1 event handlers read {type, properties}; V2 events are flat
|
|
@@ -1057,11 +1183,22 @@ export default {
|
|
|
1057
1183
|
properties: raw.properties ?? raw,
|
|
1058
1184
|
},
|
|
1059
1185
|
})
|
|
1060
|
-
|
|
1186
|
+
if (raw.type === "session.idle") {
|
|
1187
|
+
// Awaiting verification here would prevent the same event stream
|
|
1188
|
+
// from delivering the reset/delete which must cancel it.
|
|
1189
|
+
const pending = delivery.catch(() => {}).finally(() => pendingIdles.delete(pending))
|
|
1190
|
+
pendingIdles.add(pending)
|
|
1191
|
+
} else {
|
|
1192
|
+
try { await delivery } catch { /* fail open */ }
|
|
1193
|
+
}
|
|
1061
1194
|
}
|
|
1062
|
-
}
|
|
1195
|
+
} catch { /* aborting the host subscription is normal shutdown */ }
|
|
1196
|
+
finally { events.abort() }
|
|
1063
1197
|
})()
|
|
1064
|
-
return () =>
|
|
1198
|
+
return async () => {
|
|
1199
|
+
events.abort()
|
|
1200
|
+
await Promise.allSettled([...pendingIdles])
|
|
1201
|
+
}
|
|
1065
1202
|
},
|
|
1066
1203
|
}
|
|
1067
1204
|
|
package/package.json
CHANGED
|
@@ -101,7 +101,7 @@ function isUncPathPrefix(token, command, index) {
|
|
|
101
101
|
return token === '' && command[index] === '\\' && command[index + 1] === '\\';
|
|
102
102
|
}
|
|
103
103
|
// Canonical SHA-256 of harness-everything/scripts/doc-check.js (CRLF normalised to LF). Re-pin deliberately when that script changes.
|
|
104
|
-
const SHIPPED_DOC_CHECK_DIGEST = '
|
|
104
|
+
const SHIPPED_DOC_CHECK_DIGEST = '0e93f252b2350b145c34dcb5354aa3b4246f3c59e948a62c3a912305b355a475';
|
|
105
105
|
// This install's own doc-check runtime, relative to hooks/scripts/lib: the source checkout or the packaged plugin.
|
|
106
106
|
const DOC_CHECK_INSTALL_LAYOUTS = [
|
|
107
107
|
['..', '..', '..', 'harness-everything', 'scripts', 'doc-check.js'],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "harness-everything",
|
|
4
|
-
"version": "0.47.
|
|
4
|
+
"version": "0.47.3",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|
|
@@ -247,11 +247,12 @@ function globToRegExp(pattern) {
|
|
|
247
247
|
return new RegExp(`^${body}$`);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
//
|
|
250
|
+
// Keep 64-bit device/file IDs exact (Windows IDs can exceed Number's safe
|
|
251
|
+
// integer range), while still rejecting symlink and hard-link aliases.
|
|
251
252
|
function sameFile(a, b) {
|
|
252
|
-
const x = fs.statSync(a);
|
|
253
|
-
const y = fs.statSync(b);
|
|
254
|
-
return x.ino >
|
|
253
|
+
const x = fs.statSync(a, { bigint: true });
|
|
254
|
+
const y = fs.statSync(b, { bigint: true });
|
|
255
|
+
return x.ino > 0n && x.ino === y.ino && x.dev === y.dev;
|
|
255
256
|
}
|
|
256
257
|
|
|
257
258
|
// Open without blocking (O_NONBLOCK keeps a FIFO from waiting for a writer), then require a regular file before reading.
|