@detergent-software/conductor 2.2.0-dev.3 → 3.0.0-dev.10
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/CHANGELOG.md +98 -0
- package/README.md +86 -37
- package/build/broker-cli.js +19 -21
- package/build/broker-cli.js.map +1 -1
- package/build/broker.js +66 -92
- package/build/broker.js.map +1 -1
- package/build/checkout-sync.js +4 -21
- package/build/checkout-sync.js.map +1 -1
- package/build/claude.js +56 -9
- package/build/claude.js.map +1 -1
- package/build/cli.js +43 -46
- package/build/cli.js.map +1 -1
- package/build/code-host.js +10 -8
- package/build/code-host.js.map +1 -1
- package/build/config.js +10 -0
- package/build/config.js.map +1 -1
- package/build/console.js +39 -29
- package/build/console.js.map +1 -1
- package/build/daemon.js +55 -6
- package/build/daemon.js.map +1 -1
- package/build/help.js +19 -33
- package/build/help.js.map +1 -1
- package/build/in-process.js +3 -3
- package/build/in-process.js.map +1 -1
- package/build/inbox.js +47 -4
- package/build/inbox.js.map +1 -1
- package/build/lib/providers/index.js +1 -1
- package/build/lib/providers/index.js.map +1 -1
- package/build/lib/providers/npm/stage.js +14 -4
- package/build/lib/providers/npm/stage.js.map +1 -1
- package/build/local-files.js +95 -0
- package/build/local-files.js.map +1 -0
- package/build/manager.js +215 -27
- package/build/manager.js.map +1 -1
- package/build/npm-reads.js +194 -0
- package/build/npm-reads.js.map +1 -0
- package/build/paths.js +2 -0
- package/build/paths.js.map +1 -1
- package/build/plugin.js +146 -0
- package/build/plugin.js.map +1 -0
- package/build/profile.js +60 -21
- package/build/profile.js.map +1 -1
- package/build/project-check-cli.js +4 -1
- package/build/project-check-cli.js.map +1 -1
- package/build/project-check.js +89 -30
- package/build/project-check.js.map +1 -1
- package/build/rules-service.js +22 -4
- package/build/rules-service.js.map +1 -1
- package/build/scheduler.js +11 -1
- package/build/scheduler.js.map +1 -1
- package/build/server.js +8 -16
- package/build/server.js.map +1 -1
- package/build/spend.js +4 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +11 -10
- package/build/store.js.map +1 -1
- package/build/tracker-reads-cli.js +2 -4
- package/build/tracker-reads-cli.js.map +1 -1
- package/build/upgrade.js +25 -3
- package/build/upgrade.js.map +1 -1
- package/build/watchers/ado.js +3 -2
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/github.js +3 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/npm.js +6 -4
- package/build/watchers/npm.js.map +1 -1
- package/build/worktrees.js +182 -38
- package/build/worktrees.js.map +1 -1
- package/package.json +9 -9
- package/plugin/.claude-plugin/plugin.json +7 -0
- package/plugin/README.md +18 -0
- package/{mod → plugin}/hooks/model.ts +22 -4
- package/{mod → plugin}/hooks/register.tsx +63 -17
- package/plugin/skills/conductor-console/SKILL.md +94 -0
- package/plugin/skills/create-verification-skill/LICENSE +21 -0
- package/plugin/skills/create-verification-skill/README.md +9 -0
- package/plugin/skills/create-verification-skill/SKILL.md +44 -0
- package/plugin/skills/create-verification-skill/manifest.json +27 -0
- package/plugin/skills/create-verification-skill/references/feature-map-example/README.md +47 -0
- package/plugin/skills/create-verification-skill/references/feature-map-example/create-note.md +39 -0
- package/plugin/skills/create-verification-skill/references/feature-map-example/search.md +45 -0
- package/plugin/skills/maintain-verification-skill/LICENSE +21 -0
- package/plugin/skills/maintain-verification-skill/README.md +9 -0
- package/plugin/skills/maintain-verification-skill/SKILL.md +39 -0
- package/plugin/skills/maintain-verification-skill/manifest.json +23 -0
- package/plugin/skills/work-item/SKILL.md +253 -0
- package/plugin/skills/work-item/trackers/ado-boards.md +23 -0
- package/plugin/skills/work-item/trackers/github-issues.md +23 -0
- package/plugin/skills/work-item/trackers/linear.md +21 -0
- package/protocol/PROTOCOL.md +1 -1
- package/build/mod.js +0 -53
- package/build/mod.js.map +0 -1
- package/mod/.claude-plugin/marketplace.json +0 -6
- package/mod/.claude-plugin/plugin.json +0 -7
- package/mod/README.md +0 -19
- /package/{mod → plugin}/hooks/hooks.json +0 -0
- /package/{mod → plugin}/types/index.d.ts +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { cp } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, matchesGlob, relative } from 'node:path';
|
|
4
|
+
import { ConductorError } from "./errors.js";
|
|
5
|
+
import { outputOf } from "./run.js";
|
|
6
|
+
export const INCLUDE_FILE = '.worktreeinclude';
|
|
7
|
+
// Copies the source checkout's included files into `target`; returns their paths, relative and with
|
|
8
|
+
// forward slashes. Git lists ignored paths with whole ignored directories collapsed, which keeps
|
|
9
|
+
// node_modules out of the walk. A pattern without a slash (".env*") matches at any depth but not
|
|
10
|
+
// inside an ignored directory; an anchored one ("apps/api/secrets/*.json") also reaches into one.
|
|
11
|
+
export async function copyLocalFiles(git, source, target) {
|
|
12
|
+
if (!existsSync(join(source, '.git')))
|
|
13
|
+
throw new ConductorError(`localFilesFrom ${source} is not a git checkout`, [
|
|
14
|
+
"set the profile's localFilesFrom to a checkout of the project you keep",
|
|
15
|
+
]);
|
|
16
|
+
const patterns = includePatterns(join(source, INCLUDE_FILE));
|
|
17
|
+
if (patterns.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const listed = await git(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], source);
|
|
20
|
+
if (listed.code !== 0)
|
|
21
|
+
throw new ConductorError(`git couldn't list the ignored files in ${source}`, [outputOf(listed, 500)]);
|
|
22
|
+
const copied = [];
|
|
23
|
+
// Asynchronous, so an included directory of any size doesn't hold up the daemon.
|
|
24
|
+
const copy = async (rel) => {
|
|
25
|
+
const to = join(target, rel);
|
|
26
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
27
|
+
await cp(join(source, rel), to, { recursive: true });
|
|
28
|
+
copied.push(rel);
|
|
29
|
+
};
|
|
30
|
+
// Git can list a directory and also entries inside it (apps/, apps/api/, ...), parents first, so
|
|
31
|
+
// anything under a directory already copied or walked is skipped.
|
|
32
|
+
const handled = ['.claude/worktrees'];
|
|
33
|
+
for (const entry of listed.stdout.split('\0').filter(Boolean)) {
|
|
34
|
+
const isDir = entry.endsWith('/');
|
|
35
|
+
const rel = isDir ? entry.slice(0, -1) : entry;
|
|
36
|
+
if (handled.some((dir) => rel === dir || rel.startsWith(`${dir}/`)))
|
|
37
|
+
continue;
|
|
38
|
+
if (isIncluded(patterns, rel, isDir))
|
|
39
|
+
await copy(rel);
|
|
40
|
+
else if (isDir)
|
|
41
|
+
for (const file of walkAnchored(patterns, source, rel))
|
|
42
|
+
await copy(file);
|
|
43
|
+
if (isDir)
|
|
44
|
+
handled.push(rel);
|
|
45
|
+
}
|
|
46
|
+
return copied;
|
|
47
|
+
}
|
|
48
|
+
export function includePatterns(file) {
|
|
49
|
+
if (!existsSync(file))
|
|
50
|
+
return [];
|
|
51
|
+
return readFileSync(file, 'utf8')
|
|
52
|
+
.split(/\r?\n/)
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
55
|
+
.map((line) => {
|
|
56
|
+
const negated = line.startsWith('!');
|
|
57
|
+
let pattern = negated ? line.slice(1) : line;
|
|
58
|
+
const dirOnly = pattern.endsWith('/');
|
|
59
|
+
if (dirOnly)
|
|
60
|
+
pattern = pattern.slice(0, -1);
|
|
61
|
+
const anchored = pattern.includes('/');
|
|
62
|
+
if (pattern.startsWith('/'))
|
|
63
|
+
pattern = pattern.slice(1);
|
|
64
|
+
return { anchored, dirOnly, glob: anchored ? pattern : `**/${pattern}`, negated };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function isIncluded(patterns, rel, isDir) {
|
|
68
|
+
const parts = rel.split('/');
|
|
69
|
+
for (let i = 1; i < parts.length; i++)
|
|
70
|
+
if (matchesLast(patterns, parts.slice(0, i).join('/'), true))
|
|
71
|
+
return true;
|
|
72
|
+
return matchesLast(patterns, rel, isDir);
|
|
73
|
+
}
|
|
74
|
+
function matchesLast(patterns, rel, isDir) {
|
|
75
|
+
let result = false;
|
|
76
|
+
for (const p of patterns) {
|
|
77
|
+
if (p.dirOnly && !isDir)
|
|
78
|
+
continue;
|
|
79
|
+
if (matchesGlob(rel, p.glob))
|
|
80
|
+
result = !p.negated;
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
function* walkAnchored(patterns, source, dir) {
|
|
85
|
+
if (!patterns.some((p) => p.anchored && !p.negated && p.glob.startsWith(`${dir}/`)))
|
|
86
|
+
return;
|
|
87
|
+
for (const entry of readdirSync(join(source, dir), { recursive: true, withFileTypes: true })) {
|
|
88
|
+
if (!entry.isFile())
|
|
89
|
+
continue;
|
|
90
|
+
const rel = relative(source, join(entry.parentPath, entry.name)).split('\\').join('/');
|
|
91
|
+
if (isIncluded(patterns, rel, false))
|
|
92
|
+
yield rel;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=local-files.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-files.js","sourceRoot":"","sources":["../src/local-files.ts"],"names":[],"mappings":"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"}
|
package/build/manager.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { freePorts, releaseLocks } from "./broker.js";
|
|
5
|
-
import { isLive, isStarting, permissionPrompt, SessionGone, turnEnded, } from "./claude.js";
|
|
4
|
+
import { freePorts, portEnv, releaseLocks } from "./broker.js";
|
|
5
|
+
import { isLive, isStarting, permissionPrompt, SessionGone, trustStep, turnEnded, WorkspaceUntrusted, } from "./claude.js";
|
|
6
6
|
import { formatCondition, parseCondition, prRef, uncheckable } from "./conditions.js";
|
|
7
7
|
import { ConductorError } from "./errors.js";
|
|
8
8
|
import { UNWATCHED_HANDOFFS } from "./inbox.js";
|
|
9
9
|
import { processAlive as defaultProcessAlive } from "./lock.js";
|
|
10
|
+
import { missingPluginSkills, projectSkills } from "./plugin.js";
|
|
10
11
|
import { WAKE_PREFIX } from "./protocol.js";
|
|
11
12
|
import { resolveOpenWait } from "./resolve.js";
|
|
12
13
|
import { findTranscript, formatTokens, lastTurnAt, localDay, projectSlug, readUsage, transcriptFiles, } from "./spend.js";
|
|
13
14
|
import { ACTIVE_STATUSES, openStore, waitSchema, workerSchema, } from "./store.js";
|
|
15
|
+
import { linkedPr } from "./tracker-writes.js";
|
|
14
16
|
import { pendingCall } from "./transcript.js";
|
|
17
|
+
import { BRANCH_PREFIX, worktreePath } from "./worktrees.js";
|
|
15
18
|
// A worker as GET /fleet shows it.
|
|
16
19
|
export const fleetWorkerSchema = workerSchema.extend({
|
|
17
20
|
// launching, starting, working, idle, waiting on <x>, parked, stopped, no session
|
|
@@ -85,11 +88,17 @@ export const PROBE_PREFIX = 'probe-';
|
|
|
85
88
|
export const PROBE_PROMPT = 'This is a conductor probe session started by a test run. It works no issue: reply "probe ready" and wait.';
|
|
86
89
|
// conduct-atk starts workers this way; a resume keeps both (ATK-309's open check).
|
|
87
90
|
export const PERMISSION_MODE = 'auto';
|
|
91
|
+
// How long a started or woken worker's session has to report its plugin. A start runs the create
|
|
92
|
+
// hook first only for a launch, which is before the clock starts.
|
|
93
|
+
export const PLUGIN_REPORT_MS = 3 * 60_000;
|
|
88
94
|
export const DISALLOWED_TOOLS = ['EnterPlanMode', 'ExitPlanMode'];
|
|
89
95
|
const ITEM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
90
96
|
const STOP_WAIT_MS = 30_000;
|
|
91
97
|
// A park that failed (the process outlived `claude stop`) is tried again this much later.
|
|
92
98
|
const PARK_RETRY_MS = 5 * 60_000;
|
|
99
|
+
// How long a new session id has to stay listed before the worker follows it. A resuming session is
|
|
100
|
+
// listed for under a second under the id its process started with, before it loads its own (ATK-574).
|
|
101
|
+
const SESSION_ID_SETTLE_MS = 5_000;
|
|
93
102
|
const TRANSCRIPT_SCAN_MS = 60_000;
|
|
94
103
|
// A compaction with no boundary in the transcript this long after it started, its turn over, failed.
|
|
95
104
|
const COMPACT_MINUTES = 10;
|
|
@@ -112,7 +121,7 @@ export function eventText(waits) {
|
|
|
112
121
|
return `${WAKE_PREFIX}${[...facts].map(([fact, at]) => `${fact} ${clock(new Date(at))}`).join('; ')}`;
|
|
113
122
|
}
|
|
114
123
|
export function sessionManager(deps) {
|
|
115
|
-
const { claude, config, log, openTab, store, worktrees } = deps;
|
|
124
|
+
const { broker, claude, config, log, openTab, pluginDir, prs, store, worktrees } = deps;
|
|
116
125
|
const now = deps.now ?? (() => new Date());
|
|
117
126
|
const alive = deps.processAlive ?? defaultProcessAlive;
|
|
118
127
|
const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
@@ -122,6 +131,8 @@ export function sessionManager(deps) {
|
|
|
122
131
|
const stalls = new Map();
|
|
123
132
|
const transcripts = new Map();
|
|
124
133
|
const transcriptScans = new Map();
|
|
134
|
+
// worker id → a session id the listing shows for it that it hasn't followed yet, since when
|
|
135
|
+
const newSessionIds = new Map();
|
|
125
136
|
let sessions = [];
|
|
126
137
|
// Until the first sweep, nothing is known about any session.
|
|
127
138
|
let listed = false;
|
|
@@ -132,6 +143,12 @@ export function sessionManager(deps) {
|
|
|
132
143
|
const held = new Set();
|
|
133
144
|
// Workers whose compaction failed: not tried again while the daemon runs (their inbox item says so).
|
|
134
145
|
const uncompactable = new Set();
|
|
146
|
+
// Every session the conductor starts loads its plugin, and a resume whose saved --plugin-dir went
|
|
147
|
+
// missing runs on without it, silently (ATK-522). The mod reports each session start (pluginSeen),
|
|
148
|
+
// so a worker started or woken that doesn't report within PLUGIN_REPORT_MS is a
|
|
149
|
+
// `worker.plugin-missing`. In memory: a daemon restart forgets the starts it didn't see.
|
|
150
|
+
const pluginDue = new Map(); // worker id → when its session was last (re)started
|
|
151
|
+
const pluginReports = new Map(); // session id → its plugin's newest report
|
|
135
152
|
const event = (worker, kind, data = {}, rule = null) => {
|
|
136
153
|
const entry = {
|
|
137
154
|
data: { name: worker.name, worker: worker.id, ...data },
|
|
@@ -142,6 +159,48 @@ export function sessionManager(deps) {
|
|
|
142
159
|
};
|
|
143
160
|
store.appendEvent(entry);
|
|
144
161
|
};
|
|
162
|
+
// Its session was (re)started at `since`, before the claude call: its report must come after.
|
|
163
|
+
// Only a worker launched with the plugin keeps it in its saved options: one launched before it (by
|
|
164
|
+
// an older daemon, with workers in flight across an upgrade) never loads it and works on without.
|
|
165
|
+
const expectPlugin = (worker, since) => {
|
|
166
|
+
if (launchedWithPlugin(worker))
|
|
167
|
+
pluginDue.set(worker.id, since);
|
|
168
|
+
};
|
|
169
|
+
const withPlugin = new Map();
|
|
170
|
+
const launchedWithPlugin = (worker) => {
|
|
171
|
+
let known = withPlugin.get(worker.id);
|
|
172
|
+
if (known === undefined) {
|
|
173
|
+
known = store
|
|
174
|
+
.workerEvents(worker.id, 1000)
|
|
175
|
+
.some((e) => e.kind === 'worker.launched' && typeof e.data.pluginDir === 'string');
|
|
176
|
+
withPlugin.set(worker.id, known);
|
|
177
|
+
}
|
|
178
|
+
return known;
|
|
179
|
+
};
|
|
180
|
+
// A worker whose session hasn't reported its plugin within PLUGIN_REPORT_MS of its start, once per
|
|
181
|
+
// start. One with no session id yet waits for it: reports are matched by session.
|
|
182
|
+
const checkPlugins = () => {
|
|
183
|
+
const at = now().getTime();
|
|
184
|
+
for (const [id, since] of pluginDue) {
|
|
185
|
+
const worker = store.getWorker(id);
|
|
186
|
+
if (!worker || !ACTIVE_STATUSES.includes(worker.status)) {
|
|
187
|
+
pluginDue.delete(id);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (worker.sessionId === null)
|
|
191
|
+
continue;
|
|
192
|
+
if ((pluginReports.get(worker.sessionId) ?? -1) >= since) {
|
|
193
|
+
pluginDue.delete(id);
|
|
194
|
+
}
|
|
195
|
+
else if (at - since >= PLUGIN_REPORT_MS) {
|
|
196
|
+
pluginDue.delete(id);
|
|
197
|
+
event(worker, 'worker.plugin-missing', { minutes: Math.round((at - since) / 60_000), plugin: pluginDir });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const [session, reported] of pluginReports)
|
|
201
|
+
if (at - reported > PLUGIN_REPORT_MS * 10)
|
|
202
|
+
pluginReports.delete(session);
|
|
203
|
+
};
|
|
145
204
|
// Whether the rules let it park or resume now; a refusal is logged once until the rules allow it.
|
|
146
205
|
const decide = (verb, worker, waits) => {
|
|
147
206
|
const decision = policy(verb, worker, waits);
|
|
@@ -214,24 +273,56 @@ export function sessionManager(deps) {
|
|
|
214
273
|
}
|
|
215
274
|
};
|
|
216
275
|
// ---------- launch ----------
|
|
276
|
+
// The worktree first (design note v2, section 2): its ports from the broker, then `git worktree add`
|
|
277
|
+
// from origin/<base>, the local files and the profile's setup. The session starts in it.
|
|
217
278
|
const launchWorker = async (worker, root, profile, prompt) => {
|
|
218
279
|
let current = worker;
|
|
219
|
-
let stage = '
|
|
280
|
+
let stage = 'assign ports';
|
|
281
|
+
const path = worktreePath(root, worker.name);
|
|
282
|
+
// A worktree there already (a kept one, or one a person made with `claude --worktree`) isn't
|
|
283
|
+
// this launch's to remove when it fails.
|
|
284
|
+
const reused = existsSync(join(path, '.git'));
|
|
220
285
|
try {
|
|
221
|
-
|
|
286
|
+
// Recorded before it exists: the broker keeps an in-flight worker's ports while its directory
|
|
287
|
+
// isn't there yet, and a create that fails part way is cleaned up.
|
|
288
|
+
current = update(current, { worktree: path });
|
|
289
|
+
const { ports } = await broker.assignPorts(path, worker.project, profile.ports);
|
|
290
|
+
stage = 'create worktree';
|
|
291
|
+
const env = portEnv(ports);
|
|
292
|
+
const worktree = await worktrees.create({
|
|
293
|
+
base: profile.baseBranch,
|
|
294
|
+
env: { ...env, CONDUCTOR_ITEM: worker.item, CONDUCTOR_PROJECT: worker.project, CONDUCTOR_WORKTREE: path },
|
|
295
|
+
...(profile.localFilesFrom === undefined ? {} : { localFilesFrom: profile.localFilesFrom }),
|
|
296
|
+
name: worker.name,
|
|
297
|
+
root,
|
|
298
|
+
sessionEnv: env,
|
|
299
|
+
setup: profile.setup,
|
|
300
|
+
});
|
|
222
301
|
current = update(current, { branch: await worktrees.branchOf(worktree), worktree });
|
|
223
302
|
stage = 'check skills';
|
|
224
|
-
const
|
|
303
|
+
const skills = [profile.skills.work, profile.skills.verify];
|
|
304
|
+
const problem = worktrees.missingSkills(worktree, projectSkills(skills)) ?? missingPluginSkills(pluginDir, skills);
|
|
225
305
|
if (problem)
|
|
226
306
|
throw new ConductorError(problem);
|
|
227
307
|
stage = 'start session';
|
|
228
|
-
const
|
|
229
|
-
|
|
308
|
+
const started = now().getTime();
|
|
309
|
+
const handle = await claude
|
|
310
|
+
.launch({
|
|
311
|
+
cwd: worktree,
|
|
230
312
|
disallowedTools: DISALLOWED_TOOLS,
|
|
231
313
|
name: worker.name,
|
|
232
314
|
permissionMode: PERMISSION_MODE,
|
|
315
|
+
pluginDir,
|
|
233
316
|
prompt,
|
|
234
|
-
|
|
317
|
+
})
|
|
318
|
+
.catch((error) => {
|
|
319
|
+
// Claude Code's own message names the worktree, which this failed launch removes.
|
|
320
|
+
if (!(error instanceof WorkspaceUntrusted))
|
|
321
|
+
throw error;
|
|
322
|
+
throw new ConductorError(`Claude Code doesn't trust ${root}`, [
|
|
323
|
+
`${trustStep(root)}, then \`conduct project check ${worker.project}\``,
|
|
324
|
+
...error.details,
|
|
325
|
+
]);
|
|
235
326
|
});
|
|
236
327
|
try {
|
|
237
328
|
sessions = await claude.agents();
|
|
@@ -242,10 +333,11 @@ export function sessionManager(deps) {
|
|
|
242
333
|
}
|
|
243
334
|
const session = sessions.find((s) => s.id === handle);
|
|
244
335
|
current = update(current, { handle, sessionId: session?.sessionId ?? null, status: 'running' });
|
|
245
|
-
event(current, 'worker.launched', { handle, sessionId: current.sessionId, worktree });
|
|
336
|
+
event(current, 'worker.launched', { handle, pluginDir, sessionId: current.sessionId, worktree });
|
|
337
|
+
expectPlugin(current, started);
|
|
246
338
|
if (profile.workers.tab) {
|
|
247
339
|
const tab = await openTab({
|
|
248
|
-
cwd:
|
|
340
|
+
cwd: worktree,
|
|
249
341
|
handle,
|
|
250
342
|
title: worker.name,
|
|
251
343
|
window: `${worker.probe ? 'probe' : 'Conductor'} · ${worker.project}`,
|
|
@@ -256,24 +348,62 @@ export function sessionManager(deps) {
|
|
|
256
348
|
}
|
|
257
349
|
catch (error) {
|
|
258
350
|
// A worktree with no session would read as in flight; one with work in it is kept.
|
|
259
|
-
const cleanup =
|
|
351
|
+
const cleanup = reused
|
|
352
|
+
? { kept: path, keptBecause: 'it was there before the launch' }
|
|
353
|
+
: current.worktree && existsSync(current.worktree)
|
|
354
|
+
? await removeIfClean(root, current.worktree)
|
|
355
|
+
: {};
|
|
356
|
+
freePorts(store, path, 'launch failed');
|
|
260
357
|
current = update(current, { status: 'failed' });
|
|
261
358
|
event(current, 'worker.launch-failed', { error: messageOf(error), step: stage, ...cleanup });
|
|
262
359
|
log.log('warn', 'launch failed', { error: messageOf(error), step: stage, worker: worker.id });
|
|
263
360
|
}
|
|
264
361
|
};
|
|
362
|
+
// A failed launch's worktree, unless it holds work. Its branch goes with it while it still has the
|
|
363
|
+
// name the launch gave it; a renamed one was the worker's.
|
|
265
364
|
const removeIfClean = async (root, worktree) => {
|
|
266
365
|
try {
|
|
267
366
|
const work = await worktrees.unfinishedWork(worktree);
|
|
268
367
|
if (work)
|
|
269
368
|
return { kept: worktree, ...work };
|
|
270
|
-
await worktrees.
|
|
369
|
+
const branch = await worktrees.branchOf(worktree);
|
|
370
|
+
await worktrees.remove(root, worktree, { deleteBranch: branch?.startsWith(BRANCH_PREFIX) === true });
|
|
271
371
|
return { removed: worktree };
|
|
272
372
|
}
|
|
273
373
|
catch (error) {
|
|
274
374
|
return { removeError: messageOf(error) };
|
|
275
375
|
}
|
|
276
376
|
};
|
|
377
|
+
// Whether retiring the worker removes its worktree and branch: once its PR has merged, and for a
|
|
378
|
+
// probe or a forced retire. `unread`: its PR couldn't be read, so retire waits.
|
|
379
|
+
const worktreeFate = async (worker, force) => {
|
|
380
|
+
const decided = (remove, reason) => ({ reason, remove, unread: null });
|
|
381
|
+
if (worker.worktree === null || !existsSync(worker.worktree))
|
|
382
|
+
return decided(true, 'no worktree');
|
|
383
|
+
if (worker.probe)
|
|
384
|
+
return decided(true, 'a probe');
|
|
385
|
+
if (force)
|
|
386
|
+
return decided(true, 'retire --force');
|
|
387
|
+
// With no PR and nothing uncommitted or unpushed, nothing in it is lost (a canceled item, a worker
|
|
388
|
+
// retired before its first commit): it goes, with no inbox item.
|
|
389
|
+
if (worker.pr === null)
|
|
390
|
+
return (await worktrees.unfinishedWork(worker.worktree)) === null
|
|
391
|
+
? decided(true, 'no PR and nothing unpushed')
|
|
392
|
+
: decided(false, 'it has no PR');
|
|
393
|
+
const profile = store.listProjects().find((p) => p.name === worker.project)?.profile;
|
|
394
|
+
if (!profile)
|
|
395
|
+
return { reason: '', remove: false, unread: `project ${worker.project} has no readable profile` };
|
|
396
|
+
// No read would ever answer for it, so waiting would refuse the retire for good.
|
|
397
|
+
if (linkedPr(worker.pr, profile) === null)
|
|
398
|
+
return decided(false, `its PR ${worker.pr} isn't one ${worker.project}'s code host reads`);
|
|
399
|
+
try {
|
|
400
|
+
const state = await prs(profile, worker.pr);
|
|
401
|
+
return decided(state === 'merged', `its PR ${worker.pr} is ${state}`);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
return { reason: '', remove: false, unread: messageOf(error) };
|
|
405
|
+
}
|
|
406
|
+
};
|
|
277
407
|
// ---------- the sweep ----------
|
|
278
408
|
// The clock: the sweep is its timer. The other kinds are the watchers'.
|
|
279
409
|
const resolveTimeWaits = () => {
|
|
@@ -325,6 +455,7 @@ export function sessionManager(deps) {
|
|
|
325
455
|
if (!decision.allowed)
|
|
326
456
|
return;
|
|
327
457
|
const text = eventText(resolved);
|
|
458
|
+
const started = now().getTime();
|
|
328
459
|
const { copy } = await claude.resume({
|
|
329
460
|
cwd: session?.cwd ?? worker.worktree ?? rootOf(worker),
|
|
330
461
|
sessionId: worker.sessionId,
|
|
@@ -343,6 +474,7 @@ export function sessionManager(deps) {
|
|
|
343
474
|
store.deliverWaits(resolved.map((w) => w.id));
|
|
344
475
|
const running = update(worker, { attachedAt: null, status: 'running', stoppedAt: null });
|
|
345
476
|
event(running, 'worker.resumed', { text, waits: resolved.map((w) => w.id) }, decision.rule);
|
|
477
|
+
expectPlugin(running, started);
|
|
346
478
|
};
|
|
347
479
|
// A resume of a session that still ran started a copy: the copy goes.
|
|
348
480
|
const discardCopy = async (copy) => {
|
|
@@ -370,6 +502,7 @@ export function sessionManager(deps) {
|
|
|
370
502
|
const before = worker.context;
|
|
371
503
|
// Before the resume: a small context can be compacted before it returns.
|
|
372
504
|
const since = now().toISOString();
|
|
505
|
+
const started = now().getTime();
|
|
373
506
|
let copy;
|
|
374
507
|
try {
|
|
375
508
|
({ copy } = await claude.resume({
|
|
@@ -394,6 +527,7 @@ export function sessionManager(deps) {
|
|
|
394
527
|
const running = update(worker, { status: 'running', stoppedAt: null });
|
|
395
528
|
stalls.set(worker.id, { compacting: { before, since } });
|
|
396
529
|
event(running, 'worker.compacting', { tokens: before }, decision.rule);
|
|
530
|
+
expectPlugin(running, started);
|
|
397
531
|
};
|
|
398
532
|
const compactFailed = (worker, error, tokens, rule = null) => {
|
|
399
533
|
uncompactable.add(worker.id);
|
|
@@ -518,14 +652,29 @@ export function sessionManager(deps) {
|
|
|
518
652
|
let current = worker;
|
|
519
653
|
const session = sessionOf(current);
|
|
520
654
|
// The listing is the truth for ids: fill them in after a launch, and follow a changed short id.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
655
|
+
// A changed session id is followed once it has stayed listed SESSION_ID_SETTLE_MS.
|
|
656
|
+
if (session) {
|
|
657
|
+
let sessionId = current.sessionId;
|
|
658
|
+
const pending = newSessionIds.get(current.id);
|
|
659
|
+
if (sessionId === null || session.sessionId === sessionId)
|
|
660
|
+
newSessionIds.delete(current.id);
|
|
661
|
+
else if (pending?.sessionId !== session.sessionId) {
|
|
662
|
+
newSessionIds.set(current.id, { sessionId: session.sessionId, since: now().getTime() });
|
|
663
|
+
}
|
|
664
|
+
else if (now().getTime() - pending.since >= SESSION_ID_SETTLE_MS) {
|
|
665
|
+
newSessionIds.delete(current.id);
|
|
666
|
+
// A new session id (a /clear) writes a new transcript.
|
|
524
667
|
transcripts.delete(current.id);
|
|
525
668
|
transcriptScans.delete(current.id);
|
|
669
|
+
sessionId = session.sessionId;
|
|
670
|
+
}
|
|
671
|
+
sessionId ??= session.sessionId;
|
|
672
|
+
if (sessionId !== current.sessionId || session.id !== current.handle) {
|
|
673
|
+
current = update(current, { handle: session.id, sessionId });
|
|
526
674
|
}
|
|
527
|
-
current = update(current, { handle: session.id, sessionId: session.sessionId });
|
|
528
675
|
}
|
|
676
|
+
else
|
|
677
|
+
newSessionIds.delete(current.id);
|
|
529
678
|
const waits = store.waitsOf(current.id);
|
|
530
679
|
const resolved = waits.filter((w) => w.resolvedAt !== null);
|
|
531
680
|
// A listed pid can outlive its process for a moment after a stop.
|
|
@@ -691,6 +840,7 @@ export function sessionManager(deps) {
|
|
|
691
840
|
busy.delete(id);
|
|
692
841
|
}
|
|
693
842
|
}
|
|
843
|
+
checkPlugins();
|
|
694
844
|
}
|
|
695
845
|
catch (error) {
|
|
696
846
|
log.log('error', 'sweep failed', { error: messageOf(error) });
|
|
@@ -713,8 +863,16 @@ export function sessionManager(deps) {
|
|
|
713
863
|
continue;
|
|
714
864
|
}
|
|
715
865
|
const root = rootOf(worker);
|
|
716
|
-
const worktree = worker.worktree ??
|
|
717
|
-
const
|
|
866
|
+
const worktree = worker.worktree ?? worktreePath(root, worker.name);
|
|
867
|
+
const reused = store
|
|
868
|
+
.workerEvents(worker.id, 1000)
|
|
869
|
+
.some((e) => e.kind === 'worker.launching' && e.data.reused === true);
|
|
870
|
+
const cleanup = reused
|
|
871
|
+
? { kept: worktree, keptBecause: 'it was there before the launch' }
|
|
872
|
+
: listing !== null && existsSync(worktree)
|
|
873
|
+
? await removeIfClean(root, worktree)
|
|
874
|
+
: {};
|
|
875
|
+
freePorts(store, worktree, 'launch failed');
|
|
718
876
|
store.closeWaits(worker.id, 'launch failed');
|
|
719
877
|
event(update(worker, { status: 'failed' }), 'worker.launch-failed', {
|
|
720
878
|
error: 'the daemon stopped during the launch',
|
|
@@ -776,6 +934,7 @@ export function sessionManager(deps) {
|
|
|
776
934
|
try {
|
|
777
935
|
let current = worker;
|
|
778
936
|
const resumed = current.status !== 'running';
|
|
937
|
+
let started = null;
|
|
779
938
|
if (resumed) {
|
|
780
939
|
const settle = current.stoppedAt === null
|
|
781
940
|
? 0
|
|
@@ -783,6 +942,7 @@ export function sessionManager(deps) {
|
|
|
783
942
|
if (settle > 0)
|
|
784
943
|
await sleep(settle);
|
|
785
944
|
// No event text: the session wakes idle and keeps running when the tab closes.
|
|
945
|
+
started = now().getTime();
|
|
786
946
|
const { copy } = await claude.resume({
|
|
787
947
|
cwd: sessionOf(current)?.cwd ?? current.worktree ?? rootOf(current),
|
|
788
948
|
sessionId,
|
|
@@ -797,12 +957,14 @@ export function sessionManager(deps) {
|
|
|
797
957
|
current = update(current, { attachedAt: now().toISOString(), status: 'running', stoppedAt: null });
|
|
798
958
|
forgetAttach(current.id);
|
|
799
959
|
const tab = await openTab({
|
|
800
|
-
cwd: rootOf(current),
|
|
960
|
+
cwd: current.worktree ?? rootOf(current),
|
|
801
961
|
handle,
|
|
802
962
|
title: current.name,
|
|
803
963
|
window: `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`,
|
|
804
964
|
});
|
|
805
965
|
event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
|
|
966
|
+
if (started !== null)
|
|
967
|
+
expectPlugin(current, started);
|
|
806
968
|
return { resumed, tab, worker: current };
|
|
807
969
|
}
|
|
808
970
|
finally {
|
|
@@ -831,14 +993,24 @@ export function sessionManager(deps) {
|
|
|
831
993
|
probe: request.probe === true,
|
|
832
994
|
project: target.name,
|
|
833
995
|
});
|
|
996
|
+
// A worktree there already isn't the launch's to remove if it fails, after a restart too.
|
|
997
|
+
const reused = existsSync(join(worktreePath(target.root, name), '.git'));
|
|
834
998
|
event(worker, 'worker.launching', {
|
|
835
999
|
probe: worker.probe,
|
|
1000
|
+
...(reused ? { reused } : {}),
|
|
836
1001
|
...(request.cause === undefined ? {} : { cause: request.cause }),
|
|
837
1002
|
});
|
|
838
1003
|
busy.add(worker.id);
|
|
839
1004
|
void track(launchWorker(worker, target.root, target.profile, prompt).finally(() => busy.delete(worker.id)));
|
|
840
1005
|
return worker;
|
|
841
1006
|
},
|
|
1007
|
+
pluginSeen: (sessionId) => {
|
|
1008
|
+
pluginReports.set(sessionId, now().getTime());
|
|
1009
|
+
// A /clear's session reports before the worker follows its new id.
|
|
1010
|
+
return (store
|
|
1011
|
+
.listWorkers(ACTIVE_STATUSES)
|
|
1012
|
+
.find((w) => w.sessionId === sessionId || newSessionIds.get(w.id)?.sessionId === sessionId) ?? null);
|
|
1013
|
+
},
|
|
842
1014
|
retire: async (workerId, { cause, force = false } = {}) => {
|
|
843
1015
|
const worker = activeWorker(workerId);
|
|
844
1016
|
if (busy.has(worker.id) || worker.status === 'launching') {
|
|
@@ -869,7 +1041,13 @@ export function sessionManager(deps) {
|
|
|
869
1041
|
if (session && isLive(session) && !turnEnded(session) && !force) {
|
|
870
1042
|
return refuse('its turn is still running: retire once it ends, or retire --force', true);
|
|
871
1043
|
}
|
|
872
|
-
|
|
1044
|
+
// The worktree and its branch go once the PR has merged; a probe's and a forced retire's go
|
|
1045
|
+
// anyway. Otherwise both are kept, so nothing the PR didn't land is lost (design note v2,
|
|
1046
|
+
// section 2), and the inbox says where they are.
|
|
1047
|
+
const fate = await worktreeFate(worker, force);
|
|
1048
|
+
if (fate.unread)
|
|
1049
|
+
return refuse(`its PR couldn't be read: ${fate.unread}`, true);
|
|
1050
|
+
const work = fate.remove && worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
|
|
873
1051
|
if (work && !force)
|
|
874
1052
|
return refuse('unfinished work: commit and push it, or retire --force to throw it away', false, work);
|
|
875
1053
|
const root = store.listProjects().find((p) => p.name === worker.project)?.root;
|
|
@@ -890,14 +1068,21 @@ export function sessionManager(deps) {
|
|
|
890
1068
|
return { outcome: 'partial', steps, worker };
|
|
891
1069
|
}
|
|
892
1070
|
}
|
|
1071
|
+
let kept = null;
|
|
893
1072
|
if (worker.worktree !== null) {
|
|
894
1073
|
const worktree = worker.worktree;
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1074
|
+
if (fate.remove) {
|
|
1075
|
+
await step(steps, 'remove worktree', async () => {
|
|
1076
|
+
if (!root)
|
|
1077
|
+
throw new Error(`project ${worker.project} is not registered`);
|
|
1078
|
+
await worktrees.remove(root, worktree, { deleteBranch: true });
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
else if (existsSync(worktree)) {
|
|
1082
|
+
kept = { branch: await worktrees.branchOf(worktree), reason: fate.reason, worktree };
|
|
1083
|
+
}
|
|
1084
|
+
// No session serves on them any more, kept worktree or not.
|
|
1085
|
+
freePorts(store, worktree, 'worker retired');
|
|
901
1086
|
}
|
|
902
1087
|
try {
|
|
903
1088
|
readSpend(worker); // the last of it, before the session goes
|
|
@@ -919,6 +1104,7 @@ export function sessionManager(deps) {
|
|
|
919
1104
|
event(current, ok ? 'worker.retired' : 'worker.retire-partial', {
|
|
920
1105
|
forced: force && work !== null,
|
|
921
1106
|
steps,
|
|
1107
|
+
...(kept === null ? {} : { kept }),
|
|
922
1108
|
...(cause === undefined ? {} : { cause }),
|
|
923
1109
|
});
|
|
924
1110
|
return { outcome: ok ? 'retired' : 'partial', steps, worker: current };
|
|
@@ -937,6 +1123,7 @@ export function sessionManager(deps) {
|
|
|
937
1123
|
throw new ConductorError(`${worker.name} has no session`);
|
|
938
1124
|
busy.add(worker.id);
|
|
939
1125
|
try {
|
|
1126
|
+
const started = now().getTime();
|
|
940
1127
|
const { copy } = await claude.resume({
|
|
941
1128
|
cwd: worker.worktree ?? rootOf(worker),
|
|
942
1129
|
sessionId: worker.sessionId,
|
|
@@ -949,6 +1136,7 @@ export function sessionManager(deps) {
|
|
|
949
1136
|
}
|
|
950
1137
|
const running = update(worker, { status: 'running', stoppedAt: null });
|
|
951
1138
|
event(running, 'worker.revived', { text }, rule);
|
|
1139
|
+
expectPlugin(running, started);
|
|
952
1140
|
return running;
|
|
953
1141
|
}
|
|
954
1142
|
finally {
|