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.
@@ -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 full captured stdout+stderr when no test-level marker exists), not on
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 { execSync } from "node:child_process"
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 full captured evidence (stdout and stderr both), so
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 full
362
- // evidence was kept.
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
- // Known limits of this synchronous runner, kept honest: execSync blocks the
391
- // plugin's event loop for the duration of each command (worst case one budget
392
- // per script), and a command whose output exceeds VERIFY_MAX_BUFFER is
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
- // Full failure evidence for signature analysis: both captured streams, in
406
- // full. execSync already bounds each stream at VERIFY_MAX_BUFFER, and the old
407
- // stdout-only 2 KiB prefix cut happened *before* buildFailureSignature() ran,
408
- // so distinct failures behind a shared banner collapsed into one signature.
409
- function verificationEvidence(stdout, stderr) {
410
- const out = String(stdout || "")
411
- const err = String(stderr || "")
412
- if (out && err) return `${out}\nstderr:\n${err}`
413
- return out || err
414
- }
415
-
416
- // Bounded, human-readable excerpt that keeps both ends: the npm banner at the
417
- // head and the failure marker/summary at the tail survive truncation.
418
- function boundedDiagnostic(text, limit) {
419
- const value = String(text || "")
420
- if (value.length <= limit) return value
421
- const half = Math.floor(limit / 2)
422
- return `${value.slice(0, half)}\n... [${value.length - half * 2} chars omitted] ...\n${value.slice(-half)}`
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
- // User-visible diagnostic: both streams get their own budget, so a huge
426
- // stdout cannot push a short stderr (where jest writes assertions) out of
427
- // the report. A single non-empty stream stays label-free.
428
- function verificationDiagnostic(stdout, stderr) {
429
- const out = String(stdout || "")
430
- const err = String(stderr || "")
431
- if (out && err) {
432
- const share = Math.floor(VERIFY_OUTPUT_LIMIT / 2)
433
- return `stdout:\n${boundedDiagnostic(out, share)}\nstderr:\n${boundedDiagnostic(err, share)}`
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
- return boundedDiagnostic(out || err, VERIFY_OUTPUT_LIMIT)
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
- // Outcome taxonomy:
439
- // pass the command exited 0
440
- // test_failure a real non-zero exit status: a check actually failed
441
- // timeout the configured budget expired: ETIMEDOUT, or a kill with
442
- // no exit status once the budget elapsed: no verdict
443
- // execution_error spawn-level failure, or an external signal (SIGTERM/
444
- // SIGINT/SIGKILL from the user or host) before the budget:
445
- // no verdict
446
- // A signal alone is not a budget timeout - only expiry is. Only
447
- // test_failure feeds the same-check Rule-of-3 counter.
448
- function classifyVerificationError(error, startedAt, timeout) {
449
- const code = String((error && error.code) || "")
450
- const signal = String((error && error.signal) || "")
451
- const exitCode = error && Number.isInteger(error.status) ? error.status : null
452
- const budgetExpired =
453
- code === "ETIMEDOUT" || (exitCode === null && Date.now() - startedAt >= timeout)
454
- if (budgetExpired) return { outcome: "timeout", exitCode, signal, timedOut: true }
455
- if (exitCode === null || ["ENOBUFS", "ENOENT", "EACCES", "EAGAIN", "ENOMEM"].includes(code)) {
456
- return { outcome: "execution_error", exitCode, signal, timedOut: false }
457
- }
458
- return { outcome: "test_failure", exitCode, signal, timedOut: false }
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
- { cmd: "npm test", script: "test" },
465
- { cmd: "npm run lint", script: "lint" },
466
- { cmd: "npm run build", script: "build" },
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 { cmd } of commands) {
476
- const startedAt = Date.now()
477
- try {
478
- execSync(cmd, { cwd, stdio: "pipe", timeout, maxBuffer: VERIFY_MAX_BUFFER })
479
- results.push({ command: cmd, success: true, outcome: "pass", exitCode: 0, signal: null, timedOut: false })
480
- } catch (e) {
481
- const stdout = (e.stdout || "").toString()
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((r) => r.success), skipped: false, results }
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 Set()
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.add(activeSession)
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 { allPassed, skipped, results } = runVerification(workspace)
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 events = new AbortController()
1173
+ const pendingIdles = new Set()
1049
1174
  void (async () => {
1050
- for await (const raw of ctx.event.subscribe({ signal: events.signal })) {
1051
- try {
1052
- await hooks.event({
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
- } catch { // fail open, same as the hooks themselves
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 () => events.abort()
1198
+ return async () => {
1199
+ events.abort()
1200
+ await Promise.allSettled([...pendingIdles])
1201
+ }
1065
1202
  },
1066
1203
  }
1067
1204
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.47.2",
4
+ "version": "0.47.3",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.47.2",
3
+ "version": "0.47.3",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.47.2",
3
+ "version": "0.47.3",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -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 = '3ca386666d4dae9a98ffa27adf2f326bf171a783af90515375f0b328ca35f194';
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.2",
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",
@@ -4,7 +4,7 @@ description: "Route software/project work through Harness: classify tier/topolog
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.47.1
7
+ version: 0.47.3
8
8
  ---
9
9
 
10
10
  # Harness Everything
@@ -247,11 +247,12 @@ function globToRegExp(pattern) {
247
247
  return new RegExp(`^${body}$`);
248
248
  }
249
249
 
250
- // Same file when both paths stat to one device and inode, so symlinks and hard links are caught.
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 > 0 && x.ino === y.ino && x.dev === y.dev;
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.