@twinsearth/w2m-dsh-plugin 1.2.7 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -711,6 +711,7 @@ export class RabbitState {
711
711
  const t = entry.task;
712
712
  if (!t || typeof t.task_id !== 'string') return false;
713
713
  const createdAt = Number.isFinite(t.created_at_ms) ? t.created_at_ms : Date.parse(entry.ts ?? '');
714
+
714
715
  const task = {
715
716
  task_id: t.task_id,
716
717
  mode: t.mode ?? 'replicate',
@@ -737,6 +738,8 @@ export class RabbitState {
737
738
  // v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
738
739
  depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
739
740
  assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
741
+ reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
742
+ reduce_inputs: Array.isArray(t.reduce_inputs) ? t.reduce_inputs : [],
740
743
  leases: new Map(),
741
744
  cancelled: false,
742
745
  cancel_reason: null,
@@ -1723,6 +1726,24 @@ export class RabbitState {
1723
1726
  : targetIds[0] ?? null)
1724
1727
  : null;
1725
1728
 
1729
+ /**
1730
+ * v1.2.10: validate the reduce artifact list up front. A malformed list would otherwise become an offer the machine
1731
+ * cannot execute, and the failure would surface as a generic refusal on the executor instead of at dispatch.
1732
+ */
1733
+ const rawReduceInputs = input.reduce_inputs === undefined || input.reduce_inputs === null ? [] : input.reduce_inputs;
1734
+ if (!Array.isArray(rawReduceInputs) || rawReduceInputs.length > 16) {
1735
+ throw new ProtocolError('BAD_REQUEST', 'reduce_inputs must be an array of at most 16 artifacts', { count: Array.isArray(rawReduceInputs) ? rawReduceInputs.length : null });
1736
+ }
1737
+ const normalizedReduceInputs = [];
1738
+ for (const artifact of rawReduceInputs) {
1739
+ const machineId = typeof artifact?.machine_id === 'string' && artifact.machine_id !== '' ? artifact.machine_id : null;
1740
+ const sha256 = typeof artifact?.sha256 === 'string' ? artifact.sha256.toLowerCase() : '';
1741
+ if (machineId === null || !/^[0-9a-f]{64}$/.test(sha256)) {
1742
+ throw new ProtocolError('BAD_REQUEST', 'each reduce input must be {machine_id, sha256}', { machine_id: machineId, sha256 });
1743
+ }
1744
+ normalizedReduceInputs.push({ machine_id: machineId, sha256 });
1745
+ }
1746
+
1726
1747
  const task = {
1727
1748
  task_id: taskId,
1728
1749
  mode,
@@ -1757,6 +1778,12 @@ export class RabbitState {
1757
1778
  // the silent-drop failure this field-by-field literal exists to prevent.
1758
1779
  depends_on: deps.ok === true ? deps.depends_on : [],
1759
1780
  assignee: typeof input.assignee === 'string' && input.assignee !== '' ? input.assignee : null,
1781
+ /**
1782
+ * v1.2.10: a reduce task names the map task it reduces and the artifacts it consumes. The artifact LIST travels with
1783
+ * the task (never inferred by the reducer), because "which shards this reduce covers" is the completeness contract.
1784
+ */
1785
+ reduce_of: typeof input.reduce_of === 'string' && input.reduce_of !== '' ? input.reduce_of : null,
1786
+ reduce_inputs: normalizedReduceInputs,
1760
1787
  leases: new Map(),
1761
1788
  cancelled: false,
1762
1789
  cancel_reason: null,
@@ -2039,6 +2066,8 @@ export class RabbitState {
2039
2066
  // v1.2.7: the dependency graph and assignment belong to the task record, not to a reader's memory of it.
2040
2067
  depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2041
2068
  assignee: task.assignee ?? null,
2069
+ reduce_of: task.reduce_of ?? null,
2070
+ reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2042
2071
  origin_machine_id: task.origin_machine_id ?? null,
2043
2072
  p2p: task.p2p ?? null,
2044
2073
  },
@@ -2150,6 +2179,8 @@ export class RabbitState {
2150
2179
  */
2151
2180
  origin_machine_id: task.origin_machine_id ?? null,
2152
2181
  p2p: task.p2p ?? null,
2182
+ // v1.2.10: present only on a reduce task, so a normal offer stays byte-identical.
2183
+ ...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
2153
2184
  cwd_rel: task.cwd_rel,
2154
2185
  // Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
2155
2186
  // same reason `stages` above is conditional.
@@ -2306,6 +2337,8 @@ export class RabbitState {
2306
2337
  * Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
2307
2338
  * map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
2308
2339
  */
2340
+ reduce_of: task.reduce_of ?? null,
2341
+ reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2309
2342
  status: (() => {
2310
2343
  if (task.cancelled === true) return 'cancelled';
2311
2344
  const leases = [...task.leases.values()];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The reduce suggestion: what a reader can do about a finished map phase, without deciding it for them.
3
+ *
4
+ * ## Why a suggestion instead of a dispatch
5
+ *
6
+ * A reduce is a command run on a machine. Every other part of this fleet keeps "who decides to run a command" in the caller: the
7
+ * relay offers, the agent executes, the plugin dispatches when asked. A tool that dispatched the reduce itself would make a read
8
+ * into a write, and the caller would lose the ability to say "not on that machine" or "not with those inputs". So this function
9
+ * answers three questions and stops: is the data complete, who could reduce it, and what is the caller expected to do next.
10
+ *
11
+ * ## Why it is derived, not stored
12
+ *
13
+ * Two readers of the same aggregate must get the same suggestion, and reading must not change relay state. Everything here comes
14
+ * from the machine facts the aggregate already publishes (`blob_sha256`, `output_mode`, `stdout_head`, `stdout_bytes`) and the
15
+ * task's own `index_total`; nothing is written anywhere.
16
+ */
17
+ import { buildReducePlan } from './dataflow.mjs';
18
+
19
+ /** The literal next step, kept in the payload so a caller cannot mistake the suggestion for an action. */
20
+ export const REDUCE_NEXT_STEP = 'dispatch the reduce yourself; this tool does not run it';
21
+
22
+ /**
23
+ * @param {object} machine a machine entry from the aggregate (full or compact shape)
24
+ * @returns {boolean} true when this machine actually reported a result
25
+ */
26
+ export function machineReported(machine) {
27
+ if (machine === null || typeof machine !== 'object') return false;
28
+ if ((machine.envelope ?? machine.result ?? null) !== null) return true;
29
+ return typeof machine.blob_sha256 === 'string'
30
+ || typeof machine.stdout_head === 'string'
31
+ || Number.isFinite(machine.stdout_bytes)
32
+ || (typeof machine.status === 'string' && machine.status !== '' && Number.isFinite(machine.exit_code));
33
+ }
34
+
35
+ /**
36
+ * @param {object} machine
37
+ * @returns {{machine_id: string, index: number|null, mode: 'inline'|'blob', stdout?: string, blob_sha256?: string}}
38
+ */
39
+ export function reduceInputOf(machine) {
40
+ const envelope = machine?.envelope ?? machine?.result ?? null;
41
+ const blobSha256 = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256
42
+ : (typeof machine?.blob_sha256 === 'string' ? machine.blob_sha256 : null);
43
+ const head = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head
44
+ : (typeof machine?.stdout_head === 'string' && machine.stdout_head !== '' ? machine.stdout_head : null);
45
+ const index = Number.isInteger(machine?.index) ? machine.index
46
+ : (Number.isInteger(envelope?.index) ? envelope.index : null);
47
+ return {
48
+ machine_id: typeof machine?.machine_id === 'string' ? machine.machine_id : '',
49
+ index,
50
+ mode: blobSha256 === null ? 'inline' : 'blob',
51
+ ...(head === null ? {} : { stdout: head }),
52
+ ...(blobSha256 === null ? {} : { blob_sha256: blobSha256 }),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * @param {{machines?: object[], reducerMachineId?: string|null, expectedIndexTotal?: number|null}} input
58
+ * @returns {{
59
+ * ready: boolean,
60
+ * plan: object,
61
+ * candidates: Array<{machine_id: string, reason: string}>,
62
+ * missing: number[],
63
+ * next: string,
64
+ * }}
65
+ */
66
+ export function suggestReduce(input = {}) {
67
+ const machines = Array.isArray(input.machines) ? input.machines : [];
68
+ const reducerMachineId = typeof input.reducerMachineId === 'string' && input.reducerMachineId !== ''
69
+ ? input.reducerMachineId
70
+ : null;
71
+ const expectedIndexTotal = Number.isInteger(input.expectedIndexTotal) && input.expectedIndexTotal > 0
72
+ ? input.expectedIndexTotal
73
+ : null;
74
+
75
+ const reported = machines.filter(machineReported);
76
+ const results = reported.map(reduceInputOf);
77
+ const candidates = reported.map((machine) => ({
78
+ machine_id: typeof machine?.machine_id === 'string' ? machine.machine_id : '',
79
+ reason: 'reported-shard',
80
+ }));
81
+
82
+ /**
83
+ * The reducer is a caller decision. A named reducer that never reported is a refusal with a name, not a suggestion to pick
84
+ * somebody else: changing the machine changes what the reduce runs against.
85
+ */
86
+ let plan;
87
+ if (reducerMachineId !== null && !candidates.some((candidate) => candidate.machine_id === reducerMachineId)) {
88
+ plan = { ok: false, code: 'REDUCER_UNREPORTED', detail: { reducerMachineId } };
89
+ } else {
90
+ plan = buildReducePlan({
91
+ results,
92
+ reducerMachineId,
93
+ expectedIndexTotal,
94
+ });
95
+ }
96
+
97
+ const missing = plan.ok === false && plan.code === 'MISSING_SHARD' && Array.isArray(plan.detail?.missing)
98
+ ? [...plan.detail.missing]
99
+ : [];
100
+ return {
101
+ ready: plan.ok === true,
102
+ plan,
103
+ candidates,
104
+ missing,
105
+ next: REDUCE_NEXT_STEP,
106
+ };
107
+ }