@twinsearth/w2m-dsh-plugin 1.2.7 → 1.3.1
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 +149 -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/agent/agent.mjs
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
import { Readable } from 'node:stream';
|
|
25
25
|
import { access, constants as fsConstants } from 'node:fs/promises';
|
|
26
|
-
import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { existsSync, mkdirSync, mkdtempSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
28
29
|
import { randomUUID , sign as signBytes } from 'node:crypto';
|
|
29
30
|
|
|
30
31
|
import {
|
|
@@ -3027,6 +3028,58 @@ export function createAgent(options) {
|
|
|
3027
3028
|
// never allowed to ask for, and the plugin's pre-flight refuses the dispatch as a whole for the
|
|
3028
3029
|
// same reason. The refusal names the stage index, because "this chain is not allowed" is not
|
|
3029
3030
|
// actionable without knowing which stage.
|
|
3031
|
+
/**
|
|
3032
|
+
* v1.2.10: a reduce task carries the artifacts it consumes, and the machine fetches them BEFORE the reduce command runs.
|
|
3033
|
+
*
|
|
3034
|
+
* The list is the caller's completeness decision (the relay validated its shape, never its truth), and the bytes are
|
|
3035
|
+
* content-addressed, so the fetch verifies nothing less than the hash already did. A failed fetch refuses the whole task:
|
|
3036
|
+
* a reduce that ran on half the inputs would produce a confident wrong answer, which is the failure this feature exists to
|
|
3037
|
+
* make impossible.
|
|
3038
|
+
*/
|
|
3039
|
+
let reduceEnv = null;
|
|
3040
|
+
if (Array.isArray(offer.reduce_inputs) && offer.reduce_inputs.length > 0) {
|
|
3041
|
+
try {
|
|
3042
|
+
const inputDir = mkdtempSync(join(tmpdir(), 'w2m-reduce-'));
|
|
3043
|
+
const manifest = [];
|
|
3044
|
+
for (const artifact of offer.reduce_inputs) {
|
|
3045
|
+
const response = await fetch(rabbitUrl + '/v1/fleet/blobs/' + encodeURIComponent(artifact.sha256), {
|
|
3046
|
+
headers: token() ? { authorization: 'Bearer ' + token() } : {},
|
|
3047
|
+
});
|
|
3048
|
+
if (response.status !== 200) {
|
|
3049
|
+
throw new Error('blob ' + artifact.sha256 + ' -> HTTP ' + response.status);
|
|
3050
|
+
}
|
|
3051
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
3052
|
+
const file = join(inputDir, String(artifact.machine_id).replace(/[^A-Za-z0-9._-]/g, '_') + '.bin');
|
|
3053
|
+
writeFileSync(file, body);
|
|
3054
|
+
manifest.push({ machine_id: artifact.machine_id, sha256: artifact.sha256, bytes: body.length, file });
|
|
3055
|
+
}
|
|
3056
|
+
const manifestPath = join(inputDir, 'manifest.json');
|
|
3057
|
+
writeFileSync(manifestPath, JSON.stringify({ reduce_of: offer.reduce_of ?? null, inputs: manifest }, null, 2));
|
|
3058
|
+
reduceEnv = {
|
|
3059
|
+
W2M_REDUCE_INPUTS_DIR: inputDir,
|
|
3060
|
+
W2M_REDUCE_MANIFEST: manifestPath,
|
|
3061
|
+
W2M_REDUCE_OF: String(offer.reduce_of ?? ''),
|
|
3062
|
+
};
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
log('warn', offer.task_id + ': reduce input fetch failed', { error: error?.message ?? String(error) });
|
|
3065
|
+
await finish({
|
|
3066
|
+
offer,
|
|
3067
|
+
cwdRel,
|
|
3068
|
+
commandArgv,
|
|
3069
|
+
anchorsBefore,
|
|
3070
|
+
anchorsAfter: null,
|
|
3071
|
+
execution: null,
|
|
3072
|
+
status: 'refused',
|
|
3073
|
+
refusalReason: 'REDUCE_INPUT_FETCH_FAILED',
|
|
3074
|
+
warnings,
|
|
3075
|
+
startedAt,
|
|
3076
|
+
endedAt: new Date().toISOString(),
|
|
3077
|
+
protocolViolations: ['reduce inputs could not be fetched: ' + (error?.message ?? String(error))],
|
|
3078
|
+
});
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3030
3083
|
for (const stage of plannedStages) {
|
|
3031
3084
|
const stageAllowed = matchAllowedCommand(stage.command_argv, allowedCommands);
|
|
3032
3085
|
if (stageAllowed.allowed) continue;
|
|
@@ -3120,6 +3173,7 @@ export function createAgent(options) {
|
|
|
3120
3173
|
W2M_MODE: String(offer.mode ?? ''),
|
|
3121
3174
|
W2M_MACHINE_ID: String(offer.machine_id ?? ''),
|
|
3122
3175
|
W2M_MACHINE_NAME: String(offer.machine_name ?? ''),
|
|
3176
|
+
...(reduceEnv ?? {}),
|
|
3123
3177
|
};
|
|
3124
3178
|
const ran = await runArgv(stage.command_argv, {
|
|
3125
3179
|
cwd: stageCwdOutcome.cwd,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge hint: what a reader of a shared memory document should do about a conflict.
|
|
3
|
+
*
|
|
4
|
+
* A 409 from the memory CAS already carries `behind_by`, `unread_records` and a sample. The reader still has to decide what
|
|
5
|
+
* "behind by 3" means for the thing they are about to write. This turns those numbers into a named next step -- re-read, compare,
|
|
6
|
+
* then append with the current revision -- and never merges automatically: an automatic merge would write records the caller has
|
|
7
|
+
* not seen. Pure: it reads the refusal the relay already sent.
|
|
8
|
+
*/
|
|
9
|
+
export const MERGE_UP_TO_DATE = 'up-to-date';
|
|
10
|
+
export const MERGE_RE_READ = 're-read-then-append';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{conflict?: object|null}} input `conflict` is the 409 detail: { behind_by, unread_records, sample }.
|
|
14
|
+
* @returns {{state: string, behind_by: number, unread: number, sample_count: number, next: string}}
|
|
15
|
+
*/
|
|
16
|
+
export function renderMergeHint(input = {}) {
|
|
17
|
+
const conflict = input.conflict ?? null;
|
|
18
|
+
if (conflict === null || typeof conflict !== 'object') {
|
|
19
|
+
return { state: MERGE_UP_TO_DATE, behind_by: 0, unread: 0, sample_count: 0, next: 'append normally' };
|
|
20
|
+
}
|
|
21
|
+
const behindBy = Number.isInteger(conflict.behind_by) && conflict.behind_by > 0 ? conflict.behind_by : 0;
|
|
22
|
+
const unread = Array.isArray(conflict.unread_records) ? conflict.unread_records.length
|
|
23
|
+
: (Number.isInteger(conflict.unread) && conflict.unread > 0 ? conflict.unread : 0);
|
|
24
|
+
const sample = Array.isArray(conflict.sample) ? conflict.sample : [];
|
|
25
|
+
if (behindBy === 0 && unread === 0) {
|
|
26
|
+
return { state: MERGE_UP_TO_DATE, behind_by: 0, unread: 0, sample_count: 0, next: 'append normally' };
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
state: MERGE_RE_READ,
|
|
30
|
+
behind_by: behindBy,
|
|
31
|
+
unread,
|
|
32
|
+
sample_count: sample.length,
|
|
33
|
+
next: 're-read the document, compare the records listed here, then append with the current revision -- do not merge unseen records',
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -186,6 +186,7 @@ import {
|
|
|
186
186
|
import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
187
187
|
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
188
188
|
import { renderMemoryView } from './memory-view.mjs';
|
|
189
|
+
import { renderMergeHint } from './merge-hint.mjs';
|
|
189
190
|
import { buildBoard } from './task-graph.mjs';
|
|
190
191
|
import { verifyEnvelopeSignature } from '../util/envelope-signature.mjs';
|
|
191
192
|
import { createAskStore } from './ask-store.mjs';
|
|
@@ -233,6 +234,7 @@ async function writeChatSpool(stateDir, entries) {
|
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
import { buildReducePlan } from '../util/dataflow.mjs';
|
|
237
|
+
import { suggestReduce } from '../util/reduce-suggestion.mjs';
|
|
236
238
|
import { schemaProblems, strayParameterProblems } from './schema-spec.mjs';
|
|
237
239
|
import { listIntents, resolveIntent } from './intents.mjs';
|
|
238
240
|
// v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
|
|
@@ -272,7 +274,7 @@ const MAX_STAGES = 16;
|
|
|
272
274
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
273
275
|
* overrides it at runtime for tests and packagers.
|
|
274
276
|
*/
|
|
275
|
-
const PLUGIN_VERSION = '1.
|
|
277
|
+
const PLUGIN_VERSION = '1.3.1';
|
|
276
278
|
|
|
277
279
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
278
280
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -4529,6 +4531,8 @@ export async function apply(ctx, config = {}) {
|
|
|
4529
4531
|
const reducerMachineId = typeof args?.reducer_machine_id === 'string' && args.reducer_machine_id.trim() !== ''
|
|
4530
4532
|
? args.reducer_machine_id.trim()
|
|
4531
4533
|
: null;
|
|
4534
|
+
// v1.2.9: the task's own shard count is the completeness contract, used by both the plan and the suggestion.
|
|
4535
|
+
const expectedIndexTotal = Number.isInteger(body?.task?.index_total) ? body.task.index_total : null;
|
|
4532
4536
|
// v1.2.7: the relay publishes machine facts directly on the aggregate; `detail.machines` is a test/report shape that the
|
|
4533
4537
|
// real /v1/tasks response does not carry. Reading only the latter made the reduce plan silently unreachable in production.
|
|
4534
4538
|
const rawMachines = Array.isArray(aggregate.detail?.machines)
|
|
@@ -4558,7 +4562,7 @@ export async function apply(ctx, config = {}) {
|
|
|
4558
4562
|
results: mapResults,
|
|
4559
4563
|
reducerMachineId,
|
|
4560
4564
|
// v1.2.7: the task's own shard count is the completeness contract; absent it, the old "just what arrived" plan stands.
|
|
4561
|
-
expectedIndexTotal
|
|
4565
|
+
expectedIndexTotal,
|
|
4562
4566
|
})
|
|
4563
4567
|
: null;
|
|
4564
4568
|
const dataflowDegraded = reduce !== null && reduce.ok !== true;
|
|
@@ -4576,6 +4580,11 @@ export async function apply(ctx, config = {}) {
|
|
|
4576
4580
|
...(badSignature > 0 ? { signature_warning: 'bad_signature' } : {}),
|
|
4577
4581
|
...(badSignature === 0 && unsigned > 0 ? { signature_warning: 'unsigned' } : {}),
|
|
4578
4582
|
...(reduce === null ? {} : { reduce }),
|
|
4583
|
+
/**
|
|
4584
|
+
* v1.2.9: complete-or-not, the candidate reducers, and the literal next step. This is a SUGGESTION: the tool does not
|
|
4585
|
+
* dispatch the reduce, because choosing a machine to run a command is the caller's decision everywhere else in the fleet.
|
|
4586
|
+
*/
|
|
4587
|
+
reduce_suggestion: suggestReduce({ machines: rawMachines, reducerMachineId, expectedIndexTotal }),
|
|
4579
4588
|
...(dataflowDegraded ? { dataflow_degraded: true } : {}),
|
|
4580
4589
|
counts: aggregate.detail?.counts ?? null,
|
|
4581
4590
|
differences: aggregate.detail?.differences ?? [],
|
|
@@ -5601,6 +5610,8 @@ export async function apply(ctx, config = {}) {
|
|
|
5601
5610
|
doc,
|
|
5602
5611
|
status: appended?.status ?? 0,
|
|
5603
5612
|
code: appended?.json?.error?.code ?? appended?.error?.code ?? 'W2M_MEMORY_APPEND',
|
|
5613
|
+
// v1.2.11: turn the 409's numbers into the next step a reader can actually take.
|
|
5614
|
+
merge_hint: renderMergeHint({ conflict: appended?.json?.detail ?? appended?.json?.error?.detail ?? null }),
|
|
5604
5615
|
detail: appended?.json?.detail ?? null,
|
|
5605
5616
|
});
|
|
5606
5617
|
}
|
|
@@ -7139,6 +7150,66 @@ export async function apply(ctx, config = {}) {
|
|
|
7139
7150
|
},
|
|
7140
7151
|
}));
|
|
7141
7152
|
|
|
7153
|
+
|
|
7154
|
+
ctx.tools.register(defineTool({
|
|
7155
|
+
name: 'w2m_reduce',
|
|
7156
|
+
description:
|
|
7157
|
+
'Dispatch a reduce task for a finished map task. Refuses unless every declared shard reported as a blob and the named reducer is a machine that reported: the relay never picks a reducer, and this tool never invents one.',
|
|
7158
|
+
parameters: {
|
|
7159
|
+
task_id: { type: 'string', required: true, description: 'The map task whose results are being reduced.' },
|
|
7160
|
+
reducer_machine_id: { type: 'string', required: true, description: 'The machine that must run the reduce; it must have reported a shard.' },
|
|
7161
|
+
reduce_argv: { type: 'array', items: { type: 'string' }, required: true, description: 'The reduce command as argv, run with W2M_REDUCE_MANIFEST set.' },
|
|
7162
|
+
timeout_ms: { type: 'number', description: 'Wall-clock budget for the reduce task (default 300000, cap 3600000).' },
|
|
7163
|
+
cwd_rel: { type: 'string', description: 'Working directory relative to the project (default ".").' },
|
|
7164
|
+
},
|
|
7165
|
+
output: jsonOutput,
|
|
7166
|
+
presentCall: (args) => ({ card: 'generic', title: 'w2m reduce ' + (typeof args?.task_id === 'string' ? args.task_id : ''), kind: 'write', rawInput: {} }),
|
|
7167
|
+
async execute(args, exec) {
|
|
7168
|
+
throwIfAborted(exec?.signal);
|
|
7169
|
+
const base = requireRabbit(cfg);
|
|
7170
|
+
const taskId = typeof args?.task_id === 'string' ? args.task_id.trim() : '';
|
|
7171
|
+
const reducerMachineId = typeof args?.reducer_machine_id === 'string' ? args.reducer_machine_id.trim() : '';
|
|
7172
|
+
const reduceArgv = Array.isArray(args?.reduce_argv) ? args.reduce_argv.filter((part) => typeof part === 'string' && part !== '') : [];
|
|
7173
|
+
if (taskId === '') throw new W2MError('W2M_REDUCE_TASK', 'W2M_REDUCE_TASK: task_id is required');
|
|
7174
|
+
if (reducerMachineId === '') throw new W2MError('W2M_REDUCE_REDUCER', 'W2M_REDUCE_REDUCER: reducer_machine_id is required');
|
|
7175
|
+
if (reduceArgv.length === 0) throw new W2MError('W2M_REDUCE_ARGV', 'W2M_REDUCE_ARGV: reduce_argv must be a non-empty string array');
|
|
7176
|
+
const deviceToken = await resolveToken(cfg);
|
|
7177
|
+
const operatorToken = requireOperatorToken(cfg);
|
|
7178
|
+
const viewed = await request({ rabbitUrl: base, token: deviceToken, signal: exec?.signal, method: 'GET', pathname: '/v1/tasks/' + encodeURIComponent(taskId) });
|
|
7179
|
+
if (viewed?.ok !== true) throw new W2MError('W2M_REDUCE_READ', 'W2M_REDUCE_READ: the relay refused the task read');
|
|
7180
|
+
const body = viewed.json ?? {};
|
|
7181
|
+
const aggregate = body.aggregate ?? {};
|
|
7182
|
+
const machines = Array.isArray(aggregate.machines) ? aggregate.machines : (Array.isArray(aggregate.detail?.machines) ? aggregate.detail.machines : []);
|
|
7183
|
+
const expectedIndexTotal = Number.isInteger(body?.task?.index_total) ? body.task.index_total : null;
|
|
7184
|
+
const suggestion = suggestReduce({ machines, reducerMachineId, expectedIndexTotal });
|
|
7185
|
+
if (suggestion.ready !== true) {
|
|
7186
|
+
const code = suggestion?.plan?.code ?? 'NOT_READY';
|
|
7187
|
+
throw new W2MError('W2M_REDUCE_NOT_READY', 'W2M_REDUCE_NOT_READY: ' + code + ' -- refusing to reduce over incomplete data', {
|
|
7188
|
+
reason_code: code, detail: suggestion?.plan?.detail ?? null, missing: suggestion?.missing ?? [],
|
|
7189
|
+
});
|
|
7190
|
+
}
|
|
7191
|
+
const inputs = suggestion.plan.inputs.filter((entry) => entry.mode === 'blob').map((entry) => ({ machine_id: entry.machine_id, sha256: entry.sha256 }));
|
|
7192
|
+
if (inputs.length !== suggestion.plan.inputs.length) throw new W2MError('W2M_REDUCE_INLINE_INPUT', 'W2M_REDUCE_INLINE_INPUT: an inline result cannot be carried to another machine');
|
|
7193
|
+
const timeoutMs = Math.min(Math.max(Number.isFinite(args?.timeout_ms) ? Math.floor(args.timeout_ms) : 300_000, 1_000), 3_600_000);
|
|
7194
|
+
const dispatched = await request({
|
|
7195
|
+
rabbitUrl: base, token: operatorToken, signal: exec?.signal, method: 'POST', pathname: '/v1/task',
|
|
7196
|
+
body: {
|
|
7197
|
+
mode: 'broadcast', executor_machine_id: reducerMachineId, command_argv: reduceArgv,
|
|
7198
|
+
cwd_rel: typeof args?.cwd_rel === 'string' && args.cwd_rel !== '' ? args.cwd_rel : '.',
|
|
7199
|
+
index_total: 1, timeout_ms: timeoutMs, write: false,
|
|
7200
|
+
requirements: { toolchain: {}, platform: [] },
|
|
7201
|
+
compare_policy: { strip_ansi: true, normalize_crlf: true, strip_trailing_blank_lines: true, redact: [] },
|
|
7202
|
+
reduce_of: taskId, reduce_inputs: inputs,
|
|
7203
|
+
},
|
|
7204
|
+
});
|
|
7205
|
+
if (dispatched?.ok !== true) {
|
|
7206
|
+
const code = dispatched?.json?.error?.code ?? dispatched?.error?.code ?? 'W2M_REDUCE_DISPATCH';
|
|
7207
|
+
throw new W2MError('W2M_REDUCE_DISPATCH', 'W2M_REDUCE_DISPATCH: ' + code + ': the reduce was refused', { reason_code: code });
|
|
7208
|
+
}
|
|
7209
|
+
return JSON.stringify({ ok: true, action: 'reduce', reduce_task_id: dispatched.json?.task_id ?? null, reduce_of: taskId, reducer_machine_id: reducerMachineId, inputs: inputs.length });
|
|
7210
|
+
},
|
|
7211
|
+
}));
|
|
7212
|
+
|
|
7142
7213
|
ctx.tools.register(defineTool({
|
|
7143
7214
|
name: 'w2m_tasks',
|
|
7144
7215
|
description:
|