@twinsearth/w2m-dsh-plugin 1.2.6 → 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.
@@ -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.6';
277
+ const PLUGIN_VERSION = '1.3.0';
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;
@@ -3771,6 +3773,15 @@ export async function apply(ctx, config = {}) {
3771
3773
  items: { type: 'string' },
3772
3774
  description: 'Which terminal conditions justify another attempt: expired, failed, partial, timeout. Omit to never retry.',
3773
3775
  },
3776
+ depends_on: {
3777
+ type: 'array',
3778
+ items: { type: 'string' },
3779
+ description: 'Task ids this task waits for. The relay will not dispatch it until every dependency has settled; then it is offered automatically.',
3780
+ },
3781
+ assignee: {
3782
+ type: 'string',
3783
+ 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.',
3784
+ },
3774
3785
  },
3775
3786
  output: jsonOutput,
3776
3787
  async execute(args, exec) {
@@ -4181,6 +4192,11 @@ export async function apply(ctx, config = {}) {
4181
4192
  // v1.2.4: the retry policy travels only when the caller asked for one, so a default dispatch stays byte-identical.
4182
4193
  ...(Number.isInteger(args?.max_attempts) && args.max_attempts > 1 ? { max_attempts: args.max_attempts } : {}),
4183
4194
  ...(Array.isArray(args?.retry_on) && args.retry_on.length > 0 ? { retry_on: args.retry_on } : {}),
4195
+ // v1.2.7: graph edges and assignment travel only when declared, so a default dispatch stays byte-identical.
4196
+ ...(Array.isArray(args?.depends_on) && args.depends_on.filter((id) => typeof id === 'string' && id !== '').length > 0
4197
+ ? { depends_on: args.depends_on.filter((id) => typeof id === 'string' && id !== '') }
4198
+ : {}),
4199
+ ...(typeof args?.assignee === 'string' && args.assignee.trim() !== '' ? { assignee: args.assignee.trim() } : {}),
4184
4200
  created_by: cfg.machineName,
4185
4201
  // Only sent for broadcast; the relay ignores it otherwise, and the validation above already
4186
4202
  // refused the combination, so this cannot silently mean nothing.
@@ -4515,13 +4531,26 @@ export async function apply(ctx, config = {}) {
4515
4531
  const reducerMachineId = typeof args?.reducer_machine_id === 'string' && args.reducer_machine_id.trim() !== ''
4516
4532
  ? args.reducer_machine_id.trim()
4517
4533
  : null;
4518
- const rawMachines = Array.isArray(aggregate.detail?.machines) ? aggregate.detail.machines : [];
4519
- const mapResults = rawMachines.map((machine) => {
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;
4536
+ // v1.2.7: the relay publishes machine facts directly on the aggregate; `detail.machines` is a test/report shape that the
4537
+ // real /v1/tasks response does not carry. Reading only the latter made the reduce plan silently unreachable in production.
4538
+ const rawMachines = Array.isArray(aggregate.detail?.machines)
4539
+ ? aggregate.detail.machines
4540
+ : (Array.isArray(aggregate.machines) ? aggregate.machines : []);
4541
+ // v1.2.7: only a machine that actually reported can claim a shard. An expired lease still carries an index, and counting
4542
+ // it as "produced" would make a partial run look complete -- the exact failure the expected-index check exists to prevent.
4543
+ 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)));
4544
+ const mapResults = reportedMachines.map((machine) => {
4520
4545
  const envelope = machine?.envelope ?? machine?.result ?? null;
4521
- const blobSha = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256 : null;
4522
- const inline = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head : null;
4546
+ const blobSha = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256
4547
+ : (typeof machine?.blob_sha256 === 'string' ? machine.blob_sha256 : null);
4548
+ const inline = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head
4549
+ : (typeof machine?.stdout_head === 'string' && machine.stdout_head !== '' ? machine.stdout_head : null);
4523
4550
  return {
4524
4551
  machine_id: machine?.machine_id ?? null,
4552
+ // v1.2.7: the shard this result belongs to, so the reduce plan can tell "complete" from "the rest never reported".
4553
+ index: Number.isInteger(machine?.index) ? machine.index : (Number.isInteger(envelope?.index) ? envelope.index : null),
4525
4554
  mode: blobSha === null ? 'inline' : 'blob',
4526
4555
  ...(inline === null ? {} : { stdout: inline }),
4527
4556
  ...(blobSha === null ? {} : { blob_sha256: blobSha }),
@@ -4529,7 +4558,12 @@ export async function apply(ctx, config = {}) {
4529
4558
  });
4530
4559
  const anyBlob = mapResults.some((entry) => entry.mode === 'blob');
4531
4560
  const reduce = anyBlob && reducerMachineId !== null
4532
- ? buildReducePlan({ results: mapResults, reducerMachineId })
4561
+ ? buildReducePlan({
4562
+ results: mapResults,
4563
+ reducerMachineId,
4564
+ // v1.2.7: the task's own shard count is the completeness contract; absent it, the old "just what arrived" plan stands.
4565
+ expectedIndexTotal,
4566
+ })
4533
4567
  : null;
4534
4568
  const dataflowDegraded = reduce !== null && reduce.ok !== true;
4535
4569
 
@@ -4546,6 +4580,11 @@ export async function apply(ctx, config = {}) {
4546
4580
  ...(badSignature > 0 ? { signature_warning: 'bad_signature' } : {}),
4547
4581
  ...(badSignature === 0 && unsigned > 0 ? { signature_warning: 'unsigned' } : {}),
4548
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 }),
4549
4588
  ...(dataflowDegraded ? { dataflow_degraded: true } : {}),
4550
4589
  counts: aggregate.detail?.counts ?? null,
4551
4590
  differences: aggregate.detail?.differences ?? [],
@@ -5571,6 +5610,8 @@ export async function apply(ctx, config = {}) {
5571
5610
  doc,
5572
5611
  status: appended?.status ?? 0,
5573
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 }),
5574
5615
  detail: appended?.json?.detail ?? null,
5575
5616
  });
