@twinsearth/w2m-dsh-plugin 1.2.6 → 1.2.7

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.2.6"
8
+ "version": "1.2.7"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.2.6",
15
+ "version": "1.2.7",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.2.6",
4
+ "version": "1.2.7",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.2.7] — 2026-10-21
11
+
12
+ **The scheduler enforces the graph, a wait survives a restart and a dropped stream, and a partial fleet can no longer call itself
13
+ a complete reduce.**
14
+
15
+ ### Turns are a state machine on disk
16
+
17
+ A question written with `action:'ask'` becomes a persisted turn (`ask_id`, deadline). `await` attaches to that record, so a process
18
+ restart does not lose the question or turn a late answer into silence; a late answer is recorded as `answered_after_timeout`. The
19
+ wait prefers the relay's event stream, re-subscribes when the stream drops, and polls only when streaming is unavailable. A reply
20
+ now carries `reply_state: 'answered'|'refused'`, so a refusal is a state the waiter reads rather than wording it guesses.
21
+
22
+ ### Dependencies block dispatch, and release when their prerequisite settles
23
+
24
+ A task with `depends_on` is not offered: its leases are `waiting`, which redelivery skips and the sweeper ignores. When every
25
+ prerequisite is settled -- **by a result, an expiry or a refusal** -- `_releaseDependents` moves those leases to offers. The graph
26
+ edges and `assignee` are written into the task ledger, because live testing showed a restarted relay losing them and keeping a
27
+ waiting task waiting forever. `assignee` is enforced, not recorded: only that machine gets a lease, and an unknown assignee is
28
+ refused by name. `GET /v1/tasks` now publishes a derived `status` (`waiting`/`active`/`done`/`failed`/`cancelled`) and
29
+ `waiting_on`, so the board and the scheduler cannot disagree.
30
+
31
+ ### Dataflow: an expected shard count, and a reduce that refuses partial input
32
+
33
+ `buildReducePlan` takes `expectedIndexTotal` from the task (`index_total`) and refuses `MISSING_SHARD` (with the missing indexes),
34
+ `MAP_INDEX_MISSING` and `MAP_INDEX_OUT_OF_RANGE`; only machines that actually reported can claim a shard, so an expired lease no
35
+ longer makes a partial run look complete. Two production gaps were found by live testing and closed with it:
36
+
37
+ - **A large plain-command stdout was never a blob.** The blob path lived only in the pipeline loop, so a split/mapreduce map result
38
+ was always inline and a reduce plan could never see what it reduces. Any command output above the inline cap now uploads a blob
39
+ exactly as a pipeline stage does (best effort: a failed upload keeps the command successful and says why).
40
+ - **The relay's own frame limit made the blob channel unusable.** `MAX_FRAME_BYTES` (64 KiB) equals the inline threshold, so every
41
+ blob was by construction larger than the frame cap and a real upload failed with `FRAME_TOO_LARGE` while the fake relay in tests
42
+ accepted it. Blob PUTs now get a bounded 1 MiB frame (`BLOB_FRAME_BYTES`); every JSON route keeps the 64 KiB cap.
43
+ - The aggregate now publishes `blob_sha256`, `output_mode`, `stdout_head` and `stdout_bytes` per machine: `w2m_wait` used to read
44
+ `aggregate.detail.machines`, a shape the production `/v1/tasks` response never carries, so the reduce plan was unreachable
45
+ outside tests.
46
+
47
+ ### Verified
48
+
49
+ Gate GREEN twice, 15 of 15: 2687 tests / 2673 pass / 0 fail / 14 skip in 130 files.
50
+
51
+ Two-machine live runs on Windows (ToFuture) and jp-shared over the real relay:
52
+
53
+ - **await over SSE**: Windows asks, jp-shared replies `reply_state:'refused'`; the wait returns `state=refused, via=stream`, and the
54
+ turn is settled on disk.
55
+ - **dependency chain**: B with `depends_on: [A]` is `waiting`, A settles, B is released (observed `status=active` with offers).
56
+ - **missing shard**: a split task with `index_total: 5` whose map results are blobs is refused as
57
+ `MISSING_SHARD {missing:[1,3,4], expectedIndexTotal:5}` with `dataflow_degraded: true` -- the first live exercise of the blob
58
+ channel end to end.
59
+
60
+ Both live-testing defects above (release only on a result; graph edges missing from the ledger) were found by these runs and are
61
+ covered by tests now.
62
+
63
+ ### Limits, stated
64
+
65
+ - A pipeline task runs the whole chain per machine, so `index_total` stays 1 for it; blob map results come from plain commands above
66
+ the inline cap.
67
+ - The blob transport frame is 1 MiB while the agent's dataflow hard cap is 16 MiB: a single artifact above 1 MiB fails its upload
68
+ explicitly (`BLOB_UPLOAD_FAILED`) rather than being split across frames.
69
+ - The jp-shared agent was upgraded to this build; macmini runs 1.2.6 from its own checkout and needs a `git pull` + restart to get
70
+ the plain-command blob behaviour.
10
71
  ## [1.2.6] — 2026-10-20
11
72
 
12
73
  **Security surface: signatures, a permission matrix, and output that no longer carries secrets — plus the reliability the chat
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.2.6", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.2.7", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.2.6",
4
+ "version": "1.2.7",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -3215,6 +3215,28 @@ export function createAgent(options) {
3215
3215
  }
3216
3216
  }
3217
3217
 
3218
+ /**
3219
+ * v1.2.7: a plain command's large stdout is a stage artifact too.
3220
+ *
3221
+ * The blob path used to live only in the pipeline loop, so a split/mapreduce map result was always inlined and a
3222
+ * reduce plan could never see the data it reduces -- the missing-shard check was unreachable from a real task. A
3223
+ * command that prints more than the inline cap now uploads its stdout exactly as a stage does. Best effort here: a
3224
+ * failed upload must not turn a successful command into a failure, so the envelope stays inline and says why.
3225
+ */
3226
+ if (isPipeline !== true && execution !== null && lastBlobSha256 === null) {
3227
+ const plainPlan = planStageOutput({ machineId: identity.machine_id, stageIndex: 0, stdoutBytes: execution.stdout_bytes });
3228
+ if (plainPlan.ok === true && plainPlan.mode === 'blob') {
3229
+ if (execution.truncated_stdout === true && !warnings.includes('STDOUT_TRUNCATED')) warnings.push('STDOUT_TRUNCATED');
3230
+ const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout);
3231
+ if (uploaded.ok === true) {
3232
+ lastBlobSha256 = uploaded.sha256;
3233
+ } else {
3234
+ log('warn', offer.task_id + ': plain stdout blob upload failed', { code: uploaded.code });
3235
+ warnings.push('BLOB_UPLOAD_FAILED');
3236
+ }
3237
+ }
3238
+ }
3239
+
3218
3240
  heartbeat.setPhase('finalizing', 90);
