@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.
@@ -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,
@@ -3215,6 +3269,28 @@ export function createAgent(options) {
3215
3269
  }
3216
3270
  }
3217
3271
 
3272
+ /**
3273
+ * v1.2.7: a plain command's large stdout is a stage artifact too.
3274
+ *
3275
+ * The blob path used to live only in the pipeline loop, so a split/mapreduce map result was always inlined and a
3276
+ * reduce plan could never see the data it reduces -- the missing-shard check was unreachable from a real task. A
3277
+ * command that prints more than the inline cap now uploads its stdout exactly as a stage does. Best effort here: a
3278
+ * failed upload must not turn a successful command into a failure, so the envelope stays inline and says why.
3279
+ */
3280
+ if (isPipeline !== true && execution !== null && lastBlobSha256 === null) {
3281
+ const plainPlan = planStageOutput({ machineId: identity.machine_id, stageIndex: 0, stdoutBytes: execution.stdout_bytes });
3282
+ if (plainPlan.ok === true && plainPlan.mode === 'blob') {
3283
+ if (execution.truncated_stdout === true && !warnings.includes('STDOUT_TRUNCATED')) warnings.push('STDOUT_TRUNCATED');
3284
+ const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout);
3285
+ if (uploaded.ok === true) {
3286
+ lastBlobSha256 = uploaded.sha256;
3287
+ } else {
3288
+ log('warn', offer.task_id + ': plain stdout blob upload failed', { code: uploaded.code });
3289
+ warnings.push('BLOB_UPLOAD_FAILED');
3290
+ }
3291
+ }
3292
+ }
3293
+
3218
3294
  heartbeat.setPhase('finalizing', 90);