5576
5617
  }
@@ -6601,6 +6642,10 @@ export async function apply(ctx, config = {}) {
6601
6642
  type: 'string',
6602
6643
  description: 'For send/ask: replay protection. A retry with the same key returns the first message instead of posting the same sentence twice.',
6603
6644
  },
6645
+ reply_state: {
6646
+ type: 'string',
6647
+ 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.',
6648
+ },
6604
6649
  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
6650
  },
6606
6651
  output: jsonOutput,
@@ -6610,6 +6655,8 @@ export async function apply(ctx, config = {}) {
6610
6655
  const action = typeof args?.action === 'string' ? args.action : 'list';
6611
6656
  const sessionId = typeof args?.session_id === 'string' ? args.session_id.trim() : '';
6612
6657
  if (sessionId === '') throw new W2MError('W2M_CHAT_SESSION', 'W2M_CHAT_SESSION: session_id is required', { hint: 'chat is scoped to a conversation' });
6658
+ // v1.2.7: expire stale turns on every entry, so `asks` reports the truth after downtime.
6659
+ askStore.expireDue();
6613
6660
  if (action === 'asks') {
6614
6661
  // The persisted questions for this conversation, in the states they are in. A read, so it needs no relay.
6615
6662
  return JSON.stringify({ ok: true, action, session_id: sessionId, ...askStore.list({ sessionId }) });
@@ -6655,26 +6702,84 @@ export async function apply(ctx, config = {}) {
6655
6702
  if (typeof args?.reply_to === 'string' && args.reply_to.trim() !== '') addressing.reply_to = args.reply_to.trim();
6656
6703
 
6657
6704
  if (action === 'await') {
6658
- // Waiting for an ANSWER is a state machine, and a timeout is a value the caller reads rather than an exception
6659
- // that loses the question. The question stays in the flow, so it can still be answered later.
6705
+ // v1.2.7: waiting is a persisted turn, not a poll loop.
6706
+ //
6707
+ // The question is written to the ask store when it is ASKED; await attaches to that record, so a restart in between
6708
+ // does not lose the question or turn a late answer into silence. The event stream is tried first (the reply arrives
6709
+ // when it is posted, not up to a poll interval later), and polling remains the fallback for a relay or proxy that
6710
+ // cannot stream -- an old relay must not break awaiting.
6660
6711
  const target = typeof args?.reply_to === 'string' ? args.reply_to.trim() : '';
6661
- if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass the message_id returned by ask' });
6712
+ 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
6713
  const timeoutMs = Number.isFinite(args?.timeout_ms) ? Math.max(1000, Math.min(args.timeout_ms, 300000)) : 60000;
6663
6714
  const startedAt = Date.now();
6715
+ askStore.expireDue();
6716
+ let turn = askStore.list({ sessionId }).asks.find((ask) => (ask.state === 'open' || ask.state === 'timeout') && ask.message_id === target) ?? null;
6717
+ if (turn === null) {
6718
+ const opened = askStore.openAsk({ sessionId, messageId: target, timeoutMs });
6719
+ turn = opened.ok === true ? opened.ask : null;
6720
+ }
6721
+ const ownMachineId = cfg.machineId ?? null;
6722
+ const findReply = async () => {
6723
+ const listed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, limit: 200 } });
6724
+ const rows = Array.isArray(listed?.json?.messages) ? listed.json.messages : [];
6725
+ return rows.find((message) => message.reply_to === target && message.machine_id !== ownMachineId) ?? null;
6726
+ };
6727
+ const finish = (replyMessage, via) => {
6728
+ const verdict = replyMessage?.reply_state === 'refused' ? 'refused' : 'answered';
6729
+ if (turn !== null) askStore.settle(turn.ask_id, verdict, { messageId: replyMessage?.message_id ?? null });
6730
+ const recorded = turn === null ? null : (askStore.list({ sessionId }).asks.find((ask) => ask.ask_id === turn.ask_id) ?? null);
6731
+ return JSON.stringify({ ok: true, action, state: verdict, via, reply_to: target, reply: replyMessage, replies: replyMessage === null ? [] : [replyMessage], ask: recorded });
6732
+ };
6733
+ // 1) stream first -- and RE-subscribe while the budget remains: a dropped stream is not the end of the wait.
6734
+ let emptyStreams = 0;
6735
+ while (Date.now() - startedAt < timeoutMs) {
6736
+ const remaining = timeoutMs - (Date.now() - startedAt);
6737
+ const controller = new AbortController();
6738
+ const timer = setTimeout(() => controller.abort(), Math.min(remaining, 30000));
6739
+ let reachable = false;
6740
+ let gotEvent = false;
6741
+ try {
6742
+ const streamUrl = joinUrl(rabbitUrl, '/v1/fleet/messages/stream') + '?session_id=' + encodeURIComponent(sessionId);
6743
+ const response = await fetch(streamUrl, { headers: { authorization: 'Bearer ' + token }, signal: controller.signal });
6744
+ if (response.ok === true && response.body !== null) {
6745
+ reachable = true;
6746
+ const reader = response.body.getReader();
6747
+ const decoder = new TextDecoder();
6748
+ let buffer = '';
6749
+ for (;;) {
6750
+ const { value, done } = await reader.read();
6751
+ if (done === true) break;
6752
+ buffer += decoder.decode(value, { stream: true });
6753
+ const parts = buffer.split('\n\n');
6754
+ buffer = parts.pop() ?? '';
6755
+ for (const part of parts) {
6756
+ const line = part.split('\n').find((entry) => entry.startsWith('data: '));
6757
+ if (line === undefined) continue;
6758
+ gotEvent = true;
6759
+ const reply = await findReply();
6760
+ if (reply !== null) return finish(reply, 'stream');
6761
+ }
6762
+ }
6763
+ }
6764
+ } catch (error) {
6765
+ if (error?.name === 'AbortError' && Date.now() - startedAt >= timeoutMs) break;
6766
+ } finally { clearTimeout(timer); }
6767
+ if (reachable !== true) break; // a relay that will not stream at all: polling is the contract
6768
+ emptyStreams = gotEvent === true ? 0 : emptyStreams + 1;
6769
+ if (emptyStreams >= 2) break; // two streams that end immediately will not deliver this reply
6770
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
6771
+ }
6772
+ // 2) polling fallback
6664
6773
  for (;;) {
6665
6774
  throwIfAborted(exec?.signal);
6666
- const reply = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, reply_to: target, limit: '10' } });
6667
- const found = Array.isArray(reply?.json?.messages) ? reply.json.messages : [];
6668
- if (found.length > 0) {
6669
- for (const open of askStore.list({ sessionId }).asks) {
6670
- if (open.state === 'open' && open.message_id === target) askStore.settle(open.ask_id, 'answered', { messageId: found[0]?.message_id ?? null });
6671
- }
6672
- return JSON.stringify({ ok: true, action, state: 'answered', reply_to: target, replies: found });
6673
- }
6775
+ const reply = await findReply();
6776
+ if (reply !== null) return finish(reply, 'poll');
6674
6777
  if (Date.now() - startedAt >= timeoutMs) {
6675
- return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, hint: 'the question is still in the flow and can be answered later' });
6778
+ if (turn !== null) askStore.expire(turn.ask_id);
6779
+ const recorded = turn === null ? null : (askStore.list({ sessionId }).asks.find((ask) => ask.ask_id === turn.ask_id) ?? null);
6780
+ 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
6781
  }
