@twinsearth/w2m-dsh-plugin 1.9.10 → 1.9.11

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.
@@ -1,649 +1,651 @@
1
- /**
2
- * W2M Rabbit relay — §6.3 six-state aggregation + markdown/JSON reports.
3
- *
4
- * The step order is part of the contract: Step 0 → Step 1 → Step 2 → Step 3.
5
- * Steps are NEVER merged, because the order changes the verdict.
6
- */
7
-
8
- import {
9
- COMPARABLE_FIELDS,
10
- FAILURE_STATUSES,
11
- ProtocolError,
12
- PROTOCOL_VERSION,
13
- jcs,
14
- rfc3339,
15
- } from './state.mjs';
16
-
17
- /** Aggregate statuses this module can return. */
18
- export const AGGREGATE_STATUSES = Object.freeze([
19
- // ---- the six verdicts of §6.3 ----
20
- 'consistent',
21
- 'divergent',
22
- 'divergent-platform',
23
- 'failed',
24
- 'partial',
25
- 'unverifiable',
26
- // ---- added in v0.3.0 ----
27
- //
28
- // These three are *verdicts*, not phase flags, which is why they belong in this list rather than
29
- // beside `pending`. Each answers a question the original six could not express:
30
- //
31
- // timeout nobody reported and the work is no longer waiting on anybody. `partial` said "some
32
- // machines are still out"; `timeout` says "we waited as long as we agreed to wait,
33
- // and the machines that never answered are not coming back".
34
- // cancelled the operator stopped it. Distinct from `failed`: nothing went wrong, we chose this.
35
- // A cancellation must not be reported as a defect, or every intentional stop becomes
36
- // noise that trains people to ignore the status.
37
- // degraded every machine reported, some succeeded and some failed. `partial` used to carry
38
- // this, but `partial` also means "still in flight", so a reader could not tell a
39
- // finished task in a bad state from one that had not finished. Both are now explicit.
40
- 'timeout',
41
- 'cancelled',
42
- 'degraded',
43
- // ---- not verdicts: no terminal answer exists yet, or none was ever possible ----
44
- 'refused',
45
- 'pending',
46
- ]);
47
-
48
- /** Comparable fields that are derived purely from the stdout byte stream. */
49
- const STDOUT_DERIVED_FIELDS = Object.freeze([
50
- 'stdout_sha256',
51
- 'stdout_bytes',
52
- 'stdout_normalized_sha256',
53
- ]);
54
-
55
- function valueOf(envelope, field) {
56
- return envelope === null || envelope === undefined ? undefined : envelope[field];
57
- }
58
-
59
- function canon(value) {
60
- return value === undefined ? '<absent>' : jcs(value);
61
- }
62
-
63
- function truncate(text, max = 160) {
64
- const s = typeof text === 'string' ? text : String(text);
65
- return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
66
- }
67
-
68
- /**
69
- * Warning codes that explain *why* a machine failed, in a sentence a reader can act on.
70
- *
71
- * The envelope already carried these codes; nothing downstream read them. A spawn failure was
72
- * therefore rendered as `status: crashed`, `exit_code: null`, and an empty 说明 column -- the three
73
- * facts that together say nothing. Measured on macOS: a task whose `argv[0]` was not on PATH came
74
- * back exactly that way, with `warnings: ["PATH_INVALID"]` sitting unread in the envelope.
75
- *
76
- * Only codes whose meaning is unambiguous belong here. Inventing prose for a code we do not
77
- * understand would be worse than the empty cell, so anything unlisted stays unreported.
78
- */
79
- const FAILURE_WARNING_REASONS = {
80
- PATH_INVALID: 'the command could not be started: an executable in the argv was not found (PATH_INVALID)',
81
- };
82
-
83
- /**
84
- * Fill in a failed machine's `reasons` from its execution warnings.
85
- *
86
- * `reasons` is what the human report renders in the 说明 column, and it was only ever populated by
87
- * anchor/comparison problems -- so a machine that never started had nothing to say for itself.
88
- *
89
- * @param {object} m Machine record being aggregated.
90
- * @param {string} status The envelope's execution status.
91
- */
92
- function explainExecutionFailure(m, status) {
93
- if (m.reasons.length > 0) return;
94
- // `nonzero_exit` is self-explanatory: the command ran and `exit_code` reports what it returned.
95
- // Only a machine that produced no exit status at all needs the warning spelled out.
96
- if (status !== 'crashed' && status !== 'timeout') return;
97
- const warnings = Array.isArray(m.envelope?.warnings) ? m.envelope.warnings : [];
98
- for (const warning of warnings) {
99
- const reason = FAILURE_WARNING_REASONS[warning];
100
- if (reason) {
101
- m.reasons.push(reason);
102
- return;
103
- }
104
- }
105
- }
106
-
107
- /**
108
- * §6.3 six-state aggregation.
109
- *
110
- * @param {object} task Rabbit task record (state.mjs `createTask` result)
111
- * @param {Iterable<object>} resultRecords result records from `RabbitState#results`
112
- * @param {{nowMs?:number}} [opts]
113
- * @returns {object} aggregation report data
114
- */
115
- export function aggregate(task, resultRecords = [], opts = {}) {
116
- if (!task) throw new ProtocolError('NOT_FOUND', 'task is required for aggregation');
117
- const nowMs = opts.nowMs ?? Date.now();
118
-
119
- const records = [...resultRecords].filter((r) => r?.envelope?.task_id === task.task_id);
120
- // latest attempt per machine wins
121
- const byMachine = new Map();
122
- for (const rec of records) {
123
- const prev = byMachine.get(rec.machine_id);
124
- if (!prev || rec.received_at_ms >= prev.received_at_ms) byMachine.set(rec.machine_id, rec);
125
- }
126
-
127
- const expected = {
128
- base_commit: task.base_commit ?? null,
129
- base_tree: task.base_tree ?? null,
130
- command_hash: task.command_hash ?? null,
131
- };
132
-
133
- const machines = [];
134
- for (const lease of task.leases.values()) {
135
- const rec = byMachine.get(lease.machine_id) ?? null;
136
- machines.push({
137
- machine_id: lease.machine_id,
138
- machine_name: lease.machine_name ?? null,
139
- index: lease.index,
140
- attempt: lease.attempt,
141
- lease_state: lease.state,
142
- lease_expired: lease.state === 'expired',
143
- refused_reason: lease.refusal_reason ?? null,
144
- gate_refused: lease.state === 'refused',
145
- // v0.3.3 `broadcast`: a machine that was told the outcome but never handed the command. It has
146
- // no envelope and never will, so without this marker the anchor step classified it `pending`
147
- // and the task could never reach a verdict -- a broadcast would hang forever.
148
- observing: lease.state === 'observing',
149
- result: rec,
150
- envelope: rec?.envelope ?? null,
151
- // v1.2.6: the relay's own signature verdict (bound to the key registered at pairing), not a client claim.
152
- signature_status: rec?.validation?.signature_status ?? null,
153
- outcome: null,
154
- reasons: [],
155
- });
156
- }
157
- // results from machines with no lease (e.g. takeover / manual upload)
158
- for (const [machineId, rec] of byMachine) {
159
- if (machines.some((m) => m.machine_id === machineId)) continue;
160
- machines.push({
161
- machine_id: machineId,
162
- machine_name: rec.envelope.machine_name ?? null,
163
- index: rec.envelope.index ?? null,
164
- attempt: rec.attempt,
165
- lease_state: 'unleased',
166
- lease_expired: false,
167
- refused_reason: rec.envelope.refusal_reason ?? null,
168
- gate_refused: false,
169
- result: rec,
170
- envelope: rec.envelope,
171
- signature_status: rec?.validation?.signature_status ?? null,
172
- outcome: null,
173
- reasons: [],
174
- });
175
- }
176
-
177
- const steps = [];
178
- const notes = [];
179
-
180
- /* -------- Step 0: refusals are booked per machine, they never block others -------- */
181
- const step0 = { step: 0, name: 'refusal', refused: [], passed: [], observing: [] };
182
- for (const m of machines) {
183
- if (m.observing) {
184
- // Not a participant: it was never asked to run, so it cannot refuse, cannot pass, and must
185
- // never be counted as agreeing. Recording the outcome is the whole reason it is listed.
186
- m.outcome = 'observing';
187
- step0.observing.push(m.machine_id);
188
- continue;
189
- }
190
- const refusedByGate = m.gate_refused;
191
- const refusedByEnvelope = m.envelope?.status === 'refused';
192
- if (refusedByGate || refusedByEnvelope) {
193
- m.outcome = 'refused';
194
- m.refusal_reason = refusedByGate
195
- ? (m.refused_reason ?? 'REFUSED')
196
- : (m.envelope.refusal_reason ?? 'REFUSED');
197
- step0.refused.push({ machine_id: m.machine_id, refusal_reason: m.refusal_reason });
198
- } else {
199
- step0.passed.push(m.machine_id);
200
- }
201
- }
202
- steps.push(step0);
203
-
204
- const participants = machines.filter((m) => m.outcome !== 'refused' && m.outcome !== 'observing');
205
-
206
- /* -------- Step 1: anchors — any violation refuses aggregation entirely -------- */
207
- const step1 = { step: 1, name: 'anchors', violations: [], passed: [], pending: [] };
208
- for (const m of participants) {
209
- const env = m.envelope;
210
- if (!env) {
211
- const expired = m.lease_expired;
212
- m.outcome = expired ? 'expired' : 'pending';
213
- step1.pending.push(m.machine_id);
214
- continue;
215
- }
216
- const validation = m.result?.validation ?? { missing: [], problems: [] };
217
- const missing = [...(validation.missing ?? [])];
218
- if (m.result?.effective_status === 'unverifiable' && missing.length === 0) {
219
- m.reasons.push('envelope status = unverifiable');
220
- }
221
- if (missing.length > 0) m.reasons.push(`missing required fields: ${missing.join(',')}`);
222
- /**
223
- * v0.9.3: `base_commit` is enforced only when the dispatcher declared one.
224
- *
225
- * The check used to be unconditional, so a dispatcher with **no** anchor -- one whose own tree is
226
- * not a git work tree, which is the ordinary case for a machine that runs the plugin from its
227
- * profile directory -- sent `base_commit: null`, and every result was then compared against the
228
- * literal `null`. Any machine that *did* have an anchor reported its real commit and was marked
229
- * `unverifiable` for telling the truth:
230
- *
231
- * expected.base_commit : null
232
- * machine reported : a1e7dc042913b96da3ad68…
233
- * reasons : ["base_commit \"a1e7dc04…\" != expected null"]
234
- * notes : ["aggregation refused: Step 1 anchor violation"]
235
- *
236
- * which downgraded **every** dispatch from such a machine, including a wholly successful one, and
237
- * made the verdict unreadable. The anchor exists to answer "did these machines run the same tree";
238
- * with nothing declared there is no such question, and the honest verdict is that the run is
239
- * unanchored -- not that it disagreed with `null`.
240
- *
241
- * `base_tree` three lines below has always read this way (`expected.base_tree !== null && …`), and
242
- * the asymmetry was the bug: the two anchors are the same kind of statement and one of them
243
- * tolerated absence while the other did not.
244
- *
245
- * An absent anchor is still *reported* rather than silently ignored -- `unanchored: true` on the
246
- * aggregate and in the machine's own record -- because "these two machines agreed" and "nobody
247
- * said what they should have agreed on" must not read the same to an operator.
248
- */
249
- if (expected.base_commit !== null && canon(valueOf(env, 'base_commit')) !== canon(expected.base_commit)) {
250
- m.reasons.push(`base_commit ${truncate(canon(env.base_commit), 24)} != expected ${truncate(canon(expected.base_commit), 24)}`);
251
- }
252
- if (expected.base_tree !== null && canon(valueOf(env, 'pre_tree_fingerprint')) !== canon(expected.base_tree)) {
253
- m.reasons.push('pre_tree_fingerprint != base_tree');
254
- }
255
- if (canon(valueOf(env, 'command_hash')) !== canon(expected.command_hash)) {
256
- m.reasons.push('command_hash mismatch');
257
- }
258
- if (env.status === 'unverifiable' && m.reasons.length === 0) {
259
- m.reasons.push('envelope status = unverifiable');
260
- }
261
- if (m.reasons.length > 0) {
262
- m.outcome = 'unverifiable';
263
- step1.violations.push({ machine_id: m.machine_id, reasons: [...m.reasons] });
264
- } else {
265
- m.outcome = 'ok-pending-step2';
266
- step1.passed.push(m.machine_id);
267
- }
268
- }
269
- steps.push(step1);
270
-
271
- const runnable = participants.filter((m) => m.outcome === 'ok-pending-step2');
272
- const expiredMachines = participants.filter((m) => m.outcome === 'expired');
273
- const pendingMachines = participants.filter((m) => m.outcome === 'pending');
274
-
275
- let status;
276
- const step2 = { step: 2, name: 'execution', ok: [], failed: [], partial: false, expired: [], pending: [] };
277
- const step3 = { step: 3, name: 'comparison', differences: [], consistent: false, platform_only: false };
278
-
279
- if (step1.violations.length > 0) {
280
- /* §6.3 Step 1: 拒绝汇总 */
281
- status = 'unverifiable';
282
- step2.skipped = 'step1_unverifiable';
283
- step3.skipped = 'step1_unverifiable';
284
- notes.push('aggregation refused: Step 1 anchor violation (see unverifiable machines)');
285
- } else if (task.cancelled === true) {
286
- /* v0.3.0: the operator stopped it. A chosen stop is not a defect. */
287
- //
288
- // Checked before `unverifiable`-adjacent outcomes and before any timeout: once someone has
289
- // deliberately stopped the work, reporting "failed" or "timed out" would describe a decision as
290
- // a malfunction, and enough of those trains a reader to ignore the status entirely.
291
- status = 'cancelled';
292
- step2.skipped = 'task_cancelled';
293
- step3.skipped = 'task_cancelled';
294
- notes.push(
295
- task.cancel_reason ? `cancelled by the operator: ${task.cancel_reason}` : 'cancelled by the operator',
296
- );
297
- } else if (runnable.length === 0 && expiredMachines.length === 0 && pendingMachines.length === 0 && step0.refused.length > 0) {
298
- status = 'refused';
299
- step2.skipped = 'all_refused';
300
- step3.skipped = 'all_refused';
301
- } else if (runnable.length === 0 && expiredMachines.length === 0) {
302
- /* v0.3.0: distinguish "still waiting" from "waited long enough". */
303
- //
304
- // `pending` means the task is live and more envelopes may still arrive. Once the deadline the
305
- // caller set has passed and machines are still silent, that is a verdict, and calling it
306
- // `pending` forever leaves a reader unable to tell a stuck task from a slow one.
307
- const deadline = Number.isFinite(task.deadline_ms) ? task.deadline_ms : 0;
308
- if (deadline > 0 && nowMs >= deadline) {
309
- status = 'timeout';
310
- step2.skipped = 'deadline_passed';
311
- step3.skipped = 'deadline_passed';
312
- notes.push(
313
- `no result envelope by the ${task.timeout_ms}ms deadline (${pendingMachines.length} machine(s) still silent)`,
314
- );
315
- } else {
316
- status = 'pending';
317
- step2.skipped = 'no_results_yet';
318
- step3.skipped = 'no_results_yet';
319
- notes.push('no result envelope has been received yet');
320
- }
321
- } else {
322
- for (const m of runnable) {
323
- const s = m.envelope.status;
324
- if (s === 'ok') { m.outcome = 'ok'; step2.ok.push(m.machine_id); }
325
- else {
326
- // {nonzero_exit,timeout,crashed} plus anything outside the §5.1 enum
327
- m.outcome = 'failed';
328
- explainExecutionFailure(m, s);
329
- step2.failed.push({
330
- machine_id: m.machine_id,
331
- status: s,
332
- exit_code: m.envelope.exit_code ?? null,
333
- known_failure: FAILURE_STATUSES.includes(s),
334
- });
335
- }
336
- }
337
- for (const m of expiredMachines) step2.expired.push(m.machine_id);
338
- for (const m of pendingMachines) step2.pending.push(m.machine_id);
339
-
340
- if (step2.expired.length > 0) {
341
- /* §4.3: halt=never → booked and marked partial */
342
- status = 'partial';
343
- step2.partial = true;
344
- notes.push(`lease expired on ${step2.expired.length} machine(s) → partial (§4.3)`);
345
- step3.skipped = 'lease_expired';
346
- } else if (step2.pending.length > 0) {
347
- status = 'partial';
348
- step2.partial = true;
349
- notes.push(`waiting for ${step2.pending.length} machine(s) to report`);
350
- step3.skipped = 'machines_pending';
351
- } else if (step2.failed.length === 0 && step2.ok.length > 0) {
352
- /* §6.3 Step 2: 全部 ok → Step 3 */
353
- const okMachines = runnable.filter((m) => m.outcome === 'ok');
354
-
355
- // Compare **within a shard**, not across the whole task.
356
- //
357
- // `index` is a comparable field, and `split` assigns it per machine by design -- so comparing
358
- // every machine against every other made `index` differ on any successful fan-out, and every
359
- // split task was reported `divergent`. That is a false alarm on the normal case: `divergent`
360
- // tells an operator the machines disagreed, when in fact they did exactly what was asked.
361
- //
362
- // Grouping by `index` fixes it without special-casing the field: machines meant to produce the
363
- // same output are compared with each other, and machines given different slices are not
364
- // compared at all. For `replicate` every machine has index 0, so there is a single group and
365
- // the behaviour is exactly as before -- which is why the existing 140 relay tests still pass
366
- // unchanged.
367
- const shards = new Map();
368
- for (const m of okMachines) {
369
- const key = m.index ?? 0;
370
- if (!shards.has(key)) shards.set(key, []);
371
- shards.get(key).push(m);
372
- }
373
-
374
- const comparable = {};
375
- for (const field of COMPARABLE_FIELDS) {
376
- comparable[field] = {};
377
- for (const m of okMachines) {
378
- const v = valueOf(m.envelope, field);
379
- if (v !== undefined) comparable[field][m.machine_id] = v;
380
- }
381
- }
382
-
383
- /**
384
- * Fields that differ within some shard, with the shard that disagreed.
385
- *
386
- * `index` is reported with the shard so a genuine mixed-mode divergence ("shard 0 agreed,
387
- * shard 1 did not") stays unambiguous.
388
- */
389
- const differences = [];
390
- for (const [shardIndex, members] of shards) {
391
- if (members.length < 2) continue; // one machine cannot disagree with itself
392
- for (const field of COMPARABLE_FIELDS) {
393
- const values = members.map((m) => canon(valueOf(m.envelope, field)));
394
- if (values.some((v) => v !== values[0])) {
395
- differences.push({
396
- field,
397
- index: shardIndex,
398
- values: Object.fromEntries(members.map((m) => [m.machine_id, valueOf(m.envelope, field)])),
399
- });
400
- }
401
- }
402
- }
403
- step3.comparable = comparable;
404
- step3.differences = differences;
405
-
406
- /** Metadata drift within any shard, for the `divergent-platform` branch. */
407
- const metaDiffers = (field) => {
408
- for (const members of shards.values()) {
409
- if (members.length < 2) continue;
410
- const vals = members.map((m) => canon(valueOf(m.envelope, field)));
411
- if (vals.some((v) => v !== vals[0])) return true;
412
- }
413
- return false;
414
- };
415
- const platformMetaDiffers = metaDiffers('toolchain') || metaDiffers('platform');
416
-
417
- if (differences.length === 0) {
418
- status = 'consistent';
419
- step3.consistent = true;
420
- } else if (platformMetaDiffers && differences.every((d) => STDOUT_DERIVED_FIELDS.includes(d.field))) {
421
- /* §6.3 Step 3: expected — only toolchain/platform differ and the drift is stdout-only */
422
- status = 'divergent-platform';
423
- step3.platform_only = true;
424
- notes.push('divergence is confined to stdout bytes across differing toolchain/platform (expected, no alert)');
425
- } else {
426
- status = 'divergent';
427
- notes.push(`divergent fields: ${differences.map((d) => d.field).join(', ')}`);
428
- }
429
- } else if (step2.ok.length > 0) {
430
- /* v0.3.0 §6.3 Step 2: 部分 ok、部分失败 → degraded */
431
- //
432
- // This used to be `partial`, which was wrong in a way that mattered: `partial` is also the
433
- // answer while machines are still out, so "finished, and half of it failed" was
434
- // indistinguishable from "still running" in the one field a reader looks at first. Every
435
- // machine here has reported, so the task is over and the verdict is `degraded` -- some of the
436
- // work succeeded, the rest did not, and nothing further is coming.
437
- status = 'degraded';
438
- step2.partial = true;
439
- notes.push(`${step2.ok.length} machine(s) succeeded and ${step2.failed.length} failed`);
440
- } else {
441
- /* §6.3 Step 2: 全部失败 → failed */
442
- status = 'failed';
443
- }
444
- }
445
-
446
- steps.push(step2);
447
- steps.push(step3);
448
-
449
- return {
450
- protocol_version: PROTOCOL_VERSION,
451
- task_id: task.task_id,
452
- status,
453
- computed_at: rfc3339(nowMs),
454
- mode: task.mode,
455
- index_total: task.index_total,
456
- halt: task.halt,
457
- write: task.write,
458
- created_at: rfc3339(task.created_at_ms),
459
- created_by: task.created_by ?? null,
460
- cancelled: task.cancelled === true,
461
- expected,
462
- /**
463
- * v0.9.3: whether this task ran against a declared tree at all.
464
- *
465
- * `expected.base_commit === null` means the dispatcher declared no anchor -- its own tree is not a
466
- * git work tree, which is the ordinary case for the plugin running out of a profile directory. The
467
- * anchor check is therefore skipped rather than compared against the literal `null` (see Step 1).
468
- *
469
- * Reported explicitly because the two situations must not look alike: `consistent: true` from an
470
- * unanchored run says "these machines agreed with each other", **not** "these machines ran the
471
- * tree you meant". An operator comparing results needs to know which of those they are holding.
472
- */
473
- unanchored: expected.base_commit === null,
474
- machines: machines.map((m) => ({
475
- machine_id: m.machine_id,
476
- machine_name: m.machine_name,
477
- index: m.index,
478
- attempt: m.attempt,
479
- lease_state: m.lease_state,
480
- outcome: m.outcome,
481
- status: m.envelope?.status ?? null,
482
- refusal_reason: m.refusal_reason ?? null,
483
- exit_code: m.envelope?.exit_code ?? null,
484
- /* v0.4.0: how this machine's envelope travelled, straight off the stored envelope.
485
- *
486
- * `null` means "the envelope did not say" -- every v0.3.9 result, and the honest answer for a
487
- * relay that simply does not know. These two fields are REPORTED, never judged: they are not
488
- * in COMPARABLE_FIELDS, so a machine that answered over P2P and one that answered over the
489
- * relay still produce the same verdict. A path is not a result. */
490
- transport: m.envelope?.transport ?? null,
491
- p2p: m.envelope?.p2p ?? null,
492
- reasons: m.reasons,
493
- // v1.2.6: the relay's verdict, bound to the key registered at pairing.
494
- signature_status: m.signature_status ?? null,
495
- /**
496
- * v1.2.7: the dataflow facts a reader needs to build a reduce plan.
497
- *
498
- * The aggregate used to publish only a summary per machine, so a client could not see that a result was a blob -- the
499
- * reduce plan existed in tests and read raw envelopes that the production response never carried. These four fields are
500
- * that omission, closed: the blob hash, the file/blob mode, the inline head, and the byte count.
501
- */
502
- blob_sha256: m.envelope?.blob_sha256 ?? null,
503
- output_mode: m.envelope?.output_mode ?? null,
504
- stdout_head: m.envelope?.stdout_head ?? null,
505
- stdout_bytes: m.envelope?.stdout_bytes ?? null,
506
- })),
507
- steps,
508
- differences: step3.differences ?? [],
509
- notes: expected.base_commit === null
510
- ? [...notes, 'no base_commit was declared, so the anchor check did not run: machine agreement here is '
511
- + 'agreement with each other, not with a tree the dispatcher named']
512
- : notes,
513
- counts: {
514
- refused: step0.refused.length,
515
- unverifiable: step1.violations.length,
516
- ok: step2.ok?.length ?? 0,
517
- failed: step2.failed?.length ?? 0,
518
- expired: step2.expired?.length ?? 0,
519
- pending: step2.pending?.length ?? 0,
520
- },
521
- };
522
- }
523
-
524
- /** Convenience wrapper: pull the task + its results out of a RabbitState. */
525
- export function aggregateTask(state, taskId, opts = {}) {
526
- const task = state.getTask(taskId);
527
- if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
528
- return aggregate(task, state.results.values(), opts);
529
- }
530
-
531
- const STATUS_LABEL = Object.freeze({
532
- consistent: '✅ consistent',
533
- divergent: '❌ divergent',
534
- 'divergent-platform': '🟡 divergent-platform',
535
- failed: '❌ failed',
536
- partial: '🟠 partial',
537
- unverifiable: '⚠️ unverifiable',
538
- // v0.3.0 verdicts. Each keeps a distinct shape because these are read at a glance: `partial`
539
- // (still out), `degraded` (finished badly) and `timeout` (never answered) must not look alike.
540
- degraded: '🟧 degraded',
541
- timeout: '⏱️ timeout',
542
- cancelled: '🚫 cancelled',
543
- refused: '⛔ refused',
544
- pending: '⏳ pending',
545
- });
546
-
547
- function fmt(value) {
548
- if (value === undefined) return '—';
549
- if (value === null) return 'null';
550
- if (typeof value === 'string') return value.length > 80 ? `${value.slice(0, 79)}…` : value;
551
- return truncate(jcs(value), 80);
552
- }
553
-
554
- /** Markdown summary report (GET /v1/tasks/{id}/report?format=md). */
555
- export function renderReportMarkdown(agg, task = null) {
556
- const L = [];
557
- L.push(`# W2M 汇总报告 — \`${agg.task_id}\``);
558
- L.push('');
559
- L.push(`**判定:${STATUS_LABEL[agg.status] ?? agg.status}**`);
560
- L.push('');
561
- L.push('| 字段 | 值 |');
562
- L.push('|---|---|');
563
- L.push(`| protocol_version | ${agg.protocol_version} |`);
564
- L.push(`| 模式 | ${agg.mode} |`);
565
- L.push(`| index_total | ${agg.index_total} |`);
566
- L.push(`| 写入 | ${agg.write} |`);
567
- L.push(`| halt | ${agg.halt} |`);
568
- L.push(`| 创建者 | ${fmt(agg.created_by)} |`);
569
- L.push(`| 创建时间 | ${agg.created_at} |`);
570
- L.push(`| 汇总时间 | ${agg.computed_at} |`);
571
- L.push(`| base_commit | \`${fmt(agg.expected.base_commit)}\` |`);
572
- L.push(`| base_tree | \`${fmt(agg.expected.base_tree)}\` |`);
573
- L.push(`| command_hash | \`${fmt(agg.expected.command_hash)}\` |`);
574
- L.push('');
575
- L.push('## 逐机结果');
576
- L.push('');
577
- L.push('| machine_id | index | lease | 判定 | status | exit_code | 说明 | 路径 |');
578
- L.push('|---|---|---|---|---|---|---|---|');
579
- for (const m of agg.machines) {
580
- // v0.4.0: the trailing 路径 column is APPENDED, so every existing column keeps its name and
581
- // position and a reader written against v0.3.9 output is unaffected. `-` is "the envelope did
582
- // not say" (every v0.3.9 result), which is not the same claim as `relay`.
583
- L.push(`| \`${m.machine_id}\` | ${fmt(m.index)} | ${m.lease_state} | ${m.outcome ?? '—'} | ${fmt(m.status)} `
584
- + `| ${fmt(m.exit_code)} | ${m.reasons.length ? m.reasons.join('; ') : (m.refusal_reason ?? '')} `
585
- + `| ${m.transport ?? '-'} |`);
586
- }
587
- L.push('');
588
- if (agg.differences.length > 0) {
589
- L.push('## 可比字段差异');
590
- L.push('');
591
- for (const d of agg.differences) {
592
- L.push(`### \`${d.field}\``);
593
- L.push('');
594
- L.push('| machine_id | value |');
595
- L.push('|---|---|');
596
- for (const [machine, value] of Object.entries(d.values)) {
597
- L.push(`| \`${machine}\` | \`${fmt(value)}\` |`);
598
- }
599
- L.push('');
600
- }
601
- }
602
- L.push('## 判定步骤(§6.3,顺序不可合并)');
603
- L.push('');
604
- for (const s of agg.steps) {
605
- L.push(`- **Step ${s.step} · ${s.name}** — ${truncate(jcs(s), 400)}`);
606
- }
607
- L.push('');
608
- if (agg.notes.length > 0) {
609
- L.push('## 备注');
610
- L.push('');
611
- for (const n of agg.notes) L.push(`- ${n}`);
612
- L.push('');
613
- }
614
- if (task) {
615
- L.push('## 原始命令');
616
- L.push('');
617
- L.push('```');
618
- L.push(jcs(task.command_argv));
619
- L.push('```');
620
- L.push('');
621
- }
622
- return L.join('\n');
623
- }
624
-
625
- /** JSON report (GET /v1/tasks/{id}/report?format=json). */
626
- export function renderReportJson(agg) {
627
- return JSON.stringify(agg, null, 2);
628
- }
629
-
630
- /**
631
- * Build a report of the requested format.
632
- * @returns {{contentType:string, body:string, aggregate:object}}
633
- */
634
- export function buildReport(state, taskId, { format = 'json', nowMs } = {}) {
635
- const agg = aggregateTask(state, taskId, { nowMs });
636
- const task = state.getTask(taskId);
637
- if (format === 'md' || format === 'markdown') {
638
- return { contentType: 'text/markdown; charset=utf-8', body: renderReportMarkdown(agg, task), aggregate: agg };
639
- }
640
- if (format === 'json') {
641
- return { contentType: 'application/json; charset=utf-8', body: renderReportJson(agg), aggregate: agg };
642
- }
643
- throw new ProtocolError('BAD_REQUEST', 'format must be md|json', { format });
644
- }
645
-
646
- /** Machine-readable verdict used by GET /v1/tasks/{id}. */
647
- export function taskStatus(state, taskId, opts = {}) {
648
- return aggregateTask(state, taskId, opts);
649
- }
1
+ /**
2
+ * W2M Rabbit relay — §6.3 six-state aggregation + markdown/JSON reports.
3
+ *
4
+ * The step order is part of the contract: Step 0 → Step 1 → Step 2 → Step 3.
5
+ * Steps are NEVER merged, because the order changes the verdict.
6
+ */
7
+
8
+ import {
9
+ COMPARABLE_FIELDS,
10
+ FAILURE_STATUSES,
11
+ ProtocolError,
12
+ PROTOCOL_VERSION,
13
+ jcs,
14
+ rfc3339,
15
+ } from './state.mjs';
16
+
17
+ import { stripReportAggregate } from './p0-projection.mjs';
18
+
19
+ /** Aggregate statuses this module can return. */
20
+ export const AGGREGATE_STATUSES = Object.freeze([
21
+ // ---- the six verdicts of §6.3 ----
22
+ 'consistent',
23
+ 'divergent',
24
+ 'divergent-platform',
25
+ 'failed',
26
+ 'partial',
27
+ 'unverifiable',
28
+ // ---- added in v0.3.0 ----
29
+ //
30
+ // These three are *verdicts*, not phase flags, which is why they belong in this list rather than
31
+ // beside `pending`. Each answers a question the original six could not express:
32
+ //
33
+ // timeout nobody reported and the work is no longer waiting on anybody. `partial` said "some
34
+ // machines are still out"; `timeout` says "we waited as long as we agreed to wait,
35
+ // and the machines that never answered are not coming back".
36
+ // cancelled the operator stopped it. Distinct from `failed`: nothing went wrong, we chose this.
37
+ // A cancellation must not be reported as a defect, or every intentional stop becomes
38
+ // noise that trains people to ignore the status.
39
+ // degraded every machine reported, some succeeded and some failed. `partial` used to carry
40
+ // this, but `partial` also means "still in flight", so a reader could not tell a
41
+ // finished task in a bad state from one that had not finished. Both are now explicit.
42
+ 'timeout',
43
+ 'cancelled',
44
+ 'degraded',
45
+ // ---- not verdicts: no terminal answer exists yet, or none was ever possible ----
46
+ 'refused',
47
+ 'pending',
48
+ ]);
49
+
50
+ /** Comparable fields that are derived purely from the stdout byte stream. */
51
+ const STDOUT_DERIVED_FIELDS = Object.freeze([
52
+ 'stdout_sha256',
53
+ 'stdout_bytes',
54
+ 'stdout_normalized_sha256',
55
+ ]);
56
+
57
+ function valueOf(envelope, field) {
58
+ return envelope === null || envelope === undefined ? undefined : envelope[field];
59
+ }
60
+
61
+ function canon(value) {
62
+ return value === undefined ? '<absent>' : jcs(value);
63
+ }
64
+
65
+ function truncate(text, max = 160) {
66
+ const s = typeof text === 'string' ? text : String(text);
67
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
68
+ }
69
+
70
+ /**
71
+ * Warning codes that explain *why* a machine failed, in a sentence a reader can act on.
72
+ *
73
+ * The envelope already carried these codes; nothing downstream read them. A spawn failure was
74
+ * therefore rendered as `status: crashed`, `exit_code: null`, and an empty 说明 column -- the three
75
+ * facts that together say nothing. Measured on macOS: a task whose `argv[0]` was not on PATH came
76
+ * back exactly that way, with `warnings: ["PATH_INVALID"]` sitting unread in the envelope.
77
+ *
78
+ * Only codes whose meaning is unambiguous belong here. Inventing prose for a code we do not
79
+ * understand would be worse than the empty cell, so anything unlisted stays unreported.
80
+ */
81
+ const FAILURE_WARNING_REASONS = {
82
+ PATH_INVALID: 'the command could not be started: an executable in the argv was not found (PATH_INVALID)',
83
+ };
84
+
85
+ /**
86
+ * Fill in a failed machine's `reasons` from its execution warnings.
87
+ *
88
+ * `reasons` is what the human report renders in the 说明 column, and it was only ever populated by
89
+ * anchor/comparison problems -- so a machine that never started had nothing to say for itself.
90
+ *
91
+ * @param {object} m Machine record being aggregated.
92
+ * @param {string} status The envelope's execution status.
93
+ */
94
+ function explainExecutionFailure(m, status) {
95
+ if (m.reasons.length > 0) return;
96
+ // `nonzero_exit` is self-explanatory: the command ran and `exit_code` reports what it returned.
97
+ // Only a machine that produced no exit status at all needs the warning spelled out.
98
+ if (status !== 'crashed' && status !== 'timeout') return;
99
+ const warnings = Array.isArray(m.envelope?.warnings) ? m.envelope.warnings : [];
100
+ for (const warning of warnings) {
101
+ const reason = FAILURE_WARNING_REASONS[warning];
102
+ if (reason) {
103
+ m.reasons.push(reason);
104
+ return;
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * §6.3 six-state aggregation.
111
+ *
112
+ * @param {object} task Rabbit task record (state.mjs `createTask` result)
113
+ * @param {Iterable<object>} resultRecords result records from `RabbitState#results`
114
+ * @param {{nowMs?:number}} [opts]
115
+ * @returns {object} aggregation report data
116
+ */
117
+ export function aggregate(task, resultRecords = [], opts = {}) {
118
+ if (!task) throw new ProtocolError('NOT_FOUND', 'task is required for aggregation');
119
+ const nowMs = opts.nowMs ?? Date.now();
120
+
121
+ const records = [...resultRecords].filter((r) => r?.envelope?.task_id === task.task_id);
122
+ // latest attempt per machine wins
123
+ const byMachine = new Map();
124
+ for (const rec of records) {
125
+ const prev = byMachine.get(rec.machine_id);
126
+ if (!prev || rec.received_at_ms >= prev.received_at_ms) byMachine.set(rec.machine_id, rec);
127
+ }
128
+
129
+ const expected = {
130
+ base_commit: task.base_commit ?? null,
131
+ base_tree: task.base_tree ?? null,
132
+ command_hash: task.command_hash ?? null,
133
+ };
134
+
135
+ const machines = [];
136
+ for (const lease of task.leases.values()) {
137
+ const rec = byMachine.get(lease.machine_id) ?? null;
138
+ machines.push({
139
+ machine_id: lease.machine_id,
140
+ machine_name: lease.machine_name ?? null,
141
+ index: lease.index,
142
+ attempt: lease.attempt,
143
+ lease_state: lease.state,
144
+ lease_expired: lease.state === 'expired',
145
+ refused_reason: lease.refusal_reason ?? null,
146
+ gate_refused: lease.state === 'refused',
147
+ // v0.3.3 `broadcast`: a machine that was told the outcome but never handed the command. It has
148
+ // no envelope and never will, so without this marker the anchor step classified it `pending`
149
+ // and the task could never reach a verdict -- a broadcast would hang forever.
150
+ observing: lease.state === 'observing',
151
+ result: rec,
152
+ envelope: rec?.envelope ?? null,
153
+ // v1.2.6: the relay's own signature verdict (bound to the key registered at pairing), not a client claim.
154
+ signature_status: rec?.validation?.signature_status ?? null,
155
+ outcome: null,
156
+ reasons: [],
157
+ });
158
+ }
159
+ // results from machines with no lease (e.g. takeover / manual upload)
160
+ for (const [machineId, rec] of byMachine) {
161
+ if (machines.some((m) => m.machine_id === machineId)) continue;
162
+ machines.push({
163
+ machine_id: machineId,
164
+ machine_name: rec.envelope.machine_name ?? null,
165
+ index: rec.envelope.index ?? null,
166
+ attempt: rec.attempt,
167
+ lease_state: 'unleased',
168
+ lease_expired: false,
169
+ refused_reason: rec.envelope.refusal_reason ?? null,
170
+ gate_refused: false,
171
+ result: rec,
172
+ envelope: rec.envelope,
173
+ signature_status: rec?.validation?.signature_status ?? null,
174
+ outcome: null,
175
+ reasons: [],
176
+ });
177
+ }
178
+
179
+ const steps = [];
180
+ const notes = [];
181
+
182
+ /* -------- Step 0: refusals are booked per machine, they never block others -------- */
183
+ const step0 = { step: 0, name: 'refusal', refused: [], passed: [], observing: [] };
184
+ for (const m of machines) {
185
+ if (m.observing) {
186
+ // Not a participant: it was never asked to run, so it cannot refuse, cannot pass, and must
187
+ // never be counted as agreeing. Recording the outcome is the whole reason it is listed.
188
+ m.outcome = 'observing';
189
+ step0.observing.push(m.machine_id);
190
+ continue;
191
+ }
192
+ const refusedByGate = m.gate_refused;
193
+ const refusedByEnvelope = m.envelope?.status === 'refused';
194
+ if (refusedByGate || refusedByEnvelope) {
195
+ m.outcome = 'refused';
196
+ m.refusal_reason = refusedByGate
197
+ ? (m.refused_reason ?? 'REFUSED')
198
+ : (m.envelope.refusal_reason ?? 'REFUSED');
199
+ step0.refused.push({ machine_id: m.machine_id, refusal_reason: m.refusal_reason });
200
+ } else {
201
+ step0.passed.push(m.machine_id);
202
+ }
203
+ }
204
+ steps.push(step0);
205
+
206
+ const participants = machines.filter((m) => m.outcome !== 'refused' && m.outcome !== 'observing');
207
+
208
+ /* -------- Step 1: anchors — any violation refuses aggregation entirely -------- */
209
+ const step1 = { step: 1, name: 'anchors', violations: [], passed: [], pending: [] };
210
+ for (const m of participants) {
211
+ const env = m.envelope;
212
+ if (!env) {
213
+ const expired = m.lease_expired;
214
+ m.outcome = expired ? 'expired' : 'pending';
215
+ step1.pending.push(m.machine_id);
216
+ continue;
217
+ }
218
+ const validation = m.result?.validation ?? { missing: [], problems: [] };
219
+ const missing = [...(validation.missing ?? [])];
220
+ if (m.result?.effective_status === 'unverifiable' && missing.length === 0) {
221
+ m.reasons.push('envelope status = unverifiable');
222
+ }
223
+ if (missing.length > 0) m.reasons.push(`missing required fields: ${missing.join(',')}`);
224
+ /**
225
+ * v0.9.3: `base_commit` is enforced only when the dispatcher declared one.
226
+ *
227
+ * The check used to be unconditional, so a dispatcher with **no** anchor -- one whose own tree is
228
+ * not a git work tree, which is the ordinary case for a machine that runs the plugin from its
229
+ * profile directory -- sent `base_commit: null`, and every result was then compared against the
230
+ * literal `null`. Any machine that *did* have an anchor reported its real commit and was marked
231
+ * `unverifiable` for telling the truth:
232
+ *
233
+ * expected.base_commit : null
234
+ * machine reported : a1e7dc042913b96da3ad68…
235
+ * reasons : ["base_commit \"a1e7dc04…\" != expected null"]
236
+ * notes : ["aggregation refused: Step 1 anchor violation"]
237
+ *
238
+ * which downgraded **every** dispatch from such a machine, including a wholly successful one, and
239
+ * made the verdict unreadable. The anchor exists to answer "did these machines run the same tree";
240
+ * with nothing declared there is no such question, and the honest verdict is that the run is
241
+ * unanchored -- not that it disagreed with `null`.
242
+ *
243
+ * `base_tree` three lines below has always read this way (`expected.base_tree !== null && …`), and
244
+ * the asymmetry was the bug: the two anchors are the same kind of statement and one of them
245
+ * tolerated absence while the other did not.
246
+ *
247
+ * An absent anchor is still *reported* rather than silently ignored -- `unanchored: true` on the
248
+ * aggregate and in the machine's own record -- because "these two machines agreed" and "nobody
249
+ * said what they should have agreed on" must not read the same to an operator.
250
+ */
251
+ if (expected.base_commit !== null && canon(valueOf(env, 'base_commit')) !== canon(expected.base_commit)) {
252
+ m.reasons.push(`base_commit ${truncate(canon(env.base_commit), 24)} != expected ${truncate(canon(expected.base_commit), 24)}`);
253
+ }
254
+ if (expected.base_tree !== null && canon(valueOf(env, 'pre_tree_fingerprint')) !== canon(expected.base_tree)) {
255
+ m.reasons.push('pre_tree_fingerprint != base_tree');
256
+ }
257
+ if (canon(valueOf(env, 'command_hash')) !== canon(expected.command_hash)) {
258
+ m.reasons.push('command_hash mismatch');
259
+ }
260
+ if (env.status === 'unverifiable' && m.reasons.length === 0) {
261
+ m.reasons.push('envelope status = unverifiable');
262
+ }
263
+ if (m.reasons.length > 0) {
264
+ m.outcome = 'unverifiable';
265
+ step1.violations.push({ machine_id: m.machine_id, reasons: [...m.reasons] });
266
+ } else {
267
+ m.outcome = 'ok-pending-step2';
268
+ step1.passed.push(m.machine_id);
269
+ }
270
+ }
271
+ steps.push(step1);
272
+
273
+ const runnable = participants.filter((m) => m.outcome === 'ok-pending-step2');
274
+ const expiredMachines = participants.filter((m) => m.outcome === 'expired');
275
+ const pendingMachines = participants.filter((m) => m.outcome === 'pending');
276
+
277
+ let status;
278
+ const step2 = { step: 2, name: 'execution', ok: [], failed: [], partial: false, expired: [], pending: [] };
279
+ const step3 = { step: 3, name: 'comparison', differences: [], consistent: false, platform_only: false };
280
+
281
+ if (step1.violations.length > 0) {
282
+ /* §6.3 Step 1: 拒绝汇总 */
283
+ status = 'unverifiable';
284
+ step2.skipped = 'step1_unverifiable';
285
+ step3.skipped = 'step1_unverifiable';
286
+ notes.push('aggregation refused: Step 1 anchor violation (see unverifiable machines)');
287
+ } else if (task.cancelled === true) {
288
+ /* v0.3.0: the operator stopped it. A chosen stop is not a defect. */
289
+ //
290
+ // Checked before `unverifiable`-adjacent outcomes and before any timeout: once someone has
291
+ // deliberately stopped the work, reporting "failed" or "timed out" would describe a decision as
292
+ // a malfunction, and enough of those trains a reader to ignore the status entirely.
293
+ status = 'cancelled';
294
+ step2.skipped = 'task_cancelled';
295
+ step3.skipped = 'task_cancelled';
296
+ notes.push(
297
+ task.cancel_reason ? `cancelled by the operator: ${task.cancel_reason}` : 'cancelled by the operator',
298
+ );
299
+ } else if (runnable.length === 0 && expiredMachines.length === 0 && pendingMachines.length === 0 && step0.refused.length > 0) {
300
+ status = 'refused';
301
+ step2.skipped = 'all_refused';
302
+ step3.skipped = 'all_refused';
303
+ } else if (runnable.length === 0 && expiredMachines.length === 0) {
304
+ /* v0.3.0: distinguish "still waiting" from "waited long enough". */
305
+ //
306
+ // `pending` means the task is live and more envelopes may still arrive. Once the deadline the
307
+ // caller set has passed and machines are still silent, that is a verdict, and calling it
308
+ // `pending` forever leaves a reader unable to tell a stuck task from a slow one.
309
+ const deadline = Number.isFinite(task.deadline_ms) ? task.deadline_ms : 0;
310
+ if (deadline > 0 && nowMs >= deadline) {
311
+ status = 'timeout';
312
+ step2.skipped = 'deadline_passed';
313
+ step3.skipped = 'deadline_passed';
314
+ notes.push(
315
+ `no result envelope by the ${task.timeout_ms}ms deadline (${pendingMachines.length} machine(s) still silent)`,
316
+ );
317
+ } else {
318
+ status = 'pending';
319
+ step2.skipped = 'no_results_yet';
320
+ step3.skipped = 'no_results_yet';
321
+ notes.push('no result envelope has been received yet');
322
+ }
323
+ } else {
324
+ for (const m of runnable) {
325
+ const s = m.envelope.status;
326
+ if (s === 'ok') { m.outcome = 'ok'; step2.ok.push(m.machine_id); }
327
+ else {
328
+ // {nonzero_exit,timeout,crashed} plus anything outside the §5.1 enum
329
+ m.outcome = 'failed';
330
+ explainExecutionFailure(m, s);
331
+ step2.failed.push({
332
+ machine_id: m.machine_id,
333
+ status: s,
334
+ exit_code: m.envelope.exit_code ?? null,
335
+ known_failure: FAILURE_STATUSES.includes(s),
336
+ });
337
+ }
338
+ }
339
+ for (const m of expiredMachines) step2.expired.push(m.machine_id);
340
+ for (const m of pendingMachines) step2.pending.push(m.machine_id);
341
+
342
+ if (step2.expired.length > 0) {
343
+ /* §4.3: halt=never → booked and marked partial */
344
+ status = 'partial';
345
+ step2.partial = true;
346
+ notes.push(`lease expired on ${step2.expired.length} machine(s) → partial (§4.3)`);
347
+ step3.skipped = 'lease_expired';
348
+ } else if (step2.pending.length > 0) {
349
+ status = 'partial';
350
+ step2.partial = true;
351
+ notes.push(`waiting for ${step2.pending.length} machine(s) to report`);
352
+ step3.skipped = 'machines_pending';
353
+ } else if (step2.failed.length === 0 && step2.ok.length > 0) {
354
+ /* §6.3 Step 2: 全部 ok → Step 3 */
355
+ const okMachines = runnable.filter((m) => m.outcome === 'ok');
356
+
357
+ // Compare **within a shard**, not across the whole task.
358
+ //
359
+ // `index` is a comparable field, and `split` assigns it per machine by design -- so comparing
360
+ // every machine against every other made `index` differ on any successful fan-out, and every
361
+ // split task was reported `divergent`. That is a false alarm on the normal case: `divergent`
362
+ // tells an operator the machines disagreed, when in fact they did exactly what was asked.
363
+ //
364
+ // Grouping by `index` fixes it without special-casing the field: machines meant to produce the
365
+ // same output are compared with each other, and machines given different slices are not
366
+ // compared at all. For `replicate` every machine has index 0, so there is a single group and
367
+ // the behaviour is exactly as before -- which is why the existing 140 relay tests still pass
368
+ // unchanged.
369
+ const shards = new Map();
370
+ for (const m of okMachines) {
371
+ const key = m.index ?? 0;
372
+ if (!shards.has(key)) shards.set(key, []);
373
+ shards.get(key).push(m);
374
+ }
375
+
376
+ const comparable = {};
377
+ for (const field of COMPARABLE_FIELDS) {
378
+ comparable[field] = {};
379
+ for (const m of okMachines) {
380
+ const v = valueOf(m.envelope, field);
381
+ if (v !== undefined) comparable[field][m.machine_id] = v;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Fields that differ within some shard, with the shard that disagreed.
387
+ *
388
+ * `index` is reported with the shard so a genuine mixed-mode divergence ("shard 0 agreed,
389
+ * shard 1 did not") stays unambiguous.
390
+ */
391
+ const differences = [];
392
+ for (const [shardIndex, members] of shards) {
393
+ if (members.length < 2) continue; // one machine cannot disagree with itself
394
+ for (const field of COMPARABLE_FIELDS) {
395
+ const values = members.map((m) => canon(valueOf(m.envelope, field)));
396
+ if (values.some((v) => v !== values[0])) {
397
+ differences.push({
398
+ field,
399
+ index: shardIndex,
400
+ values: Object.fromEntries(members.map((m) => [m.machine_id, valueOf(m.envelope, field)])),
401
+ });
402
+ }
403
+ }
404
+ }
405
+ step3.comparable = comparable;
406
+ step3.differences = differences;
407
+
408
+ /** Metadata drift within any shard, for the `divergent-platform` branch. */
409
+ const metaDiffers = (field) => {
410
+ for (const members of shards.values()) {
411
+ if (members.length < 2) continue;
412
+ const vals = members.map((m) => canon(valueOf(m.envelope, field)));
413
+ if (vals.some((v) => v !== vals[0])) return true;
414
+ }
415
+ return false;
416
+ };
417
+ const platformMetaDiffers = metaDiffers('toolchain') || metaDiffers('platform');
418
+
419
+ if (differences.length === 0) {
420
+ status = 'consistent';
421
+ step3.consistent = true;
422
+ } else if (platformMetaDiffers && differences.every((d) => STDOUT_DERIVED_FIELDS.includes(d.field))) {
423
+ /* §6.3 Step 3: expected — only toolchain/platform differ and the drift is stdout-only */
424
+ status = 'divergent-platform';
425
+ step3.platform_only = true;
426
+ notes.push('divergence is confined to stdout bytes across differing toolchain/platform (expected, no alert)');
427
+ } else {
428
+ status = 'divergent';
429
+ notes.push(`divergent fields: ${differences.map((d) => d.field).join(', ')}`);
430
+ }
431
+ } else if (step2.ok.length > 0) {
432
+ /* v0.3.0 §6.3 Step 2: 部分 ok、部分失败 → degraded */
433
+ //
434
+ // This used to be `partial`, which was wrong in a way that mattered: `partial` is also the
435
+ // answer while machines are still out, so "finished, and half of it failed" was
436
+ // indistinguishable from "still running" in the one field a reader looks at first. Every
437
+ // machine here has reported, so the task is over and the verdict is `degraded` -- some of the
438
+ // work succeeded, the rest did not, and nothing further is coming.
439
+ status = 'degraded';
440
+ step2.partial = true;
441
+ notes.push(`${step2.ok.length} machine(s) succeeded and ${step2.failed.length} failed`);
442
+ } else {
443
+ /* §6.3 Step 2: 全部失败 → failed */
444
+ status = 'failed';
445
+ }
446
+ }
447
+
448
+ steps.push(step2);
449
+ steps.push(step3);
450
+
451
+ return stripReportAggregate({
452
+ protocol_version: PROTOCOL_VERSION,
453
+ task_id: task.task_id,
454
+ status,
455
+ computed_at: rfc3339(nowMs),
456
+ mode: task.mode,
457
+ index_total: task.index_total,
458
+ halt: task.halt,
459
+ write: task.write,
460
+ created_at: rfc3339(task.created_at_ms),
461
+ created_by: task.created_by ?? null,
462
+ cancelled: task.cancelled === true,
463
+ expected,
464
+ /**
465
+ * v0.9.3: whether this task ran against a declared tree at all.
466
+ *
467
+ * `expected.base_commit === null` means the dispatcher declared no anchor -- its own tree is not a
468
+ * git work tree, which is the ordinary case for the plugin running out of a profile directory. The
469
+ * anchor check is therefore skipped rather than compared against the literal `null` (see Step 1).
470
+ *
471
+ * Reported explicitly because the two situations must not look alike: `consistent: true` from an
472
+ * unanchored run says "these machines agreed with each other", **not** "these machines ran the
473
+ * tree you meant". An operator comparing results needs to know which of those they are holding.
474
+ */
475
+ unanchored: expected.base_commit === null,
476
+ machines: machines.map((m) => ({
477
+ machine_id: m.machine_id,
478
+ machine_name: m.machine_name,
479
+ index: m.index,
480
+ attempt: m.attempt,
481
+ lease_state: m.lease_state,
482
+ outcome: m.outcome,
483
+ status: m.envelope?.status ?? null,
484
+ refusal_reason: m.refusal_reason ?? null,
485
+ exit_code: m.envelope?.exit_code ?? null,
486
+ /* v0.4.0: how this machine's envelope travelled, straight off the stored envelope.
487
+ *
488
+ * `null` means "the envelope did not say" -- every v0.3.9 result, and the honest answer for a
489
+ * relay that simply does not know. These two fields are REPORTED, never judged: they are not
490
+ * in COMPARABLE_FIELDS, so a machine that answered over P2P and one that answered over the
491
+ * relay still produce the same verdict. A path is not a result. */
492
+ transport: m.envelope?.transport ?? null,
493
+ p2p: m.envelope?.p2p ?? null,
494
+ reasons: m.reasons,
495
+ // v1.2.6: the relay's verdict, bound to the key registered at pairing.
496
+ signature_status: m.signature_status ?? null,
497
+ /**
498
+ * v1.2.7: the dataflow facts a reader needs to build a reduce plan.
499
+ *
500
+ * The aggregate used to publish only a summary per machine, so a client could not see that a result was a blob -- the
501
+ * reduce plan existed in tests and read raw envelopes that the production response never carried. These four fields are
502
+ * that omission, closed: the blob hash, the file/blob mode, the inline head, and the byte count.
503
+ */
504
+ blob_sha256: m.envelope?.blob_sha256 ?? null,
505
+ output_mode: m.envelope?.output_mode ?? null,
506
+ stdout_head: m.envelope?.stdout_head ?? null,
507
+ stdout_bytes: m.envelope?.stdout_bytes ?? null,
508
+ })),
509
+ steps,
510
+ differences: step3.differences ?? [],
511
+ notes: expected.base_commit === null
512
+ ? [...notes, 'no base_commit was declared, so the anchor check did not run: machine agreement here is '
513
+ + 'agreement with each other, not with a tree the dispatcher named']
514
+ : notes,
515
+ counts: {
516
+ refused: step0.refused.length,
517
+ unverifiable: step1.violations.length,
518
+ ok: step2.ok?.length ?? 0,
519
+ failed: step2.failed?.length ?? 0,
520
+ expired: step2.expired?.length ?? 0,
521
+ pending: step2.pending?.length ?? 0,
522
+ },
523
+ });
524
+ }
525
+
526
+ /** Convenience wrapper: pull the task + its results out of a RabbitState. */
527
+ export function aggregateTask(state, taskId, opts = {}) {
528
+ const task = state.getTask(taskId);
529
+ if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
530
+ return aggregate(task, state.results.values(), opts);
531
+ }
532
+
533
+ const STATUS_LABEL = Object.freeze({
534
+ consistent: '✅ consistent',
535
+ divergent: '❌ divergent',
536
+ 'divergent-platform': '🟡 divergent-platform',
537
+ failed: '❌ failed',
538
+ partial: '🟠 partial',
539
+ unverifiable: '⚠️ unverifiable',
540
+ // v0.3.0 verdicts. Each keeps a distinct shape because these are read at a glance: `partial`
541
+ // (still out), `degraded` (finished badly) and `timeout` (never answered) must not look alike.
542
+ degraded: '🟧 degraded',
543
+ timeout: '⏱️ timeout',
544
+ cancelled: '🚫 cancelled',
545
+ refused: '⛔ refused',
546
+ pending: '⏳ pending',
547
+ });
548
+
549
+ function fmt(value) {
550
+ if (value === undefined) return '—';
551
+ if (value === null) return 'null';
552
+ if (typeof value === 'string') return value.length > 80 ? `${value.slice(0, 79)}…` : value;
553
+ return truncate(jcs(value), 80);
554
+ }
555
+
556
+ /** Markdown summary report (GET /v1/tasks/{id}/report?format=md). */
557
+ export function renderReportMarkdown(agg, task = null) {
558
+ const L = [];
559
+ L.push(`# W2M 汇总报告 — \`${agg.task_id}\``);
560
+ L.push('');
561
+ L.push(`**判定:${STATUS_LABEL[agg.status] ?? agg.status}**`);
562
+ L.push('');
563
+ L.push('| 字段 | 值 |');
564
+ L.push('|---|---|');
565
+ L.push(`| protocol_version | ${agg.protocol_version} |`);
566
+ L.push(`| 模式 | ${agg.mode} |`);
567
+ L.push(`| index_total | ${agg.index_total} |`);
568
+ L.push(`| 写入 | ${agg.write} |`);
569
+ L.push(`| halt | ${agg.halt} |`);
570
+ L.push(`| 创建者 | ${fmt(agg.created_by)} |`);
571
+ L.push(`| 创建时间 | ${agg.created_at} |`);
572
+ L.push(`| 汇总时间 | ${agg.computed_at} |`);
573
+ L.push(`| base_commit | \`${fmt(agg.expected.base_commit)}\` |`);
574
+ L.push(`| base_tree | \`${fmt(agg.expected.base_tree)}\` |`);
575
+ L.push(`| command_hash | \`${fmt(agg.expected.command_hash)}\` |`);
576
+ L.push('');
577
+ L.push('## 逐机结果');
578
+ L.push('');
579
+ L.push('| machine_id | index | lease | 判定 | status | exit_code | 说明 | 路径 |');
580
+ L.push('|---|---|---|---|---|---|---|---|');
581
+ for (const m of agg.machines) {
582
+ // v0.4.0: the trailing 路径 column is APPENDED, so every existing column keeps its name and
583
+ // position and a reader written against v0.3.9 output is unaffected. `-` is "the envelope did
584
+ // not say" (every v0.3.9 result), which is not the same claim as `relay`.
585
+ L.push(`| \`${m.machine_id}\` | ${fmt(m.index)} | ${m.lease_state} | ${m.outcome ?? '—'} | ${fmt(m.status)} `
586
+ + `| ${fmt(m.exit_code)} | ${m.reasons.length ? m.reasons.join('; ') : (m.refusal_reason ?? '')} `
587
+ + `| ${m.transport ?? '-'} |`);
588
+ }
589
+ L.push('');
590
+ if (agg.differences.length > 0) {
591
+ L.push('## 可比字段差异');
592
+ L.push('');
593
+ for (const d of agg.differences) {
594
+ L.push(`### \`${d.field}\``);
595
+ L.push('');
596
+ L.push('| machine_id | value |');
597
+ L.push('|---|---|');
598
+ for (const [machine, value] of Object.entries(d.values)) {
599
+ L.push(`| \`${machine}\` | \`${fmt(value)}\` |`);
600
+ }
601
+ L.push('');
602
+ }
603
+ }
604
+ L.push('## 判定步骤(§6.3,顺序不可合并)');
605
+ L.push('');
606
+ for (const s of agg.steps) {
607
+ L.push(`- **Step ${s.step} · ${s.name}** — ${truncate(jcs(s), 400)}`);
608
+ }
609
+ L.push('');
610
+ if (agg.notes.length > 0) {
611
+ L.push('## 备注');
612
+ L.push('');
613
+ for (const n of agg.notes) L.push(`- ${n}`);
614
+ L.push('');
615
+ }
616
+ if (task) {
617
+ L.push('## 原始命令');
618
+ L.push('');
619
+ L.push('```');
620
+ L.push(jcs(task.command_argv));
621
+ L.push('```');
622
+ L.push('');
623
+ }
624
+ return L.join('\n');
625
+ }
626
+
627
+ /** JSON report (GET /v1/tasks/{id}/report?format=json). */
628
+ export function renderReportJson(agg) {
629
+ return JSON.stringify(agg, null, 2);
630
+ }
631
+
632
+ /**
633
+ * Build a report of the requested format.
634
+ * @returns {{contentType:string, body:string, aggregate:object}}
635
+ */
636
+ export function buildReport(state, taskId, { format = 'json', nowMs } = {}) {
637
+ const agg = aggregateTask(state, taskId, { nowMs });
638
+ const task = state.getTask(taskId);
639
+ if (format === 'md' || format === 'markdown') {
640
+ return { contentType: 'text/markdown; charset=utf-8', body: renderReportMarkdown(agg, task), aggregate: agg };
641
+ }
642
+ if (format === 'json') {
643
+ return { contentType: 'application/json; charset=utf-8', body: renderReportJson(agg), aggregate: agg };
644
+ }
645
+ throw new ProtocolError('BAD_REQUEST', 'format must be md|json', { format });
646
+ }
647
+
648
+ /** Machine-readable verdict used by GET /v1/tasks/{id}. */
649
+ export function taskStatus(state, taskId, opts = {}) {
650
+ return aggregateTask(state, taskId, opts);
651
+ }