@webappwiz/arbor 0.0.14 → 0.0.16
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 +12 -12
- package/arbor.d.ts +0 -3
- package/config.d.ts +1 -1
- package/dev.d.ts +1 -3
- package/index.js +81 -92
- package/journal.d.ts +1 -1
- package/package.json +3 -2
- package/{rm.d.ts → remove.d.ts} +3 -3
- package/retry.d.ts +1 -1
- package/worktree-service.d.ts +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Two rules make that safe:
|
|
|
23
23
|
|
|
24
24
|
Conflicts between agents are expected, not a process failure. Discarding a task
|
|
25
25
|
and redoing it against current trunk is cheap and often better than a hard
|
|
26
|
-
rebase, and that is what `
|
|
26
|
+
rebase, and that is what `remove` is for.
|
|
27
27
|
|
|
28
28
|
## Commands
|
|
29
29
|
|
|
@@ -78,7 +78,7 @@ linear.
|
|
|
78
78
|
a `--base task/<other>` task. Git allows one worktree per branch, and that
|
|
79
79
|
tree is the only place the branch can move: advancing the ref behind its
|
|
80
80
|
back would leave its index and files on the old commit.
|
|
81
|
-
5. Discards the task (worktree, branch and record) exactly as `
|
|
81
|
+
5. Discards the task (worktree, branch and record) exactly as `remove`
|
|
82
82
|
would. The work is on trunk, so the tree has nothing left to hold, and
|
|
83
83
|
`arbor list` stays a list of live work rather than a graveyard of landed
|
|
84
84
|
tasks. The agent's own directory goes with it, so the success message
|
|
@@ -91,7 +91,7 @@ fails the branch is reset to where it was and trunk is never touched.
|
|
|
91
91
|
There is deliberately no flag to skip the gate: a repo that wants none
|
|
92
92
|
configures none.
|
|
93
93
|
|
|
94
|
-
### `arbor
|
|
94
|
+
### `arbor remove <task>`
|
|
95
95
|
|
|
96
96
|
Discards a task: `git worktree remove` plus the branch and the record.
|
|
97
97
|
|
|
@@ -100,7 +100,7 @@ discards its own tree. Use it freely. Warns about commits that never landed,
|
|
|
100
100
|
but never blocks: throwing work away is the cheap escape hatch, not a last
|
|
101
101
|
resort.
|
|
102
102
|
|
|
103
|
-
Removal leaves a tombstone in `.git/arbor/removed/` so a second `
|
|
103
|
+
Removal leaves a tombstone in `.git/arbor/removed/` so a second `remove` can say
|
|
104
104
|
`already_removed` rather than `not_found`. The ledger keeps the 50 most recent
|
|
105
105
|
and drops the oldest as new ones arrive, so a long-forgotten task reports
|
|
106
106
|
`not_found` again.
|
|
@@ -160,7 +160,7 @@ agent off it.
|
|
|
160
160
|
|
|
161
161
|
### `arbor log [--count 20] [--json]`
|
|
162
162
|
|
|
163
|
-
The last N things done here (`add`, `claim`, `merge`, `
|
|
163
|
+
The last N things done here (`add`, `claim`, `merge`, `remove`, `escalate`,
|
|
164
164
|
`retry`),
|
|
165
165
|
oldest first, each with the task and how it ended (`ok`, or the refusal reason).
|
|
166
166
|
|
|
@@ -172,7 +172,7 @@ WHEN ACTION TASK RESULT
|
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
`list` is what still exists; this is what happened. Entries outlive their tasks:
|
|
175
|
-
a successful `merge` and a `
|
|
175
|
+
a successful `merge` and a `remove` both take the record with them, so this is
|
|
176
176
|
the only thing that remembers a task landed at all. The last 200 are kept
|
|
177
177
|
(`logCapacity`) in `.git/arbor/log.jsonl`.
|
|
178
178
|
|
|
@@ -218,7 +218,7 @@ correct merge, only a decision.
|
|
|
218
218
|
### `arbor retry <task>`
|
|
219
219
|
|
|
220
220
|
Grants an escalated task another `mergeRetryCount` merge attempts and puts it
|
|
221
|
-
back to `working`. The way out of `budget_exhausted` that is not `
|
|
221
|
+
back to `working`. The way out of `budget_exhausted` that is not `remove` and redo,
|
|
222
222
|
for the case where the task was one fix away rather than genuinely lost.
|
|
223
223
|
|
|
224
224
|
Only from `escalated`, and that is the whole design. The budget exists to make
|
|
@@ -239,13 +239,13 @@ The agent's control flow runs on these.
|
|
|
239
239
|
| 2 | `conflict` | Rebase conflicted. **Rebase is still in progress.** Resolve, `git add`, `git rebase --continue`, merge again. |
|
|
240
240
|
| 3 | `tests_failed` | The gate (`postRewrite`, `preMerge`) failed after the rebase. Branch rolled back, trunk untouched. Fix and merge again. |
|
|
241
241
|
| 4 | `lease_lost` | Another agent took the tree mid-merge. **Stop. Do not retry.** |
|
|
242
|
-
| 5 | `budget_exhausted` | Out of merge attempts. `arbor escalate`, and a human can grant another budget with `arbor retry`; or `arbor
|
|
242
|
+
| 5 | `budget_exhausted` | Out of merge attempts. `arbor escalate`, and a human can grant another budget with `arbor retry`; or `arbor remove` and redo against current trunk. |
|
|
243
243
|
| 6 | `lease_held` | Another agent is driving this tree. |
|
|
244
244
|
| 7 | `dirty` | Uncommitted changes. Commit before merging. |
|
|
245
245
|
| 8 | `not_found` | No such task, or not run from a task worktree. |
|
|
246
246
|
| 9 | `hook_failed` | `postCheckout` failed (worktree still exists; fix and re-run the hook), or `postMerge` failed (the branch already landed; nothing rolled back). |
|
|
247
|
-
| 10 | `exists` | Task already exists. `arbor claim` it, or `arbor
|
|
248
|
-
| 11 | `orphaned` | Record with no worktree. `arbor
|
|
247
|
+
| 10 | `exists` | Task already exists. `arbor claim` it, or `arbor remove` first. |
|
|
248
|
+
| 11 | `orphaned` | Record with no worktree. `arbor remove` it. |
|
|
249
249
|
| 12 | `merge_failed` | The base could not be fast-forwarded (usually uncommitted changes in the worktree holding it). |
|
|
250
250
|
| 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
|
|
251
251
|
| 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
|
|
@@ -270,7 +270,7 @@ export default defineConfig({
|
|
|
270
270
|
postMerge: "bun install", // in the main tree, after the branch lands
|
|
271
271
|
leaseStalenessMs: 90_000,
|
|
272
272
|
mergeRetryCount: 2,
|
|
273
|
-
removedCapacity: 50, // removed names kept, so
|
|
273
|
+
removedCapacity: 50, // removed names kept, so remove can say "already removed"
|
|
274
274
|
logCapacity: 200, // entries `arbor log` keeps before the oldest fall off
|
|
275
275
|
});
|
|
276
276
|
```
|
|
@@ -330,5 +330,5 @@ other. Git still leaves the file staged as `UU`, so the agent must confirm with
|
|
|
330
330
|
exists because of a real livelock: an agent rebases onto trunk, another agent
|
|
331
331
|
lands during its test run, and it is stale again before it finishes. Under load
|
|
332
332
|
an unlucky agent can chase a moving trunk indefinitely. When the budget is gone,
|
|
333
|
-
escalate or `arbor
|
|
333
|
+
escalate or `arbor remove`: redoing the task against current trunk usually beats
|
|
334
334
|
retrofitting a rebase.
|
package/arbor.d.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { type Deps } from "webappwiz/cmd";
|
|
2
|
-
import type { HttpServer } from "webappwiz/http";
|
|
3
2
|
import type { Fs } from "webappwiz/system";
|
|
4
3
|
import type { Assets } from "./dev/assets.js";
|
|
5
4
|
import { type Repository } from "./repository.js";
|
|
6
5
|
/** Everything `arbor` is run with, before the repository middleware adds to it. */
|
|
7
6
|
export interface ArborDeps extends Deps {
|
|
8
7
|
fs: Fs;
|
|
9
|
-
/** HTTP server `dev` listens on to serve its preview. */
|
|
10
|
-
http: HttpServer;
|
|
11
8
|
/** The built page `dev` serves as its preview. */
|
|
12
9
|
assets: Assets;
|
|
13
10
|
}
|
package/config.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface Config {
|
|
|
33
33
|
/** Failed `merge` attempts a task gets before it must escalate or be removed. */
|
|
34
34
|
mergeRetryCount: number;
|
|
35
35
|
/**
|
|
36
|
-
* How many removed task names to keep, so `
|
|
36
|
+
* How many removed task names to keep, so `remove` can say "already removed"
|
|
37
37
|
* rather than "never existed". A flat cap with no age policy: losing the
|
|
38
38
|
* oldest costs a nicer message and nothing else.
|
|
39
39
|
*/
|
package/dev.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type AsyncResource } from "webappwiz/disposable";
|
|
2
|
-
import type { HttpServer } from "webappwiz/http";
|
|
3
2
|
import type { Logger } from "webappwiz/log";
|
|
4
3
|
import { type Fs, type PortProvider } from "webappwiz/system";
|
|
5
4
|
import type { Assets } from "./dev/assets.js";
|
|
@@ -23,12 +22,11 @@ export interface DevServer extends AsyncResource {
|
|
|
23
22
|
* repo changes. Read-only on purpose: driving arbor is what the CLI is for, and
|
|
24
23
|
* a button that took a lease would fight the agent holding it.
|
|
25
24
|
*/
|
|
26
|
-
export declare function dev({ service, fs, journal, log,
|
|
25
|
+
export declare function dev({ service, fs, journal, log, assets, }: {
|
|
27
26
|
service: WorktreeService;
|
|
28
27
|
fs: Fs;
|
|
29
28
|
journal: Journal;
|
|
30
29
|
log: Logger;
|
|
31
|
-
http: HttpServer;
|
|
32
30
|
assets: Assets;
|
|
33
31
|
}, { ports }?: DevOptions): Promise<DevServer>;
|
|
34
32
|
/**
|
package/index.js
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// index.ts
|
|
5
|
-
import { BunHttpServer } from "webappwiz/http/bun";
|
|
6
5
|
import { NodeFs as NodeFs7 } from "webappwiz/system";
|
|
7
6
|
|
|
8
7
|
// arbor.ts
|
|
9
8
|
import { cli } from "webappwiz/cmd";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { Duration as Duration2 } from "webappwiz/time";
|
|
10
|
+
import { z } from "zod";
|
|
12
11
|
|
|
13
12
|
// add.ts
|
|
14
13
|
import { color } from "webappwiz/log";
|
|
@@ -77,7 +76,7 @@ async function add({
|
|
|
77
76
|
}
|
|
78
77
|
const found = await service.find(task);
|
|
79
78
|
if (found.status === "stray") {
|
|
80
|
-
fail("exists", `branch ${found.branch} exists without a worktree: run \`arbor
|
|
79
|
+
fail("exists", `branch ${found.branch} exists without a worktree: run \`arbor remove ${task}\` first`, { task, branch: found.branch });
|
|
81
80
|
}
|
|
82
81
|
if (!found.gone) {
|
|
83
82
|
fail("exists", `task '${task}' already exists: run \`arbor claim ${task}\``, { task, worktree: found.path });
|
|
@@ -129,10 +128,10 @@ async function claim({ service, log }, task) {
|
|
|
129
128
|
});
|
|
130
129
|
}
|
|
131
130
|
if (found.status === "orphaned") {
|
|
132
|
-
fail("orphaned", `state file for '${task}' has no worktree at ${found.path}: run \`arbor
|
|
131
|
+
fail("orphaned", `state file for '${task}' has no worktree at ${found.path}: run \`arbor remove ${task}\``, { task, worktree: found.path });
|
|
133
132
|
}
|
|
134
133
|
if (found.status === "stray") {
|
|
135
|
-
fail("orphaned", `branch ${found.branch} exists but has no worktree: run \`arbor
|
|
134
|
+
fail("orphaned", `branch ${found.branch} exists but has no worktree: run \`arbor remove ${task}\` and start over`, { task, branch: found.branch });
|
|
136
135
|
}
|
|
137
136
|
if (found.leaseHeldByOther) {
|
|
138
137
|
fail("lease_held", `'${task}' is held by pid ${found.lease?.pid} on ${found.lease?.hostname} (heartbeat ${found.lease?.heartbeatAt}): another agent is driving this tree`, { task, lease: found.lease });
|
|
@@ -167,7 +166,6 @@ import {
|
|
|
167
166
|
MAX_PORT,
|
|
168
167
|
OpenPortProvider
|
|
169
168
|
} from "webappwiz/system";
|
|
170
|
-
import { Duration } from "webappwiz/time";
|
|
171
169
|
|
|
172
170
|
// snapshot.ts
|
|
173
171
|
import { NodeFs as NodeFs2 } from "webappwiz/system";
|
|
@@ -382,7 +380,6 @@ async function dev({
|
|
|
382
380
|
fs,
|
|
383
381
|
journal,
|
|
384
382
|
log: log2,
|
|
385
|
-
http,
|
|
386
383
|
assets
|
|
387
384
|
}, { ports = devPorts(DEFAULT_PORT) } = {}) {
|
|
388
385
|
const open = new Set;
|
|
@@ -425,34 +422,27 @@ async function dev({
|
|
|
425
422
|
}
|
|
426
423
|
});
|
|
427
424
|
};
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
case "/events":
|
|
445
|
-
return events();
|
|
446
|
-
default:
|
|
447
|
-
return new Response("not found", { status: 404 });
|
|
448
|
-
}
|
|
449
|
-
}, { port: await ports.get(), idleTimeout: Duration.zero() });
|
|
450
|
-
log2.info(`arbor dev on http://localhost:${listening.port}`);
|
|
425
|
+
const asset = (body, type) => new Response(body, { headers: { "content-type": type } });
|
|
426
|
+
const requested = await ports.get();
|
|
427
|
+
const server = Bun.serve({
|
|
428
|
+
port: requested,
|
|
429
|
+
idleTimeout: 0,
|
|
430
|
+
routes: {
|
|
431
|
+
"/": asset(assets.shell, "text/html; charset=utf-8"),
|
|
432
|
+
"/main.js": asset(assets.script, "text/javascript; charset=utf-8"),
|
|
433
|
+
"/styles.css": asset(assets.styles, "text/css; charset=utf-8"),
|
|
434
|
+
"/api/snapshot": async () => Response.json(await snapshot(service, journal, { fs })),
|
|
435
|
+
"/events": events
|
|
436
|
+
},
|
|
437
|
+
fetch: () => new Response("not found", { status: 404 })
|
|
438
|
+
});
|
|
439
|
+
const port = server.port ?? requested;
|
|
440
|
+
log2.info(`arbor dev on http://localhost:${port}`);
|
|
451
441
|
const disposer = new AsyncDisposer;
|
|
452
|
-
disposer.defer(() =>
|
|
442
|
+
disposer.defer(() => server.stop(true));
|
|
453
443
|
disposer.defer(async () => clearInterval(poll));
|
|
454
444
|
return {
|
|
455
|
-
port
|
|
445
|
+
port,
|
|
456
446
|
disposeAsync: disposer.disposeAsync
|
|
457
447
|
};
|
|
458
448
|
}
|
|
@@ -555,7 +545,7 @@ function listing(rows) {
|
|
|
555
545
|
];
|
|
556
546
|
const orphaned = rows.filter((row3) => row3.status === "orphaned");
|
|
557
547
|
if (orphaned.length > 0) {
|
|
558
|
-
out.push("", color7.yellow(`${orphaned.length} orphaned record(s): run \`arbor
|
|
548
|
+
out.push("", color7.yellow(`${orphaned.length} orphaned record(s): run \`arbor remove ${orphaned[0]?.task}\``));
|
|
559
549
|
}
|
|
560
550
|
return out.join(`
|
|
561
551
|
`);
|
|
@@ -587,7 +577,7 @@ async function merge({
|
|
|
587
577
|
fail("dirty", `'${task}' has uncommitted changes: commit them before merging`, { task, paths: dirty });
|
|
588
578
|
}
|
|
589
579
|
if (worktree.mergeAttempts >= config.mergeRetryCount) {
|
|
590
|
-
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
|
|
580
|
+
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 remove ${task}\` and start over against current ${base}`, { task, mergeAttempts: worktree.mergeAttempts });
|
|
591
581
|
}
|
|
592
582
|
if (worktree.leaseHeldByOther) {
|
|
593
583
|
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 });
|
|
@@ -608,7 +598,7 @@ async function merge({
|
|
|
608
598
|
"The rebase is still in progress. Resolve the conflicts, `git add` them,",
|
|
609
599
|
"`git rebase --continue`, then run `arbor merge` again.",
|
|
610
600
|
"If both sides restructured the same logic, prefer `arbor escalate <reason>`",
|
|
611
|
-
`or \`arbor
|
|
601
|
+
`or \`arbor remove ${task}\` and redo the task against current ${base}.`
|
|
612
602
|
].join(`
|
|
613
603
|
`), { task, paths });
|
|
614
604
|
}
|
|
@@ -661,7 +651,7 @@ ${gated.stderr}`)
|
|
|
661
651
|
await lock.release();
|
|
662
652
|
if (discarded.code !== 0) {
|
|
663
653
|
fail("usage", `landed '${task}' on ${base} (${head}) but could not discard its worktree: ${discarded.stderr || discarded.stdout}
|
|
664
|
-
Run \`arbor
|
|
654
|
+
Run \`arbor remove ${task}\` to clean up.`, { task });
|
|
665
655
|
}
|
|
666
656
|
if (config.postMerge) {
|
|
667
657
|
const ran = await shell.run(config.postMerge, landing, {
|
|
@@ -712,6 +702,40 @@ async function path({ service, log: log2 }, task) {
|
|
|
712
702
|
log2.info(worktree.path);
|
|
713
703
|
}
|
|
714
704
|
|
|
705
|
+
// remove.ts
|
|
706
|
+
import { color as color9 } from "webappwiz/log";
|
|
707
|
+
async function remove({ service, log: log2 }, task, { force = false } = {}) {
|
|
708
|
+
const worktree = await service.find(task);
|
|
709
|
+
if (worktree.gone) {
|
|
710
|
+
const removed = worktree.status === "removed";
|
|
711
|
+
fail(removed ? "already_removed" : "not_found", removed ? `'${task}' was already removed (${worktree.removedAt}), nothing left to remove` : `no worktree, branch or state file named '${task}', so it never existed here`, { task });
|
|
712
|
+
}
|
|
713
|
+
if (worktree.leaseHeldByOther) {
|
|
714
|
+
if (!force) {
|
|
715
|
+
fail("lease_held", `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: pass --force to discard it anyway`, { task, lease: worktree.lease });
|
|
716
|
+
}
|
|
717
|
+
log2.error(color9.yellow(`arbor: --force discarding a tree held by pid ${worktree.lease?.pid}`));
|
|
718
|
+
}
|
|
719
|
+
const unlanded = worktree.hasBranch ? await worktree.commitsAhead() : 0;
|
|
720
|
+
const discarded = await worktree.discard();
|
|
721
|
+
if (discarded.code !== 0) {
|
|
722
|
+
fail("usage", `discarding '${task}' failed: ${discarded.stderr}`, {
|
|
723
|
+
task
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
const lines = [
|
|
727
|
+
`${color9.green("removed")} ${task}`,
|
|
728
|
+
` worktree: ${worktree.exists ? worktree.path : "already gone"}`,
|
|
729
|
+
` branch: ${worktree.hasBranch ? worktree.branch : "already gone"}`,
|
|
730
|
+
` state: ${worktree.state ? "removed" : "already gone"}`
|
|
731
|
+
];
|
|
732
|
+
if (unlanded) {
|
|
733
|
+
lines.push(color9.yellow(` discarded ${unlanded} commit(s) that were never on ${worktree.base}`));
|
|
734
|
+
}
|
|
735
|
+
log2.info(lines.join(`
|
|
736
|
+
`));
|
|
737
|
+
}
|
|
738
|
+
|
|
715
739
|
// repository.ts
|
|
716
740
|
import { dirname } from "node:path";
|
|
717
741
|
import { FileLock } from "webappwiz/system";
|
|
@@ -1246,7 +1270,7 @@ function repository(at) {
|
|
|
1246
1270
|
}
|
|
1247
1271
|
|
|
1248
1272
|
// retry.ts
|
|
1249
|
-
import { color as
|
|
1273
|
+
import { color as color10 } from "webappwiz/log";
|
|
1250
1274
|
async function retry({
|
|
1251
1275
|
service,
|
|
1252
1276
|
config,
|
|
@@ -1261,7 +1285,7 @@ async function retry({
|
|
|
1261
1285
|
}
|
|
1262
1286
|
const worktree = await found.save({ status: "working", mergeAttempts: 0 });
|
|
1263
1287
|
log2.info([
|
|
1264
|
-
`${
|
|
1288
|
+
`${color10.green("retry")} ${worktree.task}`,
|
|
1265
1289
|
` attempts: 0 of ${config.mergeRetryCount}`,
|
|
1266
1290
|
` status: working`,
|
|
1267
1291
|
"",
|
|
@@ -1270,45 +1294,11 @@ async function retry({
|
|
|
1270
1294
|
`));
|
|
1271
1295
|
}
|
|
1272
1296
|
|
|
1273
|
-
// rm.ts
|
|
1274
|
-
import { color as color10 } from "webappwiz/log";
|
|
1275
|
-
async function rm({ service, log: log2 }, task, { force = false } = {}) {
|
|
1276
|
-
const worktree = await service.find(task);
|
|
1277
|
-
if (worktree.gone) {
|
|
1278
|
-
const removed = worktree.status === "removed";
|
|
1279
|
-
fail(removed ? "already_removed" : "not_found", removed ? `'${task}' was already removed (${worktree.removedAt}), nothing left to remove` : `no worktree, branch or state file named '${task}', so it never existed here`, { task });
|
|
1280
|
-
}
|
|
1281
|
-
if (worktree.leaseHeldByOther) {
|
|
1282
|
-
if (!force) {
|
|
1283
|
-
fail("lease_held", `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: pass --force to discard it anyway`, { task, lease: worktree.lease });
|
|
1284
|
-
}
|
|
1285
|
-
log2.error(color10.yellow(`arbor: --force discarding a tree held by pid ${worktree.lease?.pid}`));
|
|
1286
|
-
}
|
|
1287
|
-
const unlanded = worktree.hasBranch ? await worktree.commitsAhead() : 0;
|
|
1288
|
-
const discarded = await worktree.discard();
|
|
1289
|
-
if (discarded.code !== 0) {
|
|
1290
|
-
fail("usage", `discarding '${task}' failed: ${discarded.stderr}`, {
|
|
1291
|
-
task
|
|
1292
|
-
});
|
|
1293
|
-
}
|
|
1294
|
-
const lines2 = [
|
|
1295
|
-
`${color10.green("removed")} ${task}`,
|
|
1296
|
-
` worktree: ${worktree.exists ? worktree.path : "already gone"}`,
|
|
1297
|
-
` branch: ${worktree.hasBranch ? worktree.branch : "already gone"}`,
|
|
1298
|
-
` state: ${worktree.state ? "removed" : "already gone"}`
|
|
1299
|
-
];
|
|
1300
|
-
if (unlanded) {
|
|
1301
|
-
lines2.push(color10.yellow(` discarded ${unlanded} commit(s) that were never on ${worktree.base}`));
|
|
1302
|
-
}
|
|
1303
|
-
log2.info(lines2.join(`
|
|
1304
|
-
`));
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
1297
|
// wait.ts
|
|
1308
1298
|
import { color as color11 } from "webappwiz/log";
|
|
1309
|
-
import { Duration
|
|
1310
|
-
var DEFAULT_TIMEOUT =
|
|
1311
|
-
var POLL =
|
|
1299
|
+
import { Duration, sleep } from "webappwiz/time";
|
|
1300
|
+
var DEFAULT_TIMEOUT = Duration.mins(5);
|
|
1301
|
+
var POLL = Duration.secs(2);
|
|
1312
1302
|
var RUNNING = ["working", "merging"];
|
|
1313
1303
|
async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, poll = POLL } = {}) {
|
|
1314
1304
|
const deadline = Date.now() + timeout.ms;
|
|
@@ -1325,7 +1315,7 @@ async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, p
|
|
|
1325
1315
|
if (left <= 0) {
|
|
1326
1316
|
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 });
|
|
1327
1317
|
}
|
|
1328
|
-
await sleep(
|
|
1318
|
+
await sleep(Duration.min(poll, Duration.ms(left)));
|
|
1329
1319
|
}
|
|
1330
1320
|
}
|
|
1331
1321
|
function report2(worktree) {
|
|
@@ -1343,43 +1333,43 @@ function report2(worktree) {
|
|
|
1343
1333
|
|
|
1344
1334
|
// arbor.ts
|
|
1345
1335
|
var arbor = cli("arbor").use(exits()).use(repository());
|
|
1346
|
-
arbor.command("add").description("start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record").arg("task",
|
|
1336
|
+
arbor.command("add").description("start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record").arg("task", z.string(), { description: "task name (lowercase-with-dashes)" }).option("base", z.string(), {
|
|
1347
1337
|
default: "",
|
|
1348
1338
|
description: "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree"
|
|
1349
1339
|
}).action((opts, ctx) => ctx.journal.record("add", opts.task, () => add(ctx, opts.task, { base: opts.base || undefined })));
|
|
1350
|
-
arbor.command("claim").description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned").arg("task",
|
|
1340
|
+
arbor.command("claim").description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned").arg("task", z.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)));
|
|
1351
1341
|
arbor.command("merge").description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => ctx.journal.record("merge", await here(ctx), () => merge(ctx, ctx.ps.cwd())));
|
|
1352
|
-
arbor.command("
|
|
1342
|
+
arbor.command("remove").description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", z.string(), { description: "task name" }).option("force", z.string().transform((raw) => raw !== "false"), {
|
|
1353
1343
|
default: false,
|
|
1354
1344
|
description: "discard even when another agent holds the lease"
|
|
1355
|
-
}).action((opts, ctx) => ctx.journal.record("
|
|
1356
|
-
arbor.command("list").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",
|
|
1357
|
-
arbor.command("show").description("read one task without touching it: everything `list` shows 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",
|
|
1358
|
-
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",
|
|
1345
|
+
}).action((opts, ctx) => ctx.journal.record("remove", opts.task, () => remove(ctx, opts.task, { force: opts.force })));
|
|
1346
|
+
arbor.command("list").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", z.string().transform((raw) => raw !== "false"), { default: false, description: "emit JSON" }).action((opts, ctx) => list(ctx, { json: opts.json }));
|
|
1347
|
+
arbor.command("show").description("read one task without touching it: everything `list` shows 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", z.string(), { description: "task name" }).option("json", z.string().transform((raw) => raw !== "false"), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
|
|
1348
|
+
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", z.string(), { description: "task name" }).option("timeout-secs", z.coerce.number(), {
|
|
1359
1349
|
default: DEFAULT_TIMEOUT.secs,
|
|
1360
1350
|
description: "how long to wait before giving up"
|
|
1361
1351
|
}).action((opts, ctx) => wait(ctx, opts.task, {
|
|
1362
|
-
timeout:
|
|
1352
|
+
timeout: Duration2.secs(opts["timeout-secs"])
|
|
1363
1353
|
}));
|
|
1364
|
-
arbor.command("log").description("show what has been done here recently: one line per add, claim, merge,
|
|
1354
|
+
arbor.command("log").description("show what has been done here recently: one line per add, claim, merge, remove, escalate and retry, with how it ended; outlives the tasks themselves").option("count", z.coerce.number(), {
|
|
1365
1355
|
default: DEFAULT_COUNT,
|
|
1366
1356
|
description: "how many entries to show"
|
|
1367
|
-
}).option("json",
|
|
1368
|
-
arbor.command("dev").description("serve `list`, `show` and `log` as a web page; read-only").option("port",
|
|
1357
|
+
}).option("json", z.string().transform((raw) => raw !== "false"), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
|
|
1358
|
+
arbor.command("dev").description("serve `list`, `show` and `log` as a web page; read-only").option("port", z.coerce.number(), {
|
|
1369
1359
|
default: DEFAULT_PORT,
|
|
1370
1360
|
description: "port to listen on, or the next open one above it"
|
|
1371
1361
|
}).action((opts, ctx) => dev(ctx, { ports: devPorts(opts.port) }));
|
|
1372
|
-
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",
|
|
1362
|
+
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", z.string(), {
|
|
1373
1363
|
default: "",
|
|
1374
1364
|
description: "task name; omit for the main tree"
|
|
1375
1365
|
}).action((opts, ctx) => path(ctx, opts.task || undefined));
|
|
1376
|
-
arbor.command("escalate").description("hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish").arg("reason",
|
|
1366
|
+
arbor.command("escalate").description("hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish").arg("reason", z.string(), { description: "why this needs a human" }).option("task", z.string(), {
|
|
1377
1367
|
default: "",
|
|
1378
1368
|
description: "task name, when run outside its worktree"
|
|
1379
1369
|
}).action(async (opts, ctx) => ctx.journal.record("escalate", opts.task || await here(ctx), () => escalate(ctx, opts.reason, ctx.ps.cwd(), {
|
|
1380
1370
|
task: opts.task || undefined
|
|
1381
1371
|
})));
|
|
1382
|
-
arbor.command("retry").description("give an escalated task another mergeRetryCount merge attempts and put it back to working; the way out of `budget_exhausted` that is not
|
|
1372
|
+
arbor.command("retry").description("give an escalated task another mergeRetryCount merge attempts and put it back to working; the way out of `budget_exhausted` that is not remove and redo, and only from escalated, so a human has seen the tree first").arg("task", z.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("retry", opts.task, () => retry(ctx, opts.task)));
|
|
1383
1373
|
async function here({
|
|
1384
1374
|
service,
|
|
1385
1375
|
git,
|
|
@@ -20033,6 +20023,5 @@ var assets = { shell: shell_default, script: main_default, styles: styles_defaul
|
|
|
20033
20023
|
// index.ts
|
|
20034
20024
|
await arbor.run({
|
|
20035
20025
|
fs: new NodeFs7,
|
|
20036
|
-
http: new BunHttpServer,
|
|
20037
20026
|
assets
|
|
20038
20027
|
});
|
package/journal.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface Entry {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* What has been done in this repo, oldest first. Entries outlive their tasks:
|
|
13
|
-
* `merge` and `
|
|
13
|
+
* `merge` and `remove` take the record with them, so this is the only thing that
|
|
14
14
|
* remembers a task existed at all.
|
|
15
15
|
*/
|
|
16
16
|
/** What a `Journal` is stored through; the real filesystem 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.16",
|
|
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,8 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"webappwiz": "^0.0.
|
|
20
|
+
"webappwiz": "^0.0.16",
|
|
21
|
+
"zod": "^4.4.3"
|
|
21
22
|
},
|
|
22
23
|
"main": "./index.js",
|
|
23
24
|
"types": "./index.d.ts",
|
package/{rm.d.ts → remove.d.ts}
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Logger } from "webappwiz/log";
|
|
2
2
|
import type { WorktreeService } from "./worktree-service.js";
|
|
3
|
-
export interface
|
|
3
|
+
export interface RemoveOptions {
|
|
4
4
|
/** Discard the tree even when another agent holds its lease. */
|
|
5
5
|
force?: boolean;
|
|
6
6
|
}
|
|
@@ -11,7 +11,7 @@ export interface RmOptions {
|
|
|
11
11
|
* Throwing a task away and redoing it against current trunk is usually cheaper
|
|
12
12
|
* than a hard rebase, so this is meant to be used freely.
|
|
13
13
|
*/
|
|
14
|
-
export declare function
|
|
14
|
+
export declare function remove({ service, log }: {
|
|
15
15
|
service: WorktreeService;
|
|
16
16
|
log: Logger;
|
|
17
|
-
}, task: string, { force }?:
|
|
17
|
+
}, task: string, { force }?: RemoveOptions): Promise<void>;
|
package/retry.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Config } from "./config.js";
|
|
|
3
3
|
import type { WorktreeService } from "./worktree-service.js";
|
|
4
4
|
/**
|
|
5
5
|
* The way back from `budget_exhausted`. A task that has spent its merge
|
|
6
|
-
* attempts gets another `mergeRetryCount` of them, instead of `
|
|
6
|
+
* attempts gets another `mergeRetryCount` of them, instead of `remove` and redo.
|
|
7
7
|
*
|
|
8
8
|
* Only from `escalated`, and deliberately: the budget's whole job is to make an
|
|
9
9
|
* agent stop and hand the task over. An agent that could grant itself more
|
package/worktree-service.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export declare class WorktreeService {
|
|
|
43
43
|
private removedPath;
|
|
44
44
|
private removedAt;
|
|
45
45
|
/**
|
|
46
|
-
* Remembers a removed name so a second `
|
|
46
|
+
* Remembers a removed name so a second `remove` can say "already removed"
|
|
47
47
|
* instead of "never existed", then drops the oldest so the list of
|
|
48
48
|
* remembered names cannot grow without bound.
|
|
49
49
|
*/
|