@twinsearth/w2m-dsh-plugin 0.9.0 → 0.9.2

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 CHANGED
@@ -4,6 +4,115 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.9.2] — 2026-10-14
8
+
9
+ **The gate added in v0.8.11 pointed at a file that had never been committed, so it failed on all four CI platforms.**
10
+
11
+ ### What was broken
12
+
13
+ `verify:join` was wired in three places across two releases:
14
+
15
+ | where | when |
16
+ |---|---|
17
+ | `package.json` → `"verify:join": "node scripts/verify-join.mjs"` | v0.8.11 |
18
+ | `.github/workflows/ci.yml` → `- run: npm run verify:join` | (pushed in v0.9.1) |
19
+ | `.github/workflows/release.yml` → `- run: npm run verify:join` | (pushed in v0.9.1) |
20
+
21
+ **`scripts/verify-join.mjs` itself was never in the repository.** It existed only in the working tree that built the tarballs, so every CI platform failed with the least interesting error available:
22
+
23
+ ```
24
+ Error: Cannot find module '/home/runner/work/dsh-windows2macos/dsh-windows2macos/scripts/verify-join.mjs'
25
+ code: 'MODULE_NOT_FOUND'
26
+ ```
27
+
28
+ On `ubuntu-latest/22`, `windows-latest/22`, `windows-latest/20` and `macos-latest/22` alike. A file-existence check would have caught it; a review of the diff would not, because the diff was of files that existed.
29
+
30
+ ### How it was found
31
+
32
+ By reading the CI failure logs rather than the CI summary. The summary said *"The fleet join reaches a token (stub agent, no relay)"* failed — which reads like the check *ran and disagreed*. It had not run at all. The summary named the step; only the log said `MODULE_NOT_FOUND`.
33
+
34
+ The same class of mistake as the four files an earlier push left behind (see v0.9.1), and it is worth naming the shared root cause: **the v0.8.x tarballs were packed from a working tree, while the repository was written to by listing files individually.** A pack packs everything; an explicit list omits what the author forgets. The durable fix is to stop enumerating — but until this release the enumeration is what the tree had.
35
+
36
+ So this release does the one thing v0.9.1 should have: it diffs the working tree against the repository in **both** directions. Exactly one file was present locally and absent remotely, and it was this one.
37
+
38
+ ### Verified
39
+
40
+ - `node scripts/verify-join.mjs` — 16/16 locally, which it always was; the defect was never in the script.
41
+ - The script is now in the repository, which is the only place `verify:join` can run from: `claims.test.mjs` pins
42
+ `FORBIDDEN_IN_RELEASE = ['test', 'scripts', ...]`, so `scripts/` is deliberately **not** in `package.json#files` and
43
+ a published tarball carries no `scripts/` directory by design. `verify:join`, `pack` and `verify` are source-tree
44
+ and CI commands, not consumer commands. An earlier draft of this entry called the absent `scripts/` a packaging
45
+ defect; that was wrong, and the constraint above is why.
46
+
47
+ ## [0.9.1] — 2026-10-14
48
+
49
+ **A powered-off machine stayed `online` and selectable in every fleet view, because `online` was never derived from contact.**
50
+
51
+ ### The report
52
+
53
+ > *"Another Windows machine, clearly shut down and offline for a long time, is still online and active — not greyed out, still selectable."* And, in the same breath: *"is the fleet heartbeat not synchronised, not updated, not broadcast?"*
54
+
55
+ All three of those turned out to be one defect with three faces, and the report was exactly right. The relay's own data at the time:
56
+
57
+ | machine | `online` | `last_heartbeat_at` |
58
+ |---|---|---|
59
+ | `jp-shared` | true | `11:15:55Z` |
60
+ | `macmini` | true | `11:15:41Z` |
61
+ | **`win-desktop` (`549b9fde`)** | **true** | **`null`** |
62
+ | `ToFuture` (here) | true | `11:16:32Z` |
63
+
64
+ A machine reported `online: true` while `last_heartbeat_at` was `null` — meaning it had **never sent a heartbeat at all**. Two fields in one row contradicted each other and nothing reconciled them.
65
+
66
+ ### The cause
67
+
68
+ `device.online` was written in exactly two places, and both were the SSE stream:
69
+
70
+ ```js
71
+ device.streams === 0 && this.state.setOnline(sub.machineId, false, 'stream_closed'); // detach
72
+ this.state.setOnline(machineId, true, 'stream_open'); // attach
73
+ ```
74
+
75
+ `/v1/devices` then read the flag back:
76
+
77
+ ```js
78
+ online: device.online === true,
79
+ ```
80
+
81
+ **Nothing derived liveness from contact.** So the machine's state was really "is there a TCP connection", and a **half-open stream** — a killed process, a pulled cable, a closed lid; anything that ends the agent without a clean socket close — left `online: true` for as long as the socket survived, which is indefinitely. That is the reported machine: powered off, still listed, still a dispatch target.
82
+
83
+ The "not broadcast" half is the same cause. `peer.bye` *is* broadcast to every other machine — but it is emitted **by the detach that never came**, so no other machine was ever told either, and every machine picker in the fleet kept offering a departed host.
84
+
85
+ And the "heartbeat not synchronised" half is the third face: a heartbeat alone could not fix the flag, because the flag did not read heartbeats.
86
+
87
+ ### The fix
88
+
89
+ **Presence is now expired, next to lease expiry.** A new `sweepSilentDevices()`, driven by the relay's existing 1 s maintenance timer, declares a device silent once nothing has contacted it for `DEFAULT_SILENCE_MS`, and goes through the **existing** `setOnline(id, false, 'silent')` — which both flips the flag and emits `peer.bye`. Going through that path rather than setting the flag directly is the point: correcting only the flag would fix `/v1/devices` on the relay while every agent's own view, and therefore every picker, still showed the departed machine.
90
+
91
+ **Reads agree between sweeps.** `online` in both `deviceStatus` and `publicDevice` is now derived as well as stored, so nothing has to wait up to a second for a background timer to be told that a machine which stopped talking is gone.
92
+
93
+ **The threshold is two idle heartbeats (120 s).** An idle agent heartbeats every 60 s and a busy one every 10 s, so the slowest healthy cadence is 60 s and 120 s cannot be reached by a live machine. Deliberately *not* the 20 s lease rule (`heartbeatIntervalMs × missedHeartbeats`): that one guards a task index, where being wrong costs a duplicated execution, while this one guards a display and a target list, where being wrong costs a wasted dispatch. Same reasoning, different price. A test pins the invariant `DEFAULT_SILENCE_MS >= DEFAULT_IDLE_HEARTBEAT_MS * 2`, because a threshold at or below one beat would flap every healthy idle machine.
94
+
95
+ **`offline_reason` and `silent_ms` are now reported** (`stream_closed` | `silent` | `null`), on `/v1/devices`, in `/v1/status`, and through `w2m_stats`. "Offline" without a reason is precisely the shape that hid this: a departed machine looked identical to a quiet one, and a reader had no field to check.
96
+
97
+ ### What the tests had to be told, twice
98
+
99
+ Both mistakes are recorded in the test file because both are traps a future reader will fall into:
100
+
101
+ 1. **`RelayServer.handle` calls `touchDevice` on every authenticated request.** The first version of the wire test polled `/v1/devices` *with the token of the machine under test* — so the silent machine refreshed its own `last_seen_at` by asking, and the test read it as online. The observer must be a different device.
102
+ 2. **The threshold has to exceed the heartbeat cadence used by the test.** The first version used `silenceMs: 5_000` against a witness that heartbeated every 60 s, so the witness was legitimately swept alongside the machine under test.
103
+
104
+ A third correction was to the fix itself, and it is the subtle one: `offline_reason` originally tested `device.online !== true` **first**, which reports `stream_closed` for exactly the half-open case — the stream *is* still attached. Silence has to be tested first, or the reason names the wrong thing on the one case that matters.
105
+
106
+ ### Verified
107
+
108
+ - `test/relay.test.mjs` — **150 tests, 0 failures** (4 new: the sweep plus its broadcast, the pre-sweep read, the healthy-quiet distinction, and the threshold invariant).
109
+ - The 13 suites that touch device or fleet state — **398 tests, 0 failures**.
110
+ - `eslint .` — **0 errors**, 127 warnings.
111
+
112
+ ### Deployment note — this fix is on the wrong side of the wire
113
+
114
+ The defect lives in the **relay**, so upgrading the plugin does **not** fix a running relay: a machine running v0.9.1 still shows every other machine's stale `online` flag until the relay itself is restarted on v0.9.1. The relay at the shared default address was still serving the stale view when this was written. Restarting it is an operator action, and the relay's own operator token is not something this repository holds.
115
+
7
116
  ## [0.9.0] — 2026-10-14