3219
3241
  if (execution === null) {
3220
3242
  // Nothing ran at all (a refused first stage, or a budget already spent). `execution` stays
@@ -1,147 +1,153 @@
1
- /**
2
- * The dependency graph of a fleet's tasks, as pure functions.
3
- *
4
- * ## Why this is separate from the relay's own validation
5
- *
6
- * The relay refuses a graph it cannot store (unknown prerequisite, cycle, a self-edge), but that is one call at one moment.
7
- * A reader looking at a task board asks a different question: of the tasks I can see, which are READY, which are WAITING on
8
- * something that is still running, and which are BLOCKED because a prerequisite failed or was cancelled? That question
9
- * depends on the tasks' current states, so it is answered here, over whatever list the reader has.
10
- *
11
- * ## The rule that makes "blocked" honest
12
- *
13
- * A prerequisite that is missing from the visible list is treated as WAITING, never as ready: the list may be truncated by a
14
- * limit, and inferring "no dependency" from "not in this page" is how a board would show work as available that is not.
15
- */
16
- export const TASK_GRAPH_REFUSAL = Object.freeze({
17
- UNKNOWN_NODE: 'GRAPH_UNKNOWN_NODE',
18
- CYCLE: 'GRAPH_CYCLE',
19
- BAD_NODE: 'GRAPH_BAD_NODE',
20
- });
21
-
22
- const DONE = new Set(['completed', 'done']);
23
- const FAILED = new Set(['failed', 'cancelled', 'refused']);
24
- // States that mean the task is already in flight. It is not 'ready': ready means work that can be STARTED.
25
- const ACTIVE = new Set(['running', 'queued', 'offered', 'preparing', 'finalizing']);
26
-
27
- /**
28
- * @param {{nodes?: Array<{task_id?: string, depends_on?: string[]}>}} input
29
- * @returns {{ok: true, nodes: Map<string, string[]>} | {ok: false, code: string, detail: object}}
30
- */
31
- export function buildGraph({ nodes = [] } = {}) {
32
- if (!Array.isArray(nodes)) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: {} };
33
- const map = new Map();
34
- for (const node of nodes) {
35
- const id = typeof node?.task_id === 'string' && node.task_id !== '' ? node.task_id : null;
36
- if (id === null) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: { node } };
37
- const deps = Array.isArray(node.depends_on) ? node.depends_on.filter((dep) => typeof dep === 'string' && dep !== '') : [];
38
- map.set(id, deps);
39
- }
40
- return { ok: true, nodes: map };
41
- }
42
-
43
- /**
44
- * A stable topological order, or the cycle that prevents one.
45
- *
46
- * Ties are broken by id so the same graph renders in the same order on every machine -- a board that reshuffles between two
47
- * reads looks like it is changing.
48
- *
49
- * @param {Map<string, string[]>} graph
50
- * @returns {{ok: true, order: string[]} | {ok: false, code: string, detail: {cycle: string[]}}}
51
- */
52
- export function topoOrder(graph) {
53
- const indegree = new Map();
54
- const dependents = new Map();
55
- for (const id of graph.keys()) {
56
- indegree.set(id, 0);
57
- dependents.set(id, []);
58
- }
59
- for (const [id, deps] of graph.entries()) {
60
- for (const dep of deps) {
61
- // A prerequisite OUTSIDE this page is not an edge in it. The relay refuses an unknown prerequisite when the task is
62
- // created, so a missing one here means the reader's list is truncated -- and treating that as a broken graph would
63
- // make an ordinary limited read report a cycle.
64
- if (!graph.has(dep)) continue;
65
- indegree.set(id, (indegree.get(id) ?? 0) + 1);
66
- if (!dependents.has(dep)) dependents.set(dep, []);
67
- dependents.get(dep).push(id);
68
- }
69
- }
70
- const ready = [...indegree.entries()].filter(([, count]) => count === 0).map(([id]) => id).sort();
71
- const order = [];
72
- while (ready.length > 0) {
73
- const id = ready.shift();
74
- order.push(id);
75
- for (const next of (dependents.get(id) ?? []).slice().sort()) {
76
- indegree.set(next, indegree.get(next) - 1);
77
- if (indegree.get(next) === 0) { ready.push(next); ready.sort(); }
78
- }
79
- }
80
- if (order.length !== graph.size) {
81
- const remaining = [...graph.keys()].filter((id) => !order.includes(id));
82
- return { ok: false, code: TASK_GRAPH_REFUSAL.CYCLE, detail: { cycle: remaining } };
83
- }
84
- return { ok: true, order };
85
- }
86
-
87
- /**
88
- * Classify one task against the states of the tasks a reader can see.
89
- *
90
- * @param {{task_id: string, depends_on?: string[], status?: string, statuses?: Map<string, string>}} input
91
- * @returns {'ready'|'waiting'|'blocked'|'done'|'active'|'unknown'}
92
- */
93
- export function classifyTask({ task_id, depends_on = [], status = null, statuses = new Map() } = {}) {
94
- if (typeof task_id !== 'string' || task_id === '') return 'unknown';
95
- if (typeof status === 'string' && DONE.has(status)) return 'done';
96
- if (typeof status === 'string' && FAILED.has(status)) return 'blocked';
97
- let waiting = false;
98
- for (const dep of depends_on) {
99
- const depStatus = statuses.get(dep);
100
- if (depStatus === undefined) { waiting = true; continue; }
101
- if (FAILED.has(depStatus)) return 'blocked';
102
- if (!DONE.has(depStatus)) waiting = true;
103
- }
104
- if (waiting) return 'waiting';
105
- // In flight ONLY once its prerequisites are satisfied: a queued task behind a failed prerequisite is blocked, not active.
106
- if (typeof status === 'string' && ACTIVE.has(status)) return 'active';
107
- return 'ready';
108
- }
109
-
110
- /**
111
- * The whole board, in one pass: every visible task classified, and the readies in dependency order.
112
- *
113
- * @param {{tasks?: Array<{task_id?: string, depends_on?: string[], status?: string}>}} input
114
- * @returns {{ok: true, by_task: Record<string, string>, ready: string[], waiting: string[], blocked: string[], done: string[], active: string[], order: string[]} |
115
- * {ok: false, code: string, detail: object}}
116
- */
117
- export function buildBoard({ tasks = [] } = {}) {
118
- const built = buildGraph({ nodes: tasks });
119
- if (built.ok !== true) return built;
120
- const statuses = new Map();
121
- for (const task of tasks) {
122
- const id = typeof task?.task_id === 'string' ? task.task_id : null;
123
- if (id !== null) statuses.set(id, typeof task?.status === 'string' ? task.status : 'unknown');
124
- }
125
- const byTask = {};
126
- const buckets = { ready: [], waiting: [], blocked: [], done: [], active: [] };
127
- for (const [id, deps] of built.nodes.entries()) {
128
- const kind = classifyTask({ task_id: id, depends_on: deps, status: statuses.get(id), statuses });
129
- byTask[id] = kind;
130
- if (kind === 'done') buckets.done.push(id);
131
- else if (kind === 'active') buckets.active.push(id);
132
- else if (kind === 'blocked') buckets.blocked.push(id);
133
- else if (kind === 'waiting') buckets.waiting.push(id);
134
- else buckets.ready.push(id);
135
- }
136
- const order = topoOrder(built.nodes);
137
- return {
138
- ok: true,
139
- by_task: byTask,
140
- ready: buckets.ready.sort(),
141
- waiting: buckets.waiting.sort(),
142
- blocked: buckets.blocked.sort(),
143
- done: buckets.done.sort(),
144
- active: buckets.active.sort(),
145
- order: order.ok === true ? order.order : [],
146
- };
147
- }
1
+ /**
2
+ * The dependency graph of a fleet's tasks, as pure functions.
3
+ *
4
+ * ## Why this is separate from the relay's own validation
5
+ *
6
+ * The relay refuses a graph it cannot store (unknown prerequisite, cycle, a self-edge), but that is one call at one moment.
7
+ * A reader looking at a task board asks a different question: of the tasks I can see, which are READY, which are WAITING on
8
+ * something that is still running, and which are BLOCKED because a prerequisite failed or was cancelled? That question
9
+ * depends on the tasks' current states, so it is answered here, over whatever list the reader has.
10
+ *
11
+ * ## The rule that makes "blocked" honest
12
+ *
13
+ * A prerequisite that is missing from the visible list is treated as WAITING, never as ready: the list may be truncated by a
14
+ * limit, and inferring "no dependency" from "not in this page" is how a board would show work as available that is not.
15
+ */
16
+ export const TASK_GRAPH_REFUSAL = Object.freeze({
17
+ UNKNOWN_NODE: 'GRAPH_UNKNOWN_NODE',
18
+ CYCLE: 'GRAPH_CYCLE',
19
+ BAD_NODE: 'GRAPH_BAD_NODE',
20
+ });
21
+
22
+ const DONE = new Set(['completed', 'done']);
23
+ const FAILED = new Set(['failed', 'cancelled', 'refused']);
24
+ // States that mean the task is already in flight. It is not 'ready': ready means work that can be STARTED.
25
+ const ACTIVE = new Set(['running', 'queued', 'offered', 'preparing', 'finalizing', 'active']);
26
+ /**
27
+ * v1.2.7: the relay's scheduler reports 'waiting' for a task whose prerequisites are unsettled. Reading that as "ready" was the
28
+ * exact board/scheduler disagreement this set closes.
29
+ */
30
+ const WAITING = new Set(['waiting']);
31
+
32
+ /**
33
+ * @param {{nodes?: Array<{task_id?: string, depends_on?: string[]}>}} input
34
+ * @returns {{ok: true, nodes: Map<string, string[]>} | {ok: false, code: string, detail: object}}
35
+ */
36
+ export function buildGraph({ nodes = [] } = {}) {
37
+ if (!Array.isArray(nodes)) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: {} };
38
+ const map = new Map();
39
+ for (const node of nodes) {
40
+ const id = typeof node?.task_id === 'string' && node.task_id !== '' ? node.task_id : null;
41
+ if (id === null) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: { node } };
42
+ const deps = Array.isArray(node.depends_on) ? node.depends_on.filter((dep) => typeof dep === 'string' && dep !== '') : [];
43
+ map.set(id, deps);
44
+ }
45
+ return { ok: true, nodes: map };
46
+ }
47
+
48
+ /**
49
+ * A stable topological order, or the cycle that prevents one.
50
+ *
51
+ * Ties are broken by id so the same graph renders in the same order on every machine -- a board that reshuffles between two
52
+ * reads looks like it is changing.
53
+ *
54
+ * @param {Map<string, string[]>} graph
55
+ * @returns {{ok: true, order: string[]} | {ok: false, code: string, detail: {cycle: string[]}}}
56
+ */
57
+ export function topoOrder(graph) {
58
+ const indegree = new Map();
59
+ const dependents = new Map();
60
+ for (const id of graph.keys()) {
61
+ indegree.set(id, 0);
62
+ dependents.set(id, []);
63
+ }
64
+ for (const [id, deps] of graph.entries()) {
65
+ for (const dep of deps) {
66
+ // A prerequisite OUTSIDE this page is not an edge in it. The relay refuses an unknown prerequisite when the task is
67
+ // created, so a missing one here means the reader's list is truncated -- and treating that as a broken graph would
68
+ // make an ordinary limited read report a cycle.
69
+ if (!graph.has(dep)) continue;
70
+ indegree.set(id, (indegree.get(id) ?? 0) + 1);
71
+ if (!dependents.has(dep)) dependents.set(dep, []);
72
+ dependents.get(dep).push(id);
73
+ }
74
+ }
75
+ const ready = [...indegree.entries()].filter(([, count]) => count === 0).map(([id]) => id).sort();
76
+ const order = [];
77
+ while (ready.length > 0) {
78
+ const id = ready.shift();
79
+ order.push(id);
80
+ for (const next of (dependents.get(id) ?? []).slice().sort()) {
81
+ indegree.set(next, indegree.get(next) - 1);
82
+ if (indegree.get(next) === 0) { ready.push(next); ready.sort(); }
83
+ }
84
+ }
85
+ if (order.length !== graph.size) {
86
+ const remaining = [...graph.keys()].filter((id) => !order.includes(id));
87
+ return { ok: false, code: TASK_GRAPH_REFUSAL.CYCLE, detail: { cycle: remaining } };
88
+ }
89
+ return { ok: true, order };
90
+ }
91
+
92
+ /**
93
+ * Classify one task against the states of the tasks a reader can see.
94
+ *
95
+ * @param {{task_id: string, depends_on?: string[], status?: string, statuses?: Map<string, string>}} input
96
+ * @returns {'ready'|'waiting'|'blocked'|'done'|'active'|'unknown'}
97
+ */
98
+ export function classifyTask({ task_id, depends_on = [], status = null, statuses = new Map() } = {}) {
99
+ if (typeof task_id !== 'string' || task_id === '') return 'unknown';
100
+ if (typeof status === 'string' && DONE.has(status)) return 'done';
101
+ if (typeof status === 'string' && FAILED.has(status)) return 'blocked';
102
+ let waiting = false;
103
+ for (const dep of depends_on) {
104
+ const depStatus = statuses.get(dep);
105
+ if (depStatus === undefined) { waiting = true; continue; }
106
+ if (FAILED.has(depStatus)) return 'blocked';
107
+ if (!DONE.has(depStatus)) waiting = true;
108
+ }
109
+ if (waiting) return 'waiting';
110
+ // In flight ONLY once its prerequisites are satisfied: a queued task behind a failed prerequisite is blocked, not active.
111
+ if (typeof status === 'string' && WAITING.has(status)) return 'waiting';
112
+ if (typeof status === 'string' && ACTIVE.has(status)) return 'active';
113
+ return 'ready';
114
+ }
115
+
116
+ /**
117
+ * The whole board, in one pass: every visible task classified, and the readies in dependency order.
118
+ *
119
+ * @param {{tasks?: Array<{task_id?: string, depends_on?: string[], status?: string}>}} input
120
+ * @returns {{ok: true, by_task: Record<string, string>, ready: string[], waiting: string[], blocked: string[], done: string[], active: string[], order: string[]} |
121
+ * {ok: false, code: string, detail: object}}
122
+ */
123
+ export function buildBoard({ tasks = [] } = {}) {
124
+ const built = buildGraph({ nodes: tasks });
125
+ if (built.ok !== true) return built;
126
+ const statuses = new Map();
127
+ for (const task of tasks) {
128
+ const id = typeof task?.task_id === 'string' ? task.task_id : null;
129
+ if (id !== null) statuses.set(id, typeof task?.status === 'string' ? task.status : 'unknown');
130
+ }
131
+ const byTask = {};
132
+ const buckets = { ready: [], waiting: [], blocked: [], done: [], active: [] };
133
+ for (const [id, deps] of built.nodes.entries()) {
134
+ const kind = classifyTask({ task_id: id, depends_on: deps, status: statuses.get(id), statuses });
135
+ byTask[id] = kind;
136
+ if (kind === 'done') buckets.done.push(id);
137
+ else if (kind === 'active') buckets.active.push(id);
138
+ else if (kind === 'blocked') buckets.blocked.push(id);
139
+ else if (kind === 'waiting') buckets.waiting.push(id);
140
+ else buckets.ready.push(id);
141
+ }
142
+ const order = topoOrder(built.nodes);
143
+ return {
144
+ ok: true,
145
+ by_task: byTask,
146
+ ready: buckets.ready.sort(),
147
+ waiting: buckets.waiting.sort(),
148
+ blocked: buckets.blocked.sort(),
149
+ done: buckets.done.sort(),
150
+ active: buckets.active.sort(),
151
+ order: order.ok === true ? order.order : [],
152
+ };
153
+ }
@@ -272,7 +272,7 @@ const MAX_STAGES = 16;
272
272
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
273
273
  * overrides it at runtime for tests and packagers.
