@prohost/cli 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +46 -1
- package/dist/agent/account_runtime.d.ts +19 -3
- package/dist/agent/account_runtime.js +28 -6
- package/dist/agent/claude.js +9 -3
- package/dist/agent/command.d.ts +9 -0
- package/dist/agent/command.js +64 -2
- package/dist/agent/daemon.d.ts +4 -0
- package/dist/agent/daemon.js +4 -0
- package/dist/agent/prompt.d.ts +27 -0
- package/dist/agent/prompt.js +21 -1
- package/dist/agent/run.d.ts +28 -3
- package/dist/agent/run.js +90 -18
- package/dist/agent/scheduler.d.ts +44 -0
- package/dist/agent/scheduler.js +93 -0
- package/dist/agent/worktrees.d.ts +136 -0
- package/dist/agent/worktrees.js +317 -0
- package/dist/index.js +12 -2
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/run.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two deliberate safety properties:
|
|
12
12
|
*
|
|
13
|
-
* * **Runs are serialized.** One local agent, one run at a time;
|
|
14
|
-
* frames queue. Agents hold working directories and rate limits,
|
|
15
|
-
* several at once is a good way to corrupt both.
|
|
13
|
+
* * **Runs are serialized by default.** One local agent, one run at a time;
|
|
14
|
+
* concurrent frames queue. Agents hold working directories and rate limits,
|
|
15
|
+
* and running several at once is a good way to corrupt both. `--concurrency`
|
|
16
|
+
* raises the limit for runs in *different* conversations only — two turns of
|
|
17
|
+
* one conversation never overlap (see `scheduler.ts`) — and `--repos` gives
|
|
18
|
+
* each conversation its own git checkouts to do it in (see `worktrees.ts`).
|
|
16
19
|
* * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
|
|
17
20
|
* attempt is mirrored to this socket. Without dedupe a retry would execute
|
|
18
21
|
* the command a second time and post a second reply.
|
|
@@ -40,9 +43,11 @@ import { HeartbeatLoop } from './heartbeat.js';
|
|
|
40
43
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
41
44
|
import { NO_CAPABILITIES, capabilityProbe } from './capabilities.js';
|
|
42
45
|
import { RunStore } from './runstore.js';
|
|
46
|
+
import { MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
|
|
43
47
|
import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
|
|
44
48
|
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
45
49
|
import { verifySignature } from './signature.js';
|
|
50
|
+
import { RUN_REPOS_ENV_VAR, WorktreeManager } from './worktrees.js';
|
|
46
51
|
import { isWebhookEvent } from '../forward.js';
|
|
47
52
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
48
53
|
import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from '../liveness.js';
|
|
@@ -89,6 +94,15 @@ const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
|
89
94
|
* would hang with neither `open` nor `close` to move the loop along.
|
|
90
95
|
*/
|
|
91
96
|
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
97
|
+
/**
|
|
98
|
+
* Set to `1` in the environment of every agent command this harness starts.
|
|
99
|
+
*
|
|
100
|
+
* Lets instructions the agent reads from disk tell a paired run — someone is
|
|
101
|
+
* waiting in a thread, and the run holds one of this agent's few slots — from
|
|
102
|
+
* the same file being read anywhere else. ProhostAI's own pull-request protocol
|
|
103
|
+
* uses it to stop a run from sitting on CI after the PR is open.
|
|
104
|
+
*/
|
|
105
|
+
export const PAIRED_RUN_ENV_VAR = 'PROHOST_PAIRED_RUN';
|
|
92
106
|
/** Ack status codes reported back over the socket for each frame. */
|
|
93
107
|
const ACK_ACCEPTED = 200;
|
|
94
108
|
const ACK_IGNORED = 204;
|
|
@@ -228,6 +242,16 @@ function resolveWorkdir(options, log) {
|
|
|
228
242
|
return undefined;
|
|
229
243
|
}
|
|
230
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* The lane a run waits on: runs sharing one never overlap.
|
|
247
|
+
*
|
|
248
|
+
* The session key, because a session is exactly what two overlapping turns of
|
|
249
|
+
* one conversation would corrupt. A run with no surface resumes nothing and
|
|
250
|
+
* shares nothing, so it gets a lane of its own.
|
|
251
|
+
*/
|
|
252
|
+
function laneFor(run) {
|
|
253
|
+
return sessionKeyFor(run) ?? `run:${run.run_id}`;
|
|
254
|
+
}
|
|
231
255
|
/**
|
|
232
256
|
* Assemble the per-process runtime: workspace, session ledger, and the
|
|
233
257
|
* brain-specific MCP preparation used immediately before each run.
|
|
@@ -330,7 +354,11 @@ function buildRuntime(options, log) {
|
|
|
330
354
|
if (!resolved.ok)
|
|
331
355
|
return { mcpWired: false, error: resolved.error };
|
|
332
356
|
const prepared = prepareMcp();
|
|
333
|
-
return {
|
|
357
|
+
return {
|
|
358
|
+
...prepared,
|
|
359
|
+
execEnv: { ...account.spawnEnv(), ...prepared.execEnv },
|
|
360
|
+
accountLabel: resolved.record.label,
|
|
361
|
+
};
|
|
334
362
|
};
|
|
335
363
|
log(`${timestamp()} ⚙ account: ${account.label()}`);
|
|
336
364
|
return {
|
|
@@ -579,7 +607,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log,
|
|
|
579
607
|
* Where the streaming brain's tool-call events go, if anywhere. Only the
|
|
580
608
|
* Claude Code reader produces any; absent, they are simply never collected.
|
|
581
609
|
*/
|
|
582
|
-
toolEvents
|
|
610
|
+
toolEvents,
|
|
611
|
+
/** Harness-set variables for this run's command, over the brain's own. */
|
|
612
|
+
runEnv) {
|
|
583
613
|
// `--timeout` is a ceiling on the run, not on each attempt: the retry below
|
|
584
614
|
// must not let one run take twice what the operator allowed. Both attempts
|
|
585
615
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
@@ -667,7 +697,7 @@ toolEvents) {
|
|
|
667
697
|
timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
|
|
668
698
|
idleTimeoutMs: idleMs,
|
|
669
699
|
cwd: runtime.workdir,
|
|
670
|
-
env: mcp.execEnv,
|
|
700
|
+
env: { ...mcp.execEnv, ...runEnv },
|
|
671
701
|
spawnImpl: options.spawnImpl,
|
|
672
702
|
cancelSignal: control.signal,
|
|
673
703
|
onStdout: reader
|
|
@@ -842,11 +872,18 @@ toolEvents) {
|
|
|
842
872
|
* Every path except a dry run — including every failure — calls the completion
|
|
843
873
|
* endpoint before returning.
|
|
844
874
|
*/
|
|
845
|
-
async function handleRun(run, options, runtime, api, log, sleep, control
|
|
875
|
+
async function handleRun(run, options, runtime, api, log, sleep, control, isolation,
|
|
876
|
+
/** Prepared by the caller, which also hands the run's account back. */
|
|
877
|
+
mcp) {
|
|
846
878
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
847
879
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
848
880
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
849
|
-
|
|
881
|
+
// Before anything is spawned: the prompt names these paths. A dry run makes
|
|
882
|
+
// no writes, and a worktree is one.
|
|
883
|
+
const checkouts = isolation.worktrees.enabled && !options.dryRun ? await isolation.worktrees.prepare(isolation.lane) : undefined;
|
|
884
|
+
for (const failure of checkouts?.failures ?? []) {
|
|
885
|
+
log(` ! no private checkout of ${failure.source} for ${label} (${failure.reason})`);
|
|
886
|
+
}
|
|
850
887
|
// Only worth asking when the tools it describes are wired into this run.
|
|
851
888
|
const capabilities = mcp.mcpWired ? await runtime.capabilities() : NO_CAPABILITIES;
|
|
852
889
|
const prompt = buildPrompt(run, {
|
|
@@ -865,6 +902,9 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
865
902
|
// The real numbers, not a vague "there is a limit" — see PromptContext.
|
|
866
903
|
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
867
904
|
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
|
|
905
|
+
parallel: isolation.parallel,
|
|
906
|
+
checkouts: checkouts?.checkouts,
|
|
907
|
+
checkoutFailures: checkouts?.failures,
|
|
868
908
|
});
|
|
869
909
|
// Tool-call narration for the thread. A dry run makes no writes, so it gets
|
|
870
910
|
// no path and every event is dropped; so does a server that advertised none.
|
|
@@ -874,7 +914,10 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
874
914
|
log,
|
|
875
915
|
label,
|
|
876
916
|
});
|
|
877
|
-
const
|
|
917
|
+
const runEnv = { [PAIRED_RUN_ENV_VAR]: '1' };
|
|
918
|
+
if (checkouts && checkouts.checkouts.length > 0)
|
|
919
|
+
runEnv[RUN_REPOS_ENV_VAR] = checkouts.dir;
|
|
920
|
+
const result = await runTurn(run, prompt, options, runtime, mcp, control, log, toolEvents, runEnv);
|
|
878
921
|
// Whatever the turn did, the timeline is whole BEFORE the reply is posted or
|
|
879
922
|
// the run is closed — a completion that beats its own last tool row would
|
|
880
923
|
// leave a `calling` row on a finished run. Never rejects, and after `stop`
|
|
@@ -1109,7 +1152,19 @@ export async function runAgentHarness(options) {
|
|
|
1109
1152
|
}
|
|
1110
1153
|
return true;
|
|
1111
1154
|
};
|
|
1112
|
-
|
|
1155
|
+
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ?? 1)));
|
|
1156
|
+
const scheduler = new RunScheduler(concurrency);
|
|
1157
|
+
const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
|
|
1158
|
+
if (concurrency > 1) {
|
|
1159
|
+
log(`${timestamp()} ⚙ up to ${concurrency} runs at once — runs in the same conversation still take turns` +
|
|
1160
|
+
(worktrees.enabled
|
|
1161
|
+
? ''
|
|
1162
|
+
: '. No --repos given: runs share every checkout on this machine, so name the ' +
|
|
1163
|
+
'repositories the agent edits to give each conversation its own'));
|
|
1164
|
+
}
|
|
1165
|
+
if (worktrees.enabled) {
|
|
1166
|
+
log(`${timestamp()} ⚙ each conversation gets its own checkout of: ${(options.repos ?? []).join(', ')}`);
|
|
1167
|
+
}
|
|
1113
1168
|
let fatal;
|
|
1114
1169
|
let attempt = 0;
|
|
1115
1170
|
/** Stop tracking a run — it has reached a terminal state, one way or another. */
|
|
@@ -1195,7 +1250,7 @@ export async function runAgentHarness(options) {
|
|
|
1195
1250
|
// Let an in-flight run finish reporting before we hand back metrics — and
|
|
1196
1251
|
// any cancellation the heartbeat loop started and deliberately did not wait
|
|
1197
1252
|
// for, so the harness never exits with a run half-closed.
|
|
1198
|
-
await
|
|
1253
|
+
await scheduler.onIdle();
|
|
1199
1254
|
status.detach();
|
|
1200
1255
|
// Stopped first, so no further tick can start work after the wait below has
|
|
1201
1256
|
// taken its snapshot of `background`.
|
|
@@ -1404,7 +1459,7 @@ export async function runAgentHarness(options) {
|
|
|
1404
1459
|
// completion. Retry only the completion: re-running the agent could
|
|
1405
1460
|
// repeat whatever it did with its credential the first time.
|
|
1406
1461
|
metrics.duplicates += 1;
|
|
1407
|
-
|
|
1462
|
+
scheduler.submit(laneFor(run), async () => {
|
|
1408
1463
|
log(`${timestamp()} ↻ run ${run.run_id.slice(0, 12)} was started before but never reported; ` +
|
|
1409
1464
|
'reporting it now without re-running the agent');
|
|
1410
1465
|
const recovered = await completeWithRetry(api, previous.completion_path || run.completion_path, { ok: false, error: 'the harness restarted before this run was reported' }, log, sleep);
|
|
@@ -1434,9 +1489,11 @@ export async function runAgentHarness(options) {
|
|
|
1434
1489
|
},
|
|
1435
1490
|
onResolved: () => resolveAccepted(entry),
|
|
1436
1491
|
});
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
|
|
1492
|
+
// Queue execution but never block the socket reader: the ack and the
|
|
1493
|
+
// heartbeat have to keep flowing while the agent thinks. The lane is
|
|
1494
|
+
// what keeps two turns of one conversation from overlapping.
|
|
1495
|
+
const lane = laneFor(run);
|
|
1496
|
+
scheduler.submit(lane, async () => {
|
|
1440
1497
|
// Settled while it waited — cancelled, or closed server-side. Both
|
|
1441
1498
|
// were dealt with where they were decided; there is nothing to run
|
|
1442
1499
|
// and nothing left to report.
|
|
@@ -1465,17 +1522,32 @@ export async function runAgentHarness(options) {
|
|
|
1465
1522
|
entry.progress = activity;
|
|
1466
1523
|
},
|
|
1467
1524
|
};
|
|
1468
|
-
const
|
|
1525
|
+
const isolation = { lane, parallel: concurrency > 1, worktrees };
|
|
1526
|
+
// Resolved here rather than inside `handleRun` so the account this
|
|
1527
|
+
// run is on can be handed back however the run ends.
|
|
1528
|
+
let mcp;
|
|
1529
|
+
try {
|
|
1530
|
+
mcp = runtime.prepareMcp();
|
|
1531
|
+
}
|
|
1532
|
+
catch (err) {
|
|
1533
|
+
// Fails the run closed, and — unlike a throw from here — still
|
|
1534
|
+
// reports it, so the server is not left waiting on it.
|
|
1535
|
+
mcp = { mcpWired: false, error: `could not prepare the run (${String(err)})` };
|
|
1536
|
+
}
|
|
1537
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep, control, isolation, mcp).catch((err) => {
|
|
1469
1538
|
log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
|
|
1470
1539
|
return 'failed';
|
|
1471
1540
|
});
|
|
1541
|
+
if (mcp.accountLabel !== undefined)
|
|
1542
|
+
runtime.account?.finishSpawn(mcp.accountLabel);
|
|
1472
1543
|
// Held until here on purpose: heartbeats keep the row alive for the
|
|
1473
1544
|
// whole of `handleRun`, including the ten minutes of completion
|
|
1474
1545
|
// retries at the end of it.
|
|
1475
1546
|
release(run.run_id);
|
|
1476
1547
|
// An account picked mid-run was held back from the status frame
|
|
1477
|
-
// until this run (on the old account) was done; report it now.
|
|
1478
|
-
|
|
1548
|
+
// until this run (on the old account) was done; report it now. Asked
|
|
1549
|
+
// about *this* run's account: a newer run may already be on the pick.
|
|
1550
|
+
if (runtime.account?.changedSinceSpawn(mcp.accountLabel))
|
|
1479
1551
|
status.accountsChanged();
|
|
1480
1552
|
// Both terminal outcomes reached the completion endpoint; only then
|
|
1481
1553
|
// is the run settled and safe to skip on redelivery.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which accepted runs execute now, and which wait.
|
|
3
|
+
*
|
|
4
|
+
* `agent run` used to chain every run onto one promise: one local agent, one
|
|
5
|
+
* run at a time. That is still the default, and with a limit of 1 this
|
|
6
|
+
* scheduler is exactly that chain — strict arrival order, nothing overlapping.
|
|
7
|
+
*
|
|
8
|
+
* `--concurrency N` lifts the limit without lifting the rule that actually
|
|
9
|
+
* protects an answer: **two runs on the same lane never overlap, and they start
|
|
10
|
+
* in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
|
|
11
|
+
* Each turn resumes the session the previous turn left, so running two turns of
|
|
12
|
+
* one thread side by side would have both resume the same session and the
|
|
13
|
+
* second answer without having seen the first. Runs on different lanes share
|
|
14
|
+
* nothing but the machine, and those are the ones allowed to overlap.
|
|
15
|
+
*
|
|
16
|
+
* Among the runs free to start, the oldest goes first. A lane that is busy does
|
|
17
|
+
* not hold up the lanes behind it — which is the whole point: a quick question
|
|
18
|
+
* in one thread no longer waits out a two-hour job in another.
|
|
19
|
+
*/
|
|
20
|
+
/** Most runs one harness will execute at once, whatever the flag says. */
|
|
21
|
+
export declare const MAX_CONCURRENCY = 16;
|
|
22
|
+
export declare class RunScheduler {
|
|
23
|
+
private readonly limit;
|
|
24
|
+
private readonly pending;
|
|
25
|
+
private readonly busy;
|
|
26
|
+
private waiters;
|
|
27
|
+
constructor(limit?: number);
|
|
28
|
+
/** How many tasks are executing right now. */
|
|
29
|
+
get active(): number;
|
|
30
|
+
/** How many tasks are waiting for a slot or for their lane. */
|
|
31
|
+
get queued(): number;
|
|
32
|
+
/**
|
|
33
|
+
* Queue a task on a lane. It starts as soon as a slot is free and no earlier
|
|
34
|
+
* task on the same lane is still running or waiting.
|
|
35
|
+
*
|
|
36
|
+
* The task must not reject: a rejection is swallowed so one broken run can
|
|
37
|
+
* never wedge its lane, but nothing reports it either — callers catch inside.
|
|
38
|
+
*/
|
|
39
|
+
submit(lane: string, task: () => Promise<void>): void;
|
|
40
|
+
/** Resolves once nothing is executing and nothing is waiting. */
|
|
41
|
+
onIdle(): Promise<void>;
|
|
42
|
+
private pump;
|
|
43
|
+
private execute;
|
|
44
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which accepted runs execute now, and which wait.
|
|
3
|
+
*
|
|
4
|
+
* `agent run` used to chain every run onto one promise: one local agent, one
|
|
5
|
+
* run at a time. That is still the default, and with a limit of 1 this
|
|
6
|
+
* scheduler is exactly that chain — strict arrival order, nothing overlapping.
|
|
7
|
+
*
|
|
8
|
+
* `--concurrency N` lifts the limit without lifting the rule that actually
|
|
9
|
+
* protects an answer: **two runs on the same lane never overlap, and they start
|
|
10
|
+
* in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
|
|
11
|
+
* Each turn resumes the session the previous turn left, so running two turns of
|
|
12
|
+
* one thread side by side would have both resume the same session and the
|
|
13
|
+
* second answer without having seen the first. Runs on different lanes share
|
|
14
|
+
* nothing but the machine, and those are the ones allowed to overlap.
|
|
15
|
+
*
|
|
16
|
+
* Among the runs free to start, the oldest goes first. A lane that is busy does
|
|
17
|
+
* not hold up the lanes behind it — which is the whole point: a quick question
|
|
18
|
+
* in one thread no longer waits out a two-hour job in another.
|
|
19
|
+
*/
|
|
20
|
+
/** Most runs one harness will execute at once, whatever the flag says. */
|
|
21
|
+
export const MAX_CONCURRENCY = 16;
|
|
22
|
+
export class RunScheduler {
|
|
23
|
+
limit;
|
|
24
|
+
pending = [];
|
|
25
|
+
busy = new Set();
|
|
26
|
+
waiters = [];
|
|
27
|
+
constructor(limit = 1) {
|
|
28
|
+
this.limit = limit;
|
|
29
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
30
|
+
throw new RangeError(`concurrency must be a whole number of at least 1, got ${String(limit)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** How many tasks are executing right now. */
|
|
34
|
+
get active() {
|
|
35
|
+
return this.busy.size;
|
|
36
|
+
}
|
|
37
|
+
/** How many tasks are waiting for a slot or for their lane. */
|
|
38
|
+
get queued() {
|
|
39
|
+
return this.pending.length;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Queue a task on a lane. It starts as soon as a slot is free and no earlier
|
|
43
|
+
* task on the same lane is still running or waiting.
|
|
44
|
+
*
|
|
45
|
+
* The task must not reject: a rejection is swallowed so one broken run can
|
|
46
|
+
* never wedge its lane, but nothing reports it either — callers catch inside.
|
|
47
|
+
*/
|
|
48
|
+
submit(lane, task) {
|
|
49
|
+
this.pending.push({ lane, task });
|
|
50
|
+
this.pump();
|
|
51
|
+
}
|
|
52
|
+
/** Resolves once nothing is executing and nothing is waiting. */
|
|
53
|
+
onIdle() {
|
|
54
|
+
if (this.busy.size === 0 && this.pending.length === 0)
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
this.waiters.push(resolve);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
pump() {
|
|
61
|
+
while (this.busy.size < this.limit) {
|
|
62
|
+
// First in arrival order whose lane is free. An earlier task on a busy
|
|
63
|
+
// lane is skipped, not waited for; a later task on that same lane is
|
|
64
|
+
// skipped with it, which is what keeps a lane in order.
|
|
65
|
+
const index = this.pending.findIndex((entry) => !this.busy.has(entry.lane));
|
|
66
|
+
if (index === -1)
|
|
67
|
+
break;
|
|
68
|
+
const [next] = this.pending.splice(index, 1);
|
|
69
|
+
if (!next)
|
|
70
|
+
break;
|
|
71
|
+
this.busy.add(next.lane);
|
|
72
|
+
void this.execute(next);
|
|
73
|
+
}
|
|
74
|
+
if (this.busy.size === 0 && this.pending.length === 0) {
|
|
75
|
+
const waiters = this.waiters;
|
|
76
|
+
this.waiters = [];
|
|
77
|
+
for (const resolve of waiters)
|
|
78
|
+
resolve();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async execute(entry) {
|
|
82
|
+
try {
|
|
83
|
+
await entry.task();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* see `submit` — a task reports its own failure */
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
this.busy.delete(entry.lane);
|
|
90
|
+
this.pump();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A private checkout of each `--repos` repository, per conversation.
|
|
3
|
+
*
|
|
4
|
+
* A coding agent that opens pull requests does it in a git checkout, and until
|
|
5
|
+
* `--concurrency` there was only ever one run touching it. Two runs sharing one
|
|
6
|
+
* checkout switch each other's branch mid-edit and commit each other's
|
|
7
|
+
* half-finished files — the reason runs were serialized in the first place. So
|
|
8
|
+
* when the operator names the repositories the agent works on, each
|
|
9
|
+
* conversation gets its own `git worktree` of every one of them:
|
|
10
|
+
*
|
|
11
|
+
* $PROHOST_HOME/worktrees/<conversation>/<repo-name>
|
|
12
|
+
*
|
|
13
|
+
* Per *conversation*, not per run, for the same reason sessions are: a
|
|
14
|
+
* follow-up in the thread ("address the review comments") resumes the session
|
|
15
|
+
* that made the branch, and must find that branch where it left it. Runs of one
|
|
16
|
+
* conversation never overlap (see `scheduler.ts`), so one checkout per
|
|
17
|
+
* conversation is exactly as isolated as one per run and far cheaper.
|
|
18
|
+
*
|
|
19
|
+
* The harness makes the worktree; the agent never needs `git worktree add`.
|
|
20
|
+
* The agent command's working directory does not change — it stays the
|
|
21
|
+
* workspace, because that is where its `.claude/` configuration lives and what
|
|
22
|
+
* its sessions are keyed on. The checkouts are named in the prompt and in
|
|
23
|
+
* `$PROHOST_RUN_REPOS`.
|
|
24
|
+
*
|
|
25
|
+
* Worktrees share the source repository's object store, so a branch committed
|
|
26
|
+
* in one survives the worktree being removed. That is what makes pruning safe:
|
|
27
|
+
* `git worktree remove` (never `--force`) refuses a checkout with uncommitted
|
|
28
|
+
* or untracked files, and those are the only things a worktree holds alone.
|
|
29
|
+
*/
|
|
30
|
+
/** Child-process env var naming the directory that holds this run's checkouts. */
|
|
31
|
+
export declare const RUN_REPOS_ENV_VAR = "PROHOST_RUN_REPOS";
|
|
32
|
+
/** A conversation's checkouts idle this long are removed, if clean. */
|
|
33
|
+
export declare const WORKTREE_RETENTION_MS: number;
|
|
34
|
+
export interface RunCheckout {
|
|
35
|
+
/** Directory name of the repository, e.g. `backend-service`. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** This conversation's private worktree of it. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** The operator's checkout it was made from — the one the agent must leave alone. */
|
|
40
|
+
source: string;
|
|
41
|
+
}
|
|
42
|
+
export interface CheckoutFailure {
|
|
43
|
+
source: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
export interface PreparedCheckouts {
|
|
47
|
+
/** Directory holding the checkouts; exported as {@link RUN_REPOS_ENV_VAR}. */
|
|
48
|
+
dir: string;
|
|
49
|
+
checkouts: RunCheckout[];
|
|
50
|
+
failures: CheckoutFailure[];
|
|
51
|
+
}
|
|
52
|
+
export declare class ReposFlagError extends Error {
|
|
53
|
+
}
|
|
54
|
+
export declare function worktreesRoot(env?: NodeJS.ProcessEnv): string;
|
|
55
|
+
/**
|
|
56
|
+
* Directory name for a lane (see `sessionKeyFor`).
|
|
57
|
+
*
|
|
58
|
+
* Readable enough to recognise in `ls`, and suffixed with a digest of the whole
|
|
59
|
+
* key because the readable part is lossy: two keys that differ only in
|
|
60
|
+
* punctuation, or in the agent they belong to, must not share a checkout.
|
|
61
|
+
*/
|
|
62
|
+
export declare function laneDirName(lane: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Read `--repos a,b` into absolute paths, refusing what can never work.
|
|
65
|
+
*
|
|
66
|
+
* An empty list and two checkouts with one directory name are always errors.
|
|
67
|
+
* A path that is not a git checkout is an error only when `requireCheckouts`
|
|
68
|
+
* is set — which `install-daemon` does and `agent run` does not. At install
|
|
69
|
+
* time a mistyped path should stop the install; at run time the same check
|
|
70
|
+
* would make a daemon whose repository was moved, deleted or is on an
|
|
71
|
+
* unmounted volume exit before connecting, and the service manager restart it
|
|
72
|
+
* forever. A running harness instead reports the checkout it could not make on
|
|
73
|
+
* each run, and keeps answering.
|
|
74
|
+
*
|
|
75
|
+
* @throws ReposFlagError naming the entry that is wrong.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseReposFlag(raw: string, options?: {
|
|
78
|
+
requireCheckouts?: boolean;
|
|
79
|
+
}): string[];
|
|
80
|
+
/** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
|
|
81
|
+
export declare function isCheckout(repo: string): boolean;
|
|
82
|
+
export interface WorktreeManagerOptions {
|
|
83
|
+
/** Absolute paths of the operator's checkouts. Empty disables the feature. */
|
|
84
|
+
repos: string[];
|
|
85
|
+
env?: NodeJS.ProcessEnv;
|
|
86
|
+
log?: (line: string) => void;
|
|
87
|
+
/** Clock seam for tests. */
|
|
88
|
+
now?: () => number;
|
|
89
|
+
retentionMs?: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Makes, reuses and prunes per-conversation worktrees for one harness.
|
|
93
|
+
*
|
|
94
|
+
* Every git mutation goes through one in-process queue. Lanes run side by
|
|
95
|
+
* side, and two `git worktree add` calls against the same repository race for
|
|
96
|
+
* the same administrative name; pruning, meanwhile, must never remove the
|
|
97
|
+
* directory a run arriving this instant is about to use.
|
|
98
|
+
*/
|
|
99
|
+
export declare class WorktreeManager {
|
|
100
|
+
private readonly repos;
|
|
101
|
+
private readonly env;
|
|
102
|
+
private readonly log;
|
|
103
|
+
private readonly now;
|
|
104
|
+
private readonly retentionMs;
|
|
105
|
+
private chain;
|
|
106
|
+
private lastPrunedAt;
|
|
107
|
+
constructor(options: WorktreeManagerOptions);
|
|
108
|
+
get enabled(): boolean;
|
|
109
|
+
private exclusive;
|
|
110
|
+
/**
|
|
111
|
+
* Ensure this lane has a worktree of every repository, and say where.
|
|
112
|
+
*
|
|
113
|
+
* Never rejects and never fails the run: most runs are questions that touch
|
|
114
|
+
* no repository at all, so a checkout that could not be made is reported in
|
|
115
|
+
* {@link PreparedCheckouts.failures} for the prompt to tell the agent about.
|
|
116
|
+
*/
|
|
117
|
+
prepare(lane: string): Promise<PreparedCheckouts>;
|
|
118
|
+
/** The repository a checkout's history lives in, as one comparable path. */
|
|
119
|
+
private commonDir;
|
|
120
|
+
/**
|
|
121
|
+
* Keep an existing checkout — once it is proven to be a worktree of `source`.
|
|
122
|
+
*
|
|
123
|
+
* The directory is named after the repository's basename, so pointing
|
|
124
|
+
* `--repos` from `/old/app` to `/new/app` leaves every conversation with an
|
|
125
|
+
* `app` checkout of the *old* repository. Handing that to the agent as a
|
|
126
|
+
* checkout of the new one would have it commit to the wrong repository.
|
|
127
|
+
*/
|
|
128
|
+
private reuse;
|
|
129
|
+
/** Returns why the worktree could not be made, or `undefined` when it was. */
|
|
130
|
+
private add;
|
|
131
|
+
private touch;
|
|
132
|
+
private pruneIfDue;
|
|
133
|
+
/** Remove idle conversations' checkouts, never the one about to be used. */
|
|
134
|
+
private prune;
|
|
135
|
+
private removeLane;
|
|
136
|
+
}
|