done-done-axi 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lint.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // `done-done lint`: the deterministic merge gate on a worker's claim. It reads the newest "## Checks" block of a PR body
2
2
  // or report file and fails an unproven, reused or oversized claim. No judge, no model; the repo config only switches on
3
- // the two optional size rules (checks.upstream_catch_up, checks.moved_from) and extra generated paths (checks.generated_paths).
3
+ // the two optional size rules (checks.upstream_catch_up, checks.moved_from) and extra generated paths (checks.generated_paths);
4
+ // files the target repo marks generated in .gitattributes are not counted either.
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import { createHash } from 'node:crypto';
6
7
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -8,6 +9,26 @@ import { basename, dirname, isAbsolute, join, matchesGlob, resolve } from 'node:
8
9
  // Third-party, legal text and committed build output are not reviewable code.
9
10
  const GENERATED = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum|[^/]*\.lock)$|(^|\/)vendor\/|(^|\/)dist\/|(^|\/)(LICENSE|LICENSES|NOTICE|COPYING)[^/]*$/;
10
11
  const generatedBy = (globs = []) => (p) => GENERATED.test(p) || globs.some((g) => matchesGlob(p, g));
12
+ // A target repo's own .gitattributes marks generated files: linguist-generated, linguist-generated=true or -diff. The last matching line wins;
13
+ // linguist-generated=false and an unset attribute mean not generated. A pattern with no slash matches the basename, like git.
14
+ function attrGenerated(attrs) {
15
+ const rules = attrs.split('\n').map((l) => l.trim().split(/\s+/)).filter((w) => w[0] && !w[0].startsWith('#') && w.length > 1);
16
+ return (p) => {
17
+ let gen = false;
18
+ for (const [pat, ...as] of rules) {
19
+ const glob = pat.replace(/^\//, '');
20
+ if (!matchesGlob(glob.includes('/') ? p : basename(p), glob.endsWith('/') ? `${glob}**` : glob))
21
+ continue;
22
+ for (const a of as) {
23
+ if (/^(linguist-generated(=true)?|-diff)$/.test(a))
24
+ gen = true;
25
+ else if (/^(linguist-generated=false|-linguist-generated|!linguist-generated)$/.test(a))
26
+ gen = false;
27
+ }
28
+ }
29
+ return gen;
30
+ };
31
+ }
11
32
  export const SIZE_LIMIT = 1000;
12
33
  const FIX_TITLE = /^(fix|bugfix|hotfix)\b/i;
13
34
  const NOT_APPLICABLE = /^n\/?a\b/i;
@@ -70,7 +91,8 @@ function movedLines(run, nwo, body, paths, isGenerated) {
70
91
  const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
71
92
  // src: a PR URL or a report file; ev: the folder the evidence names are relative to; dir: where the hash store and log live.
72
93
  export function lint(src, ev, dir, rules = {}, run = gh) {
73
- const isGenerated = generatedBy(rules.generated_paths);
94
+ const byConfig = generatedBy(rules.generated_paths);
95
+ let isGenerated = byConfig;
74
96
  let warn = ''; // set for a PR that touches src/ with no journeys/ file; it only softens a PASS
75
97
  const log = (v) => {
76
98
  if (v.ok && warn)
@@ -95,6 +117,12 @@ export function lint(src, ev, dir, rules = {}, run = gh) {
95
117
  const author = pr.author?.login ?? '';
96
118
  if (/^(app\/)?(dependabot|renovate)(\[bot\])?$/.test(author))
97
119
  return log({ ok: true, why: 'bot', head });
120
+ const nwo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\//.exec(src)?.[1];
121
+ const attrs = nwo && [head, pr.baseRefOid].map((ref) => ref && run('api', '-H', 'Accept: application/vnd.github.raw', `repos/${nwo}/contents/.gitattributes?ref=${ref}`)).find((a) => a && a.status === 0);
122
+ if (attrs) {
123
+ const byAttr = attrGenerated(attrs.stdout);
124
+ isGenerated = (p) => byConfig(p) || byAttr(p);
125
+ }
98
126
  const paths = (pr.files ?? []).map((f) => f.path ?? '');
99
127
  if (paths.some((p) => p.startsWith('src/')) && !paths.some((p) => p.startsWith('journeys/')))
100
128
  warn = 'the diff changes src/ and no journeys/ file: run the journey, commit it, then open the PR';
@@ -103,7 +131,6 @@ export function lint(src, ev, dir, rules = {}, run = gh) {
103
131
  const d = run('pr', 'diff', src);
104
132
  if (!d.status) {
105
133
  let net = netLines(d.stdout, isGenerated);
106
- const nwo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\//.exec(src)?.[1];
107
134
  if (nwo && rules.upstream_catch_up && total(net) >= SIZE_LIMIT)
108
135
  net = without(net, upstreamDelta(run, nwo, head, pr.baseRefOid ?? '', isGenerated));
109
136
  if (nwo && rules.moved_from && total(net) >= SIZE_LIMIT)
@@ -0,0 +1,151 @@
1
+ ------------------------------ MODULE PaneAttach ------------------------------
2
+ (* Two Herdr panes, one shared tree refresh and one attach, as muxr v0.2.9 runs *)
3
+ (* them on @byokit/herdr 0.16.1. Pane A closes; pane B survives and is the pane *)
4
+ (* the user opens. Each action names the code it stands for; see README.md. *)
5
+ EXTENDS FiniteSets
6
+
7
+ CONSTANTS
8
+ Matched, \* reply-id shapes socket.js counts as a refusal of its own watch
9
+ DrainsQueue \* runBootstrap starts the queued follow-up (FALSE: broken variant)
10
+
11
+ Panes == {"A", "B"}
12
+ Callers == {"R", "U"} \* R: any other refresh caller; U: the user's attach to B
13
+ \* Herdr answers a watch on a closed pane with id "<id>:sub:0:probe" (muxr PR 1006).
14
+ RefusalShape == "subProbe"
15
+
16
+ VARIABLES
17
+ alive, \* Herdr: the pane exists
18
+ closedEvt, \* A's pane_closed event is on its way to the kit's event socket
19
+ booting, \* kit.js `booting`
20
+ queued, \* kit.js `queued`
21
+ phase, \* runBootstrap: "idle", "reading" (snapshot in flight), "acks"
22
+ waiters, \* kit.js `waiters`
23
+ nextWaiters, \* kit.js `nextWaiters`
24
+ watch, \* per-pane status watch socket
25
+ ready, \* that watch's `ready` promise
26
+ refusal, \* Herdr has refused this pane's watch (the pane is gone for good)
27
+ caller \* each refresh() caller
28
+
29
+ vars == <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters,
30
+ watch, ready, refusal, caller>>
31
+
32
+ TypeOK ==
33
+ /\ alive \in [Panes -> BOOLEAN]
34
+ /\ closedEvt \in BOOLEAN /\ booting \in BOOLEAN /\ queued \in BOOLEAN
35
+ /\ phase \in {"idle", "reading", "acks"}
36
+ /\ waiters \subseteq Callers /\ nextWaiters \subseteq Callers
37
+ /\ watch \in [Panes -> {"none", "opening", "retry", "open", "stopped"}]
38
+ /\ ready \in [Panes -> {"none", "pending", "ok", "refused"}]
39
+ /\ refusal \in [Panes -> BOOLEAN]
40
+ /\ caller \in [Callers -> {"idle", "waiting", "done"}]
41
+
42
+ \* The kit has started: its first bootstrap landed and both watches acked.
43
+ Init ==
44
+ /\ alive = [p \in Panes |-> TRUE]
45
+ /\ closedEvt = FALSE /\ booting = FALSE /\ queued = FALSE /\ phase = "idle"
46
+ /\ waiters = {} /\ nextWaiters = {}
47
+ /\ watch = [p \in Panes |-> "open"]
48
+ /\ ready = [p \in Panes |-> "ok"]
49
+ /\ refusal = [p \in Panes |-> FALSE]
50
+ /\ caller = [c \in Callers |-> "idle"]
51
+
52
+ \* runBootstrap -> bootstrap sends `session.snapshot` (kit.js:523-530, 363-371).
53
+ StartBoot == booting' = TRUE /\ phase' = "reading"
54
+
55
+ \* kit.refresh() (kit.js:694-706). U reaches it through open -> resolvePane ->
56
+ \* refreshSnapshot -> client.kit.refresh() (herdrSessionSource.ts:2606, 1878-1897, 1578-1583).
57
+ Refresh(c) ==
58
+ /\ caller[c] = "idle"
59
+ /\ caller' = [caller EXCEPT ![c] = "waiting"]
60
+ /\ IF booting
61
+ THEN /\ queued' = TRUE /\ nextWaiters' = nextWaiters \cup {c}
62
+ /\ UNCHANGED <<waiters, booting, phase>>
63
+ ELSE /\ waiters' = waiters \cup {c} /\ StartBoot
64
+ /\ UNCHANGED <<queued, nextWaiters>>
65
+ /\ UNCHANGED <<alive, closedEvt, watch, ready, refusal>>
66
+
67
+ \* Pane A closes. Herdr closes an acked watch's socket, which re-dials after 2 s
68
+ \* (socket.js:115-121); a watch still opening is answered by HerdrAnswer.
69
+ CloseA ==
70
+ /\ alive["A"]
71
+ /\ alive' = [alive EXCEPT !["A"] = FALSE]
72
+ /\ closedEvt' = TRUE
73
+ /\ watch' = IF watch["A"] = "open" THEN [watch EXCEPT !["A"] = "retry"] ELSE watch
74
+ /\ UNCHANGED <<booting, queued, phase, waiters, nextWaiters, ready, refusal, caller>>
75
+
76
+ \* A structural event queues a bootstrap, or a follow-up while one runs (kit.js:582-597, 562-568).
77
+ ClosedEventLands ==
78
+ /\ closedEvt
79
+ /\ closedEvt' = FALSE
80
+ /\ IF booting
81
+ THEN queued' = TRUE /\ UNCHANGED <<booting, phase>>
82
+ ELSE StartBoot /\ UNCHANGED queued
83
+ /\ UNCHANGED <<alive, waiters, nextWaiters, watch, ready, refusal, caller>>
84
+
85
+ \* The snapshot lands: stop the old status watches, open one per agent pane in it
86
+ \* (kit.js:409-414, 440-447; openStatusWatch kit.js:496-514).
87
+ SnapshotLands ==
88
+ /\ phase = "reading"
89
+ /\ phase' = "acks"
90
+ /\ watch' = [p \in Panes |-> IF alive[p] THEN "opening"
91
+ ELSE IF watch[p] = "none" THEN "none" ELSE "stopped"]
92
+ /\ ready' = [p \in Panes |-> IF alive[p] THEN "pending" ELSE "none"]
93
+ /\ UNCHANGED <<alive, closedEvt, booting, queued, waiters, nextWaiters, refusal, caller>>
94
+
95
+ \* Herdr answers one `events.subscribe` (socket.js:94-112). A live pane acks. A closed
96
+ \* pane is refused, then Herdr closes the socket: a matched refusal settles `ready` false
97
+ \* and stops; an unmatched one is ignored and the close handler re-dials (socket.js:115-121).
98
+ HerdrAnswer(p) ==
99
+ /\ watch[p] = "opening"
100
+ /\ IF alive[p]
101
+ THEN /\ watch' = [watch EXCEPT ![p] = "open"]
102
+ /\ ready' = IF ready[p] = "pending" THEN [ready EXCEPT ![p] = "ok"] ELSE ready
103
+ /\ UNCHANGED refusal
104
+ ELSE /\ refusal' = [refusal EXCEPT ![p] = TRUE]
105
+ /\ IF RefusalShape \in Matched
106
+ THEN /\ watch' = [watch EXCEPT ![p] = "stopped"]
107
+ /\ ready' = IF ready[p] = "pending" THEN [ready EXCEPT ![p] = "refused"] ELSE ready
108
+ ELSE /\ watch' = [watch EXCEPT ![p] = "retry"] /\ UNCHANGED ready
109
+ /\ UNCHANGED <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters, caller>>
110
+
111
+ \* later(open, backoff) dials a fresh watch with a new id (socket.js:88-92, 120-124).
112
+ Retry(p) ==
113
+ /\ watch[p] = "retry"
114
+ /\ watch' = [watch EXCEPT ![p] = "opening"]
115
+ /\ UNCHANGED <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters, ready, refusal, caller>>
116
+
117
+ \* Every watch this bootstrap opened has settled (Promise.allSettled, kit.js:446-447), so
118
+ \* runBootstrap's statusWatchReady() returns: answer the waiters and start the queued
119
+ \* follow-up for nextWaiters (kit.js:531-557). An answered attach finds B in the tree.
120
+ AcksSettle ==
121
+ /\ phase = "acks"
122
+ /\ \A p \in Panes : ready[p] # "pending"
123
+ /\ caller' = [c \in Callers |-> IF c \in waiters THEN "done" ELSE caller[c]]
124
+ /\ IF queued /\ DrainsQueue
125
+ THEN /\ queued' = FALSE /\ waiters' = nextWaiters /\ nextWaiters' = {}
126
+ /\ StartBoot
127
+ ELSE /\ waiters' = {} /\ booting' = FALSE /\ phase' = "idle"
128
+ /\ UNCHANGED <<queued, nextWaiters>>
129
+ /\ UNCHANGED <<alive, closedEvt, watch, ready, refusal>>
130
+
131
+ KitStep == ClosedEventLands \/ SnapshotLands \/ AcksSettle
132
+ \/ \E p \in Panes : HerdrAnswer(p) \/ Retry(p)
133
+
134
+ Next == KitStep \/ CloseA \/ \E c \in Callers : Refresh(c)
135
+
136
+ \* Herdr answers every request and the kit's timers fire: weak fairness on the kit and
137
+ \* Herdr's replies. Pane closes and user actions get none; they may never happen.
138
+ Spec == Init /\ [][Next]_vars
139
+ /\ WF_vars(ClosedEventLands) /\ WF_vars(SnapshotLands) /\ WF_vars(AcksSettle)
140
+ /\ \A p \in Panes : WF_vars(HerdrAnswer(p)) /\ WF_vars(Retry(p))
141
+
142
+ \* Property 1: after a final refusal, its watch settles (stops for good).
143
+ WatchSettles == \A p \in Panes : refusal[p] ~> (watch[p] = "stopped")
144
+
145
+ \* Property 2: an attach to the surviving pane B eventually answers. This model has no
146
+ \* failure path (no outage, no snapshot error), so an answer here is a success.
147
+ AttachAnswers == (caller["U"] = "waiting") ~> (caller["U"] = "done")
148
+
149
+ \* Witness: violated exactly when some run opens B, so the properties are not vacuous.
150
+ NeverAttached == caller["U"] # "done"
151
+ =============================================================================
@@ -0,0 +1,109 @@
1
+ # Pane-attach race: a statechart pilot
2
+
3
+ A small statechart and an executable model of one real bug: in muxr v0.2.9, a pane that closes before its status watch opens leaves the shared Herdr tree read waiting forever, so opening a pane that is still alive hangs. A pinned model checker finds that path on the old code, and the path is written below as the steps a user takes.
4
+
5
+ **This is supporting evidence only. It is not proof that muxr works or fails.** Real-product replay: **not checked**. No Herdr lifecycle command was run and muxr was not touched. The journey entry that a real replay would follow is [pane-attach-race.md](pane-attach-race.md). It is written in the format of muxr's project skill and is waiting for an isolated Herdr lab.
6
+
7
+ Files: [PaneAttach.tla](PaneAttach.tla) (model and properties), [check.mjs](check.mjs) (repeatable check), [pane-attach-race.md](pane-attach-race.md) (project-skill journey entry).
8
+
9
+ ## The code it models
10
+
11
+ | Model action | Real code (muxr tag `v0.2.9`, `@byokit/herdr` 0.16.1 as published on npm) |
12
+ |---|---|
13
+ | `Refresh(U)`: the user opens pane B | `open` → `resolvePane` → `refreshSnapshot` → `client.kit.refresh()` (`apps/host/src/agent/infrastructure/herdrSessionSource.ts:2606, 1878-1897, 1578-1583`) |
14
+ | `Refresh(c)` joins a running read or starts one | `HerdrKit.refresh()` (`dist/kit.js:694-706`) |
15
+ | `StartBoot` / `SnapshotLands` | `runBootstrap` → `bootstrap` sends `session.snapshot`, stops the old status watches and opens one per agent pane (`dist/kit.js:523-530, 363-371, 409-414, 440-447`; `openStatusWatch` `dist/kit.js:496-514`) |
16
+ | `CloseA`, then `ClosedEventLands` | a structural event queues a bootstrap, or a follow-up while one runs (`dist/kit.js:582-597, 562-568`); Herdr closing an acked watch's socket re-dials it (`dist/socket.js:115-121`) |
17
+ | `HerdrAnswer(p)` | the watch's frame handler. Only an error whose id is `''` or exactly the watch's id counts as a refusal (`dist/socket.js:94-112`). Herdr refuses a watch on a closed pane with id `<id>:sub:0:probe` (the record in muxr PR 1006) |
18
+ | `Retry(p)` | `later(open, backoff)` dials a new watch with a new id (`dist/socket.js:88-92, 120-124`) |
19
+ | `AcksSettle` | `Promise.allSettled` over the watches' `ready` promises; `runBootstrap` then answers its waiters and starts the queued follow-up (`dist/kit.js:446-447, 531-557`) |
20
+
21
+ Source identity: muxr `v0.2.9` is commit `3b8321f897f984ee1bf186177bf89ebfd812de2d`, whose `apps/host/package.json` pins `@byokit/herdr` `0.16.1`. The kit tarball `herdr-0.16.1.tgz` has sha256 `407fc6fce982fd61e5f1a77f60b0516c51ef6bf6963998f8ac80330d6bc2f2c9` (gitHead `39be7f88b7b13b7a9f32fbc1a1d74567016198b0`). Its `dist/socket.js` has sha256 `525d439da77312880dc170f9bf8f186bb0a295fb3d7210372ab19eda44700a90` and its `dist/kit.js` has sha256 `e4e6eb848e3043e97f4c95c485390a963879df67feb7f57a3f188828960f8597`.
22
+
23
+ ## Statechart
24
+
25
+ One tree read in the kit:
26
+
27
+ ```mermaid
28
+ stateDiagram-v2
29
+ [*] --> Idle
30
+ Idle --> Reading: refresh() or a pane event
31
+ Reading --> Reading: refresh() while reading, queued as a follow-up
32
+ Reading --> AwaitingWatches: snapshot lands, one watch opened per agent pane
33
+ AwaitingWatches --> Idle: every watch settled, waiters answered
34
+ AwaitingWatches --> Reading: every watch settled, queued follow-up starts
35
+ ```
36
+
37
+ One pane's status watch:
38
+
39
+ ```mermaid
40
+ stateDiagram-v2
41
+ [*] --> Opening
42
+ Opening --> Open: Herdr accepts (pane alive), ready = true
43
+ Opening --> Stopped: refusal with an empty id or the watch's own id, ready = false
44
+ Opening --> Redial: refusal with id ...sub:0:probe, ignored in 0.16.1
45
+ Open --> Redial: Herdr closes the socket (pane closed)
46
+ Redial --> Opening: re-dial with a new id
47
+ Open --> Stopped: the next snapshot stops old watches
48
+ ```
49
+
50
+ The bad loop is `Opening → Redial → Opening`, entered while the tree read waits in `AwaitingWatches`. The watch's `ready` never settles, so the tree read never leaves `AwaitingWatches`. Every `refresh()` waits behind it, and so does every pane open.
51
+
52
+ ## Properties
53
+
54
+ 1. **After a final refusal, its watch settles** (`WatchSettles`): once Herdr refuses a pane's watch, which happens only after the pane has closed for good, that watch eventually stops.
55
+ 2. **Opening a surviving pane eventually answers** (`AttachAnswers`): once the user opens pane B, the open eventually finishes. This model has no outage or snapshot error, so the only answer is success. Explicit failure paths are outside its scope.
56
+
57
+ `NeverAttached` is a witness. It is violated exactly when some run opens B, which shows the properties are not passing trivially.
58
+
59
+ ## Results
60
+
61
+ Run on 22 October 2026 with `node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>`, which exited 0:
62
+
63
+ | Run | Kit behaviour | Checks | Expected | Got | Distinct states |
64
+ |---|---|---|---|---|---|
65
+ | `old-watch` | 0.16.1 matcher (`''` or `<id>`) | `WatchSettles` | violated | violated | 460 |
66
+ | `old-attach` | 0.16.1 matcher | `AttachAnswers` | violated | violated | 460 |
67
+ | `old-witness` | 0.16.1 matcher | `NeverAttached` | violated (B can open) | violated | 97 |
68
+ | `fixed` | also matches `<id>:` ids (the kit fix named in PR 1006) | both | hold | hold | 399 |
69
+ | `fixed-witness` | corrected matcher | `NeverAttached` | violated (B can open) | violated | 93 |
70
+ | `broken-attach` | corrected matcher, but `runBootstrap` never starts the queued follow-up | `AttachAnswers` | violated | violated | 435 |
71
+ | `broken-watch` | the same broken variant | `WatchSettles` | holds | holds | 435 |
72
+
73
+ The broken variant is caught by the attach property, which was not written for it. The watch property still holds for that variant, so the two properties check different things.
74
+
75
+ ### The counterexample, as user steps (`old-attach`)
76
+
77
+ 1. Umer opens agent B on the phone. The host asks the kit to re-read the Herdr tree.
78
+ 2. Something else re-reads the tree at the same moment, such as another open. It queues behind the first read.
79
+ 3. The tree comes back listing agents A and B. The kit opens a status watch for each and waits for both.
80
+ 4. Agent A's pane closes before Herdr has accepted A's watch.
81
+ 5. Herdr accepts B's watch.
82
+ 6. Herdr refuses A's watch with id `<id>:sub:0:probe` and closes the socket. The kit does not see that id as a refusal, logs nothing, and dials A's watch again.
83
+ 7. Each new dial gets the same refusal, forever.
84
+
85
+ What Umer sees: B stays on "connecting" even though B is alive. Every later open queues behind the same tree read. Only stopping the kit (a host restart) rejects the waiting opens. `old-watch` reaches the same loop one read later: the first tree read lands, and the queued follow-up read is the one that hangs.
86
+
87
+ ## Model report
88
+
89
+ - Checker: TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802), from [tla2tools.jar v1.7.4](https://github.com/tlaplus/tlaplus/releases/tag/v1.7.4). Its sha256 is `936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88` and its sha1 `bee4a54f3ee3d4afc347c3240ec2d9e93b075104` matches the release notes. `check.mjs` refuses any other jar. It ran on OpenJDK 21.0.2 with `-workers 1 -deadlock`: v1.7.4's liveness check is only sound with one worker (tlaplus issue 971), and finished runs are allowed to stop.
90
+ - Model `PaneAttach.tla` sha256: `47caab3c3d5eb0fe34841ae841e93cafb51873b67e861fdd1d1e63aee3e7e739`.
91
+ - Properties (sha256 of each definition's text): `WatchSettles` `7b79e33aa012dad92414f12b13df535f87fb3ee4facee03b6b07d1695cf373a0`, `AttachAnswers` `40e9a75d27e47f467113ddc84428528769d192a80c73fa112891eb5e8b0da678`, `NeverAttached` `ab58eddaa46974dcbc9273bc1698e06255a1e04c74f44c5f22e501e1b71f122c`.
92
+ - Source commit: muxr `3b8321f897f984ee1bf186177bf89ebfd812de2d` (`v0.2.9`) with `@byokit/herdr` 0.16.1 (identity above).
93
+ - Bounds: two panes (A may close, B never closes), one other refresh caller and one open of B, each made once. Every open and dial of a watch is modelled, with no retry cap, because the watch loop is finite in state. A full search finds every reachable state; no depth limit is used.
94
+ - Assumptions:
95
+ - Herdr answers every request and the kit's timers fire. This is weak fairness on the kit and Herdr steps. The refusal is a delivered answer, not a lost packet, so a network that never answers is not mistaken for this bug.
96
+ - Closing a pane and the user's actions get no fairness; they may never happen.
97
+ - A closed pane never comes back.
98
+ - A snapshot shows the panes alive when it lands. A pane closing between Herdr's read and the reply landing is the same race as closing just after it lands.
99
+ - The `<id>:sub:0:probe` refusal shape comes from the record in muxr PR 1006, not from a Herdr run here.
100
+ - Not modelled: Herdr outages, snapshot errors, the event socket reconnecting, `kit.stop()`, and the 20-second refresh bound and kit restart that PR 1006 adds to the host. Whether that host recovery repairs the property is a separate check and is not claimed here.
101
+
102
+ ## Rerun it
103
+
104
+ ```bash
105
+ curl -fLo tla2tools.jar https://github.com/tlaplus/tlaplus/releases/download/v1.7.4/tla2tools.jar
106
+ node docs/examples/statechart-pilot/check.mjs tla2tools.jar <out-dir>
107
+ ```
108
+
109
+ Each run's TLC transcript, counterexamples included, and `model-report.json` with every hash land in `<out-dir>`. The command exits 1 when any run differs from the expected result above. When the kit or `herdrSessionSource.ts` changes, re-read the cited lines, update the model to match the code, and rerun. If the code no longer matches a row in the code-map table, the model is stale and its results do not apply.
@@ -0,0 +1,72 @@
1
+ // Model check for the pane-attach race pilot: runs the pinned TLC on PaneAttach.tla for the old kit, the corrected
2
+ // kit, a deliberately broken variant and two reachability witnesses, and fails unless each gives its expected result.
3
+ // Usage: node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>. Needs java on PATH; writes one TLC
4
+ // transcript per run and model-report.json into <out-dir>. This is supporting evidence, never real-product proof.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ // tla2tools.jar from https://github.com/tlaplus/tlaplus/releases/tag/v1.7.4 (sha1 bee4a54f… as its release notes list).
13
+ const TLC_SHA256 = '936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88';
14
+ const OLD = '{"exact", "empty"}'; // socket.js:95: frame.id === '' || frame.id === id
15
+ const FIXED = '{"exact", "empty", "subProbe"}'; // the kit fix PR 1006 names: match "<id>:" ids too
16
+ const RUNS = [
17
+ { name: 'old-watch', matched: OLD, drains: 'TRUE', check: 'PROPERTY WatchSettles', expect: 'violated' },
18
+ { name: 'old-attach', matched: OLD, drains: 'TRUE', check: 'PROPERTY AttachAnswers', expect: 'violated' },
19
+ { name: 'old-witness', matched: OLD, drains: 'TRUE', check: 'INVARIANT NeverAttached', expect: 'violated' },
20
+ { name: 'fixed', matched: FIXED, drains: 'TRUE', check: 'PROPERTY WatchSettles AttachAnswers', expect: 'holds' },
21
+ { name: 'fixed-witness', matched: FIXED, drains: 'TRUE', check: 'INVARIANT NeverAttached', expect: 'violated' },
22
+ // Broken variant: the corrected kit, but runBootstrap never starts the queued follow-up (kit.js:550-555).
23
+ { name: 'broken-attach', matched: FIXED, drains: 'FALSE', check: 'PROPERTY AttachAnswers', expect: 'violated' },
24
+ { name: 'broken-watch', matched: FIXED, drains: 'FALSE', check: 'PROPERTY WatchSettles', expect: 'holds' },
25
+ ];
26
+
27
+ const [jar, outArg] = process.argv.slice(2);
28
+ if (!jar || !outArg) {
29
+ console.error('usage: node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>');
30
+ process.exit(2);
31
+ }
32
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
33
+ const jarHash = sha256(readFileSync(jar));
34
+ if (jarHash !== TLC_SHA256) {
35
+ console.error(`check: ${jar} has sha256 ${jarHash}, not the pinned TLC v1.7.4 ${TLC_SHA256}`);
36
+ process.exit(2);
37
+ }
38
+ const here = dirname(fileURLToPath(import.meta.url));
39
+ const model = readFileSync(join(here, 'PaneAttach.tla'), 'utf8');
40
+ const definition = (name) => model.match(new RegExp(`^${name} ==.*(?:\\n[ \\t].*)*`, 'm'))[0];
41
+ const out = resolve(outArg);
42
+ mkdirSync(out, { recursive: true });
43
+ const work = mkdtempSync(join(tmpdir(), 'dd-paneattach-'));
44
+ const report = {
45
+ tool: '', java: spawnSync('java', ['-version'], { encoding: 'utf8' }).stderr.split('\n')[0], tla2tools_sha256: jarHash,
46
+ model_sha256: sha256(model),
47
+ properties: Object.fromEntries(['WatchSettles', 'AttachAnswers', 'NeverAttached'].map((p) => [p, sha256(definition(p))])),
48
+ runs: [],
49
+ };
50
+ let failed = false;
51
+ try {
52
+ copyFileSync(join(here, 'PaneAttach.tla'), join(work, 'PaneAttach.tla'));
53
+ for (const run of RUNS) {
54
+ const cfg = `SPECIFICATION Spec\nCONSTANTS\n Matched = ${run.matched}\n DrainsQueue = ${run.drains}\nINVARIANT TypeOK\n${run.check}\n`;
55
+ writeFileSync(join(work, `${run.name}.cfg`), cfg);
56
+ // One worker: TLC 1.7.4 liveness checking is only sound single-threaded (tlaplus issue 971).
57
+ const r = spawnSync('java', ['-cp', resolve(jar), 'tlc2.TLC', '-workers', '1', '-deadlock', '-cleanup',
58
+ '-metadir', join(work, `${run.name}.states`), '-config', `${run.name}.cfg`, 'PaneAttach.tla'], { cwd: work, encoding: 'utf8' });
59
+ const text = r.stdout + r.stderr;
60
+ writeFileSync(join(out, `${run.name}.txt`), `${cfg}\n${text}`);
61
+ report.tool ||= text.match(/TLC2 Version [^\n]*/)?.[0] ?? '';
62
+ const got = /No error has been found/.test(text) ? 'holds' : /(Temporal properties were violated|Invariant \w+ is violated)/.test(text) ? 'violated' : 'error';
63
+ const states = text.match(/(\d+) distinct states found/)?.[1];
64
+ report.runs.push({ ...run, cfg_sha256: sha256(cfg), got, distinct_states: Number(states), exit: r.status });
65
+ console.log(`${got === run.expect ? 'ok ' : 'FAIL'} ${run.name}: expected ${run.expect}, got ${got} (${states} distinct states)`);
66
+ if (got !== run.expect) failed = true;
67
+ }
68
+ } finally {
69
+ rmSync(work, { recursive: true, force: true });
70
+ }
71
+ writeFileSync(join(out, 'model-report.json'), `${JSON.stringify(report, null, 2)}\n`);
72
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,37 @@
1
+ # A pane that closes before its watch opens never stops other panes opening
2
+
3
+ A journey entry in the format of muxr's project skill (`.agents/skills/done-done-verify-pockit/features/`). It turns the model checker's counterexample in [README.md](README.md) into a journey on the real product. The entry lives here until muxr's verification owner replays it in an isolated Herdr lab and moves it into muxr's skill.
4
+
5
+ **Real-product replay: not checked.** The model results are supporting evidence only. They never count as proof that this journey passes or fails.
6
+
7
+ ## Sub-features
8
+
9
+ - While a tree read waits on the status watches, an agent pane closes before Herdr accepts its watch. Opening a different, live agent still finishes: the terminal opens, or the phone shows an explicit error. It never stays on "connecting".
10
+ - Later opens after that moment answer at normal speed, without a host restart.
11
+
12
+ ## How to get to it (user POV)
13
+
14
+ - On a busy computer, agents come and go. Open any live agent on the phone right as another agent finishes. The terminal opens.
15
+
16
+ ## Driving it with the private stack
17
+
18
+ Needs a separately authorized, isolated Herdr lab session. Never drive the captain's live Herdr.
19
+
20
+ 1. Start a lab host against a named lab Herdr session with two agent panes, A and B, and pair a lab phone client (Umer).
21
+ 2. Hold A's `pane.agent_status_changed` subscribe at a socket proxy so the kit's tree read waits for A's watch, then close pane A. This is step 4 of the counterexample.
22
+ 3. Release the subscribe. Herdr answers `{"id":"<id>:sub:0:probe","error":{"code":"pane_not_found"}}` and closes the socket.
23
+ 4. Open B from the phone, type a short line, and read it back in the terminal.
24
+ 5. Open B a second time.
25
+
26
+ Pass: steps 4 and 5 each open the terminal and show the typed line, or show an explicit failure on the phone, within 60 seconds. Fail: either open is still on "connecting" after 60 seconds.
27
+
28
+ PR 1006's `scripts/diagnostics/application/checkHerdrVanishedWatch.mjs` already holds a subscribe at a proxy, according to that PR's record. It checks the host client's tree read, not the phone, so it covers steps 1-3 only.
29
+
30
+ ## Evidence
31
+
32
+ - A phone screenshot after step 4 and after step 5.
33
+ - The host journal for the run, plus a proxy log showing A's refusal frame and its id.
34
+ - `steps.json` naming the muxr version, the starting state (two agents, A held), the steps above and what Umer sees at the end.
35
+ - Kept separately as supporting evidence: the model report from `node docs/examples/statechart-pilot/check.mjs` in done-done, at the muxr and kit commits that were driven.
36
+
37
+ On muxr v0.2.9 with `@byokit/herdr` 0.16.1, the model predicts step 4 hangs. With the kit's refusal-id fix, the model predicts both opens answer. Neither prediction has been checked on the real product.
package/docs/reference.md CHANGED
@@ -238,6 +238,7 @@ journeys:
238
238
  - `checks.upstream_catch_up: true`: in a fork, lines the parent repo already shipped are not authored here, so lint subtracts the changes between the PR's base and the merge base with the parent's default branch. A PR that merges in no new upstream commit loses nothing.
239
239
  - `checks.moved_from: true`: a line in the PR body `moved-from: <owner>/<repo>@<ref> <dst-prefix>=<src-prefix>` (more `dst=src` pairs may follow) cancels added lines that match, byte for byte after trimming, the file at the same path under `<src-prefix>` in that repo at `<ref>`. The source repo must have the PR's owner; authored lines still count.
240
240
  - `checks.generated_paths: ["gen/**", "*.snap"]`: globs for the repo's own generated files, counted as generated beside the built-in ones above, so they stay out of the size count. Default none, so results do not change. Like the two rules above it applies to a PR URL, never a report file.
241
+ - `.gitattributes` in the target repo: a file it marks `linguist-generated`, `linguist-generated=true` or `-diff` is counted as generated too, with no config. `linguist-generated=false` and an unset attribute are not; the last matching line wins. `lint` reads the file at the PR head (else its base) and, like the rules above, only for a PR URL.
241
242
 
242
243
  ### Base-ref before
243
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "done-done-axi",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Proof that the real product works before a task is called done: commit-bound evidence runs and evidence checks.",
6
6
  "type": "module",