274
274
  */
275
- const PLUGIN_VERSION = '1.2.6';
275
+ const PLUGIN_VERSION = '1.2.7';
276
276
 
277
277
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
278
278
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3771,6 +3771,15 @@ export async function apply(ctx, config = {}) {
3771
3771
  items: { type: 'string' },
3772
3772
  description: 'Which terminal conditions justify another attempt: expired, failed, partial, timeout. Omit to never retry.',
3773
3773
  },
3774
+ depends_on: {
3775
+ type: 'array',
3776
+ items: { type: 'string' },
3777
+ description: 'Task ids this task waits for. The relay will not dispatch it until every dependency has settled; then it is offered automatically.',
3778
+ },
3779
+ assignee: {
3780
+ type: 'string',
3781
+ description: 'Machine id that must execute this task. An assignee is a restriction: no other machine is offered the work, and an unknown id is refused by name.',
3782
+ },
3774
3783
  },
3775
3784
  output: jsonOutput,
3776
3785
  async execute(args, exec) {
@@ -4181,6 +4190,11 @@ export async function apply(ctx, config = {}) {
4181
4190
  // v1.2.4: the retry policy travels only when the caller asked for one, so a default dispatch stays byte-identical.
4182
4191
  ...(Number.isInteger(args?.max_attempts) && args.max_attempts > 1 ? { max_attempts: args.max_attempts } : {}),
4183
4192
  ...(Array.isArray(args?.retry_on) && args.retry_on.length > 0 ? { retry_on: args.retry_on } : {}),
4193
+ // v1.2.7: graph edges and assignment travel only when declared, so a default dispatch stays byte-identical.
4194
+ ...(Array.isArray(args?.depends_on) && args.depends_on.filter((id) => typeof id === 'string' && id !== '').length > 0
4195
+ ? { depends_on: args.depends_on.filter((id) => typeof id === 'string' && id !== '') }
4196
+ : {}),
4197
+ ...(typeof args?.assignee === 'string' && args.assignee.trim() !== '' ? { assignee: args.assignee.trim() } : {}),
4184
4198
  created_by: cfg.machineName,
4185
4199
  // Only sent for broadcast; the relay ignores it otherwise, and the validation above already
4186
4200
  // refused the combination, so this cannot silently mean nothing.
@@ -4515,13 +4529,24 @@ export async function apply(ctx, config = {}) {
4515
4529
  const reducerMachineId = typeof args?.reducer_machine_id === 'string' && args.reducer_machine_id.trim() !== ''
4516
4530
  ? args.reducer_machine_id.trim()
4517
4531
  : null;
4518
- const rawMachines = Array.isArray(aggregate.detail?.machines) ? aggregate.detail.machines : [];
4519
- const mapResults = rawMachines.map((machine) => {
4532
+ // v1.2.7: the relay publishes machine facts directly on the aggregate; `detail.machines` is a test/report shape that the
4533
+ // real /v1/tasks response does not carry. Reading only the latter made the reduce plan silently unreachable in production.
4534
+ const rawMachines = Array.isArray(aggregate.detail?.machines)
4535
+ ? aggregate.detail.machines
4536
+ : (Array.isArray(aggregate.machines) ? aggregate.machines : []);
4537
+ // v1.2.7: only a machine that actually reported can claim a shard. An expired lease still carries an index, and counting
4538
+ // it as "produced" would make a partial run look complete -- the exact failure the expected-index check exists to prevent.
4539
+ const reportedMachines = rawMachines.filter((machine) => (machine?.envelope ?? machine?.result ?? null) !== null || typeof machine?.blob_sha256 === 'string' || typeof machine?.stdout_head === 'string' || Number.isFinite(machine?.stdout_bytes) || (typeof machine?.status === 'string' && machine.status !== '' && Number.isFinite(machine?.exit_code)));
4540
+ const mapResults = reportedMachines.map((machine) => {
4520
4541
  const envelope = machine?.envelope ?? machine?.result ?? null;
4521
- const blobSha = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256 : null;
4522
- const inline = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head : null;
4542
+ const blobSha = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256
4543
+ : (typeof machine?.blob_sha256 === 'string' ? machine.blob_sha256 : null);
4544
+ const inline = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head
4545
+ : (typeof machine?.stdout_head === 'string' && machine.stdout_head !== '' ? machine.stdout_head : null);
4523
4546
  return {
4524
4547
  machine_id: machine?.machine_id ?? null,
4548
+ // v1.2.7: the shard this result belongs to, so the reduce plan can tell "complete" from "the rest never reported".
4549
+ index: Number.isInteger(machine?.index) ? machine.index : (Number.isInteger(envelope?.index) ? envelope.index : null),
4525
4550
  mode: blobSha === null ? 'inline' : 'blob',
4526
4551
  ...(inline === null ? {} : { stdout: inline }),
4527
4552
  ...(blobSha === null ? {} : { blob_sha256: blobSha }),
@@ -4529,7 +4554,12 @@ export async function apply(ctx, config = {}) {
4529
4554
  });
4530
4555
  const anyBlob = mapResults.some((entry) => entry.mode === 'blob');
4531
4556
  const reduce = anyBlob && reducerMachineId !== null
4532
- ? buildReducePlan({ results: mapResults, reducerMachineId })
4557
+ ? buildReducePlan({
4558
+ results: mapResults,
4559
+ reducerMachineId,
4560
+ // v1.2.7: the task's own shard count is the completeness contract; absent it, the old "just what arrived" plan stands.
4561
+ expectedIndexTotal: Number.isInteger(body?.task?.index_total) ? body.task.index_total : null,
4562
+ })
4533
4563
  : null;
4534
4564
  const dataflowDegraded = reduce !== null && reduce.ok !== true;
4535
4565
 
@@ -6601,6 +6631,10 @@ export async function apply(ctx, config = {}) {
6601
6631
  type: 'string',
6602
6632
  description: 'For send/ask: replay protection. A retry with the same key returns the first message instead of posting the same sentence twice.',
6603
6633
  },
6634
+ reply_state: {
6635
+ type: 'string',
6636
+ description: 'For send/ask: answer the turn as "answered" or "refused". A refusal is a state the awaiter reads, not a wording it has to guess from.',
6637
+ },
6604
6638
  timeout_ms: { type: 'number', description: 'For await: how long to wait before reporting a timeout (default 60000, capped at 300000). A timeout is a value, not an error.' },
6605
6639
  },
6606
6640
  output: jsonOutput,
@@ -6610,6 +6644,8 @@ export async function apply(ctx, config = {}) {
6610
6644
  const action = typeof args?.action === 'string' ? args.action : 'list';
6611
6645
  const sessionId = typeof args?.session_id === 'string' ? args.session_id.trim() : '';
6612
6646
  if (sessionId === '') throw new W2MError('W2M_CHAT_SESSION', 'W2M_CHAT_SESSION: session_id is required', { hint: 'chat is scoped to a conversation' });
6647
+ // v1.2.7: expire stale turns on every entry, so `asks` reports the truth after downtime.
6648
+ askStore.expireDue();
6613
6649
  if (action === 'asks') {
6614
6650
  // The persisted questions for this conversation, in the states they are in. A read, so it needs no relay.
6615
6651
  return JSON.stringify({ ok: true, action, session_id: sessionId, ...askStore.list({ sessionId }) });
@@ -6655,26 +6691,84 @@ export async function apply(ctx, config = {}) {
6655
6691
  if (typeof args?.reply_to === 'string' && args.reply_to.trim() !== '') addressing.reply_to = args.reply_to.trim();
6656
6692
 
6657
6693
  if (action === 'await') {
6658
- // Waiting for an ANSWER is a state machine, and a timeout is a value the caller reads rather than an exception
6659
- // that loses the question. The question stays in the flow, so it can still be answered later.
6694
+ // v1.2.7: waiting is a persisted turn, not a poll loop.
6695
+ //
6696
+ // The question is written to the ask store when it is ASKED; await attaches to that record, so a restart in between
6697
+ // does not lose the question or turn a late answer into silence. The event stream is tried first (the reply arrives
6698
+ // when it is posted, not up to a poll interval later), and polling remains the fallback for a relay or proxy that
6699
+ // cannot stream -- an old relay must not break awaiting.
6660
6700
  const target = typeof args?.reply_to === 'string' ? args.reply_to.trim() : '';
6661
- if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass the message_id returned by ask' });
6701
+ if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass reply_to: <message_id of the question>' });
6662
6702
  const timeoutMs = Number.isFinite(args?.timeout_ms) ? Math.max(1000, Math.min(args.timeout_ms, 300000)) : 60000;
6663
6703
  const startedAt = Date.now();
6704
+ askStore.expireDue();
6705
+ let turn = askStore.list({ sessionId }).asks.find((ask) => (ask.state === 'open' || ask.state === 'timeout') && ask.message_id === target) ?? null;
6706
+ if (turn === null) {
6707
+ const opened = askStore.openAsk({ sessionId, messageId: target, timeoutMs });
6708
+ turn = opened.ok === true ? opened.ask : null;
6709
+ }
6710
+ const ownMachineId = cfg.machineId ?? null;
6711
+ const findReply = async () => {
6712
+ const listed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, limit: 200 } });
6713
+ const rows = Array.isArray(listed?.json?.messages) ? listed.json.messages : [];
6714
+ return rows.find((message) => message.reply_to === target && message.machine_id !== ownMachineId) ?? null;
6715
+ };
6716
+ const finish = (replyMessage, via) => {
6717
+ const verdict = replyMessage?.reply_state === 'refused' ? 'refused' : 'answered';
6718
+ if (turn !== null) askStore.settle(turn.ask_id, verdict, { messageId: replyMessage?.message_id ?? null });
6719
+ const recorded = turn === null ? null : (askStore.list({ sessionId }).asks.find((ask) => ask.ask_id === turn.ask_id) ?? null);
6720
+ return JSON.stringify({ ok: true, action, state: verdict, via, reply_to: target, reply: replyMessage, replies: replyMessage === null ? [] : [replyMessage], ask: recorded });
6721
+ };
6722
+ // 1) stream first -- and RE-subscribe while the budget remains: a dropped stream is not the end of the wait.
6723
+ let emptyStreams = 0;
6724
+ while (Date.now() - startedAt < timeoutMs) {
6725
+ const remaining = timeoutMs - (Date.now() - startedAt);
6726
+ const controller = new AbortController();
6727
+ const timer = setTimeout(() => controller.abort(), Math.min(remaining, 30000));
6728
+ let reachable = false;
6729
+ let gotEvent = false;
6730
+ try {
6731
+ const streamUrl = joinUrl(rabbitUrl, '/v1/fleet/messages/stream') + '?session_id=' + encodeURIComponent(sessionId);
6732
+ const response = await fetch(streamUrl, { headers: { authorization: 'Bearer ' + token }, signal: controller.signal });
6733
+ if (response.ok === true && response.body !== null) {
6734
+ reachable = true;
6735
+ const reader = response.body.getReader();
6736
+ const decoder = new TextDecoder();
6737
+ let buffer = '';
6738
+ for (;;) {
6739
+ const { value, done } = await reader.read();
6740
+ if (done === true) break;
6741
+ buffer += decoder.decode(value, { stream: true });
6742
+ const parts = buffer.split('\n\n');
6743
+ buffer = parts.pop() ?? '';
6744
+ for (const part of parts) {
6745
+ const line = part.split('\n').find((entry) => entry.startsWith('data: '));
6746
+ if (line === undefined) continue;
6747
+ gotEvent = true;
6748
+ const reply = await findReply();
6749
+ if (reply !== null) return finish(reply, 'stream');
6750
+ }
6751
+ }
6752
+ }
6753
+ } catch (error) {
6754
+ if (error?.name === 'AbortError' && Date.now() - startedAt >= timeoutMs) break;
6755
+ } finally { clearTimeout(timer); }
6756
+ if (reachable !== true) break; // a relay that will not stream at all: polling is the contract
6757
+ emptyStreams = gotEvent === true ? 0 : emptyStreams + 1;
6758
+ if (emptyStreams >= 2) break; // two streams that end immediately will not deliver this reply
6759
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
6760
+ }
6761
+ // 2) polling fallback
6664
6762
  for (;;) {
6665
6763
  throwIfAborted(exec?.signal);
6666
- const reply = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, reply_to: target, limit: '10' } });
6667
- const found = Array.isArray(reply?.json?.messages) ? reply.json.messages : [];
6668
- if (found.length > 0) {
6669
- for (const open of askStore.list({ sessionId }).asks) {
6670
- if (open.state === 'open' && open.message_id === target) askStore.settle(open.ask_id, 'answered', { messageId: found[0]?.message_id ?? null });
6671
- }
6672
- return JSON.stringify({ ok: true, action, state: 'answered', reply_to: target, replies: found });
6673
- }
6764
+ const reply = await findReply();
6765
+ if (reply !== null) return finish(reply, 'poll');
6674
6766
  if (Date.now() - startedAt >= timeoutMs) {
6675
- return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, hint: 'the question is still in the flow and can be answered later' });
6767
+ if (turn !== null) askStore.expire(turn.ask_id);
6768
+ const recorded = turn === null ? null : (askStore.list({ sessionId }).asks.find((ask) => ask.ask_id === turn.ask_id) ?? null);
6769
+ return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, ask: recorded, hint: 'the question stays in the flow and can still be answered later' });
6676
6770
  }
6677
- await new Promise((resolve) => { setTimeout(resolve, 1500); });
6771
+ await new Promise((resolve) => { setTimeout(resolve, 1000); });
6678
6772
  }
