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