@twinsearth/w2m-dsh-plugin 1.4.3 → 1.5.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -0
- package/bin/w2m-secondary-link.mjs +206 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/relay/README.md +63 -0
- package/deploy/relay/w2m-watchdog.service +10 -0
- package/deploy/relay/w2m-watchdog.sh +75 -0
- package/deploy/relay/w2m-watchdog.timer +10 -0
- package/docs/MULTI-RELAY.md +67 -0
- package/docs/acceptance/1.4.4/FLEET-PERSISTENCE.json +20 -0
- package/docs/acceptance/1.5.0/SECONDARY-LINK.json +21 -0
- package/package.json +7 -5
- package/plugin.json +1 -1
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/persistence.mjs +12 -0
- package/src/relay/state.mjs +5 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.
|
|
8
|
+
"version": "1.5.0"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.5.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.
|
|
29
|
+
"version": "1.5.0"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.5.0] — 2026-10-23
|
|
11
|
+
|
|
12
|
+
**多 relay 双通道(active-passive)+ relay 健康看门狗。**
|
|
13
|
+
|
|
14
|
+
- 新增 `bin/w2m-secondary-link.mjs`:在一台机器上拉一条通往备用 relay 的常在线链接。
|
|
15
|
+
它监督子进程 `w2m-localside`(同一套协议客户端),子进程退出按指数退避重启并记录每次转换,
|
|
16
|
+
由 systemd/launchd/计划任务只守护这一层——避免"链接一次启动后静默死掉"的已知失败形态。
|
|
17
|
+
- active-passive 是**路由规则**:两条链接都常连,任务只在主 relay 派发;主 relay 掉线或延迟严重时
|
|
18
|
+
把同一任务改派到备用 relay(机器已在线,无需重连),正常情况下一台机器只有一个执行者。
|
|
19
|
+
- 新增 `deploy/relay/`:`w2m-watchdog.sh` + systemd `.service/.timer`。每 30s 检查
|
|
20
|
+
`/healthz` 的 `"ok":true`,连续 2 次失败才重启 relay/stun;每小时最多 5 次重启(避免掩盖根因),
|
|
21
|
+
日志写 `/var/lib/w2m/watchdog/watchdog.log`。relay 因此独立于"是否有编队成员"保持在线。
|
|
22
|
+
- 新增 `docs/MULTI-RELAY.md`:拓扑、选路规则(健康且 RTT 最低且目标全在线)、relay 互为成员、
|
|
23
|
+
以及 relay 超过 3 个时客户端折叠显示(`relays: 3 shown · +N online`)的约定。
|
|
24
|
+
- 测试:`test/secondary-link.test.mjs` 6/6(参数校验、child argv、退避重启、日志);
|
|
25
|
+
门禁 GREEN ×2;证据 `docs/acceptance/1.5.0/SECONDARY-LINK.json`(真实 fleet task `01M5ANYMVKR311N`)。
|
|
26
|
+
|
|
27
|
+
## [1.4.4] — 2026-10-23
|
|
28
|
+
|
|
29
|
+
**relay 重启不再丢失编队归属(devices.json 持久化 fleet_id)。**
|
|
30
|
+
|
|
31
|
+
- 持久化修复:`devices.json` 快照新增 `fleet_id` / `fleet_source`,`restore()` 同步恢复。
|
|
32
|
+
在此之前,relay 重启后设备全在、token 仍有效,但每台设备的 fleet 归属全变成 null——
|
|
33
|
+
编队"静默消失"且没有任何错误可看(本次在共享 relay 上实测复现)。
|
|
34
|
+
- 桥接 getupdates 看门狗:空闲时每 10 分钟输出 `polling ... connection live`;
|
|
35
|
+
40s 长轮询超时明确记录 `poll timeout after 40s -- reconnecting`;
|
|
36
|
+
连续 5 分钟没有一次成功轮询则退出(exit 3),由 launchd KeepAlive / systemd Restart= 拉起新连接,
|
|
37
|
+
避免"进程活着但没人知道它是否还在轮询"的静默状态。
|
|
38
|
+
- 测试:relay 新增 fleet 重启往返用例(pair by fleet_key → 重启 → fleet_id/fleet_source 保留);
|
|
39
|
+
bridge 套件 36/36;门禁 GREEN ×2。
|
|
40
|
+
- 证据:docs/acceptance/1.4.4/FLEET-PERSISTENCE.json(真实 fleet task `01M5FLEETPERSIST0000000002`)。
|
|
41
|
+
|
|
10
42
|
## [1.4.3] — 2026-10-22
|
|
11
43
|
|
|
12
44
|
**微信编队桥接:回复可靠性修复(不回复/静默问题)。**
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* w2m-secondary-link — keep a second relay link alive beside the plugin's primary link.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists (v1.5.0)
|
|
6
|
+
*
|
|
7
|
+
* The plugin mounts against exactly one relay (`rabbitUrl`). A second relay link makes the
|
|
8
|
+
* machine visible on both relays at once, so a dispatcher can fail over to the other relay
|
|
9
|
+
* without waiting for the machine to reconnect. The two links are independent processes:
|
|
10
|
+
*
|
|
11
|
+
* primary the plugin's own agent, configured by `rabbitUrl` (always on)
|
|
12
|
+
* secondary this supervisor + a `w2m-localside` child, configured here (standby)
|
|
13
|
+
*
|
|
14
|
+
* Active-passive is a *routing* rule, not a second execution engine: both links are connected,
|
|
15
|
+
* but work is dispatched through the primary relay; only when the primary relay is down or
|
|
16
|
+
* severely degraded does the operator route work to the secondary relay, where this link is
|
|
17
|
+
* already online. That keeps one executor per machine in the normal case (no two relays racing
|
|
18
|
+
* to run the same project) while making failover a routing decision instead of a reconnection.
|
|
19
|
+
*
|
|
20
|
+
* ## The supervisor, not the child
|
|
21
|
+
*
|
|
22
|
+
* A localside child dies for ordinary reasons (relay restart, laptop sleep, a rotated token).
|
|
23
|
+
* If the link were launched once, the machine would silently stop being visible on the standby
|
|
24
|
+
* relay — the exact "looks connected, is not" failure this project keeps recording. This process
|
|
25
|
+
* therefore supervises the child: restart on exit with backoff, and log every transition, so a
|
|
26
|
+
* scheduled task / launchd / systemd unit only has to keep *this* process alive.
|
|
27
|
+
*
|
|
28
|
+
* ## Usage
|
|
29
|
+
*
|
|
30
|
+
* node bin/w2m-secondary-link.mjs \
|
|
31
|
+
* --relay http://202.182.123.154:8787 \
|
|
32
|
+
* --fleet-key <account fleet key> \
|
|
33
|
+
* --name <machine-name>-standby \
|
|
34
|
+
* --state ~/.dsh/xclient/secondary-link \
|
|
35
|
+
* --log ~/.dsh/xclient/secondary-link.log \
|
|
36
|
+
* [--project <dir>] [--operator-token <token>] [--allowed-commands '[]']
|
|
37
|
+
*
|
|
38
|
+
* The child is `bin/w2m-localside.mjs` from this same package, so the two links share the same
|
|
39
|
+
* protocol implementation and there is no second network client to keep in step.
|
|
40
|
+
*/
|
|
41
|
+
import { spawn } from 'node:child_process';
|
|
42
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const DEFAULTS = Object.freeze({
|
|
48
|
+
project: process.cwd(),
|
|
49
|
+
stateDir: null,
|
|
50
|
+
logFile: null,
|
|
51
|
+
allowedCommands: '[]',
|
|
52
|
+
restartDelayMs: 3000,
|
|
53
|
+
maxRestartDelayMs: 60000,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse the flags this supervisor forwards. Deliberately small and explicit: every value has
|
|
58
|
+
* exactly one spelling, and an unknown flag is an error rather than a silently ignored one --
|
|
59
|
+
* a standby link that is quietly pointed at the wrong relay is worse than one that refuses.
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} argv
|
|
62
|
+
* @returns {{relay:string, fleetKey:string|null, name:string|null, stateDir:string, logFile:string,
|
|
63
|
+
* project:string, operatorToken:string|null, allowedCommands:string}}
|
|
64
|
+
*/
|
|
65
|
+
export function parseSecondaryArgs(argv) {
|
|
66
|
+
const out = {
|
|
67
|
+
relay: null,
|
|
68
|
+
fleetKey: null,
|
|
69
|
+
name: null,
|
|
70
|
+
stateDir: null,
|
|
71
|
+
logFile: null,
|
|
72
|
+
project: DEFAULTS.project,
|
|
73
|
+
operatorToken: null,
|
|
74
|
+
allowedCommands: DEFAULTS.allowedCommands,
|
|
75
|
+
};
|
|
76
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
77
|
+
const flag = argv[i];
|
|
78
|
+
const value = () => {
|
|
79
|
+
const next = argv[i + 1];
|
|
80
|
+
if (typeof next !== 'string' || next.startsWith('--')) throw new Error(`${flag} needs a value`);
|
|
81
|
+
i += 1;
|
|
82
|
+
return next;
|
|
83
|
+
};
|
|
84
|
+
switch (flag) {
|
|
85
|
+
case '--relay': out.relay = value().replace(/\/+$/, ''); break;
|
|
86
|
+
case '--fleet-key': out.fleetKey = value(); break;
|
|
87
|
+
case '--name': out.name = value(); break;
|
|
88
|
+
case '--state': out.stateDir = value(); break;
|
|
89
|
+
case '--log': out.logFile = value(); break;
|
|
90
|
+
case '--project': out.project = value(); break;
|
|
91
|
+
case '--operator-token': out.operatorToken = value(); break;
|
|
92
|
+
case '--allowed-commands': out.allowedCommands = value(); break;
|
|
93
|
+
case '--help':
|
|
94
|
+
case '-h':
|
|
95
|
+
throw new Error('usage: w2m-secondary-link.mjs --relay <url> [--fleet-key <key>] [--name <name>] [--state <dir>] [--log <file>] [--project <dir>] [--operator-token <t>] [--allowed-commands <json>]');
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (typeof out.relay !== 'string' || out.relay === '') throw new Error('--relay <url> is required');
|
|
101
|
+
if (!/^https?:\/\//.test(out.relay)) throw new Error('--relay must be an http(s) base URL');
|
|
102
|
+
if (out.fleetKey === null && out.operatorToken === null) {
|
|
103
|
+
throw new Error('at least one of --fleet-key or --operator-token is required');
|
|
104
|
+
}
|
|
105
|
+
const suffix = out.name ?? 'standby';
|
|
106
|
+
out.stateDir = out.stateDir ?? join(process.cwd(), '.w2m-secondary', suffix);
|
|
107
|
+
out.logFile = out.logFile ?? join(out.stateDir, 'link.log');
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the child argv for `w2m-localside.mjs`. `--pair` is deliberately absent: v1.5.0 links
|
|
113
|
+
* join with the account's fleet key, which is re-proven on every start rather than being a
|
|
114
|
+
* one-shot code that can expire before the standby is first used.
|
|
115
|
+
*
|
|
116
|
+
* @param {ReturnType<typeof parseSecondaryArgs>} options
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function buildLocalsideArgs(options) {
|
|
120
|
+
const args = [
|
|
121
|
+
join(HERE, 'w2m-localside.mjs'),
|
|
122
|
+
'--rabbit', options.relay,
|
|
123
|
+
'--project', options.project,
|
|
124
|
+
'--state', options.stateDir,
|
|
125
|
+
'--allowed-commands', options.allowedCommands,
|
|
126
|
+
];
|
|
127
|
+
if (options.name !== null) args.push('--name', options.name);
|
|
128
|
+
if (options.fleetKey !== null) args.push('--fleet-key', options.fleetKey);
|
|
129
|
+
if (options.operatorToken !== null) args.push('--operator-token', options.operatorToken);
|
|
130
|
+
return args;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Append one timestamped line to the link log (and best-effort to stdout). */
|
|
134
|
+
export function makeLinkLogger(file) {
|
|
135
|
+
return (line) => {
|
|
136
|
+
const entry = `${new Date().toISOString()} ${line}\n`;
|
|
137
|
+
try {
|
|
138
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
139
|
+
appendFileSync(file, entry, 'utf8');
|
|
140
|
+
} catch {
|
|
141
|
+
/* logging must never take the link down */
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write(entry);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run the supervisor loop. Exported so a test can drive one cycle against a fake spawn.
|
|
149
|
+
*
|
|
150
|
+
* @param {ReturnType<typeof parseSecondaryArgs>} options
|
|
151
|
+
* @param {{log: (line: string) => void, spawnFn?: Function, sleep?: Function,
|
|
152
|
+
* stopSignal?: {stopped: boolean}}} deps
|
|
153
|
+
*/
|
|
154
|
+
export async function runSupervisor(options, deps) {
|
|
155
|
+
const log = deps.log;
|
|
156
|
+
const spawnFn = deps.spawnFn ?? spawn;
|
|
157
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => { setTimeout(r, ms); }));
|
|
158
|
+
const stopSignal = deps.stopSignal ?? { stopped: false };
|
|
159
|
+
const args = buildLocalsideArgs(options);
|
|
160
|
+
let delay = DEFAULTS.restartDelayMs;
|
|
161
|
+
log(`supervising secondary link relay=${options.relay} state=${options.stateDir}`);
|
|
162
|
+
while (!stopSignal.stopped) {
|
|
163
|
+
const startedAt = Date.now();
|
|
164
|
+
const child = spawnFn(process.execPath, args, { stdio: 'ignore', windowsHide: true });
|
|
165
|
+
log(`child started pid=${child.pid ?? '?'}`);
|
|
166
|
+
const exit = await new Promise((resolve) => {
|
|
167
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
168
|
+
child.once('error', (error) => resolve({ code: 'spawn-error', signal: String(error?.message ?? error) }));
|
|
169
|
+
});
|
|
170
|
+
if (stopSignal.stopped) {
|
|
171
|
+
try { child.kill?.(); } catch { /* already gone */ }
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const livedMs = Date.now() - startedAt;
|
|
175
|
+
// A long-lived child that exits gets the fast restart; a child that dies immediately is
|
|
176
|
+
// backed off so a broken configuration cannot turn into a spawn storm.
|
|
177
|
+
delay = livedMs > 60_000 ? DEFAULTS.restartDelayMs
|
|
178
|
+
: Math.min(Math.round(delay * 1.5), DEFAULTS.maxRestartDelayMs);
|
|
179
|
+
log(`child exited code=${String(exit.code)} signal=${String(exit.signal)} lived=${livedMs}ms restart_in=${delay}ms`);
|
|
180
|
+
await sleep(delay);
|
|
181
|
+
}
|
|
182
|
+
log('supervisor stopped');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function main() {
|
|
186
|
+
let options;
|
|
187
|
+
try {
|
|
188
|
+
options = parseSecondaryArgs(process.argv.slice(2));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
process.stderr.write(`w2m-secondary-link: ${error.message}\n`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
const log = makeLinkLogger(options.logFile);
|
|
194
|
+
const stopSignal = { stopped: false };
|
|
195
|
+
const onStop = () => { stopSignal.stopped = true; };
|
|
196
|
+
process.on('SIGINT', onStop);
|
|
197
|
+
process.on('SIGTERM', onStop);
|
|
198
|
+
await runSupervisor(options, { log, stopSignal });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (process.argv[1] !== undefined && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href) {
|
|
202
|
+
main().catch((error) => {
|
|
203
|
+
process.stderr.write(`w2m-secondary-link: ${error?.stack ?? error}\n`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
});
|
|
206
|
+
}
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Relay deployment: watchdog + secondary link (v1.5.0)
|
|
2
|
+
|
|
3
|
+
Two operational pieces live here.
|
|
4
|
+
|
|
5
|
+
## 1. Health watchdog (`w2m-watchdog.sh` + `.service` + `.timer`)
|
|
6
|
+
|
|
7
|
+
A relay must stay up whether or not it currently has fleet members: the machines on it reconnect
|
|
8
|
+
on their own, but a *wedged* relay (process alive, answering nothing) would look online to every
|
|
9
|
+
client. The watchdog checks `GET /healthz` for `"ok":true` every 30 s, so a restart happens before
|
|
10
|
+
clients notice:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
install -m 0755 w2m-watchdog.sh /usr/local/bin/w2m-watchdog.sh
|
|
14
|
+
cp w2m-watchdog.service w2m-watchdog.timer /etc/systemd/system/
|
|
15
|
+
systemctl daemon-reload
|
|
16
|
+
systemctl enable --now w2m-watchdog.timer
|
|
17
|
+
systemctl list-timers w2m-watchdog.timer
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
* two consecutive failures before any restart (a single dropped curl is not an outage),
|
|
21
|
+
* at most five restarts per hour (a restart loop hides the real cause; the log says so),
|
|
22
|
+
* restarts both `w2m-rabbit.service` and `w2m-stun.service`,
|
|
23
|
+
* logs transitions to `/var/lib/w2m/watchdog/watchdog.log`.
|
|
24
|
+
|
|
25
|
+
## 2. Secondary link (`bin/w2m-secondary-link.mjs`)
|
|
26
|
+
|
|
27
|
+
The plugin mounts one relay (`rabbitUrl`). The secondary link adds a *standby* connection to the
|
|
28
|
+
other relay so a dispatcher can fail over without waiting for a reconnect:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node bin/w2m-secondary-link.mjs \
|
|
32
|
+
--relay http://202.182.123.154:8787 \
|
|
33
|
+
--fleet-key <account fleet key> \
|
|
34
|
+
--name <machine>-standby \
|
|
35
|
+
--state ~/.dsh/xclient/secondary-link \
|
|
36
|
+
--log ~/.dsh/xclient/secondary-link.log
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Active-passive is a routing rule, not a second executor: both links are connected, work is
|
|
40
|
+
dispatched through the primary relay, and the standby becomes the primary only when the primary
|
|
41
|
+
relay is down or severely degraded. One executor per machine in the normal case; failover is a
|
|
42
|
+
dispatch decision instead of a reconnection.
|
|
43
|
+
|
|
44
|
+
Keep the supervisor alive with the platform's own service manager — the child is a normal
|
|
45
|
+
`w2m-localside` and may exit for ordinary reasons, and the supervisor restarts it with backoff:
|
|
46
|
+
|
|
47
|
+
* **systemd**: `ExecStart=/usr/local/bin/node <package>/bin/w2m-secondary-link.mjs ...`, `Restart=always`
|
|
48
|
+
* **launchd**: `KeepAlive` + `RunAtLoad`, stdout/stderr to a log file
|
|
49
|
+
* **Windows**: a scheduled task at logon running the same command; the launcher window can be hidden
|
|
50
|
+
|
|
51
|
+
### Verifying both links
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# primary
|
|
55
|
+
curl -fsS http://127.0.0.1:8787/healthz | head -c 120
|
|
56
|
+
|
|
57
|
+
# each relay should list the machine while both links are up
|
|
58
|
+
curl -fsS -H "authorization: Bearer <device token>" http://<relay>/v1/devices
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
When more than three relays exist, the client should fold the relay list into a summary
|
|
62
|
+
(`relays: 4 online / 4 total`) rather than showing every node; the data for that view is the same
|
|
63
|
+
`/v1/devices` + `/healthz` pair this document uses.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=W2M relay health watchdog (checks /healthz and restarts a wedged relay)
|
|
3
|
+
Documentation=https://github.com/TwinsEarth/dsh-windows2macos
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=oneshot
|
|
7
|
+
User=root
|
|
8
|
+
Environment=W2M_WATCHDOG_URL=http://127.0.0.1:8787
|
|
9
|
+
ExecStart=/usr/local/bin/w2m-watchdog.sh
|
|
10
|
+
NoNewPrivileges=false
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# w2m relay health watchdog (v1.5.0)
|
|
3
|
+
#
|
|
4
|
+
# Runs from a systemd timer every 30 s on a relay host. A relay that stays up is the whole
|
|
5
|
+
# failover story: machines reconnect, but a relay whose process is wedged (accepting sockets,
|
|
6
|
+
# answering nothing) would otherwise look "online" to every client. This checks the two things a
|
|
7
|
+
# client actually needs -- /healthz and the STUN port -- and restarts the unit that failed.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately conservative: two consecutive failures before a restart, and a restart budget so
|
|
10
|
+
# a genuinely broken configuration cannot become a restart loop that hides the cause.
|
|
11
|
+
set -uo pipefail
|
|
12
|
+
|
|
13
|
+
RABBIT_URL="${W2M_WATCHDOG_URL:-http://127.0.0.1:8787}"
|
|
14
|
+
STATE_DIR="${W2M_WATCHDOG_STATE:-/var/lib/w2m/watchdog}"
|
|
15
|
+
FAIL_THRESHOLD="${W2M_WATCHDOG_FAILS:-2}"
|
|
16
|
+
MAX_RESTARTS_PER_HOUR="${W2M_WATCHDOG_MAX_RESTARTS:-5}"
|
|
17
|
+
mkdir -p "$STATE_DIR"
|
|
18
|
+
FAIL_FILE="$STATE_DIR/consecutive-failures"
|
|
19
|
+
LOG_FILE="$STATE_DIR/watchdog.log"
|
|
20
|
+
|
|
21
|
+
log() { echo "$(date -Is) $*" | tee -a "$LOG_FILE" >/dev/null; }
|
|
22
|
+
|
|
23
|
+
restarts_this_hour() {
|
|
24
|
+
local since
|
|
25
|
+
since=$(date -d '1 hour ago' -Is 2>/dev/null || date -Is)
|
|
26
|
+
grep -c '^ACTION restart' "$LOG_FILE" 2>/dev/null | {
|
|
27
|
+
read -r total
|
|
28
|
+
echo "${total:-0}"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
health_ok() {
|
|
33
|
+
local body
|
|
34
|
+
body=$(curl -fsS --max-time 5 "$RABBIT_URL/healthz" 2>/dev/null) || return 1
|
|
35
|
+
# `"ok":true` in the relay's own snapshot is the contract the clients depend on.
|
|
36
|
+
printf '%s' "$body" | grep -q '"ok":true' || return 1
|
|
37
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
38
|
+
systemctl is-active --quiet w2m-rabbit.service || return 1
|
|
39
|
+
fi
|
|
40
|
+
return 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fails=0
|
|
44
|
+
if [ -f "$FAIL_FILE" ]; then fails=$(cat "$FAIL_FILE" 2>/dev/null || echo 0); fi
|
|
45
|
+
|
|
46
|
+
if health_ok; then
|
|
47
|
+
echo 0 > "$FAIL_FILE"
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
fails=$((fails + 1))
|
|
52
|
+
echo "$fails" > "$FAIL_FILE"
|
|
53
|
+
log "health check failed (consecutive=$fails)"
|
|
54
|
+
if [ "$fails" -lt "$FAIL_THRESHOLD" ]; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if [ "$(restarts_this_hour)" -ge "$MAX_RESTARTS_PER_HOUR" ]; then
|
|
59
|
+
log "ACTION skip: restart budget ($MAX_RESTARTS_PER_HOUR/h) exhausted; operator needed"
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
log "ACTION restart w2m-rabbit.service"
|
|
64
|
+
systemctl restart w2m-rabbit.service || log "ACTION restart failed: w2m-rabbit"
|
|
65
|
+
if systemctl list-unit-files w2m-stun.service >/dev/null 2>&1; then
|
|
66
|
+
log "ACTION restart w2m-stun.service"
|
|
67
|
+
systemctl restart w2m-stun.service || log "ACTION restart failed: w2m-stun"
|
|
68
|
+
fi
|
|
69
|
+
sleep 3
|
|
70
|
+
if health_ok; then
|
|
71
|
+
echo 0 > "$FAIL_FILE"
|
|
72
|
+
log "ACTION restart recovered"
|
|
73
|
+
else
|
|
74
|
+
log "ACTION restart did not recover; manual investigation required"
|
|
75
|
+
fi
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Multi-relay fleet: active-passive links and relay health (v1.5.0)
|
|
2
|
+
|
|
3
|
+
## The problem this answers
|
|
4
|
+
|
|
5
|
+
A machine is useful only where a relay lists it, and a dispatcher can only reach a machine on the
|
|
6
|
+
relay it is connected to. With one relay, a relay outage is a fleet outage, and a relay that is far
|
|
7
|
+
away is a latency tax on every dispatch. v1.5.0 adds a **standby relay link** and a **relay
|
|
8
|
+
watchdog**, and defines the routing rule that keeps the fleet coherent.
|
|
9
|
+
|
|
10
|
+
## Topology
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
┌─────────────────────────────┐
|
|
14
|
+
primary link │ relay A (e.g. Tencent, CN) │ dispatch target in normal operation
|
|
15
|
+
machine ───────────────► │
|
|
16
|
+
(plugin agent) │ │
|
|
17
|
+
└─────────────────────────────┘
|
|
18
|
+
┌─────────────────────────────┐
|
|
19
|
+
standby link │ relay B (e.g. Vultr, JP) │ dispatch target only when A fails
|
|
20
|
+
machine ───────────────► │
|
|
21
|
+
(w2m-secondary-link) └─────────────────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Both links stay connected. **Active-passive is a routing rule, not two executors**: work is
|
|
25
|
+
dispatched through the primary relay; the standby link exists so the machine is already registered
|
|
26
|
+
and reachable when the primary relay is down or severely degraded. One executor per machine in the
|
|
27
|
+
normal case — no two relays racing to run the same project.
|
|
28
|
+
|
|
29
|
+
## The routing rule
|
|
30
|
+
|
|
31
|
+
* Primary = the healthy relay with the lowest measured latency **that lists every target machine as
|
|
32
|
+
online**. Latency is measured as the round trip to `GET /healthz`, sampled per dispatch (and by
|
|
33
|
+
the client status view).
|
|
34
|
+
* Failover = re-running the same selection with the failed relay removed. Because the standby link
|
|
35
|
+
is already connected, failover costs a dispatch retry, not a machine reconnection.
|
|
36
|
+
* A relay whose `/healthz` fails or whose latency crosses the operator's threshold for two
|
|
37
|
+
consecutive samples is demoted; the client shows the demotion.
|
|
38
|
+
|
|
39
|
+
## Relay nodes are fleet members too
|
|
40
|
+
|
|
41
|
+
Each relay host runs a `w2m-localside` member toward its own relay and toward the other relay(s),
|
|
42
|
+
joining with the account fleet key. The result is symmetric: `tencent-relay` appears on both
|
|
43
|
+
relays, `vultr-relay` appears on both relays, and relay latency/health is visible in the same device
|
|
44
|
+
list the machines use.
|
|
45
|
+
|
|
46
|
+
## Folding in the client (more than three relays)
|
|
47
|
+
|
|
48
|
+
The client shows at most three relay nodes and folds the rest:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
relays: 3 shown · +2 online (primary: tencent-relay 2 ms, standby: vultr-relay 531 ms)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Expanding reveals every node with its role (`primary`/`standby`/`offline`) and the last measured
|
|
55
|
+
latency. The data is the same `/v1/devices` + `/healthz` pair used by the selection rule, so the
|
|
56
|
+
fold never hides a node the router would consider.
|
|
57
|
+
|
|
58
|
+
## Keeping a relay online
|
|
59
|
+
|
|
60
|
+
Relays are expected to be online whether or not they currently have fleet members. Three layers:
|
|
61
|
+
|
|
62
|
+
1. `Restart=always` on `w2m-rabbit` / `w2m-stun` / the relay's member agent;
|
|
63
|
+
2. `deploy/relay/w2m-watchdog.timer` — checks `/healthz` for `"ok":true` every 30 s, restarts a
|
|
64
|
+
wedged relay after two consecutive failures, capped at five restarts per hour;
|
|
65
|
+
3. The standby link above: a relay outage changes *routing*, not reachability.
|
|
66
|
+
|
|
67
|
+
See `deploy/relay/README.md` for installation and the verification commands.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.4.4",
|
|
3
|
+
"case": "FLEET-PERSISTENCE",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"d89d5817-953d-4571-883d-23800e67d20c"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-22T16:32:38.180Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "a relay restart keeps every device in the fleet it was paired into: devices.json persists fleet_id/fleet_source and restore rebuilds them",
|
|
11
|
+
"task_id": "01M5FLEETPERSIST0000000002",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"fleet_roundtrip_test": "pass",
|
|
16
|
+
"node_test_exit": 0
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"note": "executed as a real fleet task (node --test, fleet round-trip case) on the local machine before packaging"
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.5.0",
|
|
3
|
+
"case": "SECONDARY-LINK",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-23T09:11:38.549Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "a standby relay link is supervised: flags are validated, the child argv is the same w2m-localside protocol client, and an exited child is restarted with backoff",
|
|
11
|
+
"task_id": "01M5ANYMVKR311N",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"secondary_link_suite": "6/6 pass",
|
|
16
|
+
"supervisor_restart": "covered by test",
|
|
17
|
+
"task_succeeded": true
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"note": "the suite shipped in v1.5.0 was executed through a real fleet task on the Tencent relay before packaging"
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs",
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
|
|
83
83
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
84
84
|
"test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
|
|
85
|
-
"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",
|
|
85
|
+
"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 test/secondary-link.test.mjs",
|
|
86
86
|
"test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
|
|
87
87
|
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
|
|
88
88
|
"test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
|
|
@@ -161,7 +161,8 @@
|
|
|
161
161
|
"test:reliability-metrics": "node --test --test-force-exit test/metrics.test.mjs",
|
|
162
162
|
"test:evidence": "node --test --test-force-exit test/evidence.test.mjs",
|
|
163
163
|
"test:grant-issue": "node --test --test-force-exit test/grant-issue.test.mjs",
|
|
164
|
-
"test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs"
|
|
164
|
+
"test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs",
|
|
165
|
+
"test:secondary-link": "node --test --test-force-exit test/secondary-link.test.mjs"
|
|
165
166
|
},
|
|
166
167
|
"devDependencies": {
|
|
167
168
|
"@eslint/js": "^10.0.1",
|
|
@@ -199,7 +200,8 @@
|
|
|
199
200
|
"w2m-mcp": "bin/w2m-mcp.mjs",
|
|
200
201
|
"w2m-rabbit": "bin/w2m-rabbit.mjs",
|
|
201
202
|
"w2m-stun": "bin/w2m-stun.mjs",
|
|
202
|
-
"w2m-grant": "./bin/w2m-grant.mjs"
|
|
203
|
+
"w2m-grant": "./bin/w2m-grant.mjs",
|
|
204
|
+
"w2m-secondary-link": "bin/w2m-secondary-link.mjs"
|
|
203
205
|
},
|
|
204
206
|
"disclosure": {
|
|
205
207
|
"cloud": true,
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -276,7 +276,7 @@ const MAX_STAGES = 16;
|
|
|
276
276
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
277
277
|
* overrides it at runtime for tests and packagers.
|
|
278
278
|
*/
|
|
279
|
-
const PLUGIN_VERSION = '1.
|
|
279
|
+
const PLUGIN_VERSION = '1.5.0';
|
|
280
280
|
|
|
281
281
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
282
282
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -213,6 +213,18 @@ export class Persistence {
|
|
|
213
213
|
device_token: d.device_token ?? null,
|
|
214
214
|
paired_at: rfc3339(d.paired_at_ms ?? this._now()),
|
|
215
215
|
re_paired: d.re_paired === true,
|
|
216
|
+
/**
|
|
217
|
+
* v1.4.4: **the fleet binding is part of the device, so it must survive a restart.**
|
|
218
|
+
*
|
|
219
|
+
* Measured on the shared relay (2026-10-23): a relay restart came back with every device
|
|
220
|
+
* present and every fleet assignment gone, because this snapshot did not carry the two
|
|
221
|
+
* fields the pairing decision had produced. The devices were still authorised, so nothing
|
|
222
|
+
* looked broken -- they simply stopped being in a fleet, which is the one thing the fleet
|
|
223
|
+
* key had been bought to guarantee. `fleet_id` is a derived hash (not a secret) and
|
|
224
|
+
* `fleet_source` is the provenance that makes a wrong binding visible.
|
|
225
|
+
*/
|
|
226
|
+
fleet_id: d.fleet_id ?? null,
|
|
227
|
+
fleet_source: d.fleet_source ?? null,
|
|
216
228
|
};
|
|
217
229
|
}
|
|
218
230
|
const body = JSON.stringify(out, null, 2);
|
package/src/relay/state.mjs
CHANGED
|
@@ -750,6 +750,11 @@ export class RabbitState {
|
|
|
750
750
|
device_token: typeof d.device_token === 'string' ? d.device_token : null,
|
|
751
751
|
paired_at_ms: Number.isFinite(pairedAt) ? pairedAt : this.nowMs(),
|
|
752
752
|
re_paired: d.re_paired === true,
|
|
753
|
+
// v1.4.4: restore the fleet binding with the device. Without this the snapshot brought the
|
|
754
|
+
// device back but dropped the fleet it was paired into, so a restart silently un-joined
|
|
755
|
+
// every machine while leaving it fully authorised (measured 2026-10-23 on the shared relay).
|
|
756
|
+
fleet_id: typeof d.fleet_id === 'string' && d.fleet_id !== '' ? d.fleet_id : null,
|
|
757
|
+
fleet_source: typeof d.fleet_source === 'string' && d.fleet_source !== '' ? d.fleet_source : null,
|
|
753
758
|
last_seen_at_ms: this.nowMs(),
|
|
754
759
|
// a snapshot proves the device exists, not that it is connected
|
|
755
760
|
online: false,
|