@webappwiz/arbor 0.0.7 → 0.0.9
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 +18 -0
- package/config.d.ts +11 -34
- package/config.js +6 -5
- package/dev.d.ts +16 -4
- package/exit.d.ts +1 -0
- package/index-84xjmns8.js +20 -0
- package/index.js +93 -27
- package/load-config.d.ts +1 -1
- package/package.json +2 -2
- package/wait.d.ts +22 -0
package/README.md
CHANGED
|
@@ -131,6 +131,23 @@ prescribes (`# <task>`, `## Goal`, `## Next` with something unchecked in it, a
|
|
|
131
131
|
Warnings only, never a refusal: the agent that wrote the file is the one that
|
|
132
132
|
runs `show` on it, and a rough plan still beats none.
|
|
133
133
|
|
|
134
|
+
### `arbor wait <task> [--timeout-secs 300]`
|
|
135
|
+
|
|
136
|
+
Blocks until a task stops moving, then prints where it stopped.
|
|
137
|
+
|
|
138
|
+
Moving means `working` or `merging`. Everything else is somewhere it stays
|
|
139
|
+
without a person: `escalated` (printed with its reason), `removed` (merged or
|
|
140
|
+
discarded, and `arbor log` says which), or one of the broken statuses.
|
|
141
|
+
|
|
142
|
+
This is for the agent whose own work overlaps a task already in flight and
|
|
143
|
+
would rather rebase onto its result than against it. The timeout is short by
|
|
144
|
+
design: five minutes, and then a `timeout` refusal that hands the decision
|
|
145
|
+
back, rather than a session that blocks all afternoon on a tree nobody is
|
|
146
|
+
driving. Wait again, work alongside it, or ask the human.
|
|
147
|
+
|
|
148
|
+
Like `show` and `path`, it takes no lease, so watching a task cannot knock its
|
|
149
|
+
agent off it.
|
|
150
|
+
|
|
134
151
|
### `arbor log [--count 20] [--json]`
|
|
135
152
|
|
|
136
153
|
The last N things done here (`add`, `claim`, `merge`, `rm`, `escalate`,
|
|
@@ -221,6 +238,7 @@ The agent's control flow runs on these.
|
|
|
221
238
|
| 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
|
|
222
239
|
| 12 | `merge_failed` | Trunk could not be fast-forwarded (usually a dirty main worktree). |
|
|
223
240
|
| 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
|
|
241
|
+
| 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
|
|
224
242
|
|
|
225
243
|
Every failure prints a one-line JSON object on **stdout** (`{"reason": ...}`,
|
|
226
244
|
plus fields like `paths` for conflicts) and the human explanation on **stderr**.
|
package/config.d.ts
CHANGED
|
@@ -1,49 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { type InferConfig } from "webappwiz/config";
|
|
2
|
+
/**
|
|
3
|
+
* The settings arbor runs a repo with. `loadConfig` builds one; everything a
|
|
4
|
+
* command needs comes off it with `get`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CONFIG_FACTORY: import("webappwiz/config").ConfigFactory<{
|
|
6
7
|
trunk: string;
|
|
7
|
-
/** Directory holding one worktree per task, a sibling of the repo. */
|
|
8
8
|
worktreeRoot: string;
|
|
9
|
-
/** Command run by `add` in the new worktree, via `sh -c`. */
|
|
10
9
|
postCheckout: string | null;
|
|
11
|
-
/**
|
|
12
|
-
* Command run by `merge` after the rebase, before `preMerge`, via `sh -c`.
|
|
13
|
-
* A rebase can bring in a dependency the worktree has never installed, and
|
|
14
|
-
* whatever `preMerge` runs needs it.
|
|
15
|
-
*/
|
|
16
10
|
postRewrite: string | null;
|
|
17
|
-
/**
|
|
18
|
-
* Command run by `merge` after the rebase, via `sh -c`. The last gate before
|
|
19
|
-
* the branch lands: a nonzero exit rolls the branch back and leaves the base
|
|
20
|
-
* untouched. Tests are the obvious thing to put here, but arbor has no
|
|
21
|
-
* opinion; a repo with nothing to run leaves it null and merges on a green
|
|
22
|
-
* rebase alone.
|
|
23
|
-
*/
|
|
24
11
|
preMerge: string | null;
|
|
25
|
-
/**
|
|
26
|
-
* Command run by `merge` in the main tree after the base fast-forwards, via
|
|
27
|
-
* `sh -c`. The branch has already landed and the worktree is gone: a nonzero
|
|
28
|
-
* exit reports the failure but rolls nothing back.
|
|
29
|
-
*/
|
|
30
12
|
postMerge: string | null;
|
|
31
|
-
/** How long since its last heartbeat before a task's lease is up for grabs. */
|
|
32
13
|
leaseStalenessMs: number;
|
|
33
|
-
/** Failed `merge` attempts a task gets before it must escalate or be removed. */
|
|
34
14
|
mergeRetryCount: number;
|
|
35
|
-
/**
|
|
36
|
-
* How many removed task names to keep, so `rm` can say "already removed"
|
|
37
|
-
* rather than "never existed". A flat cap with no age policy: losing the
|
|
38
|
-
* oldest costs a nicer message and nothing else.
|
|
39
|
-
*/
|
|
40
15
|
removedCapacity: number;
|
|
41
|
-
/** How many entries `arbor log` keeps before the oldest fall off. */
|
|
42
16
|
logCapacity: number;
|
|
43
|
-
}
|
|
17
|
+
}>;
|
|
18
|
+
export type Config = InferConfig<typeof CONFIG_FACTORY>;
|
|
19
|
+
/** The plain shape behind a `Config`: what a config file exports a part of. */
|
|
20
|
+
export type ConfigRecord = ReturnType<Config["toRecord"]>;
|
|
44
21
|
/**
|
|
45
22
|
* Identity, for the types. `export default defineConfig({ ... })` in
|
|
46
23
|
* `arbor.config.ts` gets the key names and their types checked, and completion
|
|
47
24
|
* while writing it; a bare object literal gets neither.
|
|
48
25
|
*/
|
|
49
|
-
export declare function defineConfig(config: Partial<
|
|
26
|
+
export declare function defineConfig(config: Partial<ConfigRecord>): Partial<ConfigRecord>;
|
package/config.js
CHANGED
package/dev.d.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type { HttpServer } from "webappwiz/http";
|
|
2
2
|
import type { Logger } from "webappwiz/log";
|
|
3
|
-
import type
|
|
3
|
+
import { type Fs, type PortProvider } from "webappwiz/system";
|
|
4
4
|
import type { Assets } from "./dev/assets.js";
|
|
5
5
|
import type { Journal } from "./journal.js";
|
|
6
6
|
import type { WorktreeService } from "./worktree-service.js";
|
|
7
|
+
/** Preferred, not required: `dev` moves up from here when it is taken. */
|
|
7
8
|
export declare const DEFAULT_PORT = 4269;
|
|
9
|
+
/** How far above the port asked for `dev` will look before giving up. */
|
|
10
|
+
export declare const PORT_SPAN = 20;
|
|
11
|
+
/**
|
|
12
|
+
* Where `dev` will listen, given the port asked for. A flag is outside input,
|
|
13
|
+
* so a port that cannot exist is a refusal with an exit code rather than the
|
|
14
|
+
* assertion `OpenPortProvider` would raise for a bug in here.
|
|
15
|
+
*/
|
|
16
|
+
export declare function devPorts(from: number): PortProvider;
|
|
17
|
+
/** What `dev` lets a caller choose. */
|
|
18
|
+
export interface DevOptions {
|
|
19
|
+
/** Where to listen; the port `--port` asked for, and the span above it. */
|
|
20
|
+
ports?: PortProvider;
|
|
21
|
+
}
|
|
8
22
|
/** A running server, and the one thing a caller ever wants to do with it. */
|
|
9
23
|
export interface DevServer {
|
|
10
24
|
port: number;
|
|
@@ -26,6 +40,4 @@ export declare function dev({ service, fs, journal, log, http, assets, }: {
|
|
|
26
40
|
log: Logger;
|
|
27
41
|
http: HttpServer;
|
|
28
42
|
assets: Assets;
|
|
29
|
-
}, {
|
|
30
|
-
port?: number | undefined;
|
|
31
|
-
}): Promise<DevServer>;
|
|
43
|
+
}, { ports }?: DevOptions): Promise<DevServer>;
|
package/exit.d.ts
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// config.ts
|
|
2
|
+
import { Config as WizConfig } from "webappwiz/config";
|
|
3
|
+
import { t } from "webappwiz/t";
|
|
4
|
+
var CONFIG_FACTORY = WizConfig.factory({
|
|
5
|
+
trunk: t.string(),
|
|
6
|
+
worktreeRoot: t.string(),
|
|
7
|
+
postCheckout: t.nullable(t.string()),
|
|
8
|
+
postRewrite: t.nullable(t.string()),
|
|
9
|
+
preMerge: t.nullable(t.string()),
|
|
10
|
+
postMerge: t.nullable(t.string()),
|
|
11
|
+
leaseStalenessMs: t.number(),
|
|
12
|
+
mergeRetryCount: t.number(),
|
|
13
|
+
removedCapacity: t.number(),
|
|
14
|
+
logCapacity: t.number()
|
|
15
|
+
});
|
|
16
|
+
function defineConfig(config) {
|
|
17
|
+
return config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { CONFIG_FACTORY, defineConfig };
|
package/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
+
import {
|
|
4
|
+
CONFIG_FACTORY
|
|
5
|
+
} from "./index-84xjmns8.js";
|
|
3
6
|
|
|
4
7
|
// index.ts
|
|
5
8
|
import { BunHttpServer } from "webappwiz/http/bun";
|
|
@@ -8,6 +11,7 @@ import { NodeFs as NodeFs7 } from "webappwiz/system";
|
|
|
8
11
|
// arbor.ts
|
|
9
12
|
import { cli } from "webappwiz/cmd";
|
|
10
13
|
import { t } from "webappwiz/t";
|
|
14
|
+
import { Duration as Duration3 } from "webappwiz/time";
|
|
11
15
|
|
|
12
16
|
// add.ts
|
|
13
17
|
import { color } from "webappwiz/log";
|
|
@@ -27,7 +31,8 @@ var EXIT = {
|
|
|
27
31
|
exists: 10,
|
|
28
32
|
orphaned: 11,
|
|
29
33
|
merge_failed: 12,
|
|
30
|
-
already_removed: 13
|
|
34
|
+
already_removed: 13,
|
|
35
|
+
timeout: 14
|
|
31
36
|
};
|
|
32
37
|
|
|
33
38
|
class Exit extends Error {
|
|
@@ -60,14 +65,14 @@ function exits() {
|
|
|
60
65
|
// add.ts
|
|
61
66
|
var NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
62
67
|
var PLAN_FILE = "ARBOR.md";
|
|
63
|
-
var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
|
|
68
|
+
var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Files").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
|
|
64
69
|
async function add({
|
|
65
70
|
service,
|
|
66
71
|
shell,
|
|
67
72
|
config,
|
|
68
73
|
log,
|
|
69
74
|
fs
|
|
70
|
-
}, task, { base = config.trunk } = {}) {
|
|
75
|
+
}, task, { base = config.get("trunk") } = {}) {
|
|
71
76
|
if (!NAME.test(task)) {
|
|
72
77
|
fail("usage", `invalid task name '${task}': use lowercase letters, digits and dashes`, { task });
|
|
73
78
|
}
|
|
@@ -83,7 +88,7 @@ async function add({
|
|
|
83
88
|
}
|
|
84
89
|
const added = await service.add(task, { base });
|
|
85
90
|
if (added.code !== 0) {
|
|
86
|
-
const missingTrunk = base === config.trunk && !await service.git.branchExists(base);
|
|
91
|
+
const missingTrunk = base === config.get("trunk") && !await service.git.branchExists(base);
|
|
87
92
|
fail("usage", missingTrunk ? `trunk '${base}' is not a branch in this repo: set \`trunk\` in arbor.config.ts` : `git worktree add failed: ${added.stderr}`, { task });
|
|
88
93
|
}
|
|
89
94
|
const worktree = await (await service.find(task)).take({ base });
|
|
@@ -97,13 +102,14 @@ ${PLAN_FILE}
|
|
|
97
102
|
`.trimStart());
|
|
98
103
|
}
|
|
99
104
|
await fs.write(`${worktree.path}/${PLAN_FILE}`, PLAN(task));
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
const postCheckout = config.get("postCheckout");
|
|
106
|
+
if (postCheckout) {
|
|
107
|
+
const { exitCode } = await shell.stream(postCheckout, {
|
|
102
108
|
cwd: worktree.path,
|
|
103
109
|
env: {
|
|
104
110
|
ARBOR_TASK: task,
|
|
105
111
|
ARBOR_WORKTREE: worktree.path,
|
|
106
|
-
ARBOR_TRUNK: config.trunk
|
|
112
|
+
ARBOR_TRUNK: config.get("trunk")
|
|
107
113
|
}
|
|
108
114
|
});
|
|
109
115
|
if (exitCode !== 0) {
|
|
@@ -159,6 +165,10 @@ ${changes.map((change) => ` ${change}`).join(`
|
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
// dev.ts
|
|
168
|
+
import {
|
|
169
|
+
MAX_PORT,
|
|
170
|
+
OpenPortProvider
|
|
171
|
+
} from "webappwiz/system";
|
|
162
172
|
import { Duration } from "webappwiz/time";
|
|
163
173
|
|
|
164
174
|
// snapshot.ts
|
|
@@ -219,7 +229,7 @@ import { NodeFs } from "webappwiz/system";
|
|
|
219
229
|
// plan.ts
|
|
220
230
|
import { Markdown } from "webappwiz/md";
|
|
221
231
|
var SECTIONS = ["Goal", "Files", "Done", "Next", "Notes", "Blocked"];
|
|
222
|
-
var REQUIRED = ["Goal", "Next"];
|
|
232
|
+
var REQUIRED = ["Goal", "Files", "Next"];
|
|
223
233
|
var UNCHECKED = /^[ \t]*- \[ \]/m;
|
|
224
234
|
function checkPlan(text, { task, escalated = false }) {
|
|
225
235
|
const md = Markdown.parse(text);
|
|
@@ -236,6 +246,9 @@ function checkPlan(text, { task, escalated = false }) {
|
|
|
236
246
|
if (section("Goal") === "") {
|
|
237
247
|
problems.push("## Goal is empty: say in a line or two what done means");
|
|
238
248
|
}
|
|
249
|
+
if (section("Files") === "") {
|
|
250
|
+
problems.push("## Files is empty: list the file paths you plan to touch");
|
|
251
|
+
}
|
|
239
252
|
const next = section("Next");
|
|
240
253
|
if (next !== null && !UNCHECKED.test(next)) {
|
|
241
254
|
problems.push("## Next has no `- [ ]` item: say what the next step is");
|
|
@@ -358,6 +371,15 @@ function fingerprint({ tasks, entries }) {
|
|
|
358
371
|
|
|
359
372
|
// dev.ts
|
|
360
373
|
var DEFAULT_PORT = 4269;
|
|
374
|
+
var PORT_SPAN = 20;
|
|
375
|
+
function devPorts(from) {
|
|
376
|
+
if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
|
|
377
|
+
fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
|
|
378
|
+
port: from
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return OpenPortProvider.span({ from, span: PORT_SPAN });
|
|
382
|
+
}
|
|
361
383
|
var POLL_MS = 2000;
|
|
362
384
|
async function dev({
|
|
363
385
|
service,
|
|
@@ -366,7 +388,7 @@ async function dev({
|
|
|
366
388
|
log: log2,
|
|
367
389
|
http,
|
|
368
390
|
assets
|
|
369
|
-
}, {
|
|
391
|
+
}, { ports = devPorts(DEFAULT_PORT) } = {}) {
|
|
370
392
|
const open = new Set;
|
|
371
393
|
const encoder = new TextEncoder;
|
|
372
394
|
let last = fingerprint(await snapshot(service, journal, { fs }));
|
|
@@ -428,7 +450,7 @@ async function dev({
|
|
|
428
450
|
default:
|
|
429
451
|
return new Response("not found", { status: 404 });
|
|
430
452
|
}
|
|
431
|
-
}, { port, idleTimeout: Duration.zero() });
|
|
453
|
+
}, { port: await ports.get(), idleTimeout: Duration.zero() });
|
|
432
454
|
log2.info(`arbor dev on http://localhost:${listening.port}`);
|
|
433
455
|
return {
|
|
434
456
|
port: listening.port,
|
|
@@ -560,8 +582,8 @@ async function merge({
|
|
|
560
582
|
if (dirty.length > 0) {
|
|
561
583
|
fail("dirty", `'${task}' has uncommitted changes: commit them before merging`, { task, paths: dirty });
|
|
562
584
|
}
|
|
563
|
-
if (worktree.mergeAttempts >= config.mergeRetryCount) {
|
|
564
|
-
fail("budget_exhausted", `'${task}' has used its ${config.mergeRetryCount} merge attempts: run \`arbor escalate <reason>\`, and a human can grant another ${config.mergeRetryCount} with \`arbor retry ${task}\`; or \`arbor rm ${task}\` and start over against current ${base}`, { task, mergeAttempts: worktree.mergeAttempts });
|
|
585
|
+
if (worktree.mergeAttempts >= config.get("mergeRetryCount")) {
|
|
586
|
+
fail("budget_exhausted", `'${task}' has used its ${config.get("mergeRetryCount")} merge attempts: run \`arbor escalate <reason>\`, and a human can grant another ${config.get("mergeRetryCount")} with \`arbor retry ${task}\`; or \`arbor rm ${task}\` and start over against current ${base}`, { task, mergeAttempts: worktree.mergeAttempts });
|
|
565
587
|
}
|
|
566
588
|
if (worktree.leaseHeldByOther) {
|
|
567
589
|
fail("lease_held", `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: another agent is driving this tree`, { task, lease: worktree.lease });
|
|
@@ -586,7 +608,7 @@ async function merge({
|
|
|
586
608
|
].join(`
|
|
587
609
|
`), { task, paths });
|
|
588
610
|
}
|
|
589
|
-
const gate = [config.postRewrite, config.preMerge].filter(Boolean).join(" && ");
|
|
611
|
+
const gate = [config.get("postRewrite"), config.get("preMerge")].filter(Boolean).join(" && ");
|
|
590
612
|
const gated = gate ? await shell.run(gate, {
|
|
591
613
|
cwd: worktree.path,
|
|
592
614
|
env: {
|
|
@@ -627,8 +649,9 @@ ${gated.stderr}`)
|
|
|
627
649
|
fail("usage", `landed '${task}' on ${base} (${head}) but could not discard its worktree: ${discarded.stderr || discarded.stdout}
|
|
628
650
|
Run \`arbor rm ${task}\` to clean up.`, { task });
|
|
629
651
|
}
|
|
630
|
-
|
|
631
|
-
|
|
652
|
+
const postMerge = config.get("postMerge");
|
|
653
|
+
if (postMerge) {
|
|
654
|
+
const ran = await shell.run(postMerge, {
|
|
632
655
|
cwd: git.root,
|
|
633
656
|
env: { ARBOR_TASK: task }
|
|
634
657
|
});
|
|
@@ -852,7 +875,7 @@ import { NodeFs as NodeFs5 } from "webappwiz/system";
|
|
|
852
875
|
async function loadConfig(root, opts = {}) {
|
|
853
876
|
const found = await file(opts.fs ?? new NodeFs5, root);
|
|
854
877
|
const trunk = found.trunk ?? await (opts.git ?? new Git(root)).defaultBranch() ?? "main";
|
|
855
|
-
return { ...defaults(root, trunk), ...found };
|
|
878
|
+
return CONFIG_FACTORY.create({ ...defaults(root, trunk), ...found });
|
|
856
879
|
}
|
|
857
880
|
function defaults(root, trunk) {
|
|
858
881
|
return {
|
|
@@ -933,7 +956,7 @@ class Worktree {
|
|
|
933
956
|
if (!lease) {
|
|
934
957
|
return false;
|
|
935
958
|
}
|
|
936
|
-
if (Date.now() - Date.parse(lease.heartbeatAt) >= config.leaseStalenessMs) {
|
|
959
|
+
if (Date.now() - Date.parse(lease.heartbeatAt) >= config.get("leaseStalenessMs")) {
|
|
937
960
|
return false;
|
|
938
961
|
}
|
|
939
962
|
return lease.hostname === ps.hostname ? ps.alive(lease.pid) : true;
|
|
@@ -1054,10 +1077,10 @@ class WorktreeService {
|
|
|
1054
1077
|
await this.fs.mkdir(this.removedDir);
|
|
1055
1078
|
}
|
|
1056
1079
|
get trunk() {
|
|
1057
|
-
return this.config.trunk;
|
|
1080
|
+
return this.config.get("trunk");
|
|
1058
1081
|
}
|
|
1059
1082
|
pathFor(task) {
|
|
1060
|
-
return resolve2(this.config.worktreeRoot, task);
|
|
1083
|
+
return resolve2(this.config.get("worktreeRoot"), task);
|
|
1061
1084
|
}
|
|
1062
1085
|
branchFor(task) {
|
|
1063
1086
|
return `${BRANCH_PREFIX}${task}`;
|
|
@@ -1099,8 +1122,8 @@ class WorktreeService {
|
|
|
1099
1122
|
}
|
|
1100
1123
|
return found;
|
|
1101
1124
|
}
|
|
1102
|
-
async add(task, { base = this.config.trunk } = {}) {
|
|
1103
|
-
await this.fs.mkdir(this.config.worktreeRoot);
|
|
1125
|
+
async add(task, { base = this.config.get("trunk") } = {}) {
|
|
1126
|
+
await this.fs.mkdir(this.config.get("worktreeRoot"));
|
|
1104
1127
|
return this.git.addWorktree(this.branchFor(task), this.pathFor(task), base);
|
|
1105
1128
|
}
|
|
1106
1129
|
async discard(worktree) {
|
|
@@ -1137,7 +1160,7 @@ class WorktreeService {
|
|
|
1137
1160
|
return raw?.trim() ?? null;
|
|
1138
1161
|
}
|
|
1139
1162
|
async rememberRemoved(task, at = new Date().toISOString()) {
|
|
1140
|
-
const
|
|
1163
|
+
const removedCapacity = this.config.get("removedCapacity");
|
|
1141
1164
|
await this.fs.write(this.removedPath(task), `${at}
|
|
1142
1165
|
`);
|
|
1143
1166
|
const names = await this.fs.readdir(this.removedDir).catch(() => []);
|
|
@@ -1186,10 +1209,10 @@ function repository(at) {
|
|
|
1186
1209
|
fs,
|
|
1187
1210
|
ps,
|
|
1188
1211
|
log: log2,
|
|
1189
|
-
stalenessMs: config.leaseStalenessMs
|
|
1212
|
+
stalenessMs: config.get("leaseStalenessMs")
|
|
1190
1213
|
}),
|
|
1191
1214
|
shell: new Shell({ ps }),
|
|
1192
|
-
journal: new Journal(`${arborDir}/log.jsonl`, config.logCapacity, {
|
|
1215
|
+
journal: new Journal(`${arborDir}/log.jsonl`, config.get("logCapacity"), {
|
|
1193
1216
|
fs
|
|
1194
1217
|
})
|
|
1195
1218
|
});
|
|
@@ -1213,7 +1236,7 @@ async function retry({
|
|
|
1213
1236
|
const worktree = await found.save({ status: "working", mergeAttempts: 0 });
|
|
1214
1237
|
log2.info([
|
|
1215
1238
|
`${color9.green("retry")} ${worktree.task}`,
|
|
1216
|
-
` attempts: 0 of ${config.mergeRetryCount}`,
|
|
1239
|
+
` attempts: 0 of ${config.get("mergeRetryCount")}`,
|
|
1217
1240
|
` status: working`,
|
|
1218
1241
|
"",
|
|
1219
1242
|
`Run \`arbor claim ${worktree.task}\` to pick it up.`
|
|
@@ -1255,6 +1278,43 @@ async function rm({ service, log: log2 }, task, { force = false } = {}) {
|
|
|
1255
1278
|
`));
|
|
1256
1279
|
}
|
|
1257
1280
|
|
|
1281
|
+
// wait.ts
|
|
1282
|
+
import { color as color11 } from "webappwiz/log";
|
|
1283
|
+
import { Duration as Duration2, sleep } from "webappwiz/time";
|
|
1284
|
+
var DEFAULT_TIMEOUT = Duration2.mins(5);
|
|
1285
|
+
var POLL = Duration2.secs(2);
|
|
1286
|
+
var RUNNING = ["working", "merging"];
|
|
1287
|
+
async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, poll = POLL } = {}) {
|
|
1288
|
+
const deadline = Date.now() + timeout.ms;
|
|
1289
|
+
for (;; ) {
|
|
1290
|
+
const worktree = await service.find(task);
|
|
1291
|
+
if (worktree.status === "absent") {
|
|
1292
|
+
fail("not_found", `no task '${task}': run \`arbor ls\` to see what there is`, { task });
|
|
1293
|
+
}
|
|
1294
|
+
if (!RUNNING.includes(worktree.status)) {
|
|
1295
|
+
log2.info(report2(worktree));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const left = deadline - Date.now();
|
|
1299
|
+
if (left <= 0) {
|
|
1300
|
+
fail("timeout", `'${task}' is still ${worktree.status} after ${timeout.secs}s: wait again, work alongside it and accept the rebase, or ask the human`, { task, status: worktree.status });
|
|
1301
|
+
}
|
|
1302
|
+
await sleep(Duration2.min(poll, Duration2.ms(left)));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
function report2(worktree) {
|
|
1306
|
+
const lines2 = [`${color11.bold(worktree.task)} ${worktree.status}`];
|
|
1307
|
+
const escalation = worktree.state?.escalations?.at(-1)?.reason;
|
|
1308
|
+
if (escalation) {
|
|
1309
|
+
lines2.push(` ${color11.yellow(`escalated: ${escalation}`)}`);
|
|
1310
|
+
}
|
|
1311
|
+
if (worktree.status === "removed") {
|
|
1312
|
+
lines2.push("", `Nothing left of it here: \`arbor log\` says whether it landed.`);
|
|
1313
|
+
}
|
|
1314
|
+
return lines2.join(`
|
|
1315
|
+
`);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1258
1318
|
// arbor.ts
|
|
1259
1319
|
var here = async ({
|
|
1260
1320
|
service,
|
|
@@ -1274,14 +1334,20 @@ arbor.command("rm").description("discard a task: worktree, branch and state file
|
|
|
1274
1334
|
}).action((opts, ctx) => ctx.journal.record("rm", opts.task, () => rm(ctx, opts.task, { force: opts.force })));
|
|
1275
1335
|
arbor.command("ls").description("list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age").option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => ls(ctx, { json: opts.json }));
|
|
1276
1336
|
arbor.command("show").description("read one task without touching it: everything `ls` lists for it, plus the ARBOR.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree").arg("task", t.string(), { description: "task name" }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
|
|
1337
|
+
arbor.command("wait").description("block until a task stops moving: escalated, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", t.string(), { description: "task name" }).option("timeout-secs", t.number(), {
|
|
1338
|
+
default: DEFAULT_TIMEOUT.secs,
|
|
1339
|
+
description: "how long to wait before giving up"
|
|
1340
|
+
}).action((opts, ctx) => wait(ctx, opts.task, {
|
|
1341
|
+
timeout: Duration3.secs(opts["timeout-secs"])
|
|
1342
|
+
}));
|
|
1277
1343
|
arbor.command("log").description("show what has been done here recently: one line per add, claim, merge, rm, escalate and retry, with how it ended; outlives the tasks themselves").option("count", t.number(), {
|
|
1278
1344
|
default: DEFAULT_COUNT,
|
|
1279
1345
|
description: "how many entries to show"
|
|
1280
1346
|
}).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
|
|
1281
1347
|
arbor.command("dev").description("serve `ls`, `show` and `log` as a web page; read-only").option("port", t.number(), {
|
|
1282
1348
|
default: DEFAULT_PORT,
|
|
1283
|
-
description: "port to listen on"
|
|
1284
|
-
}).action((opts, ctx) => dev(ctx, {
|
|
1349
|
+
description: "port to listen on, or the next open one above it"
|
|
1350
|
+
}).action((opts, ctx) => dev(ctx, { ports: devPorts(opts.port) }));
|
|
1285
1351
|
arbor.command("path").description("print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease").arg("task", t.string(), {
|
|
1286
1352
|
default: "",
|
|
1287
1353
|
description: "task name; omit for the main tree"
|
package/load-config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Fs } from "webappwiz/system";
|
|
2
|
-
import type
|
|
2
|
+
import { type Config } from "./config.js";
|
|
3
3
|
import { Git } from "./git.js";
|
|
4
4
|
export interface LoadConfigOptions {
|
|
5
5
|
/** What `arbor.config.ts` is looked for through; the real one by default. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webappwiz/arbor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Runs several AI coding agents on one repository at once, each in its own git worktree",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jared Johnson",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"webappwiz": "^0.0.
|
|
20
|
+
"webappwiz": "^0.0.9"
|
|
21
21
|
},
|
|
22
22
|
"main": "./index.js",
|
|
23
23
|
"types": "./index.d.ts",
|
package/wait.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Logger } from "webappwiz/log";
|
|
2
|
+
import { Duration } from "webappwiz/time";
|
|
3
|
+
import type { WorktreeService } from "./worktree-service.js";
|
|
4
|
+
/** How long `wait` gives a task before handing the wait back to its caller. */
|
|
5
|
+
export declare const DEFAULT_TIMEOUT: Duration;
|
|
6
|
+
export interface WaitOptions {
|
|
7
|
+
/** How long to wait before giving up. */
|
|
8
|
+
timeout?: Duration;
|
|
9
|
+
/** How long between reads of the record; the default suits a human's patience. */
|
|
10
|
+
poll?: Duration;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Blocks until a task stops moving: merged or removed (both leave the name
|
|
14
|
+
* `removed`), escalated to a human, or broken. Waiting is for the agent whose
|
|
15
|
+
* own work overlaps this one's and would rather rebase onto the result than
|
|
16
|
+
* against it, which is why the timeout is short enough to come back and think
|
|
17
|
+
* again rather than block a session for an afternoon.
|
|
18
|
+
*/
|
|
19
|
+
export declare function wait({ service, log }: {
|
|
20
|
+
service: WorktreeService;
|
|
21
|
+
log: Logger;
|
|
22
|
+
}, task: string, { timeout, poll }?: WaitOptions): Promise<void>;
|