8
117
 
9
118
  **Three defects that were mounted, wired, and doing nothing — and the lint gate that had stopped being able to say so.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -180,7 +180,7 @@ const MAX_STAGES = 16;
180
180
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
181
181
  * overrides it at runtime for tests and packagers.
182
182
  */
183
- const PLUGIN_VERSION = '0.9.0';
183
+ const PLUGIN_VERSION = '0.9.2';
184
184
 
185
185
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
186
186
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -4621,6 +4621,17 @@ export async function apply(ctx, config = {}) {
4621
4621
  machine_id: d?.machine_id ?? null,
4622
4622
  machine_name: d?.machine_name ?? null,
4623
4623
  online: d?.online === true,
4624
+ /**
4625
+ * v0.9.1: why a machine is not usable, when it is not.
4626
+ *
4627
+ * Passed through rather than summarised, because "offline" alone was the shape that
4628
+ * hid the reported defect: a machine powered off for hours was listed `online` and
4629
+ * selectable, and there was no field anywhere that would have said otherwise. A
4630
+ * relay older than v0.9.1 sends no such field, so this reads `null` there rather than
4631
+ * inventing a reason.
4632
+ */
4633
+ offline_reason: d?.offline_reason ?? null,
4634
+ silent_ms: d?.silent_ms ?? null,
4624
4635
  rtt_ms: d?.rtt_ms ?? null,
4625
4636
  rtt_stale: d?.rtt_stale ?? null,
4626
4637
  last_heartbeat_at: d?.last_heartbeat_at ?? null,
@@ -1086,6 +1086,18 @@ export class RelayServer {
1086
1086
  /** Periodic maintenance, called by the sweep timer. */
1087
1087
  maintenance() {
1088
1088
  const expired = this.sweep();
1089
+ /**
1090
+ * v0.9.1: presence expiry, alongside lease expiry.
1091
+ *
1092
+ * `this.sweep()` writes off lease *holds*; this writes off *presence*. They were conflated, so a
1093
+ * machine that stopped existing was only ever noticed when its SSE stream detached — and a
1094
+ * half-open stream never detaches.
1095
+ */
1096
+ const silent = this.state.sweepSilentDevices();
1097
+ if (silent.length > 0) {
1098
+ this._log(`[w2m-rabbit] marked ${silent.length} silent device(s) offline: `
1099
+ + silent.map((s) => `${s.machine_name} (${Math.round(s.silent_ms / 1000)}s)`).join(', '));
1100
+ }
1089
1101
  this.pairLimiter.prune();
1090
1102
  this.peers.sweep();
1091
1103
  // Keep the nonce cache from growing without bound in a long-running process.
@@ -121,6 +121,37 @@ export const MAX_RTT_MS = 24 * 60 * 60 * 1000;
121
121
  export const DEFAULT_RTT_STALE_MS = 180_000;
122
122
  /** v0.3.0: recommended cadence for an agent with no lease (reference for the agent side). */
123
123
  export const DEFAULT_IDLE_HEARTBEAT_MS = 60_000;
124
+
125
+ /**
126
+ * v0.9.1: how long a device may stay `online` without any contact before the relay calls it silent.
127
+ *
128
+ * ## The defect this closes
129
+ *
130
+ * `online` was written in exactly two places, both driven by the SSE stream: set true when a
131
+ * subscriber attached, false when it detached. **Nothing derived it from contact.** So a machine
132
+ * whose process died without the TCP connection closing — a half-open stream, which is what a killed
133
+ * process or a yanked network path leaves behind — stayed `online: true` in `/v1/devices`
134
+ * indefinitely, and stayed selectable as a dispatch target. Reported from a real fleet: a Windows
135
+ * machine powered off hours earlier was still listed online, with `last_heartbeat_at: null` to say it
136
+ * had never heartbeated at all.
137
+ *
138
+ * `peer.bye` did not help either, because it is emitted *by* the stream closing (`setOnline(…, false)`
139
+ * on detach). With no close there is no bye, so the machine was never removed from any other
140
+ * machine's fleet view either — which is the "not broadcast" half of the same report.
141
+ *
142
+ * ## The threshold
143
+ *
144
+ * An idle agent heartbeats every {@link DEFAULT_IDLE_HEARTBEAT_MS} (60 s), and a busy one renews its
145
+ * lease every {@link DEFAULT_HEARTBEAT_INTERVAL_MS} (10 s), so the *slowest* healthy cadence is 60 s.
146
+ * Two skipped idle beats is therefore 120 s: a live machine cannot reach it, and a dead one is
147
+ * noticed within two cycles plus the sweep tick.
148
+ *
149
+ * Deliberately longer than `heartbeatIntervalMs * missedHeartbeats` (the 20 s lease rule): that rule
150
+ * is about holding a *task index*, where being wrong costs a duplicated execution. This one is about
151
+ * a display and a target list, where being wrong costs a wasted dispatch. Same reasoning, different
152
+ * price, so not the same number.
153
+ */
154
+ export const DEFAULT_SILENCE_MS = DEFAULT_IDLE_HEARTBEAT_MS * 2;
124
155
  export const ENVELOPE_VERSION = '1.0';
125
156
  export const FINGERPRINT_ALGO = 'git-temp-index-tree/v1';
126
157
 
@@ -416,6 +447,11 @@ export class RabbitState {
416
447
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
417
448
  this.missedHeartbeats = options.missedHeartbeats ?? DEFAULT_MISSED_HEARTBEATS;
418
449
  this.leaseGraceMs = options.leaseGraceMs ?? DEFAULT_LEASE_GRACE_MS;
450
+ /**
451
+ * v0.9.1: how long an `online` device may go without contact before it is declared silent.
452
+ * See {@link DEFAULT_SILENCE_MS} for why this is not the lease number.
453
+ */
454
+ this.silenceMs = options.silenceMs ?? DEFAULT_SILENCE_MS;
419
455
  /**
420
456
  * v0.4.0: how long the relay holds its own offer back when the dispatcher says it will push the
421
457
  * offer over the direct path (`origin_machine_id` + `p2p.mode`).
@@ -1049,6 +1085,67 @@ export class RabbitState {
1049
1085
  };
1050
1086
  }
1051
1087
 
1088
+ /**
1089
+ * v0.9.1: how long since this device last made any contact, or null when it never has.
1090
+ *
1091
+ * `last_seen_at_ms` is the right field rather than `last_heartbeat_at_ms`: `touchDevice` advances it
1092
+ * on every contact the relay accepts — a heartbeat, a task poll, a lease renewal — so it measures
1093
+ * "is anything there", while `last_heartbeat_at_ms` measures "has it ever heartbeated", which is
1094
+ * null for an agent old enough not to send them.
1095
+ *
1096
+ * Falls back to `paired_at_ms` so that a device which paired but has not spoken since is judged
1097
+ * from the moment it arrived rather than from `null`, which would have read as "silent forever" and
1098
+ * marked it offline the instant it appeared.
1099
+ *
1100
+ * @param {object} device
1101
+ * @param {number} nowMs
1102
+ * @returns {number|null}
1103
+ */
1104
+ silenceAgeMs(device, nowMs = this.nowMs()) {
1105
+ const seen = device.last_seen_at_ms ?? device.last_heartbeat_at_ms ?? device.paired_at_ms ?? null;
1106
+ if (seen === null || seen === undefined) return null;
1107
+ return Math.max(0, nowMs - seen);
1108
+ }
1109
+
1110
+ /**
1111
+ * v0.9.1 §4.3 companion: declare a device silent once nothing has contacted the relay for
1112
+ * {@link DEFAULT_SILENCE_MS}.
1113
+ *
1114
+ * ## Why this exists next to `sweepExpired`
1115
+ *
1116
+ * `sweepExpired` writes off lease *holds*; this writes off *presence*. Both are expiry, and they
1117
+ * were conflated: a machine that stopped existing was only noticed when its SSE stream actually
1118
+ * detached, so a half-open stream — a killed process, a pulled cable, a laptop lid — left it
1119
+ * `online: true` and selectable for as long as the socket stayed open, which can be hours or
1120
+ * indefinitely.
1121
+ *
1122
+ * Going through {@link setOnline} is the point rather than an implementation detail: it flips the
1123
+ * flag **and emits `peer.bye`**, which is what tells every other machine in the fleet. Fixing only
1124
+ * the flag would correct `/v1/devices` on the relay while leaving every agent's own view — and
1125
+ * therefore every machine picker — still showing a departed machine.
1126
+ *
1127
+ * @param {number} [nowMs]
1128
+ * @returns {Array<{machine_id: string, machine_name: string, silent_ms: number}>} what was swept
1129
+ */
1130
+ sweepSilentDevices(nowMs = this.nowMs()) {
1131
+ const went = [];
1132
+ for (const device of this.devices.values()) {
1133
+ if (device.online !== true) continue;
1134
+ const age = this.silenceAgeMs(device, nowMs);
1135
+ if (age === null || age < this.silenceMs) continue;
1136
+ this.emit('notice', {
1137
+ level: 'info',
1138
+ code: 'DEVICE_SILENT',
1139
+ message: `machine ${device.machine_name} (${device.machine_id}) stopped making contact; marking it offline`,
1140
+ machine_id: device.machine_id,
1141
+ silent_ms: age,
1142
+ });
1143
+ this.setOnline(device.machine_id, false, 'silent');
1144
+ went.push({ machine_id: device.machine_id, machine_name: device.machine_name, silent_ms: age });
1145
+ }
1146
+ return went;
1147
+ }
1148
+
1052
1149
  /**
1053
1150
  * v0.3.0 single-machine status view.
1054
1151
  *
@@ -1056,19 +1153,37 @@ export class RabbitState {
1056
1153
  * between them is the whole point: a machine can be heartbeating (alive) while
1057
1154
  * its RTT number is old (a v0.2.3 agent that never sends `rtt_ms`), and a
1058
1155
  * machine can be gone while a plausible-looking RTT is still on file.
1156
+ *
1157
+ * v0.9.1: `online` is additionally cross-checked against silence, so a direct call to this method
1158
+ * (a test, or a caller that never runs the sweep) cannot report a departed machine as present.
1159
+ * The sweep is still what fixes the flag and broadcasts; this only refuses to repeat a stale `true`.
1059
1160
  */
1060
1161
  deviceStatus(machineId, nowMs = this.nowMs()) {
1061
1162
  const device = this.devices.get(machineId);
1062
1163
  if (!device) return null;
1063
1164
  const rttAt = device.rtt_at_ms ?? null;
1064
1165
  const lastHeartbeat = device.last_heartbeat_at_ms ?? null;
1166
+ const silentMs = this.silenceAgeMs(device, nowMs);
1167
+ const silent = device.online === true && silentMs !== null && silentMs >= this.silenceMs;
1065
1168
  return {
1066
1169
  machine_id: device.machine_id,
1067
1170
  machine_name: device.machine_name,
1068
1171
  relay_id: this.relayId,
1069
1172
  connected: (device.streams ?? 0) > 0,
1070
1173
  streams: device.streams ?? 0,
1071
- online: device.online === true,
1174
+ online: device.online === true && !silent,
1175
+ /**
1176
+ * Why this machine is not usable, when it is not. `null` for a healthy one.
1177
+ *
1178
+ * Added because "offline" without a reason is exactly the shape that made this defect hard to
1179
+ * see: a machine that had been powered off for hours was listed `online`, and a machine that is
1180
+ * correctly offline still gives no clue whether it is gone or merely quiet.
1181
+ *
1182
+ * `silent` first, for the same reason as in `publicDevice`: the stored flag is `true` in the
1183
+ * half-open case, so the silence is the finding.
1184
+ */
1185
+ offline_reason: silent ? 'silent' : (device.online !== true ? 'stream_closed' : null),
1186
+ silent_ms: silentMs,
1072
1187
  rtt_ms: device.rtt_ms ?? null,
1073
1188
  rtt_at: rttAt === null ? null : rfc3339(rttAt),
1074
1189
  rtt_age_ms: this.rttAgeMs(device, nowMs),
@@ -1089,6 +1204,9 @@ export class RabbitState {
1089
1204
 
1090
1205
  /** Public device view — never leaks device_token. */
1091
1206
  publicDevice(device) {
1207
+ // Computed once, so the three fields below cannot disagree with each other.
1208
+ const silentMs = this.silenceAgeMs(device);
1209
+ const silent = silentMs !== null && silentMs >= this.silenceMs;
1092
1210
  return {
1093
1211
  machine_id: device.machine_id,
1094
1212
  machine_name: device.machine_name,
@@ -1114,8 +1232,28 @@ export class RabbitState {
1114
1232
  // list is read by every device, and there is no reason to hand every machine every other
1115
1233
  // machine's key when the verification happens relay-side.
1116
1234
  public_key_present: typeof device.public_key === 'string' && device.public_key !== '',
1117
- online: device.online,
1235
+ /**
1236
+ * v0.9.1: `online` is derived, not just read.
1237
+ *
1238
+ * It used to be the raw stored flag, which is only ever written by the SSE stream attaching or
1239
+ * detaching. A half-open stream therefore reported `online: true` for a machine that was gone,
1240
+ * and `/v1/devices` handed that to every machine picker in the fleet. The sweep is what flips
1241
+ * the stored flag and broadcasts `peer.bye`; this expression makes the read agree with it even
1242
+ * between sweeps, so no caller can observe the stale value at all.
1243
+ */
1244
+ online: device.online === true && !silent,
1118
1245
  streams: device.streams,
1246
+ /**
1247
+ * v0.9.1: why this machine is not usable, when it is not.
1248
+ *
1249
+ * `silent` is tested FIRST, and that order is the whole subtlety: a half-open stream leaves the
1250
+ * stored `online` flag `true`, so testing `device.online !== true` first reported
1251
+ * `stream_closed` for a machine whose stream was precisely the thing still attached. The
1252
+ * reported defect in one line -- the flag says connected, the truth says gone, and the reason
1253
+ * has to name the truth.
1254
+ */
1255
+ offline_reason: silent ? 'silent' : (device.online !== true ? 'stream_closed' : null),
1256
+ silent_ms: silentMs,
1119
1257
  paired_at: rfc3339(device.paired_at_ms),
1120
1258
  last_seen_at: rfc3339(device.last_seen_at_ms),
1121
1259
  // v0.3.0: `null` means "never measured", which is NOT the same as 0ms.