total-recall-brain 3.41.6 → 3.42.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/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +3118 -17
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +84 -40
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/exit-after-flush.mjs +22 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-api-rotation.mjs +61 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +8 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +20 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/state.mjs +15 -7
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +51 -7
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +67 -25
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +52 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +21 -8
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/compat-harness.mjs +110 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +48 -22
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +9 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +21 -9
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +9 -5
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +10 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +30 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +6 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +19 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +222 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-worker.mjs +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +94 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +70 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +60 -19
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +32 -7
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +24 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +88 -125
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +110 -29
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +35 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +95 -5
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +13 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +78 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/context-provider.mjs +113 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/src/cli/agent.mjs +11 -6
- package/src/cli/compact.mjs +3 -3
- package/src/cli/context.mjs +52 -19
- package/src/cli/harness.mjs +65 -147
- package/src/cli/init.mjs +4 -2
- package/src/cli/preflight.mjs +56 -13
- package/src/core/agent-manager.mjs +23 -329
- package/src/core/context-compactor.mjs +2 -1
- package/src/core/context-compiler.mjs +16 -25
- package/src/core/context-providers.mjs +69 -0
- package/src/core/meta-harness.mjs +27 -749
- package/src/core/plugin-runner.mjs +12 -5
- package/src/core/plugin-runtime.mjs +4 -2
- package/src/core/runtime.mjs +30 -604
- package/src/core/task-context.mjs +122 -0
- package/src/server/routes/context.mjs +4 -1
- package/scaffold/.agent/skills/decision/SKILL.md +0 -20
- package/scaffold/.agent/skills/decision/references/integration.md +0 -25
- package/scaffold/.agent/skills/decision/references/use-cases.md +0 -20
- package/scaffold/.agent/skills/meta-harness/SKILL.md +0 -15
- package/scaffold/.agent/skills/meta-harness/references/optimized/index.md +0 -20
- package/scaffold/.agent/skills/meta-harness/references/optimized/manifest.json +0 -79
- package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +0 -135
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-01.md +0 -5
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-02.md +0 -17
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-03.md +0 -8
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-04.md +0 -2
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-05.md +0 -6
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-06.md +0 -14
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-07.md +0 -8
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-08.md +0 -4
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-09.md +0 -20
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-10.md +0 -6
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-11.md +0 -5
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-12.md +0 -7
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-13.md +0 -4
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-14.md +0 -7
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-15.md +0 -9
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-16.md +0 -5
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
12
14
|
import { ADAPTER_CATALOG } from '../capability/index.mjs';
|
|
13
15
|
import { appendThreadTurn, updateThread } from './threads.mjs';
|
|
14
16
|
import { notifyAlert } from './alerts.mjs';
|
|
15
17
|
import { dispatchWithReceipt } from './dispatch.mjs';
|
|
16
|
-
import { resolvePool } from './pool.mjs';
|
|
18
|
+
import { resolvePool, localExecutionNode } from './pool.mjs';
|
|
17
19
|
import { lastJsonWith } from './json-reply.mjs';
|
|
20
|
+
import { createWorktree, removeWorktree, commitAll, headCommit } from './worktree.mjs';
|
|
21
|
+
import { forwardRemoteOperation } from './remote-operation.mjs';
|
|
18
22
|
|
|
19
23
|
const MAX_DIFF = 30000;
|
|
20
24
|
// Generated surfaces say nothing a reviewer can act on, and they crowd out the source.
|
|
@@ -27,25 +31,35 @@ const GENERATED = ['.cursorrules', '.cursor', 'openwiki', 'package-lock.json', '
|
|
|
27
31
|
*/
|
|
28
32
|
export function extractGitDiff(options = {}) {
|
|
29
33
|
const cwd = options.cwd || process.cwd();
|
|
30
|
-
const
|
|
31
|
-
const
|
|
34
|
+
const run = args => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024 * 1024 });
|
|
35
|
+
const read = (args, extra = []) => run(['diff', ...(extra.length ? extra : ['--binary']), ...args, '--', '.', ...GENERATED]);
|
|
36
|
+
const ref = value => {
|
|
37
|
+
const result = run(['rev-parse', '--verify', `${value}^{commit}`]);
|
|
38
|
+
if (result.status !== 0) throw new Error(String(result.stderr || 'Invalid review reference').trim());
|
|
39
|
+
return result.stdout.trim();
|
|
40
|
+
};
|
|
41
|
+
const done = (args, diff, target, snapshot) => {
|
|
32
42
|
const stat = read(args, ['--stat']).stdout || '';
|
|
33
|
-
const untracked =
|
|
34
|
-
return { ok: true, diff, target, stat: stat.trim(), untracked: untracked.
|
|
43
|
+
const untracked = snapshot.mode === 'working' ? run(['ls-files', '-o', '--exclude-standard', '-z', '--', '.', ...GENERATED]).stdout || '' : '';
|
|
44
|
+
return { ok: true, diff, target, stat: stat.trim(), untracked: untracked.split('\0').filter(Boolean), snapshot };
|
|
35
45
|
};
|
|
36
46
|
const explicit = options.staged === true ? ['--staged'] : (options.target || options.branch) ? [options.target || options.branch] : null;
|
|
37
47
|
try {
|
|
38
48
|
if (explicit) {
|
|
49
|
+
if (explicit[0] !== '--staged' && explicit[0].startsWith('-')) throw new Error('Review targets must be Git references, not options');
|
|
39
50
|
const result = read(explicit);
|
|
40
51
|
if (result.status !== 0) return { ok: false, error: String(result.stderr || 'git diff failed').trim(), diff: '' };
|
|
41
|
-
|
|
52
|
+
let snapshot;
|
|
53
|
+
if (options.staged === true) snapshot = { mode: 'staged', ref: ref('HEAD') };
|
|
54
|
+
else if (explicit[0].includes('..')) snapshot = { mode: 'commit', ref: ref(explicit[0].split(/\.{2,3}/).at(-1) || 'HEAD') };
|
|
55
|
+
else snapshot = { mode: 'working', ref: ref(explicit[0]) };
|
|
56
|
+
return done(explicit, result.stdout.trim(), explicit.join(' '), snapshot);
|
|
42
57
|
}
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const working = read(['HEAD']);
|
|
58
|
+
const mergeBase = run(['merge-base', getDefaultBranch(cwd), 'HEAD']);
|
|
59
|
+
const base = mergeBase.status === 0 ? mergeBase.stdout.trim() : ref('HEAD');
|
|
60
|
+
const working = read([base]);
|
|
47
61
|
if (working.status !== 0) return { ok: false, error: String(working.stderr || 'git diff failed').trim(), diff: '' };
|
|
48
|
-
return done([
|
|
62
|
+
return done([base], working.stdout.trim(), 'working tree', { mode: 'working', ref: base });
|
|
49
63
|
} catch (error) {
|
|
50
64
|
return { ok: false, error: error.message, diff: '' };
|
|
51
65
|
}
|
|
@@ -55,12 +69,33 @@ function getDefaultBranch(cwd) {
|
|
|
55
69
|
try {
|
|
56
70
|
const res = spawnSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, encoding: 'utf8', timeout: 3000 });
|
|
57
71
|
if (res.status === 0 && res.stdout.includes('/')) {
|
|
58
|
-
return res.stdout.trim()
|
|
72
|
+
return res.stdout.trim();
|
|
59
73
|
}
|
|
60
74
|
} catch {}
|
|
61
75
|
return 'main';
|
|
62
76
|
}
|
|
63
77
|
|
|
78
|
+
/** Pin the selected diff in a disposable commit, so reviewers never read unrelated current files. */
|
|
79
|
+
function reviewSnapshot(cwd, selection) {
|
|
80
|
+
const directory = createWorktree(cwd, { ref: selection.snapshot.ref, prefix: 'meta-harness-review-' });
|
|
81
|
+
try {
|
|
82
|
+
if (selection.snapshot.mode !== 'commit') {
|
|
83
|
+
if (selection.diff) {
|
|
84
|
+
const patch = spawnSync('git', ['apply', '--index', '--binary', '-'], { cwd: directory, input: `${selection.diff}\n`, encoding: 'utf8', timeout: 15000 });
|
|
85
|
+
if (patch.status !== 0) throw new Error(`Could not pin the selected review diff: ${String(patch.stderr || patch.error).trim()}`);
|
|
86
|
+
}
|
|
87
|
+
for (const file of selection.untracked) {
|
|
88
|
+
const source = path.resolve(cwd, file), destination = path.resolve(directory, file);
|
|
89
|
+
if (!source.startsWith(`${path.resolve(cwd)}${path.sep}`) || !destination.startsWith(`${directory}${path.sep}`) || !fs.lstatSync(source).isFile()) throw new Error(`Unsafe untracked review file: ${file}`);
|
|
90
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
91
|
+
fs.copyFileSync(source, destination);
|
|
92
|
+
}
|
|
93
|
+
commitAll(directory, 'meta-harness: pin selected review snapshot');
|
|
94
|
+
}
|
|
95
|
+
return { directory, commit: headCommit(directory) };
|
|
96
|
+
} catch (error) { removeWorktree(cwd, directory); throw error; }
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
/**
|
|
65
100
|
* Assign an adversarial reviewer from an alternative model family
|
|
66
101
|
*/
|
|
@@ -132,14 +167,17 @@ export function parseReviewVerdict(response) {
|
|
|
132
167
|
}
|
|
133
168
|
|
|
134
169
|
export async function executeCodeReview(options = {}) {
|
|
170
|
+
const remote = await forwardRemoteOperation('review', null, options);
|
|
171
|
+
if (remote) return remote;
|
|
172
|
+
localExecutionNode(options);
|
|
135
173
|
const cwd = options.cwd || process.cwd();
|
|
136
174
|
const author = options.author || null;
|
|
137
175
|
const diffResult = extractGitDiff({ cwd, target: options.target, staged: options.staged });
|
|
138
176
|
if (!diffResult.ok) throw new Error(`Failed to read the diff: ${diffResult.error}`);
|
|
139
|
-
if (!diffResult.diff) return { ok: true, verdict: 'NO_DIFF', target: diffResult.target, summary: `Nothing to review for ${diffResult.target}.` };
|
|
177
|
+
if (!diffResult.diff && !diffResult.untracked?.length) return { ok: true, verdict: 'NO_DIFF', target: diffResult.target, summary: `Nothing to review for ${diffResult.target}.` };
|
|
140
178
|
|
|
141
179
|
const { ready, excluded } = await resolvePool(options);
|
|
142
|
-
const reviewer = options.reviewer ?
|
|
180
|
+
const reviewer = options.reviewer ? ready.find(h => h.id === options.reviewer) : selectAdversarialReviewer(author, ready);
|
|
143
181
|
if (!reviewer) throw new Error(`No independent reviewer can take work right now: ${excluded.map(e => `${e.harness} (${e.reason})`).join(', ') || 'no other harness available'}`);
|
|
144
182
|
|
|
145
183
|
const authorFamily = ADAPTER_CATALOG[author]?.family || null;
|
|
@@ -147,14 +185,17 @@ export async function executeCodeReview(options = {}) {
|
|
|
147
185
|
if (author && (reviewer.id === author || (authorFamily && authorFamily === reviewerFamily))) throw new Error(`${reviewer.id} shares a model family with the author ${author}, so it cannot review this independently`);
|
|
148
186
|
const independence = author ? 'verified' : 'unverified: the author was not named';
|
|
149
187
|
const truncated = diffResult.diff.length > MAX_DIFF;
|
|
150
|
-
const
|
|
151
|
-
|
|
188
|
+
const snapshot = reviewSnapshot(cwd, diffResult);
|
|
189
|
+
let result;
|
|
190
|
+
try { result = await dispatchWithReceipt({
|
|
191
|
+
agent: reviewer.id, node: 'local', cwd: snapshot.directory, contextCwd: cwd, contextTask: options.task || `Review the code changes in ${diffResult.target}`,
|
|
152
192
|
prompt: buildReviewPrompt(diffResult.diff, { task: options.task, branch: options.branch || diffResult.target, stat: diffResult.stat, untracked: diffResult.untracked }),
|
|
153
|
-
permissions: { read: true, write: false }, allowUnknownAuth: true,
|
|
154
|
-
}, { dispatchFn: options.dispatchFn, role: 'Reviewer' });
|
|
193
|
+
permissions: { read: true, write: false }, allowUnknownAuth: options.allowUnknownAuth === true,
|
|
194
|
+
}, { dispatchFn: options.dispatchFn, role: 'Reviewer' }); }
|
|
195
|
+
finally { removeWorktree(cwd, snapshot.directory); }
|
|
155
196
|
if (!result.ok) return { ok: false, target: diffResult.target, reviewer: reviewer.id, error: result.error, failureKind: result.failureKind, excluded };
|
|
156
197
|
|
|
157
|
-
const review = { ...parseReviewVerdict(result.response), reviewer: reviewer.id, reviewerName: reviewer.name || reviewer.id, author, target: diffResult.target, truncated, independence, timestamp: new Date().toISOString() };
|
|
198
|
+
const review = { ...parseReviewVerdict(result.response), reviewer: reviewer.id, reviewerName: reviewer.name || reviewer.id, author, target: diffResult.target, snapshotCommit: snapshot.commit, truncated, independence, node: 'local', workspace: cwd, ...(options.threadId ? { threadId: options.threadId } : {}), timestamp: new Date().toISOString() };
|
|
158
199
|
let threadError;
|
|
159
200
|
if (options.threadId) {
|
|
160
201
|
try {
|
|
@@ -19,13 +19,13 @@ import { inspectAgents } from './worker.mjs';
|
|
|
19
19
|
import { inspectNode } from './executor.mjs';
|
|
20
20
|
import { getModelCatalog, joinHarnessModels } from './model-catalog.mjs';
|
|
21
21
|
import { readReceipts, activeQuotaWalls, activeCredentialBlocks } from './learner.mjs';
|
|
22
|
-
import { resolvePool, exclusionReason } from './pool.mjs';
|
|
22
|
+
import { resolvePool, exclusionReason, isLocalExecutionNode } from './pool.mjs';
|
|
23
23
|
import { readCodexRateLimits, measuredWalls } from './telemetry.mjs';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Helper to safely query host Total Recall CLI without assumptions
|
|
27
27
|
*/
|
|
28
|
-
function runHostCli(args = []) {
|
|
28
|
+
function runHostCli(args = [], { captureFailure = false } = {}) {
|
|
29
29
|
try {
|
|
30
30
|
const totalRecallBin = process.env.TOTAL_RECALL_BIN || 'total-recall';
|
|
31
31
|
const output = execFileSync(totalRecallBin, args, {
|
|
@@ -34,8 +34,9 @@ function runHostCli(args = []) {
|
|
|
34
34
|
encoding: 'utf8'
|
|
35
35
|
});
|
|
36
36
|
return output.trim();
|
|
37
|
-
} catch {
|
|
38
|
-
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// A failing owner often explains itself as JSON on stdout. Callers that need the reason read it here.
|
|
39
|
+
return captureFailure ? String(error.stdout || '').trim() || null : null;
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -136,7 +137,26 @@ export async function inspectQuotas(options = {}) {
|
|
|
136
137
|
meteredQuota.quotaNote = `OpenRouter could not be reached: ${error.message}`;
|
|
137
138
|
meteredQuota.status = 'unavailable';
|
|
138
139
|
}
|
|
140
|
+
// Per-key usage says nothing about whether the account can pay. Only the account balance does.
|
|
141
|
+
try {
|
|
142
|
+
const management = options.openRouterManagementKey !== undefined ? options.openRouterManagementKey : runHostCli(['auth', 'secret', 'get', 'OPENROUTER_MANAGEMENT_KEY']);
|
|
143
|
+
if (management) {
|
|
144
|
+
const response = await (options.fetchImpl || fetch)('https://openrouter.ai/api/v1/credits', { headers: { Authorization: `Bearer ${management}` }, signal: AbortSignal.timeout(6000) });
|
|
145
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
146
|
+
const { total_credits: credits, total_usage: used } = (await response.json()).data || {};
|
|
147
|
+
if (typeof credits === 'number' && typeof used === 'number') {
|
|
148
|
+
meteredQuota.creditRemainingUSD = +(credits - used).toFixed(2);
|
|
149
|
+
meteredQuota.quotaNote = `Account balance $${meteredQuota.creditRemainingUSD} of $${credits}`;
|
|
150
|
+
if (credits - used <= 0) meteredQuota.status = 'exhausted';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
meteredQuota.balanceError = `The OpenRouter balance could not be read: ${error.message}`;
|
|
155
|
+
}
|
|
139
156
|
quotas.metered = meteredQuota;
|
|
157
|
+
if (meteredQuota.status === 'exhausted') for (const id of ['dsh', 'hermes', 'pi']) {
|
|
158
|
+
if (quotas[id]?.installed && !['needs_auth', 'blocked'].includes(quotas[id].status)) Object.assign(quotas[id], { status: 'exhausted', quotaNote: `Bills OpenRouter. ${meteredQuota.quotaNote}` });
|
|
159
|
+
}
|
|
140
160
|
|
|
141
161
|
return { ok: true, timestamp: new Date().toISOString(), node: options.node || 'local', fleet: { ok: fleet.ok, error: fleet.error, machines: fleet.machines, answered: fleet.answered, nodes: fleet.nodes }, quotas };
|
|
142
162
|
}
|
|
@@ -154,11 +174,12 @@ export async function getBenchmarks(options = {}) {
|
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
/** Ask the decision plugin to classify. Its failure is reported; the caller sees which classifier answered. */
|
|
157
|
-
export function classifyWithDecision(task, run = args => runHostCli(args)) {
|
|
158
|
-
const questions = { complexity: { type: 'choice',
|
|
177
|
+
export function classifyWithDecision(task, run = args => runHostCli(args, { captureFailure: true })) {
|
|
178
|
+
const questions = { complexity: { type: 'choice', instructions: 'How demanding is this software engineering task for a coding agent?', criteria: { local: 'A small mechanical change a local model can make.', budget: 'Routine engineering with a clear path.', frontier: 'Architecture, security, or work across many files.' } } };
|
|
159
179
|
const output = run(['decision', 'run', '--state', JSON.stringify({ task: task.slice(0, 2000) }), '--questions', JSON.stringify(questions), '--json']);
|
|
160
180
|
if (!output) throw new Error('The decision plugin did not answer');
|
|
161
181
|
const parsed = JSON.parse(output);
|
|
182
|
+
if (parsed.ok === false) throw new Error(parsed.error || 'The decision plugin reported a failure');
|
|
162
183
|
const answer = parsed.answers?.complexity ?? parsed.decision?.answers?.complexity ?? parsed.result?.complexity;
|
|
163
184
|
const tier = typeof answer === 'string' ? answer : answer?.choice;
|
|
164
185
|
if (!['local', 'budget', 'frontier'].includes(tier)) throw new Error('The decision plugin returned no usable tier');
|
|
@@ -185,7 +206,11 @@ export async function routeTask(taskDescription, options = {}) {
|
|
|
185
206
|
classifier = { source: 'heuristic', decisionError: String(error.message).slice(0, 300) };
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
|
-
|
|
209
|
+
// Plan-only routing on a selected node chooses from that node's harnesses, not this machine's.
|
|
210
|
+
const remote = options.node && !isLocalExecutionNode(options.node);
|
|
211
|
+
const inspected = remote ? (options.inspectNodeFn || inspectNode)(options.node) : null;
|
|
212
|
+
const scoped = remote ? { ...options, inspectNodeFn: () => inspected, inspect: () => inspected } : options;
|
|
213
|
+
const [quotaReport, pool, receipts] = await Promise.all([inspectQuotas(scoped), resolvePool(scoped), readReceipts()]);
|
|
189
214
|
const quotas = quotaReport.quotas;
|
|
190
215
|
const routing = selectOptimalHarness(taskDescription, pool.ready.map(h => h.id), quotas, { ...options, tierOverride: complexity.tier, empiricalScorecard: computeEmpiricalScorecard(receipts) });
|
|
191
216
|
const selectedQuota = quotas[routing.selected] || {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A stopped run takes its agents, checkouts, unfinished branches and open threads with it. */
|
|
2
|
+
import { stopActiveProcesses } from './worker.mjs';
|
|
3
|
+
import { removeActiveWorktrees } from './worktree.mjs';
|
|
4
|
+
import { failOpenThreads } from './coordinator.mjs';
|
|
5
|
+
|
|
6
|
+
const SIGNALS = { SIGTERM: 143, SIGINT: 130, SIGHUP: 129 };
|
|
7
|
+
let installed = false;
|
|
8
|
+
|
|
9
|
+
export function stopOnTermination({ graceMs = 500 } = {}) {
|
|
10
|
+
if (installed) return;
|
|
11
|
+
installed = true;
|
|
12
|
+
let stopping = false;
|
|
13
|
+
for (const [signal, code] of Object.entries(SIGNALS)) process.on(signal, () => {
|
|
14
|
+
if (stopping) return;
|
|
15
|
+
stopping = true;
|
|
16
|
+
stopActiveProcesses('SIGTERM');
|
|
17
|
+
setTimeout(() => {
|
|
18
|
+
stopActiveProcesses('SIGKILL');
|
|
19
|
+
removeActiveWorktrees();
|
|
20
|
+
failOpenThreads();
|
|
21
|
+
process.exit(code);
|
|
22
|
+
}, graceMs);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,156 +1,119 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Meta Harness Multi-Platform Thread Manager
|
|
3
|
-
* Unifies thread tracking and session supervision across integrated agent platforms:
|
|
4
|
-
* agy, claude, codex, dsh, hermes, openclaw, cursor, devin, etc.
|
|
5
|
-
*
|
|
6
|
-
* Persists thread state, PR branch associations, turn history, and review status.
|
|
7
|
-
*/
|
|
8
|
-
|
|
1
|
+
/** Thread state is canonical SSSS, mutated through the native kernel with optimistic concurrency. */
|
|
9
2
|
import fs from 'node:fs';
|
|
10
|
-
import
|
|
3
|
+
import crypto from 'node:crypto';
|
|
11
4
|
import { threadsFile } from './paths.mjs';
|
|
5
|
+
import { PluginStateStore } from './ssss-store.mjs';
|
|
6
|
+
|
|
7
|
+
const STATUSES = new Set(['active', 'review_pending', 'approved', 'completed', 'failed']);
|
|
8
|
+
const identifier = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
9
|
+
const storeForThreads = () => new PluginStateStore(threadsFile(), 'meta-harness');
|
|
10
|
+
|
|
11
|
+
function validateThreads(value) {
|
|
12
|
+
if (!Array.isArray(value)) throw new Error('The thread store is corrupt: threads must be an array');
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
for (const thread of value) {
|
|
15
|
+
if (!thread || typeof thread !== 'object' || !identifier.test(thread.id || '') || ids.has(thread.id)
|
|
16
|
+
|| typeof thread.title !== 'string' || !STATUSES.has(thread.status) || !Array.isArray(thread.turns)
|
|
17
|
+
|| thread.turns.some(turn => !turn || typeof turn !== 'object' || typeof turn.content !== 'string')) {
|
|
18
|
+
throw new Error('The thread store is corrupt: invalid thread identity, status or turns');
|
|
19
|
+
}
|
|
20
|
+
ids.add(thread.id);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
12
24
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
let
|
|
16
|
-
try {
|
|
17
|
-
catch (error) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
/** Existing JSON is read only for migration. The first mutation imports it through SSSS; it is never overwritten. */
|
|
26
|
+
function readState(store) {
|
|
27
|
+
let value;
|
|
28
|
+
try { value = store.read(null); }
|
|
29
|
+
catch (error) { throw new Error(`The canonical thread store is corrupt or unsafe: ${error.message}`); }
|
|
30
|
+
if (value === null) {
|
|
31
|
+
let content;
|
|
32
|
+
try { content = fs.readFileSync(threadsFile(), 'utf8'); }
|
|
33
|
+
catch (error) { if (error.code === 'ENOENT') return { threads: [] }; throw error; }
|
|
34
|
+
try { value = JSON.parse(content); }
|
|
35
|
+
catch { throw new Error(`The thread store ${threadsFile()} is corrupt. It was left in place.`); }
|
|
36
|
+
}
|
|
37
|
+
const state = Array.isArray(value) ? { threads: value } : value;
|
|
38
|
+
if (!state || typeof state !== 'object') throw new Error('The thread store is corrupt: missing thread state');
|
|
39
|
+
validateThreads(state.threads);
|
|
40
|
+
return state;
|
|
22
41
|
}
|
|
23
42
|
|
|
24
|
-
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
for (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
export const threadDocumentPath = () => storeForThreads().documentPath;
|
|
44
|
+
export function readThreads() { return readState(storeForThreads()).threads; }
|
|
45
|
+
|
|
46
|
+
/** Native SSSS owns the mutation guard and leases. CAS retries reload state; no age-based lock stealing. */
|
|
47
|
+
function mutate(work) {
|
|
48
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
49
|
+
const store = storeForThreads();
|
|
50
|
+
const state = readState(store);
|
|
51
|
+
const result = work(state.threads);
|
|
52
|
+
validateThreads(state.threads);
|
|
53
|
+
try {
|
|
54
|
+
store.write({ ...state, version: '2.0.0', updatedAt: new Date().toISOString(), threads: state.threads });
|
|
55
|
+
return structuredClone(result);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!String(error.message).includes('version conflict') || attempt === 7) throw error;
|
|
36
58
|
}
|
|
37
59
|
}
|
|
38
|
-
try { return work(); } finally { fs.rmdirSync(lock); }
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
export function writeThreads(threads) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const payload = {
|
|
45
|
-
version: '1.0.0',
|
|
46
|
-
updatedAt: new Date().toISOString(),
|
|
47
|
-
threads: Array.isArray(threads) ? threads : []
|
|
48
|
-
};
|
|
49
|
-
const temporary = `${file}.${process.pid}.tmp`;
|
|
50
|
-
fs.writeFileSync(temporary, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
51
|
-
fs.renameSync(temporary, file);
|
|
63
|
+
validateThreads(threads);
|
|
64
|
+
return mutate(current => { current.splice(0, current.length, ...structuredClone(threads)); return current; });
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
export function listThreads(filter = {}) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.filter(t => {
|
|
58
|
-
if (filter.platform && t.platform !== filter.platform) return false;
|
|
59
|
-
if (filter.status && t.status !== filter.status) return false;
|
|
60
|
-
if (filter.branch && t.branch !== filter.branch) return false;
|
|
61
|
-
return true;
|
|
62
|
-
})
|
|
68
|
+
return readThreads().filter(thread => (!filter.platform || thread.platform === filter.platform)
|
|
69
|
+
&& (!filter.status || thread.status === filter.status) && (!filter.branch || thread.branch === filter.branch))
|
|
63
70
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
64
71
|
}
|
|
65
|
-
|
|
66
|
-
export function getThread(id) {
|
|
67
|
-
const threads = readThreads();
|
|
68
|
-
return threads.find(t => t.id === id) || null;
|
|
69
|
-
}
|
|
72
|
+
export function getThread(id) { return readThreads().find(thread => thread.id === id) || null; }
|
|
70
73
|
|
|
71
74
|
export function createThread(data = {}) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
prNumber: data.prNumber || null,
|
|
86
|
-
prUrl: data.prUrl || null,
|
|
87
|
-
status: data.status || 'active', // 'active' | 'review_pending' | 'approved' | 'completed' | 'failed'
|
|
88
|
-
createdAt: now,
|
|
89
|
-
updatedAt: now,
|
|
90
|
-
turns: Array.isArray(data.turns) ? data.turns : [],
|
|
91
|
-
review: data.review || null,
|
|
92
|
-
summary: data.summary || null
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
threads.unshift(thread);
|
|
96
|
-
writeThreads(threads);
|
|
97
|
-
return thread;
|
|
75
|
+
const id = data.id || `thread-${crypto.randomUUID()}`;
|
|
76
|
+
return mutate(threads => {
|
|
77
|
+
if (threads.some(thread => thread.id === id)) throw new Error(`Thread already exists: ${id}`);
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
const thread = {
|
|
80
|
+
...data, id, title: String(data.title || 'Untitled Agent Thread').slice(0, 140),
|
|
81
|
+
task: String(data.task || data.title || '').trim(), platform: data.platform || 'universal', model: data.model || 'auto',
|
|
82
|
+
branch: data.branch || null, worktreePath: data.worktreePath || null, prNumber: data.prNumber || null, prUrl: data.prUrl || null,
|
|
83
|
+
status: data.status || 'active', createdAt: now, updatedAt: now,
|
|
84
|
+
turns: Array.isArray(data.turns) ? data.turns : [], review: data.review || null, summary: data.summary || null,
|
|
85
|
+
};
|
|
86
|
+
threads.unshift(thread);
|
|
87
|
+
return thread;
|
|
98
88
|
});
|
|
99
89
|
}
|
|
100
90
|
|
|
101
91
|
export function appendThreadTurn(id, turn = {}) {
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
role: turn.role || 'agent',
|
|
110
|
-
agent: turn.agent || 'unknown',
|
|
111
|
-
content: String(turn.content || '').slice(0, 50000),
|
|
112
|
-
timestamp: now
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
threads[index].turns.push(entry);
|
|
116
|
-
threads[index].updatedAt = now;
|
|
117
|
-
writeThreads(threads);
|
|
118
|
-
return threads[index];
|
|
92
|
+
return mutate(threads => {
|
|
93
|
+
const thread = threads.find(item => item.id === id);
|
|
94
|
+
if (!thread) throw new Error(`Thread not found: ${id}`);
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
thread.turns.push({ role: turn.role || 'agent', agent: turn.agent || 'unknown', content: String(turn.content || '').slice(0, 50000), timestamp: now });
|
|
97
|
+
thread.updatedAt = now;
|
|
98
|
+
return thread;
|
|
119
99
|
});
|
|
120
100
|
}
|
|
121
101
|
|
|
122
102
|
export function updateThread(id, updates = {}) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
threads[index] = {
|
|
130
|
-
...threads[index],
|
|
131
|
-
...updates,
|
|
132
|
-
id, // protect immutable ID
|
|
133
|
-
updatedAt: now
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
writeThreads(threads);
|
|
137
|
-
return threads[index];
|
|
103
|
+
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) throw new Error('Thread updates must be an object');
|
|
104
|
+
return mutate(threads => {
|
|
105
|
+
const index = threads.findIndex(thread => thread.id === id);
|
|
106
|
+
if (index < 0) throw new Error(`Thread not found: ${id}`);
|
|
107
|
+
threads[index] = { ...threads[index], ...updates, id, createdAt: threads[index].createdAt, updatedAt: new Date().toISOString() };
|
|
108
|
+
return threads[index];
|
|
138
109
|
});
|
|
139
110
|
}
|
|
140
|
-
|
|
141
|
-
export function closeThread(id, resolution = 'completed') {
|
|
142
|
-
return updateThread(id, {
|
|
143
|
-
status: resolution,
|
|
144
|
-
closedAt: new Date().toISOString()
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
111
|
+
export function closeThread(id, resolution = 'completed') { return updateThread(id, { status: resolution, closedAt: new Date().toISOString() }); }
|
|
148
112
|
export function deleteThread(id) {
|
|
149
|
-
return
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
writeThreads(filtered);
|
|
113
|
+
return mutate(threads => {
|
|
114
|
+
const index = threads.findIndex(thread => thread.id === id);
|
|
115
|
+
if (index < 0) return false;
|
|
116
|
+
threads.splice(index, 1);
|
|
154
117
|
return true;
|
|
155
118
|
});
|
|
156
119
|
}
|