@awebai/oats 0.42.0 → 0.42.1
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/bin/oats.mjs +8 -3
- package/docs/desktop-cli-api.md +77 -12
- package/docs/desktop.md +6 -2
- package/docs/release-notes/v0.42.1.md +110 -0
- package/docs/servers.md +4 -1
- package/docs/souls-and-instances.md +126 -6
- package/injects/instance-boundary.md +4 -3
- package/injects/work-checkout.md +25 -3
- package/injects/work-worktree.md +30 -7
- package/lib/core.mjs +181 -18
- package/lib/instance-git.mjs +22 -0
- package/lib/instance-lifecycle.mjs +17 -2
- package/lib/retire-output.mjs +14 -1
- package/lib/servers.mjs +5 -2
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_
|
|
|
53
53
|
|
|
54
54
|
import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
|
|
55
55
|
import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
|
|
56
|
-
import { formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
|
|
56
|
+
import { extraWorktreeLines, formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
|
|
57
57
|
const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
|
|
58
58
|
import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
|
|
59
59
|
import { readEvents, setWaiting, incarnationOf } from "../lib/instance-events.mjs";
|
|
@@ -2498,7 +2498,7 @@ function retireCmd() {
|
|
|
2498
2498
|
const planRev = flag("plan-revision"), idemKey = flag("idempotency-key");
|
|
2499
2499
|
if (planRev === true || idemKey === true) die("--plan-revision and --idempotency-key need values");
|
|
2500
2500
|
if ((planRev !== undefined) !== (idemKey !== undefined)) die("--plan-revision and --idempotency-key go together");
|
|
2501
|
-
let replayPath = null, childrenStopped = null;
|
|
2501
|
+
let replayPath = null, childrenStopped = null, plannedExtraWorktrees;
|
|
2502
2502
|
if (planRev !== undefined) {
|
|
2503
2503
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idemKey)) die("--idempotency-key: 1-128 chars of [A-Za-z0-9._:-]");
|
|
2504
2504
|
// Replay first: after a successful retire the home is gone, so the receipt
|
|
@@ -2508,6 +2508,9 @@ function retireCmd() {
|
|
|
2508
2508
|
let fresh;
|
|
2509
2509
|
try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.details) : die(e.message); }
|
|
2510
2510
|
replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
|
|
2511
|
+
// The extra trees the confirmed plan names, as it names them: the retire refuses as stale rather than
|
|
2512
|
+
// move or remove one that no longer reads that way when it gets to them.
|
|
2513
|
+
plannedExtraWorktrees = fresh.facts.extraWorktrees;
|
|
2511
2514
|
if (fresh.planRevision !== planRev) return args.includes("--json") ? jsonFail("E_PLAN_STALE", `the retire plan changed since it was shown (${planRev} → ${fresh.planRevision}); review the fresh plan`, { plan: fresh }) : die(`the retire plan changed since it was shown; re-run oats retire ${name} --plan`);
|
|
2512
2515
|
// The plan promised: recorded children are STOPPED first (bounded, never
|
|
2513
2516
|
// escalated) and retained. A child still running after the grace refuses
|
|
@@ -2521,7 +2524,7 @@ function retireCmd() {
|
|
|
2521
2524
|
if (running.length) return args.includes("--json") ? jsonFail("E_CHILDREN_RUNNING", `${running.map((k) => k.instance).join(", ")} ${running.length === 1 ? "is" : "are"} still running after a bounded stop; nothing was retired and nothing was escalated`, { childrenStopped, plan: fresh }) : die(`children still running: ${running.map((k) => k.instance).join(", ")}; nothing retired`);
|
|
2522
2525
|
}
|
|
2523
2526
|
let r;
|
|
2524
|
-
try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") }); }
|
|
2527
|
+
try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(plannedExtraWorktrees ? { plannedExtraWorktrees } : {}) }); }
|
|
2525
2528
|
catch (e) { if (!e?.code) throw e; return args.includes("--json") ? jsonFail(e.code, e.message, e.candidates ? { ...e.details, candidates: e.candidates } : e.details) : die(e.message); }
|
|
2526
2529
|
if (childrenStopped) r.childrenStopped = childrenStopped;
|
|
2527
2530
|
if (replayPath) { r.planRevision = planRev; r.idempotencyKey = idemKey; r.replayed = false; try { writeFileAtomic(replayPath, JSON.stringify(r, null, 2)); } catch { /* receipt is evidence, not authority */ } }
|
|
@@ -2563,6 +2566,7 @@ function retireCmd() {
|
|
|
2563
2566
|
// Preserving work and not saying so leaves the operator believing it is gone,
|
|
2564
2567
|
// which is most of the harm of deleting it. Name the classes and the path.
|
|
2565
2568
|
for (const line of workRecoveryLines(r)) console.log(line);
|
|
2569
|
+
for (const line of extraWorktreeLines(r)) console.log(line);
|
|
2566
2570
|
for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
|
|
2567
2571
|
if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
|
|
2568
2572
|
}
|
|
@@ -3714,6 +3718,7 @@ async function serverRouteCmd() {
|
|
|
3714
3718
|
}
|
|
3715
3719
|
console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
|
|
3716
3720
|
for (const line of workRecoveryLines(r, { host: target.sshHost })) console.log(line);
|
|
3721
|
+
for (const line of extraWorktreeLines(r, { host: target.sshHost })) console.log(line);
|
|
3717
3722
|
if (r.rollbackIncomplete) {
|
|
3718
3723
|
for (const f of r.rollbackIncomplete) console.error(` ${f}`);
|
|
3719
3724
|
const branch = failedSpawnBranchLine(r, r.rollbackIncomplete, { host: target.sshHost });
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -1923,7 +1923,11 @@ route target:
|
|
|
1923
1923
|
"running":true,"identity":{"alias":"dev-a","address":"acme/dev-a"},"identityAddress":"acme/dev-a",
|
|
1924
1924
|
"teams":[{"label":"default","team":"acme:team"}],"startedAt":"2026-09-29T10:00:00.000Z","createdAt":"2026-09-29T09:58:12.004Z",
|
|
1925
1925
|
"model":"opus","runtimeState":null,"parentInstance":"lead","siblingInstance":null,"relation":"child","relativeTo":"lead",
|
|
1926
|
-
"spawnOrigin":"instance","
|
|
1926
|
+
"spawnOrigin":"instance","work":"worktree","repo":"/srv/team/ws","branch":"agents/dev-a","modelFrom":"soul",
|
|
1927
|
+
"soul":{"repoKey":"github.com/acme/team","commit":"66566512…","current":"9c1e04ab…","status":"moved"},
|
|
1928
|
+
"modules":[{"name":"oats.okf","from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
|
|
1929
|
+
"commit":"ab897841…","current":{"commit":"ab897841…","version":"2.1.3"},"status":"current"}],
|
|
1930
|
+
"retirePending":false,"rollbackIncomplete":false,
|
|
1927
1931
|
"savedRoute":false,"addressable":true,"missingRemotely":false}],
|
|
1928
1932
|
"retireFailures":[]}
|
|
1929
1933
|
```
|
|
@@ -1940,9 +1944,16 @@ route target:
|
|
|
1940
1944
|
- **Instance rows** relay the host's own `status --json` row: `identity`,
|
|
1941
1945
|
`identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
|
|
1942
1946
|
`runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
|
|
1943
|
-
`relativeTo` and `spawnOrigin
|
|
1944
|
-
|
|
1945
|
-
|
|
1947
|
+
`relativeTo` and `spawnOrigin`, and (relayed from 0.42.1) `work`, `repo`,
|
|
1948
|
+
`branch`, `modelFrom`, `soul` and `modules`, are always present, `null`
|
|
1949
|
+
when the host does not supply them (an older host, a fact it never
|
|
1950
|
+
recorded, or a saved route the host no longer lists). Each is the
|
|
1951
|
+
[local row's](#the-roster-oats-status---json) fact of the same name,
|
|
1952
|
+
relayed as the host answered it. Nothing is derived on this side: `repo`
|
|
1953
|
+
is a path on the host, never read here, and `soul` and `modules` are the
|
|
1954
|
+
host's own drift observation (against its own members and lock), not
|
|
1955
|
+
recomputed: `modules` is the drift rows, or the recorded map when the host
|
|
1956
|
+
could not read its workspace, as on a local row.
|
|
1946
1957
|
- **`waitingOnYou`** (0.40.2, [Waiting on you](#waiting-on-you)) is on a row
|
|
1947
1958
|
only when the host's kernel reports it: a row from a host before 0.40.0,
|
|
1948
1959
|
and a saved route the host did not list, have no such key. Absent means
|
|
@@ -2219,6 +2230,15 @@ oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--
|
|
|
2219
2230
|
`--delete-branch` (since 0.41.0: `this self-retire was requested with
|
|
2220
2231
|
--delete-branch by an older OATS; retirement no longer deletes branches, so
|
|
2221
2232
|
the branch and the worktree were left`).
|
|
2233
|
+
- `worktree-retained` (`data: {movedTo, branch, recordedBranch}`) and
|
|
2234
|
+
`worktree-removed` (`data: {branch}`) are written to the workspace log only,
|
|
2235
|
+
by the retire's worktree step. Since 0.42.1 the retire also writes one per
|
|
2236
|
+
extra tree it handled
|
|
2237
|
+
([extra trees at retire](souls-and-instances.md#extra-trees-at-retire)),
|
|
2238
|
+
with `extra: true` and the tree's absolute `path` added:
|
|
2239
|
+
`worktree-retained` `{movedTo, branch, recordedBranch: null, extra: true,
|
|
2240
|
+
path}` and `worktree-removed` `{branch, extra: true, path}`. A row without
|
|
2241
|
+
`extra` is about `work/`.
|
|
2222
2242
|
- **Incarnation.** Each row carries the writing home's `createdAt` (or
|
|
2223
2243
|
`null` for old rows); the top-level `incarnation` is the current home's (or
|
|
2224
2244
|
`null`). Earlier incarnations are returned as this address's history.
|
|
@@ -2438,7 +2458,9 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
|
|
|
2438
2458
|
"upstream":{"ref":null,"ahead":null,"behind":null},"base":{"ref":null,"ahead":null,"behind":null},"remote":null},
|
|
2439
2459
|
"workMode":"worktree","repo":"/w/one","recordedBranch":"agents/dev-1",
|
|
2440
2460
|
"children":[{"instance":"dev-1-child","agent":"dev","home":"/w/agents/dev/instances/dev-1-child","session":{"state":"shell","present":true,"backend":"tmux","established":true}}],
|
|
2441
|
-
"ambiguous":[],"pullRequest":"unknown"
|
|
2461
|
+
"ambiguous":[],"pullRequest":"unknown",
|
|
2462
|
+
"extraWorktrees":[{"path":"/w/agents/dev/instances/dev-1/.work-docs","repo":"/w/docs","branch":"agents/dev-1-docs","detachedAt":null,
|
|
2463
|
+
"disposition":"retain","movedTo":"/w/.agents/worktrees/docs/agents-dev-1-docs","reason":"…"}]},
|
|
2442
2464
|
"defaults":{"retainWorktree":true,"deleteBranch":false,"stopChildren":true,"retainChildren":true},
|
|
2443
2465
|
"planRevision":"4e5f6a7b8c9d0e1f2a3b4c5d","notes":["the worktree is on feat/x, not the recorded agents/dev-1; …"]}
|
|
2444
2466
|
```
|
|
@@ -2476,10 +2498,35 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
|
|
|
2476
2498
|
there when it is not empty` in directory mode, and absent in checkout,
|
|
2477
2499
|
attached and workspace modes. They are strings in `notes`: no other key
|
|
2478
2500
|
changes, and `planRevision` is unaffected.
|
|
2501
|
+
- `facts.extraWorktrees` (0.42.1) lists the instance's
|
|
2502
|
+
[extra trees](souls-and-instances.md#extra-trees-at-retire): linked
|
|
2503
|
+
worktrees at `<home>/.work-*` that Git confirms. It is an array, empty when
|
|
2504
|
+
there are none, in every work mode. Each row is `{path, repo, branch,
|
|
2505
|
+
detachedAt, disposition, movedTo, reason}`:
|
|
2506
|
+
- `path`: the tree's absolute path in the home.
|
|
2507
|
+
- `repo`: its repository, the first entry of that repository's `git
|
|
2508
|
+
worktree list` (the main worktree, or the bare repository).
|
|
2509
|
+
- `branch`: the branch its HEAD is on, or `null`. `detachedAt`: the commit
|
|
2510
|
+
when HEAD is detached, else `null`.
|
|
2511
|
+
- `disposition`: `"remove"` (the tree is clean: it would be removed, its
|
|
2512
|
+
branch kept), `"retain"` (it would be moved to `movedTo`, as `work/` is
|
|
2513
|
+
retained) or `"refuse"` (the retire would refuse with
|
|
2514
|
+
`E_WORK_PRESERVATION_FAILED` and keep the home).
|
|
2515
|
+
- `movedTo`: the target for `"retain"`, else `null`.
|
|
2516
|
+
- `reason`: `null` for `"remove"`, why the tree is not clean for
|
|
2517
|
+
`"retain"`, why it is refused for `"refuse"`.
|
|
2518
|
+
|
|
2519
|
+
`notes` also carries one string per tree that says the same. The trees are
|
|
2520
|
+
part of `planRevision`: a tree created, removed, dirtied or cleaned between
|
|
2521
|
+
the plan and the apply, or a change of its disposition or target (another
|
|
2522
|
+
directory taking the `<leaf>-N` it would move to, for example), refuses a
|
|
2523
|
+
guarded apply with `E_PLAN_STALE` before anything runs. A reader that does
|
|
2524
|
+
not know the key can ignore it; the `notes` strings say the same.
|
|
2479
2525
|
|
|
2480
2526
|
Plain `retire` keeps a worktree-mode instance's work: the worktree is moved
|
|
2481
2527
|
(`git worktree move`) to `<deployment>/.agents/worktrees/<repo>/<branch>` (a
|
|
2482
|
-
`-2` suffix if taken; `detached-<oid12>` when detached), state intact.
|
|
2528
|
+
`-2` suffix if taken; `detached-<oid12>` when detached), state intact. An
|
|
2529
|
+
extra tree that is not clean is moved the same way; a clean one is removed.
|
|
2483
2530
|
|
|
2484
2531
|
```text
|
|
2485
2532
|
oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--home <abs>] --json
|
|
@@ -2535,6 +2582,21 @@ A first retire prints the **raw receipt**, not an envelope:
|
|
|
2535
2582
|
retire (hooks run, a recovery copied), and neither says that one happened:
|
|
2536
2583
|
an error here is not proof that nothing happened, nor that a recovery
|
|
2537
2584
|
exists.
|
|
2585
|
+
- `extraWorktrees` (0.42.1): the extra trees the retire handled, present only
|
|
2586
|
+
when it handled at least one. Each row is the plan's row (`{path, repo,
|
|
2587
|
+
branch, detachedAt, disposition, movedTo, reason}`) plus `outcome`:
|
|
2588
|
+
`"removed"` or `"retained"`, with `movedTo` where a retained tree went. The
|
|
2589
|
+
step runs only when the home is removed (not with `--keep-dir`, not when the
|
|
2590
|
+
home is kept for a retry), after the hooks and before the worktree step of
|
|
2591
|
+
`work/`. `--discard-worktree` does not apply to it. A locked tree
|
|
2592
|
+
(`"refuse"` in the plan) stops the retire with `E_WORK_PRESERVATION_FAILED`
|
|
2593
|
+
naming the tree before anything runs (no session stop, no retire hook);
|
|
2594
|
+
a lock that appears during the hooks, or a move or removal Git refuses,
|
|
2595
|
+
stops it at the step, after the hooks. `--force` does not bypass either;
|
|
2596
|
+
the home and `work/` are kept, and trees already handled stay handled. A tree
|
|
2597
|
+
that no longer matches what the applied plan said refuses with
|
|
2598
|
+
`E_PLAN_STALE`, the home kept, rather than be moved or removed unplanned.
|
|
2599
|
+
The key is additive.
|
|
2538
2600
|
- `workRecovery`: `{path, classes, bytes, home, outputs?, repoCopy?,
|
|
2539
2601
|
notCopied?, afterHooks?}`, present when a recovery was written. One retire
|
|
2540
2602
|
writes at most one recovery directory, and `path` is that directory.
|
|
@@ -2560,10 +2622,12 @@ A first retire prints the **raw receipt**, not an envelope:
|
|
|
2560
2622
|
names: a prefix that matches several entries lists each one, and a
|
|
2561
2623
|
declared entry that does not exist is not listed. `owner` is the
|
|
2562
2624
|
declaring capability; when two declare the same entry it is the first in
|
|
2563
|
-
capability-name order.
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2625
|
+
capability-name order. Since 0.42.1 the list also holds each verified
|
|
2626
|
+
extra tree (`.work-<purpose>`), with `owner: "kernel:extra-worktree"`: the
|
|
2627
|
+
retire handles it at its own step, never in the copy. Each entry has
|
|
2628
|
+
exactly these three keys: names and owners only, no sizes, hashes, modes
|
|
2629
|
+
or contents of what was left out. `scope` is always `"home"` in this
|
|
2630
|
+
release. Present only when there is at least one.
|
|
2567
2631
|
- `afterHooks`: `{home: boolean, work: boolean}`, saying which parts were
|
|
2568
2632
|
copied again under `after-hooks/`: `after-hooks/home/` when a retire hook
|
|
2569
2633
|
changed the home (its bytes and permission bits, the kernel's own
|
|
@@ -2615,8 +2679,9 @@ A first retire prints the **raw receipt**, not an envelope:
|
|
|
2615
2679
|
`idempotencyKey` and `replayed: false`.
|
|
2616
2680
|
|
|
2617
2681
|
Refusals (envelopes): `E_PLAN_STALE`, `E_CHILDREN_RUNNING`,
|
|
2618
|
-
`E_WORK_PRESERVATION_FAILED` (the home is kept; retry or
|
|
2619
|
-
`--discard-worktree`
|
|
2682
|
+
`E_WORK_PRESERVATION_FAILED` (the home is kept; retry, or
|
|
2683
|
+
`--discard-worktree` when it is `work/` that could not be re-homed: it does
|
|
2684
|
+
not apply to an extra tree), `E_WORK_INSPECTION_FAILED` (the home is kept; the
|
|
2620
2685
|
message names the entry or the state that could not be read),
|
|
2621
2686
|
`E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
|
|
2622
2687
|
`E_NO_ROOT`, `E_LIFECYCLE_FAILED`. A recovery whose Git status disagrees with
|
package/docs/desktop.md
CHANGED
|
@@ -249,14 +249,18 @@ for a password or key, and never runs anything over ssh itself.
|
|
|
249
249
|
|
|
250
250
|
Every instance a registered server reports shows in its workspace's roster, whoever spawned it. You
|
|
251
251
|
can open its terminal, start, restart, stop and remove it, and read its readiness, activity, Git
|
|
252
|
-
and diffs, as for a local one.
|
|
252
|
+
and diffs, as for a local one. The context panel's Soul tab and its Messaging & Teams list read
|
|
253
|
+
through the server too (`oats inspect --server <id> --home <path>`), and its Work card, "model
|
|
254
|
+
from" line and "older build" chip show the facts the server relays. Only its pull request is not read here, since the forge reads this
|
|
253
255
|
computer's clones. Every command goes to the server by the instance's home (`--server <id> --home
|
|
254
256
|
<path>`), never by a bare name. Stop and Remove show the plan the server makes, and confirm
|
|
255
257
|
against it.
|
|
256
258
|
|
|
257
259
|
A read waits for the server: the view says "Reading from <server>…", and gives up after about
|
|
258
260
|
45 seconds with "Couldn't reach <server>." When the server refuses, you see its code and message;
|
|
259
|
-
nothing is read from this computer in its place.
|
|
261
|
+
nothing is read from this computer in its place. When the server's OATS, or this computer's, lacks
|
|
262
|
+
what a part of the panel needs, that part names the server and says which OATS to update, rather
|
|
263
|
+
than showing nothing. A row that can't be opened says why on the row:
|
|
260
264
|
Herdr no longer supported, gone from <server>, not reachable on <server>, or <server> not reached.
|
|
261
265
|
For an instance a server no longer lists, the reason names the command that removes it from this
|
|
262
266
|
computer (`oats server forget <server> --instance <name>`).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# OATS 0.42.1
|
|
2
|
+
|
|
3
|
+
## Changed
|
|
4
|
+
|
|
5
|
+
- **Remote roster rows carry the local row's work, repository, branch,
|
|
6
|
+
model-from, soul and module drift facts**
|
|
7
|
+
([#675](https://github.com/awebai/oats/issues/675)). A remote instance
|
|
8
|
+
row (`oats server roster --json`, and the Desktop's remote roster) now
|
|
9
|
+
relays `work`, `repo`, `branch`, `modelFrom`, `soul` and `modules` from
|
|
10
|
+
the host's own `oats status --json` row, with the same names and meaning
|
|
11
|
+
as on a local row. `soul` and `modules` are the host's own drift
|
|
12
|
+
observation, relayed as it answered; nothing is recomputed on this side,
|
|
13
|
+
and `repo` is the host's path. Each is always present, `null` from a host
|
|
14
|
+
that doesn't report it (an older host, a fact it never recorded, or a saved
|
|
15
|
+
route the host no longer lists). See
|
|
16
|
+
[the remote roster](../desktop-cli-api.md#the-remote-roster-oats-server-roster---json).
|
|
17
|
+
|
|
18
|
+
- **The `worktree` and `checkout` briefings allow extra trees, and give the
|
|
19
|
+
command** ([#674](https://github.com/awebai/oats/issues/674)). Both
|
|
20
|
+
briefings forbade extra Git worktrees: `worktree` said "Don't create extra
|
|
21
|
+
worktrees; `work/` is your one tree", and `checkout` said to ask for a
|
|
22
|
+
worktree-mode instance for work that needs its own branch. That
|
|
23
|
+
contradicted the shipped `oats.engineering` package, whose developer
|
|
24
|
+
briefing and `/worktrees` skill say "your work-mode briefing has the
|
|
25
|
+
command", with `.work-<purpose>` in the home as the default place. No
|
|
26
|
+
briefing gave that command. The case that needs it is a developer whose
|
|
27
|
+
one task touches several repositories of the deployment.
|
|
28
|
+
|
|
29
|
+
Both briefings now carry the same short paragraph. An extra tree is created
|
|
30
|
+
in the home, from any clone of the deployment (`oats-local.yaml` `clones:`,
|
|
31
|
+
or `<deployment>/<repo>`), from the remote's current state:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
|
|
35
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
|
|
36
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The fetch runs inside the new tree, which has its own `FETCH_HEAD`, so
|
|
40
|
+
creating it moves none of the clone's refs. The paragraph also covers
|
|
41
|
+
branch names (the repository's rules, else `agents/<instance>-<purpose>`;
|
|
42
|
+
never `-C` or `-B`), pushing a tree without an upstream
|
|
43
|
+
(`git push origin HEAD:<remote-branch>`), and closing: merge each tree into
|
|
44
|
+
the PR branch or push it and name it in the hand-back, then
|
|
45
|
+
`git -C <clone> worktree remove`. `git switch` needs Git 2.23 or later.
|
|
46
|
+
|
|
47
|
+
`worktree` mode now says never to work in a shared checkout (the main
|
|
48
|
+
checkout, or any clone others use), and that changes happen in `work/` or
|
|
49
|
+
in the extra trees. It drops "Parallel work means your human spawns
|
|
50
|
+
another instance". `checkout` mode now says that work needing its own
|
|
51
|
+
branch goes in an extra tree, not in `work/`. The `workspace`, `directory`
|
|
52
|
+
and `attached` briefings do not change, and there is no new `oats`
|
|
53
|
+
command. The instance-boundary block every instance receives said repository
|
|
54
|
+
work happens in `work/` "and only there"; it now says "there, or in the
|
|
55
|
+
extra trees your mode block grants, and nowhere else", so the two blocks
|
|
56
|
+
no longer disagree. Instances spawned before the upgrade keep the briefing
|
|
57
|
+
they were spawned with. See
|
|
58
|
+
[souls and instances](../souls-and-instances.md#extra-trees).
|
|
59
|
+
|
|
60
|
+
- **Retire protects extra trees in the home**
|
|
61
|
+
([#674](https://github.com/awebai/oats/issues/674)). An extra tree is a
|
|
62
|
+
top-level `.work-*` entry of the home that Git confirms is a linked
|
|
63
|
+
worktree of some repository. Before, retire treated it as home bytes: it
|
|
64
|
+
copied it to recovery and removed it with the home, leaving the
|
|
65
|
+
repository's admin entry behind. Now, in every work mode:
|
|
66
|
+
|
|
67
|
+
- A verified extra tree is not copied to recovery. `workRecovery.notCopied`
|
|
68
|
+
lists it with `owner: "kernel:extra-worktree"`. A `.work-*` entry that
|
|
69
|
+
does not verify (a plain directory, a symbolic link, a full clone), or
|
|
70
|
+
that belongs to a repository inside the home, is copied as before.
|
|
71
|
+
- When the home is removed, the retire handles each tree after its hooks
|
|
72
|
+
and before the `work/` worktree step. A clean tree (empty status,
|
|
73
|
+
ignored and untracked files included; no operation in progress; HEAD
|
|
74
|
+
reached by a ref of its repository, not only by the tree's own) is
|
|
75
|
+
removed with `git worktree remove` and pruned. Its branch is kept. Any
|
|
76
|
+
other tree is moved, as `work/` is retained, to
|
|
77
|
+
`<deployment>/.agents/worktrees/<repo>/<branch>`.
|
|
78
|
+
- A locked tree refuses the retire with `E_WORK_PRESERVATION_FAILED`
|
|
79
|
+
before anything runs: no session is stopped and no retire hook runs. A
|
|
80
|
+
move or removal Git refuses refuses it after the hooks. Either way the
|
|
81
|
+
home is kept. `--force` does not bypass it, and `--discard-worktree` does
|
|
82
|
+
not apply to extra trees.
|
|
83
|
+
- With `--keep-dir`, or when the home is kept for a retry, the trees stay
|
|
84
|
+
where they are.
|
|
85
|
+
|
|
86
|
+
`oats retire <instance> --plan` adds `facts.extraWorktrees` (`[{path, repo,
|
|
87
|
+
branch, detachedAt, disposition, movedTo, reason}]`, empty when there are
|
|
88
|
+
none) and one `notes` string per tree. The trees are part of
|
|
89
|
+
`planRevision`: a tree created, removed, dirtied or cleaned between the plan
|
|
90
|
+
and the apply refuses the apply with `E_PLAN_STALE`. The retire receipt adds
|
|
91
|
+
`extraWorktrees` (the plan's row plus `outcome`), present when a tree was
|
|
92
|
+
handled. The `worktree-removed` and `worktree-retained` events for an extra
|
|
93
|
+
tree carry `extra: true` and its `path`. All keys are additive. See
|
|
94
|
+
[souls and instances](../souls-and-instances.md#extra-trees-at-retire) and
|
|
95
|
+
[the CLI API](../desktop-cli-api.md#retire).
|
|
96
|
+
|
|
97
|
+
## Fixed
|
|
98
|
+
|
|
99
|
+
- **The context panel shows the soul and teams of an instance on a server**
|
|
100
|
+
([#675](https://github.com/awebai/oats/issues/675)). The Soul tab and the
|
|
101
|
+
Messaging & Teams list were empty for an instance on a registered server.
|
|
102
|
+
They now read through the server by the instance's home, as the soul page
|
|
103
|
+
does, and say "Reading from <server>…" while they wait. The Soul tab's
|
|
104
|
+
header shows the soul's description from the server's roster. The Work
|
|
105
|
+
card, the "model from" line and the "older build" chip show the facts the
|
|
106
|
+
server relays. When a part can't be shown, it says why and what to update:
|
|
107
|
+
this computer's OATS when it can't route the read or doesn't relay the
|
|
108
|
+
facts, the server's OATS when it lacks the feature or doesn't report them.
|
|
109
|
+
A server's other refusals show its code and message, as the rest of the
|
|
110
|
+
panel does.
|
package/docs/servers.md
CHANGED
|
@@ -300,7 +300,10 @@ before 0.36.0, `null` when it reports none or the probe failed), the remote soul
|
|
|
300
300
|
the host's own facts from its `status --json`: `identity`,
|
|
301
301
|
`identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
|
|
302
302
|
`runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
|
|
303
|
-
`relativeTo
|
|
303
|
+
`relativeTo`, `spawnOrigin`, and (0.42.1) `work`, `repo`, `branch`,
|
|
304
|
+
`modelFrom`, `soul` and `modules`: the local row's facts, with `soul` and
|
|
305
|
+
`modules` the host's own drift observation, never recomputed here, and
|
|
306
|
+
`repo` a path on the host. A fact the host does not supply is `null`
|
|
304
307
|
(an older host, or a saved route the host no longer lists). A row also
|
|
305
308
|
carries `waitingOnYou` (needs input) when the host's kernel reports it, and
|
|
306
309
|
only then: an older host's row has no such key. A removed or edited registration keeps
|
|
@@ -809,6 +809,76 @@ commit yet cannot be read that way: a retire that would remove it, or that
|
|
|
809
809
|
has work of it to copy, refuses with `E_WORK_INSPECTION_FAILED` and removes
|
|
810
810
|
nothing.
|
|
811
811
|
|
|
812
|
+
#### Extra trees at retire
|
|
813
|
+
|
|
814
|
+
An instance can hold [extra trees](#extra-trees) in its home beside `work/`.
|
|
815
|
+
Retire handles them itself, so the home removal never deletes their work.
|
|
816
|
+
|
|
817
|
+
An **extra tree** is a top-level entry of the home named `.work-*` that is a
|
|
818
|
+
real directory (not a symbolic link), whose `.git` is a regular file, and that
|
|
819
|
+
Git confirms is a registered linked worktree of some repository: its Git
|
|
820
|
+
directory differs from its common directory, its top level is the entry
|
|
821
|
+
itself, and that repository's `git worktree list` names it. Its repository is
|
|
822
|
+
the first entry of that list (the main worktree, or the bare repository).
|
|
823
|
+
Anything named `.work-*` that does not verify (a plain directory, an orphaned
|
|
824
|
+
`.git` file, a symbolic link, a nested full clone), or that is a worktree of a
|
|
825
|
+
repository inside the home itself, is ordinary home bytes, and the home's
|
|
826
|
+
recovery copies it as before (with that repository). This holds in every work mode,
|
|
827
|
+
`directory` included.
|
|
828
|
+
|
|
829
|
+
A verified extra tree is not part of the home's recovery bytes: it does not
|
|
830
|
+
count as changed instance-home bytes, it is not copied, and the recovery's
|
|
831
|
+
`notCopied` lists it as `{scope: "home", path: ".work-<purpose>", owner:
|
|
832
|
+
"kernel:extra-worktree"}`.
|
|
833
|
+
|
|
834
|
+
The extra-tree step runs only when the home is going to be removed: not with
|
|
835
|
+
`--keep-dir`, and not when the retire keeps the home for a retry. That is the
|
|
836
|
+
condition of the `work/` worktree step (nothing outstanding, or `--force`).
|
|
837
|
+
It runs after the retire hooks and before the `work/` step, so a refusal
|
|
838
|
+
leaves `work/` untouched. For each tree:
|
|
839
|
+
|
|
840
|
+
- **A clean tree is removed.** Clean means that `git status`, ignored and
|
|
841
|
+
untracked files included, is empty; no merge, rebase, cherry-pick, revert,
|
|
842
|
+
bisect or sequencer operation is in progress; and its HEAD commit is
|
|
843
|
+
reached by a ref of its repository (the tree's own HEAD, reflog and
|
|
844
|
+
`refs/worktree/` refs do not count: they go with its admin entry). The retire runs `git worktree remove` (without
|
|
845
|
+
`--force`) and `git worktree prune`, and verifies that the tree is gone from
|
|
846
|
+
`git worktree list`. Every Git command of this step runs helper-free (no
|
|
847
|
+
fsmonitor, hooks or external diff the repository's configuration names). Its branch is never deleted: a commit on the branch that
|
|
848
|
+
was not pushed stays in the clone, on that branch.
|
|
849
|
+
- **Any other tree is re-homed**, as `work/` is by default: `git worktree
|
|
850
|
+
move` to `<deployment>/.agents/worktrees/<repo>/<leaf>`, where `<leaf>` is
|
|
851
|
+
the branch name with characters outside `A-Za-z0-9._-` replaced by `-`, or
|
|
852
|
+
`detached-<12 hex>` when HEAD is detached. A target that exists gets `-2`,
|
|
853
|
+
`-3`, and so on. A tree whose HEAD cannot be read is re-homed too.
|
|
854
|
+
- **A tree the retire cannot handle refuses it.** A locked tree (`git worktree
|
|
855
|
+
lock`) refuses before anything runs: a retire that would remove the home
|
|
856
|
+
finds the lock in its first inspection, before the session is stopped and
|
|
857
|
+
before any retire hook, and stops with "nothing was run or removed". A lock
|
|
858
|
+
that appears while the hooks run is refused at the step, before any tree is
|
|
859
|
+
touched. A move or a removal that Git refuses (a tree with submodules, for
|
|
860
|
+
example), or a removal that cannot be verified, refuses at the step, after
|
|
861
|
+
the hooks. Each refusal is `E_WORK_PRESERVATION_FAILED` naming the tree,
|
|
862
|
+
and the home is kept. At the step, trees already handled in that pass stay
|
|
863
|
+
handled, and the message says what was done. `--force` does not bypass it:
|
|
864
|
+
it forces past hook cleanup, not past local work.
|
|
865
|
+
|
|
866
|
+
`--discard-worktree` applies to `work/` only: a tree that is not clean is
|
|
867
|
+
always re-homed. The retire writes one workspace event per handled tree
|
|
868
|
+
(`worktree-removed` or `worktree-retained`, with `extra: true` and the tree's
|
|
869
|
+
`path`), and its summary prints one line per tree: removed, or re-homed to
|
|
870
|
+
the new path.
|
|
871
|
+
|
|
872
|
+
`oats retire <instance> --plan` lists the trees and what the retire would do
|
|
873
|
+
with each, and they are part of the plan's revision: a tree created, removed,
|
|
874
|
+
dirtied or cleaned between the plan and a guarded apply, or a new target for
|
|
875
|
+
it, refuses the apply with `E_PLAN_STALE` before anything runs. The retire
|
|
876
|
+
checks the trees again at the step itself and refuses with `E_PLAN_STALE`,
|
|
877
|
+
keeping the home, rather than move or remove a tree in a way the plan did
|
|
878
|
+
not say. The retire hooks have run by then, and no tree was moved or
|
|
879
|
+
removed. The fields are in
|
|
880
|
+
[the CLI API](desktop-cli-api.md#retire).
|
|
881
|
+
|
|
812
882
|
## Work modes
|
|
813
883
|
|
|
814
884
|
A work mode decides what `./work` points at and what discipline the agent must
|
|
@@ -824,7 +894,8 @@ instructions state first (`injects/instance-boundary.md`):
|
|
|
824
894
|
target another one deliberately).
|
|
825
895
|
- `<instance-home>/work` — the repository or workspace view — is where
|
|
826
896
|
repository reading, editing, building, testing, git and commits happen, to the
|
|
827
|
-
extent the mode below permits.
|
|
897
|
+
extent the mode below permits. In `worktree` and `checkout` mode, the
|
|
898
|
+
instance's [extra trees](#extra-trees) in the home serve the same purpose.
|
|
828
899
|
- The home has no soul link: the composed `AGENTS.md` already carries the
|
|
829
900
|
soul's instructions, and `instance.json` `soulDir` records the (read-only,
|
|
830
901
|
per-commit) soul directory every hook and dispatched command receives as
|
|
@@ -863,10 +934,12 @@ Use this for agents that will edit code or docs independently.
|
|
|
863
934
|
Rules:
|
|
864
935
|
|
|
865
936
|
- Build, test, and commit from `work/`, on your own branch.
|
|
866
|
-
- Never
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
937
|
+
- Never work in a shared checkout (the repo's main checkout, or any clone
|
|
938
|
+
others use): do not edit, commit or switch branches there. Against a clone,
|
|
939
|
+
run only `git worktree add` and `git worktree remove`, as in
|
|
940
|
+
[extra trees](#extra-trees).
|
|
941
|
+
- Everything you change happens in `work/` or in your extra trees.
|
|
942
|
+
- Leave your branch and the worktree list clean when your task closes.
|
|
870
943
|
|
|
871
944
|
### `checkout` — shared current branch
|
|
872
945
|
|
|
@@ -880,7 +953,10 @@ Rules:
|
|
|
880
953
|
|
|
881
954
|
- Stay on the currently checked-out branch.
|
|
882
955
|
- Do not switch branches unless explicitly asked.
|
|
883
|
-
-
|
|
956
|
+
- No destructive git operations (`reset --hard`, rebase, force-push, checkout
|
|
957
|
+
of another branch) unless the human explicitly asks.
|
|
958
|
+
- Work that needs its own branch goes in an [extra tree](#extra-trees), not
|
|
959
|
+
in `work/`.
|
|
884
960
|
|
|
885
961
|
### `attached` — another instance's tree
|
|
886
962
|
|
|
@@ -935,6 +1011,50 @@ Rules:
|
|
|
935
1011
|
|
|
936
1012
|
The instance records no branch: the workspace is not a Git tree.
|
|
937
1013
|
|
|
1014
|
+
### Extra trees
|
|
1015
|
+
|
|
1016
|
+
An instance in `worktree` or `checkout` mode can create extra trees: linked
|
|
1017
|
+
Git worktrees in its home, beside `work/`. It does so when the work needs
|
|
1018
|
+
another branch, or another repository of the deployment (one task that
|
|
1019
|
+
touches several repositories). The `worktree` and `checkout` briefings give
|
|
1020
|
+
the command; the `workspace`, `directory` and `attached` briefings do not.
|
|
1021
|
+
There is no `oats` command for it.
|
|
1022
|
+
|
|
1023
|
+
`<clone>` is any clone of the deployment (`oats-local.yaml` `clones:`, or
|
|
1024
|
+
`<deployment>/<repo>`), `origin` is its remote for that repository, and
|
|
1025
|
+
`<base>` is the remote branch the work starts from (the branch itself, to
|
|
1026
|
+
rework an existing one):
|
|
1027
|
+
|
|
1028
|
+
```bash
|
|
1029
|
+
git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
|
|
1030
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
|
|
1031
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
- The tree starts from the remote's current state, never from a local branch
|
|
1035
|
+
of the clone, which may be stale.
|
|
1036
|
+
- Creating it moves none of the clone's refs. The fetch runs inside the new
|
|
1037
|
+
linked tree, which has its own `FETCH_HEAD`, and `--refmap=` keeps it from
|
|
1038
|
+
updating remote-tracking refs. The clone's `FETCH_HEAD`, branches and work
|
|
1039
|
+
tree are not touched, so creation does not race with others who use the
|
|
1040
|
+
clone. The fetched objects go to the repository's shared object store.
|
|
1041
|
+
- `git switch` needs Git 2.23 or later.
|
|
1042
|
+
- `<branch>` follows the repository's own naming rules, else
|
|
1043
|
+
`agents/<instance>-<purpose>`. If that branch already exists in the clone,
|
|
1044
|
+
`switch -c` refuses: use `<instance>/<branch>`. Never `-C` or `-B`, which
|
|
1045
|
+
reset a branch someone else may own.
|
|
1046
|
+
- The tree has no upstream. Push with `git push origin HEAD:<remote-branch>`
|
|
1047
|
+
(`<base>` when reworking an existing branch). A push does update the
|
|
1048
|
+
clone's `refs/remotes/origin/<remote-branch>`, as any push does.
|
|
1049
|
+
- Before the task closes, merge each extra tree into the PR branch, or push
|
|
1050
|
+
its branch and name it in the hand-back; then
|
|
1051
|
+
`git -C <clone> worktree remove "$OATS_INSTANCE_HOME/.work-<purpose>"`.
|
|
1052
|
+
|
|
1053
|
+
`$OATS_INSTANCE_HOME` is set in every harness session OATS launches (Claude
|
|
1054
|
+
Code, Codex, pi), not only in hooks. Retirement removes a clean extra tree
|
|
1055
|
+
and re-homes one that holds work, but the briefing tells the agent not to
|
|
1056
|
+
rely on it: see [extra trees at retire](#extra-trees-at-retire).
|
|
1057
|
+
|
|
938
1058
|
## Agents root
|
|
939
1059
|
|
|
940
1060
|
Instance homes live under the deployment's agents root,
|
|
@@ -26,9 +26,10 @@ root, and not the work tree. Anything that says "your home" means this directory
|
|
|
26
26
|
**`<instance-home>/work` is your repository or workspace view** — whatever your
|
|
27
27
|
work mode grants you of the code.
|
|
28
28
|
|
|
29
|
-
- **Repository work happens there
|
|
30
|
-
|
|
31
|
-
or from your
|
|
29
|
+
- **Repository work happens there, or in the extra trees your mode block
|
|
30
|
+
grants, and nowhere else**: reading, editing, building, testing, git and
|
|
31
|
+
commits, on repository content. Never from the main checkout or from your
|
|
32
|
+
home root, beyond what your mode block names.
|
|
32
33
|
- **What your mode permits is the mode block's call**, immediately below. Some
|
|
33
34
|
modes are read-only, some share a tree with others, and that block is the
|
|
34
35
|
authority on which operations are yours to perform.
|
package/injects/work-checkout.md
CHANGED
|
@@ -7,6 +7,28 @@ in the same tree as the human and possibly other agents.
|
|
|
7
7
|
explicitly asked.**
|
|
8
8
|
- No destructive git operations (reset --hard, rebase, force-push, checkout
|
|
9
9
|
of another branch) without an explicit human instruction.
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
- Work that needs its own branch goes in an extra tree, not in `work/`.
|
|
11
|
+
|
|
12
|
+
### Extra trees
|
|
13
|
+
|
|
14
|
+
When the work needs another branch, or another repository of this deployment,
|
|
15
|
+
create an extra tree in your home. `<clone>` is any clone of this deployment
|
|
16
|
+
(`oats-local.yaml` `clones:`, or `<deployment>/<repo>`); `origin` is its remote
|
|
17
|
+
for that repository; `<base>` is the remote branch the work starts from (the
|
|
18
|
+
branch itself, when you rework an existing one):
|
|
19
|
+
|
|
20
|
+
git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
|
|
21
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
|
|
22
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
|
|
23
|
+
|
|
24
|
+
- This starts from the remote's current state and moves none of the clone's
|
|
25
|
+
refs. Never start from a local branch of the clone, which may be stale.
|
|
26
|
+
- Name `<branch>` by the repository's own rules, else `agents/<instance>-<purpose>`.
|
|
27
|
+
If it already exists in that clone, `switch -c` refuses: use `<instance>/<branch>`.
|
|
28
|
+
Never `-C`/`-B`, which reset a branch someone else may own.
|
|
29
|
+
- The tree has no upstream: push with `git push origin HEAD:<remote-branch>`
|
|
30
|
+
(`<base>` when you rework an existing branch).
|
|
31
|
+
- Before your task closes, merge each extra tree into your PR branch, or push its
|
|
32
|
+
branch and name it in your hand-back; then
|
|
33
|
+
`git -C <clone> worktree remove "$OATS_INSTANCE_HOME/.work-<purpose>"`.
|
|
34
|
+
Retirement keeps a tree that still holds uncommitted work, but don't rely on it.
|
package/injects/work-worktree.md
CHANGED
|
@@ -3,11 +3,34 @@
|
|
|
3
3
|
Your `./work` is a **git worktree on your own branch** — a full checkout that is
|
|
4
4
|
yours alone: build, test and commit there, on your branch.
|
|
5
5
|
|
|
6
|
-
- **Never
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
your human spawns another instance.
|
|
6
|
+
- **Never work in a shared checkout** (the repo's main checkout, or any clone
|
|
7
|
+
others use): don't edit, commit or switch branches there. Against a clone you
|
|
8
|
+
run only `worktree add`/`remove` below. If unsure, `pwd`.
|
|
9
|
+
- Everything you change happens in `work/` or your extra trees — **including your
|
|
10
|
+
own soul** when it lives in this repo: soul edits are branch changes, reviewed
|
|
11
|
+
and merged like code.
|
|
13
12
|
- Leave your branch and the worktree list clean when your task closes.
|
|
13
|
+
|
|
14
|
+
### Extra trees
|
|
15
|
+
|
|
16
|
+
When the work needs another branch, or another repository of this deployment,
|
|
17
|
+
create an extra tree in your home. `<clone>` is any clone of this deployment
|
|
18
|
+
(`oats-local.yaml` `clones:`, or `<deployment>/<repo>`); `origin` is its remote
|
|
19
|
+
for that repository; `<base>` is the remote branch the work starts from (the
|
|
20
|
+
branch itself, when you rework an existing one):
|
|
21
|
+
|
|
22
|
+
git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
|
|
23
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
|
|
24
|
+
git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
|
|
25
|
+
|
|
26
|
+
- This starts from the remote's current state and moves none of the clone's
|
|
27
|
+
refs. Never start from a local branch of the clone, which may be stale.
|
|
28
|
+
- Name `<branch>` by the repository's own rules, else `agents/<instance>-<purpose>`.
|
|
29
|
+
If it already exists in that clone, `switch -c` refuses: use `<instance>/<branch>`.
|
|
30
|
+
Never `-C`/`-B`, which reset a branch someone else may own.
|
|
31
|
+
- The tree has no upstream: push with `git push origin HEAD:<remote-branch>`
|
|
32
|
+
(`<base>` when you rework an existing branch).
|
|
33
|
+
- Before your task closes, merge each extra tree into your PR branch, or push its
|
|
34
|
+
branch and name it in your hand-back; then
|
|
35
|
+
`git -C <clone> worktree remove "$OATS_INSTANCE_HOME/.work-<purpose>"`.
|
|
36
|
+
Retirement keeps a tree that still holds uncommitted work, but don't rely on it.
|
package/lib/core.mjs
CHANGED
|
@@ -71,7 +71,7 @@ export { exactTreeDigest, fingerprintTree, KERNEL_HOME_RECEIPTS };
|
|
|
71
71
|
import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
|
|
72
72
|
import { readPortableBytes } from "./bounded-read.mjs";
|
|
73
73
|
import { copyTreeSafe } from "./tree-copy.mjs";
|
|
74
|
-
import { assertSameWorktreeHead, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
|
|
74
|
+
import { assertSameWorktreeHead, gitRead, gitRepoRead, gitRepoRun, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
|
|
75
75
|
/** The package and capability id grammar (namespaced, lowercase): an id names a
|
|
76
76
|
* directory (a home's module copy), so no path spelling fits it. */
|
|
77
77
|
const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -5141,18 +5141,123 @@ function baselineDisposableHome(baseline) {
|
|
|
5141
5141
|
* that pass all use this one set. → { excludeRoot: Set (work/ and the covered
|
|
5142
5142
|
* names), notCopied: [{ scope: "home", path, owner }] sorted by path }: names
|
|
5143
5143
|
* and owners only. Two owners of one entry: the first in owner order. */
|
|
5144
|
-
function resolveHomeExclusions(home, disposableHome = []) {
|
|
5144
|
+
function resolveHomeExclusions(home, disposableHome = [], extraTrees = []) {
|
|
5145
5145
|
const excludeRoot = new Set(["work"]);
|
|
5146
5146
|
const notCopied = [];
|
|
5147
|
-
|
|
5147
|
+
const extra = new Set(extraTrees.map((t) => t.name));
|
|
5148
|
+
if (!disposableHome.length && !extra.size) return { excludeRoot, notCopied };
|
|
5148
5149
|
for (const name of readdirSync(home).sort(byCodeUnit)) {
|
|
5149
|
-
const
|
|
5150
|
-
if (!
|
|
5150
|
+
const owner = extra.has(name) ? EXTRA_WORKTREE_OWNER : name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name))?.owner;
|
|
5151
|
+
if (!owner) continue;
|
|
5151
5152
|
excludeRoot.add(name);
|
|
5152
|
-
notCopied.push({ scope: "home", path: name, owner
|
|
5153
|
+
notCopied.push({ scope: "home", path: name, owner });
|
|
5153
5154
|
}
|
|
5154
5155
|
return { excludeRoot, notCopied };
|
|
5155
5156
|
}
|
|
5157
|
+
|
|
5158
|
+
/** The `notCopied` owner of a verified extra tree: the retire's extra-tree
|
|
5159
|
+
* step handles it, not the home recovery. */
|
|
5160
|
+
const EXTRA_WORKTREE_OWNER = "kernel:extra-worktree";
|
|
5161
|
+
/** `git worktree list --porcelain -z` → [{ worktree, locked }]: `locked` is
|
|
5162
|
+
* the lock's reason ("" when it gives none), undefined when not locked. */
|
|
5163
|
+
function parseWorktreeList(out) {
|
|
5164
|
+
const records = [];
|
|
5165
|
+
for (const field of out.split("\0")) {
|
|
5166
|
+
if (field.startsWith("worktree ")) records.push({ worktree: field.slice("worktree ".length) });
|
|
5167
|
+
else if (records.length && (field === "locked" || field.startsWith("locked "))) records.at(-1).locked = field.slice("locked ".length);
|
|
5168
|
+
}
|
|
5169
|
+
return records;
|
|
5170
|
+
}
|
|
5171
|
+
/** The home's extra trees (awebai/oats#674), in every work mode: top-level
|
|
5172
|
+
* entries named `.work-*` that are real directories (not symlinks), whose
|
|
5173
|
+
* `.git` is a regular file, and that Git confirms are registered linked
|
|
5174
|
+
* worktrees of a repository outside the home: the git dir differs from the
|
|
5175
|
+
* common dir, the toplevel is the entry, and the repository's worktree list
|
|
5176
|
+
* names it. The repository is that list's first entry (its main worktree, or
|
|
5177
|
+
* the bare dir), as for the primary checkout (canonicalDeploymentPath).
|
|
5178
|
+
* Anything else named `.work-*` is ordinary home bytes. Read-only probes
|
|
5179
|
+
* (gitRead). → [{ name, path, repo, gitDir, commonDir, locked }] sorted by
|
|
5180
|
+
* name: `commonDir` is the repository's Git directory, which the step and
|
|
5181
|
+
* the reachability read name it by (bare or not). */
|
|
5182
|
+
function extraWorktreesOf(home) {
|
|
5183
|
+
const trees = [];
|
|
5184
|
+
let names;
|
|
5185
|
+
try { names = readdirSync(home); } catch { return trees; }
|
|
5186
|
+
const realHome = realPathOrNearest(home);
|
|
5187
|
+
const inHome = (p) => { const rel = relative(realHome, realPathOrNearest(p)); return rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)); };
|
|
5188
|
+
for (const name of names.sort(byCodeUnit)) {
|
|
5189
|
+
if (!name.startsWith(".work-")) continue;
|
|
5190
|
+
const path = join(home, name);
|
|
5191
|
+
try {
|
|
5192
|
+
if (!lstatSync(path).isDirectory() || !lstatSync(join(path, ".git")).isFile()) continue;
|
|
5193
|
+
} catch { continue; }
|
|
5194
|
+
const dirs = gitRead(path, ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-toplevel"]);
|
|
5195
|
+
if (!dirs.ok) continue;
|
|
5196
|
+
const [gitDir, commonDir, toplevel] = dirs.out.split("\n");
|
|
5197
|
+
if (!gitDir || !commonDir || !toplevel || realPathOrNearest(gitDir) === realPathOrNearest(commonDir)) continue;
|
|
5198
|
+
const real = realPathOrNearest(path);
|
|
5199
|
+
// A repository inside the home goes with the home: its trees are home bytes.
|
|
5200
|
+
if (realPathOrNearest(toplevel) !== real || inHome(commonDir)) continue;
|
|
5201
|
+
const list = gitRead(path, ["worktree", "list", "--porcelain", "-z"]);
|
|
5202
|
+
if (!list.ok) continue;
|
|
5203
|
+
const records = parseWorktreeList(list.out);
|
|
5204
|
+
const self = records.find((r) => realPathOrNearest(r.worktree) === real);
|
|
5205
|
+
if (!records.length || !self) continue;
|
|
5206
|
+
trees.push({ name, path, repo: records[0].worktree, gitDir, commonDir: realPathOrNearest(commonDir), locked: self.locked });
|
|
5207
|
+
}
|
|
5208
|
+
return trees;
|
|
5209
|
+
}
|
|
5210
|
+
/** Where a retired worktree is re-homed: `<workspace>/.agents/worktrees/<repoName>/<leaf>`,
|
|
5211
|
+
* the leaf being the branch, else `detached-<commit>`; when that exists (or
|
|
5212
|
+
* `taken` holds it), `<leaf>-2`, `-3`, … The one naming rule of work/ and of
|
|
5213
|
+
* the extra trees. Creates nothing. */
|
|
5214
|
+
function retainedWorktreeDest(workspace, repo, branch, commit, taken = new Set()) {
|
|
5215
|
+
const repoName = basename(realPathOrNearest(repo)).replace(/\.git$/, "") || "repo";
|
|
5216
|
+
const leaf = (branch ?? `detached-${(commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
|
|
5217
|
+
const retainedRoot = join(workspace, ".agents", "worktrees", repoName);
|
|
5218
|
+
let dest = join(retainedRoot, leaf);
|
|
5219
|
+
for (let n = 2; existsSync(dest) || taken.has(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
|
|
5220
|
+
taken.add(dest);
|
|
5221
|
+
return dest;
|
|
5222
|
+
}
|
|
5223
|
+
/** What retirement does with each extra tree (`trees`: extraWorktreesOf) →
|
|
5224
|
+
* [{ path, repo, branch, detachedAt, disposition, movedTo, reason }], one row
|
|
5225
|
+
* per tree in order: the retire plan's `extraWorktrees` and the binding the
|
|
5226
|
+
* retire checks before it acts. `remove` when the tree is clean: an empty
|
|
5227
|
+
* status (ignored and untracked files count), no operation in progress, and
|
|
5228
|
+
* a HEAD commit some ref of the repository reaches: its shared refs, never
|
|
5229
|
+
* the tree's own HEAD, reflog or refs/worktree/, which go with its admin
|
|
5230
|
+
* entry when it is removed. `retain` (to `movedTo`) otherwise, with why
|
|
5231
|
+
* in `reason`; a HEAD that cannot be read is not clean. `refuse` for a locked
|
|
5232
|
+
* tree, which Git will neither move nor remove. Read-only. */
|
|
5233
|
+
function extraWorktreeRows(trees, root) {
|
|
5234
|
+
const taken = new Set();
|
|
5235
|
+
return trees.map((tree) => {
|
|
5236
|
+
let name = null, commit = null;
|
|
5237
|
+
try { name = headName(tree.path); } catch { /* not clean, below */ }
|
|
5238
|
+
try { commit = worktreeHead(tree.path).commit; } catch { /* not clean, below */ }
|
|
5239
|
+
const row = { path: tree.path, repo: tree.repo, branch: name?.branch ?? null, detachedAt: name?.detached ? commit : null, disposition: "remove", movedTo: null, reason: null };
|
|
5240
|
+
if (tree.locked !== undefined) return { ...row, disposition: "refuse", reason: `it is locked${tree.locked ? ` (${tree.locked})` : ""}; unlock it with \`git worktree unlock\`, or move it out of the home, then retire again` };
|
|
5241
|
+
const why = [];
|
|
5242
|
+
if (!name || !commit) why.push("its HEAD could not be read");
|
|
5243
|
+
const status = gitRead(tree.path, ["status", "--porcelain", "-z", "--ignored", "--untracked-files=all", "--ignore-submodules=none"]);
|
|
5244
|
+
if (!status.ok) why.push(`its status could not be read (${status.err})`);
|
|
5245
|
+
else if (status.out.length) why.push("it holds uncommitted, untracked or ignored files");
|
|
5246
|
+
if (RECOVERABLE_GIT_ADMIN.some((n) => existsSync(join(tree.gitDir, n)))) why.push("an operation is in progress in it");
|
|
5247
|
+
if (commit) {
|
|
5248
|
+
const reach = gitRepoRead(tree.commonDir, ["for-each-ref", "--contains", commit, "--count=1", "--format=%(objectname)"]);
|
|
5249
|
+
if (!reach.ok) why.push(`which refs reach its HEAD commit could not be read (${reach.err})`);
|
|
5250
|
+
else if (!reach.out.length) why.push("its HEAD commit is reached by no ref");
|
|
5251
|
+
}
|
|
5252
|
+
if (!why.length) return row;
|
|
5253
|
+
return { ...row, disposition: "retain", movedTo: retainedWorktreeDest(workspaceOf(root), tree.repo, row.branch, commit, taken), reason: why.join("; ") };
|
|
5254
|
+
});
|
|
5255
|
+
}
|
|
5256
|
+
/** The retire plan's view of a home's extra trees: extraWorktreeRows of what
|
|
5257
|
+
* is there now. */
|
|
5258
|
+
export function extraWorktreePlan(home, root) {
|
|
5259
|
+
return extraWorktreeRows(extraWorktreesOf(home), root);
|
|
5260
|
+
}
|
|
5156
5261
|
function unionNotCopied(...lists) {
|
|
5157
5262
|
const byPath = new Map();
|
|
5158
5263
|
for (const row of lists.flatMap((list) => list || [])) if (!byPath.has(row.path)) byPath.set(row.path, row);
|
|
@@ -6414,9 +6519,11 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
|
|
|
6414
6519
|
}
|
|
6415
6520
|
const baselineValid = retirementBaselineValid(baseline, home);
|
|
6416
6521
|
// This pass's one resolved exclusion set: provider-owned entries the spawn
|
|
6417
|
-
// baseline declared
|
|
6418
|
-
//
|
|
6419
|
-
|
|
6522
|
+
// baseline declared, and the home's verified extra trees (whatever the
|
|
6523
|
+
// baseline), which the retire's extra-tree step handles from this same set.
|
|
6524
|
+
// Every home fingerprint here and the copy made from this observation use it.
|
|
6525
|
+
const extraTrees = extraWorktreesOf(home);
|
|
6526
|
+
const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : [], extraTrees);
|
|
6420
6527
|
// One walk of the home, two digests: the stored one for the comparison with
|
|
6421
6528
|
// the baseline, which must not see the kernel's own fields of instance.json,
|
|
6422
6529
|
// and the exact one for the comparison after the hooks, which must see
|
|
@@ -6504,7 +6611,7 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
|
|
|
6504
6611
|
.update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
|
|
6505
6612
|
.update(unreached?.head.ref ?? "")
|
|
6506
6613
|
.digest("hex");
|
|
6507
|
-
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
6614
|
+
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, extraTrees, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
6508
6615
|
}
|
|
6509
6616
|
|
|
6510
6617
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -7020,7 +7127,8 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
7020
7127
|
const intent = {
|
|
7021
7128
|
instance: name, agent: found.agent.name, root: resolve(root),
|
|
7022
7129
|
requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
|
|
7023
|
-
|
|
7130
|
+
// A confirmed plan's extra trees go with the intent, so the completion is bound by them as well.
|
|
7131
|
+
options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session, ...(Array.isArray(o.plannedExtraWorktrees) ? { plannedExtraWorktrees: o.plannedExtraWorktrees } : {}) }, resultPath,
|
|
7024
7132
|
};
|
|
7025
7133
|
const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
|
|
7026
7134
|
for (const k of CORE_LAUNCH_ENV) delete env[k];
|
|
@@ -7098,6 +7206,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
|
7098
7206
|
try {
|
|
7099
7207
|
result = retireInstance(intent.root, intent.instance, {
|
|
7100
7208
|
home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
7209
|
+
...(Array.isArray(intent.options?.plannedExtraWorktrees) ? { plannedExtraWorktrees: intent.options.plannedExtraWorktrees } : {}),
|
|
7101
7210
|
...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
|
|
7102
7211
|
});
|
|
7103
7212
|
} catch (e) {
|
|
@@ -7241,6 +7350,16 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7241
7350
|
// Whether this retire removes the worktree, when it gets to that step.
|
|
7242
7351
|
const worktreeRemoval = { removes: !!(o.discardWorktree || owesWorktree), repo: meta.repo };
|
|
7243
7352
|
const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
7353
|
+
// A locked extra tree is known from the home as it is now: Git will neither move nor remove it, so a retire that
|
|
7354
|
+
// would remove the home refuses here, before the session is stopped and before any retire hook runs (the hooks
|
|
7355
|
+
// revoke identities a kept home would still need). The extra-tree step keeps its own check, for a lock that appears
|
|
7356
|
+
// during the hooks. --force does not bypass it: it covers hook debt, not local work.
|
|
7357
|
+
if (!o.keepDir) {
|
|
7358
|
+
const locked = initialObservation.extraTrees.filter((t) => t.locked !== undefined);
|
|
7359
|
+
if (locked.length) {
|
|
7360
|
+
throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${locked.map((t) => `the extra worktree ${t.path} is locked${t.locked ? ` (${t.locked})` : ""}`).join("; ")}; Git will neither move nor remove a locked worktree. Unlock it with \`git worktree unlock\`, or move it out of the home, then retire again; nothing was run or removed`);
|
|
7361
|
+
}
|
|
7362
|
+
}
|
|
7244
7363
|
// Harness identity is destructive authority. The mutable child metadata may
|
|
7245
7364
|
// describe it for humans, but only the independent baseline can authorize the
|
|
7246
7365
|
// endpoint that proves quiescence.
|
|
@@ -7484,6 +7603,54 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7484
7603
|
// A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
|
|
7485
7604
|
// --discard-worktree. An orphaned work directory is never touched.
|
|
7486
7605
|
const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
|
|
7606
|
+
// The home's extra trees (awebai/oats#674), in every work mode, before the
|
|
7607
|
+
// work/ step so that a refusal here leaves work/ as it is. Only when the
|
|
7608
|
+
// home is going to be removed: not under --keep-dir, and not when it is
|
|
7609
|
+
// kept for a retry (the work/ step's condition). The trees are the final
|
|
7610
|
+
// inspection's verified set, the one its recovery left out of the home's
|
|
7611
|
+
// bytes. A clean tree is removed (its branch stays in its repository); any
|
|
7612
|
+
// other is re-homed like work/; a locked one, or one Git will not move or
|
|
7613
|
+
// remove, refuses and keeps the home, --force included: it covers hook
|
|
7614
|
+
// debt, not local work. --discard-worktree does not apply. An applied plan
|
|
7615
|
+
// (`plannedExtraWorktrees`) binds what is done: trees that no longer read
|
|
7616
|
+
// as planned refuse as stale before any of them is touched.
|
|
7617
|
+
let extraWorktrees;
|
|
7618
|
+
if (!o.keepDir && !(outstandingBeforeWorktree.length > 0 && !o.force)) {
|
|
7619
|
+
const rows = extraWorktreeRows(finalObservation.extraTrees, root);
|
|
7620
|
+
if (o.plannedExtraWorktrees && JSON.stringify(rows) !== JSON.stringify(o.plannedExtraWorktrees)) {
|
|
7621
|
+
throw oatsError("E_PLAN_STALE", `${name}: the home's extra worktrees changed since the retire plan was shown, so none of them was moved or removed. The retire hooks have run; the home and its work are kept. Review the fresh plan (\`oats retire ${name} --plan\`) and apply it again.`);
|
|
7622
|
+
}
|
|
7623
|
+
const refused = rows.filter((r) => r.disposition === "refuse");
|
|
7624
|
+
if (refused.length) {
|
|
7625
|
+
throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${refused.map((r) => `the extra worktree ${r.path}: ${r.reason}`).join("; ")}. No extra worktree was moved or removed and work/ is untouched; the retire hooks have run and the home is kept so nothing is lost.`);
|
|
7626
|
+
}
|
|
7627
|
+
extraWorktrees = [];
|
|
7628
|
+
const done = () => extraWorktrees.length ? ` Already done in this retire: ${extraWorktrees.map((r) => r.outcome === "removed" ? `${r.path} removed` : `${r.path} re-homed to ${r.movedTo}`).join(", ")}.` : "";
|
|
7629
|
+
// Each row's repository, by its Git directory: the commands run helper-free (gitRepoRun).
|
|
7630
|
+
const gitDirOf = new Map(finalObservation.extraTrees.map((tree) => [tree.path, tree.commonDir]));
|
|
7631
|
+
const git = (row, argv) => gitRepoRun(gitDirOf.get(row.path), argv);
|
|
7632
|
+
const failed = (row, what, e) => oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the extra worktree ${row.path} ${what} (${String(e?.stderr ?? e?.message ?? e ?? "").trim()}).${done()} work/ is untouched; the retire hooks have run and the home is kept so nothing is lost — resolve and retry.`);
|
|
7633
|
+
for (const row of rows) {
|
|
7634
|
+
if (row.disposition === "remove") {
|
|
7635
|
+
try {
|
|
7636
|
+
git(row, ["worktree", "remove", row.path]);
|
|
7637
|
+
git(row, ["worktree", "prune"]);
|
|
7638
|
+
const real = realPathOrNearest(row.path);
|
|
7639
|
+
if (existsSync(row.path) || parseWorktreeList(git(row, ["worktree", "list", "--porcelain", "-z"])).some((r) => realPathOrNearest(r.worktree) === real)) throw new Error("it is still there after `git worktree remove`");
|
|
7640
|
+
} catch (e) { throw failed(row, "could not be removed, or its removal could not be verified", e); }
|
|
7641
|
+
extraWorktrees.push({ ...row, outcome: "removed" });
|
|
7642
|
+
appendEvent(found.home, { kind: "worktree-removed", data: { branch: row.branch, extra: true, path: row.path } }, { workspaceOnly: true });
|
|
7643
|
+
} else {
|
|
7644
|
+
try {
|
|
7645
|
+
mkdirSync(dirname(row.movedTo), { recursive: true });
|
|
7646
|
+
git(row, ["worktree", "move", row.path, row.movedTo]);
|
|
7647
|
+
} catch (e) { throw failed(row, `could not be re-homed to ${row.movedTo}`, e); }
|
|
7648
|
+
extraWorktrees.push({ ...row, outcome: "retained" });
|
|
7649
|
+
appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: row.movedTo, branch: row.branch, recordedBranch: null, extra: true, path: row.path } }, { workspaceOnly: true });
|
|
7650
|
+
}
|
|
7651
|
+
}
|
|
7652
|
+
if (!extraWorktrees.length) extraWorktrees = undefined;
|
|
7653
|
+
}
|
|
7487
7654
|
const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
|
|
7488
7655
|
const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
|
|
7489
7656
|
const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
|
|
@@ -7507,12 +7674,8 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7507
7674
|
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
7508
7675
|
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
7509
7676
|
} else if (existsSync(workPath)) {
|
|
7510
|
-
const
|
|
7511
|
-
|
|
7512
|
-
const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
|
|
7513
|
-
mkdirSync(retainedRoot, { recursive: true });
|
|
7514
|
-
let dest = join(retainedRoot, leaf);
|
|
7515
|
-
for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
|
|
7677
|
+
const dest = retainedWorktreeDest(workspaceOf(root), meta.repo, verifiedBranch, ref.commit);
|
|
7678
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
7516
7679
|
try {
|
|
7517
7680
|
execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
7518
7681
|
} catch (e) {
|
|
@@ -7623,7 +7786,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7623
7786
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
7624
7787
|
}
|
|
7625
7788
|
|
|
7626
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
|
|
7789
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, retention, extraWorktrees, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
|
|
7627
7790
|
const w = [...(hookResults?.warnings || [])];
|
|
7628
7791
|
if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
|
|
7629
7792
|
if (isCapturedHome(meta) && !quarantine) {
|
package/lib/instance-git.mjs
CHANGED
|
@@ -43,6 +43,28 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
const trim = (s) => (s === null ? null : s.trim());
|
|
46
|
+
/** One helper-free Git probe run like the module's other reads
|
|
47
|
+
* (READ_ONLY_GIT, gitEnv, argv, no shell) → { ok, out, err }. `out` is text.
|
|
48
|
+
* Never throws: the caller decides what a failure means. `cwd`: the tree
|
|
49
|
+
* `-C` names; gitRepoRead names the repository by its Git directory instead. */
|
|
50
|
+
export function gitRead(cwd, argv) { return gitProbe(["-C", cwd], argv); }
|
|
51
|
+
/** gitRead against the repository whose (common) Git directory is `gitDir`,
|
|
52
|
+
* named explicitly (`--git-dir`), so that a bare repository is read too
|
|
53
|
+
* (READ_ONLY_GIT sets safe.bareRepository=explicit). */
|
|
54
|
+
export function gitRepoRead(gitDir, argv) { return gitProbe(["--git-dir", gitDir], argv); }
|
|
55
|
+
/** A Git command that changes the repository whose Git directory is `gitDir`
|
|
56
|
+
* (a worktree move, remove or prune), under the same discipline as the
|
|
57
|
+
* reads: the repository's own configuration names no helper that runs
|
|
58
|
+
* (fsmonitor, hooks, external diff; they reach the commands Git starts for
|
|
59
|
+
* it through GIT_CONFIG_PARAMETERS), and nothing of the caller's Git
|
|
60
|
+
* environment is passed. → stdout; throws the error execFileSync throws. */
|
|
61
|
+
export function gitRepoRun(gitDir, argv) {
|
|
62
|
+
return execFileSync("git", ["--git-dir", gitDir, ...READ_ONLY_GIT, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 120_000, env: gitEnv() });
|
|
63
|
+
}
|
|
64
|
+
function gitProbe(where, argv) {
|
|
65
|
+
const r = spawnSync("git", [...where, ...READ_ONLY_GIT, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
|
|
66
|
+
return !r.error && r.status === 0 ? { ok: true, out: r.stdout } : { ok: false, out: "", err: String(r.error?.message ?? r.stderr ?? "").trim() || `git ${argv[0]} exited with ${r.status}` };
|
|
67
|
+
}
|
|
46
68
|
|
|
47
69
|
const BRANCH_REFS = Buffer.from("refs/heads/");
|
|
48
70
|
/** The two reads of HEAD, as bytes. They run like the module's other reads:
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { basename, join } from "node:path";
|
|
13
|
-
import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
|
|
13
|
+
import { extraWorktreePlan, findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
|
|
14
14
|
import { groupedByOwner } from "./retire-output.mjs";
|
|
15
15
|
import { observeInstanceGit } from "./instance-git.mjs";
|
|
16
16
|
import { appendEvent } from "./instance-events.mjs";
|
|
@@ -165,6 +165,13 @@ function writeFileSyncAtomic(path, value) {
|
|
|
165
165
|
writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** A retire plan's note for one extra tree (core extraWorktreePlan row). */
|
|
169
|
+
function extraWorktreeNote(t) {
|
|
170
|
+
const at = t.branch ? `branch ${t.branch}` : t.detachedAt ? `detached at ${t.detachedAt.slice(0, 12)}` : "a HEAD that could not be read";
|
|
171
|
+
if (t.disposition === "remove") return `extra worktree ${t.path} (${at}) is clean and would be removed; its branch and commits stay in ${t.repo}`;
|
|
172
|
+
if (t.disposition === "retain") return `extra worktree ${t.path} (${at}) would be re-homed to ${t.movedTo}: ${t.reason}`;
|
|
173
|
+
return `extra worktree ${t.path} (${at}): retire refuses, ${t.reason}`;
|
|
174
|
+
}
|
|
168
175
|
/** How many declared roots a retire plan's recovery note lists before `, and N more`. */
|
|
169
176
|
const PLAN_NOT_COPIED_CAP = 16;
|
|
170
177
|
/** Facts for Remove (retire): what retirement would touch, with the design's
|
|
@@ -178,12 +185,19 @@ export function planRetire(ctx, root, name, { home } = {}) {
|
|
|
178
185
|
const target = targetFacts({ ...me, instance: name, depth: 0 });
|
|
179
186
|
target.session = retireSessionFacts(me.home, target.session);
|
|
180
187
|
const meta = readJson(join(me.home, "instance.json")) || {};
|
|
188
|
+
// The home's extra trees and what retire does with each: listed, and bound
|
|
189
|
+
// into the revision (below), so that an apply refuses as stale when a tree
|
|
190
|
+
// was created, removed, dirtied or cleaned, or its target was taken.
|
|
191
|
+
const extraWorktrees = extraWorktreePlan(me.home, me.root);
|
|
181
192
|
const facts = { session: target.session, work: target.work, workMode: meta.work ?? null, repo: meta.repo ?? null, recordedBranch: meta.branch ?? null,
|
|
193
|
+
extraWorktrees,
|
|
182
194
|
children: kids.map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, session: sessionFacts(c.home) })),
|
|
183
195
|
ambiguous: (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason })),
|
|
184
196
|
pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
|
|
185
197
|
const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
|
|
186
|
-
const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)
|
|
198
|
+
const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home),
|
|
199
|
+
// Only when there are any: a home without extra trees keeps the revision it had.
|
|
200
|
+
...(extraWorktrees.length ? [extraWorktrees] : [])];
|
|
187
201
|
// What retire would preserve and where, read from the spawn baseline and the
|
|
188
202
|
// work mode: nothing is hashed, and the plan revision does not depend on it.
|
|
189
203
|
// The declared roots are listed as written, capped so one note stays short.
|
|
@@ -195,6 +209,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
|
|
|
195
209
|
notes: [
|
|
196
210
|
...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
|
|
197
211
|
...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
|
|
212
|
+
...extraWorktrees.map(extraWorktreeNote),
|
|
198
213
|
...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
|
|
199
214
|
...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
|
|
200
215
|
...(target.session.note ? [target.session.established ? `session absent: ${target.session.note}; nothing needs quiescing` : `session not observably absent: ${target.session.note}; retire refuses until it is stopped`] : []),
|
package/lib/retire-output.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** What `oats retire` says about the work it preserved. One function renders
|
|
2
2
|
* the lines for the local and the remote path of bin/oats.mjs, from the
|
|
3
|
-
* receipt's `workRecovery` (lib/core.mjs, retireInstance)
|
|
3
|
+
* receipt's `workRecovery` (lib/core.mjs, retireInstance); another renders
|
|
4
|
+
* its `extraWorktrees`. A receipt from an
|
|
4
5
|
* older kernel, or a stored one, may carry `workRecoveries[]` instead: one
|
|
5
6
|
* block is printed per entry. Dependency-free. */
|
|
6
7
|
|
|
@@ -48,3 +49,15 @@ export function workRecoveryLines(receipt, { host } = {}) {
|
|
|
48
49
|
}
|
|
49
50
|
return lines;
|
|
50
51
|
}
|
|
52
|
+
|
|
53
|
+
/** The lines `oats retire` prints for the home's extra trees it handled
|
|
54
|
+
* (the receipt's `extraWorktrees`): one per tree, removed or re-homed. */
|
|
55
|
+
export function extraWorktreeLines(receipt, { host } = {}) {
|
|
56
|
+
const rows = Array.isArray(receipt?.extraWorktrees) ? receipt.extraWorktrees : [];
|
|
57
|
+
return rows.map((t) => {
|
|
58
|
+
const at = t.branch ? `branch ${t.branch}` : t.detachedAt ? `detached at ${t.detachedAt}` : "a HEAD that could not be read";
|
|
59
|
+
return t.outcome === "removed"
|
|
60
|
+
? `Extra worktree ${t.path} removed (${at}); its branch and commits stay in ${t.repo}${host ? ` on ${host}` : ""}`
|
|
61
|
+
: `Extra worktree ${t.path} re-homed${host ? ` on ${host}` : ""} to ${t.movedTo} (${at}): ${t.reason}`;
|
|
62
|
+
});
|
|
63
|
+
}
|
package/lib/servers.mjs
CHANGED
|
@@ -783,8 +783,11 @@ function listSnapshotServers() {
|
|
|
783
783
|
* action authority. */
|
|
784
784
|
/** The facts a remote row relays from the host's `status --json` row, as the
|
|
785
785
|
* host reported them; a fact the host does not supply is null, never
|
|
786
|
-
* derived here.
|
|
787
|
-
|
|
786
|
+
* derived here. `work`, `repo`, `branch` and `modelFrom` are the host's own
|
|
787
|
+
* record (`repo` is a path on the host, never read here); `soul` and `modules`
|
|
788
|
+
* carry the host's drift observation against its own members and lock, not
|
|
789
|
+
* recomputed here (#675). */
|
|
790
|
+
export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin", "work", "repo", "branch", "modelFrom", "soul", "modules"];
|
|
788
791
|
const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
|
|
789
792
|
/** `waitingOnYou` (feature waiting-on-you) is relayed ONLY when the host's row has the
|
|
790
793
|
* key, so it is not one of REMOTE_ROW_FACTS: a host whose kernel predates the feature
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.1",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|