@twinsearth/w2m-dsh-plugin 1.2.6 → 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',
@@ -734,6 +735,11 @@ export class RabbitState {
734
735
  created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
735
736
  command_hash: t.command_hash ?? null,
736
737
  attempt: t.attempt ?? 1,
738
+ // v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
739
+ depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
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 : [],
737
743
  leases: new Map(),
738
744
  cancelled: false,
739
745
  cancel_reason: null,
@@ -1720,6 +1726,24 @@ export class RabbitState {
1720
1726
  : targetIds[0] ?? null)
1721
1727
  : null;
1722
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
+
1723
1747
  const task = {
1724
1748
  task_id: taskId,
1725
1749
  mode,
@@ -1754,6 +1778,12 @@ export class RabbitState {
1754
1778
  // the silent-drop failure this field-by-field literal exists to prevent.
1755
1779
  depends_on: deps.ok === true ? deps.depends_on : [],
1756
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,
1757
1787
  leases: new Map(),
1758
1788
  cancelled: false,
1759
1789
  cancel_reason: null,
@@ -1917,6 +1947,31 @@ export class RabbitState {
1917
1947
  if (typeof dedupeKeyBase !== 'string' || dedupeKeyBase.length !== 64) {
1918
1948
  throw new ProtocolError('INTERNAL', 'failed to compute dedupe_key');
1919
1949
  }
1950
+ /**
1951
+ * v1.2.7: a dependency that is not settled means this task is NOT dispatched yet. Its leases are recorded as `waiting`,
1952
+ * which the sweeper ignores and redelivery skips, and `_releaseDependents` moves them to offers when the graph allows it.
1953
+ */
1954
+ const dependencyStates = (task.depends_on ?? []).map((depId) => {
1955
+ const dep = this.tasks.get(depId);
1956
+ if (dep === undefined) return 'unknown';
1957
+ if (dep.cancelled === true) return 'cancelled';
1958
+ return this._taskSettled(dep) === true ? 'completed' : 'waiting';
1959
+ });
1960
+ const graphBlocked = dependencyStates.some((state) => state === 'cancelled' || state === 'unknown');
1961
+ const graphReady = graphBlocked !== true && dependencyStates.every((state) => state === 'completed');
1962
+ const waitingOn = graphReady === true ? [] : (task.depends_on ?? []);
1963
+ /**
1964
+ * v1.2.7: an assignee is a restriction, not a label. Only that machine gets a lease; another machine cannot accidentally
1965
+ * take work that was meant for one host. An unknown assignee is refused by name rather than producing a task nobody holds.
1966
+ */
1967
+ if (task.assignee !== null && !this.devices.has(task.assignee)) {
1968
+ throw new ProtocolError('BAD_REQUEST', 'assignee is not a known device on this relay', { assignee: task.assignee });
1969
+ }
1970
+ if (task.assignee !== null) {
1971
+ const narrowed = targets.filter((device) => device.machine_id === task.assignee);
1972
+ targets.length = 0;
1973
+ targets.push(...narrowed);
1974
+ }
1920
1975
  let lastSeq = this.seq;
1921
1976
  targets.forEach((device, i) => {
1922
1977
  const index = mode === 'replicate' ? 0 : i % indexTotal;
@@ -1950,6 +2005,12 @@ export class RabbitState {
1950
2005
  task.leases.set(device.machine_id, lease);
1951
2006
  return;
1952
2007
  }
2008
+ if (graphReady !== true) {
2009
+ lease.state = 'waiting';
2010
+ lease.waiting_on = waitingOn;
2011
+ task.leases.set(device.machine_id, lease);
2012
+ return;
2013
+ }
1953
2014
  const gate = gateMachine(task, device);
1954
2015
  if (gate) {
1955
2016
  lease.state = 'refused';
@@ -2002,6 +2063,11 @@ export class RabbitState {
2002
2063
  attempt: task.attempt,
2003
2064
  deadline_ms: task.deadline_ms,
2004
2065
  // v0.4.0: routing metadata, so a restarted relay re-offers with the same facts.
2066
+ // v1.2.7: the dependency graph and assignment belong to the task record, not to a reader's memory of it.
2067
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2068
+ assignee: task.assignee ?? null,
2069
+ reduce_of: task.reduce_of ?? null,
2070
+ reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2005
2071
  origin_machine_id: task.origin_machine_id ?? null,
2006
2072
  p2p: task.p2p ?? null,
2007
2073
  },
@@ -2113,6 +2179,8 @@ export class RabbitState {
2113
2179
  */
2114
2180
  origin_machine_id: task.origin_machine_id ?? null,
2115
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 } : {}),
2116
2184
  cwd_rel: task.cwd_rel,
2117
2185
  // Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
2118
2186
  // same reason `stages` above is conditional.
@@ -2211,6 +2279,47 @@ export class RabbitState {
2211
2279
  .map((t) => this.taskSummary(t));
2212
2280
  }
2213
2281
 
2282
+ /** v1.2.7: a task is settled when no lease can still produce a result. Dependencies wait for that, not for a wording. */
2283
+ _taskSettled(task) {
2284
+ for (const lease of task.leases.values()) if (ACTIVE_LEASE_STATES.includes(lease.state)) return false;
2285
+ return true;
2286
+ }
2287
+
2288
+ /**
2289
+ * v1.2.7: release tasks whose prerequisites are all settled.
2290
+ *
2291
+ * Called after a result is stored: a dependency that finished must actually move its dependents from `waiting` to an offer.
2292
+ * Without this the graph would be validated, stored, and then never scheduled -- the exact "validated but not enforced" gap.
2293
+ */
2294
+ _releaseDependents(completedTaskId = null) {
2295
+ const released = [];
2296
+ for (const candidate of this.tasks.values()) {
2297
+ if (!Array.isArray(candidate.depends_on) || candidate.depends_on.length === 0) continue;
2298
+ if (completedTaskId !== null && !candidate.depends_on.includes(completedTaskId)) continue;
2299
+ const waiting = [...candidate.leases.values()].filter((lease) => lease.state === 'waiting');
2300
+ if (waiting.length === 0) continue;
2301
+ let ready = true;
2302
+ for (const depId of candidate.depends_on) {
2303
+ const dep = this.tasks.get(depId);
2304
+ if (dep === undefined) { ready = false; break; }
2305
+ if (dep.cancelled === true) { ready = false; break; }
2306
+ if (this._taskSettled(dep) !== true) { ready = false; break; }
2307
+ }
2308
+ if (ready !== true) continue;
2309
+ for (const lease of waiting) {
2310
+ lease.state = 'queued';
2311
+ lease.claimed_at_ms = this.nowMs();
2312
+ lease.lease_until_ms = this.nowMs() + this.leaseWindowMs();
2313
+ const device = this.devices.get(lease.machine_id);
2314
+ if (device === undefined) continue;
2315
+ this.emitOffer(candidate, lease);
2316
+ }
2317
+ this.emit('notice', { level: 'info', code: 'TASK_READY', message: candidate.task_id + ' is ready: dependencies settled', task_id: candidate.task_id });
2318
+ released.push(candidate.task_id);
2319
+ }
2320
+ return released;
2321
+ }
2322
+
2214
2323
  taskSummary(task) {
2215
2324
  return {
2216
2325
  task_id: task.task_id,
@@ -2222,6 +2331,26 @@ export class RabbitState {
2222
2331
  degraded: task.degraded,
2223
2332
  leases: [...task.leases.values()].map((l) => l.machine_id),
2224
2333
  lease_states: Object.fromEntries([...task.leases.values()].map((l) => [l.machine_id, l.state])),
2334
+ /**
2335
+ * v1.2.7: the state the scheduler is actually in, derived here rather than by each reader.
2336
+ *
2337
+ * Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
2338
+ * map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
2339
+ */
2340
+ reduce_of: task.reduce_of ?? null,
2341
+ reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2342
+ status: (() => {
2343
+ if (task.cancelled === true) return 'cancelled';
2344
+ const leases = [...task.leases.values()];
2345
+ if (leases.some((lease) => lease.state === 'waiting')) return 'waiting';
2346
+ if (leases.some((lease) => ACTIVE_LEASE_STATES.includes(lease.state))) return 'active';
2347
+ const results = [...this.results.values()].filter((record) => record?.envelope?.task_id === task.task_id);
2348
+ if (results.length > 0 && results.every((record) => ['failed', 'crashed', 'refused', 'timeout'].includes(record?.envelope?.status))) return 'failed';
2349
+ return this._taskSettled(task) === true ? 'done' : 'unknown';
2350
+ })(),
2351
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2352
+ assignee: task.assignee ?? null,
2353
+ waiting_on: [...task.leases.values()].some((l) => l.state === 'waiting') ? (Array.isArray(task.depends_on) ? task.depends_on : []) : [],
2225
2354
  };
2226
2355
  }
2227
2356
 
@@ -2450,6 +2579,9 @@ export class RabbitState {
2450
2579
  }
2451
2580
  if (changed) task.degraded = 'partial';
2452
2581
  }
2582
+ // v1.2.7: a lease that expires (or a machine that refuses) is a settlement too -- the dependent graph must move.
2583
+ this._releaseDependents();
2584
+
2453
2585
  return expired;
2454
2586
  }
2455
2587
 
@@ -2738,6 +2870,9 @@ export class RabbitState {
2738
2870
  });
2739
2871
 
2740
2872
  // §7: results go into the ledger so the dedupe index survives a restart.
2873
+ // v1.2.7: a settled task may be the prerequisite that frees its dependents.
2874
+ this._releaseDependents(taskId);
2875
+
2741
2876
  this._ledger({ type: 'result.stored', task_id: taskId, machine_id: machineId, result: record });
2742
2877
  if (machineId) {
2743
2878
  const changed = task.leases.get(machineId);
@@ -41,7 +41,8 @@ const SHA256 = /^[0-9a-f]{64}$/;
41
41
  * A missing or malformed hash is refused by name -- the reduce step must never run on a partial fleet and call the answer
42
42
  * complete.
43
43
  *
44
- * @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
44
+ * @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string, index?: number}>,
45
+ * expectedIndexTotal?: number|null,
45
46
  * reducerMachineId?: string}} input
46
47
  * @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
47
48
  * {ok: false, code: string, detail: object}}
@@ -51,6 +52,15 @@ export function buildReducePlan(input = {}) {
51
52
  if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
52
53
  const results = Array.isArray(input.results) ? input.results : null;
53
54
  if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
55
+ /**
56
+ * v1.2.7: what the task SAID would be produced, not just what arrived.
57
+ *
58
+ * Without an expected count the plan can only describe the reports it was handed, so a split run where one shard never
59
+ * reported produced a plan that looked complete. The count comes from the task (index_total); a missing index is refused
60
+ * by name before the reduce step can run on a partial fleet.
61
+ */
62
+ const expectedTotal = Number.isInteger(input.expectedIndexTotal) && input.expectedIndexTotal > 0 ? input.expectedIndexTotal : null;
63
+ const seenIndexes = new Set();
54
64
 
55
65
  const seen = new Set();
56
66
  const inputs = [];
@@ -59,6 +69,14 @@ export function buildReducePlan(input = {}) {
59
69
  if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
60
70
  if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
61
71
  seen.add(machineId);
72
+ if (expectedTotal !== null) {
73
+ const index = Number.isInteger(result?.index) ? result.index : null;
74
+ if (index === null) return { ok: false, code: 'MAP_INDEX_MISSING', detail: { machineId } };
75
+ if (index < 0 || index >= expectedTotal) {
76
+ return { ok: false, code: 'MAP_INDEX_OUT_OF_RANGE', detail: { machineId, index, expectedIndexTotal: expectedTotal } };
77
+ }
78
+ seenIndexes.add(index);
79
+ }
62
80
  const mode = result?.mode === 'blob' ? 'blob' : 'inline';
63
81
  if (mode === 'blob') {
64
82
  const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
@@ -68,5 +86,10 @@ export function buildReducePlan(input = {}) {
68
86
  inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
69
87
  }
70
88
  }
89
+ if (expectedTotal !== null) {
90
+ const missing = [];
91
+ for (let index = 0; index < expectedTotal; index += 1) if (!seenIndexes.has(index)) missing.push(index);
92
+ if (missing.length > 0) return { ok: false, code: 'MISSING_SHARD', detail: { missing, expectedIndexTotal: expectedTotal } };
93
+ }
71
94
  return { ok: true, reducerMachineId: reducer, inputs };
72
95
  }
@@ -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
+ }