6679
6773
  }
6680
6774
  if (action === 'send' || action === 'ask') {
@@ -6699,6 +6793,7 @@ export async function apply(ctx, config = {}) {
6699
6793
  ...addressing,
6700
6794
  ...(mentions.length === 0 ? {} : { mentions }),
6701
6795
  ...(typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== '' ? { idempotency_key: args.idempotency_key.trim() } : { idempotency_key: 'spool-' + Date.now() + '-' + Math.random().toString(36).slice(2) }),
6796
+ ...(typeof args?.reply_state === 'string' && (args.reply_state === 'answered' || args.reply_state === 'refused') ? { reply_state: args.reply_state } : {}),
6702
6797
  };
6703
6798
  let posted = null;
6704
6799
  try {
@@ -6715,9 +6810,23 @@ export async function apply(ctx, config = {}) {
6715
6810
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'W2M_CHAT_SEND';
6716
6811
  throw new W2MError('W2M_CHAT_SEND', code + ': the message was refused', { reason_code: code, hint: 'quota, size and non-text each need a different answer' });
6717
6812
  }
6813
+ let ask = null;
6814
+ if (action === 'ask' && posted.json?.message?.message_id !== undefined) {
6815
+ // v1.2.7: the question becomes a turn on disk the moment it is asked, so a later await (in this process or after a
6816
+ // restart) settles the same record instead of opening a second one.
6817
+ const opened = askStore.openAsk({
6818
+ sessionId,
6819
+ messageId: posted.json.message.message_id,
6820
+ threadId: typeof args?.thread_id === 'string' ? args.thread_id : null,
6821
+ to: typeof args?.to === 'string' ? args.to : null,
6822
+ timeoutMs: Number.isFinite(args?.timeout_ms) ? args.timeout_ms : 60000,
6823
+ });
6824
+ if (opened.ok === true) ask = opened.ask;
6825
+ }
6718
6826
  return JSON.stringify({
6719
6827
  ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null,
6720
6828
  encrypted: sealed !== null && sealed.ok === true,
6829
+ ...(ask === null ? {} : { ask }),
6721
6830
  ...(posted.json?.deduped === true ? { deduped: true } : {}),
6722
6831
  });
6723
6832
  }
@@ -7069,6 +7178,11 @@ export async function apply(ctx, config = {}) {
7069
7178
  mode: row?.mode ?? null,
7070
7179
  status: row?.status ?? null,
7071
7180
  cancelled: row?.cancelled === true,
7181
+ // v1.2.7: graph facts travel with each row so the board and the scheduler cannot disagree.
7182
+ depends_on: Array.isArray(row?.depends_on) ? row.depends_on : [],
7183
+ assignee: row?.assignee ?? null,
7184
+ waiting_on: Array.isArray(row?.waiting_on) ? row.waiting_on : [],
7185
+ lease_states: row?.lease_states ?? {},
7072
7186
  machines: Array.isArray(row?.leases)
7073
7187
  ? row.leases.map((lease) => lease?.machine_id ?? null).filter((id) => typeof id === "string")
7074
7188
  : [],
@@ -143,6 +143,11 @@ export function createFleetMessages(options = {}) {
143
143
  mentions = [...new Set(rawMentions)];
144
144
  }
145
145
 
146
+ // v1.2.7: a reply can be an ANSWER or a REFUSAL, and the difference is a state, not a wording. Validated here so the
147
+ // turn state machine reads the sender's own word instead of guessing from text.
148
+ const replyState = input?.reply_state === undefined || input?.reply_state === null ? null : input.reply_state;
149
+ if (replyState !== null && replyState !== 'answered' && replyState !== 'refused') return { ok: false, code: 'BAD_REPLY_STATE' };
150
+
146
151
  const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
147
152
  ? input.thread_id.trim().slice(0, 64)
148
153
  : null;
@@ -179,6 +184,7 @@ export function createFleetMessages(options = {}) {
179
184
  // the cipher it does not have a key for.
180
185
  ...(enc === null ? {} : { enc }),
181
186
  ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
187
+ ...(replyState === null ? {} : { reply_state: replyState }),
182
188
  at_ms: now(),
183
189
  bytes,
184
190
  };
@@ -492,6 +492,17 @@ export function aggregate(task, resultRecords = [], opts = {}) {
492
492
  reasons: m.reasons,
493
493
  // v1.2.6: the relay's verdict, bound to the key registered at pairing.
494
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,
495
506
  })),
