@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.
- package/CHANGELOG.md +1176 -0
- package/HANDOFF.md +173 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +325 -308
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +257 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +34 -22
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +401 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-join.mjs +164 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +258 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-fleet-bridge.mjs +197 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +440 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- 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
|
+
}
|