passalong 1.4.0 → 1.6.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/README.md +0 -4
- package/bin/passalong +88 -88
- package/package.json +1 -1
- package/src/api.js +0 -5
- package/src/capture.js +28 -0
- package/src/checks.js +10 -3
- package/src/hooks.js +76 -3
- package/src/mcp.js +20 -8
- package/src/passalong.js +130 -439
- package/src/update.js +47 -2
- package/src/runner.js +0 -645
package/src/passalong.js
CHANGED
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
// so anything an agent can do through MCP a human can do from the terminal and vice versa.
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
6
14
|
import { hostname } from "node:os";
|
|
7
15
|
import { basename, join } from "node:path";
|
|
8
16
|
import * as api from "./api.js";
|
|
9
|
-
import { context } from "./capture.js";
|
|
17
|
+
import { context, openPullRequest } from "./capture.js";
|
|
10
18
|
import { refusal, runChecks } from "./checks.js";
|
|
11
|
-
import * as runner from "./runner.js";
|
|
12
19
|
import * as store from "./store.js";
|
|
13
20
|
import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
|
|
14
21
|
|
|
@@ -38,6 +45,11 @@ export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}
|
|
|
38
45
|
author: c.author,
|
|
39
46
|
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
40
47
|
});
|
|
48
|
+
// Written inside a repo and not saying which repo it is for: it is for this one. Only `new
|
|
49
|
+
// "<text>"` used to fill it in, so work written from a file was for no repo — no agent there was
|
|
50
|
+
// offered it, and it never reached its project (2026-11-07). A file that names one, or names none
|
|
51
|
+
// with an empty `target_context:`, is taken at its word.
|
|
52
|
+
if (!Object.hasOwn(work.meta, "target_context") && c.repo) work.meta.target_context = c.repo;
|
|
41
53
|
const target = parseTarget(to);
|
|
42
54
|
if (target.team) work.meta.team = target.team;
|
|
43
55
|
if (target.to) work.meta.to = target.to;
|
|
@@ -219,12 +231,11 @@ function needsSync(what) {
|
|
|
219
231
|
|
|
220
232
|
/**
|
|
221
233
|
* Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
|
|
222
|
-
* several. `null` when there is nothing to do.
|
|
223
|
-
* run unattended (the runner).
|
|
234
|
+
* several. `null` when there is nothing to do.
|
|
224
235
|
*/
|
|
225
|
-
export async function nextWork({ cwd = process.cwd(), any = false
|
|
236
|
+
export async function nextWork({ cwd = process.cwd(), any = false } = {}) {
|
|
226
237
|
needsSync("taking work");
|
|
227
|
-
const { task } = await api.nextTask({ ...agent(cwd), any
|
|
238
|
+
const { task } = await api.nextTask({ ...agent(cwd), any });
|
|
228
239
|
return task || null;
|
|
229
240
|
}
|
|
230
241
|
|
|
@@ -263,7 +274,7 @@ function rememberFence(id, fence, cwd) {
|
|
|
263
274
|
writeFileSync(fenceFile(cwd), `${JSON.stringify(held, null, 2)}\n`);
|
|
264
275
|
}
|
|
265
276
|
|
|
266
|
-
/** A check
|
|
277
|
+
/** A check that was actually run: the command, and what the process returned. See checks.js. */
|
|
267
278
|
const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
|
|
268
279
|
|
|
269
280
|
/**
|
|
@@ -327,7 +338,7 @@ export async function handIn(
|
|
|
327
338
|
) {
|
|
328
339
|
needsSync("handing work in");
|
|
329
340
|
// A check that names a command is run here, before anything is sent. Checks that already carry
|
|
330
|
-
// an exit
|
|
341
|
+
// an exit were already run by the MCP server, and are not run again, so nothing runs twice.
|
|
331
342
|
const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
|
|
332
343
|
if (pending.length) {
|
|
333
344
|
const done = runChecks(pending, { cwd });
|
|
@@ -341,6 +352,9 @@ export async function handIn(
|
|
|
341
352
|
// whose command was actually run is exempt from the length rule — its exit code is the evidence.
|
|
342
353
|
if (checks.length) checks.forEach((c) => (verified(c) ? null : requireEvidence(c?.ran)));
|
|
343
354
|
else requireEvidence(evidence);
|
|
355
|
+
// Where the change is, for the person reviewing it: the agent's link, or this branch's open pull
|
|
356
|
+
// request when it gave none.
|
|
357
|
+
if (!pr) pr = openPullRequest(cwd);
|
|
344
358
|
const fence = fenceFor(id, cwd);
|
|
345
359
|
return api.handIn(id, {
|
|
346
360
|
agent: agent(cwd).agent,
|
|
@@ -379,11 +393,9 @@ export async function assign(ref, to) {
|
|
|
379
393
|
}
|
|
380
394
|
|
|
381
395
|
/**
|
|
382
|
-
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
|
|
383
|
-
* says the runner is giving it back, unfinished: the server then clears its Safe to run alone tick,
|
|
384
|
-
* so it waits for a person rather than going straight back to an unattended agent.
|
|
396
|
+
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
|
|
385
397
|
*/
|
|
386
|
-
export async function pass(id, why, { cwd = process.cwd()
|
|
398
|
+
export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
387
399
|
needsSync("passing work");
|
|
388
400
|
if (!String(why || "").trim())
|
|
389
401
|
throw new PassalongError("say why you are passing it, so whoever is next knows");
|
|
@@ -392,7 +404,6 @@ export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}
|
|
|
392
404
|
agent: agent(cwd).agent,
|
|
393
405
|
why,
|
|
394
406
|
...(fence === undefined ? {} : { fence }),
|
|
395
|
-
...(runner ? { runner: true } : {}),
|
|
396
407
|
});
|
|
397
408
|
}
|
|
398
409
|
|
|
@@ -420,7 +431,6 @@ export function needsWhy(t) {
|
|
|
420
431
|
if (!t.mine) return "";
|
|
421
432
|
if (t.state === "draft") return "in Draft: read it and mark it Ready";
|
|
422
433
|
if (t.state === "stalled") return "its agent went quiet";
|
|
423
|
-
if (t.state === "review") return "the runner finished it: ready for your review";
|
|
424
434
|
if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
|
|
425
435
|
if (/^BLOCKED:/i.test(t.claim?.note || ""))
|
|
426
436
|
return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
|
|
@@ -490,6 +500,111 @@ export async function now({ cwd = process.cwd() } = {}) {
|
|
|
490
500
|
};
|
|
491
501
|
}
|
|
492
502
|
|
|
503
|
+
/**
|
|
504
|
+
* What someone wrote on work this worktree's agent holds that the agent has not been told, for the
|
|
505
|
+
* hook that runs after each of Claude Code's tool calls (hooks.js). Asking is a `progress` with no
|
|
506
|
+
* note: it renews the hold, hands over the unread replies and marks them read (claims.deliver in
|
|
507
|
+
* apps/api), so a reply reaches the agent at its next step instead of its next progress call.
|
|
508
|
+
*
|
|
509
|
+
* At most once every `every` ms per worktree, checked against a file's age before any network, so
|
|
510
|
+
* a session making tool calls back to back asks the server a few times a minute, not on every call.
|
|
511
|
+
* Read, never minted, like now(): a worktree that never took anything is never asked about.
|
|
512
|
+
*/
|
|
513
|
+
export async function liveReplies({
|
|
514
|
+
cwd = process.cwd(),
|
|
515
|
+
every = 15_000,
|
|
516
|
+
at = Date.now(),
|
|
517
|
+
session = "",
|
|
518
|
+
} = {}) {
|
|
519
|
+
if (!api.loggedIn()) return [];
|
|
520
|
+
const who = knownAgent(cwd);
|
|
521
|
+
if (!who.agent) return [];
|
|
522
|
+
// This session is working here: what the session-end hook reads to leave other sessions' work.
|
|
523
|
+
if (session) markSession(cwd, session, at);
|
|
524
|
+
const stamp = join(context(cwd).root || cwd, ".passalong", "replies-checked");
|
|
525
|
+
try {
|
|
526
|
+
if (at - statSync(stamp).mtimeMs < every) return [];
|
|
527
|
+
} catch {}
|
|
528
|
+
try {
|
|
529
|
+
writeFileSync(stamp, `${new Date(at).toISOString()}\n`);
|
|
530
|
+
} catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const { working } = await api.working();
|
|
534
|
+
// Work with replies waiting, and work whose hold ends soon: an agent busy on a long run that posts
|
|
535
|
+
// no progress read as gone quiet after thirty minutes while it worked (2026-11-07). Not a hold
|
|
536
|
+
// waiting on an answer that has none yet: it runs for a day, and a renewal would cut it short.
|
|
537
|
+
const soon = new Date(at + RENEW_WITHIN_MS).toISOString();
|
|
538
|
+
const waiting = working.filter(
|
|
539
|
+
(x) =>
|
|
540
|
+
x.agent === who.agent &&
|
|
541
|
+
x.by?.you &&
|
|
542
|
+
(x.replies > 0 || (!x.asking && String(x.lease_until || "") < soon)),
|
|
543
|
+
);
|
|
544
|
+
const found = [];
|
|
545
|
+
for (const h of waiting) {
|
|
546
|
+
const fence = fenceFor(h.id, cwd);
|
|
547
|
+
const r = await api
|
|
548
|
+
.progress(h.id, { agent: who.agent, ...(fence === undefined ? {} : { fence }) })
|
|
549
|
+
.catch(() => null);
|
|
550
|
+
if (r?.replies?.length) found.push({ id: h.id, title: h.title, replies: r.replies });
|
|
551
|
+
}
|
|
552
|
+
return found;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** A hold is renewed by the tool hook once it has less than this left. Holds last 30 minutes. */
|
|
556
|
+
export const RENEW_WITHIN_MS = 20 * 60 * 1000;
|
|
557
|
+
|
|
558
|
+
/** How recently another session must have worked here for the session-end hook to leave its work. */
|
|
559
|
+
const SESSION_FRESH_MS = 10 * 60 * 1000;
|
|
560
|
+
|
|
561
|
+
const sessionsDir = (cwd) => join(context(cwd).root || cwd, ".passalong", "sessions");
|
|
562
|
+
const sessionFile = (cwd, session) =>
|
|
563
|
+
join(
|
|
564
|
+
sessionsDir(cwd),
|
|
565
|
+
String(session)
|
|
566
|
+
.replace(/[^A-Za-z0-9_-]/g, "")
|
|
567
|
+
.slice(0, 80),
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
function markSession(cwd, session, at) {
|
|
571
|
+
try {
|
|
572
|
+
mkdirSync(sessionsDir(cwd), { recursive: true });
|
|
573
|
+
writeFileSync(sessionFile(cwd, session), `${new Date(at).toISOString()}\n`);
|
|
574
|
+
} catch {}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Whether a session other than `session` worked in this worktree in the last few minutes. */
|
|
578
|
+
export function otherSessions(cwd, session, at = Date.now()) {
|
|
579
|
+
const mine = sessionFile(cwd, session);
|
|
580
|
+
try {
|
|
581
|
+
return readdirSync(sessionsDir(cwd)).some((f) => {
|
|
582
|
+
const path = join(sessionsDir(cwd), f);
|
|
583
|
+
return path !== mine && at - statSync(path).mtimeMs < SESSION_FRESH_MS;
|
|
584
|
+
});
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A session ended: what it still holds goes back to the queue, with the reason (hooks.js
|
|
592
|
+
* endDecision says what is left alone and why). Returns the ids given back.
|
|
593
|
+
*/
|
|
594
|
+
export async function endSession({ cwd = process.cwd(), input = {} } = {}) {
|
|
595
|
+
const { endDecision, ENDED } = await import("./hooks.js");
|
|
596
|
+
const session = String(input.session_id || "");
|
|
597
|
+
const { held } = await now({ cwd });
|
|
598
|
+
const ids = endDecision({ held, input, others: session ? otherSessions(cwd, session) : false });
|
|
599
|
+
const gone = [];
|
|
600
|
+
for (const id of ids) {
|
|
601
|
+
const r = await pass(id, ENDED, { cwd }).catch(() => null);
|
|
602
|
+
if (r) gone.push(id);
|
|
603
|
+
}
|
|
604
|
+
if (session) rmSync(sessionFile(cwd, session), { force: true });
|
|
605
|
+
return gone;
|
|
606
|
+
}
|
|
607
|
+
|
|
493
608
|
/** Finished work back to To do, with why. */
|
|
494
609
|
export async function reopen(id, why) {
|
|
495
610
|
needsSync("reopening work");
|
|
@@ -738,8 +853,6 @@ export function workPrompt(t) {
|
|
|
738
853
|
*
|
|
739
854
|
* An agent that exits without finishing stops the loop, rather than piling up half-done work. It
|
|
740
855
|
* stays held here, for a person to see on the board and resume (take it) or release.
|
|
741
|
-
*
|
|
742
|
-
* The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
|
|
743
856
|
*/
|
|
744
857
|
export async function work({
|
|
745
858
|
cwd = process.cwd(),
|
|
@@ -774,428 +887,6 @@ export async function work({
|
|
|
774
887
|
}
|
|
775
888
|
}
|
|
776
889
|
|
|
777
|
-
/**
|
|
778
|
-
* The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
|
|
779
|
-
* a git worktree of its own beside its checkout, on its own branch, by an agent that finishes with
|
|
780
|
-
* a pull request. Up to `atOnce` at a time, with no count a day; a run that ends without the work
|
|
781
|
-
* done passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
|
|
782
|
-
*
|
|
783
|
-
* When the agent's usage runs out (runner.usageOut) its work is not given back: it stays held here
|
|
784
|
-
* with its worktree, the runner takes nothing new, and once the usage is back (when the agent said,
|
|
785
|
-
* or every `USAGE_RETRY_MS`) it carries on where it stopped.
|
|
786
|
-
*
|
|
787
|
-
* Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
|
|
788
|
-
* one with an `origin` remote, so one terminal does what six used to. The folder is checked before
|
|
789
|
-
* anything is said (`onStart`), so a runner that cannot run says why instead of what it will do.
|
|
790
|
-
*
|
|
791
|
-
* Work is asked for before a worktree is made: the agent's id is minted first and written into the
|
|
792
|
-
* worktree once there is work for it, so a runner serving many repos does not make and remove a
|
|
793
|
-
* worktree in each of them every minute. What the agent does is read as it goes (runner.describe)
|
|
794
|
-
* and sent to the hub every few seconds as steps, and to `onStep` for the terminal.
|
|
795
|
-
*/
|
|
796
|
-
export async function watch({
|
|
797
|
-
cwd = process.cwd(),
|
|
798
|
-
agent: command,
|
|
799
|
-
every = 60,
|
|
800
|
-
atOnce = runner.LIMITS.atOnce,
|
|
801
|
-
minutes = runner.LIMITS.minutes,
|
|
802
|
-
cli,
|
|
803
|
-
git = runner.git,
|
|
804
|
-
runAgent = runner.runAgent,
|
|
805
|
-
now = () => Date.now(),
|
|
806
|
-
countFile = join(store.HOME, "runner.json"),
|
|
807
|
-
machine = runner.machineName,
|
|
808
|
-
onStart = () => {},
|
|
809
|
-
onTask = () => {},
|
|
810
|
-
onStep = () => {},
|
|
811
|
-
onIdle = () => {},
|
|
812
|
-
onPaused = () => {},
|
|
813
|
-
onFinished = () => {},
|
|
814
|
-
onPassed = () => {},
|
|
815
|
-
onWaiting = () => {},
|
|
816
|
-
onResumed = () => {},
|
|
817
|
-
onTrouble = () => {},
|
|
818
|
-
report = api.runnerStatus,
|
|
819
|
-
postSteps = api.workSteps,
|
|
820
|
-
stepEvery = 5,
|
|
821
|
-
signals = false,
|
|
822
|
-
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
823
|
-
} = {}) {
|
|
824
|
-
needsSync("running work");
|
|
825
|
-
const inside = context(cwd).root;
|
|
826
|
-
const { found, skipped } = runner.checkoutsIn(cwd, { git });
|
|
827
|
-
if (inside && !found.length)
|
|
828
|
-
throw new PassalongError(
|
|
829
|
-
"this checkout has no `origin` remote: the runner pushes each change to a branch there",
|
|
830
|
-
);
|
|
831
|
-
if (!found.length)
|
|
832
|
-
throw new PassalongError(
|
|
833
|
-
`there's no git checkout in ${cwd} or the folders under it` +
|
|
834
|
-
(skipped.length ? ` with an \`origin\` remote (${skipped.length} without one)` : "") +
|
|
835
|
-
". Start the runner inside a repo, or in the folder that holds your repos.",
|
|
836
|
-
);
|
|
837
|
-
const checkouts = found.map(({ root, remote }) => {
|
|
838
|
-
const runs = runner.runsDirOf(root);
|
|
839
|
-
mkdirSync(runs, { recursive: true });
|
|
840
|
-
return { root, runs, repo: context(root).repo || remote, forge: runner.forgeOf(remote) };
|
|
841
|
-
});
|
|
842
|
-
const repos = checkouts.map((k) => k.repo);
|
|
843
|
-
const name = machine();
|
|
844
|
-
onStart({ repos, skipped });
|
|
845
|
-
|
|
846
|
-
// What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
|
|
847
|
-
// nobody has to look at this terminal to know. A failed report never stops a run.
|
|
848
|
-
const onNow = new Map();
|
|
849
|
-
const say = (stopped = false) =>
|
|
850
|
-
Promise.resolve(
|
|
851
|
-
report({
|
|
852
|
-
host: name,
|
|
853
|
-
repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
|
|
854
|
-
repos,
|
|
855
|
-
running: [...onNow].map(([id, title]) => ({ id, title })),
|
|
856
|
-
runs_today: runner.runsToday(countFile, runner.dayOf(now())),
|
|
857
|
-
per_day: 0,
|
|
858
|
-
at_once: atOnce,
|
|
859
|
-
stopped,
|
|
860
|
-
}),
|
|
861
|
-
).catch(() => {});
|
|
862
|
-
// Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
|
|
863
|
-
// terminal; what they held stays held, and the sweep puts it back once it goes quiet.
|
|
864
|
-
if (signals)
|
|
865
|
-
process.once("SIGINT", async () => {
|
|
866
|
-
await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
|
|
867
|
-
process.exit(130);
|
|
868
|
-
});
|
|
869
|
-
/**
|
|
870
|
-
* The agent's command for one run: `--agent` as given, else Claude Code with what the server's
|
|
871
|
-
* brief allows it (runner-brief.ts on the server), or this package's own list without one.
|
|
872
|
-
*/
|
|
873
|
-
const agentRun = (brief) =>
|
|
874
|
-
command
|
|
875
|
-
? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
|
|
876
|
-
: runner.claudeCommand({
|
|
877
|
-
cli,
|
|
878
|
-
...(brief?.permissions?.allow ? { permissions: brief.permissions } : {}),
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* Make the worktree a taken piece of work runs in, as the agent whose id took it. Work sent back
|
|
883
|
-
* from review starts where its last run left off: on its pushed branch, not from scratch, which
|
|
884
|
-
* would also leave the agent unable to push over its own earlier commits.
|
|
885
|
-
*/
|
|
886
|
-
function worktreeFor(k, dir, id, workId) {
|
|
887
|
-
try {
|
|
888
|
-
git(["-C", k.root, "fetch", "--quiet", "origin"]);
|
|
889
|
-
} catch {}
|
|
890
|
-
let base = "HEAD";
|
|
891
|
-
try {
|
|
892
|
-
base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
|
|
893
|
-
? "origin/HEAD"
|
|
894
|
-
: base;
|
|
895
|
-
} catch {}
|
|
896
|
-
try {
|
|
897
|
-
const earlier = `origin/${runner.branchOf(workId)}`;
|
|
898
|
-
if (git(["-C", k.root, "rev-parse", "--verify", "--quiet", earlier])) base = earlier;
|
|
899
|
-
} catch {}
|
|
900
|
-
git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
|
|
901
|
-
writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* One slot: a piece of work taken for one of the repos, its worktree made and its agent started.
|
|
906
|
-
* `null` when nothing waits in any of them. The run itself is handed back unawaited, so several
|
|
907
|
-
* go at once. Repos are asked in turn, starting after the last one that had work.
|
|
908
|
-
*/
|
|
909
|
-
let turn = 0;
|
|
910
|
-
async function startOne(n) {
|
|
911
|
-
let t = null;
|
|
912
|
-
let k = null;
|
|
913
|
-
let dir = "";
|
|
914
|
-
let id = "";
|
|
915
|
-
for (let i = 0; i < checkouts.length && !t; i++) {
|
|
916
|
-
k = checkouts[(turn + i) % checkouts.length];
|
|
917
|
-
dir = join(k.runs, `run-${now()}-${n}`);
|
|
918
|
-
id = randomBytes(12).toString("hex");
|
|
919
|
-
try {
|
|
920
|
-
const got = await api.nextTask({
|
|
921
|
-
agent: id,
|
|
922
|
-
host: hostname(),
|
|
923
|
-
repo: k.repo,
|
|
924
|
-
worktree: dir,
|
|
925
|
-
alone: true,
|
|
926
|
-
// So the server's brief ends the run the way this checkout's host takes changes.
|
|
927
|
-
forge: k.forge,
|
|
928
|
-
});
|
|
929
|
-
// The brief rides with the work: what to tell its agent, from the server (1.4.0's).
|
|
930
|
-
t = got?.task ? { ...got.task, brief: got.runner || null } : null;
|
|
931
|
-
} catch (err) {
|
|
932
|
-
onTrouble(err);
|
|
933
|
-
}
|
|
934
|
-
if (t) turn = (turn + i + 1) % checkouts.length;
|
|
935
|
-
}
|
|
936
|
-
if (!t || !k) return null;
|
|
937
|
-
// Its branch, or a fresh one beside it when that one is still checked out in the worktree of
|
|
938
|
-
// an earlier run that didn't finish (kept for its author), as when a person ticks it again.
|
|
939
|
-
let branch = runner.branchOf(t.id);
|
|
940
|
-
try {
|
|
941
|
-
worktreeFor(k, dir, id, t.id);
|
|
942
|
-
try {
|
|
943
|
-
git(["-C", dir, "switch", "--quiet", "-C", branch]);
|
|
944
|
-
} catch {
|
|
945
|
-
branch = `${branch}-${now().toString(36)}`;
|
|
946
|
-
git(["-C", dir, "switch", "--quiet", "-c", branch]);
|
|
947
|
-
}
|
|
948
|
-
} catch (err) {
|
|
949
|
-
// Taken and then stuck before any agent started: never left held with nobody on it.
|
|
950
|
-
const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
|
|
951
|
-
await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
|
|
952
|
-
onTrouble(e, t),
|
|
953
|
-
);
|
|
954
|
-
onPassed(t, { why, dir, log: "" });
|
|
955
|
-
return { run: Promise.resolve() };
|
|
956
|
-
}
|
|
957
|
-
runner.countRun(countFile, runner.dayOf(now()));
|
|
958
|
-
onNow.set(t.id, t.title || t.id);
|
|
959
|
-
onTask(t, { dir, branch, repo: k.repo });
|
|
960
|
-
say();
|
|
961
|
-
return {
|
|
962
|
-
run: finish(t, k, dir, branch).finally(() => {
|
|
963
|
-
// A paused run is still on: the hub keeps showing it, with its last step saying why.
|
|
964
|
-
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
965
|
-
say();
|
|
966
|
-
}),
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
async function finish(t, k, dir, branch, { resumed = false } = {}) {
|
|
971
|
-
const log = join(k.runs, `${t.id}.log`);
|
|
972
|
-
// Steps go to the hub every few seconds rather than one request each: an agent can take a
|
|
973
|
-
// dozen in a second. Whatever is left goes when the run ends.
|
|
974
|
-
const pending = [];
|
|
975
|
-
const flush = async () => {
|
|
976
|
-
if (!pending.length) return;
|
|
977
|
-
await postSteps(t.id, pending.splice(0)).catch(() => {});
|
|
978
|
-
};
|
|
979
|
-
const step = (text) => {
|
|
980
|
-
pending.push({ at: new Date(now()).toISOString(), text });
|
|
981
|
-
onStep(t, text);
|
|
982
|
-
};
|
|
983
|
-
step(
|
|
984
|
-
resumed === "usage"
|
|
985
|
-
? `Carrying on now that Claude's usage is back, on ${name}`
|
|
986
|
-
: resumed
|
|
987
|
-
? `Carrying on after your answer, on ${name}`
|
|
988
|
-
: `Started on ${name} in ${k.repo}, on branch ${branch}`,
|
|
989
|
-
);
|
|
990
|
-
const timer = setInterval(flush, stepEvery * 1000);
|
|
991
|
-
// The server's words when it sent them, with this run's branch filled in; else this package's.
|
|
992
|
-
const told = (said) => (typeof said === "string" ? said.replaceAll("{branch}", branch) : "");
|
|
993
|
-
const prompt =
|
|
994
|
-
resumed === "usage"
|
|
995
|
-
? told(t.brief?.paused) || runner.pausedPrompt(t, branch, { forge: k.forge })
|
|
996
|
-
: resumed
|
|
997
|
-
? told(t.brief?.resume) || runner.resumePrompt(t, branch, { forge: k.forge })
|
|
998
|
-
: told(t.brief?.start) || runner.runnerPrompt(t, branch, { forge: k.forge });
|
|
999
|
-
const ran = await runAgent({
|
|
1000
|
-
...agentRun(t.brief),
|
|
1001
|
-
prompt,
|
|
1002
|
-
...(t.brief?.usage ? { usage: t.brief.usage } : {}),
|
|
1003
|
-
cwd: dir,
|
|
1004
|
-
log,
|
|
1005
|
-
minutes,
|
|
1006
|
-
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
1007
|
-
onStep: step,
|
|
1008
|
-
}).finally(() => clearInterval(timer));
|
|
1009
|
-
if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
|
|
1010
|
-
let held = null;
|
|
1011
|
-
try {
|
|
1012
|
-
held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
|
|
1013
|
-
} catch {}
|
|
1014
|
-
// Handed in: Done, or (from 1.4.0's server on) waiting for a person to approve it or send it
|
|
1015
|
-
// back. Either way this run is over and its branch is pushed.
|
|
1016
|
-
const handedIn = held?.state === "done" || held?.state === "review";
|
|
1017
|
-
// Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
|
|
1018
|
-
// it back again would be refused, and it is no longer this runner's to describe.
|
|
1019
|
-
if (!handedIn && !held?.claim) {
|
|
1020
|
-
step("It was no longer held here when the agent stopped, so the runner left it as it is");
|
|
1021
|
-
await flush();
|
|
1022
|
-
onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
// The last step says how the run ended, while the work is still held here: once it is passed
|
|
1026
|
-
// back, its steps are no longer this runner's to write.
|
|
1027
|
-
// Its usage ran out: not given up. It stays held here, and carries on once the usage is back.
|
|
1028
|
-
if (ran.usage && held.state !== "done" && !held.claim?.asking) {
|
|
1029
|
-
pauseUntil = Math.max(pauseUntil, ran.usage.until || now() + runner.USAGE_RETRY_MS);
|
|
1030
|
-
const when = ran.usage.until ? `at ${runner.clockOf(pauseUntil)}` : "when it's back";
|
|
1031
|
-
step(`Paused: Claude's usage ran out. It carries on here ${when}, with nothing lost.`);
|
|
1032
|
-
await flush();
|
|
1033
|
-
paused.set(t.id, { t, k, dir, branch });
|
|
1034
|
-
onPaused(t, { until: ran.usage.until ? pauseUntil : 0 });
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
if (!handedIn && !held.claim?.asking)
|
|
1038
|
-
step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
|
|
1039
|
-
if (held.state === "review")
|
|
1040
|
-
step(
|
|
1041
|
-
"Handed in. It's ready for your review: approve it, or send it back with what to change",
|
|
1042
|
-
);
|
|
1043
|
-
if (held.claim?.asking) step("Waiting for your answer, then it carries on");
|
|
1044
|
-
await flush();
|
|
1045
|
-
if (handedIn) {
|
|
1046
|
-
try {
|
|
1047
|
-
git(["-C", k.root, "worktree", "remove", "--force", dir]);
|
|
1048
|
-
} catch {}
|
|
1049
|
-
onFinished(t, { branch, log });
|
|
1050
|
-
return;
|
|
1051
|
-
}
|
|
1052
|
-
// It stopped to ask, and the question is waiting on a person: not given up. The work stays held
|
|
1053
|
-
// here with its worktree, and the run picks up again once they answer (resumeAnswered).
|
|
1054
|
-
if (held?.claim?.asking) {
|
|
1055
|
-
waiting.set(t.id, { t, k, dir, branch });
|
|
1056
|
-
onWaiting(t, { question: held.claim.asking, dir });
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
const why = runner.passBack({
|
|
1060
|
-
minutes,
|
|
1061
|
-
...ran,
|
|
1062
|
-
note: held?.claim?.note || "",
|
|
1063
|
-
said: ran.said || runner.lastLine(log),
|
|
1064
|
-
branch,
|
|
1065
|
-
machine: name,
|
|
1066
|
-
});
|
|
1067
|
-
try {
|
|
1068
|
-
await pass(t.id, why, { cwd: dir, runner: true });
|
|
1069
|
-
onPassed(t, { why, dir, log });
|
|
1070
|
-
} catch (err) {
|
|
1071
|
-
onTrouble(err, t);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const running = new Set();
|
|
1076
|
-
/** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
|
|
1077
|
-
const waiting = new Map();
|
|
1078
|
-
/** Runs stopped because the agent's usage ran out, the same way: they carry on at `pauseUntil`. */
|
|
1079
|
-
const paused = new Map();
|
|
1080
|
-
let pauseUntil = 0;
|
|
1081
|
-
/** Start an agent again where it stopped: the same worktree, branch and claim. */
|
|
1082
|
-
function resume(t, k, dir, branch, { afterPause = false } = {}) {
|
|
1083
|
-
runner.countRun(countFile, runner.dayOf(now()));
|
|
1084
|
-
onNow.set(t.id, t.title || t.id);
|
|
1085
|
-
onResumed(t, { dir, branch, afterPause });
|
|
1086
|
-
say();
|
|
1087
|
-
const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
|
|
1088
|
-
.catch((err) => onTrouble(err, t))
|
|
1089
|
-
.finally(() => {
|
|
1090
|
-
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
1091
|
-
running.delete(run);
|
|
1092
|
-
say();
|
|
1093
|
-
});
|
|
1094
|
-
running.add(run);
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/**
|
|
1098
|
-
* Questions answered since the last look start their run again, while there is a slot and the
|
|
1099
|
-
* day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
|
|
1100
|
-
*/
|
|
1101
|
-
async function resumeAnswered() {
|
|
1102
|
-
if (!waiting.size) return;
|
|
1103
|
-
let rows = [];
|
|
1104
|
-
try {
|
|
1105
|
-
rows = (await api.tasks()).tasks;
|
|
1106
|
-
} catch (err) {
|
|
1107
|
-
return onTrouble(err);
|
|
1108
|
-
}
|
|
1109
|
-
for (const [id, w] of [...waiting]) {
|
|
1110
|
-
const row = rows.find((x) => x.id === id);
|
|
1111
|
-
if (row?.state === "done") {
|
|
1112
|
-
waiting.delete(id);
|
|
1113
|
-
try {
|
|
1114
|
-
git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
|
|
1115
|
-
} catch {}
|
|
1116
|
-
onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
if (!row?.claim) {
|
|
1120
|
-
waiting.delete(id);
|
|
1121
|
-
onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
if (row.claim.asking) continue;
|
|
1125
|
-
if (running.size >= atOnce || paused.size) return;
|
|
1126
|
-
waiting.delete(id);
|
|
1127
|
-
resume(w.t, w.k, w.dir, w.branch);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// A runner started again finds what its earlier runs left held: their worktrees are still beside
|
|
1132
|
-
// their checkouts. One waiting on a question waits on; any other (paused when the usage ran out,
|
|
1133
|
-
// or stopped with the runner) carries on straight away.
|
|
1134
|
-
try {
|
|
1135
|
-
for (const row of (await api.tasks()).tasks) {
|
|
1136
|
-
const leaf = basename(row.claim?.worktree || "");
|
|
1137
|
-
if (!leaf.startsWith("run-") || row.state === "done") continue;
|
|
1138
|
-
const k = checkouts.find((x) => existsSync(join(x.runs, leaf)));
|
|
1139
|
-
if (!k) continue;
|
|
1140
|
-
const dir = join(k.runs, leaf);
|
|
1141
|
-
let branch = runner.branchOf(row.id);
|
|
1142
|
-
try {
|
|
1143
|
-
branch = git(["-C", dir, "branch", "--show-current"]) || branch;
|
|
1144
|
-
} catch {}
|
|
1145
|
-
(row.claim?.asking ? waiting : paused).set(row.id, {
|
|
1146
|
-
t: { id: row.id, title: row.title, markdown: "" },
|
|
1147
|
-
k,
|
|
1148
|
-
dir,
|
|
1149
|
-
branch,
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
} catch {}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* While the agent's usage is out: keep each paused run's hold alive (an empty report of steps
|
|
1156
|
-
* renews it, so the hub doesn't call it gone quiet), and once the time comes, carry them on. If
|
|
1157
|
-
* the usage is still out they pause again at once, which costs nothing.
|
|
1158
|
-
*/
|
|
1159
|
-
async function carryOnPaused() {
|
|
1160
|
-
if (!paused.size) return;
|
|
1161
|
-
if (now() < pauseUntil) {
|
|
1162
|
-
for (const id of paused.keys()) await postSteps(id, []).catch(() => {});
|
|
1163
|
-
return;
|
|
1164
|
-
}
|
|
1165
|
-
pauseUntil = 0;
|
|
1166
|
-
for (const [id, p] of [...paused]) {
|
|
1167
|
-
if (running.size >= atOnce) break;
|
|
1168
|
-
paused.delete(id);
|
|
1169
|
-
resume(p.t, p.k, p.dir, p.branch, { afterPause: true });
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
let n = 0;
|
|
1174
|
-
for (;;) {
|
|
1175
|
-
await carryOnPaused();
|
|
1176
|
-
await resumeAnswered();
|
|
1177
|
-
// Nothing new while the usage is out: it would only start and stop at once.
|
|
1178
|
-
while (running.size < atOnce && !paused.size && now() >= pauseUntil) {
|
|
1179
|
-
let slot;
|
|
1180
|
-
try {
|
|
1181
|
-
slot = await startOne(++n);
|
|
1182
|
-
} catch (err) {
|
|
1183
|
-
onTrouble(err);
|
|
1184
|
-
break;
|
|
1185
|
-
}
|
|
1186
|
-
if (!slot) {
|
|
1187
|
-
if (!running.size) onIdle(repos);
|
|
1188
|
-
break;
|
|
1189
|
-
}
|
|
1190
|
-
const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
|
|
1191
|
-
running.add(run);
|
|
1192
|
-
}
|
|
1193
|
-
// Wait for a run to end or for the next look, whichever is first.
|
|
1194
|
-
await say();
|
|
1195
|
-
await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
890
|
/**
|
|
1200
891
|
* Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
|
|
1201
892
|
* moment a planner has written several and you have read them all. Only your own: sending work on
|