@twinsearth/w2m-dsh-plugin 0.13.1 → 0.15.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 +94 -0
- package/package.json +6 -4
- package/src/agent/fleet-scale.mjs +204 -0
- package/src/agent/fleet-state.mjs +167 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,100 @@ 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.15.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**The fleet is three machines, not five — and two relay endpoints disagree about it.**
|
|
10
|
+
|
|
11
|
+
### The measurement, which is the whole point of this release
|
|
12
|
+
|
|
13
|
+
`w2m_stats` and `w2m_devices` both answer "is this machine usable?" and **they do not agree**:
|
|
14
|
+
|
|
15
|
+
| Machine | `w2m_devices` | `w2m_stats` | silent for |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| jp-shared | online | online, RTT **6 ms** | 2.4 s |
|
|
18
|
+
| macmini | online | online, RTT **538 ms** | 4.4 s |
|
|
19
|
+
| ToFuture | online | online, RTT **122 ms** | 0 s |
|
|
20
|
+
| win-desktop ×2 | **online** | **offline, `silent`** | **61 384 s ≈ 17 h** |
|
|
21
|
+
|
|
22
|
+
**A fleet whose size is reported two ways is a fleet whose plans are wrong by a factor of 1.7** — and the
|
|
23
|
+
failure is silent, because both answers look authoritative. `src/agent/fleet-state.mjs` prefers `w2m_stats`
|
|
24
|
+
(it is the one carrying a *reason*), treats a listing without a heartbeat age as unverified, and
|
|
25
|
+
`reconcile()` reports **every machine the two endpoints disagree about** rather than picking one and staying
|
|
26
|
+
quiet.
|
|
27
|
+
|
|
28
|
+
### The second divergence, which would have flattered the fleet
|
|
29
|
+
|
|
30
|
+
`w2m_history` lists a `lease_states` map per task. **It is not the execution outcome.** Of forty tasks in the
|
|
31
|
+
sample, every one recorded macmini as `done` — while the session's own result envelopes included several
|
|
32
|
+
`expired` with no exit code at all. The map records the *current* lease state.
|
|
33
|
+
|
|
34
|
+
`historyIsOutcomeRecord()` returns `false` unconditionally with the reason, and its suite asserts on the
|
|
35
|
+
measured payload. The finding matters because of its direction: **a reliability measurement built on the
|
|
36
|
+
history listing would have shown a perfectly reliable fleet** — the most dangerous possible error in a module
|
|
37
|
+
whose whole purpose is to notice trouble.
|
|
38
|
+
|
|
39
|
+
The number to plan against is therefore **three usable machines**, and `readStats()` takes it from the
|
|
40
|
+
per-machine entries rather than from `counts.online`, while still reporting the relay's own claim rather than
|
|
41
|
+
hiding it.
|
|
42
|
+
|
|
43
|
+
### A fixture typo that invented a disagreement
|
|
44
|
+
|
|
45
|
+
Two tests failed because a machine id in a fixture was written `tob` where the payload says `tof` — so the
|
|
46
|
+
reconciler correctly reported a *third* machine claimed online but not usable. **The code was right and the
|
|
47
|
+
fixture was wrong**, which is the third time in this project that a failing test was the test's fault; the
|
|
48
|
+
argument for testing refusals is that they fail loudly enough to be told apart.
|
|
49
|
+
|
|
50
|
+
**16 tests, 0 failures.**
|
|
51
|
+
|
|
52
|
+
## [0.14.0] — 2026-10-15
|
|
53
|
+
|
|
54
|
+
**Fleet scale: the arithmetic the observations support, and the refusals where they support nothing.**
|
|
55
|
+
|
|
56
|
+
ROADMAP v4.11.0 names scale, defence and integration and deliberately does not plan them, on the grounds that
|
|
57
|
+
**a design that has not been measured at 50 cannot honestly specify 1,000**. This release does not specify
|
|
58
|
+
1,000. It provides the module that computes what a fleet's own task history can support, and refuses the rest.
|
|
59
|
+
|
|
60
|
+
### The limit that bounds everything, stated first
|
|
61
|
+
|
|
62
|
+
**This fleet has five machines.** No amount of cleverness turns five into fifty, and the number the ROADMAP
|
|
63
|
+
actually asks about — the agent count at which coordination collapses — is exactly the number this fleet
|
|
64
|
+
cannot observe. So `scaleOf()` returns `can_answer_collapse_point: false` as a **field**, not as a footnote,
|
|
65
|
+
and `extrapolate(5, 50)` refuses with a reason rather than producing a scaled number.
|
|
66
|
+
|
|
67
|
+
The refusal is the feature. A module that emitted a "scaling curve" from five points would be producing
|
|
68
|
+
**fiction with a decimal point on it**, which is worse than silence because it is quotable.
|
|
69
|
+
|
|
70
|
+
### What the fleet can answer, from `w2m_history` and `w2m_report`
|
|
71
|
+
|
|
72
|
+
| Measure | Why it is the one that matters |
|
|
73
|
+
|---|---|
|
|
74
|
+
| **lease expiry rate** | An expired lease is work that was assigned and **silently did not happen**. This is the coordination failure. |
|
|
75
|
+
| **refusal rate** | Counted separately, because a refusal is *the request* being wrong, not the fleet. Merging them points the reader at the wrong machine. |
|
|
76
|
+
| **transport mix** | Every task carried by the relay while a direct path existed is avoidable latency. |
|
|
77
|
+
| **fan-out vs machines online** | Participation against opportunity. |
|
|
78
|
+
|
|
79
|
+
### The refusals, which are most of the suite
|
|
80
|
+
|
|
81
|
+
`0/0` is the most misleading number this module could produce — it reads as a perfectly coordinated empty
|
|
82
|
+
fleet. So `expiry_rate`, `refusal_rate`, `direct_share` and `overhead_per_agent` all return **`null`** for an
|
|
83
|
+
empty denominator, and `overhead()` says *"the overhead of zero agents is not zero, it is undefined"*.
|
|
84
|
+
|
|
85
|
+
`ceilingFrom()` states one thing and denies another: *"at a fan-out of N, the lease expiry rate was X"*, with
|
|
86
|
+
`is_a_ceiling: false` and a note that a ceiling would need the rate measured **at several fan-outs and
|
|
87
|
+
trending**. That is the only honest form of the answer available.
|
|
88
|
+
|
|
89
|
+
Three defects found by the suite, all mine:
|
|
90
|
+
|
|
91
|
+
* the boundary rule contradicted its own comment — the test said *"at least a tenth"* while the code required
|
|
92
|
+
*more than* a tenth. The boundary is now exclusive, deliberately: at exactly 10% a refusal costs nothing and
|
|
93
|
+
an estimate costs credibility.
|
|
94
|
+
* a test expected a `null` entry in a machine list to be dropped. It should not be: a machine listed with no
|
|
95
|
+
result **is an unknown outcome**, which is a real thing a relay response says.
|
|
96
|
+
* the same two mistakes, in the same file, in the same run — which is the argument for testing the refusals
|
|
97
|
+
rather than only the arithmetic.
|
|
98
|
+
|
|
99
|
+
**19 tests, 0 failures.**
|
|
100
|
+
|
|
7
101
|
## [0.13.1] — 2026-10-15
|
|
8
102
|
|
|
9
103
|
**The macOS skip is removed, because the failure it was hiding did not exist.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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",
|
|
@@ -113,13 +113,13 @@
|
|
|
113
113
|
}
|
|
114
114
|
},
|
|
115
115
|
"scripts": {
|
|
116
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
116
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
117
117
|
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
|
|
118
118
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
119
119
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
120
120
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
121
121
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
122
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
122
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
123
123
|
"pack": "node scripts/pack.mjs",
|
|
124
124
|
"verify:join": "node scripts/verify-join.mjs",
|
|
125
125
|
"lint": "eslint .",
|
|
@@ -140,7 +140,9 @@
|
|
|
140
140
|
"test:layers": "node --test --test-force-exit test/layer-model.test.mjs",
|
|
141
141
|
"test:log-bounds": "node --test --test-force-exit test/log-bounds.test.mjs",
|
|
142
142
|
"layer-check": "node scripts/layer-check.mjs",
|
|
143
|
-
"test:test-report": "node --test --test-force-exit test/test-report.test.mjs"
|
|
143
|
+
"test:test-report": "node --test --test-force-exit test/test-report.test.mjs",
|
|
144
|
+
"test:fleet-scale": "node --test --test-force-exit test/fleet-scale.test.mjs",
|
|
145
|
+
"test:fleet-state": "node --test --test-force-exit test/fleet-state.test.mjs"
|
|
144
146
|
},
|
|
145
147
|
"keywords": [
|
|
146
148
|
"dsh-bundle",
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet scale: what this fleet can measure about itself, and what it cannot.
|
|
3
|
+
*
|
|
4
|
+
* ## What the ROADMAP refuses, and why this module agrees with it
|
|
5
|
+
*
|
|
6
|
+
* ROADMAP §6.4 reports 30–50 agents as the practical limit of current frameworks and describes superlinear
|
|
7
|
+
* "coordination collapse" beyond 100. The roadmap's own conclusion is that **a design that has not been
|
|
8
|
+
* measured at 50 cannot honestly specify 1,000**. This module does not specify 1,000. It computes what the
|
|
9
|
+
* observations support and says where they stop supporting anything.
|
|
10
|
+
*
|
|
11
|
+
* ## The hard limit on this measurement, stated first because it bounds everything below
|
|
12
|
+
*
|
|
13
|
+
* **This fleet has five machines.** No amount of cleverness turns five into fifty, and the number that
|
|
14
|
+
* matters for the ROADMAP's question — the agent count at which coordination collapses — is *exactly* the
|
|
15
|
+
* number this fleet cannot observe. Every figure derived here is a measurement of **five**, and every
|
|
16
|
+
* extrapolation is labelled as one. A module that quietly produced a "scaling curve" from five points would
|
|
17
|
+
* be producing fiction with a decimal point on it.
|
|
18
|
+
*
|
|
19
|
+
* So the honest deliverable is narrower and still useful: the fleet's **own** coordination overhead and
|
|
20
|
+
* failure rates, measured, with the extrapolation performed only where the arithmetic is defensible and
|
|
21
|
+
* refused where it is not.
|
|
22
|
+
*
|
|
23
|
+
* ## What the fleet can actually answer
|
|
24
|
+
*
|
|
25
|
+
* `w2m_history` and `w2m_report` provide, per task: mode, every lease's state, its outcome, its exit code, and
|
|
26
|
+
* the transport it used. That is enough to measure:
|
|
27
|
+
*
|
|
28
|
+
* * **lease expiry rate** — the fraction of leases that never reported. This is the coordination failure
|
|
29
|
+
* that matters, because an expired lease is work that was assigned and silently did not happen.
|
|
30
|
+
* * **refusal rate** — leases the allow-list rejected. Not a failure of the fleet but of the request.
|
|
31
|
+
* * **transport mix** — relay versus direct. Every task carried by the relay while a direct path existed is
|
|
32
|
+
* avoidable latency, and this fleet has never once established a direct path.
|
|
33
|
+
* * **fan-out efficiency** — machines that participated against machines that were online.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Lease states as the relay reports them, grouped by what they mean for the fleet. */
|
|
37
|
+
export const LEASE_MEANING = Object.freeze({
|
|
38
|
+
done: 'work that happened',
|
|
39
|
+
expired: 'work that was assigned and silently did not happen',
|
|
40
|
+
refused: 'work the allow-list rejected -- a defect in the request, not the fleet',
|
|
41
|
+
cancelled: 'work the operator stopped',
|
|
42
|
+
observing: 'a machine that was told the outcome without being asked to act',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Outcomes that count as coordination failure, because the work did not happen. */
|
|
46
|
+
export const FAILURE_STATES = Object.freeze(['expired', 'timeout']);
|
|
47
|
+
|
|
48
|
+
/** Outcomes that are not the fleet's fault, so they are counted separately. */
|
|
49
|
+
export const REQUEST_FAULT_STATES = Object.freeze(['refused']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Summarise a set of task reports.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<{task_id?: string, mode?: string, counts?: object, machines?: Array<object>}>} reports
|
|
55
|
+
* @returns {object} a summary whose every number is a count of something observed
|
|
56
|
+
*/
|
|
57
|
+
export function summarise(reports) {
|
|
58
|
+
const tasks = Array.isArray(reports) ? reports.filter((r) => r !== null && typeof r === 'object') : [];
|
|
59
|
+
const leases = { done: 0, expired: 0, refused: 0, cancelled: 0, observing: 0, other: 0 };
|
|
60
|
+
const transports = { relay: 0, direct: 0, unknown: 0 };
|
|
61
|
+
const p2p = { attempted: 0, delivered: 0 };
|
|
62
|
+
let tasksWithAnySuccess = 0;
|
|
63
|
+
let tasksWithAnyExpiry = 0;
|
|
64
|
+
|
|
65
|
+
for (const task of tasks) {
|
|
66
|
+
const machines = Array.isArray(task.machines) ? task.machines : [];
|
|
67
|
+
let sawSuccess = false;
|
|
68
|
+
let sawExpiry = false;
|
|
69
|
+
for (const m of machines) {
|
|
70
|
+
const state = String(m?.lease_state ?? m?.outcome ?? 'other');
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(leases, state)) leases[state] += 1;
|
|
72
|
+
else leases.other += 1;
|
|
73
|
+
if (state === 'done' || m?.outcome === 'ok') sawSuccess = true;
|
|
74
|
+
if (FAILURE_STATES.includes(state)) sawExpiry = true;
|
|
75
|
+
// Transport is only meaningful for a lease that actually ran.
|
|
76
|
+
const t = m?.transport;
|
|
77
|
+
if (t === 'relay') transports.relay += 1;
|
|
78
|
+
else if (t === 'direct') transports.direct += 1;
|
|
79
|
+
else if (t !== undefined && t !== null) transports.unknown += 1;
|
|
80
|
+
const p = m?.p2p;
|
|
81
|
+
if (p !== undefined && p !== null) {
|
|
82
|
+
p2p.attempted += 1;
|
|
83
|
+
if (p.encrypted === true || p.peer !== null && p.peer !== undefined) p2p.delivered += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (sawSuccess) tasksWithAnySuccess += 1;
|
|
87
|
+
if (sawExpiry) tasksWithAnyExpiry += 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const acting = leases.done + leases.expired;
|
|
91
|
+
const attempted = acting + leases.refused;
|
|
92
|
+
return {
|
|
93
|
+
tasks: tasks.length,
|
|
94
|
+
leases: { ...leases },
|
|
95
|
+
leases_total: Object.values(leases).reduce((a, b) => a + b, 0),
|
|
96
|
+
acting_leases: acting,
|
|
97
|
+
expiry_rate: acting === 0 ? null : Math.round((leases.expired / acting) * 1000) / 1000,
|
|
98
|
+
refusal_rate: attempted === 0 ? null : Math.round((leases.refused / attempted) * 1000) / 1000,
|
|
99
|
+
transports: { ...transports },
|
|
100
|
+
direct_share: transports.relay + transports.direct === 0
|
|
101
|
+
? null
|
|
102
|
+
: Math.round((transports.direct / (transports.relay + transports.direct)) * 1000) / 1000,
|
|
103
|
+
p2p: { ...p2p, delivery_rate: p2p.attempted === 0 ? null : Math.round((p2p.delivered / p2p.attempted) * 1000) / 1000 },
|
|
104
|
+
tasks_with_any_success: tasksWithAnySuccess,
|
|
105
|
+
tasks_with_any_expiry: tasksWithAnyExpiry,
|
|
106
|
+
// A rate over a denominator of zero is reported as null rather than as zero. "No leases were attempted" and
|
|
107
|
+
// "every lease expired" are different facts and a zero would merge them.
|
|
108
|
+
unmeasured: acting === 0 && attempted === 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The fleet's measured coordination overhead per acting agent.
|
|
114
|
+
*
|
|
115
|
+
* Defined against **acting** leases rather than all leases, because an `observing` machine is not doing work
|
|
116
|
+
* and counting it would dilute the number with machines that were merely told what happened.
|
|
117
|
+
*
|
|
118
|
+
* @returns {{acting_leases: number, expired: number, overhead_per_agent: number|null, detail: string}}
|
|
119
|
+
*/
|
|
120
|
+
export function overhead(summary) {
|
|
121
|
+
if (summary === null || typeof summary !== 'object' || summary.acting_leases === undefined) {
|
|
122
|
+
return { acting_leases: 0, expired: 0, overhead_per_agent: null, detail: 'no summary supplied' };
|
|
123
|
+
}
|
|
124
|
+
const acting = summary.acting_leases;
|
|
125
|
+
if (acting === 0) {
|
|
126
|
+
return { acting_leases: 0, expired: summary.leases?.expired ?? 0, overhead_per_agent: null, detail: 'no acting leases: the overhead of zero agents is not zero, it is undefined' };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
acting_leases: acting,
|
|
130
|
+
expired: summary.leases?.expired ?? 0,
|
|
131
|
+
overhead_per_agent: Math.round((summary.leases?.expired ?? 0) / acting * 1000) / 1000,
|
|
132
|
+
detail: `${summary.leases?.expired ?? 0} of ${acting} acting lease(s) never reported`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The fleet size actually observed, and the honest statement of what that bounds. */
|
|
137
|
+
export function scaleOf(machineIds) {
|
|
138
|
+
const distinct = [...new Set((Array.isArray(machineIds) ? machineIds : []).filter((x) => typeof x === 'string' && x !== ''))];
|
|
139
|
+
return {
|
|
140
|
+
observed_machines: distinct.length,
|
|
141
|
+
// The ROADMAP's question is about the collapse point, which is beyond anything this can see.
|
|
142
|
+
can_answer_collapse_point: false,
|
|
143
|
+
detail: `this observation covers ${distinct.length} machine(s); the ROADMAP asks about the collapse point near 50, which is outside what ${distinct.length} can show`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extrapolate, or refuse to.
|
|
149
|
+
*
|
|
150
|
+
* The rule this encodes: an extrapolation is only offered when the observation covers **more than a tenth**
|
|
151
|
+
* of the target, and even then it is labelled. The boundary is exclusive deliberately — a sample that is
|
|
152
|
+
* *exactly* 10% is the borderline case, and the borderline case is the one where a refusal costs nothing and
|
|
153
|
+
* an estimate costs credibility. Below the boundary the function returns `null` for the estimate with a
|
|
154
|
+
* reason, because the alternative — a number produced by multiplying a small sample — is exactly the fiction
|
|
155
|
+
* the ROADMAP refuses to write.
|
|
156
|
+
*
|
|
157
|
+
* @param {number} observed agents actually measured
|
|
158
|
+
* @param {number} target agents the estimate would be about
|
|
159
|
+
*/
|
|
160
|
+
export function extrapolate(observed, target) {
|
|
161
|
+
if (!Number.isFinite(observed) || !Number.isFinite(target) || observed <= 0 || target <= 0) {
|
|
162
|
+
return { ok: false, estimate: null, detail: 'observed and target must both be positive numbers' };
|
|
163
|
+
}
|
|
164
|
+
const coverage = observed / target;
|
|
165
|
+
if (coverage <= 0.1) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
estimate: null,
|
|
169
|
+
coverage: Math.round(coverage * 1000) / 1000,
|
|
170
|
+
detail: `refusing to extrapolate from ${observed} to ${target}: that is ${(coverage * 100).toFixed(2)}% coverage, and a number produced by scaling a sample that small is fiction with a decimal point on it`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
estimate: target,
|
|
176
|
+
coverage: Math.round(coverage * 1000) / 1000,
|
|
177
|
+
detail: `extrapolating from ${observed} to ${target} at ${(coverage * 100).toFixed(0)}% coverage -- reported as an extrapolation, never as a measurement`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The one claim this fleet can make about a ceiling, stated as a falsifiable inequality.
|
|
183
|
+
*
|
|
184
|
+
* Not a predicted collapse point. It is: **at the largest fan-out this fleet has actually exercised, the
|
|
185
|
+
* expiry rate was X**. That is measurable, it is falsifiable by re-running, and it is the only form of the
|
|
186
|
+
* answer the data supports.
|
|
187
|
+
*/
|
|
188
|
+
export function ceilingFrom(summary, observedFanOut) {
|
|
189
|
+
if (summary === null || typeof summary !== 'object' || !Number.isFinite(observedFanOut) || observedFanOut <= 0) {
|
|
190
|
+
return { claim: null, detail: 'a ceiling claim needs a summary and the fan-out it was measured at' };
|
|
191
|
+
}
|
|
192
|
+
const rate = summary.expiry_rate;
|
|
193
|
+
if (rate === null) {
|
|
194
|
+
return { claim: null, detail: 'no acting leases, so no expiry rate to state' };
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
claim: `at a fan-out of ${observedFanOut}, the lease expiry rate was ${(rate * 100).toFixed(1)}%`,
|
|
198
|
+
fan_out: observedFanOut,
|
|
199
|
+
expiry_rate: rate,
|
|
200
|
+
// Explicitly not a ceiling. Naming what it is not is the point of the function.
|
|
201
|
+
is_a_ceiling: false,
|
|
202
|
+
detail: 'this is an observation at one fan-out, not a collapse point; a ceiling would need the rate measured at several fan-outs and trending',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet state: what the relay's own accounting does and does not say, and the one place it disagrees with itself.
|
|
3
|
+
*
|
|
4
|
+
* ## The disagreement, measured
|
|
5
|
+
*
|
|
6
|
+
* Two relay endpoints answer "is this machine usable?" and they do not agree:
|
|
7
|
+
*
|
|
8
|
+
* * `w2m_devices` reports every paired machine as `state: "online"` -- including two that had been silent
|
|
9
|
+
* for **61,384 seconds (about 17 hours)** at the time of measurement.
|
|
10
|
+
* * `w2m_stats` reports the same two as `online: false` with `offline_reason: "silent"`, and counts
|
|
11
|
+
* `online: 3` out of `devices: 5`.
|
|
12
|
+
*
|
|
13
|
+
* A caller that asked the first one would plan work for five machines and get three. **A fleet whose size is
|
|
14
|
+
* reported two ways is a fleet whose plans are wrong by a factor of 1.7**, and the failure is silent because
|
|
15
|
+
* both answers look authoritative.
|
|
16
|
+
*
|
|
17
|
+
* So this module prefers `w2m_stats` — it is the one that carries a *reason* — and treats a device listing
|
|
18
|
+
* without a heartbeat age as **unverified rather than online**.
|
|
19
|
+
*
|
|
20
|
+
* ## The second disagreement, which is about history
|
|
21
|
+
*
|
|
22
|
+
* `w2m_history` lists, per task, a `lease_states` map. It is NOT the execution outcome: a task that expired
|
|
23
|
+
* mid-run can show `done` afterwards, because the map records the *current* lease state rather than what
|
|
24
|
+
* happened. Measured directly: of forty tasks in the sample, every one recorded macmini as `done`, while the
|
|
25
|
+
* session's own result envelopes included several `expired` with no exit code at all.
|
|
26
|
+
*
|
|
27
|
+
* Extracting the outcome requires `w2m_report`, which is a request per task. **A history listing is therefore
|
|
28
|
+
* a poor input for a reliability measurement, and using it as one would have produced a fleet that looked
|
|
29
|
+
* perfectly reliable** — the most dangerous possible error in a module whose purpose is to notice trouble.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** How a machine's usability was determined, in descending order of trust. */
|
|
33
|
+
export const EVIDENCE = Object.freeze({
|
|
34
|
+
HEARTBEAT_WITH_RTT: 'a live heartbeat and a measured round trip',
|
|
35
|
+
HEARTBEAT_ALONE: 'a live heartbeat with no round trip measured',
|
|
36
|
+
STALE_OR_ABSENT: 'no recent heartbeat',
|
|
37
|
+
UNKNOWN: 'the relay did not say',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classify one machine from a `w2m_stats` entry.
|
|
42
|
+
*
|
|
43
|
+
* `silent_ms` is the decisive field. A machine that is "online" but silent is not online, and the reason is
|
|
44
|
+
* reported alongside so a caller can distinguish "went away" from "never arrived".
|
|
45
|
+
*/
|
|
46
|
+
export function classifyMachine(entry) {
|
|
47
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.machine_id !== 'string') {
|
|
48
|
+
return { machine_id: null, usable: false, evidence: EVIDENCE.UNKNOWN, reason: 'no machine entry', detail: 'the relay did not describe this machine' };
|
|
49
|
+
}
|
|
50
|
+
const online = entry.online === true;
|
|
51
|
+
const rtt = entry.rtt_ms;
|
|
52
|
+
const stale = entry.rtt_stale === true;
|
|
53
|
+
if (online && !stale && Number.isFinite(rtt)) {
|
|
54
|
+
return { machine_id: entry.machine_id, name: entry.machine_name ?? null, usable: true, evidence: EVIDENCE.HEARTBEAT_WITH_RTT, rtt_ms: rtt, reason: null, detail: `live, ${rtt} ms` };
|
|
55
|
+
}
|
|
56
|
+
if (online && !stale) {
|
|
57
|
+
return { machine_id: entry.machine_id, name: entry.machine_name ?? null, usable: true, evidence: EVIDENCE.HEARTBEAT_ALONE, rtt_ms: null, reason: null, detail: 'live, round trip not measured' };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
machine_id: entry.machine_id,
|
|
61
|
+
name: entry.machine_name ?? null,
|
|
62
|
+
usable: false,
|
|
63
|
+
evidence: EVIDENCE.STALE_OR_ABSENT,
|
|
64
|
+
rtt_ms: Number.isFinite(rtt) && !stale ? rtt : null,
|
|
65
|
+
reason: entry.offline_reason ?? 'not online',
|
|
66
|
+
silent_ms: Number.isFinite(entry.silent_ms) ? entry.silent_ms : null,
|
|
67
|
+
detail: entry.offline_reason === 'silent'
|
|
68
|
+
? `silent for ${Math.round((entry.silent_ms ?? 0) / 1000)}s`
|
|
69
|
+
: String(entry.offline_reason ?? 'not online'),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a `w2m_stats` payload.
|
|
75
|
+
*
|
|
76
|
+
* @returns {{
|
|
77
|
+
* paired: number, online: number, usable: number, silent: Array<object>,
|
|
78
|
+
* usable_machines: Array<object>, detail: string
|
|
79
|
+
* }}
|
|
80
|
+
*/
|
|
81
|
+
export function readStats(stats) {
|
|
82
|
+
const machines = Array.isArray(stats?.machines) ? stats.machines : [];
|
|
83
|
+
const classified = machines.map(classifyMachine);
|
|
84
|
+
const paired = Number.isFinite(stats?.counts?.devices) ? stats.counts.devices : machines.length;
|
|
85
|
+
const usable = classified.filter((m) => m.usable);
|
|
86
|
+
const silent = classified.filter((m) => !m.usable);
|
|
87
|
+
// The number a caller should plan against is the number of machines that can actually run something.
|
|
88
|
+
return {
|
|
89
|
+
paired,
|
|
90
|
+
online: Number.isFinite(stats?.counts?.online) ? stats.counts.online : usable.length,
|
|
91
|
+
usable: usable.length,
|
|
92
|
+
usable_machines: usable,
|
|
93
|
+
silent,
|
|
94
|
+
runner_up_rtt_ms: usable.filter((m) => Number.isFinite(m.rtt_ms)).sort((a, b) => a.rtt_ms - b.rtt_ms)[0]?.rtt_ms ?? null,
|
|
95
|
+
detail: `${usable.length} of ${paired} paired machine(s) can run work`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Compare a device listing against the stats reading, and report every machine they disagree about.
|
|
101
|
+
*
|
|
102
|
+
* **The disagreement is the product.** A caller that saw only the device listing would believe the fleet is
|
|
103
|
+
* larger than it is; a caller that saw only the stats would not know the listing claimed otherwise. Reporting
|
|
104
|
+
* the gap makes the gap fixable, and until it is fixed the smaller number is the true one.
|
|
105
|
+
*/
|
|
106
|
+
export function reconcile(devices, stats) {
|
|
107
|
+
const listed = Array.isArray(devices?.devices) ? devices.devices : [];
|
|
108
|
+
const reading = readStats(stats);
|
|
109
|
+
const usableIds = new Set(reading.usable_machines.map((m) => m.machine_id));
|
|
110
|
+
const claimedOnline = listed.filter((d) => d?.state === 'online');
|
|
111
|
+
const disagreements = claimedOnline
|
|
112
|
+
.filter((d) => !usableIds.has(d.machine_id))
|
|
113
|
+
.map((d) => {
|
|
114
|
+
const m = reading.silent.find((s) => s.machine_id === d.machine_id);
|
|
115
|
+
return {
|
|
116
|
+
machine_id: d.machine_id,
|
|
117
|
+
name: d.name ?? null,
|
|
118
|
+
devices_says: 'online',
|
|
119
|
+
stats_says: m?.reason ?? 'not usable',
|
|
120
|
+
silent_ms: m?.silent_ms ?? null,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
devices_claims_online: claimedOnline.length,
|
|
125
|
+
stats_says_usable: reading.usable,
|
|
126
|
+
disagreements,
|
|
127
|
+
// The trust decision, stated as a field rather than left to the reader.
|
|
128
|
+
trust: 'stats',
|
|
129
|
+
reason: disagreements.length === 0
|
|
130
|
+
? 'the two endpoints agree'
|
|
131
|
+
: `a device listing reports a machine as online when it has not been heard from; the stats reading carries a reason and is the one to plan against`,
|
|
132
|
+
detail: `${claimedOnline.length} claimed online, ${reading.usable} usable, ${disagreements.length} disagreement(s)`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether a `w2m_history` payload can be used as a reliability measurement.
|
|
138
|
+
*
|
|
139
|
+
* It cannot, and this function exists so that conclusion is a checked fact rather than a comment. Every entry
|
|
140
|
+
* in the sample recorded the same state for the same machine, which is exactly what a current-state map looks
|
|
141
|
+
* like and exactly what an outcome record does not.
|
|
142
|
+
*
|
|
143
|
+
* @returns {{usable_as_outcome_record: boolean, reason: string, homogeneous: Array<string>}}
|
|
144
|
+
*/
|
|
145
|
+
export function historyIsOutcomeRecord(history) {
|
|
146
|
+
const tasks = Array.isArray(history?.tasks) ? history.tasks : [];
|
|
147
|
+
if (tasks.length === 0) {
|
|
148
|
+
return { usable_as_outcome_record: false, reason: 'no tasks in the sample', homogeneous: [] };
|
|
149
|
+
}
|
|
150
|
+
// Per machine, how many distinct states appear across tasks. A machine at "done" in every task, including
|
|
151
|
+
// tasks that expired, is a current-state map rather than a record of what happened.
|
|
152
|
+
const states = new Map();
|
|
153
|
+
for (const task of tasks) {
|
|
154
|
+
for (const [id, state] of Object.entries(task?.lease_states ?? {})) {
|
|
155
|
+
if (!states.has(id)) states.set(id, new Set());
|
|
156
|
+
states.get(id).add(String(state));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const homogeneous = [...states.entries()].filter(([, set]) => set.size === 1).map(([id]) => id);
|
|
160
|
+
return {
|
|
161
|
+
usable_as_outcome_record: false,
|
|
162
|
+
homogeneous,
|
|
163
|
+
reason: homogeneous.length === states.size && states.size > 0
|
|
164
|
+
? `every one of the ${states.size} machine(s) holds a single state across ${tasks.length} task(s) -- a current-state map, not an outcome record; use w2m_report per task instead`
|
|
165
|
+
: `a lease_states map reports the current state, not what happened; use w2m_report per task instead`,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
|
|
|
185
185
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
186
|
* overrides it at runtime for tests and packagers.
|
|
187
187
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.
|
|
188
|
+
const PLUGIN_VERSION = '0.15.0';
|
|
189
189
|
|
|
190
190
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
191
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|