@detergent-software/conductor 3.0.0-dev.4 → 3.0.0-dev.40
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 +339 -0
- package/README.md +270 -123
- 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 +15 -10
- package/build/broker-cli.js.map +1 -1
- package/build/broker.js +1 -1
- package/build/broker.js.map +1 -1
- package/build/claude.js +48 -2
- package/build/claude.js.map +1 -1
- package/build/cli.js +221 -71
- 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 +158 -39
- 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 +140 -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 +43 -27
- package/build/console.js.map +1 -1
- package/build/credentials.js +49 -36
- package/build/credentials.js.map +1 -1
- package/build/daemon.js +57 -66
- package/build/daemon.js.map +1 -1
- package/build/file-lock.js +35 -3
- 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 +228 -82
- package/build/help.js.map +1 -1
- package/build/history.js +10 -4
- package/build/history.js.map +1 -1
- 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 +46 -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 +6 -5
- package/build/in-process.js.map +1 -1
- package/build/inbox.js +50 -34
- package/build/inbox.js.map +1 -1
- package/build/init.js +509 -0
- package/build/init.js.map +1 -0
- package/build/landing.js +86 -7
- 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 +4 -3
- package/build/lib/providers/index.js.map +1 -1
- package/build/lib/providers/npm/stage.js +28 -5
- 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 +1 -1
- package/build/logins.js.map +1 -1
- package/build/manager.js +131 -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 +323 -0
- package/build/npm-reads.js.map +1 -0
- 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 +107 -51
- package/build/profile.js.map +1 -1
- package/build/project-check-cli.js +19 -19
- package/build/project-check-cli.js.map +1 -1
- package/build/project-check.js +194 -108
- 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 +104 -47
- 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 +89 -13
- 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 -5
- 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 -18
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/github.js +19 -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/npm.js +6 -4
- package/build/watchers/npm.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 +46 -6
- package/plugin/hooks/register.tsx +41 -2
- package/plugin/skills/conductor-console/SKILL.md +26 -27
- package/plugin/skills/work-item/SKILL.md +57 -53
- 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
|
@@ -2,19 +2,21 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { freePorts, portEnv, releaseLocks } from "./broker.js";
|
|
5
|
-
import { isLive, isStarting, permissionPrompt, SessionGone, turnEnded, } from "./claude.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 { 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,6 +98,9 @@ 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;
|
|
100
105
|
// A compaction with no boundary in the transcript this long after it started, its turn over, failed.
|
|
101
106
|
const COMPACT_MINUTES = 10;
|
|
@@ -118,7 +123,7 @@ export function eventText(waits) {
|
|
|
118
123
|
return `${WAKE_PREFIX}${[...facts].map(([fact, at]) => `${fact} ${clock(new Date(at))}`).join('; ')}`;
|
|
119
124
|
}
|
|
120
125
|
export function sessionManager(deps) {
|
|
121
|
-
const { broker, claude, config, log,
|
|
126
|
+
const { broker, claude, config, log, pluginDir, prs, store, tabs, worktrees } = deps;
|
|
122
127
|
const now = deps.now ?? (() => new Date());
|
|
123
128
|
const alive = deps.processAlive ?? defaultProcessAlive;
|
|
124
129
|
const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
@@ -128,6 +133,8 @@ export function sessionManager(deps) {
|
|
|
128
133
|
const stalls = new Map();
|
|
129
134
|
const transcripts = new Map();
|
|
130
135
|
const transcriptScans = new Map();
|
|
136
|
+
// worker id → a session id the listing shows for it that it hasn't followed yet, since when
|
|
137
|
+
const newSessionIds = new Map();
|
|
131
138
|
let sessions = [];
|
|
132
139
|
// Until the first sweep, nothing is known about any session.
|
|
133
140
|
let listed = false;
|
|
@@ -138,6 +145,8 @@ export function sessionManager(deps) {
|
|
|
138
145
|
const held = new Set();
|
|
139
146
|
// Workers whose compaction failed: not tried again while the daemon runs (their inbox item says so).
|
|
140
147
|
const uncompactable = new Set();
|
|
148
|
+
// worker id → the attach (its attachedAt) a retire was last refused for, so each is logged once.
|
|
149
|
+
const attachRefusals = new Map();
|
|
141
150
|
// Every session the conductor starts loads its plugin, and a resume whose saved --plugin-dir went
|
|
142
151
|
// missing runs on without it, silently (ATK-522). The mod reports each session start (pluginSeen),
|
|
143
152
|
// so a worker started or woken that doesn't report within PLUGIN_REPORT_MS is a
|
|
@@ -228,10 +237,15 @@ export function sessionManager(deps) {
|
|
|
228
237
|
throw new ConductorError(`project "${found.name}" has a problem: ${found.problem ?? 'no profile'}`);
|
|
229
238
|
if (!found.enabled)
|
|
230
239
|
throw new ConductorError(`project "${found.name}" is disabled`);
|
|
240
|
+
if (registeredByCheckout(deps.paths, found)) {
|
|
241
|
+
throw new ConductorError(`project "${found.name}" is registered by the checkout ${found.clone}`, [
|
|
242
|
+
`the conductor works only in a clone of its own: run \`conduct project add "${found.clone}"\` first`,
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
231
245
|
return { ...found, profile: found.profile };
|
|
232
246
|
};
|
|
233
|
-
// Where a worker's commands run: its project's
|
|
234
|
-
const
|
|
247
|
+
// Where a worker's commands run when it has no worktree: its project's clone.
|
|
248
|
+
const cloneOf = (worker) => projectPaths(deps.paths, worker.project).clone;
|
|
235
249
|
const sessionOf = (worker) => sessions.find((s) => (worker.sessionId !== null && s.sessionId === worker.sessionId) ||
|
|
236
250
|
(worker.handle !== null && s.id === worker.handle));
|
|
237
251
|
const minutesSince = (iso) => (now().getTime() - Date.parse(iso)) / 60_000;
|
|
@@ -269,13 +283,14 @@ export function sessionManager(deps) {
|
|
|
269
283
|
};
|
|
270
284
|
// ---------- launch ----------
|
|
271
285
|
// 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
|
-
|
|
286
|
+
// in the project's clone from origin/<base>, the local files and the profile's setup. The session
|
|
287
|
+
// starts in it.
|
|
288
|
+
const launchWorker = async (worker, profile, prompt) => {
|
|
274
289
|
let current = worker;
|
|
275
290
|
let stage = 'assign ports';
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
// this launch's to remove when it fails.
|
|
291
|
+
const { clone, worktrees: dir } = projectPaths(deps.paths, worker.project);
|
|
292
|
+
const path = join(dir, worker.name);
|
|
293
|
+
// A worktree there already (a kept one) isn't this launch's to remove when it fails.
|
|
279
294
|
const reused = existsSync(join(path, '.git'));
|
|
280
295
|
try {
|
|
281
296
|
// Recorded before it exists: the broker keeps an in-flight worker's ports while its directory
|
|
@@ -286,10 +301,11 @@ export function sessionManager(deps) {
|
|
|
286
301
|
const env = portEnv(ports);
|
|
287
302
|
const worktree = await worktrees.create({
|
|
288
303
|
base: profile.baseBranch,
|
|
304
|
+
clone,
|
|
289
305
|
env: { ...env, CONDUCTOR_ITEM: worker.item, CONDUCTOR_PROJECT: worker.project, CONDUCTOR_WORKTREE: path },
|
|
290
306
|
...(profile.localFilesFrom === undefined ? {} : { localFilesFrom: profile.localFilesFrom }),
|
|
291
307
|
name: worker.name,
|
|
292
|
-
|
|
308
|
+
path,
|
|
293
309
|
sessionEnv: env,
|
|
294
310
|
setup: profile.setup,
|
|
295
311
|
});
|
|
@@ -301,13 +317,23 @@ export function sessionManager(deps) {
|
|
|
301
317
|
throw new ConductorError(problem);
|
|
302
318
|
stage = 'start session';
|
|
303
319
|
const started = now().getTime();
|
|
304
|
-
const handle = await claude
|
|
320
|
+
const handle = await claude
|
|
321
|
+
.launch({
|
|
305
322
|
cwd: worktree,
|
|
306
323
|
disallowedTools: DISALLOWED_TOOLS,
|
|
307
324
|
name: worker.name,
|
|
308
325
|
permissionMode: PERMISSION_MODE,
|
|
309
326
|
pluginDir,
|
|
310
327
|
prompt,
|
|
328
|
+
})
|
|
329
|
+
.catch((error) => {
|
|
330
|
+
// Claude Code's own message names the worktree, which this failed launch removes.
|
|
331
|
+
if (!(error instanceof WorkspaceUntrusted))
|
|
332
|
+
throw error;
|
|
333
|
+
throw new ConductorError(`Claude Code doesn't trust ${clone}`, [
|
|
334
|
+
`${trustStep(clone)}, then \`conduct project check ${worker.project}\``,
|
|
335
|
+
...error.details,
|
|
336
|
+
]);
|
|
311
337
|
});
|
|
312
338
|
try {
|
|
313
339
|
sessions = await claude.agents();
|
|
@@ -320,8 +346,8 @@ export function sessionManager(deps) {
|
|
|
320
346
|
current = update(current, { handle, sessionId: session?.sessionId ?? null, status: 'running' });
|
|
321
347
|
event(current, 'worker.launched', { handle, pluginDir, sessionId: current.sessionId, worktree });
|
|
322
348
|
expectPlugin(current, started);
|
|
323
|
-
if (
|
|
324
|
-
const tab = await
|
|
349
|
+
if (tabs.onLaunch) {
|
|
350
|
+
const tab = await tabs.open({
|
|
325
351
|
cwd: worktree,
|
|
326
352
|
handle,
|
|
327
353
|
title: worker.name,
|
|
@@ -336,7 +362,7 @@ export function sessionManager(deps) {
|
|
|
336
362
|
const cleanup = reused
|
|
337
363
|
? { kept: path, keptBecause: 'it was there before the launch' }
|
|
338
364
|
: current.worktree && existsSync(current.worktree)
|
|
339
|
-
? await removeIfClean(
|
|
365
|
+
? await removeIfClean(clone, current.worktree)
|
|
340
366
|
: {};
|
|
341
367
|
freePorts(store, path, 'launch failed');
|
|
342
368
|
current = update(current, { status: 'failed' });
|
|
@@ -346,13 +372,13 @@ export function sessionManager(deps) {
|
|
|
346
372
|
};
|
|
347
373
|
// A failed launch's worktree, unless it holds work. Its branch goes with it while it still has the
|
|
348
374
|
// name the launch gave it; a renamed one was the worker's.
|
|
349
|
-
const removeIfClean = async (
|
|
375
|
+
const removeIfClean = async (clone, worktree) => {
|
|
350
376
|
try {
|
|
351
377
|
const work = await worktrees.unfinishedWork(worktree);
|
|
352
378
|
if (work)
|
|
353
379
|
return { kept: worktree, ...work };
|
|
354
380
|
const branch = await worktrees.branchOf(worktree);
|
|
355
|
-
await worktrees.remove(
|
|
381
|
+
await worktrees.remove(worktree, { deleteBranch: branch?.startsWith(BRANCH_PREFIX) === true, repo: clone });
|
|
356
382
|
return { removed: worktree };
|
|
357
383
|
}
|
|
358
384
|
catch (error) {
|
|
@@ -442,7 +468,7 @@ export function sessionManager(deps) {
|
|
|
442
468
|
const text = eventText(resolved);
|
|
443
469
|
const started = now().getTime();
|
|
444
470
|
const { copy } = await claude.resume({
|
|
445
|
-
cwd: session?.cwd ?? worker.worktree ??
|
|
471
|
+
cwd: session?.cwd ?? worker.worktree ?? cloneOf(worker),
|
|
446
472
|
sessionId: worker.sessionId,
|
|
447
473
|
text,
|
|
448
474
|
});
|
|
@@ -491,7 +517,7 @@ export function sessionManager(deps) {
|
|
|
491
517
|
let copy;
|
|
492
518
|
try {
|
|
493
519
|
({ copy } = await claude.resume({
|
|
494
|
-
cwd: session?.cwd ?? worker.worktree ??
|
|
520
|
+
cwd: session?.cwd ?? worker.worktree ?? cloneOf(worker),
|
|
495
521
|
sessionId: worker.sessionId,
|
|
496
522
|
text: COMPACT_TEXT,
|
|
497
523
|
}));
|
|
@@ -568,7 +594,7 @@ export function sessionManager(deps) {
|
|
|
568
594
|
});
|
|
569
595
|
}
|
|
570
596
|
}
|
|
571
|
-
// An attached worker is
|
|
597
|
+
// An attached worker is the operator's while they have it open. One whose Handoff is open waits on them,
|
|
572
598
|
// though no wait says so: its note raised the inbox item already (ATK-416).
|
|
573
599
|
if (!turnEnded(session) || waiting || worker.attachedAt !== null || handedOff(worker)) {
|
|
574
600
|
cleared('idle', state.idle?.reported);
|
|
@@ -584,13 +610,13 @@ export function sessionManager(deps) {
|
|
|
584
610
|
}
|
|
585
611
|
stalls.set(worker.id, state);
|
|
586
612
|
};
|
|
587
|
-
// The daemon can't see the tab, so attached means "
|
|
613
|
+
// The daemon can't see the tab, so attached means "the operator may be in it" only while the session is
|
|
588
614
|
// in use. Once its turn has been over `idleMinutes`, the worker is the daemon's again, and its
|
|
589
615
|
// waits park and resume it as usual. A resolved wait takes it back at once when the transcript
|
|
590
616
|
// shows no turn since the attach: nobody is in it, and the park and resume deliver the wait
|
|
591
617
|
// (ATK-516). A turn, or no transcript to tell, holds it, and the hold is logged once.
|
|
592
618
|
// Only a wait that resolved after the attach releases it: one resolved before was there when
|
|
593
|
-
//
|
|
619
|
+
// The operator chose to attach, so taking the session back would undo the attach.
|
|
594
620
|
// A new attach, or the end of one, starts its spell afresh.
|
|
595
621
|
const forgetAttach = (workerId) => {
|
|
596
622
|
const state = stalls.get(workerId);
|
|
@@ -637,14 +663,29 @@ export function sessionManager(deps) {
|
|
|
637
663
|
let current = worker;
|
|
638
664
|
const session = sessionOf(current);
|
|
639
665
|
// The listing is the truth for ids: fill them in after a launch, and follow a changed short id.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
666
|
+
// A changed session id is followed once it has stayed listed SESSION_ID_SETTLE_MS.
|
|
667
|
+
if (session) {
|
|
668
|
+
let sessionId = current.sessionId;
|
|
669
|
+
const pending = newSessionIds.get(current.id);
|
|
670
|
+
if (sessionId === null || session.sessionId === sessionId)
|
|
671
|
+
newSessionIds.delete(current.id);
|
|
672
|
+
else if (pending?.sessionId !== session.sessionId) {
|
|
673
|
+
newSessionIds.set(current.id, { sessionId: session.sessionId, since: now().getTime() });
|
|
674
|
+
}
|
|
675
|
+
else if (now().getTime() - pending.since >= SESSION_ID_SETTLE_MS) {
|
|
676
|
+
newSessionIds.delete(current.id);
|
|
677
|
+
// A new session id (a /clear) writes a new transcript.
|
|
643
678
|
transcripts.delete(current.id);
|
|
644
679
|
transcriptScans.delete(current.id);
|
|
680
|
+
sessionId = session.sessionId;
|
|
681
|
+
}
|
|
682
|
+
sessionId ??= session.sessionId;
|
|
683
|
+
if (sessionId !== current.sessionId || session.id !== current.handle) {
|
|
684
|
+
current = update(current, { handle: session.id, sessionId });
|
|
645
685
|
}
|
|
646
|
-
current = update(current, { handle: session.id, sessionId: session.sessionId });
|
|
647
686
|
}
|
|
687
|
+
else
|
|
688
|
+
newSessionIds.delete(current.id);
|
|
648
689
|
const waits = store.waitsOf(current.id);
|
|
649
690
|
const resolved = waits.filter((w) => w.resolvedAt !== null);
|
|
650
691
|
// A listed pid can outlive its process for a moment after a stop.
|
|
@@ -692,6 +733,10 @@ export function sessionManager(deps) {
|
|
|
692
733
|
}
|
|
693
734
|
if (current.status !== 'parked')
|
|
694
735
|
return;
|
|
736
|
+
// A park needs a wait, so a parked worker with none was stopped by a retire that didn't finish
|
|
737
|
+
// (ATK-519): the retire, tried again, takes it on.
|
|
738
|
+
if (waits.length === 0)
|
|
739
|
+
return;
|
|
695
740
|
if (resolved.length > 0)
|
|
696
741
|
await resume(current, resolved, session);
|
|
697
742
|
else
|
|
@@ -832,8 +877,8 @@ export function sessionManager(deps) {
|
|
|
832
877
|
});
|
|
833
878
|
continue;
|
|
834
879
|
}
|
|
835
|
-
const root =
|
|
836
|
-
const worktree = worker.worktree ??
|
|
880
|
+
const root = cloneOf(worker);
|
|
881
|
+
const worktree = worker.worktree ?? join(projectPaths(deps.paths, worker.project).worktrees, worker.name);
|
|
837
882
|
const reused = store
|
|
838
883
|
.workerEvents(worker.id, 1000)
|
|
839
884
|
.some((e) => e.kind === 'worker.launching' && e.data.reused === true);
|
|
@@ -872,7 +917,7 @@ export function sessionManager(deps) {
|
|
|
872
917
|
const problem = uncheckable(parsed, project?.profile ?? null);
|
|
873
918
|
if (problem) {
|
|
874
919
|
throw new ConductorError(problem, [
|
|
875
|
-
`the conductor takes ${worker.project}'s profile
|
|
920
|
+
`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`,
|
|
876
921
|
]);
|
|
877
922
|
}
|
|
878
923
|
// Stored in its normal full form, so the watchers and `conduct status` read one spelling. A
|
|
@@ -914,7 +959,7 @@ export function sessionManager(deps) {
|
|
|
914
959
|
// No event text: the session wakes idle and keeps running when the tab closes.
|
|
915
960
|
started = now().getTime();
|
|
916
961
|
const { copy } = await claude.resume({
|
|
917
|
-
cwd: sessionOf(current)?.cwd ?? current.worktree ??
|
|
962
|
+
cwd: sessionOf(current)?.cwd ?? current.worktree ?? cloneOf(current),
|
|
918
963
|
sessionId,
|
|
919
964
|
text: null,
|
|
920
965
|
});
|
|
@@ -926,16 +971,12 @@ export function sessionManager(deps) {
|
|
|
926
971
|
}
|
|
927
972
|
current = update(current, { attachedAt: now().toISOString(), status: 'running', stoppedAt: null });
|
|
928
973
|
forgetAttach(current.id);
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
handle,
|
|
932
|
-
title: current.name,
|
|
933
|
-
window: `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`,
|
|
934
|
-
});
|
|
974
|
+
const window = `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`;
|
|
975
|
+
const tab = await tabs.open({ cwd: current.worktree ?? cloneOf(current), handle, title: current.name, window });
|
|
935
976
|
event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
|
|
936
977
|
if (started !== null)
|
|
937
978
|
expectPlugin(current, started);
|
|
938
|
-
return { resumed, tab, worker: current };
|
|
979
|
+
return { hint: tab === null ? tabs.hint(window) : null, resumed, tab, worker: current };
|
|
939
980
|
}
|
|
940
981
|
finally {
|
|
941
982
|
busy.delete(worker.id);
|
|
@@ -964,19 +1005,22 @@ export function sessionManager(deps) {
|
|
|
964
1005
|
project: target.name,
|
|
965
1006
|
});
|
|
966
1007
|
// A worktree there already isn't the launch's to remove if it fails, after a restart too.
|
|
967
|
-
const reused = existsSync(join(
|
|
1008
|
+
const reused = existsSync(join(projectPaths(deps.paths, target.name).worktrees, name, '.git'));
|
|
968
1009
|
event(worker, 'worker.launching', {
|
|
969
1010
|
probe: worker.probe,
|
|
970
1011
|
...(reused ? { reused } : {}),
|
|
971
1012
|
...(request.cause === undefined ? {} : { cause: request.cause }),
|
|
972
1013
|
});
|
|
973
1014
|
busy.add(worker.id);
|
|
974
|
-
void track(launchWorker(worker, target.
|
|
1015
|
+
void track(launchWorker(worker, target.profile, prompt).finally(() => busy.delete(worker.id)));
|
|
975
1016
|
return worker;
|
|
976
1017
|
},
|
|
977
1018
|
pluginSeen: (sessionId) => {
|
|
978
1019
|
pluginReports.set(sessionId, now().getTime());
|
|
979
|
-
|
|
1020
|
+
// A /clear's session reports before the worker follows its new id.
|
|
1021
|
+
return (store
|
|
1022
|
+
.listWorkers(ACTIVE_STATUSES)
|
|
1023
|
+
.find((w) => w.sessionId === sessionId || newSessionIds.get(w.id)?.sessionId === sessionId) ?? null);
|
|
980
1024
|
},
|
|
981
1025
|
retire: async (workerId, { cause, force = false } = {}) => {
|
|
982
1026
|
const worker = activeWorker(workerId);
|
|
@@ -990,8 +1034,6 @@ export function sessionManager(deps) {
|
|
|
990
1034
|
}
|
|
991
1035
|
busy.add(worker.id);
|
|
992
1036
|
try {
|
|
993
|
-
// Fresh: the last sweep's listing can be ten seconds old.
|
|
994
|
-
const session = (await claude.agents().catch(() => sessions)).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
|
|
995
1037
|
const refuse = (reason, transient, work) => {
|
|
996
1038
|
event(worker, 'worker.retire-refused', {
|
|
997
1039
|
reason,
|
|
@@ -1001,6 +1043,27 @@ export function sessionManager(deps) {
|
|
|
1001
1043
|
});
|
|
1002
1044
|
return { outcome: 'refused', reason, transient, ...(work ? { work } : {}), worker };
|
|
1003
1045
|
};
|
|
1046
|
+
// The operator has its session open in a tab (ATK-519). The scheduler tries again every tick,
|
|
1047
|
+
// so it is logged once per attach. Only a running worker is attached: one whose session
|
|
1048
|
+
// stopped unexpectedly keeps the attach it had.
|
|
1049
|
+
const attachedAt = worker.status === 'running' ? worker.attachedAt : null;
|
|
1050
|
+
if (attachedAt !== null && !force) {
|
|
1051
|
+
const reason = 'attached: retire once its tab is released, or retire --force';
|
|
1052
|
+
if (attachRefusals.get(worker.id) !== attachedAt) {
|
|
1053
|
+
attachRefusals.set(worker.id, attachedAt);
|
|
1054
|
+
event(worker, 'worker.retire-refused', {
|
|
1055
|
+
attached: true,
|
|
1056
|
+
reason,
|
|
1057
|
+
transient: true,
|
|
1058
|
+
...(cause === undefined ? {} : { cause }),
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
return { attached: true, outcome: 'refused', reason, transient: true, worker };
|
|
1062
|
+
}
|
|
1063
|
+
attachRefusals.delete(worker.id);
|
|
1064
|
+
// Fresh: the last sweep's listing can be ten seconds old. Null when it couldn't be read.
|
|
1065
|
+
const listing = await claude.agents().catch(() => null);
|
|
1066
|
+
const session = (listing ?? sessions).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
|
|
1004
1067
|
// Removing the worktree while `claude --bg` still sets the session up there strands it.
|
|
1005
1068
|
if (session && isStarting(session))
|
|
1006
1069
|
return refuse('its session is still starting: try again shortly', true);
|
|
@@ -1017,7 +1080,6 @@ export function sessionManager(deps) {
|
|
|
1017
1080
|
const work = fate.remove && worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
|
|
1018
1081
|
if (work && !force)
|
|
1019
1082
|
return refuse('unfinished work: commit and push it, or retire --force to throw it away', false, work);
|
|
1020
|
-
const root = store.listProjects().find((p) => p.name === worker.project)?.root;
|
|
1021
1083
|
const steps = [];
|
|
1022
1084
|
const handle = worker.handle;
|
|
1023
1085
|
if (handle !== null && session?.pid !== undefined) {
|
|
@@ -1039,14 +1101,17 @@ export function sessionManager(deps) {
|
|
|
1039
1101
|
if (worker.worktree !== null) {
|
|
1040
1102
|
const worktree = worker.worktree;
|
|
1041
1103
|
if (fate.remove) {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
await worktrees.remove(root, worktree, { deleteBranch: true });
|
|
1046
|
-
});
|
|
1104
|
+
// The repository is the worktree's own: one launched before clones is in a person's
|
|
1105
|
+
// checkout, and retires from there.
|
|
1106
|
+
await step(steps, 'remove worktree', () => worktrees.remove(worktree, { deleteBranch: true, repo: cloneOf(worker) }));
|
|
1047
1107
|
}
|
|
1048
1108
|
else if (existsSync(worktree)) {
|
|
1049
|
-
kept = {
|
|
1109
|
+
kept = {
|
|
1110
|
+
branch: await worktrees.branchOf(worktree),
|
|
1111
|
+
reason: fate.reason,
|
|
1112
|
+
repo: await worktrees.repoOf(worktree),
|
|
1113
|
+
worktree,
|
|
1114
|
+
};
|
|
1050
1115
|
}
|
|
1051
1116
|
// No session serves on them any more, kept worktree or not.
|
|
1052
1117
|
freePorts(store, worktree, 'worker retired');
|
|
@@ -1060,11 +1125,21 @@ export function sessionManager(deps) {
|
|
|
1060
1125
|
if (handle !== null)
|
|
1061
1126
|
await step(steps, 'rm', () => claude.rm(handle));
|
|
1062
1127
|
const ok = steps.every((s) => s.ok);
|
|
1063
|
-
|
|
1064
|
-
|
|
1128
|
+
// Its session no longer runs once the stop succeeded, or when a fresh listing showed it wasn't
|
|
1129
|
+
// live, even when a later step failed. A retire that didn't finish then parks the worker with no
|
|
1130
|
+
// wait, so no sweep reads it as stopped unexpectedly or wakes it, and the retire is tried again
|
|
1131
|
+
// (ATK-519). With no fresh listing to say, the worker stays as it was.
|
|
1132
|
+
const down = steps.some((s) => s.step === 'stop' && s.ok) || (listing !== null && !(session && isLive(session)));
|
|
1133
|
+
const stoppedAt = now().toISOString();
|
|
1134
|
+
const parked = !ok && down;
|
|
1135
|
+
const current = ok
|
|
1136
|
+
? update(worker, { status: 'exited', stoppedAt })
|
|
1137
|
+
: parked
|
|
1138
|
+
? update(worker, { attachedAt: null, status: 'parked', stoppedAt })
|
|
1139
|
+
: worker;
|
|
1140
|
+
if (ok || parked)
|
|
1065
1141
|
store.closeWaits(worker.id, 'worker retired');
|
|
1066
|
-
//
|
|
1067
|
-
// the live worker, whose locks those are.
|
|
1142
|
+
// A probe shares its item with the live worker, whose locks those are.
|
|
1068
1143
|
if (!worker.probe)
|
|
1069
1144
|
releaseLocks(store, worker.item, 'worker retired');
|
|
1070
1145
|
stalls.delete(worker.id);
|
|
@@ -1072,6 +1147,7 @@ export function sessionManager(deps) {
|
|
|
1072
1147
|
forced: force && work !== null,
|
|
1073
1148
|
steps,
|
|
1074
1149
|
...(kept === null ? {} : { kept }),
|
|
1150
|
+
...(parked ? { parked } : {}),
|
|
1075
1151
|
...(cause === undefined ? {} : { cause }),
|
|
1076
1152
|
});
|
|
1077
1153
|
return { outcome: ok ? 'retired' : 'partial', steps, worker: current };
|
|
@@ -1092,7 +1168,7 @@ export function sessionManager(deps) {
|
|
|
1092
1168
|
try {
|
|
1093
1169
|
const started = now().getTime();
|
|
1094
1170
|
const { copy } = await claude.resume({
|
|
1095
|
-
cwd: worker.worktree ??
|
|
1171
|
+
cwd: worker.worktree ?? cloneOf(worker),
|
|
1096
1172
|
sessionId: worker.sessionId,
|
|
1097
1173
|
text,
|
|
1098
1174
|
});
|