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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "The fused scion of cortex-os-agent + persona: one zero-LLM-dependent agent stack — Mind proposes, Muscle executes, Gate disposes.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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, { verifyFn, applyCandidate, ladder: ladderOpts }, results)
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 runTask(task, opts);
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
+ }