@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +61 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +22 -0
- package/src/plugin/task-graph.mjs +153 -147
- package/src/plugin/tools.mjs +133 -19
- package/src/relay/fleet-messages.mjs +6 -0
- package/src/relay/report.mjs +11 -0
- package/src/relay/server.mjs +12 -1
- package/src/relay/state.mjs +102 -0
- package/src/util/dataflow.mjs +24 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
package/codex/mcp-servers.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.2.
|
|
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.
|
|
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": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
for (const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
4519
|
-
|
|
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
|
|
4522
|
-
|
|
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({
|
|
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
|
-
//
|
|
6659
|
-
//
|
|
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
|
|
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
|
|
6667
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
};
|
package/src/relay/report.mjs
CHANGED
|
@@ -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 ?? [],
|
package/src/relay/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/relay/state.mjs
CHANGED
|
@@ -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);
|
package/src/util/dataflow.mjs
CHANGED
|
@@ -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
|
}
|