passalong 1.0.0 → 1.2.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 +3 -1
- package/bin/passalong +58 -20
- package/package.json +1 -1
- package/src/api.js +8 -3
- package/src/hooks.js +5 -5
- package/src/mcp.js +80 -46
- package/src/passalong.js +407 -37
- package/src/runner.js +515 -0
- package/src/update.js +1 -1
- package/src/work.js +4 -4
package/src/passalong.js
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, join } from "node:path";
|
|
|
8
8
|
import * as api from "./api.js";
|
|
9
9
|
import { context } from "./capture.js";
|
|
10
10
|
import { refusal, runChecks } from "./checks.js";
|
|
11
|
+
import * as runner from "./runner.js";
|
|
11
12
|
import * as store from "./store.js";
|
|
12
13
|
import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
|
|
13
14
|
|
|
@@ -48,11 +49,11 @@ export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}
|
|
|
48
49
|
// below is what refuses work that names itself.
|
|
49
50
|
if (parent) work.meta.parent = await resolveId(String(parent).trim());
|
|
50
51
|
if (work.meta.to && !work.meta.team)
|
|
51
|
-
throw new PassalongError("`to:` needs a team
|
|
52
|
+
throw new PassalongError("`to:` needs a team: address it as team/@handle");
|
|
52
53
|
const errors = [
|
|
53
54
|
...unheldFields(markdown).map(
|
|
54
55
|
(k) =>
|
|
55
|
-
`frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped
|
|
56
|
+
`frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped: move it into the body (a fenced yaml block keeps it as written)`,
|
|
56
57
|
),
|
|
57
58
|
...validate(work),
|
|
58
59
|
];
|
|
@@ -212,7 +213,7 @@ function knownAgent(cwd = process.cwd()) {
|
|
|
212
213
|
function needsSync(what) {
|
|
213
214
|
if (!api.loggedIn())
|
|
214
215
|
throw new PassalongError(
|
|
215
|
-
`${what} needs sync
|
|
216
|
+
`${what} needs sync: work lives on the server (run \`passalong login\`)`,
|
|
216
217
|
);
|
|
217
218
|
}
|
|
218
219
|
|
|
@@ -273,7 +274,7 @@ function requireEvidence(evidence) {
|
|
|
273
274
|
const said = String(evidence || "").trim();
|
|
274
275
|
if (said.length >= 16) return;
|
|
275
276
|
throw new PassalongError(
|
|
276
|
-
"send `evidence`: what you ran and what came back
|
|
277
|
+
"send `evidence`: what you ran and what came back, with the command and the lines that decided " +
|
|
277
278
|
"it, a test summary, a link to the change, or a screenshot url. " +
|
|
278
279
|
`"${said || "nothing"}" is a claim, not evidence.`,
|
|
279
280
|
);
|
|
@@ -377,8 +378,12 @@ export async function assign(ref, to) {
|
|
|
377
378
|
return api.assign(id, target);
|
|
378
379
|
}
|
|
379
380
|
|
|
380
|
-
/**
|
|
381
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next. `runner`
|
|
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.
|
|
385
|
+
*/
|
|
386
|
+
export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}) {
|
|
382
387
|
needsSync("passing work");
|
|
383
388
|
if (!String(why || "").trim())
|
|
384
389
|
throw new PassalongError("say why you are passing it, so whoever is next knows");
|
|
@@ -387,6 +392,7 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
|
387
392
|
agent: agent(cwd).agent,
|
|
388
393
|
why,
|
|
389
394
|
...(fence === undefined ? {} : { fence }),
|
|
395
|
+
...(runner ? { runner: true } : {}),
|
|
390
396
|
});
|
|
391
397
|
}
|
|
392
398
|
|
|
@@ -395,7 +401,7 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
|
395
401
|
* and not `progress`, which says carry on: the answer to this one is "stop, and take it again when
|
|
396
402
|
* they have replied", and the reply comes back with that `take`.
|
|
397
403
|
*/
|
|
398
|
-
export async function ask(id, question, { cwd = process.cwd() } = {}) {
|
|
404
|
+
export async function ask(id, question, { cwd = process.cwd(), choices } = {}) {
|
|
399
405
|
needsSync("asking");
|
|
400
406
|
if (!String(question || "").trim())
|
|
401
407
|
throw new PassalongError("say what you need to know, so the person can answer it");
|
|
@@ -404,6 +410,8 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
|
|
|
404
410
|
agent: agent(cwd).agent,
|
|
405
411
|
question,
|
|
406
412
|
...(fence === undefined ? {} : { fence }),
|
|
413
|
+
// Options the person picks from with a tap in the hub; the pick comes back as the reply.
|
|
414
|
+
...(Array.isArray(choices) && choices.length ? { choices } : {}),
|
|
407
415
|
});
|
|
408
416
|
}
|
|
409
417
|
|
|
@@ -517,7 +525,7 @@ export async function attachBytes(data, { name = "", type = "image/png" } = {})
|
|
|
517
525
|
needsSync("adding a file");
|
|
518
526
|
const allowed = new Set(Object.values(SHOT_TYPES));
|
|
519
527
|
if (!allowed.has(type))
|
|
520
|
-
throw new PassalongError(`pictures must be ${[...allowed].join(", ")}
|
|
528
|
+
throw new PassalongError(`pictures must be ${[...allowed].join(", ")}: ${type} is not one`);
|
|
521
529
|
// A data: URL is what several browser tools hand back, so it is accepted rather than made the
|
|
522
530
|
// caller's problem to strip.
|
|
523
531
|
const bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
|
|
@@ -525,7 +533,7 @@ export async function attachBytes(data, { name = "", type = "image/png" } = {})
|
|
|
525
533
|
// The server's own ceiling, checked here so a large paste fails before it is sent.
|
|
526
534
|
if (bytes.length > 5 * 1024 * 1024)
|
|
527
535
|
throw new PassalongError(
|
|
528
|
-
`that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB
|
|
536
|
+
`that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB: write it to a file and pass its path`,
|
|
529
537
|
);
|
|
530
538
|
const label = name || "screenshot";
|
|
531
539
|
const shot = await api.uploadShot(bytes, type, label);
|
|
@@ -544,7 +552,7 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
544
552
|
const type = SHOT_TYPES[ext];
|
|
545
553
|
if (!type)
|
|
546
554
|
throw new PassalongError(
|
|
547
|
-
`pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")}
|
|
555
|
+
`pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")}: ${ext || file} is not one`,
|
|
548
556
|
);
|
|
549
557
|
const shot = await api.uploadShot(readFileSync(file), type, name || basename(file));
|
|
550
558
|
return { ...shot, markdown: `` };
|
|
@@ -578,7 +586,7 @@ export async function attachFile(file, { name = "" } = {}) {
|
|
|
578
586
|
if (!bytes.length) throw new PassalongError(`${file} is empty`);
|
|
579
587
|
if (bytes.length > FILE_MAX)
|
|
580
588
|
throw new PassalongError(
|
|
581
|
-
`${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB
|
|
589
|
+
`${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB: send a link instead`,
|
|
582
590
|
);
|
|
583
591
|
const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
|
|
584
592
|
const label = (name || basename(file)).replace(/[[\]()]/g, "");
|
|
@@ -612,7 +620,7 @@ export async function reply(ref, text, { files = [] } = {}) {
|
|
|
612
620
|
* work. Neither is a second document handed to one person. Mirrors AFTER in apps/api/src/mcp-http.ts.
|
|
613
621
|
*/
|
|
614
622
|
export const AFTER =
|
|
615
|
-
"<!-- passalong: what you learn doing this
|
|
623
|
+
"<!-- passalong: what you learn doing this (how something works, a decision and why) goes " +
|
|
616
624
|
"into the project's documents (find_projects, read_document, save_document), not into a new " +
|
|
617
625
|
"piece of work. Work somebody else should now do is create_work with `parent` set to this id. " +
|
|
618
626
|
"What you did and how you checked it goes on hand_in. -->";
|
|
@@ -625,6 +633,35 @@ export const AFTER =
|
|
|
625
633
|
* worth failing the call over: the work itself is what was asked for. Mirrors parentOf() in
|
|
626
634
|
* apps/api/src/mcp-http.ts.
|
|
627
635
|
*/
|
|
636
|
+
/**
|
|
637
|
+
* What to know before starting, as the server hands it over on take: each project the work is filed
|
|
638
|
+
* in, its README and the names of its other documents — or "" when it is in none. It sits in front
|
|
639
|
+
* of the work, after the work it came out of. Mirrors projectsNote in apps/api/src/mcp-http.ts.
|
|
640
|
+
*/
|
|
641
|
+
export function projectsNote(projects) {
|
|
642
|
+
if (!Array.isArray(projects) || !projects.length) return "";
|
|
643
|
+
const blocks = projects.map((p) => {
|
|
644
|
+
const name = p.title || p.id;
|
|
645
|
+
const where = `${name} (${p.id})${p.repo ? `, built from ${p.repo}` : ""}`;
|
|
646
|
+
const about = p.description ? ` ${p.description}` : "";
|
|
647
|
+
const lead = p.readme
|
|
648
|
+
? `WHAT TO KNOW FIRST: this work is filed in the project ${where}.${about} Its README is ` +
|
|
649
|
+
"below: read it before you start."
|
|
650
|
+
: `WHAT TO KNOW FIRST: this work is filed in the project ${where}.${about} It has no README yet.`;
|
|
651
|
+
const readme = p.readme
|
|
652
|
+
? `\n\n--- README of ${name}: document ${p.readme.id} ---\n${p.readme.body.trimEnd()}\n` +
|
|
653
|
+
(p.readme.cut ? `…it goes on: read_document ${p.readme.id} for the rest.\n` : "")
|
|
654
|
+
: "\n";
|
|
655
|
+
const docs = p.documents?.length
|
|
656
|
+
? "Its other documents: open the ones that bear on this with read_document: " +
|
|
657
|
+
`${p.documents.map((d) => `"${d.name}" (${d.id})`).join(", ")}` +
|
|
658
|
+
`${p.more ? `, and ${p.more} more (find_projects id ${p.id})` : ""}.\n`
|
|
659
|
+
: "";
|
|
660
|
+
return `${lead}${readme}${docs}`;
|
|
661
|
+
});
|
|
662
|
+
return `${blocks.join("\n")}\n`;
|
|
663
|
+
}
|
|
664
|
+
|
|
628
665
|
export async function parentWork(meta = {}, { parent = api.parent, loggedIn = api.loggedIn } = {}) {
|
|
629
666
|
if (!meta.id || !meta.parent || !loggedIn()) return "";
|
|
630
667
|
try {
|
|
@@ -633,7 +670,7 @@ export async function parentWork(meta = {}, { parent = api.parent, loggedIn = ap
|
|
|
633
670
|
const who = work.by?.name || (work.by?.handle ? `@${work.by.handle}` : "");
|
|
634
671
|
const state = work.state === "held" && who ? `held by ${who}` : work.state || "open";
|
|
635
672
|
return (
|
|
636
|
-
`THIS CAME OUT OF ${work.id}: ${work.title || "untitled"}
|
|
673
|
+
`THIS CAME OUT OF ${work.id}: ${work.title || "untitled"} (${state}). It is part of that ` +
|
|
637
674
|
"work, which is below; read it first. If this depends on " +
|
|
638
675
|
`${work.id} being done and it is not, say so rather than starting.\n\n` +
|
|
639
676
|
`--- the work it came out of: ${work.id} ---\n${String(work.markdown ?? "").trimEnd()}\n\n`
|
|
@@ -674,17 +711,17 @@ export function workPrompt(t) {
|
|
|
674
711
|
t.markdown.trim(),
|
|
675
712
|
"",
|
|
676
713
|
"Do it in this repo: read it, work out what done means, and get there. Call the passalong MCP",
|
|
677
|
-
`tool progress with id ${t.id} and a one-line status at each milestone
|
|
714
|
+
`tool progress with id ${t.id} and a one-line status at each milestone; 30 minutes of`,
|
|
678
715
|
"silence stalls it. When it is done, commit what you changed as one commit whose message",
|
|
679
|
-
`starts with "${t.id}: "
|
|
716
|
+
`starts with "${t.id}: ": several pieces of work share this worktree, and a person reads each`,
|
|
680
717
|
`one's change on its own. Then call hand_in with id ${t.id}, \`pr\` set to that commit's hash,`,
|
|
681
718
|
"`note`: one plain sentence saying what you did, and `evidence`: what you ran and what came",
|
|
682
|
-
"back
|
|
719
|
+
"back, with the commands and the lines that decided it, the test summary, the commit. Keep that",
|
|
683
720
|
"as you go; at the end you would be writing it from memory. What you learned about the",
|
|
684
721
|
"project goes into its documents (save_document). If a call says you no longer hold it, stop.",
|
|
685
722
|
"",
|
|
686
723
|
"Nobody reads what you print: this session runs unattended. If something only a person can",
|
|
687
|
-
"do stands between you and done
|
|
724
|
+
"do stands between you and done (a permission, a secret, a decision), call",
|
|
688
725
|
`progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you`,
|
|
689
726
|
"need, leave it unfinished and uncommitted, and stop. The board shows that note.",
|
|
690
727
|
].join("\n");
|
|
@@ -701,34 +738,20 @@ export function workPrompt(t) {
|
|
|
701
738
|
* An agent that exits without finishing stops the loop, rather than piling up half-done work. It
|
|
702
739
|
* stays held here, for a person to see on the board and resume (take it) or release.
|
|
703
740
|
*
|
|
704
|
-
*
|
|
705
|
-
* checked out, it takes only work a person marked safe to run alone, waits `every` seconds when
|
|
706
|
-
* there is none, and keeps going past work its agent did not finish — that stays held here, the
|
|
707
|
-
* sweep puts it back in the queue once it goes quiet, and a second time a person is told it is
|
|
708
|
-
* stuck. Nobody is watching, so nothing stops for a person.
|
|
741
|
+
* The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
|
|
709
742
|
*/
|
|
710
743
|
export async function work({
|
|
711
744
|
cwd = process.cwd(),
|
|
712
745
|
agent = process.env.PASSALONG_AGENT || "claude -p",
|
|
713
746
|
any = false,
|
|
714
747
|
once = false,
|
|
715
|
-
watch = false,
|
|
716
|
-
every = 60,
|
|
717
748
|
onTask = () => {},
|
|
718
|
-
onIdle = () => {},
|
|
719
|
-
onUnfinished = () => {},
|
|
720
|
-
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
721
749
|
} = {}) {
|
|
722
750
|
needsSync("working the queue");
|
|
723
751
|
const [command, ...args] = agent.trim().split(/\s+/);
|
|
724
752
|
const finished = [];
|
|
725
753
|
for (;;) {
|
|
726
|
-
const t = await nextWork({ cwd, any
|
|
727
|
-
if (!t && watch) {
|
|
728
|
-
onIdle();
|
|
729
|
-
await sleep(Math.max(10, every) * 1000);
|
|
730
|
-
continue;
|
|
731
|
-
}
|
|
754
|
+
const t = await nextWork({ cwd, any });
|
|
732
755
|
if (!t) return { finished, stopped: "empty" };
|
|
733
756
|
onTask(t);
|
|
734
757
|
const run = spawnSync(command, [...args, workPrompt(t)], {
|
|
@@ -737,10 +760,6 @@ export async function work({
|
|
|
737
760
|
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
738
761
|
});
|
|
739
762
|
const state = (await api.tasks()).tasks.find((x) => x.id === t.id)?.state;
|
|
740
|
-
if (state !== "done" && watch) {
|
|
741
|
-
onUnfinished(t, state, run.status ?? run.error?.message);
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
763
|
if (state !== "done")
|
|
745
764
|
return {
|
|
746
765
|
finished,
|
|
@@ -754,6 +773,357 @@ export async function work({
|
|
|
754
773
|
}
|
|
755
774
|
}
|
|
756
775
|
|
|
776
|
+
/**
|
|
777
|
+
* The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
|
|
778
|
+
* a git worktree of its own beside its checkout, on its own branch, by an agent that finishes with
|
|
779
|
+
* a pull request. Up to `atOnce` at a time and `perDay` a day; a run that ends without the work done
|
|
780
|
+
* passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
|
|
781
|
+
*
|
|
782
|
+
* Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
|
|
783
|
+
* one with an `origin` remote, so one terminal does what six used to. The folder is checked before
|
|
784
|
+
* anything is said (`onStart`), so a runner that cannot run says why instead of what it will do.
|
|
785
|
+
*
|
|
786
|
+
* Work is asked for before a worktree is made: the agent's id is minted first and written into the
|
|
787
|
+
* worktree once there is work for it, so a runner serving many repos does not make and remove a
|
|
788
|
+
* worktree in each of them every minute. What the agent does is read as it goes (runner.describe)
|
|
789
|
+
* and sent to the hub every few seconds as steps, and to `onStep` for the terminal.
|
|
790
|
+
*/
|
|
791
|
+
export async function watch({
|
|
792
|
+
cwd = process.cwd(),
|
|
793
|
+
agent: command,
|
|
794
|
+
every = 60,
|
|
795
|
+
atOnce = runner.LIMITS.atOnce,
|
|
796
|
+
perDay = runner.LIMITS.perDay,
|
|
797
|
+
minutes = runner.LIMITS.minutes,
|
|
798
|
+
cli,
|
|
799
|
+
git = runner.git,
|
|
800
|
+
runAgent = runner.runAgent,
|
|
801
|
+
now = () => Date.now(),
|
|
802
|
+
countFile = join(store.HOME, "runner.json"),
|
|
803
|
+
machine = runner.machineName,
|
|
804
|
+
onStart = () => {},
|
|
805
|
+
onTask = () => {},
|
|
806
|
+
onStep = () => {},
|
|
807
|
+
onIdle = () => {},
|
|
808
|
+
onCapped = () => {},
|
|
809
|
+
onFinished = () => {},
|
|
810
|
+
onPassed = () => {},
|
|
811
|
+
onWaiting = () => {},
|
|
812
|
+
onResumed = () => {},
|
|
813
|
+
onTrouble = () => {},
|
|
814
|
+
report = api.runnerStatus,
|
|
815
|
+
postSteps = api.workSteps,
|
|
816
|
+
stepEvery = 5,
|
|
817
|
+
signals = false,
|
|
818
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
819
|
+
} = {}) {
|
|
820
|
+
needsSync("running work");
|
|
821
|
+
const inside = context(cwd).root;
|
|
822
|
+
const { found, skipped } = runner.checkoutsIn(cwd, { git });
|
|
823
|
+
if (inside && !found.length)
|
|
824
|
+
throw new PassalongError(
|
|
825
|
+
"this checkout has no `origin` remote: the runner pushes each change to a branch there",
|
|
826
|
+
);
|
|
827
|
+
if (!found.length)
|
|
828
|
+
throw new PassalongError(
|
|
829
|
+
`there's no git checkout in ${cwd} or the folders under it` +
|
|
830
|
+
(skipped.length ? ` with an \`origin\` remote (${skipped.length} without one)` : "") +
|
|
831
|
+
". Start the runner inside a repo, or in the folder that holds your repos.",
|
|
832
|
+
);
|
|
833
|
+
const checkouts = found.map(({ root, remote }) => {
|
|
834
|
+
const runs = runner.runsDirOf(root);
|
|
835
|
+
mkdirSync(runs, { recursive: true });
|
|
836
|
+
return { root, runs, repo: context(root).repo || remote, forge: runner.forgeOf(remote) };
|
|
837
|
+
});
|
|
838
|
+
const repos = checkouts.map((k) => k.repo);
|
|
839
|
+
const name = machine();
|
|
840
|
+
onStart({ repos, skipped });
|
|
841
|
+
|
|
842
|
+
// What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
|
|
843
|
+
// nobody has to look at this terminal to know. A failed report never stops a run.
|
|
844
|
+
const onNow = new Map();
|
|
845
|
+
const say = (stopped = false) =>
|
|
846
|
+
Promise.resolve(
|
|
847
|
+
report({
|
|
848
|
+
host: name,
|
|
849
|
+
repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
|
|
850
|
+
repos,
|
|
851
|
+
running: [...onNow].map(([id, title]) => ({ id, title })),
|
|
852
|
+
runs_today: runner.runsToday(countFile, runner.dayOf(now())),
|
|
853
|
+
per_day: perDay,
|
|
854
|
+
at_once: atOnce,
|
|
855
|
+
stopped,
|
|
856
|
+
}),
|
|
857
|
+
).catch(() => {});
|
|
858
|
+
// Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
|
|
859
|
+
// terminal; what they held stays held, and the sweep puts it back once it goes quiet.
|
|
860
|
+
if (signals)
|
|
861
|
+
process.once("SIGINT", async () => {
|
|
862
|
+
await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
|
|
863
|
+
process.exit(130);
|
|
864
|
+
});
|
|
865
|
+
const agentRun = command
|
|
866
|
+
? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
|
|
867
|
+
: runner.claudeCommand({ cli });
|
|
868
|
+
|
|
869
|
+
/** Make the worktree a taken piece of work runs in, as the agent whose id took it. */
|
|
870
|
+
function worktreeFor(k, dir, id) {
|
|
871
|
+
try {
|
|
872
|
+
git(["-C", k.root, "fetch", "--quiet", "origin"]);
|
|
873
|
+
} catch {}
|
|
874
|
+
let base = "HEAD";
|
|
875
|
+
try {
|
|
876
|
+
base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
|
|
877
|
+
? "origin/HEAD"
|
|
878
|
+
: base;
|
|
879
|
+
} catch {}
|
|
880
|
+
git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
|
|
881
|
+
writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* One slot: a piece of work taken for one of the repos, its worktree made and its agent started.
|
|
886
|
+
* `null` when nothing waits in any of them. The run itself is handed back unawaited, so several
|
|
887
|
+
* go at once. Repos are asked in turn, starting after the last one that had work.
|
|
888
|
+
*/
|
|
889
|
+
let turn = 0;
|
|
890
|
+
async function startOne(n) {
|
|
891
|
+
let t = null;
|
|
892
|
+
let k = null;
|
|
893
|
+
let dir = "";
|
|
894
|
+
let id = "";
|
|
895
|
+
for (let i = 0; i < checkouts.length && !t; i++) {
|
|
896
|
+
k = checkouts[(turn + i) % checkouts.length];
|
|
897
|
+
dir = join(k.runs, `run-${now()}-${n}`);
|
|
898
|
+
id = randomBytes(12).toString("hex");
|
|
899
|
+
try {
|
|
900
|
+
const got = await api.nextTask({
|
|
901
|
+
agent: id,
|
|
902
|
+
host: hostname(),
|
|
903
|
+
repo: k.repo,
|
|
904
|
+
worktree: dir,
|
|
905
|
+
alone: true,
|
|
906
|
+
});
|
|
907
|
+
t = got?.task || null;
|
|
908
|
+
} catch (err) {
|
|
909
|
+
onTrouble(err);
|
|
910
|
+
}
|
|
911
|
+
if (t) turn = (turn + i + 1) % checkouts.length;
|
|
912
|
+
}
|
|
913
|
+
if (!t || !k) return null;
|
|
914
|
+
// Its branch, or a fresh one beside it when that one is still checked out in the worktree of
|
|
915
|
+
// an earlier run that didn't finish (kept for its author), as when a person ticks it again.
|
|
916
|
+
let branch = runner.branchOf(t.id);
|
|
917
|
+
try {
|
|
918
|
+
worktreeFor(k, dir, id);
|
|
919
|
+
try {
|
|
920
|
+
git(["-C", dir, "switch", "--quiet", "-C", branch]);
|
|
921
|
+
} catch {
|
|
922
|
+
branch = `${branch}-${now().toString(36)}`;
|
|
923
|
+
git(["-C", dir, "switch", "--quiet", "-c", branch]);
|
|
924
|
+
}
|
|
925
|
+
} catch (err) {
|
|
926
|
+
// Taken and then stuck before any agent started: never left held with nobody on it.
|
|
927
|
+
const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
|
|
928
|
+
await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
|
|
929
|
+
onTrouble(e, t),
|
|
930
|
+
);
|
|
931
|
+
onPassed(t, { why, dir, log: "" });
|
|
932
|
+
return { run: Promise.resolve() };
|
|
933
|
+
}
|
|
934
|
+
runner.countRun(countFile, runner.dayOf(now()));
|
|
935
|
+
onNow.set(t.id, t.title || t.id);
|
|
936
|
+
onTask(t, { dir, branch, repo: k.repo });
|
|
937
|
+
say();
|
|
938
|
+
return {
|
|
939
|
+
run: finish(t, k, dir, branch).finally(() => {
|
|
940
|
+
onNow.delete(t.id);
|
|
941
|
+
say();
|
|
942
|
+
}),
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function finish(t, k, dir, branch, { resumed = false } = {}) {
|
|
947
|
+
const log = join(k.runs, `${t.id}.log`);
|
|
948
|
+
// Steps go to the hub every few seconds rather than one request each: an agent can take a
|
|
949
|
+
// dozen in a second. Whatever is left goes when the run ends.
|
|
950
|
+
const pending = [];
|
|
951
|
+
const flush = async () => {
|
|
952
|
+
if (!pending.length) return;
|
|
953
|
+
await postSteps(t.id, pending.splice(0)).catch(() => {});
|
|
954
|
+
};
|
|
955
|
+
const step = (text) => {
|
|
956
|
+
pending.push({ at: new Date(now()).toISOString(), text });
|
|
957
|
+
onStep(t, text);
|
|
958
|
+
};
|
|
959
|
+
step(
|
|
960
|
+
resumed
|
|
961
|
+
? `Carrying on after your answer, on ${name}`
|
|
962
|
+
: `Started on ${name} in ${k.repo}, on branch ${branch}`,
|
|
963
|
+
);
|
|
964
|
+
const timer = setInterval(flush, stepEvery * 1000);
|
|
965
|
+
const ran = await runAgent({
|
|
966
|
+
...agentRun,
|
|
967
|
+
prompt: resumed
|
|
968
|
+
? runner.resumePrompt(t, branch, { forge: k.forge })
|
|
969
|
+
: runner.runnerPrompt(t, branch, { forge: k.forge }),
|
|
970
|
+
cwd: dir,
|
|
971
|
+
log,
|
|
972
|
+
minutes,
|
|
973
|
+
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
974
|
+
onStep: step,
|
|
975
|
+
}).finally(() => clearInterval(timer));
|
|
976
|
+
if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
|
|
977
|
+
let held = null;
|
|
978
|
+
try {
|
|
979
|
+
held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
|
|
980
|
+
} catch {}
|
|
981
|
+
// Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
|
|
982
|
+
// it back again would be refused, and it is no longer this runner's to describe.
|
|
983
|
+
if (held?.state !== "done" && !held?.claim) {
|
|
984
|
+
step("It was no longer held here when the agent stopped, so the runner left it as it is");
|
|
985
|
+
await flush();
|
|
986
|
+
onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
// The last step says how the run ended, while the work is still held here: once it is passed
|
|
990
|
+
// back, its steps are no longer this runner's to write.
|
|
991
|
+
if (held.state !== "done" && !held.claim?.asking)
|
|
992
|
+
step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
|
|
993
|
+
if (held.claim?.asking) step("Waiting for your answer, then it carries on");
|
|
994
|
+
await flush();
|
|
995
|
+
if (held?.state === "done") {
|
|
996
|
+
try {
|
|
997
|
+
git(["-C", k.root, "worktree", "remove", "--force", dir]);
|
|
998
|
+
} catch {}
|
|
999
|
+
onFinished(t, { branch, log });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// It stopped to ask, and the question is waiting on a person: not given up. The work stays held
|
|
1003
|
+
// here with its worktree, and the run picks up again once they answer (resumeAnswered).
|
|
1004
|
+
if (held?.claim?.asking) {
|
|
1005
|
+
waiting.set(t.id, { t, k, dir, branch });
|
|
1006
|
+
onWaiting(t, { question: held.claim.asking, dir });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const why = runner.passBack({
|
|
1010
|
+
minutes,
|
|
1011
|
+
...ran,
|
|
1012
|
+
note: held?.claim?.note || "",
|
|
1013
|
+
said: ran.said || runner.lastLine(log),
|
|
1014
|
+
branch,
|
|
1015
|
+
machine: name,
|
|
1016
|
+
});
|
|
1017
|
+
try {
|
|
1018
|
+
await pass(t.id, why, { cwd: dir, runner: true });
|
|
1019
|
+
onPassed(t, { why, dir, log });
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
onTrouble(err, t);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const running = new Set();
|
|
1026
|
+
/** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
|
|
1027
|
+
const waiting = new Map();
|
|
1028
|
+
/** Start an agent again where it stopped: the same worktree, branch and claim. */
|
|
1029
|
+
function resume(t, k, dir, branch) {
|
|
1030
|
+
runner.countRun(countFile, runner.dayOf(now()));
|
|
1031
|
+
onNow.set(t.id, t.title || t.id);
|
|
1032
|
+
onResumed(t, { dir, branch });
|
|
1033
|
+
say();
|
|
1034
|
+
const run = finish(t, k, dir, branch, { resumed: true })
|
|
1035
|
+
.catch((err) => onTrouble(err, t))
|
|
1036
|
+
.finally(() => {
|
|
1037
|
+
onNow.delete(t.id);
|
|
1038
|
+
running.delete(run);
|
|
1039
|
+
say();
|
|
1040
|
+
});
|
|
1041
|
+
running.add(run);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Questions answered since the last look start their run again, while there is a slot and the
|
|
1046
|
+
* day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
|
|
1047
|
+
*/
|
|
1048
|
+
async function resumeAnswered() {
|
|
1049
|
+
if (!waiting.size) return;
|
|
1050
|
+
let rows = [];
|
|
1051
|
+
try {
|
|
1052
|
+
rows = (await api.tasks()).tasks;
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
return onTrouble(err);
|
|
1055
|
+
}
|
|
1056
|
+
for (const [id, w] of [...waiting]) {
|
|
1057
|
+
const row = rows.find((x) => x.id === id);
|
|
1058
|
+
if (row?.state === "done") {
|
|
1059
|
+
waiting.delete(id);
|
|
1060
|
+
try {
|
|
1061
|
+
git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
|
|
1062
|
+
} catch {}
|
|
1063
|
+
onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (!row?.claim) {
|
|
1067
|
+
waiting.delete(id);
|
|
1068
|
+
onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (row.claim.asking) continue;
|
|
1072
|
+
if (running.size >= atOnce) return;
|
|
1073
|
+
if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
|
|
1074
|
+
waiting.delete(id);
|
|
1075
|
+
resume(w.t, w.k, w.dir, w.branch);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// A runner started again finds the questions its earlier runs left waiting: their worktrees are
|
|
1080
|
+
// still beside their checkouts, and the work is still held by them.
|
|
1081
|
+
try {
|
|
1082
|
+
for (const row of (await api.tasks()).tasks) {
|
|
1083
|
+
const leaf = basename(row.claim?.worktree || "");
|
|
1084
|
+
if (!leaf.startsWith("run-") || row.state === "done") continue;
|
|
1085
|
+
const k = checkouts.find((x) => existsSync(join(x.runs, leaf)));
|
|
1086
|
+
if (!k) continue;
|
|
1087
|
+
const dir = join(k.runs, leaf);
|
|
1088
|
+
let branch = runner.branchOf(row.id);
|
|
1089
|
+
try {
|
|
1090
|
+
branch = git(["-C", dir, "branch", "--show-current"]) || branch;
|
|
1091
|
+
} catch {}
|
|
1092
|
+
waiting.set(row.id, { t: { id: row.id, title: row.title, markdown: "" }, k, dir, branch });
|
|
1093
|
+
}
|
|
1094
|
+
} catch {}
|
|
1095
|
+
|
|
1096
|
+
let n = 0;
|
|
1097
|
+
let cappedOn = "";
|
|
1098
|
+
for (;;) {
|
|
1099
|
+
await resumeAnswered();
|
|
1100
|
+
while (running.size < atOnce) {
|
|
1101
|
+
const day = runner.dayOf(now());
|
|
1102
|
+
if (runner.runsToday(countFile, day) >= perDay) {
|
|
1103
|
+
if (cappedOn !== day) onCapped(perDay);
|
|
1104
|
+
cappedOn = day;
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
let slot;
|
|
1108
|
+
try {
|
|
1109
|
+
slot = await startOne(++n);
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
onTrouble(err);
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
if (!slot) {
|
|
1115
|
+
if (!running.size) onIdle(repos);
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
|
|
1119
|
+
running.add(run);
|
|
1120
|
+
}
|
|
1121
|
+
// Wait for a run to end or for the next look, whichever is first.
|
|
1122
|
+
await say();
|
|
1123
|
+
await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
757
1127
|
/**
|
|
758
1128
|
* Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
|
|
759
1129
|
* moment a planner has written several and you have read them all. Only your own: sending work on
|