@detergent-software/conductor 3.0.0-dev.9 → 3.1.0-dev.1
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 +486 -0
- package/README.md +272 -126
- package/build/brief-cli.js +5 -12
- package/build/brief-cli.js.map +1 -1
- package/build/brief.js +4 -3
- package/build/brief.js.map +1 -1
- package/build/broker-cli.js +41 -14
- package/build/broker-cli.js.map +1 -1
- package/build/broker.js +1 -1
- package/build/broker.js.map +1 -1
- package/build/cli.js +226 -64
- package/build/cli.js.map +1 -1
- package/build/clones.js +92 -0
- package/build/clones.js.map +1 -0
- package/build/code-host-cli.js +172 -40
- package/build/code-host-cli.js.map +1 -1
- package/build/code-host-service.js +35 -2
- package/build/code-host-service.js.map +1 -1
- package/build/code-host.js +138 -16
- package/build/code-host.js.map +1 -1
- package/build/conditions.js +33 -16
- package/build/conditions.js.map +1 -1
- package/build/config.js +58 -28
- package/build/config.js.map +1 -1
- package/build/console.js +17 -27
- package/build/console.js.map +1 -1
- package/build/credentials.js +86 -54
- package/build/credentials.js.map +1 -1
- package/build/daemon.js +54 -65
- package/build/daemon.js.map +1 -1
- package/build/file-lock.js +155 -40
- package/build/file-lock.js.map +1 -1
- package/build/follow-ups.js +2 -2
- package/build/follow-ups.js.map +1 -1
- package/build/help.js +242 -83
- package/build/help.js.map +1 -1
- package/build/history.js +10 -4
- package/build/history.js.map +1 -1
- package/build/host/attach-clients.js +56 -0
- package/build/host/attach-clients.js.map +1 -0
- package/build/host/credential-manager.js +109 -0
- package/build/host/credential-manager.js.map +1 -0
- package/build/host/keychain.js +44 -0
- package/build/host/keychain.js.map +1 -0
- package/build/host/launchd.js +118 -0
- package/build/host/launchd.js.map +1 -0
- package/build/host/libsecret.js +40 -0
- package/build/host/libsecret.js.map +1 -0
- package/build/host/macos-terminal.js +63 -0
- package/build/host/macos-terminal.js.map +1 -0
- package/build/host/platform.js +4 -0
- package/build/host/platform.js.map +1 -0
- package/build/host/secrets.js +46 -0
- package/build/host/secrets.js.map +1 -0
- package/build/host/shell.js +26 -0
- package/build/host/shell.js.map +1 -0
- package/build/host/startup.js +26 -0
- package/build/host/startup.js.map +1 -0
- package/build/host/systemd.js +91 -0
- package/build/host/systemd.js.map +1 -0
- package/build/host/task-scheduler.js +78 -0
- package/build/host/task-scheduler.js.map +1 -0
- package/build/host/terminal.js +47 -0
- package/build/host/terminal.js.map +1 -0
- package/build/host/tmux.js +39 -0
- package/build/host/tmux.js.map +1 -0
- package/build/host/windows-terminal.js +46 -0
- package/build/host/windows-terminal.js.map +1 -0
- package/build/in-process.js +5 -4
- package/build/in-process.js.map +1 -1
- package/build/inbox.js +47 -35
- package/build/inbox.js.map +1 -1
- package/build/init.js +509 -0
- package/build/init.js.map +1 -0
- package/build/landing.js +107 -10
- package/build/landing.js.map +1 -1
- package/build/lib/providers/ado/code-host.js +5 -1
- package/build/lib/providers/ado/code-host.js.map +1 -1
- package/build/lib/providers/ado/pipelines.js +138 -26
- package/build/lib/providers/ado/pipelines.js.map +1 -1
- package/build/lib/providers/ado/trigger.js +5 -0
- package/build/lib/providers/ado/trigger.js.map +1 -1
- package/build/lib/providers/ado/writes.js +17 -2
- package/build/lib/providers/ado/writes.js.map +1 -1
- package/build/lib/providers/github/actions.js +90 -3
- package/build/lib/providers/github/actions.js.map +1 -1
- package/build/lib/providers/github/code-host.js +2 -0
- package/build/lib/providers/github/code-host.js.map +1 -1
- package/build/lib/providers/github/writes.js +25 -2
- package/build/lib/providers/github/writes.js.map +1 -1
- package/build/lib/providers/index.js +3 -2
- package/build/lib/providers/index.js.map +1 -1
- package/build/lib/providers/npm/stage.js +14 -1
- package/build/lib/providers/npm/stage.js.map +1 -1
- package/build/lib/providers/timing.js +10 -0
- package/build/lib/providers/timing.js.map +1 -0
- package/build/lib/providers/verdicts/pr.js +47 -6
- package/build/lib/providers/verdicts/pr.js.map +1 -1
- package/build/lib/providers/verdicts/runs.js +12 -2
- package/build/lib/providers/verdicts/runs.js.map +1 -1
- package/build/logins.js +13 -6
- package/build/logins.js.map +1 -1
- package/build/manager.js +200 -55
- package/build/manager.js.map +1 -1
- package/build/note.js +5 -15
- package/build/note.js.map +1 -1
- package/build/notes.js +130 -23
- package/build/notes.js.map +1 -1
- package/build/notify.js +104 -42
- package/build/notify.js.map +1 -1
- package/build/npm-reads.js +151 -22
- package/build/npm-reads.js.map +1 -1
- package/build/ownership.js +2 -2
- package/build/paths.js +44 -1
- package/build/paths.js.map +1 -1
- package/build/plugin.js +4 -2
- package/build/plugin.js.map +1 -1
- package/build/profile-fields.js +153 -0
- package/build/profile-fields.js.map +1 -0
- package/build/profile-layers.js +264 -0
- package/build/profile-layers.js.map +1 -0
- package/build/profile.js +69 -49
- package/build/profile.js.map +1 -1
- package/build/project-check-cli.js +17 -19
- package/build/project-check-cli.js.map +1 -1
- package/build/project-check.js +166 -104
- package/build/project-check.js.map +1 -1
- package/build/protocol.js +5 -21
- package/build/protocol.js.map +1 -1
- package/build/pushover.js +5 -5
- package/build/pushover.js.map +1 -1
- package/build/queue.js +4 -4
- package/build/queue.js.map +1 -1
- package/build/registry.js +195 -36
- package/build/registry.js.map +1 -1
- package/build/release-kinds.js +34 -0
- package/build/release-kinds.js.map +1 -0
- package/build/rule-actions.js +8 -6
- package/build/rule-actions.js.map +1 -1
- package/build/rules-service.js +83 -44
- package/build/rules-service.js.map +1 -1
- package/build/rules.js +23 -23
- package/build/rules.js.map +1 -1
- package/build/run.js +20 -7
- package/build/run.js.map +1 -1
- package/build/runs.js +218 -68
- package/build/runs.js.map +1 -1
- package/build/scheduler.js +78 -12
- package/build/scheduler.js.map +1 -1
- package/build/search.js +2 -1
- package/build/search.js.map +1 -1
- package/build/server.js +51 -14
- package/build/server.js.map +1 -1
- package/build/spend.js +1 -1
- package/build/spend.js.map +1 -1
- package/build/store.js +79 -20
- package/build/store.js.map +1 -1
- package/build/supervise.js +1 -1
- package/build/supervise.js.map +1 -1
- package/build/timeline.js +104 -0
- package/build/timeline.js.map +1 -0
- package/build/tracker-reads-cli.js +4 -3
- package/build/tracker-reads-cli.js.map +1 -1
- package/build/trackers.js +56 -10
- package/build/trackers.js.map +1 -1
- package/build/train-cli.js +6 -5
- package/build/train-cli.js.map +1 -1
- package/build/train.js +2 -2
- package/build/train.js.map +1 -1
- package/build/upgrade.js +8 -9
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +10 -10
- package/build/wait.js.map +1 -1
- package/build/watchers/ado.js +42 -19
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/github.js +18 -4
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +2 -2
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-queue.js +47 -14
- package/build/watchers/linear-queue.js.map +1 -1
- package/build/watchers/linear-writes.js +26 -12
- package/build/watchers/linear-writes.js.map +1 -1
- package/build/watchers/linear.js +2 -2
- package/build/watchers/linear.js.map +1 -1
- package/build/watchers/source.js +5 -1
- package/build/watchers/source.js.map +1 -1
- package/build/webhook.js +80 -0
- package/build/webhook.js.map +1 -0
- package/build/why.js +1 -1
- package/build/why.js.map +1 -1
- package/build/worktrees.js +56 -93
- package/build/worktrees.js.map +1 -1
- package/package.json +61 -61
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/model.ts +55 -6
- package/plugin/hooks/register.tsx +43 -4
- package/plugin/skills/conductor-console/SKILL.md +26 -27
- package/plugin/skills/work-item/SKILL.md +58 -54
- package/plugin/skills/work-item/trackers/ado-boards.md +2 -2
- package/plugin/skills/work-item/trackers/github-issues.md +1 -1
- package/plugin/skills/work-item/trackers/linear.md +2 -2
- package/plugin/types/index.d.ts +1 -1
- package/protocol/PROTOCOL.md +3 -9
- package/build/checkout-sync.js +0 -249
- package/build/checkout-sync.js.map +0 -1
- package/build/credential.js +0 -66
- package/build/credential.js.map +0 -1
- package/build/startup.js +0 -92
- package/build/startup.js.map +0 -1
- package/build/tab.js +0 -32
- package/build/tab.js.map +0 -1
package/build/manager.js
CHANGED
|
@@ -7,14 +7,16 @@ import { formatCondition, parseCondition, prRef, uncheckable } from "./condition
|
|
|
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 { projectPaths } from "./paths.js";
|
|
10
11
|
import { missingPluginSkills, projectSkills } from "./plugin.js";
|
|
11
12
|
import { WAKE_PREFIX } from "./protocol.js";
|
|
13
|
+
import { registeredByCheckout } from "./registry.js";
|
|
12
14
|
import { resolveOpenWait } from "./resolve.js";
|
|
13
15
|
import { findTranscript, formatTokens, lastTurnAt, localDay, projectSlug, readUsage, transcriptFiles, } from "./spend.js";
|
|
14
16
|
import { ACTIVE_STATUSES, openStore, waitSchema, workerSchema, } from "./store.js";
|
|
15
17
|
import { linkedPr } from "./tracker-writes.js";
|
|
16
18
|
import { pendingCall } from "./transcript.js";
|
|
17
|
-
import { BRANCH_PREFIX
|
|
19
|
+
import { BRANCH_PREFIX } from "./worktrees.js";
|
|
18
20
|
// A worker as GET /fleet shows it.
|
|
19
21
|
export const fleetWorkerSchema = workerSchema.extend({
|
|
20
22
|
// launching, starting, working, idle, waiting on <x>, parked, stopped, no session
|
|
@@ -86,7 +88,7 @@ export function workerLine(w) {
|
|
|
86
88
|
}
|
|
87
89
|
export const PROBE_PREFIX = 'probe-';
|
|
88
90
|
export const PROBE_PROMPT = 'This is a conductor probe session started by a test run. It works no issue: reply "probe ready" and wait.';
|
|
89
|
-
//
|
|
91
|
+
// Workers start in auto mode; a resume keeps it (ATK-309's open check).
|
|
90
92
|
export const PERMISSION_MODE = 'auto';
|
|
91
93
|
// How long a started or woken worker's session has to report its plugin. A start runs the create
|
|
92
94
|
// hook first only for a launch, which is before the clock starts.
|
|
@@ -96,7 +98,15 @@ const ITEM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
96
98
|
const STOP_WAIT_MS = 30_000;
|
|
97
99
|
// A park that failed (the process outlived `claude stop`) is tried again this much later.
|
|
98
100
|
const PARK_RETRY_MS = 5 * 60_000;
|
|
101
|
+
// How long a new session id has to stay listed before the worker follows it. A resuming session is
|
|
102
|
+
// listed for under a second under the id its process started with, before it loads its own (ATK-574).
|
|
103
|
+
const SESSION_ID_SETTLE_MS = 5_000;
|
|
99
104
|
const TRANSCRIPT_SCAN_MS = 60_000;
|
|
105
|
+
// The process scan for `claude attach` clients runs at most this often, and only while a worker is
|
|
106
|
+
// attached: on Windows it takes about a second (ATK-520).
|
|
107
|
+
const CLIENT_SCAN_MS = 30_000;
|
|
108
|
+
// This many scans in a row without the client, once one had found it, mean the tab closed.
|
|
109
|
+
const CLIENT_MISSES = 2;
|
|
100
110
|
// A compaction with no boundary in the transcript this long after it started, its turn over, failed.
|
|
101
111
|
const COMPACT_MINUTES = 10;
|
|
102
112
|
// What a parked worker is resumed with when its context has grown past the rules' threshold
|
|
@@ -118,7 +128,7 @@ export function eventText(waits) {
|
|
|
118
128
|
return `${WAKE_PREFIX}${[...facts].map(([fact, at]) => `${fact} ${clock(new Date(at))}`).join('; ')}`;
|
|
119
129
|
}
|
|
120
130
|
export function sessionManager(deps) {
|
|
121
|
-
const { broker, claude, config, log,
|
|
131
|
+
const { broker, claude, config, log, pluginDir, prs, store, tabs, worktrees } = deps;
|
|
122
132
|
const now = deps.now ?? (() => new Date());
|
|
123
133
|
const alive = deps.processAlive ?? defaultProcessAlive;
|
|
124
134
|
const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
@@ -128,7 +138,12 @@ export function sessionManager(deps) {
|
|
|
128
138
|
const stalls = new Map();
|
|
129
139
|
const transcripts = new Map();
|
|
130
140
|
const transcriptScans = new Map();
|
|
141
|
+
// worker id → a session id the listing shows for it that it hasn't followed yet, since when
|
|
142
|
+
const newSessionIds = new Map();
|
|
131
143
|
let sessions = [];
|
|
144
|
+
// The last scan for `claude attach` clients: when, its number, and the sessions it found them for
|
|
145
|
+
// (null: the processes couldn't be read).
|
|
146
|
+
let clientScan = null;
|
|
132
147
|
// Until the first sweep, nothing is known about any session.
|
|
133
148
|
let listed = false;
|
|
134
149
|
let sweeping = null;
|
|
@@ -138,6 +153,8 @@ export function sessionManager(deps) {
|
|
|
138
153
|
const held = new Set();
|
|
139
154
|
// Workers whose compaction failed: not tried again while the daemon runs (their inbox item says so).
|
|
140
155
|
const uncompactable = new Set();
|
|
156
|
+
// worker id → the attach (its attachedAt) a retire was last refused for, so each is logged once.
|
|
157
|
+
const attachRefusals = new Map();
|
|
141
158
|
// Every session the conductor starts loads its plugin, and a resume whose saved --plugin-dir went
|
|
142
159
|
// missing runs on without it, silently (ATK-522). The mod reports each session start (pluginSeen),
|
|
143
160
|
// so a worker started or woken that doesn't report within PLUGIN_REPORT_MS is a
|
|
@@ -228,10 +245,15 @@ export function sessionManager(deps) {
|
|
|
228
245
|
throw new ConductorError(`project "${found.name}" has a problem: ${found.problem ?? 'no profile'}`);
|
|
229
246
|
if (!found.enabled)
|
|
230
247
|
throw new ConductorError(`project "${found.name}" is disabled`);
|
|
248
|
+
if (registeredByCheckout(deps.paths, found)) {
|
|
249
|
+
throw new ConductorError(`project "${found.name}" is registered by the checkout ${found.clone}`, [
|
|
250
|
+
`the conductor works only in a clone of its own: run \`conduct project add "${found.clone}"\` first`,
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
231
253
|
return { ...found, profile: found.profile };
|
|
232
254
|
};
|
|
233
|
-
// Where a worker's commands run: its project's
|
|
234
|
-
const
|
|
255
|
+
// Where a worker's commands run when it has no worktree: its project's clone.
|
|
256
|
+
const cloneOf = (worker) => projectPaths(deps.paths, worker.project).clone;
|
|
235
257
|
const sessionOf = (worker) => sessions.find((s) => (worker.sessionId !== null && s.sessionId === worker.sessionId) ||
|
|
236
258
|
(worker.handle !== null && s.id === worker.handle));
|
|
237
259
|
const minutesSince = (iso) => (now().getTime() - Date.parse(iso)) / 60_000;
|
|
@@ -269,13 +291,14 @@ export function sessionManager(deps) {
|
|
|
269
291
|
};
|
|
270
292
|
// ---------- launch ----------
|
|
271
293
|
// The worktree first (design note v2, section 2): its ports from the broker, then `git worktree add`
|
|
272
|
-
// from origin/<base>, the local files and the profile's setup. The session
|
|
273
|
-
|
|
294
|
+
// in the project's clone from origin/<base>, the local files and the profile's setup. The session
|
|
295
|
+
// starts in it.
|
|
296
|
+
const launchWorker = async (worker, profile, prompt) => {
|
|
274
297
|
let current = worker;
|
|
275
298
|
let stage = 'assign ports';
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
// this launch's to remove when it fails.
|
|
299
|
+
const { clone, worktrees: dir } = projectPaths(deps.paths, worker.project);
|
|
300
|
+
const path = join(dir, worker.name);
|
|
301
|
+
// A worktree there already (a kept one) isn't this launch's to remove when it fails.
|
|
279
302
|
const reused = existsSync(join(path, '.git'));
|
|
280
303
|
try {
|
|
281
304
|
// Recorded before it exists: the broker keeps an in-flight worker's ports while its directory
|
|
@@ -286,10 +309,11 @@ export function sessionManager(deps) {
|
|
|
286
309
|
const env = portEnv(ports);
|
|
287
310
|
const worktree = await worktrees.create({
|
|
288
311
|
base: profile.baseBranch,
|
|
312
|
+
clone,
|
|
289
313
|
env: { ...env, CONDUCTOR_ITEM: worker.item, CONDUCTOR_PROJECT: worker.project, CONDUCTOR_WORKTREE: path },
|
|
290
314
|
...(profile.localFilesFrom === undefined ? {} : { localFilesFrom: profile.localFilesFrom }),
|
|
291
315
|
name: worker.name,
|
|
292
|
-
|
|
316
|
+
path,
|
|
293
317
|
sessionEnv: env,
|
|
294
318
|
setup: profile.setup,
|
|
295
319
|
});
|
|
@@ -314,8 +338,8 @@ export function sessionManager(deps) {
|
|
|
314
338
|
// Claude Code's own message names the worktree, which this failed launch removes.
|
|
315
339
|
if (!(error instanceof WorkspaceUntrusted))
|
|
316
340
|
throw error;
|
|
317
|
-
throw new ConductorError(`Claude Code doesn't trust ${
|
|
318
|
-
`${trustStep(
|
|
341
|
+
throw new ConductorError(`Claude Code doesn't trust ${clone}`, [
|
|
342
|
+
`${trustStep(clone)}, then \`conduct project check ${worker.project}\``,
|
|
319
343
|
...error.details,
|
|
320
344
|
]);
|
|
321
345
|
});
|
|
@@ -330,8 +354,8 @@ export function sessionManager(deps) {
|
|
|
330
354
|
current = update(current, { handle, sessionId: session?.sessionId ?? null, status: 'running' });
|
|
331
355
|
event(current, 'worker.launched', { handle, pluginDir, sessionId: current.sessionId, worktree });
|
|
332
356
|
expectPlugin(current, started);
|
|
333
|
-
if (
|
|
334
|
-
const tab = await
|
|
357
|
+
if (tabs.onLaunch) {
|
|
358
|
+
const tab = await tabs.open({
|
|
335
359
|
cwd: worktree,
|
|
336
360
|
handle,
|
|
337
361
|
title: worker.name,
|
|
@@ -346,7 +370,7 @@ export function sessionManager(deps) {
|
|
|
346
370
|
const cleanup = reused
|
|
347
371
|
? { kept: path, keptBecause: 'it was there before the launch' }
|
|
348
372
|
: current.worktree && existsSync(current.worktree)
|
|
349
|
-
? await removeIfClean(
|
|
373
|
+
? await removeIfClean(clone, current.worktree)
|
|
350
374
|
: {};
|
|
351
375
|
freePorts(store, path, 'launch failed');
|
|
352
376
|
current = update(current, { status: 'failed' });
|
|
@@ -356,13 +380,13 @@ export function sessionManager(deps) {
|
|
|
356
380
|
};
|
|
357
381
|
// A failed launch's worktree, unless it holds work. Its branch goes with it while it still has the
|
|
358
382
|
// name the launch gave it; a renamed one was the worker's.
|
|
359
|
-
const removeIfClean = async (
|
|
383
|
+
const removeIfClean = async (clone, worktree) => {
|
|
360
384
|
try {
|
|
361
385
|
const work = await worktrees.unfinishedWork(worktree);
|
|
362
386
|
if (work)
|
|
363
387
|
return { kept: worktree, ...work };
|
|
364
388
|
const branch = await worktrees.branchOf(worktree);
|
|
365
|
-
await worktrees.remove(
|
|
389
|
+
await worktrees.remove(worktree, { deleteBranch: branch?.startsWith(BRANCH_PREFIX) === true, repo: clone });
|
|
366
390
|
return { removed: worktree };
|
|
367
391
|
}
|
|
368
392
|
catch (error) {
|
|
@@ -452,7 +476,7 @@ export function sessionManager(deps) {
|
|
|
452
476
|
const text = eventText(resolved);
|
|
453
477
|
const started = now().getTime();
|
|
454
478
|
const { copy } = await claude.resume({
|
|
455
|
-
cwd: session?.cwd ?? worker.worktree ??
|
|
479
|
+
cwd: session?.cwd ?? worker.worktree ?? cloneOf(worker),
|
|
456
480
|
sessionId: worker.sessionId,
|
|
457
481
|
text,
|
|
458
482
|
});
|
|
@@ -501,7 +525,7 @@ export function sessionManager(deps) {
|
|
|
501
525
|
let copy;
|
|
502
526
|
try {
|
|
503
527
|
({ copy } = await claude.resume({
|
|
504
|
-
cwd: session?.cwd ?? worker.worktree ??
|
|
528
|
+
cwd: session?.cwd ?? worker.worktree ?? cloneOf(worker),
|
|
505
529
|
sessionId: worker.sessionId,
|
|
506
530
|
text: COMPACT_TEXT,
|
|
507
531
|
}));
|
|
@@ -578,7 +602,7 @@ export function sessionManager(deps) {
|
|
|
578
602
|
});
|
|
579
603
|
}
|
|
580
604
|
}
|
|
581
|
-
// An attached worker is
|
|
605
|
+
// An attached worker is the operator's while they have it open. One whose Handoff is open waits on them,
|
|
582
606
|
// though no wait says so: its note raised the inbox item already (ATK-416).
|
|
583
607
|
if (!turnEnded(session) || waiting || worker.attachedAt !== null || handedOff(worker)) {
|
|
584
608
|
cleared('idle', state.idle?.reported);
|
|
@@ -594,25 +618,78 @@ export function sessionManager(deps) {
|
|
|
594
618
|
}
|
|
595
619
|
stalls.set(worker.id, state);
|
|
596
620
|
};
|
|
597
|
-
//
|
|
621
|
+
// Once the process scan has seen the worker's `claude attach` client and then missed it
|
|
622
|
+
// CLIENT_MISSES times in a row, and its turn is over, the tab closed: the worker is the daemon's
|
|
623
|
+
// again (ATK-520). A client seen still doesn't hold it past the releases below: a tab left open
|
|
624
|
+
// with nobody in it would hold its waits as before ATK-516, and a park closes the tab.
|
|
625
|
+
// Attached means "the operator may be in it" only while the session is
|
|
598
626
|
// in use. Once its turn has been over `idleMinutes`, the worker is the daemon's again, and its
|
|
599
627
|
// waits park and resume it as usual. A resolved wait takes it back at once when the transcript
|
|
600
628
|
// shows no turn since the attach: nobody is in it, and the park and resume deliver the wait
|
|
601
629
|
// (ATK-516). A turn, or no transcript to tell, holds it, and the hold is logged once.
|
|
602
630
|
// Only a wait that resolved after the attach releases it: one resolved before was there when
|
|
603
|
-
//
|
|
631
|
+
// The operator chose to attach, so taking the session back would undo the attach.
|
|
604
632
|
// A new attach, or the end of one, starts its spell afresh.
|
|
605
633
|
const forgetAttach = (workerId) => {
|
|
606
634
|
const state = stalls.get(workerId);
|
|
607
635
|
if (!state)
|
|
608
636
|
return;
|
|
637
|
+
state.attachedClient = undefined;
|
|
609
638
|
state.attachedHeld = undefined;
|
|
610
639
|
state.attachedIdleSince = undefined;
|
|
611
640
|
state.attachedUsed = undefined;
|
|
612
641
|
};
|
|
642
|
+
// Where the daemon can read the processes, it sees the tab after all (ATK-520): a `claude attach`
|
|
643
|
+
// client on the session is the tab, or one opened by hand in another terminal. Never rejects.
|
|
644
|
+
const scanClients = async () => {
|
|
645
|
+
if (!tabs.clients)
|
|
646
|
+
return null;
|
|
647
|
+
// As of its start: an attach's scan and a sweep's can overlap, and the older mustn't win.
|
|
648
|
+
const at = now().getTime();
|
|
649
|
+
let targets = null;
|
|
650
|
+
try {
|
|
651
|
+
targets = await tabs.clients();
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
log.log('warn', 'attach client scan failed', { error: messageOf(error) });
|
|
655
|
+
}
|
|
656
|
+
if (clientScan === null || at >= clientScan.at)
|
|
657
|
+
clientScan = { at, scan: (clientScan?.scan ?? 0) + 1, targets };
|
|
658
|
+
return targets;
|
|
659
|
+
};
|
|
660
|
+
// What a client of the worker's session attaches by: its short id, session id or name.
|
|
661
|
+
const clientNames = (worker) => [worker.handle, worker.sessionId, worker.name].filter((name) => name !== null);
|
|
662
|
+
// Each scan since the attach counts once. One that finds the client marks it seen and starts the
|
|
663
|
+
// misses over; one that doesn't, after it was seen, is a miss. So a tab that never opened (its
|
|
664
|
+
// terminal failed, and the operator runs `claude attach` by hand) or hasn't started its client yet
|
|
665
|
+
// is no closed tab. The names include every one the worker had since the attach: a client keeps
|
|
666
|
+
// the id it attached by when the worker follows a new one. A scan that couldn't read the
|
|
667
|
+
// processes tells nothing.
|
|
668
|
+
const watchClient = (worker, state) => {
|
|
669
|
+
if (worker.attachedAt === null)
|
|
670
|
+
return;
|
|
671
|
+
const watch = (state.attachedClient ??= { misses: 0, names: [], scan: 0, seen: false });
|
|
672
|
+
for (const name of clientNames(worker))
|
|
673
|
+
if (!watch.names.includes(name))
|
|
674
|
+
watch.names.push(name);
|
|
675
|
+
const scan = clientScan;
|
|
676
|
+
if (scan === null || scan.targets === null || scan.scan === watch.scan)
|
|
677
|
+
return;
|
|
678
|
+
if (scan.at < Date.parse(worker.attachedAt))
|
|
679
|
+
return;
|
|
680
|
+
const { targets } = scan;
|
|
681
|
+
watch.scan = scan.scan;
|
|
682
|
+
if (watch.names.some((name) => targets.has(name))) {
|
|
683
|
+
watch.seen = true;
|
|
684
|
+
watch.misses = 0;
|
|
685
|
+
}
|
|
686
|
+
else if (watch.seen)
|
|
687
|
+
watch.misses += 1;
|
|
688
|
+
};
|
|
613
689
|
const releaseAttached = (worker, session, resolved) => {
|
|
614
690
|
const state = stalls.get(worker.id) ?? {};
|
|
615
691
|
stalls.set(worker.id, state);
|
|
692
|
+
watchClient(worker, state);
|
|
616
693
|
if (!turnEnded(session)) {
|
|
617
694
|
state.attachedIdleSince = undefined;
|
|
618
695
|
return worker;
|
|
@@ -623,6 +700,9 @@ export function sessionManager(deps) {
|
|
|
623
700
|
event(released, 'worker.detached', data);
|
|
624
701
|
return released;
|
|
625
702
|
};
|
|
703
|
+
// Nobody has it open: its waits park and resume it as usual, with no idle spell first.
|
|
704
|
+
if ((state.attachedClient?.misses ?? 0) >= CLIENT_MISSES)
|
|
705
|
+
return detach({ reason: 'tab closed' });
|
|
626
706
|
const attachedAt = worker.attachedAt;
|
|
627
707
|
if (resolved.length > 0 && attachedAt !== null) {
|
|
628
708
|
const since = Date.parse(attachedAt);
|
|
@@ -647,14 +727,29 @@ export function sessionManager(deps) {
|
|
|
647
727
|
let current = worker;
|
|
648
728
|
const session = sessionOf(current);
|
|
649
729
|
// The listing is the truth for ids: fill them in after a launch, and follow a changed short id.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
730
|
+
// A changed session id is followed once it has stayed listed SESSION_ID_SETTLE_MS.
|
|
731
|
+
if (session) {
|
|
732
|
+
let sessionId = current.sessionId;
|
|
733
|
+
const pending = newSessionIds.get(current.id);
|
|
734
|
+
if (sessionId === null || session.sessionId === sessionId)
|
|
735
|
+
newSessionIds.delete(current.id);
|
|
736
|
+
else if (pending?.sessionId !== session.sessionId) {
|
|
737
|
+
newSessionIds.set(current.id, { sessionId: session.sessionId, since: now().getTime() });
|
|
738
|
+
}
|
|
739
|
+
else if (now().getTime() - pending.since >= SESSION_ID_SETTLE_MS) {
|
|
740
|
+
newSessionIds.delete(current.id);
|
|
741
|
+
// A new session id (a /clear) writes a new transcript.
|
|
653
742
|
transcripts.delete(current.id);
|
|
654
743
|
transcriptScans.delete(current.id);
|
|
744
|
+
sessionId = session.sessionId;
|
|
745
|
+
}
|
|
746
|
+
sessionId ??= session.sessionId;
|
|
747
|
+
if (sessionId !== current.sessionId || session.id !== current.handle) {
|
|
748
|
+
current = update(current, { handle: session.id, sessionId });
|
|
655
749
|
}
|
|
656
|
-
current = update(current, { handle: session.id, sessionId: session.sessionId });
|
|
657
750
|
}
|
|
751
|
+
else
|
|
752
|
+
newSessionIds.delete(current.id);
|
|
658
753
|
const waits = store.waitsOf(current.id);
|
|
659
754
|
const resolved = waits.filter((w) => w.resolvedAt !== null);
|
|
660
755
|
// A listed pid can outlive its process for a moment after a stop.
|
|
@@ -702,6 +797,10 @@ export function sessionManager(deps) {
|
|
|
702
797
|
}
|
|
703
798
|
if (current.status !== 'parked')
|
|
704
799
|
return;
|
|
800
|
+
// A park needs a wait, so a parked worker with none was stopped by a retire that didn't finish
|
|
801
|
+
// (ATK-519): the retire, tried again, takes it on.
|
|
802
|
+
if (waits.length === 0)
|
|
803
|
+
return;
|
|
705
804
|
if (resolved.length > 0)
|
|
706
805
|
await resume(current, resolved, session);
|
|
707
806
|
else
|
|
@@ -798,6 +897,9 @@ export function sessionManager(deps) {
|
|
|
798
897
|
return;
|
|
799
898
|
}
|
|
800
899
|
try {
|
|
900
|
+
const attached = store.listWorkers(['running']).some((w) => w.attachedAt !== null);
|
|
901
|
+
if (attached && (clientScan === null || now().getTime() - clientScan.at >= CLIENT_SCAN_MS))
|
|
902
|
+
await scanClients();
|
|
801
903
|
resolveTimeWaits();
|
|
802
904
|
for (const { id } of store.listWorkers(SWEPT)) {
|
|
803
905
|
if (busy.has(id))
|
|
@@ -842,8 +944,8 @@ export function sessionManager(deps) {
|
|
|
842
944
|
});
|
|
843
945
|
continue;
|
|
844
946
|
}
|
|
845
|
-
const root =
|
|
846
|
-
const worktree = worker.worktree ??
|
|
947
|
+
const root = cloneOf(worker);
|
|
948
|
+
const worktree = worker.worktree ?? join(projectPaths(deps.paths, worker.project).worktrees, worker.name);
|
|
847
949
|
const reused = store
|
|
848
950
|
.workerEvents(worker.id, 1000)
|
|
849
951
|
.some((e) => e.kind === 'worker.launching' && e.data.reused === true);
|
|
@@ -882,7 +984,7 @@ export function sessionManager(deps) {
|
|
|
882
984
|
const problem = uncheckable(parsed, project?.profile ?? null);
|
|
883
985
|
if (problem) {
|
|
884
986
|
throw new ConductorError(problem, [
|
|
885
|
-
`the conductor takes ${worker.project}'s profile
|
|
987
|
+
`the conductor takes ${worker.project}'s profile again when it starts, when ${project?.profilePath ?? 'its machine profile'} is saved, and after each fetch of its clone; \`conduct profile ${worker.project}\` shows it, and \`conduct rules\` lists one it refused`,
|
|
886
988
|
]);
|
|
887
989
|
}
|
|
888
990
|
// Stored in its normal full form, so the watchers and `conduct status` read one spelling. A
|
|
@@ -914,6 +1016,19 @@ export function sessionManager(deps) {
|
|
|
914
1016
|
try {
|
|
915
1017
|
let current = worker;
|
|
916
1018
|
const resumed = current.status !== 'running';
|
|
1019
|
+
const window = `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`;
|
|
1020
|
+
// A running session can have a client already: an attach's tab, a launch's, or one by hand.
|
|
1021
|
+
// A second tab would be a second client; the operator has the first (ATK-520). The hint
|
|
1022
|
+
// still says how to reach a tab off the desktop.
|
|
1023
|
+
const targets = resumed ? null : await scanClients();
|
|
1024
|
+
if (targets !== null && clientNames(current).some((name) => targets.has(name))) {
|
|
1025
|
+
if (current.attachedAt === null) {
|
|
1026
|
+
current = update(current, { attachedAt: now().toISOString() });
|
|
1027
|
+
forgetAttach(current.id);
|
|
1028
|
+
}
|
|
1029
|
+
event(current, 'worker.attached', { resumed, tabOpen: true });
|
|
1030
|
+
return { already: true, hint: tabs.hint(window), resumed, tab: null, worker: current };
|
|
1031
|
+
}
|
|
917
1032
|
let started = null;
|
|
918
1033
|
if (resumed) {
|
|
919
1034
|
const settle = current.stoppedAt === null
|
|
@@ -924,7 +1039,7 @@ export function sessionManager(deps) {
|
|
|
924
1039
|
// No event text: the session wakes idle and keeps running when the tab closes.
|
|
925
1040
|
started = now().getTime();
|
|
926
1041
|
const { copy } = await claude.resume({
|
|
927
|
-
cwd: sessionOf(current)?.cwd ?? current.worktree ??
|
|
1042
|
+
cwd: sessionOf(current)?.cwd ?? current.worktree ?? cloneOf(current),
|
|
928
1043
|
sessionId,
|
|
929
1044
|
text: null,
|
|
930
1045
|
});
|
|
@@ -936,16 +1051,11 @@ export function sessionManager(deps) {
|
|
|
936
1051
|
}
|
|
937
1052
|
current = update(current, { attachedAt: now().toISOString(), status: 'running', stoppedAt: null });
|
|
938
1053
|
forgetAttach(current.id);
|
|
939
|
-
const tab = await
|
|
940
|
-
cwd: current.worktree ?? rootOf(current),
|
|
941
|
-
handle,
|
|
942
|
-
title: current.name,
|
|
943
|
-
window: `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`,
|
|
944
|
-
});
|
|
1054
|
+
const tab = await tabs.open({ cwd: current.worktree ?? cloneOf(current), handle, title: current.name, window });
|
|
945
1055
|
event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
|
|
946
1056
|
if (started !== null)
|
|
947
1057
|
expectPlugin(current, started);
|
|
948
|
-
return { resumed, tab, worker: current };
|
|
1058
|
+
return { already: false, hint: tab === null ? tabs.hint(window) : null, resumed, tab, worker: current };
|
|
949
1059
|
}
|
|
950
1060
|
finally {
|
|
951
1061
|
busy.delete(worker.id);
|
|
@@ -974,19 +1084,22 @@ export function sessionManager(deps) {
|
|
|
974
1084
|
project: target.name,
|
|
975
1085
|
});
|
|
976
1086
|
// A worktree there already isn't the launch's to remove if it fails, after a restart too.
|
|
977
|
-
const reused = existsSync(join(
|
|
1087
|
+
const reused = existsSync(join(projectPaths(deps.paths, target.name).worktrees, name, '.git'));
|
|
978
1088
|
event(worker, 'worker.launching', {
|
|
979
1089
|
probe: worker.probe,
|
|
980
1090
|
...(reused ? { reused } : {}),
|
|
981
1091
|
...(request.cause === undefined ? {} : { cause: request.cause }),
|
|
982
1092
|
});
|
|
983
1093
|
busy.add(worker.id);
|
|
984
|
-
void track(launchWorker(worker, target.
|
|
1094
|
+
void track(launchWorker(worker, target.profile, prompt).finally(() => busy.delete(worker.id)));
|
|
985
1095
|
return worker;
|
|
986
1096
|
},
|
|
987
1097
|
pluginSeen: (sessionId) => {
|
|
988
1098
|
pluginReports.set(sessionId, now().getTime());
|
|
989
|
-
|
|
1099
|
+
// A /clear's session reports before the worker follows its new id.
|
|
1100
|
+
return (store
|
|
1101
|
+
.listWorkers(ACTIVE_STATUSES)
|
|
1102
|
+
.find((w) => w.sessionId === sessionId || newSessionIds.get(w.id)?.sessionId === sessionId) ?? null);
|
|
990
1103
|
},
|
|
991
1104
|
retire: async (workerId, { cause, force = false } = {}) => {
|
|
992
1105
|
const worker = activeWorker(workerId);
|
|
@@ -1000,8 +1113,6 @@ export function sessionManager(deps) {
|
|
|
1000
1113
|
}
|
|
1001
1114
|
busy.add(worker.id);
|
|
1002
1115
|
try {
|
|
1003
|
-
// Fresh: the last sweep's listing can be ten seconds old.
|
|
1004
|
-
const session = (await claude.agents().catch(() => sessions)).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
|
|
1005
1116
|
const refuse = (reason, transient, work) => {
|
|
1006
1117
|
event(worker, 'worker.retire-refused', {
|
|
1007
1118
|
reason,
|
|
@@ -1011,6 +1122,27 @@ export function sessionManager(deps) {
|
|
|
1011
1122
|
});
|
|
1012
1123
|
return { outcome: 'refused', reason, transient, ...(work ? { work } : {}), worker };
|
|
1013
1124
|
};
|
|
1125
|
+
// The operator has its session open in a tab (ATK-519). The scheduler tries again every tick,
|
|
1126
|
+
// so it is logged once per attach. Only a running worker is attached: one whose session
|
|
1127
|
+
// stopped unexpectedly keeps the attach it had.
|
|
1128
|
+
const attachedAt = worker.status === 'running' ? worker.attachedAt : null;
|
|
1129
|
+
if (attachedAt !== null && !force) {
|
|
1130
|
+
const reason = 'attached: retire once its tab is released, or retire --force';
|
|
1131
|
+
if (attachRefusals.get(worker.id) !== attachedAt) {
|
|
1132
|
+
attachRefusals.set(worker.id, attachedAt);
|
|
1133
|
+
event(worker, 'worker.retire-refused', {
|
|
1134
|
+
attached: true,
|
|
1135
|
+
reason,
|
|
1136
|
+
transient: true,
|
|
1137
|
+
...(cause === undefined ? {} : { cause }),
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return { attached: true, outcome: 'refused', reason, transient: true, worker };
|
|
1141
|
+
}
|
|
1142
|
+
attachRefusals.delete(worker.id);
|
|
1143
|
+
// Fresh: the last sweep's listing can be ten seconds old. Null when it couldn't be read.
|
|
1144
|
+
const listing = await claude.agents().catch(() => null);
|
|
1145
|
+
const session = (listing ?? sessions).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
|
|
1014
1146
|
// Removing the worktree while `claude --bg` still sets the session up there strands it.
|
|
1015
1147
|
if (session && isStarting(session))
|
|
1016
1148
|
return refuse('its session is still starting: try again shortly', true);
|
|
@@ -1027,7 +1159,6 @@ export function sessionManager(deps) {
|
|
|
1027
1159
|
const work = fate.remove && worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
|
|
1028
1160
|
if (work && !force)
|
|
1029
1161
|
return refuse('unfinished work: commit and push it, or retire --force to throw it away', false, work);
|
|
1030
|
-
const root = store.listProjects().find((p) => p.name === worker.project)?.root;
|
|
1031
1162
|
const steps = [];
|
|
1032
1163
|
const handle = worker.handle;
|
|
1033
1164
|
if (handle !== null && session?.pid !== undefined) {
|
|
@@ -1049,14 +1180,17 @@ export function sessionManager(deps) {
|
|
|
1049
1180
|
if (worker.worktree !== null) {
|
|
1050
1181
|
const worktree = worker.worktree;
|
|
1051
1182
|
if (fate.remove) {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
await worktrees.remove(root, worktree, { deleteBranch: true });
|
|
1056
|
-
});
|
|
1183
|
+
// The repository is the worktree's own: one launched before clones is in a person's
|
|
1184
|
+
// checkout, and retires from there.
|
|
1185
|
+
await step(steps, 'remove worktree', () => worktrees.remove(worktree, { deleteBranch: true, repo: cloneOf(worker) }));
|
|
1057
1186
|
}
|
|
1058
1187
|
else if (existsSync(worktree)) {
|
|
1059
|
-
kept = {
|
|
1188
|
+
kept = {
|
|
1189
|
+
branch: await worktrees.branchOf(worktree),
|
|
1190
|
+
reason: fate.reason,
|
|
1191
|
+
repo: await worktrees.repoOf(worktree),
|
|
1192
|
+
worktree,
|
|
1193
|
+
};
|
|
1060
1194
|
}
|
|
1061
1195
|
// No session serves on them any more, kept worktree or not.
|
|
1062
1196
|
freePorts(store, worktree, 'worker retired');
|
|
@@ -1070,11 +1204,21 @@ export function sessionManager(deps) {
|
|
|
1070
1204
|
if (handle !== null)
|
|
1071
1205
|
await step(steps, 'rm', () => claude.rm(handle));
|
|
1072
1206
|
const ok = steps.every((s) => s.ok);
|
|
1073
|
-
|
|
1074
|
-
|
|
1207
|
+
// Its session no longer runs once the stop succeeded, or when a fresh listing showed it wasn't
|
|
1208
|
+
// live, even when a later step failed. A retire that didn't finish then parks the worker with no
|
|
1209
|
+
// wait, so no sweep reads it as stopped unexpectedly or wakes it, and the retire is tried again
|
|
1210
|
+
// (ATK-519). With no fresh listing to say, the worker stays as it was.
|
|
1211
|
+
const down = steps.some((s) => s.step === 'stop' && s.ok) || (listing !== null && !(session && isLive(session)));
|
|
1212
|
+
const stoppedAt = now().toISOString();
|
|
1213
|
+
const parked = !ok && down;
|
|
1214
|
+
const current = ok
|
|
1215
|
+
? update(worker, { status: 'exited', stoppedAt })
|
|
1216
|
+
: parked
|
|
1217
|
+
? update(worker, { attachedAt: null, status: 'parked', stoppedAt })
|
|
1218
|
+
: worker;
|
|
1219
|
+
if (ok || parked)
|
|
1075
1220
|
store.closeWaits(worker.id, 'worker retired');
|
|
1076
|
-
//
|
|
1077
|
-
// the live worker, whose locks those are.
|
|
1221
|
+
// A probe shares its item with the live worker, whose locks those are.
|
|
1078
1222
|
if (!worker.probe)
|
|
1079
1223
|
releaseLocks(store, worker.item, 'worker retired');
|
|
1080
1224
|
stalls.delete(worker.id);
|
|
@@ -1082,6 +1226,7 @@ export function sessionManager(deps) {
|
|
|
1082
1226
|
forced: force && work !== null,
|
|
1083
1227
|
steps,
|
|
1084
1228
|
...(kept === null ? {} : { kept }),
|
|
1229
|
+
...(parked ? { parked } : {}),
|
|
1085
1230
|
...(cause === undefined ? {} : { cause }),
|
|
1086
1231
|
});
|
|
1087
1232
|
return { outcome: ok ? 'retired' : 'partial', steps, worker: current };
|
|
@@ -1102,7 +1247,7 @@ export function sessionManager(deps) {
|
|
|
1102
1247
|
try {
|
|
1103
1248
|
const started = now().getTime();
|
|
1104
1249
|
const { copy } = await claude.resume({
|
|
1105
|
-
cwd: worker.worktree ??
|
|
1250
|
+
cwd: worker.worktree ?? cloneOf(worker),
|
|
1106
1251
|
sessionId: worker.sessionId,
|
|
1107
1252
|
text,
|
|
1108
1253
|
});
|