@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.
- package/.claude-plugin/marketplace.json +30 -29
- package/.claude-plugin/plugin.json +29 -29
- package/CHANGELOG.md +197 -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 +77 -1
- package/src/plugin/merge-hint.mjs +35 -0
- package/src/plugin/task-graph.mjs +153 -147
- package/src/plugin/tools.mjs +204 -19
- package/src/relay/fleet-messages.mjs +258 -241
- package/src/relay/report.mjs +11 -0
- package/src/relay/server.mjs +16 -1
- package/src/relay/state.mjs +135 -0
- package/src/util/dataflow.mjs +24 -1
- package/src/util/reduce-suggestion.mjs +107 -0
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.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
|
-
|
|
4519
|
-
const
|
|
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
|
|
4522
|
-
|
|
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({
|
|
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
|
-
//
|
|
6659
|
-
//
|
|
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
|
|
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
|
|
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
|
-
}
|
|
6775
|
+
const reply = await findReply();
|
|
6776
|
+
if (reply !== null) return finish(reply, 'poll');
|
|
6674
6777
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
6675
|
-
|
|
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,
|
|
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
|
: [],
|