3219
3295
  if (execution === null) {
3220
3296
  // Nothing ran at all (a refused first stage, or a budget already spent). `execution` stays
@@ -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
+ }
@@ -1,147 +1,153 @@
1
- /**
2
- * The dependency graph of a fleet's tasks, as pure functions.
3
- *
4
- * ## Why this is separate from the relay's own validation
5
- *
6
- * The relay refuses a graph it cannot store (unknown prerequisite, cycle, a self-edge), but that is one call at one moment.
7
- * A reader looking at a task board asks a different question: of the tasks I can see, which are READY, which are WAITING on
8
- * something that is still running, and which are BLOCKED because a prerequisite failed or was cancelled? That question
9
- * depends on the tasks' current states, so it is answered here, over whatever list the reader has.
10
- *
11
- * ## The rule that makes "blocked" honest
12
- *
13
- * A prerequisite that is missing from the visible list is treated as WAITING, never as ready: the list may be truncated by a
14
- * limit, and inferring "no dependency" from "not in this page" is how a board would show work as available that is not.
15
- */
16
- export const TASK_GRAPH_REFUSAL = Object.freeze({
17
- UNKNOWN_NODE: 'GRAPH_UNKNOWN_NODE',
18
- CYCLE: 'GRAPH_CYCLE',
19
- BAD_NODE: 'GRAPH_BAD_NODE',
20
- });
21
-
22
- const DONE = new Set(['completed', 'done']);
23
- const FAILED = new Set(['failed', 'cancelled', 'refused']);
24
- // States that mean the task is already in flight. It is not 'ready': ready means work that can be STARTED.
25
- const ACTIVE = new Set(['running', 'queued', 'offered', 'preparing', 'finalizing']);
26
-
27
- /**
28
- * @param {{nodes?: Array<{task_id?: string, depends_on?: string[]}>}} input
29
- * @returns {{ok: true, nodes: Map<string, string[]>} | {ok: false, code: string, detail: object}}
30
- */
31
- export function buildGraph({ nodes = [] } = {}) {
32
- if (!Array.isArray(nodes)) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: {} };
33
- const map = new Map();
34
- for (const node of nodes) {
35
- const id = typeof node?.task_id === 'string' && node.task_id !== '' ? node.task_id : null;
36
- if (id === null) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: { node } };
37
- const deps = Array.isArray(node.depends_on) ? node.depends_on.filter((dep) => typeof dep === 'string' && dep !== '') : [];
38
- map.set(id, deps);
39
- }
40
- return { ok: true, nodes: map };
41
- }
42
-
43
- /**
44
- * A stable topological order, or the cycle that prevents one.
45
- *
46
- * Ties are broken by id so the same graph renders in the same order on every machine -- a board that reshuffles between two
47
- * reads looks like it is changing.
48
- *
49
- * @param {Map<string, string[]>} graph
50
- * @returns {{ok: true, order: string[]} | {ok: false, code: string, detail: {cycle: string[]}}}
51
- */
52
- export function topoOrder(graph) {
53
- const indegree = new Map();
54
- const dependents = new Map();
55
- for (const id of graph.keys()) {
56
- indegree.set(id, 0);
57
- dependents.set(id, []);
58
- }
59
- for (const [id, deps] of graph.entries()) {
60
- for (const dep of deps) {
61
- // A prerequisite OUTSIDE this page is not an edge in it. The relay refuses an unknown prerequisite when the task is
62
- // created, so a missing one here means the reader's list is truncated -- and treating that as a broken graph would
63
- // make an ordinary limited read report a cycle.
64
- if (!graph.has(dep)) continue;
65
- indegree.set(id, (indegree.get(id) ?? 0) + 1);
66
- if (!dependents.has(dep)) dependents.set(dep, []);
67
- dependents.get(dep).push(id);
68
- }
69
- }
70
- const ready = [...indegree.entries()].filter(([, count]) => count === 0).map(([id]) => id).sort();
71
- const order = [];
72
- while (ready.length > 0) {
73
- const id = ready.shift();
74
- order.push(id);
75
- for (const next of (dependents.get(id) ?? []).slice().sort()) {
76
- indegree.set(next, indegree.get(next) - 1);
77
- if (indegree.get(next) === 0) { ready.push(next); ready.sort(); }
78
- }
79
- }
80
- if (order.length !== graph.size) {
81
- const remaining = [...graph.keys()].filter((id) => !order.includes(id));
82
- return { ok: false, code: TASK_GRAPH_REFUSAL.CYCLE, detail: { cycle: remaining } };
83
- }
84
- return { ok: true, order };
85
- }
86
-
87
- /**
88
- * Classify one task against the states of the tasks a reader can see.
89
- *
90
- * @param {{task_id: string, depends_on?: string[], status?: string, statuses?: Map<string, string>}} input
91
- * @returns {'ready'|'waiting'|'blocked'|'done'|'active'|'unknown'}
92
- */
93
- export function classifyTask({ task_id, depends_on = [], status = null, statuses = new Map() } = {}) {
94
- if (typeof task_id !== 'string' || task_id === '') return 'unknown';
95
- if (typeof status === 'string' && DONE.has(status)) return 'done';
96
- if (typeof status === 'string' && FAILED.has(status)) return 'blocked';
97
- let waiting = false;
98
- for (const dep of depends_on) {
99
- const depStatus = statuses.get(dep);
100
- if (depStatus === undefined) { waiting = true; continue; }
101
- if (FAILED.has(depStatus)) return 'blocked';
102
- if (!DONE.has(depStatus)) waiting = true;
103
- }
104
- if (waiting) return 'waiting';
105
- // In flight ONLY once its prerequisites are satisfied: a queued task behind a failed prerequisite is blocked, not active.
106
- if (typeof status === 'string' && ACTIVE.has(status)) return 'active';
107
- return 'ready';
108
- }
109
-
110
- /**
111
- * The whole board, in one pass: every visible task classified, and the readies in dependency order.
112
- *
113
- * @param {{tasks?: Array<{task_id?: string, depends_on?: string[], status?: string}>}} input
114
- * @returns {{ok: true, by_task: Record<string, string>, ready: string[], waiting: string[], blocked: string[], done: string[], active: string[], order: string[]} |
115
- * {ok: false, code: string, detail: object}}
116
- */
117
- export function buildBoard({ tasks = [] } = {}) {
118
- const built = buildGraph({ nodes: tasks });
119
- if (built.ok !== true) return built;
120
- const statuses = new Map();
121
- for (const task of tasks) {
122
- const id = typeof task?.task_id === 'string' ? task.task_id : null;
123
- if (id !== null) statuses.set(id, typeof task?.status === 'string' ? task.status : 'unknown');
124
- }
125
- const byTask = {};
126
- const buckets = { ready: [], waiting: [], blocked: [], done: [], active: [] };
127
- for (const [id, deps] of built.nodes.entries()) {
128
- const kind = classifyTask({ task_id: id, depends_on: deps, status: statuses.get(id), statuses });
129
- byTask[id] = kind;
130
- if (kind === 'done') buckets.done.push(id);
131
- else if (kind === 'active') buckets.active.push(id);
132
- else if (kind === 'blocked') buckets.blocked.push(id);
133
- else if (kind === 'waiting') buckets.waiting.push(id);
134
- else buckets.ready.push(id);
135
- }
136
- const order = topoOrder(built.nodes);
137
- return {
138
- ok: true,
139
- by_task: byTask,
140
- ready: buckets.ready.sort(),
141
- waiting: buckets.waiting.sort(),
142
- blocked: buckets.blocked.sort(),
143
- done: buckets.done.sort(),
144
- active: buckets.active.sort(),
145
- order: order.ok === true ? order.order : [],
146
- };
147
- }
1
+ /**
2
+ * The dependency graph of a fleet's tasks, as pure functions.
3
+ *
4
+ * ## Why this is separate from the relay's own validation
5
+ *
6
+ * The relay refuses a graph it cannot store (unknown prerequisite, cycle, a self-edge), but that is one call at one moment.
7
+ * A reader looking at a task board asks a different question: of the tasks I can see, which are READY, which are WAITING on
8
+ * something that is still running, and which are BLOCKED because a prerequisite failed or was cancelled? That question
9
+ * depends on the tasks' current states, so it is answered here, over whatever list the reader has.
10
+ *
11
+ * ## The rule that makes "blocked" honest
12
+ *
13
+ * A prerequisite that is missing from the visible list is treated as WAITING, never as ready: the list may be truncated by a
14
+ * limit, and inferring "no dependency" from "not in this page" is how a board would show work as available that is not.
15
+ */
16
+ export const TASK_GRAPH_REFUSAL = Object.freeze({
17
+ UNKNOWN_NODE: 'GRAPH_UNKNOWN_NODE',
18
+ CYCLE: 'GRAPH_CYCLE',
19
+ BAD_NODE: 'GRAPH_BAD_NODE',
20
+ });
21
+
22
+ const DONE = new Set(['completed', 'done']);
23
+ const FAILED = new Set(['failed', 'cancelled', 'refused']);
24
+ // States that mean the task is already in flight. It is not 'ready': ready means work that can be STARTED.
25
+ const ACTIVE = new Set(['running', 'queued', 'offered', 'preparing', 'finalizing', 'active']);
26
+ /**
27
+ * v1.2.7: the relay's scheduler reports 'waiting' for a task whose prerequisites are unsettled. Reading that as "ready" was the
28
+ * exact board/scheduler disagreement this set closes.
29
+ */
30
+ const WAITING = new Set(['waiting']);
31
+
32
+ /**
33
+ * @param {{nodes?: Array<{task_id?: string, depends_on?: string[]}>}} input
34
+ * @returns {{ok: true, nodes: Map<string, string[]>} | {ok: false, code: string, detail: object}}
35
+ */
36
+ export function buildGraph({ nodes = [] } = {}) {
37
+ if (!Array.isArray(nodes)) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: {} };
38
+ const map = new Map();
39
+ for (const node of nodes) {
40
+ const id = typeof node?.task_id === 'string' && node.task_id !== '' ? node.task_id : null;
41
+ if (id === null) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: { node } };
42
+ const deps = Array.isArray(node.depends_on) ? node.depends_on.filter((dep) => typeof dep === 'string' && dep !== '') : [];
43
+ map.set(id, deps);
44
+ }
45
+ return { ok: true, nodes: map };
46
+ }
47
+
48
+ /**
49
+ * A stable topological order, or the cycle that prevents one.
50
+ *
51
+ * Ties are broken by id so the same graph renders in the same order on every machine -- a board that reshuffles between two
52
+ * reads looks like it is changing.
53
+ *
54
+ * @param {Map<string, string[]>} graph
55
+ * @returns {{ok: true, order: string[]} | {ok: false, code: string, detail: {cycle: string[]}}}
56
+ */
57
+ export function topoOrder(graph) {
58
+ const indegree = new Map();
59
+ const dependents = new Map();
60
+ for (const id of graph.keys()) {
61
+ indegree.set(id, 0);
62
+ dependents.set(id, []);
63
+ }
64
+ for (const [id, deps] of graph.entries()) {
65
+ for (const dep of deps) {
66
+ // A prerequisite OUTSIDE this page is not an edge in it. The relay refuses an unknown prerequisite when the task is
67
+ // created, so a missing one here means the reader's list is truncated -- and treating that as a broken graph would
68
+ // make an ordinary limited read report a cycle.
69
+ if (!graph.has(dep)) continue;
70
+ indegree.set(id, (indegree.get(id) ?? 0) + 1);
71
+ if (!dependents.has(dep)) dependents.set(dep, []);
72
+ dependents.get(dep).push(id);
73
+ }
74
+ }
75
+ const ready = [...indegree.entries()].filter(([, count]) => count === 0).map(([id]) => id).sort();
76
+ const order = [];
77
+ while (ready.length > 0) {
78
+ const id = ready.shift();
79
+ order.push(id);
80
+ for (const next of (dependents.get(id) ?? []).slice().sort()) {
81
+ indegree.set(next, indegree.get(next) - 1);
82
+ if (indegree.get(next) === 0) { ready.push(next); ready.sort(); }
83
+ }
84
+ }
85
+ if (order.length !== graph.size) {
86
+ const remaining = [...graph.keys()].filter((id) => !order.includes(id));
87
+ return { ok: false, code: TASK_GRAPH_REFUSAL.CYCLE, detail: { cycle: remaining } };
88
+ }
89
+ return { ok: true, order };
90
+ }
91
+
92
+ /**
93
+ * Classify one task against the states of the tasks a reader can see.
94
+ *
95
+ * @param {{task_id: string, depends_on?: string[], status?: string, statuses?: Map<string, string>}} input
96
+ * @returns {'ready'|'waiting'|'blocked'|'done'|'active'|'unknown'}
97
+ */
98
+ export function classifyTask({ task_id, depends_on = [], status = null, statuses = new Map() } = {}) {
99
+ if (typeof task_id !== 'string' || task_id === '') return 'unknown';
100
+ if (typeof status === 'string' && DONE.has(status)) return 'done';
101
+ if (typeof status === 'string' && FAILED.has(status)) return 'blocked';
102
+ let waiting = false;
103
+ for (const dep of depends_on) {
104
+ const depStatus = statuses.get(dep);
105
+ if (depStatus === undefined) { waiting = true; continue; }
106
+ if (FAILED.has(depStatus)) return 'blocked';
107
+ if (!DONE.has(depStatus)) waiting = true;
108
+ }
109
+ if (waiting) return 'waiting';
110
+ // In flight ONLY once its prerequisites are satisfied: a queued task behind a failed prerequisite is blocked, not active.
111
+ if (typeof status === 'string' && WAITING.has(status)) return 'waiting';
112
+ if (typeof status === 'string' && ACTIVE.has(status)) return 'active';
113
+ return 'ready';
114
+ }
115
+
116
+ /**
117
+ * The whole board, in one pass: every visible task classified, and the readies in dependency order.
118
+ *
119
+ * @param {{tasks?: Array<{task_id?: string, depends_on?: string[], status?: string}>}} input
120
+ * @returns {{ok: true, by_task: Record<string, string>, ready: string[], waiting: string[], blocked: string[], done: string[], active: string[], order: string[]} |
121
+ * {ok: false, code: string, detail: object}}
122
+ */
123
+ export function buildBoard({ tasks = [] } = {}) {
124
+ const built = buildGraph({ nodes: tasks });
125
+ if (built.ok !== true) return built;
126
+ const statuses = new Map();
127
+ for (const task of tasks) {
128
+ const id = typeof task?.task_id === 'string' ? task.task_id : null;
129
+ if (id !== null) statuses.set(id, typeof task?.status === 'string' ? task.status : 'unknown');
130
+ }
131
+ const byTask = {};
132
+ const buckets = { ready: [], waiting: [], blocked: [], done: [], active: [] };
133
+ for (const [id, deps] of built.nodes.entries()) {
134
+ const kind = classifyTask({ task_id: id, depends_on: deps, status: statuses.get(id), statuses });
135
+ byTask[id] = kind;
136
+ if (kind === 'done') buckets.done.push(id);
137
+ else if (kind === 'active') buckets.active.push(id);
138
+ else if (kind === 'blocked') buckets.blocked.push(id);
139
+ else if (kind === 'waiting') buckets.waiting.push(id);
140
+ else buckets.ready.push(id);
141
+ }
142
+ const order = topoOrder(built.nodes);
143
+ return {
144
+ ok: true,
145
+ by_task: byTask,
146
+ ready: buckets.ready.sort(),
147
+ waiting: buckets.waiting.sort(),
148
+ blocked: buckets.blocked.sort(),
149
+ done: buckets.done.sort(),
150
+ active: buckets.active.sort(),
151
+ order: order.ok === true ? order.order : [],
152
+ };
153
+ }