@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/HANDOFF.md +173 -0
  3. package/README.md +9 -2
  4. package/bin/w2m-localside.mjs +325 -308
  5. package/bin/w2m-rabbit.mjs +6 -0
  6. package/bin/w2m-stun.mjs +0 -0
  7. package/deploy/mac/mac-self-update.mjs +257 -0
  8. package/deploy/server/README.md +109 -0
  9. package/deploy/server/deploy-w2m.sh +161 -0
  10. package/deploy/server/w2m-localside.service +31 -0
  11. package/deploy/server/w2m-rabbit.service +26 -0
  12. package/deploy/server/w2m-stun.service +26 -0
  13. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  14. package/package.json +34 -22
  15. package/src/agent/agent.mjs +24 -2
  16. package/src/chatops/account.mjs +401 -0
  17. package/src/chatops/actions.mjs +227 -0
  18. package/src/chatops/approval-broker.mjs +320 -0
  19. package/src/chatops/auth.mjs +376 -0
  20. package/src/chatops/bridge.mjs +344 -0
  21. package/src/chatops/channel.mjs +192 -0
  22. package/src/chatops/commands.mjs +409 -0
  23. package/src/chatops/config-route.mjs +332 -0
  24. package/src/chatops/device-signing.mjs +218 -0
  25. package/src/chatops/durability.mjs +202 -0
  26. package/src/chatops/file-safety.mjs +282 -0
  27. package/src/chatops/fleet-join.mjs +164 -0
  28. package/src/chatops/fleet-scope.mjs +176 -0
  29. package/src/chatops/fleet-wiring.mjs +258 -0
  30. package/src/chatops/fleet.mjs +272 -0
  31. package/src/chatops/handoff.mjs +258 -0
  32. package/src/chatops/ilink-fleet-bridge.mjs +197 -0
  33. package/src/chatops/ilink-protocol.mjs +165 -0
  34. package/src/chatops/ilink-transport.mjs +168 -0
  35. package/src/chatops/ilink.mjs +423 -0
  36. package/src/chatops/index.mjs +440 -0
  37. package/src/chatops/outbound-queue.mjs +282 -0
  38. package/src/chatops/policy.mjs +356 -0
  39. package/src/chatops/run-command.mjs +308 -0
  40. package/src/plugin/tools.mjs +1 -1
  41. package/src/relay/server.mjs +96 -0
  42. package/src/relay/state.mjs +2279 -2011
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Running a fleet task and reading its verdict, callable from the chat layer (v0.7.2).
3
+ *
4
+ * ## Why this is a separate module
5
+ *
6
+ * `w2m_run` and `w2m_wait` are DSH *tools*: their bodies live inside the plugin's `ctx.tools`
7
+ * registration and are shaped for a model to call — a schema, a card, an error type the host
8
+ * knows how to render. The chat bridge needs the same work with none of that: an argv in, a
9
+ * verdict and a sentence out, no tool registry involved.
10
+ *
11
+ * The alternative was to have the bridge call the tools. It cannot. A tool call needs
12
+ * `@deepseek-ai/dsh-tools`, which exists **only inside `app.asar`** — a separate process cannot
13
+ * complete one, and an in-process call would have to fabricate the host's own calling
14
+ * convention. So the fleet work is expressed once, here, against the relay's HTTP API, and the
15
+ * tool and the chat bridge are both callers.
16
+ *
17
+ * ## The two things a live relay taught this file
18
+ *
19
+ * The first version guessed, and both guesses were wrong in ways the unit tests could not see —
20
+ * the same failure mode as `ilink.mjs` in v0.7.0, reproduced one layer up:
21
+ *
22
+ * 1. **`GET /v1/tasks/<id>` needs the `device_token`, not the `operator_token`.** With the
23
+ * operator token it answers `401 {"code":"UNAUTHORIZED","message":"missing or invalid
24
+ * device_token"}`. Dispatching and requesting take *different* credentials, so both are held.
25
+ * 2. **The verdict is not at the top level.** It is `body.aggregate.status`, and the per-machine
26
+ * detail is `body.leases[]` — whose machine name is `machine_name`, not `machine`. A reader
27
+ * looking for `body.verdict` finds `undefined` forever while the task completes perfectly,
28
+ * which is exactly what happened: the agent ran the command, delivered the result, and the
29
+ * poll loop reported `verdict=undefined` thirty times.
30
+ *
31
+ * ## What it deliberately does not do
32
+ *
33
+ * **It does not wait forever.** A chat reply has a human on the other end, and a task with one
34
+ * dead machine in the fleet never reaches a terminal state. Every wait is bounded and reports
35
+ * `timeout` as a verdict rather than hanging — which is a real answer ("it is still running")
36
+ * and not an error.
37
+ *
38
+ * **It does not invent a verdict.** The relay's own word is passed through, and an unrecognised
39
+ * one is reported as unrecognised instead of being mapped onto the nearest familiar value.
40
+ */
41
+
42
+ /** Relay paths, from the plugin's own tool implementations. */
43
+ export const RELAY = Object.freeze({
44
+ TASK: '/v1/task',
45
+ TASK_STATUS: (id) => `/v1/tasks/${encodeURIComponent(id)}`,
46
+ TASK_REPORT: (id) => `/v1/tasks/${encodeURIComponent(id)}/report`,
47
+ DEVICES: '/v1/devices',
48
+ PAIR: '/v1/pair',
49
+ });
50
+
51
+ /** Terminal verdicts the relay reports. Passed through, never re-derived. */
52
+ export const TERMINAL = Object.freeze([
53
+ 'consistent',
54
+ 'divergent',
55
+ 'divergent-platform',
56
+ 'failed',
57
+ 'partial',
58
+ 'unverifiable',
59
+ 'timeout',
60
+ 'cancelled',
61
+ 'degraded',
62
+ ]);
63
+
64
+ export function isTerminal(verdict) {
65
+ return typeof verdict === 'string' && TERMINAL.includes(verdict);
66
+ }
67
+
68
+ /**
69
+ * The verdict in a status or report body, wherever the relay put it.
70
+ *
71
+ * A status read nests it under `aggregate`; a report puts it at the top level. Both are checked
72
+ * because both are real, and the shape difference is the relay's, not a mistake to normalise
73
+ * away silently.
74
+ */
75
+ export function verdictOf(body) {
76
+ if (body === null || typeof body !== 'object') return null;
77
+ const candidate = body.aggregate?.status ?? body.status ?? body.verdict ?? null;
78
+ return typeof candidate === 'string' && candidate !== '' ? candidate : null;
79
+ }
80
+
81
+ /** The per-machine rows in a status or report body. `leases` on a status, `machines` on a report. */
82
+ export function rowsOf(body) {
83
+ if (body === null || typeof body !== 'object') return [];
84
+ for (const key of ['machines', 'leases', 'results']) {
85
+ if (Array.isArray(body[key])) return body[key];
86
+ }
87
+ return [];
88
+ }
89
+
90
+ /**
91
+ * Create the executor.
92
+ *
93
+ * @param {object} options
94
+ * @param {string} options.rabbitUrl
95
+ * @param {string|null} [options.token] The operator token. **Required to dispatch.**
96
+ * @param {string|null} [options.deviceToken] The device token. **Required to read a task.**
97
+ * Taken from the identity file when not given, because an install already has one.
98
+ * @param {number} [options.timeoutMs] Per HTTP request.
99
+ * @param {typeof fetch} [options.fetch]
100
+ * @param {() => number} [options.now]
101
+ * @param {(ms: number) => Promise<void>} [options.sleep]
102
+ * @param {(event: string, detail?: object) => void} [options.audit]
103
+ */
104
+ export function createFleetExecutor(options) {
105
+ const rabbitUrl = options.rabbitUrl;
106
+ if (typeof rabbitUrl !== 'string' || rabbitUrl === '') {
107
+ throw new TypeError('createFleetExecutor requires a rabbitUrl');
108
+ }
109
+ const token = options.token ?? null;
110
+ const deviceToken = options.deviceToken ?? null;
111
+ const timeoutMs = options.timeoutMs ?? 30_000;
112
+ const doFetch = options.fetch ?? globalThis.fetch;
113
+ const now = options.now ?? (() => Date.now());
114
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
115
+ const audit = options.audit ?? (() => {});
116
+
117
+ /** One relay call. Resolves for every HTTP status: a refusal is an answer, not an exception. */
118
+ async function relay(pathname, { method = 'GET', body, accept = 'application/json', credential = 'operator' } = {}) {
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
121
+ const url = new URL(pathname, rabbitUrl.endsWith('/') ? rabbitUrl : `${rabbitUrl}/`).toString();
122
+ const headers = { accept };
123
+ // Measured: dispatch authenticates with the operator token, task reads with the device token.
124
+ // Sending the wrong one is a 401 whose message names the credential it wanted.
125
+ const bearer = credential === 'device' ? deviceToken : token;
126
+ if (bearer) headers.authorization = `Bearer ${bearer}`;
127
+ if (body !== undefined) headers['content-type'] = 'application/json';
128
+ try {
129
+ const response = await doFetch(url, {
130
+ method,
131
+ headers,
132
+ body: body === undefined ? undefined : JSON.stringify(body),
133
+ signal: controller.signal,
134
+ });
135
+ const text = await response.text();
136
+ let json = null;
137
+ try {
138
+ json = JSON.parse(text);
139
+ } catch {
140
+ json = null;
141
+ }
142
+ return { status: response.status, ok: response.ok, json, text };
143
+ } catch (error) {
144
+ const cause = error?.cause;
145
+ const detail = cause ? `${cause.code ?? ''}${cause.code ? ' ' : ''}${cause.message ?? cause}`.trim() : '';
146
+ return { status: 0, ok: false, json: null, text: '', error: `${error?.message ?? error}${detail ? ` [${detail}]` : ''}` };
147
+ } finally {
148
+ clearTimeout(timer);
149
+ }
150
+ }
151
+
152
+ /** Why the relay refused, in the caller's terms. */
153
+ function refusal(result) {
154
+ const code = result.json?.error?.code ?? null;
155
+ const message = result.json?.error?.message ?? result.error ?? result.text.slice(0, 200);
156
+ if (result.status === 401 || code === 'OPERATOR_REQUIRED') {
157
+ // The relay names the credential it wanted; the two cases need different fixes and one of
158
+ // them is a bug in this file, so they are not merged.
159
+ if (String(message).includes('device_token')) {
160
+ return {
161
+ reason: 'device_token_required',
162
+ text: '读取任务需要 device token(不是 operator token)。这台机器还没配对,或者身份文件里没有 token。',
163
+ code,
164
+ message,
165
+ };
166
+ }
167
+ return {
168
+ reason: 'operator_required',
169
+ text: '这个 relay 要求 operator token 才能派发任务,而发过去的那个没被接受。设备 token 无法派发任务。',
170
+ code,
171
+ message,
172
+ };
173
+ }
174
+ if (code === 'NO_ONLINE_DEVICE') {
175
+ return { reason: 'no_online_device', text: '现在没有机器在线,先在各机器上启动 agent。', code, message };
176
+ }
177
+ return { reason: 'refused', text: `relay 拒绝了请求(HTTP ${result.status}${code ? ` ${code}` : ''}):${message}`, code, message };
178
+ }
179
+
180
+ /**
181
+ * Dispatch one task.
182
+ *
183
+ * @param {string[]} argv
184
+ * @param {object} [extra] Any further relay fields (mode, write, cwd_rel, …).
185
+ */
186
+ async function run(argv, extra = {}) {
187
+ if (!Array.isArray(argv) || argv.length === 0) {
188
+ return { ok: false, reason: 'bad_argv', text: '需要一条命令,例如 /run node --test' };
189
+ }
190
+ const result = await relay(RELAY.TASK, {
191
+ method: 'POST',
192
+ body: { command_argv: argv, mode: extra.mode ?? 'replicate', ...extra },
193
+ credential: 'operator',
194
+ });
195
+ if (!result.ok) {
196
+ const bad = refusal(result);
197
+ audit('fleet/refused', { status: result.status, ...bad });
198
+ return { ok: false, ...bad };
199
+ }
200
+ const taskId = result.json?.task_id ?? result.json?.id ?? null;
201
+ if (typeof taskId !== 'string' || taskId === '') {
202
+ return { ok: false, reason: 'no_task_id', text: 'relay 接受了请求但没有返回 task_id,无法跟踪结果。' };
203
+ }
204
+ audit('fleet/dispatched', { task_id: taskId, argv });
205
+ return { ok: true, taskId, raw: result.json };
206
+ }
207
+
208
+ /** One status read. Reads with the device token, which is what the relay accepts here. */
209
+ async function status(taskId) {
210
+ const result = await relay(RELAY.TASK_STATUS(taskId), { credential: 'device' });
211
+ if (!result.ok) return { ok: false, ...refusal(result) };
212
+ return { ok: true, verdict: verdictOf(result.json), rows: rowsOf(result.json), raw: result.json };
213
+ }
214
+
215
+ /**
216
+ * Wait for a terminal verdict, or give up and say so.
217
+ *
218
+ * `timeout` is returned as a verdict rather than thrown: "it is still running" is a real
219
+ * answer to give someone on a phone, and it is not a failure of the request.
220
+ */
221
+ async function wait(taskId, { maxMs = 120_000, pollMs = 1500 } = {}) {
222
+ const deadline = now() + maxMs;
223
+ let last = null;
224
+ while (now() < deadline) {
225
+ const read = await status(taskId);
226
+ if (!read.ok) return read;
227
+ last = read;
228
+ if (isTerminal(read.verdict)) {
229
+ return { ok: true, verdict: read.verdict, rows: read.rows, body: read.raw, terminal: true };
230
+ }
231
+ await sleep(pollMs);
232
+ }
233
+ return {
234
+ ok: true,
235
+ verdict: 'timeout',
236
+ rows: last?.rows ?? [],
237
+ body: last?.raw ?? null,
238
+ terminal: false,
239
+ note: 'still running when the wait expired',
240
+ };
241
+ }
242
+
243
+ /** The per-machine report. Also reads with the device token. */
244
+ async function report(taskId) {
245
+ const result = await relay(RELAY.TASK_REPORT(taskId), { credential: 'device' });
246
+ if (!result.ok) return { ok: false, ...refusal(result) };
247
+ return { ok: true, verdict: verdictOf(result.json), rows: rowsOf(result.json), raw: result.json };
248
+ }
249
+
250
+ /** The machines the relay currently holds. */
251
+ async function devices() {
252
+ // Measured: /v1/devices wants the device token too, on this relay build. The operator token
253
+ // still authorises the dispatch; the reads all want the device identity.
254
+ const result = await relay(RELAY.DEVICES, { credential: 'device' });
255
+ if (!result.ok) return { ok: false, ...refusal(result) };
256
+ const list = Array.isArray(result.json?.devices) ? result.json.devices : [];
257
+ return { ok: true, devices: list };
258
+ }
259
+
260
+ return {
261
+ run,
262
+ wait,
263
+ status,
264
+ report,
265
+ devices,
266
+ relay,
267
+ /** Whether a task read is possible at all. Said up front rather than as a 401 later. */
268
+ get canReadTasks() {
269
+ return typeof deviceToken === 'string' && deviceToken !== '';
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Hand-off: what happens when the machine holding a task does not answer (v0.7.7).
3
+ *
4
+ * ## What this decides
5
+ *
6
+ * "The default PC did not reply, so another PC takes over" is a rule with a sharp edge that is
7
+ * easy to miss while implementing it, and the edge is this:
8
+ *
9
+ * > **Hand-off turns execution from at-most-once into at-least-once.**
10
+ *
11
+ * A command may now run on **two** machines. That is harmless for `node --test` and it is not
12
+ * harmless for anything that writes — a `write: true` task could apply its change twice, and the
13
+ * second application happens on a machine that has no idea the first one already ran. The design
14
+ * document forbids automatic hand-off for writing tasks until a fencing token exists, and this
15
+ * module **is** that prohibition: `write: true` plus `halt: 'now'` is refused outright rather than
16
+ * quietly allowed.
17
+ *
18
+ * ## The four cases, and why each needs its own answer
19
+ *
20
+ * | case | what it means | what happens |
21
+ * |---|---|---|
22
+ * | **done** | the machine answered | nothing; the aggregate is decided |
23
+ * | **silent** | connected but its heartbeat stopped | takes over only under `halt: 'now'` |
24
+ * | **offline** | the connection dropped | same as silent, but immediately — this is the main path |
25
+ * | **late** | the original reported *after* a takeover | recorded, and **never** overwrites the successor |
26
+ *
27
+ * The late case is the one worth stating: a result that arrives after its lease was reassigned is
28
+ * **evidence**, not an answer. Overwriting the successor's result with it would mean the verdict
29
+ * depends on which machine was slower to reply, which is not a property anyone asked for.
30
+ *
31
+ * ## Refusals are named
32
+ *
33
+ * "Why did nothing take over" has several different answers — the task was cancelled, the caller
34
+ * asked not to halt, the task writes, there is no other machine — and they have different fixes.
35
+ * Collapsing them into "no hand-off" is how an operator concludes the feature is broken.
36
+ */
37
+
38
+ /** Why a takeover did not happen. Every value is a thing an operator can act on. */
39
+ export const NO_HANDOFF = Object.freeze({
40
+ /** Nothing to do: the lease finished. */
41
+ DONE: 'done',
42
+ /** The caller asked the relay not to reassign (`halt: 'never'`). */
43
+ HALT_NEVER: 'halt_never',
44
+ /** The task writes, so a second run could apply the same change twice. */
45
+ WRITE_NOT_IDEMPOTENT: 'write_not_idempotent',
46
+ /** The task was cancelled; taking it over would resurrect it. */
47
+ CANCELLED: 'cancelled',
48
+ /** No other machine in this fleet is online. */
49
+ NO_ALTERNATE_MACHINE: 'no_alternate_machine',
50
+ /** Every other machine has already tried this slice. */
51
+ ALL_TRIED: 'all_tried',
52
+ /** The lease has not expired yet, so the holder is presumed to be working. */
53
+ NOT_YET_EXPIRED: 'not_yet_expired',
54
+ });
55
+
56
+ /** Which attempt a lease is on. Kept explicit so a late result can be recognised by number. */
57
+ export const LEASE_STATE = Object.freeze({
58
+ OFFERED: 'offered',
59
+ CLAIMED: 'claimed',
60
+ DONE: 'done',
61
+ EXPIRED: 'expired',
62
+ REFUSED: 'refused',
63
+ CANCELLED: 'cancelled',
64
+ });
65
+
66
+ /**
67
+ * Is this lease finished, one way or another?
68
+ *
69
+ * `expired` and `refused` are terminal for the *lease* but not for the *task* — the slice may
70
+ * still be handed to another machine, which is the whole point. `done` is terminal for both.
71
+ */
72
+ export function isLeaseTerminal(state) {
73
+ return state === LEASE_STATE.DONE || state === LEASE_STATE.CANCELLED;
74
+ }
75
+
76
+ /**
77
+ * Should this slice be handed to another machine?
78
+ *
79
+ * @param {object} input
80
+ * @param {{state: string, machine_id: string, attempt: number, lease_until_ms?: number}} input.lease
81
+ * @param {boolean} input.write Whether the task writes. Decides whether hand-off is safe at all.
82
+ * @param {'never'|'now'} input.halt The caller's stated policy.
83
+ * @param {boolean} input.cancelled
84
+ * @param {string[]} input.triedMachineIds Machines that have already held this slice.
85
+ * @param {Array<{machine_id: string, fleet_id?: string, online?: boolean, caps?: object}>} input.candidates
86
+ * Devices that may take over. Already fleet-filtered by the caller.
87
+ * @param {number} [input.now]
88
+ * @param {number} [input.takeoverGraceMs] Extra delay before a takeover, so a machine that is
89
+ * merely slow is not pre-empted. A slow machine is not a dead one.
90
+ */
91
+ export function shouldHandOff({
92
+ lease,
93
+ write,
94
+ halt,
95
+ cancelled,
96
+ triedMachineIds,
97
+ candidates,
98
+ now = Date.now(),
99
+ takeoverGraceMs = 0,
100
+ }) {
101
+ if (lease === null || typeof lease !== 'object') {
102
+ return { takeOver: false, reason: NO_HANDOFF.DONE, text: '没有租约。' };
103
+ }
104
+ if (cancelled === true) {
105
+ // Reassigning a cancelled task would resurrect it, and the operator cancelled it on purpose.
106
+ return { takeOver: false, reason: NO_HANDOFF.CANCELLED, text: '任务已取消,不会重派。' };
107
+ }
108
+ if (isLeaseTerminal(lease.state)) {
109
+ return { takeOver: false, reason: NO_HANDOFF.DONE, text: '这一片已经有结果了。' };
110
+ }
111
+ if (halt !== 'now') {
112
+ // The default. A stopped machine is *recorded* rather than replaced, because for anything
113
+ // that writes, a second runner is worse than a missing result.
114
+ return { takeOver: false, reason: NO_HANDOFF.HALT_NEVER, text: '这个任务没有开启接手(halt 不是 now)。' };
115
+ }
116
+ if (write === true) {
117
+ // The sharp edge. Refused, not warned about: a second application of the same write is a
118
+ // data-loss shape, and the caller cannot tell it happened from the verdict alone.
119
+ return {
120
+ takeOver: false,
121
+ reason: NO_HANDOFF.WRITE_NOT_IDEMPOTENT,
122
+ text: '这是写任务,自动接手会让同一条命令执行两次,因此拒绝。',
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A lease that has not expired is presumed to be progressing.
128
+ *
129
+ * Two separate questions, in this order — and the first version of this got the arithmetic
130
+ * backwards by adding the grace to the wrong side, which a test caught:
131
+ *
132
+ * 1. **Is the lease still valid?** If `now < lease_until`, the holder is within its window and
133
+ * nothing is taken over, whatever the grace says.
134
+ * 2. **Has the grace elapsed since it expired?** Only then is a takeover considered. A machine
135
+ * that is merely slow is not a machine that is gone.
136
+ *
137
+ * The boundary is inclusive on the decline side, so the outcome does not depend on which side of
138
+ * a millisecond the scheduler landed.
139
+ */
140
+ const until = Number(lease.lease_until_ms);
141
+ if (Number.isFinite(until)) {
142
+ if (now < until) {
143
+ return { takeOver: false, reason: NO_HANDOFF.NOT_YET_EXPIRED, text: '租约还没到期,先假定它在正常工作。' };
144
+ }
145
+ if (now < until + takeoverGraceMs) {
146
+ return { takeOver: false, reason: NO_HANDOFF.NOT_YET_EXPIRED, text: '租约刚到期,还在宽限期内。' };
147
+ }
148
+ }
149
+
150
+ const tried = new Set(Array.isArray(triedMachineIds) ? triedMachineIds : []);
151
+ // The current holder is "tried" whether or not the caller said so: handing the slice back to the
152
+ // machine that just went silent is the one reassignment guaranteed to be useless.
153
+ if (typeof lease.machine_id === 'string') tried.add(lease.machine_id);
154
+
155
+ // "Available" means untried **and online**. An offline machine is not an alternate, and counting
156
+ // it as one is what would make the two refusals below indistinguishable.
157
+ const available = (Array.isArray(candidates) ? candidates : []).filter(
158
+ (c) => c && typeof c.machine_id === 'string' && !tried.has(c.machine_id) && c.online !== false,
159
+ );
160
+ const alternate = available[0];
161
+
162
+ if (alternate === undefined) {
163
+ /**
164
+ * Two different situations, and the distinction is *how many machines exist*, not how many
165
+ * are untried.
166
+ *
167
+ * A test found the first version of this checking "is there an untried machine", which is
168
+ * always false in the all-tried case — so the two refusals were indistinguishable and the
169
+ * named reason was unreachable. The question an operator is actually asking is:
170
+ *
171
+ * * the fleet has other machines and every one of them has had a turn → `ALL_TRIED`
172
+ * (fix: wait, or make one succeed)
173
+ * * the fleet holds only this machine → `NO_ALTERNATE_MACHINE`
174
+ * (fix: add a machine to the fleet)
175
+ *
176
+ * The current holder is excluded from *availability* but not from *existence*: a fleet of one
177
+ * has no alternate and never will.
178
+ */
179
+ const others = (Array.isArray(candidates) ? candidates : []).filter(
180
+ (c) => c && typeof c.machine_id === 'string' && c.machine_id !== lease.machine_id,
181
+ );
182
+ return others.length > 0
183
+ ? { takeOver: false, reason: NO_HANDOFF.ALL_TRIED, text: '这条舰队里其他机器都已经试过这一片了。' }
184
+ : { takeOver: false, reason: NO_HANDOFF.NO_ALTERNATE_MACHINE, text: '这条舰队里没有其他机器可以接手。' };
185
+ }
186
+
187
+ return {
188
+ takeOver: true,
189
+ machineId: alternate.machine_id,
190
+ attempt: (Number.isFinite(lease.attempt) ? lease.attempt : 1) + 1,
191
+ text: `交给 ${alternate.machine_name ?? alternate.machine_id} 接手(第 ${(Number.isFinite(lease.attempt) ? lease.attempt : 1) + 1} 次尝试)。`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Which attempt's result counts, when more than one arrived.
197
+ *
198
+ * The rule is **highest attempt wins**, not "last to arrive" and not "first to arrive". Arrival
199
+ * order is a property of the network, and a verdict that depends on it is a verdict nobody can
200
+ * reason about. The attempt number is assigned by the relay, so it is the one ordering that does
201
+ * not depend on who was faster.
202
+ *
203
+ * Results from superseded attempts are **returned rather than dropped**: a late result from the
204
+ * machine that went silent is diagnostic — it says the machine was alive and slow, not dead —
205
+ * and discarding it turns a diagnosable situation into an unexplained one.
206
+ */
207
+ export function reconcileResults(results) {
208
+ const list = (Array.isArray(results) ? results : []).filter((r) => r && typeof r === 'object');
209
+ if (list.length === 0) return { winner: null, superseded: [], arrivedLate: [] };
210
+
211
+ // A missing attempt number is treated as **0**, not as infinity. A frame with no attempt is an
212
+ // old frame whose attempt was not recorded — treating it as the newest would let a stale result
213
+ // from a superseded lease win the aggregate, which is the exact inversion this function exists
214
+ // to prevent. A test asserts the direction.
215
+ const attemptOf = (r) => (Number.isFinite(r.attempt) ? r.attempt : 0);
216
+
217
+ let winner = list[0];
218
+ for (const result of list) {
219
+ const better = attemptOf(result);
220
+ const best = attemptOf(winner);
221
+ if (better > best) {
222
+ winner = result;
223
+ } else if (better === best) {
224
+ // Same attempt twice: only possible when one machine delivered twice. Either delivery is
225
+ // equivalent, so the first is kept — which makes the outcome stable across a replay of the
226
+ // ledger rather than dependent on ingest order.
227
+ continue;
228
+ }
229
+ }
230
+
231
+ const superseded = list.filter((r) => r !== winner && attemptOf(r) < attemptOf(winner));
232
+ const arrivedLate = superseded.filter((r) => r.arrivedAfterHandoff === true);
233
+ return { winner, superseded, arrivedLate };
234
+ }
235
+
236
+ /**
237
+ * The slice's story, in the order the relay saw it, for a report.
238
+ *
239
+ * Kept as a list rather than a summary because "which machine tried, in what order, and why the
240
+ * next one did or did not" is the question an operator actually asks, and a summarised answer
241
+ * cannot be interrogated.
242
+ */
243
+ export function handoffTrace(events) {
244
+ const list = Array.isArray(events) ? events : [];
245
+ return list
246
+ .filter((e) => e && typeof e === 'object')
247
+ .map((e) => {
248
+ const machine = e.machine_name ?? e.machine_id ?? '?';
249
+ if (e.event === 'offer') return `attempt ${e.attempt ?? '?'}: offered to ${machine}`;
250
+ if (e.event === 'done') return `attempt ${e.attempt ?? '?'}: ${machine} answered (${e.status ?? 'ok'})`;
251
+ if (e.event === 'expired') return `attempt ${e.attempt ?? '?'}: ${machine} went silent`;
252
+ if (e.event === 'refused') return `attempt ${e.attempt ?? '?'}: ${machine} refused (${e.reason ?? '?'})`;
253
+ if (e.event === 'handoff') return `attempt ${e.attempt ?? '?'}: handed to ${machine}`;
254
+ if (e.event === 'handoff-declined') return `no hand-off: ${e.reason ?? '?'}`;
255
+ if (e.event === 'late') return `attempt ${e.attempt ?? '?'}: ${machine} reported late, not counted`;
256
+ return `${e.event ?? '?'}: ${machine}`;
257
+ });
258
+ }