6677
- await new Promise((resolve) => { setTimeout(resolve, 1500); });
6782
+ await new Promise((resolve) => { setTimeout(resolve, 1000); });
6678
6783
  }
6679
6784
  }
6680
6785
  if (action === 'send' || action === 'ask') {
@@ -6699,6 +6804,7 @@ export async function apply(ctx, config = {}) {
6699
6804
  ...addressing,
6700
6805
  ...(mentions.length === 0 ? {} : { mentions }),
6701
6806
  ...(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) }),
6807
+ ...(typeof args?.reply_state === 'string' && (args.reply_state === 'answered' || args.reply_state === 'refused') ? { reply_state: args.reply_state } : {}),
6702
6808
  };
6703
6809
  let posted = null;
6704
6810
  try {
@@ -6715,9 +6821,23 @@ export async function apply(ctx, config = {}) {
6715
6821
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'W2M_CHAT_SEND';
6716
6822
  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
6823
  }
6824
+ let ask = null;
6825
+ if (action === 'ask' && posted.json?.message?.message_id !== undefined) {
6826
+ // 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
6827
+ // restart) settles the same record instead of opening a second one.
6828
+ const opened = askStore.openAsk({
6829
+ sessionId,
6830
+ messageId: posted.json.message.message_id,
6831
+ threadId: typeof args?.thread_id === 'string' ? args.thread_id : null,
6832
+ to: typeof args?.to === 'string' ? args.to : null,
6833
+ timeoutMs: Number.isFinite(args?.timeout_ms) ? args.timeout_ms : 60000,
6834
+ });
6835
+ if (opened.ok === true) ask = opened.ask;
6836
+ }
6718
6837
  return JSON.stringify({
6719
6838
  ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null,
6720
6839
  encrypted: sealed !== null && sealed.ok === true,
6840
+ ...(ask === null ? {} : { ask }),
6721
6841
  ...(posted.json?.deduped === true ? { deduped: true } : {}),
6722
6842
  });
6723
6843
  }
@@ -7030,6 +7150,66 @@ export async function apply(ctx, config = {}) {
7030
7150
  },
7031
7151
  }));
7032
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
+
7033
7213
  ctx.tools.register(defineTool({
7034
7214
  name: 'w2m_tasks',
7035
7215
  description:
@@ -7069,6 +7249,11 @@ export async function apply(ctx, config = {}) {
7069
7249
  mode: row?.mode ?? null,
7070
7250
  status: row?.status ?? null,
7071
7251
  cancelled: row?.cancelled === true,
7252
+ // v1.2.7: graph facts travel with each row so the board and the scheduler cannot disagree.
7253
+ depends_on: Array.isArray(row?.depends_on) ? row.depends_on : [],
7254
+ assignee: row?.assignee ?? null,
7255
+ waiting_on: Array.isArray(row?.waiting_on) ? row.waiting_on : [],
7256
+ lease_states: row?.lease_states ?? {},
7072
7257
  machines: Array.isArray(row?.leases)
7073
7258
  ? row.leases.map((lease) => lease?.machine_id ?? null).filter((id) => typeof id === "string")
7074
7259
  : [],