futura-scion 0.2.9 → 0.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/README.md +12 -0
- package/package.json +1 -1
- package/src/kernel/auto-decompose.js +172 -0
- package/src/swarm.js +38 -3
package/README.md
CHANGED
|
@@ -946,6 +946,18 @@ persistent world" doctrine FS already holds, taken further:
|
|
|
946
946
|
its node; a new problem shape ≥ 0.5 Jaccard-similar to an accepted
|
|
947
947
|
ancestor becomes its descendant. The codebase's evolution is navigable.
|
|
948
948
|
|
|
949
|
+
- **Swarm auto-decomposition** (`src/kernel/auto-decompose.js`): a task
|
|
950
|
+
enqueued with `kind: 'decompose', payload: { large: true }` opens a
|
|
951
|
+
delegation run under a manager template and enqueues its declared
|
|
952
|
+
`subtasks` (plain objectives or `{ text, file }` targets) as child tasks —
|
|
953
|
+
chained sequentially by default, independent on request. Every child's
|
|
954
|
+
gate verdict ROLLS UP to its delegation node: accepted children advance
|
|
955
|
+
the tree; gate-failed or escalated children reject it. Fire-and-forget
|
|
956
|
+
parents complete on enqueue; awaiting parents escalate with the run id
|
|
957
|
+
so the reviewer reads the tree (`scion decompose tree <run>`). Verified
|
|
958
|
+
end-to-end: two file-targeted children patched two files through real
|
|
959
|
+
`node --check` oracles and rolled up `accepted, accepted`.
|
|
960
|
+
|
|
949
961
|
- **Operational hygiene** (`src/kernel/ops.js`, `scion ops`): per-task
|
|
950
962
|
scratch dirs reclaimed EVENT-DRIVEN at terminal status (containment-
|
|
951
963
|
guarded), and peak/off-peak concurrency windows (`ops.peak_windows`)
|
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel/auto-decompose.js — SWARM AUTO-DECOMPOSITION (EvoX Genesis
|
|
3
|
+
* lineage, wired into runSwarm).
|
|
4
|
+
*
|
|
5
|
+
* The gap: decompose.js provides the delegation spine, but nothing AUTOMATIC
|
|
6
|
+
* routed large objectives into it. Here the swarm itself gains the Genesis
|
|
7
|
+
* behavior: a task flagged `large: true` (explicit, never inferred — the
|
|
8
|
+
* doctrine demands declared knobs) opens a delegation run under a MANAGER
|
|
9
|
+
* template; the decomposition plan (declared `subtasks` in the payload —
|
|
10
|
+
* the plan is data) enqueues CHILD TASKS on the same queue; every child's
|
|
11
|
+
* gate verdict ROLLS UP to its delegation node; the parent task completes
|
|
12
|
+
* only when all children are accepted.
|
|
13
|
+
*
|
|
14
|
+
* Rollup is the acceptance machinery: a child that gate-fails or escalates
|
|
15
|
+
* marks its node rejected, and the parent — visible to the reviewer through
|
|
16
|
+
* the delegation tree — carries the failure upward instead of completing.
|
|
17
|
+
* Transient agents, persistent world, only accepted results advance.
|
|
18
|
+
*
|
|
19
|
+
* maybeAutoDecompose(task, opts) → { run, parent, children } | null
|
|
20
|
+
* rollupChild(nodeId, childResult) → { run, allAccepted, accepted, total }
|
|
21
|
+
* runDecompositionTask(task, opts) → the swarm's handler for kind 'decompose'
|
|
22
|
+
*
|
|
23
|
+
* @module kernel/auto-decompose
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
import * as queue from './queue.js';
|
|
29
|
+
import * as decompose from './decompose.js';
|
|
30
|
+
import { getDb } from '../brain/db.js';
|
|
31
|
+
import { journal as trailJournal } from './trail.js';
|
|
32
|
+
import { get as getConfig } from '../config.js';
|
|
33
|
+
|
|
34
|
+
/** The manager template used when a task doesn't name one (shipped default). */
|
|
35
|
+
export function managerTemplateFor(task) {
|
|
36
|
+
return task?.payload?.manager_template
|
|
37
|
+
?? (() => { try { return getConfig()?.decomposition?.manager_template; } catch { return undefined; } })()
|
|
38
|
+
?? 'constructor';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rowOf(nodeId) {
|
|
42
|
+
return getDb().prepare('SELECT * FROM delegations WHERE node_id = ?').get(nodeId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function flatten(tree) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const walk = (n) => { out.push(n); for (const c of n.children) walk(c); };
|
|
48
|
+
for (const r of tree.roots) walk(r);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Open a delegation run for a large task and enqueue its children.
|
|
54
|
+
* The decomposition plan comes from the task payload: `subtasks: [objective,
|
|
55
|
+
* ...]` (declared by the caller/enqueuer — the plan is data). Children
|
|
56
|
+
* enqueue onto the SAME queue (the swarm's existing workers drain them)
|
|
57
|
+
* with `delegation_node` stamped into their payloads for rollup.
|
|
58
|
+
*
|
|
59
|
+
* @returns {{ run, parent, children: Array<{ node, task }>} | null}
|
|
60
|
+
* null when the task is not flagged large.
|
|
61
|
+
*/
|
|
62
|
+
export function maybeAutoDecompose(task, opts = {}) {
|
|
63
|
+
if (!task?.payload?.large) return null; // explicit flag only — never inferred
|
|
64
|
+
const managerTemplate = opts.managerTemplate ?? managerTemplateFor(task);
|
|
65
|
+
const subtasks = Array.isArray(task.payload.subtasks) && task.payload.subtasks.length > 0
|
|
66
|
+
? task.payload.subtasks
|
|
67
|
+
: [String(task.payload.text ?? task.id)]; // single-leaf fallback: the objective itself
|
|
68
|
+
|
|
69
|
+
const root = decompose.decompose({
|
|
70
|
+
text: String(task.payload.text ?? task.id),
|
|
71
|
+
rootTemplate: managerTemplate,
|
|
72
|
+
repo: task.payload.repo ?? process.cwd(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const children = [];
|
|
76
|
+
let prevTaskId = null;
|
|
77
|
+
for (const sub of subtasks) {
|
|
78
|
+
// A subtask is either a plain string objective or a declared object
|
|
79
|
+
// { text, file? } — a file-targeted child lets the analyzer rung derive
|
|
80
|
+
// a concrete patch; a text-only child researches and escalates.
|
|
81
|
+
const objective = typeof sub === 'string' ? { text: sub } : sub;
|
|
82
|
+
const node = decompose.delegate(root, opts.childTemplate ?? 'verifier', String(objective.text), {
|
|
83
|
+
repo: task.payload.repo ?? process.cwd(),
|
|
84
|
+
// Children work the PRIMARY repo — foreign writes stay root-only.
|
|
85
|
+
});
|
|
86
|
+
const t = queue.enqueue({
|
|
87
|
+
kind: task.payload.child_kind ?? 'fix',
|
|
88
|
+
payload: {
|
|
89
|
+
text: objective.text,
|
|
90
|
+
...(objective.file ? { file: objective.file } : {}),
|
|
91
|
+
// File-targeted children get the analyzer's fresh-truth path; the
|
|
92
|
+
// earned-research rung would burn web budget on a subtask the tree
|
|
93
|
+
// already scopes. Text-only children keep research (opt-out is the
|
|
94
|
+
// operator's call, declared per task via payload.child_research).
|
|
95
|
+
...(objective.file && task.payload.child_research !== true ? { research: false } : {}),
|
|
96
|
+
delegation_node: node.id,
|
|
97
|
+
delegation_run: root.id,
|
|
98
|
+
parent_task: task.id,
|
|
99
|
+
},
|
|
100
|
+
// Children chain sequentially by default (Genesis: writes to one repo
|
|
101
|
+
// are a manager's job, one at a time); `sequential: false` trusts the
|
|
102
|
+
// caller's declaration of independent subtasks.
|
|
103
|
+
...(prevTaskId && task.payload.sequential !== false ? { depends_on: [prevTaskId] } : {}),
|
|
104
|
+
...(task.payload.child_capabilities ? { capabilities: task.payload.child_capabilities } : {}),
|
|
105
|
+
});
|
|
106
|
+
children.push({ node, task: t });
|
|
107
|
+
prevTaskId = t.id;
|
|
108
|
+
}
|
|
109
|
+
trailJournal('autodecompose.opened', {
|
|
110
|
+
task: task.id, run: root.id, manager: managerTemplate,
|
|
111
|
+
children: children.length, sequential: task.payload.sequential !== false,
|
|
112
|
+
});
|
|
113
|
+
return { run: root.id, parent: root, children };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Roll a child's terminal verdict up to its delegation node.
|
|
118
|
+
* @param {string} nodeId — the child's delegation_node
|
|
119
|
+
* @param {{ ok: boolean, rung?: string, error?: string }} childResult
|
|
120
|
+
* @returns {{ run, allAccepted: boolean, accepted: number, total: number }}
|
|
121
|
+
*/
|
|
122
|
+
export function rollupChild(nodeId, childResult) {
|
|
123
|
+
const row = rowOf(nodeId);
|
|
124
|
+
if (!row) throw new Error(`auto-decompose.rollup: unknown delegation node ${JSON.stringify(nodeId)}`);
|
|
125
|
+
const accepted = childResult?.ok === true;
|
|
126
|
+
decompose.completeDelegation(nodeId, {
|
|
127
|
+
ok: accepted,
|
|
128
|
+
...(childResult?.error ? { error: String(childResult.error).slice(0, 300) } : {}),
|
|
129
|
+
...(childResult?.rung ? { rung: childResult.rung } : {}),
|
|
130
|
+
});
|
|
131
|
+
const tree = decompose.delegationTree(row.task_id);
|
|
132
|
+
const nodes = flatten(tree);
|
|
133
|
+
const childNodes = nodes.filter(n => n.status !== 'root');
|
|
134
|
+
const acceptedCount = childNodes.filter(n => n.status === 'accepted').length;
|
|
135
|
+
const allAccepted = childNodes.length > 0 && acceptedCount === childNodes.length;
|
|
136
|
+
trailJournal('autodecompose.rollup', { node: nodeId, ok: accepted, accepted: acceptedCount, total: childNodes.length, allAccepted });
|
|
137
|
+
return { run: row.task_id, allAccepted, accepted: acceptedCount, total: childNodes.length };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether every child of a run is terminal (accepted/rejected/closed). */
|
|
141
|
+
export function runComplete(runId) {
|
|
142
|
+
const nodes = flatten(decompose.delegationTree(runId)).filter(n => n.status !== 'root');
|
|
143
|
+
return nodes.length > 0 && nodes.every(n => n.status !== 'pending');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The swarm's handler for a `decompose` task: open the run, enqueue
|
|
148
|
+
* children, and park THIS task on the children's completion — the parent
|
|
149
|
+
* task's own lifecycle closes when the last child rolls up. Implemented as:
|
|
150
|
+
* enqueue children with depends_on the parent is released by; the parent
|
|
151
|
+
* fails-soft as 'awaiting-children' (an escalation-shaped verdict so the
|
|
152
|
+
* review lane can see the tree) OR, when opts.awaitParent is false, the
|
|
153
|
+
* parent completes immediately after enqueueing (fire-and-forget mode).
|
|
154
|
+
*/
|
|
155
|
+
export async function runDecompositionTask(task, opts = {}) {
|
|
156
|
+
const opened = maybeAutoDecompose(task, opts);
|
|
157
|
+
if (!opened) {
|
|
158
|
+
// Flagged decompose but not large: loud misconfiguration.
|
|
159
|
+
throw new Error(`auto-decompose: task ${task.id} has kind 'decompose' but payload.large is not set — the trigger is explicit, never inferred`);
|
|
160
|
+
}
|
|
161
|
+
// awaitParent is the TASK's declaration first (payload.awaitParent), with
|
|
162
|
+
// the caller's opts as the operator override. Fire-and-forget: the parent
|
|
163
|
+
// completes when the children are enqueued — the delegation tree carries
|
|
164
|
+
// the outcomes, and the reviewer reads it (scion decompose tree).
|
|
165
|
+
const awaitParent = task.payload?.awaitParent ?? opts.awaitParent ?? true;
|
|
166
|
+
if (awaitParent === false) {
|
|
167
|
+
return { ok: true, rung: 'decompose', decision: { rung: 'decompose', candidate: { description: `delegated ${opened.children.length} subtask(s)`, run: opened.run } } };
|
|
168
|
+
}
|
|
169
|
+
// Default: the parent waits — fail-soft with the run visible in the tree.
|
|
170
|
+
const failResult = queue.fail(task.id, `awaiting children of run ${opened.run} (see: scion decompose tree ${opened.run})`, { escalate: true });
|
|
171
|
+
return { ok: false, escalated: true, rung: 'decompose', awaitingRun: opened.run, ...failResult };
|
|
172
|
+
}
|
package/src/swarm.js
CHANGED
|
@@ -14,13 +14,14 @@ import * as queue from './kernel/queue.js';
|
|
|
14
14
|
import * as roles from './kernel/roles.js';
|
|
15
15
|
import * as trail from './kernel/trail.js';
|
|
16
16
|
import { runTask } from './worker.js';
|
|
17
|
+
import { runDecompositionTask, rollupChild } from './kernel/auto-decompose.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Run workers until the queue drains (or bounds stop progress).
|
|
20
21
|
* @param {object} opts — { workers, verifyFn, applyCandidate, ladder }
|
|
21
22
|
* @returns {{ completed, failed, escalated }}
|
|
22
23
|
*/
|
|
23
|
-
export async function runSwarm({ workers = 3, verifyFn, applyCandidate, ladder: ladderOpts } = {}) {
|
|
24
|
+
export async function runSwarm({ workers = 3, verifyFn, verifiers, applyCandidate, ladder: ladderOpts, decompose: decomposeOpts } = {}) {
|
|
24
25
|
const results = { completed: 0, failed: 0, escalated: 0 };
|
|
25
26
|
|
|
26
27
|
// Capability routing: swarm workers claim as a REGISTERED generalist role
|
|
@@ -32,11 +33,16 @@ export async function runSwarm({ workers = 3, verifyFn, applyCandidate, ladder:
|
|
|
32
33
|
roles.registerRole({ id: 'swarm-generalist', capabilities: ['*'] });
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// Forward EVERYTHING the worker path needs — verifiers especially: they
|
|
37
|
+
// were silently dropped here once, and the Gate then refused every
|
|
38
|
+
// candidate with 'verifier-required' (a confusing diagnosis for the caller).
|
|
39
|
+
const workerOpts = { verifyFn, verifiers, applyCandidate, ladder: ladderOpts, decompose: decomposeOpts };
|
|
40
|
+
|
|
35
41
|
await Promise.all(
|
|
36
42
|
Array.from({ length: workers }, (_, i) => {
|
|
37
43
|
const workerId = `w${i + 1}`;
|
|
38
44
|
roles.bindWorker(workerId, 'swarm-generalist');
|
|
39
|
-
return workerLoop(workerId,
|
|
45
|
+
return workerLoop(workerId, workerOpts, results)
|
|
40
46
|
.finally(() => roles.unbindWorker(workerId));
|
|
41
47
|
}),
|
|
42
48
|
);
|
|
@@ -49,9 +55,38 @@ async function workerLoop(workerId, opts, results) {
|
|
|
49
55
|
for (;;) {
|
|
50
56
|
const task = queue.claim(workerId);
|
|
51
57
|
if (!task) return; // queue drained — survivor exits cleanly
|
|
52
|
-
const r = await
|
|
58
|
+
const r = await runOne(task, opts);
|
|
53
59
|
if (r.ok) results.completed += 1;
|
|
54
60
|
else if (r.status === 'escalated') results.escalated += 1;
|
|
55
61
|
else results.failed += 1;
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
|
|
65
|
+
/** One task, routed: decompose tasks open delegation runs; ordinary tasks
|
|
66
|
+
* run the ladder, and any child carrying a delegation_node rolls its
|
|
67
|
+
* gate verdict up to the tree (Genesis: only accepted results advance). */
|
|
68
|
+
async function runOne(task, opts) {
|
|
69
|
+
if (task.kind === 'decompose') {
|
|
70
|
+
const r = await runDecompositionTask(task, opts.decompose ?? {});
|
|
71
|
+
// Fire-and-forget parents COMPLETE on enqueue (the tree carries the
|
|
72
|
+
// outcomes); the claimed row must not be left dangling. The awaiting
|
|
73
|
+
// mode escalates via queue.fail inside the handler — no complete here.
|
|
74
|
+
if (r.ok) queue.complete(task.id, { rung: 'decompose', run: r.decision?.candidate?.run });
|
|
75
|
+
return { ...r, status: r.escalated ? 'escalated' : undefined };
|
|
76
|
+
}
|
|
77
|
+
const r = await runTask(task, opts);
|
|
78
|
+
// ROLLUP: a child of a decomposition run reports to its node — the run's
|
|
79
|
+
// parent then reflects accepted/rejected in the delegation tree.
|
|
80
|
+
if (task.payload?.delegation_node) {
|
|
81
|
+
try {
|
|
82
|
+
r.rollup = rollupChild(task.payload.delegation_node, {
|
|
83
|
+
ok: r.ok, rung: r.rung,
|
|
84
|
+
...(r.error ? { error: r.error } : {}),
|
|
85
|
+
});
|
|
86
|
+
trail.journal('swarm.child-rollup', { task: task.id, node: task.payload.delegation_node, ok: r.ok, allAccepted: r.rollup.allAccepted });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
trail.journal('swarm.rollup-error', { task: task.id, error: String(err?.message || err).slice(0, 160) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return r;
|
|
92
|
+
}
|