496
507
  steps,
497
508
  differences: step3.differences ?? [],
@@ -22,6 +22,14 @@ import {
22
22
  RabbitState,
23
23
  rfc3339,
24
24
  } from './state.mjs';
25
+
26
+ // v1.2.7: the content-addressed blob channel carries ARTIFACTS, so its frame limit cannot be the JSON frame limit.
27
+ //
28
+ // MAX_FRAME_BYTES (64 KiB) is exactly the inline threshold: any blob is larger than an inline result by construction, so with
29
+ // one shared limit the blob channel could never accept a single byte -- a pipeline blob upload from a real relay failed with
30
+ // FRAME_TOO_LARGE while the fake relay in the tests accepted it. The dataflow hard cap stays the agent-side bound; this is the
31
+ // transport bound, and it is still bounded.
32
+ export const BLOB_FRAME_BYTES = 1024 * 1024; // 1 MiB per blob frame
25
33
  import { createFleetMessages } from './fleet-messages.mjs';
26
34
  import { can as mayAct } from '../util/policy-matrix.mjs';
27
35
  import { createFleetMemory } from './fleet-memory.mjs';
@@ -687,7 +695,9 @@ export class RelayServer {
687
695
 
688
696
  // v0.3.0 §9: read the body ONCE, before routing. An HMAC covers the exact
689
697
  // received bytes, so no route may consume the stream first and re-serialise.
690
- const rawBody = methodHasBody(method) ? await readRawBody(req, this.frameLimitBytes) : null;
698
+ /** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
699
+ const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
700
+ const rawBody = methodHasBody(method) ? await readRawBody(req, blobFrameLimit) : null;
691
701
  const takeJson = () => parseJsonBody(rawBody);
692
702
 
693
703
  /* ---- public endpoints ---- */
@@ -1145,6 +1155,7 @@ export class RelayServer {
1145
1155
  text: body.text,
1146
1156
  mentions: body.mentions,
1147
1157
  enc: body.enc,
1158
+ reply_state: body.reply_state,
1148
1159
  idempotency_key: body.idempotency_key,
1149
1160
  });
