@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.
@@ -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
+ }
@@ -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.2.7';
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: Number.isInteger(body?.task?.index_total) ? body.task.index_total : null,
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: