brainclaw 1.28.1 → 1.28.3
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +2 -0
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/harvest.js +32 -43
- package/dist/commands/loops-handlers.js +66 -3
- package/dist/commands/mcp-catalog.js +2 -2
- package/dist/commands/mcp-write-coordination.js +426 -141
- package/dist/commands/mcp-write-entities.js +5 -2
- package/dist/commands/mcp.js +57 -11
- package/dist/core/agentrun-reconciler.js +138 -4
- package/dist/core/claims.js +4 -1
- package/dist/core/code-map/aggregate.js +20 -7
- package/dist/core/code-map/backend.js +25 -7
- package/dist/core/code-map/cascade-jobs.js +174 -0
- package/dist/core/code-map/cascade-worker.js +15 -0
- package/dist/core/code-map/cascade.js +63 -26
- package/dist/core/code-map/query.js +6 -3
- package/dist/core/entity-operations.js +18 -4
- package/dist/core/execution-adapters.js +23 -8
- package/dist/core/hygiene-policy.js +2 -1
- package/dist/core/loop-turn-dispatch.js +18 -1
- package/dist/core/loops/attempt-authority.js +22 -4
- package/dist/core/loops/attempt-generations.js +17 -4
- package/dist/core/loops/attempt-reservation.js +14 -1
- package/dist/core/loops/attempt-takeover.js +173 -76
- package/dist/core/loops/reconcile-turn.js +224 -26
- package/dist/core/loops/result-reducers.js +8 -8
- package/dist/core/loops/turn-execution.js +38 -19
- package/dist/core/loops/types.js +3 -0
- package/dist/core/loops/verbs.js +1 -1
- package/dist/core/spawn-check.js +9 -1
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/cli.md +2 -2
- package/docs/code-map.md +30 -9
- package/docs/concepts/loop-engine.md +5 -0
- package/docs/integrations/mcp.md +2 -2
- package/docs/mcp-schema-changelog.md +30 -0
- package/package.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { loadConfig } from '../config.js';
|
|
7
|
+
import { writeFileAtomic } from '../io.js';
|
|
8
|
+
import { codeMapDir } from './paths.js';
|
|
9
|
+
import { inspectNestedProjects, refreshWorkspaceCascade } from './cascade.js';
|
|
10
|
+
export function summarizeCascadeRefreshJob(job) {
|
|
11
|
+
const { result, ...summary } = job;
|
|
12
|
+
if (!result)
|
|
13
|
+
return summary;
|
|
14
|
+
const projects = [result.root_result, ...result.children];
|
|
15
|
+
const outcomeCounts = {};
|
|
16
|
+
for (const project of projects)
|
|
17
|
+
outcomeCounts[project.outcome] = (outcomeCounts[project.outcome] ?? 0) + 1;
|
|
18
|
+
const problemProjects = projects
|
|
19
|
+
.filter((project) => project.outcome !== 'indexed')
|
|
20
|
+
.map((project) => ({ path: project.path, outcome: project.outcome, ...(project.reason ? { reason: project.reason } : {}), ...(project.error ? { error: project.error } : {}) }));
|
|
21
|
+
return { ...summary, outcome_counts: outcomeCounts, ...(problemProjects.length ? { problem_projects: problemProjects } : {}) };
|
|
22
|
+
}
|
|
23
|
+
function jobsDir(root) {
|
|
24
|
+
return path.join(codeMapDir(root), 'cascade-jobs');
|
|
25
|
+
}
|
|
26
|
+
function jobPath(root, jobId) {
|
|
27
|
+
return path.join(jobsDir(root), `${jobId}.json`);
|
|
28
|
+
}
|
|
29
|
+
function writeJob(job) {
|
|
30
|
+
const dir = jobsDir(job.root);
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
const target = jobPath(job.root, job.job_id);
|
|
33
|
+
writeFileAtomic(target, `${JSON.stringify(job, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
export function readCascadeRefreshJob(root, jobId) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(jobPath(path.resolve(root), jobId), 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function latestCascadeRefreshJob(root) {
|
|
44
|
+
const dir = jobsDir(path.resolve(root));
|
|
45
|
+
try {
|
|
46
|
+
const jobs = fs.readdirSync(dir)
|
|
47
|
+
.filter((name) => name.endsWith('.json'))
|
|
48
|
+
.map((name) => {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.filter((job) => job !== null)
|
|
57
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
58
|
+
return jobs[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function processAlive(pid) {
|
|
65
|
+
if (!pid)
|
|
66
|
+
return false;
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function startCascadeRefreshJob(root, scope) {
|
|
76
|
+
const resolvedRoot = path.resolve(root);
|
|
77
|
+
try {
|
|
78
|
+
if (loadConfig(resolvedRoot).project_mode !== 'multi-project')
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const previous = latestCascadeRefreshJob(resolvedRoot);
|
|
85
|
+
if (previous && (previous.status === 'queued' || previous.status === 'running') && processAlive(previous.pid)) {
|
|
86
|
+
return previous;
|
|
87
|
+
}
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
const job = {
|
|
90
|
+
job_id: `cmj_${crypto.randomBytes(8).toString('hex')}`,
|
|
91
|
+
root: resolvedRoot,
|
|
92
|
+
scope,
|
|
93
|
+
status: 'queued',
|
|
94
|
+
created_at: now,
|
|
95
|
+
updated_at: now,
|
|
96
|
+
projects_total: inspectNestedProjects(resolvedRoot).projects.length + 1,
|
|
97
|
+
projects_completed: 0,
|
|
98
|
+
current_project: null,
|
|
99
|
+
};
|
|
100
|
+
writeJob(job);
|
|
101
|
+
const worker = fileURLToPath(new URL('./cascade-worker.js', import.meta.url));
|
|
102
|
+
const child = spawn(process.execPath, [worker, resolvedRoot, job.job_id, scope], {
|
|
103
|
+
cwd: resolvedRoot,
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: 'ignore',
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
});
|
|
108
|
+
const markWorkerExit = (detail) => {
|
|
109
|
+
const current = readCascadeRefreshJob(resolvedRoot, job.job_id);
|
|
110
|
+
if (!current || current.status === 'completed' || current.status === 'failed')
|
|
111
|
+
return;
|
|
112
|
+
const completed = new Date().toISOString();
|
|
113
|
+
writeJob({
|
|
114
|
+
...current,
|
|
115
|
+
status: 'failed',
|
|
116
|
+
error: detail,
|
|
117
|
+
current_project: null,
|
|
118
|
+
completed_at: completed,
|
|
119
|
+
updated_at: completed,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
child.once('error', (error) => {
|
|
123
|
+
markWorkerExit(`cascade worker failed to start: ${error.message}`);
|
|
124
|
+
});
|
|
125
|
+
child.once('exit', (code, signal) => {
|
|
126
|
+
// The worker writes its terminal state synchronously before exiting. If no
|
|
127
|
+
// terminal state exists here, the launch/runtime failed outside its guard.
|
|
128
|
+
markWorkerExit(`cascade worker exited before completion (code=${code ?? 'null'}, signal=${signal ?? 'none'})`);
|
|
129
|
+
});
|
|
130
|
+
child.unref();
|
|
131
|
+
const current = readCascadeRefreshJob(resolvedRoot, job.job_id) ?? job;
|
|
132
|
+
if (current.status === 'queued') {
|
|
133
|
+
current.pid = child.pid;
|
|
134
|
+
current.updated_at = new Date().toISOString();
|
|
135
|
+
writeJob(current);
|
|
136
|
+
}
|
|
137
|
+
return current;
|
|
138
|
+
}
|
|
139
|
+
/** Worker entry seam, exported for deterministic tests and the tiny worker file. */
|
|
140
|
+
export async function runCascadeRefreshJob(root, jobId, scope) {
|
|
141
|
+
const job = readCascadeRefreshJob(root, jobId);
|
|
142
|
+
if (!job)
|
|
143
|
+
throw new Error(`cascade job not found: ${jobId}`);
|
|
144
|
+
const started = new Date().toISOString();
|
|
145
|
+
writeJob({ ...job, status: 'running', pid: process.pid, started_at: started, updated_at: started });
|
|
146
|
+
try {
|
|
147
|
+
const result = await refreshWorkspaceCascade({
|
|
148
|
+
rootCwd: root,
|
|
149
|
+
scope,
|
|
150
|
+
onProgress: (progress) => {
|
|
151
|
+
const current = readCascadeRefreshJob(root, jobId) ?? job;
|
|
152
|
+
writeJob({
|
|
153
|
+
...current,
|
|
154
|
+
status: 'running',
|
|
155
|
+
pid: process.pid,
|
|
156
|
+
projects_total: progress.total,
|
|
157
|
+
projects_completed: progress.completed,
|
|
158
|
+
current_project: progress.current_project,
|
|
159
|
+
...(progress.last_result ? { last_result: progress.last_result } : {}),
|
|
160
|
+
updated_at: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const current = readCascadeRefreshJob(root, jobId) ?? job;
|
|
165
|
+
const completed = new Date().toISOString();
|
|
166
|
+
writeJob({ ...current, status: 'completed', result, current_project: null, projects_completed: current.projects_total, completed_at: completed, updated_at: completed });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const current = readCascadeRefreshJob(root, jobId) ?? job;
|
|
170
|
+
const completed = new Date().toISOString();
|
|
171
|
+
writeJob({ ...current, status: 'failed', error: error instanceof Error ? error.message : String(error), current_project: null, completed_at: completed, updated_at: completed });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=cascade-jobs.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { runCascadeRefreshJob } from './cascade-jobs.js';
|
|
2
|
+
const [root, jobId, rawScope] = process.argv.slice(2);
|
|
3
|
+
if (!root || !jobId)
|
|
4
|
+
process.exit(2);
|
|
5
|
+
const scope = rawScope === 'all' ? 'all' : 'changed';
|
|
6
|
+
try {
|
|
7
|
+
await runCascadeRefreshJob(root, jobId, scope);
|
|
8
|
+
// Tree-sitter/native handles can keep Node's event loop alive after the
|
|
9
|
+
// durable terminal record has been flushed. This process owns no other work.
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=cascade-worker.js.map
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* refreshes existing brainclaw projects, it does not initialise new ones.
|
|
21
21
|
*/
|
|
22
22
|
import path from 'node:path';
|
|
23
|
-
import {
|
|
23
|
+
import { scanNestedBrainclawProjectsDetailed } from '../workspace-projects.js';
|
|
24
24
|
import { loadConfig } from '../config.js';
|
|
25
25
|
import { refresh as runRefresh } from './refresh.js';
|
|
26
26
|
import { readManifest } from './store.js';
|
|
@@ -54,10 +54,14 @@ function projectIdFor(cwd, fallbackId) {
|
|
|
54
54
|
* cascade and the `status --cascade` recap so both agree on the project set.
|
|
55
55
|
*/
|
|
56
56
|
export function listNestedProjects(rootCwd) {
|
|
57
|
+
return inspectNestedProjects(rootCwd).projects;
|
|
58
|
+
}
|
|
59
|
+
export function inspectNestedProjects(rootCwd) {
|
|
57
60
|
const root = path.resolve(rootCwd);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const discovered = scanNestedBrainclawProjectsDetailed(root);
|
|
62
|
+
return { projects: Array.from(new Set(discovered.projects
|
|
63
|
+
.map((c) => path.resolve(c.path))
|
|
64
|
+
.filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort(), truncated: discovered.truncated };
|
|
61
65
|
}
|
|
62
66
|
/**
|
|
63
67
|
* Refresh the whole multi-project workspace: every nested brainclaw project +
|
|
@@ -68,7 +72,8 @@ export async function refreshWorkspaceCascade(input) {
|
|
|
68
72
|
const rootCwd = path.resolve(input.rootCwd);
|
|
69
73
|
// Enumerate nested brainclaw projects strictly under the root (FS scan, so it
|
|
70
74
|
// is strategy-agnostic). De-dup + sort by path for deterministic output.
|
|
71
|
-
const
|
|
75
|
+
const discovery = inspectNestedProjects(rootCwd);
|
|
76
|
+
const childAbsPaths = discovery.projects;
|
|
72
77
|
// Every project to refresh, root first.
|
|
73
78
|
const allProjects = [rootCwd, ...childAbsPaths];
|
|
74
79
|
const refreshOne = async (projectCwd, isRoot) => {
|
|
@@ -77,40 +82,72 @@ export async function refreshWorkspaceCascade(input) {
|
|
|
77
82
|
const nestedUnder = allProjects.filter((p) => p !== projectCwd && isStrictlyUnder(p, projectCwd));
|
|
78
83
|
const extraIgnorePatterns = nestedUnder.map((p) => `${toPosix(path.relative(projectCwd, p))}/**`);
|
|
79
84
|
const projectId = projectIdFor(projectCwd);
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
const projectPath = isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd));
|
|
86
|
+
try {
|
|
87
|
+
const result = await runRefresh({
|
|
88
|
+
projectId,
|
|
89
|
+
projectRoot: projectCwd,
|
|
90
|
+
scope: input.scope,
|
|
91
|
+
cwd: projectCwd,
|
|
92
|
+
extraIgnorePatterns,
|
|
93
|
+
ownerAgent: input.ownerAgent ?? null,
|
|
94
|
+
ownerAgentId: input.ownerAgentId ?? null,
|
|
95
|
+
});
|
|
96
|
+
const filesIndexed = readManifest(projectCwd)?.stats.files_indexed ?? 0;
|
|
97
|
+
const outcome = !result.lock_acquired
|
|
98
|
+
? 'locked'
|
|
99
|
+
: filesIndexed === 0 ? 'no_eligible_files' : 'indexed';
|
|
100
|
+
return {
|
|
101
|
+
path: projectPath,
|
|
102
|
+
project_id: projectId,
|
|
103
|
+
is_root: isRoot,
|
|
104
|
+
ran: result.ran,
|
|
105
|
+
lock_acquired: result.lock_acquired,
|
|
106
|
+
files_parsed: result.files_parsed,
|
|
107
|
+
files_compacted: result.files_compacted,
|
|
108
|
+
files_indexed: filesIndexed,
|
|
109
|
+
freshness: result.freshness.status,
|
|
110
|
+
outcome,
|
|
111
|
+
...(outcome === 'no_eligible_files' ? { reason: 'no eligible source files found' } : {}),
|
|
112
|
+
...(result.lock_status ? { lock_status: result.lock_status, reason: result.lock_status } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
path: projectPath,
|
|
118
|
+
project_id: projectId,
|
|
119
|
+
is_root: isRoot,
|
|
120
|
+
ran: false,
|
|
121
|
+
lock_acquired: false,
|
|
122
|
+
files_parsed: 0,
|
|
123
|
+
files_compacted: 0,
|
|
124
|
+
files_indexed: readManifest(projectCwd)?.stats.files_indexed ?? null,
|
|
125
|
+
freshness: 'partial',
|
|
126
|
+
outcome: 'failed',
|
|
127
|
+
reason: 'refresh_failed',
|
|
128
|
+
error: error instanceof Error ? error.message : String(error),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
100
131
|
};
|
|
101
132
|
// Children first, then the root (sequential — each holds its own project lock
|
|
102
133
|
// briefly; never blocks bclaw_work, rule 8).
|
|
103
134
|
const children = [];
|
|
135
|
+
const total = childAbsPaths.length + 1;
|
|
136
|
+
input.onProgress?.({ completed: 0, total, current_project: childAbsPaths[0] ? toPosix(path.relative(rootCwd, childAbsPaths[0])) : '.' });
|
|
104
137
|
for (const childCwd of childAbsPaths) {
|
|
105
|
-
|
|
138
|
+
const result = await refreshOne(childCwd, false);
|
|
139
|
+
children.push(result);
|
|
140
|
+
input.onProgress?.({ completed: children.length, total, current_project: children.length < childAbsPaths.length ? toPosix(path.relative(rootCwd, childAbsPaths[children.length])) : '.', last_result: result });
|
|
106
141
|
}
|
|
107
142
|
const rootResult = await refreshOne(rootCwd, true);
|
|
143
|
+
input.onProgress?.({ completed: total, total, current_project: null, last_result: rootResult });
|
|
108
144
|
return {
|
|
109
145
|
is_cascade: true,
|
|
110
146
|
root: rootCwd,
|
|
111
147
|
root_result: rootResult,
|
|
112
148
|
children,
|
|
113
149
|
children_refreshed: children.length,
|
|
150
|
+
discovery_truncated: discovery.truncated,
|
|
114
151
|
};
|
|
115
152
|
}
|
|
116
153
|
//# sourceMappingURL=cascade.js.map
|
|
@@ -232,7 +232,7 @@ export function isTestPath(p) {
|
|
|
232
232
|
/**
|
|
233
233
|
* Score a symbol index entry against the query. Matching is separator/case
|
|
234
234
|
* INSENSITIVE (pln#601): an exact NORMALIZED match scores highest, then prefix,
|
|
235
|
-
* then substring
|
|
235
|
+
* then substring; sub-token-only candidates score zero. Exported symbols + components/hooks
|
|
236
236
|
* get a small boost; test-file symbols are biased DOWN so a test helper never
|
|
237
237
|
* outranks the real definition of the same name (the Fable-audit brief-noise
|
|
238
238
|
* companion to the find defect). Exported for focused ranking tests.
|
|
@@ -250,7 +250,7 @@ export function scoreEntry(entry, query) {
|
|
|
250
250
|
else if (name.includes(q))
|
|
251
251
|
score += 3; // substring
|
|
252
252
|
else
|
|
253
|
-
|
|
253
|
+
return 0; // shared-token noise is not a match
|
|
254
254
|
score *= entry.score_hint; // exported (1.0) vs internal (0.8)
|
|
255
255
|
if (entry.subtype === 'component' || entry.subtype === 'hook')
|
|
256
256
|
score += 1;
|
|
@@ -367,6 +367,9 @@ export function findInStore(query, ctx, checker, acc) {
|
|
|
367
367
|
const confident = validateEntry(entry, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
|
|
368
368
|
if (!confident)
|
|
369
369
|
continue;
|
|
370
|
+
const score = scoreEntry(entry, query);
|
|
371
|
+
if (score <= 0)
|
|
372
|
+
continue;
|
|
370
373
|
ranked.push({
|
|
371
374
|
match: {
|
|
372
375
|
node_id: entry.node_id,
|
|
@@ -375,7 +378,7 @@ export function findInStore(query, ctx, checker, acc) {
|
|
|
375
378
|
file_id: entry.file_id,
|
|
376
379
|
kind: entry.kind,
|
|
377
380
|
subtype: entry.subtype ?? null,
|
|
378
|
-
score
|
|
381
|
+
score,
|
|
379
382
|
},
|
|
380
383
|
centrality: importCentrality(entry, resolutionIndex),
|
|
381
384
|
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { loadState, mutateState } from './state.js';
|
|
18
|
+
import { detectDuplicates } from './duplicates.js';
|
|
18
19
|
import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
|
|
19
20
|
import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
|
|
20
21
|
import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
|
|
@@ -511,6 +512,19 @@ export function getEntity(name, idOrShortLabel, cwd) {
|
|
|
511
512
|
// ─── CREATE ────────────────────────────────────────────────────────────
|
|
512
513
|
export function createEntity(name, data, cwd) {
|
|
513
514
|
assertKnownEntity(name, 'create');
|
|
515
|
+
const proximityKinds = new Set(['decision', 'constraint', 'trap']);
|
|
516
|
+
const nearby = proximityKinds.has(name) && typeof data.text === 'string'
|
|
517
|
+
? detectDuplicates(data.text, name, loadState(cwd), listCandidates(undefined, cwd).filter((candidate) => candidate.status === 'pending')).slice(0, 3).map((match) => ({
|
|
518
|
+
id: match.id,
|
|
519
|
+
source: match.source,
|
|
520
|
+
reason: match.reason,
|
|
521
|
+
preview: match.text.length > 160 ? `${match.text.slice(0, 157)}…` : match.text,
|
|
522
|
+
}))
|
|
523
|
+
: [];
|
|
524
|
+
const result = (base) => ({
|
|
525
|
+
...base,
|
|
526
|
+
...(nearby.length ? { nearby_items: nearby } : {}),
|
|
527
|
+
});
|
|
514
528
|
switch (name) {
|
|
515
529
|
case 'plan': {
|
|
516
530
|
// Explicit field whitelist + required-author check brings plan create in line
|
|
@@ -530,7 +544,7 @@ export function createEntity(name, data, cwd) {
|
|
|
530
544
|
estimatedEffort: data.estimated_effort,
|
|
531
545
|
}, cwd);
|
|
532
546
|
stampProvenanceOnStateItem('plan', res.id, defaultProvenance(data), cwd);
|
|
533
|
-
return { entity: name, id: res.id, short_label: res.shortLabel };
|
|
547
|
+
return result({ entity: name, id: res.id, short_label: res.shortLabel });
|
|
534
548
|
}
|
|
535
549
|
case 'decision': {
|
|
536
550
|
const res = createDecision({
|
|
@@ -542,7 +556,7 @@ export function createEntity(name, data, cwd) {
|
|
|
542
556
|
planId: data.plan_id,
|
|
543
557
|
}, cwd);
|
|
544
558
|
stampProvenanceOnStateItem('decision', res.id, defaultProvenance(data), cwd);
|
|
545
|
-
return { entity: name, id: res.id, short_label: res.shortLabel };
|
|
559
|
+
return result({ entity: name, id: res.id, short_label: res.shortLabel });
|
|
546
560
|
}
|
|
547
561
|
case 'constraint': {
|
|
548
562
|
const res = createConstraint({
|
|
@@ -553,7 +567,7 @@ export function createEntity(name, data, cwd) {
|
|
|
553
567
|
relatedPaths: data.related_paths,
|
|
554
568
|
}, cwd);
|
|
555
569
|
stampProvenanceOnStateItem('constraint', res.id, defaultProvenance(data), cwd);
|
|
556
|
-
return { entity: name, id: res.id, short_label: res.shortLabel };
|
|
570
|
+
return result({ entity: name, id: res.id, short_label: res.shortLabel });
|
|
557
571
|
}
|
|
558
572
|
case 'trap': {
|
|
559
573
|
const res = createTrap({
|
|
@@ -564,7 +578,7 @@ export function createEntity(name, data, cwd) {
|
|
|
564
578
|
relatedPaths: data.related_paths,
|
|
565
579
|
}, cwd);
|
|
566
580
|
stampProvenanceOnStateItem('trap', res.id, defaultProvenance(data), cwd);
|
|
567
|
-
return { entity: name, id: res.id, short_label: res.shortLabel };
|
|
581
|
+
return result({ entity: name, id: res.id, short_label: res.shortLabel });
|
|
568
582
|
}
|
|
569
583
|
case 'runtime_note': {
|
|
570
584
|
const id = generateId('runtime_note');
|
|
@@ -197,13 +197,27 @@ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = pr
|
|
|
197
197
|
args.splice(insertAt, 0, '--cd', worktreePath);
|
|
198
198
|
const quote = (value) => isWin32
|
|
199
199
|
? `"${value.replace(/"/g, '""')}"`
|
|
200
|
-
: `
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
200
|
+
: `"${value.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/`/g, '\\`').replace(/"/g, '\\"')}"`;
|
|
201
|
+
// Re-render from structured argv. bashCommand also contains the model-authored
|
|
202
|
+
// prompt, so searching it for "| codex" can mistake prompt data for the real
|
|
203
|
+
// executable boundary and splice --cd inside quoted content.
|
|
204
|
+
const command = [invoke.executable, ...args.map(quote)].join(' ');
|
|
205
|
+
const prompt = invoke.promptText ?? '';
|
|
206
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
|
207
|
+
let bashCommand = command;
|
|
208
|
+
if (!isWin32 && invoke.promptDelivery === 'stdin_pipe') {
|
|
209
|
+
bashCommand = `printf '%s' '${escapedPrompt}' | ${command}`;
|
|
210
|
+
}
|
|
211
|
+
else if (!isWin32 && invoke.promptDelivery === 'temp_file' && invoke.tempFilePath) {
|
|
212
|
+
bashCommand = `printf '%s' '${escapedPrompt}' > ${quote(invoke.tempFilePath)} && ${command}`;
|
|
213
|
+
}
|
|
214
|
+
return { ...invoke, args, bashCommand };
|
|
215
|
+
}
|
|
216
|
+
/** Refuse a stdin-delivery invoke that would otherwise inherit `/dev/null`. */
|
|
217
|
+
export function assertPromptDelivery(invoke) {
|
|
218
|
+
if (invoke.promptDelivery === 'stdin_pipe' && !invoke.promptText?.trim()) {
|
|
219
|
+
throw new Error('Invalid stdin_pipe invocation: promptText is empty; refusing to spawn an agent with ignored stdin.');
|
|
220
|
+
}
|
|
207
221
|
}
|
|
208
222
|
export class CliExecutionAdapter {
|
|
209
223
|
id = 'cli';
|
|
@@ -273,6 +287,7 @@ export class CliExecutionAdapter {
|
|
|
273
287
|
};
|
|
274
288
|
}
|
|
275
289
|
start(invoke, options) {
|
|
290
|
+
assertPromptDelivery(invoke);
|
|
276
291
|
const isWin32 = process.platform === 'win32';
|
|
277
292
|
invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
|
|
278
293
|
// F7 (trp_0e5150d3): route worker env through buildWorkerIdentityEnv so the
|
|
@@ -309,7 +324,7 @@ export class CliExecutionAdapter {
|
|
|
309
324
|
}
|
|
310
325
|
const spawnExecutable = resolvedExecutable ?? invoke.executable;
|
|
311
326
|
const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
|
|
312
|
-
const needsStdin = invoke.promptDelivery === 'stdin_pipe'
|
|
327
|
+
const needsStdin = invoke.promptDelivery === 'stdin_pipe';
|
|
313
328
|
// pln#520 step 4: when we ack-wrap, the SHELL redirects stdout/stderr to the
|
|
314
329
|
// per-assignment log files (fds passed via stdio are NOT inherited through
|
|
315
330
|
// the cmd.exe → .cmd → node shim — the empty-logs bug of can_f792cacd), and
|
|
@@ -25,7 +25,8 @@ import { logger } from './logger.js';
|
|
|
25
25
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
26
|
export const DEFAULT_HYGIENE_POLICY = {
|
|
27
27
|
disabled: false,
|
|
28
|
-
|
|
28
|
+
// A never-accepted dispatch should not survive into the next day's dogfood.
|
|
29
|
+
assignment_offered_ttl_ms: 1 * DAY_MS,
|
|
29
30
|
assignment_accepted_ttl_ms: 1 * DAY_MS,
|
|
30
31
|
assignment_started_ttl_ms: 1 * DAY_MS,
|
|
31
32
|
handoff_closed_ttl_ms: 30 * DAY_MS,
|
|
@@ -10,7 +10,7 @@ import { resolveModel } from './agent-capability.js';
|
|
|
10
10
|
import { listAgentIdentities } from './agent-registry.js';
|
|
11
11
|
import { transitionAgentRun } from './agentruns.js';
|
|
12
12
|
import { loadAssignment, patchAssignmentMessageId, transitionAssignment } from './assignments.js';
|
|
13
|
-
import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, } from './claims.js';
|
|
13
|
+
import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, releaseClaimIfActive, } from './claims.js';
|
|
14
14
|
import { generateDispatchBrief } from './dispatcher.js';
|
|
15
15
|
import { search } from './search.js';
|
|
16
16
|
import { attemptExecution } from './execution.js';
|
|
@@ -21,6 +21,7 @@ import { buildIdeationBrief } from './loops/brief-assembly.js';
|
|
|
21
21
|
import { getLoop } from './loops/store.js';
|
|
22
22
|
import { prepareTurnExecution } from './loops/turn-execution.js';
|
|
23
23
|
import { sendMessage } from './messaging.js';
|
|
24
|
+
import { removeWorktree } from './worktree.js';
|
|
24
25
|
export async function dispatchLoopTurn(input) {
|
|
25
26
|
const loop = getLoop(input.loop_id, input.cwd);
|
|
26
27
|
if (!loop)
|
|
@@ -131,6 +132,22 @@ export async function dispatchLoopTurn(input) {
|
|
|
131
132
|
if (prepared.kind !== 'won') {
|
|
132
133
|
result.execution_status = 'inbox_only';
|
|
133
134
|
result.error = prepared.reason;
|
|
135
|
+
if (prepared.claim_disposition === 'release' && !claim.reusedExisting) {
|
|
136
|
+
try {
|
|
137
|
+
const released = releaseClaimIfActive(claim.claimId, input.cwd);
|
|
138
|
+
if (released.released && claim.worktreePath) {
|
|
139
|
+
try {
|
|
140
|
+
removeWorktree(input.cwd, claim.worktreePath, { force: true });
|
|
141
|
+
}
|
|
142
|
+
catch (cleanupError) {
|
|
143
|
+
result.error += `; denied-claim worktree cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (cleanupError) {
|
|
148
|
+
result.error += `; denied-claim cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
134
151
|
return result;
|
|
135
152
|
}
|
|
136
153
|
result.assignment_id = prepared.assignment_id;
|
|
@@ -219,6 +219,7 @@ export function executionContractForGeneration(reservation, generation) {
|
|
|
219
219
|
}
|
|
220
220
|
return { contract, ref };
|
|
221
221
|
}
|
|
222
|
+
const snapshot = generation.executor?.capability_snapshot ?? reservation.capability_snapshot;
|
|
222
223
|
const contract = ExecutionContractSchema.parse({
|
|
223
224
|
...reservation.execution_contract,
|
|
224
225
|
identity: {
|
|
@@ -234,7 +235,7 @@ export function executionContractForGeneration(reservation, generation) {
|
|
|
234
235
|
isolation: 'worktree',
|
|
235
236
|
},
|
|
236
237
|
});
|
|
237
|
-
const ref = executionContractRef(contract,
|
|
238
|
+
const ref = executionContractRef(contract, snapshot);
|
|
238
239
|
if (ref.hash !== generation.contract_hash) {
|
|
239
240
|
throw new AttemptGenerationError('fenced', `generation ${generation.attempt_epoch} contract hash ${generation.contract_hash} does not match derived ${ref.hash}`);
|
|
240
241
|
}
|
|
@@ -269,6 +270,12 @@ export function bootstrapAttemptAuthorityV2(input) {
|
|
|
269
270
|
workspace_path: workspacePath,
|
|
270
271
|
workspace_digest: attemptWorkspaceDigest(workspacePath, reservation.turn_id, 0),
|
|
271
272
|
launch_nonce: grant.token,
|
|
273
|
+
executor: {
|
|
274
|
+
agent: reservation.agent,
|
|
275
|
+
agent_id: reservation.agent_id,
|
|
276
|
+
claim_id: reservation.claim_id,
|
|
277
|
+
capability_snapshot: reservation.capability_snapshot,
|
|
278
|
+
},
|
|
272
279
|
});
|
|
273
280
|
if (initial.assignment_id !== reservation.child_ids.assignment_id
|
|
274
281
|
|| initial.run_id !== reservation.child_ids.run_id
|
|
@@ -331,7 +338,8 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
331
338
|
&& close.decision === expectedMode
|
|
332
339
|
&& close.cause === requestedCause
|
|
333
340
|
&& canonicalWorkspacePath(close.next_generation.workspace_path) === canonicalWorkspacePath(input.next_workspace_path)
|
|
334
|
-
&& JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)
|
|
341
|
+
&& JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)
|
|
342
|
+
&& (!input.next_executor || JSON.stringify(close.next_generation.executor) === JSON.stringify(input.next_executor))) {
|
|
335
343
|
const generationContract = executionContractForGeneration(reservation, close.next_generation);
|
|
336
344
|
return {
|
|
337
345
|
won: false,
|
|
@@ -376,9 +384,10 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
376
384
|
contract_hash: '0'.repeat(64),
|
|
377
385
|
workspace_path: nextWorkspacePath,
|
|
378
386
|
workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
|
|
387
|
+
executor: input.next_executor,
|
|
379
388
|
});
|
|
380
389
|
const baseContract = reservation.execution_contract;
|
|
381
|
-
const snapshot = reservation.capability_snapshot;
|
|
390
|
+
const snapshot = input.next_executor?.capability_snapshot ?? reservation.capability_snapshot;
|
|
382
391
|
if (!baseContract || !snapshot) {
|
|
383
392
|
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} lacks a contracted capability snapshot`);
|
|
384
393
|
}
|
|
@@ -402,6 +411,7 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
402
411
|
contract_hash: nextRef.hash,
|
|
403
412
|
workspace_path: nextWorkspacePath,
|
|
404
413
|
workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
|
|
414
|
+
executor: input.next_executor,
|
|
405
415
|
launch_nonce: provisional.launch_nonce,
|
|
406
416
|
created_at: provisional.created_at,
|
|
407
417
|
});
|
|
@@ -419,7 +429,15 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
419
429
|
if (generationDigest(incumbent.next_generation) !== generationDigest(next)) {
|
|
420
430
|
throw new AttemptGenerationError('fenced', `a different successor already won generation ${current.attempt_epoch}`);
|
|
421
431
|
}
|
|
422
|
-
|
|
432
|
+
// The close cell above is the immutable authority boundary. `head.json` is
|
|
433
|
+
// only a rebuildable read cache, so a projection failure after a winning
|
|
434
|
+
// close must never escape as a pre-commit takeover failure: callers could
|
|
435
|
+
// otherwise roll back claims while the successor generation already owns
|
|
436
|
+
// the turn. Readers repair the cache from immutable cells on replay.
|
|
437
|
+
try {
|
|
438
|
+
rebuildAttemptGenerationHead(input.cwd, current);
|
|
439
|
+
}
|
|
440
|
+
catch { /* immutable close(epoch) remains authoritative */ }
|
|
423
441
|
return {
|
|
424
442
|
won: published.won,
|
|
425
443
|
rollout,
|