@zhuxixi/pi-agent-board 0.7.0 → 0.8.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/CHANGELOG.md +26 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Plan: Control Command Lifecycle (issue #91 Phase 5, D4)
|
|
2
|
+
|
|
3
|
+
Spec: `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md` (§ D4 command table, §7 协议快照, §9 control-protocol layer, §10 非幂等 input 故障窗口, acceptance A1/A2)
|
|
4
|
+
Parent issue: #91 (do NOT close)
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
控制 socket 升级为带生命周期的 ack 协议:命令信封(`commandId`/`clientId`/连接内 `seq`/`viewId`/`instanceId`)、三阶段 ack(`accepted`/`applied`/`observed`,外加终态 `superseded`)、按命令类型的交付语义表、reconcile 基线建立、generation token(顺带结构性修复 Phase 4 账本的 epoch 歧义)。旧客户端(无信封)走现有路径零回归。
|
|
9
|
+
|
|
10
|
+
## Non-goals
|
|
11
|
+
|
|
12
|
+
- 删 `childInputLooksEmpty()`(Phase 6)
|
|
13
|
+
- legacy attach 路径清理(Phase 6+)
|
|
14
|
+
- D6 Windows JSON-runner
|
|
15
|
+
|
|
16
|
+
## Ground truth (verified)
|
|
17
|
+
|
|
18
|
+
- ownedMain 已有:`input` + `requestId` → dedup 表(FIFO cap)→ `child.write` → `input_ack`(accepted/applied 合一);无 requestId 的键盘输入 fire-and-forget(spec 认可,保持)。
|
|
19
|
+
- UI 控制命令(resize/interrupt/terminate/detach)无信封无 ack;resize 有 clampInt + model 配对 + cachedResize(child 未就绪时缓存)。
|
|
20
|
+
- `hello` 已带 `status: host`(instanceId 在 host 里);`snapshot_begin` 带 cols/rows/frameVersion;无 generation、无 reconcile。
|
|
21
|
+
- service durable follow-up:`sendHostInput(socketPath, data, {requestId: item.id})`,重试幂等靠 runner dedup;host 重启后新实例没见过 requestId(service 注释已述)——但没有 accepted/applied 区分,重启后「已 accepted 结果未知」的命令语义未定义(§10 窗口)。
|
|
22
|
+
- coordinator 的 materializedRevision 戳在 state.json(runner 可只读)。
|
|
23
|
+
|
|
24
|
+
## Architecture (per spec §9 control-protocol layer split)
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
src/core/control-protocol.mjs # NEW: pure decision layer — envelope validate/encode,
|
|
28
|
+
# ack classification, retryPolicy, resize latest-wins tracker,
|
|
29
|
+
# command-type semantics table
|
|
30
|
+
runner/pty-runner.mjs # envelope handling + staged acks + durable journal +
|
|
31
|
+
# reconcile + generation token (both mains)
|
|
32
|
+
src/core/terminal-attach-client.mjs # reconnect order + generation epoch detection (consumer)
|
|
33
|
+
src/runtime/service.mjs # durable follow-up: commandId + staged acks + reconcile-then-retry
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Envelope & acks
|
|
37
|
+
|
|
38
|
+
- Envelope: `{commandId, clientId, seq, viewId, instanceId, type, ...payload}`. Runner accepts commands WITHOUT envelope verbatim (legacy path, zero behavior change).
|
|
39
|
+
- `seq`: per-connection monotonic; runner validates and drops out-of-order with a diagnostic (ordering aid, NOT dedup — dedup is `commandId` only, per spec).
|
|
40
|
+
- Staged acks as separate messages `{type:"cmd_ack", commandId, stage: "accepted"|"applied"|"observed"|"superseded", ...}`:
|
|
41
|
+
- `accepted`: durable commands only (follow-up input), after journal append.
|
|
42
|
+
- `applied`: action executed; carries actual applied value (resize: real cols/rows post-clamp; terminate: "started"; detach: "accepted"; interrupt/keystroke: no ack needed for fire-and-forget? — spec says transient commands get commandId for ack correlation: emit `applied` for resize/interrupt/terminate/detach).
|
|
43
|
+
- `observed`: structured evidence only — terminate on child exit confirmation. `resize` NEVER observed ("不能伪称 child 已完成渲染").
|
|
44
|
+
- `superseded`: resize latest-wins — a newer resize (same client) supersedes un-applied older ones; carries `byCommandId`.
|
|
45
|
+
|
|
46
|
+
### Per-type semantics (spec table, binding)
|
|
47
|
+
|
|
48
|
+
| type | retry/dedup | notes |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| input durable (requestId/commandId) | accepted(journaled)→applied(written); re-send same commandId returns cached final stage | journal resolves §10 window |
|
|
51
|
+
| input keystroke | never retry after disconnect | fire-and-forget stays |
|
|
52
|
+
| resize | same commandId → cached result; newer size supersedes older un-applied | applied returns REAL PTY dims |
|
|
53
|
+
| terminate | idempotent; observed on exit | repeats return current lifecycle state |
|
|
54
|
+
| detach | idempotent | applied on accept |
|
|
55
|
+
| reconcile | never retried blind | returns baseline |
|
|
56
|
+
|
|
57
|
+
### Durable command journal (runner, per host)
|
|
58
|
+
|
|
59
|
+
- `control-journal.jsonl` in the host's run dir: append `{commandId, command, acceptedAt}` on accept; append `{commandId, appliedAt}` (or result) on apply; GC keep last 256 records.
|
|
60
|
+
- Restart: load journal; entries accepted-without-applied are surfaced by reconcile as `{commandId, status: "accepted_unknown"}` and are NEVER auto-replayed (§10 binding rule). The service decides per its own queue semantics.
|
|
61
|
+
|
|
62
|
+
### Reconcile message
|
|
63
|
+
|
|
64
|
+
- Client → runner `{type:"reconcile", ...envelope}`. Response `{type:"reconcile_result", generation, hostRevision, terminalCursor: {lastSeq}, stateMaterializedRevision, unresolved: [{commandId, status}]}`.
|
|
65
|
+
- `generation` = runner boot UUID (also in hello/status/snapshot_begin). Client-side epoch rule: generation changed ⇒ discard cursor & local buffer assumptions ⇒ fresh snapshot (fixes Phase 4's epoch ambiguity: ring replay can never be mistaken across runner generations).
|
|
66
|
+
- `hostRevision` = host `lastSeenAt`/update counter — use an incrementing `revision` field added to host updates (cheap); `stateMaterializedRevision` read from state.json stamp (read-only; absent ⇒ null).
|
|
67
|
+
- Reconnect order becomes binding in client: `hello → reconcile → subscribe_terminal → resume retryable commands`.
|
|
68
|
+
|
|
69
|
+
### Retry discipline (client)
|
|
70
|
+
|
|
71
|
+
- Timeout on any durable command ⇒ reconcile/query by commandId BEFORE retry (never blind retry).
|
|
72
|
+
- UI resize: pending tracker drops on `superseded`; user-initiated resizes always send new commandId (latest-wins).
|
|
73
|
+
- Service follow-up: ambiguous on timeout ⇒ reconcile ⇒ if `applied` → done; if `accepted_unknown` (runner restarted) → per queue semantics mark ambiguous, no re-send to new child without explicit policy; if unknown commandId → safe retry (dedup protects).
|
|
74
|
+
|
|
75
|
+
## Tasks (bite-sized, commit per task)
|
|
76
|
+
|
|
77
|
+
### Task 1 — control-protocol pure layer (`src/core/control-protocol.mjs` + `test/control-protocol.test.mjs`)
|
|
78
|
+
Envelope validate/encode (legacy passthrough detection), ack classification per type table, `retryPolicy`, resize latest-wins tracker (supersede with byCommandId; cached result for same commandId), dedup key rules (commandId only, never seq), journal record shapes + GC policy as pure functions. Unit matrix per spec A1 (test/control-protocol.test.mjs per spec naming).
|
|
79
|
+
|
|
80
|
+
### Task 2 — runner integration (both mains)
|
|
81
|
+
Envelope handling for input/resize/interrupt/terminate/detach (+subscribe_terminal passthrough untouched); staged acks per table; durable journal (accept/applied/GC/restart-load); resize latest-wins + applied-with-real-dims; terminate observed-on-exit; `generation` boot UUID on hello/status/snapshot_begin; `reconcile` message + result. Legacy (no-envelope) path byte-identical. Unit + integration tests (fake socket, journal restart scenarios).
|
|
82
|
+
|
|
83
|
+
### Task 3 — UI/client integration
|
|
84
|
+
Reconnect order hello → reconcile → subscribe (client module); generation epoch rule (discard cursor on change ⇒ fresh snapshot — component e2e: restart with generation change beats ring-replay ambiguity); UI control commands carry envelope (commandId/clientId/seq); resize superseded tracking; terminate observed wiring (exit handling unchanged behaviorally).
|
|
85
|
+
|
|
86
|
+
### Task 4 — durable follow-up staged flow (service side)
|
|
87
|
+
`sendHostInput` → commandId + expect accepted→applied; timeout ⇒ reconcile-query ⇒ policy (applied/accepted_unknown/unknown); runner-restart fault-window e2e: accepted-then-kill ⇒ new runner reconcile shows accepted_unknown ⇒ service does NOT re-send to new child (assert no double-write; item marked ambiguous per existing queue handling). Existing requestId flow stays as the legacy envelope-less mode.
|
|
88
|
+
|
|
89
|
+
### Task 5 — A2 integration + acceptance sweep
|
|
90
|
+
`test/control-reconcile.integration.test.mjs` (spec A2): disconnect → hello → reconcile → snapshot/subscribe → resume retryable; 对账结果/host instance(generation)/terminal cursor 与 runner 一致. Full regression + acceptance table (A1/A2 CLOSED) + residual ledger (journal size bounds, keystroke no-ack rationale, legacy envelope-less coexistence).
|
|
91
|
+
|
|
92
|
+
## Verification per task
|
|
93
|
+
|
|
94
|
+
Targeted tests → FULL suite → typecheck → conventional commit (explicit git add). SDD task reviews; whole-branch final review before PR.
|
|
95
|
+
|
|
96
|
+
## Risks
|
|
97
|
+
|
|
98
|
+
- **Journal write on the accept hot path**: follow-up inputs are rare (queued prompts), not keystrokes — fs append per durable command is fine; keystrokes NEVER touch the journal.
|
|
99
|
+
- **Envelope on keystrokes adds per-key bytes**: tiny (commandId+seq ≈ 60B); keystrokes get NO ack (fire-and-forget preserved) — envelope is correlation-only.
|
|
100
|
+
- **Backward compatibility surface**: no-envelope messages must behave byte-identically (old UIs during upgrade window) — pin with compat tests in every task that touches the runner.
|
|
101
|
+
- **`instanceId` fencing interplay**: envelope's instanceId must not fight the existing host-ownership fencing — read the #70 fencing code before wiring (Task 2 first step).
|
|
102
|
+
|
|
103
|
+
## Residual ledger (Phase 5)
|
|
104
|
+
|
|
105
|
+
Acceptance: A1 CLOSED (test/control-protocol.test.mjs unit matrix — 33 tests); A2 CLOSED (test/control-reconcile.integration.test.mjs — wire-order reconnect, epoch discard, §10 never-rewrite e2e; 3 tests).
|
|
106
|
+
|
|
107
|
+
- Journal: ≤512 records between rewrites; rare durable commands only; keystrokes never journaled.
|
|
108
|
+
- Keystroke no-ack rationale: the envelope on transient commands is correlation/debugging metadata only — fire-and-forget by contract (no ack, no journal; journal writes must never sit on the keystroke path).
|
|
109
|
+
- Legacy envelope-less coexistence: byte-pinned compat tests; old runners serve legacy paths only (envelopes fenced with instance_mismatch).
|
|
110
|
+
- host_starting retry budget: 5×300ms client-side vs legacy unbounded cachedResize hold (documented parity choice).
|
|
111
|
+
- Failed follow-ups (§10 accepted_unknown) surface via error diagnostic + queue item text; no dedicated dashboard affordance (product note).
|
|
112
|
+
- envelope_invalid (non-retryable) still re-attempts in the queue loop — legacy shape, diagnostic-only signal.
|
|
113
|
+
- TERMINAL_ERROR_CODES includes journal_unavailable for the resize chain — unreachable for resize (never journaled), harmless.
|
|
114
|
+
- Boot-banner accept race (banner broadcast before socket accept, no replay): protocol property documented in 2026-08-27 spec; echo-probe test pattern adopted in terminal-snapshot + pty-runner integration files.
|
|
115
|
+
- Stray high-water subscribe cursor: the reconcile-gate legacy broadcast overlap is closed client-side (strays are EMITTED — exactly-once UI delivery — and the replay starts past the high-water); a sub-ms residual window (between subscribe write and runner processing) remains, covered by the client's stray-discard semantics. The epoch-reset path zeroes the high-water (fresh snapshot subsumes strays).
|
|
116
|
+
- Reconcile-deadline fallback (phase-4-era runner, lost result): subscribes with the remembered cursor and NO epoch comparison — an undetected generation change within a lost-result gate relies on runner-side invalid_since_seq/begin flags — which cover cursor-ahead / evicted-range / empty-model ONLY; a stale cursor INSIDE the new generation's ring range takes a seamless cross-generation replay (visual-only, self-heals at the next gated reconnect). Pre-diff behavior, noted for the fleet-refresh window.
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# Issue #121 Perf Gate Out of Coverage — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Move the A11 perf assertions out of the default/coverage suites behind an opt-in gate, so they only decide CI from a dedicated, serial, non-instrumented step (issue #121).
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three layers that must not merge — pure decision (`test-support/perf-gate.mjs`, no side effects) ↔ side-effect entry (`scripts/run-perf-gate.mjs`, spawn/env/exit-code only, no decision logic, no measurement) ↔ measurement (`test/terminal-model-perf.test.mjs`, owns thresholds and assertions). Wiring (package.json / ci.yml / docs) is pinned by committed static tests so silently deleting the CI step fails the suite.
|
|
8
|
+
|
|
9
|
+
**Spec:** `docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md` (acceptance IDs A1–A8, U1–U2 referenced below). U1/U2 are post-merge observations, not implementation tasks.
|
|
10
|
+
|
|
11
|
+
**Tech Stack:** Node `node:test` runner, c8, GitHub Actions, plain ESM `.mjs` (no TypeScript, no new deps).
|
|
12
|
+
|
|
13
|
+
**Worktree (all paths relative to this root):** `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-121-perf-gate-out-of-coverage` — never touch the main checkout.
|
|
14
|
+
|
|
15
|
+
## Global Constraints
|
|
16
|
+
|
|
17
|
+
- Thresholds stay verbatim: `FEED_P95_LIMIT = 5`, `FEED_P99_LIMIT = 8`, `CAPTURE_LIMIT = 50`, `HYDRATE_LIMIT = 100` in `test/terminal-model-perf.test.mjs`. No silent relax (spec D4).
|
|
18
|
+
- No new npm dependencies. No production-code changes (`src/`, `runner/` untouched). `.c8rc.json` untouched (`test-support/**` already excluded).
|
|
19
|
+
- `test` and `test:coverage` scripts and their `test/*.test.mjs` glob stay byte-identical.
|
|
20
|
+
- No new CI job, no branch-protection changes — exactly one new step in the existing job.
|
|
21
|
+
- Windows-compatible: no inline `VAR=1 cmd` in package.json; env is injected via `spawn` with `process.execPath`.
|
|
22
|
+
- Node 22 + 24 compatible (CI matrix).
|
|
23
|
+
- Commit messages: English, conventional commits. The squash-merged PR title lands in CHANGELOG under "Changes" — write it for readers.
|
|
24
|
+
- The `AGENT_BOARD_*` env naming is established precedent (`AGENT_BOARD_NO_SWEEP`, `AGENT_BOARD_AUTO_STATE`, …); the new var is `AGENT_BOARD_PERF_GATE`.
|
|
25
|
+
|
|
26
|
+
## File Structure
|
|
27
|
+
|
|
28
|
+
| File | Responsibility | Task |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `test-support/perf-gate.mjs` (create) | Pure gate decision `perfGateDecision(env)` | 1 |
|
|
31
|
+
| `test/perf-gate.test.mjs` (create) | A1 truth table + A2 entry-refusal tests | 1, 2 |
|
|
32
|
+
| `scripts/run-perf-gate.mjs` (create) | Entry: instrumentation detection → loud refuse, else spawn perf file with `AGENT_BOARD_PERF_GATE=1` | 2 |
|
|
33
|
+
| `test/terminal-model-perf.test.mjs` (modify) | Consume the gate; skip with reason when not opted in | 3 |
|
|
34
|
+
| `package.json` (modify) | Add `test:perf`; extend `verify` | 4 |
|
|
35
|
+
| `.github/workflows/ci.yml` (modify) | New "Perf gate" step before Unit tests | 5 |
|
|
36
|
+
| `test/perf-gate-wiring.test.mjs` (create) | A5 wiring + A8 docs static assertions | 5, 6 |
|
|
37
|
+
| `README.md`, `VERIFY.md` (modify) | Document the opt-in gate | 6 |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
### Task 1: `perfGateDecision` pure function + truth-table tests (A1)
|
|
42
|
+
|
|
43
|
+
**Files:**
|
|
44
|
+
- Create: `test-support/perf-gate.mjs`
|
|
45
|
+
- Test: `test/perf-gate.test.mjs`
|
|
46
|
+
|
|
47
|
+
**Interfaces:**
|
|
48
|
+
- Consumes: nothing (new code).
|
|
49
|
+
- Produces: `perfGateDecision(env = process.env): { run: boolean, reason: string }` — consumed by Task 3 (`test/terminal-model-perf.test.mjs`). **NOT** consumed by Task 2's entry script (spec §3.2 layering contract: the script does its own instrumentation check and never silently skips).
|
|
50
|
+
|
|
51
|
+
- [ ] **Step 1: Write the failing test**
|
|
52
|
+
|
|
53
|
+
Create `test/perf-gate.test.mjs`:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import assert from "node:assert/strict";
|
|
57
|
+
import { spawnSync } from "node:child_process";
|
|
58
|
+
import { fileURLToPath } from "node:url";
|
|
59
|
+
import { test } from "node:test";
|
|
60
|
+
|
|
61
|
+
import { perfGateDecision } from "../test-support/perf-gate.mjs";
|
|
62
|
+
|
|
63
|
+
// A1: truth table over the full domain —
|
|
64
|
+
// AGENT_BOARD_PERF_GATE ∈ {unset, "1", "0", other} × NODE_V8_COVERAGE ∈ {unset, set}.
|
|
65
|
+
const CASES = [
|
|
66
|
+
// [gate, coverage, expectedRun, reasonSubstring]
|
|
67
|
+
[undefined, undefined, false, "AGENT_BOARD_PERF_GATE=1"],
|
|
68
|
+
[undefined, "/tmp/x", false, "AGENT_BOARD_PERF_GATE=1"],
|
|
69
|
+
["1", undefined, true, ""],
|
|
70
|
+
["1", "/tmp/x", false, "coverage instrumentation"],
|
|
71
|
+
["0", undefined, false, "AGENT_BOARD_PERF_GATE=1"],
|
|
72
|
+
["0", "/tmp/x", false, "AGENT_BOARD_PERF_GATE=1"],
|
|
73
|
+
["yes", undefined, false, "AGENT_BOARD_PERF_GATE=1"],
|
|
74
|
+
["yes", "/tmp/x", false, "AGENT_BOARD_PERF_GATE=1"],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
for (const [gate, coverage, expectedRun, reasonSubstring] of CASES) {
|
|
78
|
+
test(`gate=${JSON.stringify(gate)} coverage=${coverage ?? "unset"} → run=${expectedRun}`, () => {
|
|
79
|
+
const env = {};
|
|
80
|
+
if (gate !== undefined) env.AGENT_BOARD_PERF_GATE = gate;
|
|
81
|
+
if (coverage !== undefined) env.NODE_V8_COVERAGE = coverage;
|
|
82
|
+
const d = perfGateDecision(env);
|
|
83
|
+
assert.equal(d.run, expectedRun);
|
|
84
|
+
if (expectedRun) assert.equal(d.reason, "");
|
|
85
|
+
else assert.ok(
|
|
86
|
+
d.reason.includes(reasonSubstring),
|
|
87
|
+
`reason ${JSON.stringify(d.reason)} must contain ${JSON.stringify(reasonSubstring)}`,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
94
|
+
|
|
95
|
+
Run: `node --test test/perf-gate.test.mjs`
|
|
96
|
+
Expected: FAIL — the file errors with `Cannot find module .../test-support/perf-gate.mjs` (module does not exist yet).
|
|
97
|
+
|
|
98
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
99
|
+
|
|
100
|
+
Create `test-support/perf-gate.mjs`:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
/**
|
|
104
|
+
* Perf-gate decision — a pure function of two environment variables (issue #121).
|
|
105
|
+
*
|
|
106
|
+
* The A11 perf assertions are only meaningful in a quiet, non-instrumented
|
|
107
|
+
* environment: c8 instrumentation inflates measured latency ~2.5–6× and the
|
|
108
|
+
* parallel suite adds contention noise (see research/03 in the issue-121
|
|
109
|
+
* research dir). They must therefore never decide results inside `npm test`
|
|
110
|
+
* or `npm run test:coverage`. The only authoritative path is
|
|
111
|
+
* `npm run test:perf`, which sets AGENT_BOARD_PERF_GATE=1.
|
|
112
|
+
*
|
|
113
|
+
* @param {Record<string, string | undefined>} env
|
|
114
|
+
* @returns {{ run: boolean, reason: string }}
|
|
115
|
+
*/
|
|
116
|
+
export function perfGateDecision(env = process.env) {
|
|
117
|
+
const instrumented = env.NODE_V8_COVERAGE !== undefined;
|
|
118
|
+
if (env.AGENT_BOARD_PERF_GATE === "1") {
|
|
119
|
+
if (instrumented) {
|
|
120
|
+
return {
|
|
121
|
+
run: false,
|
|
122
|
+
reason:
|
|
123
|
+
"perf assertions refuse to measure under coverage instrumentation " +
|
|
124
|
+
"(NODE_V8_COVERAGE is set); run `npm run test:perf` instead",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { run: true, reason: "" };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
run: false,
|
|
131
|
+
reason:
|
|
132
|
+
"perf assertions are opt-in: run them via `npm run test:perf` " +
|
|
133
|
+
"(or set AGENT_BOARD_PERF_GATE=1)",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Note the reason wording pins the two assertion substrings the test relies on: `"AGENT_BOARD_PERF_GATE=1"` for every non-run case except instrumented-opted-in, which contains `"coverage instrumentation"`.
|
|
139
|
+
|
|
140
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
141
|
+
|
|
142
|
+
Run: `node --test test/perf-gate.test.mjs`
|
|
143
|
+
Expected: PASS — `pass 8 / fail 0`.
|
|
144
|
+
|
|
145
|
+
- [ ] **Step 5: Commit**
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
git add test-support/perf-gate.mjs test/perf-gate.test.mjs
|
|
149
|
+
git commit -m "test: add perf-gate decision function with truth-table coverage (issue #121)"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### Task 2: `scripts/run-perf-gate.mjs` entry + instrumentation-refusal test (A2)
|
|
155
|
+
|
|
156
|
+
**Files:**
|
|
157
|
+
- Create: `scripts/run-perf-gate.mjs`
|
|
158
|
+
- Modify: `test/perf-gate.test.mjs` (append the A2 test)
|
|
159
|
+
|
|
160
|
+
**Interfaces:**
|
|
161
|
+
- Consumes: nothing from Task 1 (deliberate layering: the script does NOT call `perfGateDecision`).
|
|
162
|
+
- Produces: the executable invoked by `npm run test:perf` (Task 4) and the CI step (Task 5). Contract: exit 0 ⇔ the perf file ran to completion under `AGENT_BOARD_PERF_GATE=1`; exit 1 with a stderr message when `NODE_V8_COVERAGE` is present.
|
|
163
|
+
|
|
164
|
+
- [ ] **Step 1: Write the failing test**
|
|
165
|
+
|
|
166
|
+
Append to `test/perf-gate.test.mjs`:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
// A2: the entry script refuses loudly under instrumentation — nonzero exit,
|
|
170
|
+
// a clear message, and no measurement output. Setting AGENT_BOARD_PERF_GATE=1
|
|
171
|
+
// too proves the instrumentation check dominates the opt-in.
|
|
172
|
+
const ENTRY_SCRIPT = fileURLToPath(new URL("../scripts/run-perf-gate.mjs", import.meta.url));
|
|
173
|
+
|
|
174
|
+
test("run-perf-gate.mjs refuses when NODE_V8_COVERAGE is set", () => {
|
|
175
|
+
const r = spawnSync(process.execPath, [ENTRY_SCRIPT], {
|
|
176
|
+
env: { ...process.env, NODE_V8_COVERAGE: "/tmp/perf-gate-a2", AGENT_BOARD_PERF_GATE: "1" },
|
|
177
|
+
encoding: "utf8",
|
|
178
|
+
});
|
|
179
|
+
assert.notEqual(r.status, 0);
|
|
180
|
+
assert.match(r.stderr + r.stdout, /coverage instrumentation|NODE_V8_COVERAGE/);
|
|
181
|
+
assert.doesNotMatch(r.stdout, /burst:|paced:/);
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
186
|
+
|
|
187
|
+
Run: `node --test test/perf-gate.test.mjs`
|
|
188
|
+
Expected: the 8 truth-table tests still pass; the new test FAILS (`spawnSync` errors / nonzero status with an ENOENT-style message because `scripts/run-perf-gate.mjs` does not exist).
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 3: Write the implementation**
|
|
191
|
+
|
|
192
|
+
Create `scripts/run-perf-gate.mjs`:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
#!/usr/bin/env node
|
|
196
|
+
/**
|
|
197
|
+
* Authoritative entry for the A11 perf gate (issue #121).
|
|
198
|
+
*
|
|
199
|
+
* Loudly refuses under coverage instrumentation: c8 sets NODE_V8_COVERAGE
|
|
200
|
+
* for its whole process tree, and instrumented latency measurements are
|
|
201
|
+
* invalid (2.5–6× inflation). Silently skipping here would manufacture a
|
|
202
|
+
* fake-green gate, so refusal is a nonzero exit with an explicit message.
|
|
203
|
+
*
|
|
204
|
+
* This script is the side-effect layer only — no decision logic, no
|
|
205
|
+
* measurement. The pure gate decision lives in test-support/perf-gate.mjs
|
|
206
|
+
* and serves the test file; do not merge the layers (spec §3.2/§4).
|
|
207
|
+
*/
|
|
208
|
+
import { spawn } from "node:child_process";
|
|
209
|
+
import { fileURLToPath } from "node:url";
|
|
210
|
+
|
|
211
|
+
if (process.env.NODE_V8_COVERAGE !== undefined) {
|
|
212
|
+
console.error(
|
|
213
|
+
"run-perf-gate: refusing to measure under coverage instrumentation " +
|
|
214
|
+
"(NODE_V8_COVERAGE is set). Perf assertions are only valid without c8; " +
|
|
215
|
+
"run `npm run test:perf` directly, outside any coverage wrapper.",
|
|
216
|
+
);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const perfTest = fileURLToPath(new URL("../test/terminal-model-perf.test.mjs", import.meta.url));
|
|
221
|
+
// No --test-concurrency=1: the flag requires Node 21+ but package.json
|
|
222
|
+
// declares engines >=20, and it is a no-op while the gate runs a single
|
|
223
|
+
// file (spec D3, revised 2026-09-20). Re-add it when a second perf file
|
|
224
|
+
// lands — parallel measurement across files must stay impossible.
|
|
225
|
+
const child = spawn(
|
|
226
|
+
process.execPath,
|
|
227
|
+
["--test", perfTest],
|
|
228
|
+
{ stdio: "inherit", env: { ...process.env, AGENT_BOARD_PERF_GATE: "1" } },
|
|
229
|
+
);
|
|
230
|
+
child.on("error", (err) => {
|
|
231
|
+
console.error(`run-perf-gate: failed to spawn the perf suite: ${err.message}`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
});
|
|
234
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- [ ] **Step 4: Run the tests + a manual happy-path smoke**
|
|
238
|
+
|
|
239
|
+
Run: `node --test test/perf-gate.test.mjs` → PASS, `pass 9 / fail 0`.
|
|
240
|
+
Run: `node scripts/run-perf-gate.mjs` → exit 0, output contains `burst:` and `paced:` value lines, `pass 3 / fail 0`. (Pre-Task-3 the perf tests always measure, so this already works end to end.)
|
|
241
|
+
|
|
242
|
+
- [ ] **Step 5: Commit**
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
git add scripts/run-perf-gate.mjs test/perf-gate.test.mjs
|
|
246
|
+
git commit -m "test: add run-perf-gate entry script with instrumentation refusal (issue #121)"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
### Task 3: Wire the gate into `test/terminal-model-perf.test.mjs` (A4, first half)
|
|
252
|
+
|
|
253
|
+
**Files:**
|
|
254
|
+
- Modify: `test/terminal-model-perf.test.mjs` (imports ~L1-15, constants ~L84-87, three `test(...)` declarations at ~L89/L142/L169)
|
|
255
|
+
|
|
256
|
+
**Interfaces:**
|
|
257
|
+
- Consumes: `perfGateDecision` from Task 1.
|
|
258
|
+
- Produces: the skip behavior A4 asserts — three skipped tests with a reason pointing at `npm run test:perf` whenever the gate says no.
|
|
259
|
+
|
|
260
|
+
- [ ] **Step 1: Add the import and the gate evaluation**
|
|
261
|
+
|
|
262
|
+
After the last import line (`import { createTerminalSubscription } from "../src/core/terminal-attach-protocol.mjs";`), add:
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
import { perfGateDecision } from "../test-support/perf-gate.mjs";
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Directly below the four threshold constants (`FEED_P95_LIMIT` … `HYDRATE_LIMIT`), add:
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
// Perf assertions are opt-in (issue #121): they only measure via
|
|
272
|
+
// `npm run test:perf`. Under the default/parallel/coverage suites they skip —
|
|
273
|
+
// c8 instrumentation inflates latency ~2.5–6× and parallel contention is
|
|
274
|
+
// noise, so measuring there would decide CI on runner busy-ness, not code.
|
|
275
|
+
const GATE = perfGateDecision(process.env);
|
|
276
|
+
const PERF_SKIP = { skip: GATE.run ? false : GATE.reason };
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- [ ] **Step 2: Attach the option to all three tests**
|
|
280
|
+
|
|
281
|
+
Change each declaration — `test("A11: 750-chunk burst (60s × 12.5fps equivalent) meets feed/capture/hydrate thresholds", async () => {` becomes `test("A11: 750-chunk burst (60s × 12.5fps equivalent) meets feed/capture/hydrate thresholds", PERF_SKIP, async () => {`, and identically for the `paced stream` and `ring overflow` tests (insert `PERF_SKIP, ` as the second argument). Do not touch any assertion, threshold, or measurement code.
|
|
282
|
+
|
|
283
|
+
- [ ] **Step 3: Verify the four environment behaviors**
|
|
284
|
+
|
|
285
|
+
Run each from the worktree root:
|
|
286
|
+
|
|
287
|
+
1. `node --test test/terminal-model-perf.test.mjs` → `tests 3 / pass 0 / fail 0 / skipped 3`; each line shows `# SKIP` with a reason containing `npm run test:perf`.
|
|
288
|
+
2. `AGENT_BOARD_PERF_GATE=1 node --test test/terminal-model-perf.test.mjs` → `pass 3 / fail 0 / skipped 0`, output contains `burst:` and `paced:` lines (~7s wall).
|
|
289
|
+
3. `npx c8 node --test test/terminal-model-perf.test.mjs` → `skipped 3` (opt-in reason; instrumentation without opt-in does not change the verdict).
|
|
290
|
+
4. `AGENT_BOARD_PERF_GATE=1 npx c8 node --test test/terminal-model-perf.test.mjs` → `skipped 3` with the coverage-instrumentation refusal reason.
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 4: Verify the default suite now skips (A4 behavior half)**
|
|
293
|
+
|
|
294
|
+
Run: `npm test 2>&1 | tail -12`
|
|
295
|
+
Expected: the three A11 lines show as skipped with the reason; summary `skipped 3` (the suite has zero other skips at this HEAD); `fail 0`.
|
|
296
|
+
|
|
297
|
+
- [ ] **Step 5: Commit**
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
git add test/terminal-model-perf.test.mjs
|
|
301
|
+
git commit -m "test: wire A11 perf assertions behind the opt-in gate (issue #121)"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
### Task 4: `package.json` scripts — `test:perf` + `verify` ordering (A3, A5 package half)
|
|
307
|
+
|
|
308
|
+
**Files:**
|
|
309
|
+
- Modify: `package.json` (`scripts` block)
|
|
310
|
+
|
|
311
|
+
**Interfaces:**
|
|
312
|
+
- Consumes: `scripts/run-perf-gate.mjs` (Task 2).
|
|
313
|
+
- Produces: `"test:perf": "node scripts/run-perf-gate.mjs"`; `verify` runs perf before the parallel suite and before coverage (mirrors CI step order, spec §3.4).
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 1: Edit the scripts block**
|
|
316
|
+
|
|
317
|
+
In `package.json`, add one line and change the `verify` value:
|
|
318
|
+
|
|
319
|
+
```json
|
|
320
|
+
"test": "node --test test/*.test.mjs",
|
|
321
|
+
"test:coverage": "c8 node --test test/*.test.mjs",
|
|
322
|
+
"test:perf": "node scripts/run-perf-gate.mjs",
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```json
|
|
326
|
+
"verify": "npm run typecheck && npm run test:perf && npm test && npm run test:coverage && npm run pack:dry",
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Keep `test` / `test:coverage` byte-identical (the glob must not change — spec D2). Match the file's existing tab indentation.
|
|
330
|
+
|
|
331
|
+
- [ ] **Step 2: Verify the authoritative entry (A3)**
|
|
332
|
+
|
|
333
|
+
Run: `npm run test:perf`
|
|
334
|
+
Expected: exit 0; output contains `burst:` and `paced:` value lines; summary `pass 3 / fail 0 / skipped 0`. (Post-Task-3 this proves the script's `AGENT_BOARD_PERF_GATE=1` injection is what unlocks measurement — the layering works end to end.)
|
|
335
|
+
|
|
336
|
+
- [ ] **Step 3: Verify the loud refusal through the npm script**
|
|
337
|
+
|
|
338
|
+
Run: `npx c8 npm run test:perf`
|
|
339
|
+
Expected: nonzero exit; stderr contains the refusal message naming `NODE_V8_COVERAGE`; no `burst:`/`paced:` lines.
|
|
340
|
+
|
|
341
|
+
- [ ] **Step 4: Commit**
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
git add package.json
|
|
345
|
+
git commit -m "chore: add test:perf script and gate it into verify (issue #121)"
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
### Task 5: CI step + wiring static tests (A5)
|
|
351
|
+
|
|
352
|
+
**Files:**
|
|
353
|
+
- Modify: `.github/workflows/ci.yml` (between the Typecheck and Unit tests steps)
|
|
354
|
+
- Create: `test/perf-gate-wiring.test.mjs`
|
|
355
|
+
|
|
356
|
+
**Interfaces:**
|
|
357
|
+
- Consumes: `npm run test:perf` (Task 4).
|
|
358
|
+
- Produces: the CI step `Perf gate (serial, no coverage)`; committed static assertions that pin the wiring (spec §7: deleting the CI step must fail the suite).
|
|
359
|
+
|
|
360
|
+
- [ ] **Step 1: Write the failing wiring tests**
|
|
361
|
+
|
|
362
|
+
Create `test/perf-gate-wiring.test.mjs`:
|
|
363
|
+
|
|
364
|
+
```js
|
|
365
|
+
import assert from "node:assert/strict";
|
|
366
|
+
import { readFileSync } from "node:fs";
|
|
367
|
+
import { test } from "node:test";
|
|
368
|
+
|
|
369
|
+
// A5: the perf-gate wiring is a contract — deleting the CI step, moving it
|
|
370
|
+
// after the parallel suite, or retargeting the npm scripts must fail loudly
|
|
371
|
+
// here instead of silently un-guarding the perf assertions (spec §7).
|
|
372
|
+
const CI = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8");
|
|
373
|
+
const PKG = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
374
|
+
|
|
375
|
+
test("ci.yml: perf gate step exists and runs before Unit tests", () => {
|
|
376
|
+
const perfIdx = CI.indexOf("npm run test:perf");
|
|
377
|
+
const unitIdx = CI.indexOf("- name: Unit tests");
|
|
378
|
+
assert.ok(perfIdx > -1, "ci.yml wires `npm run test:perf`");
|
|
379
|
+
assert.ok(unitIdx > -1, "ci.yml still has the Unit tests step");
|
|
380
|
+
assert.ok(perfIdx < unitIdx, "perf gate runs before Unit tests (quietest machine window)");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("package.json: default globs unchanged, test:perf wired, verify ordered", () => {
|
|
384
|
+
assert.equal(PKG.scripts.test, "node --test test/*.test.mjs");
|
|
385
|
+
assert.equal(PKG.scripts["test:coverage"], "c8 node --test test/*.test.mjs");
|
|
386
|
+
assert.equal(PKG.scripts["test:perf"], "node scripts/run-perf-gate.mjs");
|
|
387
|
+
const order = PKG.scripts.verify.split("&&").map((s) => s.trim());
|
|
388
|
+
const idx = (needle) => order.findIndex((s) => s === needle);
|
|
389
|
+
assert.ok(idx("npm run test:perf") > idx("npm run typecheck"), "perf runs after typecheck");
|
|
390
|
+
assert.ok(idx("npm run test:perf") < idx("npm test"), "perf runs before the parallel suite");
|
|
391
|
+
assert.ok(idx("npm run test:perf") < idx("npm run test:coverage"), "perf runs before coverage");
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
396
|
+
|
|
397
|
+
Run: `node --test test/perf-gate-wiring.test.mjs`
|
|
398
|
+
Expected: the package.json test PASSES (Task 4 already satisfied it); the ci.yml test FAILS (`ci.yml wires npm run test:perf` assertion).
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 3: Add the CI step**
|
|
401
|
+
|
|
402
|
+
In `.github/workflows/ci.yml`, between `- name: Typecheck` / `run: npm run typecheck` and `- name: Unit tests`, insert (6-space step indentation, matching the file):
|
|
403
|
+
|
|
404
|
+
```yaml
|
|
405
|
+
- name: "Perf gate (serial, no coverage)"
|
|
406
|
+
run: npm run test:perf
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Resulting step order: checkout → setup-node → Install dependencies → Typecheck → **Perf gate (serial, no coverage)** → Unit tests → Coverage → Package dry-run.
|
|
410
|
+
|
|
411
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
412
|
+
|
|
413
|
+
Run: `node --test test/perf-gate-wiring.test.mjs`
|
|
414
|
+
Expected: PASS — `pass 2 / fail 0`.
|
|
415
|
+
|
|
416
|
+
- [ ] **Step 5: Commit**
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
git add .github/workflows/ci.yml test/perf-gate-wiring.test.mjs
|
|
420
|
+
git commit -m "chore(ci): run the perf gate as a dedicated serial step before unit tests (issue #121)"
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
### Task 6: Docs (README, VERIFY.md) + docs static test (A8)
|
|
426
|
+
|
|
427
|
+
**Files:**
|
|
428
|
+
- Modify: `README.md` (the `npm run verify` description line, ~L380)
|
|
429
|
+
- Modify: `VERIFY.md` (§0 "Static checks" block, ~L5-14)
|
|
430
|
+
- Modify: `test/perf-gate-wiring.test.mjs` (append the docs test)
|
|
431
|
+
|
|
432
|
+
**Interfaces:**
|
|
433
|
+
- Consumes: everything above (docs describe final behavior).
|
|
434
|
+
- Produces: doc text the A8 test pins: `npm run test:perf` must appear in both files; README must state perf assertions are opt-in/skipped by default.
|
|
435
|
+
|
|
436
|
+
- [ ] **Step 1: Write the failing docs test**
|
|
437
|
+
|
|
438
|
+
Append to `test/perf-gate-wiring.test.mjs`:
|
|
439
|
+
|
|
440
|
+
```js
|
|
441
|
+
// A8: the docs are part of the contract — if the opt-in gate disappears from
|
|
442
|
+
// README/VERIFY, the next maintainer will re-add perf assertions to the
|
|
443
|
+
// coverage path and reintroduce the flake this issue removes.
|
|
444
|
+
const README = readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
|
445
|
+
const VERIFY_MD = readFileSync(new URL("../VERIFY.md", import.meta.url), "utf8");
|
|
446
|
+
|
|
447
|
+
test("docs: README and VERIFY document the opt-in perf gate", () => {
|
|
448
|
+
assert.match(README, /npm run test:perf/, "README mentions the perf gate entry");
|
|
449
|
+
assert.match(README, /perf assertions.*(opt-in|skip)/i, "README states perf assertions are opt-in / skipped by default");
|
|
450
|
+
assert.match(VERIFY_MD, /npm run test:perf/, "VERIFY.md §0 mentions the perf gate entry");
|
|
451
|
+
});
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
455
|
+
|
|
456
|
+
Run: `node --test test/perf-gate-wiring.test.mjs`
|
|
457
|
+
Expected: the two wiring tests pass; the docs test FAILS (`README mentions the perf gate entry`).
|
|
458
|
+
|
|
459
|
+
- [ ] **Step 3: Update README**
|
|
460
|
+
|
|
461
|
+
In `README.md`, replace the verify description sentence
|
|
462
|
+
|
|
463
|
+
`npm run verify` runs typecheck, tests, coverage, and a package dry-run.
|
|
464
|
+
|
|
465
|
+
with:
|
|
466
|
+
|
|
467
|
+
`npm run verify` runs typecheck, the perf gate (`npm run test:perf`), tests, coverage, and a package dry-run. The A11 perf assertions are opt-in — they skip under `npm test` / `npm run test:coverage` and only measure via `npm run test:perf` (issue #121).
|
|
468
|
+
|
|
469
|
+
(Leave the surrounding sentences — "The same checks run in CI…" and the VERIFY.md link — untouched.)
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 4: Update VERIFY.md §0**
|
|
472
|
+
|
|
473
|
+
In `VERIFY.md`, in the §0 "Static checks" code block, insert a line between `npm run typecheck` and `npm test`:
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
npm run test:perf # expect: `burst:`/`paced:` value lines, pass 3 / skipped 0 — the ONLY path that measures perf assertions; they skip under npm test (issue #121)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
and adjust the `npm test` comment to note the three A11 perf tests now show as skipped with a reason pointing at `npm run test:perf`.
|
|
480
|
+
|
|
481
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
482
|
+
|
|
483
|
+
Run: `node --test test/perf-gate-wiring.test.mjs`
|
|
484
|
+
Expected: PASS — `pass 3 / fail 0`.
|
|
485
|
+
|
|
486
|
+
- [ ] **Step 6: Commit**
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
git add README.md VERIFY.md test/perf-gate-wiring.test.mjs
|
|
490
|
+
git commit -m "docs: document the opt-in perf gate in README and VERIFY (issue #121)"
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
### Task 7: Acceptance sweep (A3/A4/A6/A7 re-verified, ledger)
|
|
496
|
+
|
|
497
|
+
**Files:** none (verification only; the ledger goes into the PR body and an issue comment, not the repo).
|
|
498
|
+
|
|
499
|
+
- [ ] **Step 1: A1+A2+A5+A8** — `node --test test/perf-gate.test.mjs test/perf-gate-wiring.test.mjs` → all pass (12 tests).
|
|
500
|
+
- [ ] **Step 2: A3** — `npm run test:perf` → exit 0, `burst:`/`paced:` lines, `pass 3 / fail 0 / skipped 0`.
|
|
501
|
+
- [ ] **Step 3: A4** — `npm test 2>&1 | tail -15` → 3 A11 tests skipped with `npm run test:perf` reason, no perf assertion failures; `npm run test:coverage 2>&1 | tail -15` → same skip behavior; `rg -c "perfGateDecision" test/terminal-model-perf.test.mjs` → ≥2.
|
|
502
|
+
- [ ] **Step 4: A6** — `npm run test:coverage` → exit 0; record the `All files` line; assert Lines ≥ 85, Functions ≥ 80, Branches ≥ 70 (R3 baseline at HEAD 794c755: 92.46 / 91.41 / 80.07 — expect the same modulo new files).
|
|
503
|
+
- [ ] **Step 5: A7** — `npm test` three consecutive rounds; record `pass/fail/skipped` per round. Any failure is only acceptable if it lands in the known-flake ledger (#95 family, or the A5 mid-stream assertion from #122) AND is unrelated to this change's files (perf-gate / run-perf-gate / terminal-model-perf / package.json / ci.yml); otherwise it blocks.
|
|
504
|
+
- [ ] **Step 6: Full verify** — `npm run verify` → exit 0 end to end (this is the exact command the release flow uses).
|
|
505
|
+
- [ ] **Step 7: Write the acceptance ledger** — a per-ID table (A1–A8: command run + observed result; U1/U2: pending post-merge) for the PR description / issue comment.
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Self-Review
|
|
510
|
+
|
|
511
|
+
**1. Spec coverage:** D1 opt-in gate → Tasks 1+3 (A1/A4). D2 unchanged globs → Task 4 byte-identical guard + Task 5 static test (A5). D3 serial CI step → Task 5 (A5). D4 thresholds untouched → Global Constraints + Task 3 Step 2 (constants untouched). D5 instrumentation refusal → Tasks 1+2 (A1 row 4, A2). Docs → Task 6 (A8). Sweep → Task 7 (A3/A4/A6/A7). U1/U2 → explicitly post-merge, noted in header. Spec §9 migration order maps 1:1 onto Tasks 1–7. No gaps.
|
|
512
|
+
|
|
513
|
+
**2. Placeholder scan:** every code step contains full file/test content; no TBD/TODO/"add tests" without code.
|
|
514
|
+
|
|
515
|
+
**3. Type consistency:** `perfGateDecision(env)` → `{ run, reason }` used identically in Tasks 1/3; entry script path `scripts/run-perf-gate.mjs` identical in Tasks 2/4/5; `PERF_SKIP` name only used inside Task 3; test file names consistent across tasks (`test/perf-gate.test.mjs` Tasks 1-2, `test/perf-gate-wiring.test.mjs` Tasks 5-6).
|
|
516
|
+
|
|
517
|
+
**Residual risks accepted:** A7's flake-ledger clause requires judgment at sweep time; U1 stays dependent on the separate A5 mid-stream fix (spec §7 row).
|