1150
1161
  if (posted.ok !== true) {
@@ -734,6 +734,9 @@ export class RabbitState {
734
734
  created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
735
735
  command_hash: t.command_hash ?? null,
736
736
  attempt: t.attempt ?? 1,
737
+ // v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
738
+ depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
739
+ assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
737
740
  leases: new Map(),
738
741
  cancelled: false,
739
742
  cancel_reason: null,
@@ -1917,6 +1920,31 @@ export class RabbitState {
1917
1920
  if (typeof dedupeKeyBase !== 'string' || dedupeKeyBase.length !== 64) {
1918
1921
  throw new ProtocolError('INTERNAL', 'failed to compute dedupe_key');
1919
1922
  }
1923
+ /**
1924
+ * v1.2.7: a dependency that is not settled means this task is NOT dispatched yet. Its leases are recorded as `waiting`,
1925
+ * which the sweeper ignores and redelivery skips, and `_releaseDependents` moves them to offers when the graph allows it.
1926
+ */
1927
+ const dependencyStates = (task.depends_on ?? []).map((depId) => {
1928
+ const dep = this.tasks.get(depId);
1929
+ if (dep === undefined) return 'unknown';
1930
+ if (dep.cancelled === true) return 'cancelled';
1931
+ return this._taskSettled(dep) === true ? 'completed' : 'waiting';
1932
+ });
1933
+ const graphBlocked = dependencyStates.some((state) => state === 'cancelled' || state === 'unknown');
1934
+ const graphReady = graphBlocked !== true && dependencyStates.every((state) => state === 'completed');
1935
+ const waitingOn = graphReady === true ? [] : (task.depends_on ?? []);
1936
+ /**
1937
+ * v1.2.7: an assignee is a restriction, not a label. Only that machine gets a lease; another machine cannot accidentally
1938
+ * take work that was meant for one host. An unknown assignee is refused by name rather than producing a task nobody holds.
1939
+ */
1940
+ if (task.assignee !== null && !this.devices.has(task.assignee)) {
1941
+ throw new ProtocolError('BAD_REQUEST', 'assignee is not a known device on this relay', { assignee: task.assignee });
1942
+ }
1943
+ if (task.assignee !== null) {
1944
+ const narrowed = targets.filter((device) => device.machine_id === task.assignee);
1945
+ targets.length = 0;
1946
+ targets.push(...narrowed);
1947
+ }
1920
1948
  let lastSeq = this.seq;
1921
1949
  targets.forEach((device, i) => {
1922
1950
  const index = mode === 'replicate' ? 0 : i % indexTotal;
@@ -1950,6 +1978,12 @@ export class RabbitState {
1950
1978
  task.leases.set(device.machine_id, lease);
1951
1979
  return;
1952
1980
  }
1981
+ if (graphReady !== true) {
1982
+ lease.state = 'waiting';
1983
+ lease.waiting_on = waitingOn;
1984
+ task.leases.set(device.machine_id, lease);
1985
+ return;
1986
+ }
1953
1987
  const gate = gateMachine(task, device);
1954
1988
  if (gate) {
1955
1989
  lease.state = 'refused';
@@ -2002,6 +2036,9 @@ export class RabbitState {
2002
2036
  attempt: task.attempt,
2003
2037
  deadline_ms: task.deadline_ms,
2004
2038
  // v0.4.0: routing metadata, so a restarted relay re-offers with the same facts.
2039
+ // v1.2.7: the dependency graph and assignment belong to the task record, not to a reader's memory of it.
2040
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2041
+ assignee: task.assignee ?? null,
2005
2042
  origin_machine_id: task.origin_machine_id ?? null,
2006
2043
  p2p: task.p2p ?? null,
2007
2044
  },
@@ -2211,6 +2248,47 @@ export class RabbitState {
2211
2248
  .map((t) => this.taskSummary(t));
2212
2249
  }
2213
2250
 
2251
+ /** v1.2.7: a task is settled when no lease can still produce a result. Dependencies wait for that, not for a wording. */
2252
+ _taskSettled(task) {
2253
+ for (const lease of task.leases.values()) if (ACTIVE_LEASE_STATES.includes(lease.state)) return false;
2254
+ return true;
2255
+ }
2256
+
2257
+ /**
2258
+ * v1.2.7: release tasks whose prerequisites are all settled.
2259
+ *
2260
+ * Called after a result is stored: a dependency that finished must actually move its dependents from `waiting` to an offer.
2261
+ * Without this the graph would be validated, stored, and then never scheduled -- the exact "validated but not enforced" gap.
2262
+ */
2263
+ _releaseDependents(completedTaskId = null) {
2264
+ const released = [];
2265
+ for (const candidate of this.tasks.values()) {
2266
+ if (!Array.isArray(candidate.depends_on) || candidate.depends_on.length === 0) continue;
2267
+ if (completedTaskId !== null && !candidate.depends_on.includes(completedTaskId)) continue;
2268
+ const waiting = [...candidate.leases.values()].filter((lease) => lease.state === 'waiting');
2269
+ if (waiting.length === 0) continue;
2270
+ let ready = true;
2271
+ for (const depId of candidate.depends_on) {
2272
+ const dep = this.tasks.get(depId);
2273
+ if (dep === undefined) { ready = false; break; }
2274
+ if (dep.cancelled === true) { ready = false; break; }
2275
+ if (this._taskSettled(dep) !== true) { ready = false; break; }
2276
+ }
2277
+ if (ready !== true) continue;
2278
+ for (const lease of waiting) {
2279
+ lease.state = 'queued';
2280
+ lease.claimed_at_ms = this.nowMs();
2281
+ lease.lease_until_ms = this.nowMs() + this.leaseWindowMs();
2282
+ const device = this.devices.get(lease.machine_id);
2283
+ if (device === undefined) continue;
2284
+ this.emitOffer(candidate, lease);
2285
+ }
2286
+ this.emit('notice', { level: 'info', code: 'TASK_READY', message: candidate.task_id + ' is ready: dependencies settled', task_id: candidate.task_id });
2287
+ released.push(candidate.task_id);
2288
+ }
2289
+ return released;
2290
+ }
2291
+
2214
2292
  taskSummary(task) {
2215
2293
  return {
2216
2294
  task_id: task.task_id,
@@ -2222,6 +2300,24 @@ export class RabbitState {
2222
2300
  degraded: task.degraded,
2223
2301
  leases: [...task.leases.values()].map((l) => l.machine_id),
2224
2302
  lease_states: Object.fromEntries([...task.leases.values()].map((l) => [l.machine_id, l.state])),
2303
+ /**
2304
+ * v1.2.7: the state the scheduler is actually in, derived here rather than by each reader.
2305
+ *
2306
+ * Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
2307
+ * map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
2308
+ */
2309
+ status: (() => {
2310
+ if (task.cancelled === true) return 'cancelled';
2311
+ const leases = [...task.leases.values()];
2312
+ if (leases.some((lease) => lease.state === 'waiting')) return 'waiting';
2313
+ if (leases.some((lease) => ACTIVE_LEASE_STATES.includes(lease.state))) return 'active';
2314
+ const results = [...this.results.values()].filter((record) => record?.envelope?.task_id === task.task_id);
2315
+ if (results.length > 0 && results.every((record) => ['failed', 'crashed', 'refused', 'timeout'].includes(record?.envelope?.status))) return 'failed';
2316
+ return this._taskSettled(task) === true ? 'done' : 'unknown';
2317
+ })(),
2318
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2319
+ assignee: task.assignee ?? null,
2320
+ waiting_on: [...task.leases.values()].some((l) => l.state === 'waiting') ? (Array.isArray(task.depends_on) ? task.depends_on : []) : [],
2225
2321
  };
2226
2322
  }
2227
2323
 
@@ -2450,6 +2546,9 @@ export class RabbitState {
2450
2546
  }
2451
2547
  if (changed) task.degraded = 'partial';
2452
2548
  }
2549
+ // v1.2.7: a lease that expires (or a machine that refuses) is a settlement too -- the dependent graph must move.
2550
+ this._releaseDependents();
2551
+
2453
2552
  return expired;
2454
2553
  }
2455
2554
 
@@ -2738,6 +2837,9 @@ export class RabbitState {
2738
2837
  });
2739
2838
 
2740
2839
  // §7: results go into the ledger so the dedupe index survives a restart.
2840
+ // v1.2.7: a settled task may be the prerequisite that frees its dependents.
2841
+ this._releaseDependents(taskId);
2842
+
2741
2843
  this._ledger({ type: 'result.stored', task_id: taskId, machine_id: machineId, result: record });
2742
2844
  if (machineId) {
2743
2845
  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
  }