@henryqw/pi-subagent 27.4.5 → 27.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/discard.d.ts +51 -0
- package/dist/discard.js +224 -0
- package/dist/runner.d.ts +23 -1
- package/dist/runner.js +146 -18
- package/dist/schema.d.ts +3 -3
- package/dist/schema.js +4 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.js +87 -1
- package/docs/orchestration.md +16 -0
- package/extensions/isolated.ts +22 -2
- package/extensions/subagent-command.ts +53 -7
- package/extensions/subagent.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Commands and UI are for you. Tools and the packaged skill are for Main, except `
|
|
|
29
29
|
|
|
30
30
|
| Surface | Type | Purpose |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `/subagent` | command | Browse direct and isolated work
|
|
32
|
+
| `/subagent` | command | Browse direct and isolated work, including requests whose parent checkout was removed. Stop direct workers, inspect isolated work, send follow-ups, or delete owned isolated worktrees after confirmation. Without interactive UI, only reports. |
|
|
33
33
|
| `/subagent roles` | command | List Role sources, resources, resolved model and thinking level, route errors, and file errors. Read-only; requires no Herdr pane. |
|
|
34
34
|
| `/subagent prune` | command | Review remaining worker and integration branches and worktrees. Remove merged resources after confirmation. Requires a running Herdr server; without interactive UI, only reports. |
|
|
35
35
|
| `/subagent recover` | command | Classify orphaned isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
|
|
@@ -131,6 +131,10 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
|
|
|
131
131
|
|
|
132
132
|
Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. **Refresh** reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
|
|
133
133
|
|
|
134
|
+
Requests whose parent checkout was removed show as **Isolated · orphaned** from any checkout of the same repository. Select **Close and delete worktrees (discard work)** to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
|
|
135
|
+
|
|
136
|
+
Only shown resources can be removed; live jobs, uncertain ownership, or other Herdr panes can block removal. Agents and tools cannot trigger this action. See [Close and delete isolated worktrees](./docs/orchestration.md#close-and-delete-isolated-worktrees) for guards and partial cleanup.
|
|
137
|
+
|
|
134
138
|
Inspection needs a Herdr pane, the configured `scout` Role, and the `fast` route. If it cannot start, the request stays unchanged; Main can use `subagent_status` to read state. See [Inspect and revise active work](./docs/orchestration.md#inspect-and-revise-active-work) for inspection and follow-up details.
|
|
135
139
|
|
|
136
140
|
See [Status widgets](./docs/orchestration.md#status-widgets) for reference numbering, token counts, and activity metrics, and [Choose a delegation mode](./docs/orchestration.md#choose-a-delegation-mode) for routing guidance.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type GitRunner } from "./git-process.ts";
|
|
2
|
+
import type { ChangesetTaskAttempt, ChangesetTaskState, IntegrationGeneration, RunState, TextTaskAttempt } from "./schema.ts";
|
|
3
|
+
import type { RunStateHandle } from "./store.ts";
|
|
4
|
+
import { type WorktreeInfo } from "./worktree.ts";
|
|
5
|
+
import type { PruneGuards } from "./prune.ts";
|
|
6
|
+
export interface DiscardItem {
|
|
7
|
+
key: string;
|
|
8
|
+
identity: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
branch?: string;
|
|
11
|
+
tip?: string;
|
|
12
|
+
checkout: boolean;
|
|
13
|
+
commits?: number;
|
|
14
|
+
dirty?: boolean;
|
|
15
|
+
ignored: string[];
|
|
16
|
+
hosts: string[];
|
|
17
|
+
blockers: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface DiscardPlan {
|
|
20
|
+
root: string;
|
|
21
|
+
directory: string;
|
|
22
|
+
requestId: string;
|
|
23
|
+
items: DiscardItem[];
|
|
24
|
+
}
|
|
25
|
+
export interface DiscardGuards {
|
|
26
|
+
panes: PruneGuards["panes"];
|
|
27
|
+
blocked(): Promise<string | undefined>;
|
|
28
|
+
current(): boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface DiscardResource {
|
|
31
|
+
key: string;
|
|
32
|
+
worktree?: Omit<WorktreeInfo, "baseCommit"> & {
|
|
33
|
+
baseCommit?: string;
|
|
34
|
+
};
|
|
35
|
+
textAttempt?: TextTaskAttempt;
|
|
36
|
+
task?: ChangesetTaskState;
|
|
37
|
+
attempt?: ChangesetTaskAttempt;
|
|
38
|
+
generation?: IntegrationGeneration;
|
|
39
|
+
}
|
|
40
|
+
export type DiscardRemoval = {
|
|
41
|
+
key: string;
|
|
42
|
+
outcome: "removed" | "skipped" | "failed";
|
|
43
|
+
reason: string;
|
|
44
|
+
};
|
|
45
|
+
export declare function discardResources(state: RunState): DiscardResource[];
|
|
46
|
+
export declare function primaryCheckout(root: string, run?: GitRunner): Promise<string>;
|
|
47
|
+
/** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
|
|
48
|
+
export declare function inspectDiscardResource(primary: string, mainCwd: string, resource: DiscardResource, guards: DiscardGuards, run?: GitRunner): Promise<DiscardItem>;
|
|
49
|
+
export declare function inspectDiscard(primary: string, mainCwd: string, state: RunState, guards: DiscardGuards): Promise<DiscardItem[]>;
|
|
50
|
+
/** Called only after the command's confirmation. Never exposed as an agent tool. */
|
|
51
|
+
export declare function removeDiscardResources(handle: RunStateHandle, primary: string, mainCwd: string, reviewed: readonly DiscardItem[], guards: DiscardGuards, close: (resource: DiscardResource, check: () => Promise<void>) => Promise<void>, run?: GitRunner): Promise<DiscardRemoval[]>;
|
package/dist/discard.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
3
|
+
import { runGit } from "./git-process.js";
|
|
4
|
+
import { inspectIndexFlags, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
5
|
+
export function discardResources(state) {
|
|
6
|
+
return [
|
|
7
|
+
...state.tasks.flatMap((task) => task.kind === "changeset" ? task.attempts.map((attempt, index) => ({
|
|
8
|
+
key: `task/${task.taskId}/${index + 1}`, task, attempt,
|
|
9
|
+
worktree: attempt.allocations.filter((item) => item.kind === "worktree").at(-1)?.worktree,
|
|
10
|
+
})) : task.attempts.flatMap((attempt) => attempt.cleanup ? [{
|
|
11
|
+
key: `task/${task.taskId}/${attempt.number}`, textAttempt: attempt,
|
|
12
|
+
worktree: { path: attempt.cleanup.path, cwd: attempt.cleanup.path, branch: attempt.cleanup.branch,
|
|
13
|
+
repoRoot: dirname(dirname(attempt.cleanup.path)) },
|
|
14
|
+
}] : [])),
|
|
15
|
+
...state.integration.generations.map((generation) => ({ key: `integration/${generation.number}`, generation, worktree: generation.worktree })),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
async function git(args, root, run) {
|
|
19
|
+
const result = await run(args, root);
|
|
20
|
+
if (result.code !== 0)
|
|
21
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr.trim().slice(0, 300)}`);
|
|
22
|
+
return result.stdout.trimEnd();
|
|
23
|
+
}
|
|
24
|
+
export async function primaryCheckout(root, run = runGit) {
|
|
25
|
+
const first = (await git(["worktree", "list", "--porcelain", "-z"], root, run)).split("\0")[0];
|
|
26
|
+
if (!first?.startsWith("worktree "))
|
|
27
|
+
throw new Error("Primary Git checkout is unavailable.");
|
|
28
|
+
return realpathSync.native(first.slice(9));
|
|
29
|
+
}
|
|
30
|
+
const within = (root, path) => path === root || path.startsWith(`${root}${sep}`);
|
|
31
|
+
const identity = (resource) => JSON.stringify([resource.worktree, resource.attempt?.allocations]);
|
|
32
|
+
/** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
|
|
33
|
+
export async function inspectDiscardResource(primary, mainCwd, resource, guards, run = runGit) {
|
|
34
|
+
const { worktree, attempt } = resource;
|
|
35
|
+
const allocations = attempt?.allocations ?? [];
|
|
36
|
+
const item = {
|
|
37
|
+
key: resource.key, identity: identity(resource), checkout: false, ignored: [], blockers: [],
|
|
38
|
+
hosts: allocations.filter((allocation) => allocation.kind !== "worktree").map((allocation) => JSON.stringify(allocation)),
|
|
39
|
+
};
|
|
40
|
+
const blocked = await guards.blocked();
|
|
41
|
+
if (blocked)
|
|
42
|
+
item.blockers.push(blocked);
|
|
43
|
+
if (allocations.some((allocation) => allocation.status === "allocating" || allocation.status === "unknown")) {
|
|
44
|
+
item.blockers.push("Allocation ownership is uncertain; inspect the recorded resources.");
|
|
45
|
+
}
|
|
46
|
+
if (!worktree)
|
|
47
|
+
return item;
|
|
48
|
+
item.path = worktree.path;
|
|
49
|
+
item.branch = worktree.branch;
|
|
50
|
+
if (!OWNED_BRANCH_PATTERN.test(worktree.branch) || worktree.repoRoot !== primary
|
|
51
|
+
|| worktree.path !== join(primary, WORKTREES_DIRNAME, basename(worktree.branch))) {
|
|
52
|
+
item.blockers.push("Checkout or branch is not at this repository's owned path.");
|
|
53
|
+
return item;
|
|
54
|
+
}
|
|
55
|
+
const ref = `refs/heads/${worktree.branch}`;
|
|
56
|
+
const branches = (await git(["for-each-ref", "--format=%(refname) %(objectname) %(symref)", ref], primary, run)).split("\n");
|
|
57
|
+
const branch = branches.find((line) => line.startsWith(`${ref} `))?.split(" ");
|
|
58
|
+
item.tip = branch?.[1];
|
|
59
|
+
if (branch?.[2])
|
|
60
|
+
item.blockers.push("Owned branch is a symbolic reference; inspect it manually.");
|
|
61
|
+
if (item.tip && worktree.baseCommit)
|
|
62
|
+
item.commits = Number(await git(["rev-list", "--count", `${worktree.baseCommit}..${item.tip}`], primary, run));
|
|
63
|
+
const records = (await git(["worktree", "list", "--porcelain", "-z"], primary, run)).split("\0\0");
|
|
64
|
+
const registered = records.filter((record) => record.split("\0").includes(`branch refs/heads/${worktree.branch}`));
|
|
65
|
+
const owned = records.find((record) => record.startsWith(`worktree ${worktree.path}\0`));
|
|
66
|
+
if (registered.some((record) => !record.startsWith(`worktree ${worktree.path}\0`)))
|
|
67
|
+
item.blockers.push("Branch is checked out at another path.");
|
|
68
|
+
item.checkout = existsSync(worktree.path);
|
|
69
|
+
const cleanup = attempt?.cleanup ?? resource.generation?.cleanup;
|
|
70
|
+
if (item.checkout && (resource.textAttempt?.cleanup?.outcome === "pruned" || cleanup?.some((step) => step.kind === "worktree" && step.status === "completed"))
|
|
71
|
+
|| item.tip && (resource.textAttempt?.cleanup?.outcome === "pruned" || cleanup?.some((step) => step.kind === "branch" && step.status === "completed"))) {
|
|
72
|
+
item.blockers.push("A resource exists after completed cleanup; new ownership is unproved.");
|
|
73
|
+
}
|
|
74
|
+
if (allocations.filter((allocation) => allocation.kind === "worktree").at(-1)?.status === "absent"
|
|
75
|
+
&& (item.checkout || item.tip))
|
|
76
|
+
item.blockers.push("Allocation was recorded absent; ownership of the existing resource is unproved.");
|
|
77
|
+
if (owned && !item.checkout)
|
|
78
|
+
item.blockers.push("Checkout registration remains but its directory is missing; inspect it manually.");
|
|
79
|
+
if (!item.checkout)
|
|
80
|
+
return item;
|
|
81
|
+
if (!owned || !owned.split("\0").includes(`branch refs/heads/${worktree.branch}`))
|
|
82
|
+
item.blockers.push("Checkout registration changed or checkout is detached.");
|
|
83
|
+
if (owned?.split("\0").some((field) => field === "locked" || field.startsWith("locked ")))
|
|
84
|
+
item.blockers.push("Checkout is locked.");
|
|
85
|
+
const real = realpathSync.native(worktree.path);
|
|
86
|
+
if (real !== worktree.path)
|
|
87
|
+
item.blockers.push("Owned checkout path is redirected.");
|
|
88
|
+
if (within(real, realpathSync.native(mainCwd)))
|
|
89
|
+
item.blockers.push("Main runs inside the checkout.");
|
|
90
|
+
const status = (await git(["--no-optional-locks", "status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal", "--ignore-submodules=none"], worktree.path, run)).split("\0").filter(Boolean);
|
|
91
|
+
item.ignored = status.filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
|
|
92
|
+
item.dirty = status.some((entry) => !entry.startsWith("!! "));
|
|
93
|
+
if (!item.dirty) {
|
|
94
|
+
const flags = await inspectIndexFlags(worktree.path, run);
|
|
95
|
+
if (flags.failure || flags.hidden) {
|
|
96
|
+
delete item.dirty;
|
|
97
|
+
item.blockers.push(flags.failure ?? "Index flags hide tracked files; dirty state cannot be proved.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const submodules = await git(["submodule", "status", "--recursive"], worktree.path, run);
|
|
101
|
+
if (submodules.split("\n").some((line) => line && !line.startsWith("-"))) {
|
|
102
|
+
item.blockers.push("Initialized submodules require separate inspection of their commits and ignored files.");
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const ownPanes = allocations.flatMap((allocation) => allocation.status !== "owned" ? []
|
|
106
|
+
: allocation.kind === "workspace" ? [allocation.rootPaneId]
|
|
107
|
+
: allocation.kind === "worker_tab" || allocation.kind === "agent" ? [allocation.paneId] : []);
|
|
108
|
+
const foreign = (await guards.panes()).filter((pane) => !ownPanes.includes(pane.paneId)
|
|
109
|
+
&& pane.cwds.some((cwd) => within(real, cwd) || within(worktree.path, cwd)));
|
|
110
|
+
if (foreign.length)
|
|
111
|
+
item.blockers.push(`Other Herdr panes use this checkout: ${foreign.map((pane) => pane.paneId).join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
item.blockers.push(`Herdr cannot be inspected: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
return item;
|
|
117
|
+
}
|
|
118
|
+
export async function inspectDiscard(primary, mainCwd, state, guards) {
|
|
119
|
+
const items = [];
|
|
120
|
+
for (const resource of discardResources(state)) {
|
|
121
|
+
try {
|
|
122
|
+
const item = await inspectDiscardResource(primary, mainCwd, resource, guards);
|
|
123
|
+
const complete = resource.textAttempt?.cleanup?.outcome === "pruned"
|
|
124
|
+
|| (resource.attempt?.cleanup ?? resource.generation?.cleanup)?.every((step) => step.status === "completed");
|
|
125
|
+
if (!complete || item.checkout || item.tip || item.blockers.length)
|
|
126
|
+
items.push(item);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
items.push({ key: resource.key, identity: identity(resource), path: resource.worktree?.path,
|
|
130
|
+
branch: resource.worktree?.branch, checkout: false, ignored: [], hosts: [], blockers: [`Inspection failed: ${String(error)}`] });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return items;
|
|
134
|
+
}
|
|
135
|
+
/** Called only after the command's confirmation. Never exposed as an agent tool. */
|
|
136
|
+
export async function removeDiscardResources(handle, primary, mainCwd, reviewed, guards, close, run = runGit) {
|
|
137
|
+
const results = [];
|
|
138
|
+
const removals = [];
|
|
139
|
+
for (const item of reviewed) {
|
|
140
|
+
const resource = discardResources(handle.state).find((resource) => resource.key === item.key);
|
|
141
|
+
if (!resource || identity(resource) !== item.identity || item.blockers.length) {
|
|
142
|
+
results.push({ key: item.key, outcome: "skipped", reason: "Ownership changed or the reviewed item is blocked." });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
let checkoutRemoved = false;
|
|
146
|
+
const verify = async () => {
|
|
147
|
+
if (!guards.current())
|
|
148
|
+
throw new Error("Session changed; reopen /subagent.");
|
|
149
|
+
const fresh = await inspectDiscardResource(primary, mainCwd, resource, guards, run);
|
|
150
|
+
if (fresh.blockers.length)
|
|
151
|
+
throw new Error(fresh.blockers.join("; "));
|
|
152
|
+
if (fresh.tip !== item.tip)
|
|
153
|
+
throw new Error("Branch was deleted or moved since review; branch kept.");
|
|
154
|
+
if (fresh.checkout !== (checkoutRemoved ? false : item.checkout))
|
|
155
|
+
throw new Error("Checkout changed since review.");
|
|
156
|
+
if (fresh.ignored.some((entry) => !item.ignored.includes(entry)))
|
|
157
|
+
throw new Error("Ignored entries changed since review.");
|
|
158
|
+
const blocked = await guards.blocked();
|
|
159
|
+
if (blocked)
|
|
160
|
+
throw new Error(blocked);
|
|
161
|
+
if (!guards.current())
|
|
162
|
+
throw new Error("Session changed; reopen /subagent.");
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
await verify();
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
results.push({ key: item.key, outcome: "skipped", reason: String(error) });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const failed = async (error) => {
|
|
172
|
+
const pending = (resource.attempt?.cleanup ?? resource.generation?.cleanup)?.find((step) => step.status === "running");
|
|
173
|
+
if (pending) {
|
|
174
|
+
pending.failure = `User-confirmed discard failed: ${String(error)}`.slice(0, 500);
|
|
175
|
+
await handle.save();
|
|
176
|
+
}
|
|
177
|
+
results.push({ key: item.key, outcome: "failed", reason: `${checkoutRemoved ? "Checkout removed; " : ""}${String(error)}` });
|
|
178
|
+
};
|
|
179
|
+
try {
|
|
180
|
+
await close(resource, verify);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
await failed(error);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Close all reviewed workers and host resources before any Git checkout is removed.
|
|
187
|
+
removals.push(async () => {
|
|
188
|
+
try {
|
|
189
|
+
const pending = [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
|
|
190
|
+
const steps = resource.attempt?.cleanup ?? (resource.generation ? resource.generation.cleanup ??= pending : pending);
|
|
191
|
+
for (const kind of ["worktree", "branch"]) {
|
|
192
|
+
const step = steps.find((step) => step.kind === kind);
|
|
193
|
+
await verify();
|
|
194
|
+
if (step.status === "completed")
|
|
195
|
+
continue;
|
|
196
|
+
step.status = "running";
|
|
197
|
+
delete step.failure;
|
|
198
|
+
await handle.save();
|
|
199
|
+
await verify();
|
|
200
|
+
if (kind === "worktree" && item.checkout) {
|
|
201
|
+
await git(["worktree", "remove", "--force", item.path], primary, run);
|
|
202
|
+
checkoutRemoved = true;
|
|
203
|
+
}
|
|
204
|
+
else if (kind === "branch" && item.tip) {
|
|
205
|
+
await git(["update-ref", "--no-deref", "-d", `refs/heads/${item.branch}`, item.tip], primary, run);
|
|
206
|
+
}
|
|
207
|
+
step.status = "completed";
|
|
208
|
+
if (resource.textAttempt)
|
|
209
|
+
resource.textAttempt.cleanup = kind === "branch"
|
|
210
|
+
? { outcome: "pruned", path: item.path, branch: item.branch }
|
|
211
|
+
: { outcome: "recovery", path: item.path, branch: item.branch, note: "User confirmed discard: checkout removed; branch cleanup pending." };
|
|
212
|
+
await handle.save();
|
|
213
|
+
}
|
|
214
|
+
results.push({ key: item.key, outcome: "removed", reason: "Reviewed resources removed; cleanup saved." });
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
await failed(error);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
for (const remove of removals)
|
|
222
|
+
await remove();
|
|
223
|
+
return results;
|
|
224
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type DiscardGuards, type DiscardPlan } from "./discard.ts";
|
|
1
2
|
import { type EphemeralSubagentExecutor } from "./ephemeral.ts";
|
|
2
3
|
import type { PruneOwnership } from "./prune.ts";
|
|
3
4
|
import { IntegrationGit } from "./integration-git.ts";
|
|
4
5
|
import { type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
|
|
5
6
|
import { FileRunStore } from "./store.ts";
|
|
6
|
-
/** One durable follow-up per attempt;
|
|
7
|
+
/** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
|
|
7
8
|
export declare const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
|
|
8
9
|
export declare const CLEANUP_SAFETY_BUDGET_MS = 30000;
|
|
9
10
|
export declare const TERMINATION_SAFETY_BUDGET_MS = 15000;
|
|
@@ -305,6 +306,8 @@ export declare class IsolatedRunner {
|
|
|
305
306
|
id: string;
|
|
306
307
|
name: string;
|
|
307
308
|
status: string;
|
|
309
|
+
directory: string;
|
|
310
|
+
root: string;
|
|
308
311
|
tasks: Array<{
|
|
309
312
|
id: string;
|
|
310
313
|
name: string;
|
|
@@ -346,6 +349,25 @@ export declare class IsolatedRunner {
|
|
|
346
349
|
private releaseSuperseded;
|
|
347
350
|
private cleanupPromoted;
|
|
348
351
|
private requireExactIntegration;
|
|
352
|
+
listOrphanRequests(cwd: string): Promise<{
|
|
353
|
+
invalidIds: string[];
|
|
354
|
+
requests: {
|
|
355
|
+
id: string;
|
|
356
|
+
name: string;
|
|
357
|
+
status: string;
|
|
358
|
+
directory: string;
|
|
359
|
+
root: string;
|
|
360
|
+
orphaned: boolean;
|
|
361
|
+
tasks: never[];
|
|
362
|
+
}[];
|
|
363
|
+
}>;
|
|
364
|
+
private discardGuards;
|
|
365
|
+
discardPlan(cwd: string, id: string, directory: string | undefined, guards: DiscardGuards): Promise<DiscardPlan>;
|
|
366
|
+
/** Command-only explicit discard. No tool calls this method. */
|
|
367
|
+
discard(cwd: string, plan: DiscardPlan, guards: DiscardGuards): Promise<import("./discard.ts").DiscardRemoval[]>;
|
|
368
|
+
private terminateAbortedAttempt;
|
|
369
|
+
private cleanupAbortedHost;
|
|
370
|
+
private closeDiscardResource;
|
|
349
371
|
abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
350
372
|
/** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
|
|
351
373
|
private retainedWorkAbsent;
|
package/dist/runner.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { inspectDiscard, primaryCheckout, removeDiscardResources } from "./discard.js";
|
|
2
4
|
import { utf8Prefix } from "./ephemeral.js";
|
|
3
5
|
import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
|
|
4
6
|
import { runGit } from "./git-process.js";
|
|
@@ -11,7 +13,7 @@ const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
|
|
|
11
13
|
const CORRECTION_DELIVERY_RESERVE_BYTES = 1024;
|
|
12
14
|
const MAX_WORKER_PROMPTS = 32;
|
|
13
15
|
const MAX_QUEUED_FOLLOWUPS = 16;
|
|
14
|
-
/** One durable follow-up per attempt;
|
|
16
|
+
/** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
|
|
15
17
|
export const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
|
|
16
18
|
export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
|
|
17
19
|
export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
|
|
@@ -422,6 +424,14 @@ function correctionEligible(request, attempt) {
|
|
|
422
424
|
function terminal(state) {
|
|
423
425
|
return state.status === "completed" || state.status === "final_failed" || state.status === "superseded" || state.status === "aborted";
|
|
424
426
|
}
|
|
427
|
+
function inventoryStatus(state) {
|
|
428
|
+
const retained = state.tasks.some((task) => task.kind === "changeset"
|
|
429
|
+
? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
|
|
430
|
+
: task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
|
|
431
|
+
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
432
|
+
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true));
|
|
433
|
+
return ["completed", "aborted"].includes(state.status) && retained ? `${state.status} · retained` : state.status;
|
|
434
|
+
}
|
|
425
435
|
/** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
|
|
426
436
|
export function buildChangesetTaskPrompt(input) {
|
|
427
437
|
if (input.kind === "correction"
|
|
@@ -551,14 +561,9 @@ export class IsolatedRunner {
|
|
|
551
561
|
}
|
|
552
562
|
async listRequests(root) {
|
|
553
563
|
const { states: entries, invalidIds } = await this.store.list(root);
|
|
554
|
-
return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
|
|
555
|
-
id: state.request.id, name: state.request.goal,
|
|
556
|
-
status:
|
|
557
|
-
? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
|
|
558
|
-
: task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
|
|
559
|
-
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
560
|
-
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
|
|
561
|
-
? `${state.status} · retained` : state.status,
|
|
564
|
+
return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state, path }) => ({
|
|
565
|
+
id: state.request.id, name: state.request.goal, directory: dirname(path), root: state.root,
|
|
566
|
+
status: inventoryStatus(state),
|
|
562
567
|
tasks: state.request.tasks.map((task) => ({
|
|
563
568
|
id: task.id, name: task.requirements, kind: task.kind,
|
|
564
569
|
status: state.tasks.find((item) => item.taskId === task.id).status,
|
|
@@ -1657,6 +1662,136 @@ export class IsolatedRunner {
|
|
|
1657
1662
|
if (!sameIdentity(main, generation.expectedMain))
|
|
1658
1663
|
throw new Error("Main changed or became dirty before promotion.");
|
|
1659
1664
|
}
|
|
1665
|
+
async listOrphanRequests(cwd) {
|
|
1666
|
+
const primary = await primaryCheckout(cwd);
|
|
1667
|
+
const { states, invalidIds } = await this.store.listOrphans(primary);
|
|
1668
|
+
return { invalidIds, requests: states.map((handle) => ({
|
|
1669
|
+
id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state),
|
|
1670
|
+
directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
|
|
1671
|
+
})) };
|
|
1672
|
+
}
|
|
1673
|
+
discardGuards(directory, root, guards) {
|
|
1674
|
+
return { ...guards, blocked: async () => {
|
|
1675
|
+
if (this.productiveLifecycles.has(root))
|
|
1676
|
+
return "A local productive job is active.";
|
|
1677
|
+
if (await this.store.hasProductiveRunLeaseInDirectory(directory))
|
|
1678
|
+
return "A live productive lease owns this request root.";
|
|
1679
|
+
return await guards.blocked();
|
|
1680
|
+
} };
|
|
1681
|
+
}
|
|
1682
|
+
async discardPlan(cwd, id, directory, guards) {
|
|
1683
|
+
const primary = await primaryCheckout(cwd);
|
|
1684
|
+
directory ??= this.store.stateDirectory(realpathSync.native(cwd));
|
|
1685
|
+
const handle = await this.store.loadFromDirectory(directory, id);
|
|
1686
|
+
return { directory, root: handle.state.root, requestId: id, items: await inspectDiscard(primary, cwd, handle.state, this.discardGuards(directory, handle.state.root, guards)) };
|
|
1687
|
+
}
|
|
1688
|
+
/** Command-only explicit discard. No tool calls this method. */
|
|
1689
|
+
async discard(cwd, plan, guards) {
|
|
1690
|
+
const primary = await primaryCheckout(cwd);
|
|
1691
|
+
return await this.store.withDirectoryLock(plan.directory, async () => {
|
|
1692
|
+
const handle = await this.store.loadFromDirectory(plan.directory, plan.requestId);
|
|
1693
|
+
const state = handle.state;
|
|
1694
|
+
if (state.root !== plan.root)
|
|
1695
|
+
throw new Error("Request root changed since review.");
|
|
1696
|
+
const checked = this.discardGuards(plan.directory, state.root, guards);
|
|
1697
|
+
const blocked = await checked.blocked();
|
|
1698
|
+
if (blocked || !checked.current())
|
|
1699
|
+
throw new Error(blocked ?? "Session changed; reopen /subagent.");
|
|
1700
|
+
const removals = await removeDiscardResources(handle, primary, cwd, plan.items, checked, async (resource, verify) => {
|
|
1701
|
+
await verify();
|
|
1702
|
+
state.status = "aborted";
|
|
1703
|
+
state.accepted = false;
|
|
1704
|
+
delete state.acceptedAt;
|
|
1705
|
+
if (state.final.status === "passed") {
|
|
1706
|
+
state.final.status = "superseded";
|
|
1707
|
+
state.final.failure = "User confirmed discard from /subagent.";
|
|
1708
|
+
}
|
|
1709
|
+
delete state.recovery;
|
|
1710
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
1711
|
+
// Invalidate readiness before termination or supersession; rejected candidates retain exact accounting.
|
|
1712
|
+
for (const candidate of state.integration.candidates) {
|
|
1713
|
+
candidate.decision = "rejected";
|
|
1714
|
+
const task = changesetTaskState(state, candidate.taskId);
|
|
1715
|
+
if (candidate.attempt !== task.attempts.length)
|
|
1716
|
+
continue;
|
|
1717
|
+
if (candidate.worker !== "retained") {
|
|
1718
|
+
task.status = "rejected";
|
|
1719
|
+
delete task.failure;
|
|
1720
|
+
}
|
|
1721
|
+
else
|
|
1722
|
+
this.attention(task, "User confirmed discard of this request.");
|
|
1723
|
+
}
|
|
1724
|
+
for (const generation of state.integration.generations) {
|
|
1725
|
+
if (generation.status === "superseded")
|
|
1726
|
+
continue;
|
|
1727
|
+
generation.status = "superseded";
|
|
1728
|
+
generation.supersededFrom = "discarded";
|
|
1729
|
+
generation.failure = "User confirmed discard from /subagent.";
|
|
1730
|
+
delete generation.promotion;
|
|
1731
|
+
delete generation.supersededPromotion;
|
|
1732
|
+
delete generation.combinedTip;
|
|
1733
|
+
delete generation.checks;
|
|
1734
|
+
delete generation.review;
|
|
1735
|
+
}
|
|
1736
|
+
await handle.save();
|
|
1737
|
+
await this.closeDiscardResource(handle, resource, verify);
|
|
1738
|
+
});
|
|
1739
|
+
for (const candidate of state.integration.candidates) {
|
|
1740
|
+
const task = changesetTaskState(state, candidate.taskId);
|
|
1741
|
+
const attempt = task.attempts[candidate.attempt - 1];
|
|
1742
|
+
if (candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
|
|
1743
|
+
|| attempt.cleanup.some((step) => step.status !== "completed"))
|
|
1744
|
+
continue;
|
|
1745
|
+
candidate.worker = "released";
|
|
1746
|
+
if (attempt === task.attempts.at(-1)) {
|
|
1747
|
+
task.status = "rejected";
|
|
1748
|
+
delete task.failure;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
await handle.save();
|
|
1752
|
+
return removals;
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
async terminateAbortedAttempt(handle, task, attempt, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
|
|
1756
|
+
if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
|
|
1757
|
+
return;
|
|
1758
|
+
await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), signal, deadline);
|
|
1759
|
+
}
|
|
1760
|
+
async cleanupAbortedHost(handle, task, attempt, kind, scope) {
|
|
1761
|
+
const result = await scope.call((context) => this.hostRuntime.cleanupHost({
|
|
1762
|
+
requestId: handle.state.request.id, kind, task: changesetTaskRequest(handle.state, task.taskId), attempt,
|
|
1763
|
+
}, context));
|
|
1764
|
+
if (result.outcome === "blocked")
|
|
1765
|
+
throw new Error(result.failure);
|
|
1766
|
+
}
|
|
1767
|
+
async closeDiscardResource(handle, resource, verify) {
|
|
1768
|
+
const { task, attempt } = resource;
|
|
1769
|
+
if (!task || !attempt)
|
|
1770
|
+
return;
|
|
1771
|
+
await verify();
|
|
1772
|
+
await this.terminateAbortedAttempt(handle, task, attempt);
|
|
1773
|
+
if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated")
|
|
1774
|
+
throw new Error("Worker termination remains unproved.");
|
|
1775
|
+
const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now());
|
|
1776
|
+
try {
|
|
1777
|
+
for (const step of attempt.cleanup) {
|
|
1778
|
+
if (step.kind !== "worker_tab" && step.kind !== "workspace" || step.status === "completed")
|
|
1779
|
+
continue;
|
|
1780
|
+
await verify();
|
|
1781
|
+
step.status = "running";
|
|
1782
|
+
delete step.failure;
|
|
1783
|
+
await handle.save();
|
|
1784
|
+
await verify();
|
|
1785
|
+
if (allocationByKind(attempt, step.kind))
|
|
1786
|
+
await this.cleanupAbortedHost(handle, task, attempt, step.kind, scope);
|
|
1787
|
+
step.status = "completed";
|
|
1788
|
+
await handle.save();
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
finally {
|
|
1792
|
+
scope.close();
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1660
1795
|
async abort(id, root, outerSignal) {
|
|
1661
1796
|
root = realpathSync.native(root);
|
|
1662
1797
|
return await this.store.withLock(root, async (lifecycle) => {
|
|
@@ -1695,10 +1830,8 @@ export class IsolatedRunner {
|
|
|
1695
1830
|
if (task.kind !== "changeset")
|
|
1696
1831
|
continue;
|
|
1697
1832
|
for (const attempt of task.attempts) {
|
|
1698
|
-
if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
|
|
1699
|
-
continue;
|
|
1700
1833
|
try {
|
|
1701
|
-
await this.
|
|
1834
|
+
await this.terminateAbortedAttempt(handle, task, attempt, outerSignal, safetyDeadline);
|
|
1702
1835
|
}
|
|
1703
1836
|
catch (error) {
|
|
1704
1837
|
this.attention(task, errorText(error));
|
|
@@ -1767,12 +1900,7 @@ export class IsolatedRunner {
|
|
|
1767
1900
|
delete step.failure;
|
|
1768
1901
|
await handle.save();
|
|
1769
1902
|
if ((kind === "worker_tab" || kind === "workspace") && allocationByKind(attempt, kind)) {
|
|
1770
|
-
|
|
1771
|
-
requestId: state.request.id, kind,
|
|
1772
|
-
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1773
|
-
}, context));
|
|
1774
|
-
if (result.outcome === "blocked")
|
|
1775
|
-
throw new Error(result.failure);
|
|
1903
|
+
await this.cleanupAbortedHost(handle, task, attempt, kind, scope);
|
|
1776
1904
|
}
|
|
1777
1905
|
else if ((kind === "worktree" || kind === "branch") && worker && expected) {
|
|
1778
1906
|
const result = await scope.call((context) => this.integrationGit.release(state.root, worker, expected, kind, context.signal));
|
package/dist/schema.d.ts
CHANGED
|
@@ -1430,7 +1430,7 @@ declare const IntegrationGenerationSchema: Type.TObject<{
|
|
|
1430
1430
|
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">]>;
|
|
1431
1431
|
failure: Type.TOptional<Type.TString>;
|
|
1432
1432
|
}>>>;
|
|
1433
|
-
supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">]>>;
|
|
1433
|
+
supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"discarded">]>>;
|
|
1434
1434
|
failure: Type.TOptional<Type.TString>;
|
|
1435
1435
|
}>;
|
|
1436
1436
|
export declare const IntegrationStateSchema: Type.TObject<{
|
|
@@ -1670,7 +1670,7 @@ export declare const IntegrationStateSchema: Type.TObject<{
|
|
|
1670
1670
|
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">]>;
|
|
1671
1671
|
failure: Type.TOptional<Type.TString>;
|
|
1672
1672
|
}>>>;
|
|
1673
|
-
supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">]>>;
|
|
1673
|
+
supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"discarded">]>>;
|
|
1674
1674
|
failure: Type.TOptional<Type.TString>;
|
|
1675
1675
|
}>>;
|
|
1676
1676
|
refresh: Type.TOptional<Type.TObject<{
|
|
@@ -2231,7 +2231,7 @@ declare const RunStateSchema: Type.TObject<{
|
|
|
2231
2231
|
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">]>;
|
|
2232
2232
|
failure: Type.TOptional<Type.TString>;
|
|
2233
2233
|
}>>>;
|
|
2234
|
-
supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">]>>;
|
|
2234
|
+
supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"discarded">]>>;
|
|
2235
2235
|
failure: Type.TOptional<Type.TString>;
|
|
2236
2236
|
}>>;
|
|
2237
2237
|
refresh: Type.TOptional<Type.TObject<{
|
package/dist/schema.js
CHANGED
|
@@ -387,7 +387,7 @@ const IntegrationGenerationSchema = Type.Object({
|
|
|
387
387
|
cleanup: Type.Optional(Type.Array(CleanupStepSchema, { minItems: 2, maxItems: 2 })),
|
|
388
388
|
supersededFrom: Type.Optional(Type.Union([
|
|
389
389
|
Type.Literal("staging"), Type.Literal("conflict"), Type.Literal("validation_failed"),
|
|
390
|
-
Type.Literal("ready"), Type.Literal("promotion_failed"),
|
|
390
|
+
Type.Literal("ready"), Type.Literal("promotion_failed"), Type.Literal("discarded"),
|
|
391
391
|
])),
|
|
392
392
|
failure: OptionalRuntimeTextSchema,
|
|
393
393
|
}, { additionalProperties: false });
|
|
@@ -978,6 +978,9 @@ export function parseRunState(value) {
|
|
|
978
978
|
const state = value;
|
|
979
979
|
const request = parseExecuteRequest(state.request);
|
|
980
980
|
parseIntegrationState(state.integration, request);
|
|
981
|
+
if (state.status !== "aborted" && state.integration.generations.some((generation) => generation.supersededFrom === "discarded")) {
|
|
982
|
+
throw new Error("User-discarded generations require an aborted request.");
|
|
983
|
+
}
|
|
981
984
|
const refresh = state.integration.refresh;
|
|
982
985
|
if (refresh && (refresh.generation === 1 && !sameIdentity(refresh.from, state.requestStartMain)
|
|
983
986
|
|| (state.integration.generations.at(-1)?.status !== "promoted" && !sameIdentity(refresh.to, state.main)))) {
|
package/dist/store.d.ts
CHANGED
|
@@ -41,6 +41,16 @@ export declare class FileRunStore {
|
|
|
41
41
|
withLock<T>(root: string, operation: (lifecycle: LifecycleLock) => Promise<T>, options?: LifecycleLockOptions): Promise<T>;
|
|
42
42
|
create(state: RunState): Promise<RunStateHandle>;
|
|
43
43
|
load(root: string, id: string): Promise<RunStateHandle>;
|
|
44
|
+
/** Address saved state without resolving a parent checkout that may no longer exist. */
|
|
45
|
+
private validateDirectory;
|
|
46
|
+
loadFromDirectory(directory: string, id: string): Promise<RunStateHandle>;
|
|
47
|
+
hasProductiveRunLeaseInDirectory(directory: string): Promise<boolean>;
|
|
48
|
+
withDirectoryLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
|
|
49
|
+
/** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
|
|
50
|
+
listOrphans(primary: string): Promise<{
|
|
51
|
+
states: RunStateHandle[];
|
|
52
|
+
invalidIds: string[];
|
|
53
|
+
}>;
|
|
44
54
|
private stateEntries;
|
|
45
55
|
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
46
56
|
assertLegacyAdmissionSafe(root: string): Promise<void>;
|
package/dist/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
5
5
|
import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
|
|
6
6
|
import { check, lock } from "proper-lockfile";
|
|
7
7
|
import { parseRunState, RUN_STATE_VERSION } from "./schema.js";
|
|
8
|
+
import { discardResources } from "./discard.js";
|
|
8
9
|
const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
|
|
9
10
|
/*
|
|
10
11
|
* create() caps the complete initial serialization at 2 MiB. Loaded requests
|
|
@@ -117,7 +118,7 @@ export class FileRunStore {
|
|
|
117
118
|
return join(this.stateDirectory(root), "productive-run");
|
|
118
119
|
}
|
|
119
120
|
async assertSafeDestination(root, destination) {
|
|
120
|
-
if (isWithin(
|
|
121
|
+
if (isWithin(await canonicalPlannedPath(root), await canonicalPlannedPath(destination))) {
|
|
121
122
|
throw new Error("Pi Subagent state directory must be outside the Git workspace.");
|
|
122
123
|
}
|
|
123
124
|
}
|
|
@@ -216,6 +217,91 @@ export class FileRunStore {
|
|
|
216
217
|
}
|
|
217
218
|
return this.handle(state, path);
|
|
218
219
|
}
|
|
220
|
+
/** Address saved state without resolving a parent checkout that may no longer exist. */
|
|
221
|
+
async validateDirectory(directory) {
|
|
222
|
+
const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
|
|
223
|
+
if (dirname(directory) !== parent || !/^[a-f0-9]{64}$/.test(basename(directory))
|
|
224
|
+
|| !(await lstat(directory)).isDirectory() || await realpath(directory) !== join(await realpath(parent), basename(directory))) {
|
|
225
|
+
throw new Error("Invalid Pi Subagent state directory.");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async loadFromDirectory(directory, id) {
|
|
229
|
+
await this.validateDirectory(directory);
|
|
230
|
+
if (basename(id) !== id || !id || id === "." || id === "..")
|
|
231
|
+
throw new Error("Invalid request ID.");
|
|
232
|
+
const path = join(directory, `${id}.json`);
|
|
233
|
+
if (!(await lstat(path)).isFile())
|
|
234
|
+
throw new Error("State is not a regular file.");
|
|
235
|
+
const state = parseRunState(JSON.parse(await readTextFileBounded(path, STATE_MAX_BYTES)));
|
|
236
|
+
if (state.request.id !== id || !isAbsolute(state.root)
|
|
237
|
+
|| createHash("sha256").update(state.root).digest("hex") !== basename(directory)) {
|
|
238
|
+
throw new Error("pi-subagent state identity does not match its directory and filename.");
|
|
239
|
+
}
|
|
240
|
+
await this.assertSafeDestination(state.root, path);
|
|
241
|
+
return this.handle(state, path);
|
|
242
|
+
}
|
|
243
|
+
async hasProductiveRunLeaseInDirectory(directory) {
|
|
244
|
+
await this.validateDirectory(directory);
|
|
245
|
+
const path = join(directory, "productive-run");
|
|
246
|
+
return await check(path, { realpath: false, stale: LOCK_OPTIONS.stale, lockfilePath: `${path}.lock` });
|
|
247
|
+
}
|
|
248
|
+
async withDirectoryLock(directory, operation) {
|
|
249
|
+
await this.validateDirectory(directory);
|
|
250
|
+
const path = join(directory, "lifecycle.lock");
|
|
251
|
+
const release = await lock(path, { ...LIFECYCLE_LOCK_OPTIONS, lockfilePath: `${path}.lock` });
|
|
252
|
+
try {
|
|
253
|
+
return await operation();
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
await release();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
|
|
260
|
+
async listOrphans(primary) {
|
|
261
|
+
const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
|
|
262
|
+
let directories;
|
|
263
|
+
try {
|
|
264
|
+
directories = await readdir(parent, { withFileTypes: true });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (isMissing(error))
|
|
268
|
+
return { states: [], invalidIds: [] };
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
const states = [];
|
|
272
|
+
const invalidIds = [];
|
|
273
|
+
for (const entry of directories) {
|
|
274
|
+
const directory = join(parent, entry.name);
|
|
275
|
+
try {
|
|
276
|
+
await this.validateDirectory(directory);
|
|
277
|
+
for (const file of await readdir(directory)) {
|
|
278
|
+
if (!file.endsWith(".json"))
|
|
279
|
+
continue;
|
|
280
|
+
try {
|
|
281
|
+
const handle = await this.loadFromDirectory(directory, file.slice(0, -5));
|
|
282
|
+
try {
|
|
283
|
+
await lstat(handle.state.root);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (!isMissing(error))
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
const worktrees = discardResources(handle.state).flatMap((resource) => resource.worktree ? [resource.worktree] : []);
|
|
291
|
+
if (worktrees.length && worktrees.every((item) => item.repoRoot === primary))
|
|
292
|
+
states.push(handle);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
invalidIds.push(join(directory, file));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
invalidIds.push(directory);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { states, invalidIds };
|
|
304
|
+
}
|
|
219
305
|
async stateEntries(root) {
|
|
220
306
|
try {
|
|
221
307
|
return (await readdir(this.stateDirectory(root), { withFileTypes: true }))
|
package/docs/orchestration.md
CHANGED
|
@@ -251,6 +251,8 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
|
|
|
251
251
|
|
|
252
252
|
To select work by label, open `/subagent` without arguments. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout. This includes requests launched in other sessions. Select an isolated request. Then select **Inspect status and recovery**.
|
|
253
253
|
|
|
254
|
+
The menu also shows **Isolated · orphaned** requests whose saved parent checkout no longer exists. You can open this view from any checkout of the same repository. The saved worker or integration path identifies the primary repository. Orphaned requests have the same **Close and delete worktrees (discard work)** action, but no inspection or follow-up actions. The extension reads their original state directories; it does not recreate the removed parent checkout. Unreadable files stay preserved and are reported.
|
|
255
|
+
|
|
254
256
|
This launches a Herdr tab without focus, with the configured `scout` Role on the configured `fast` route. The tab runs a read-access task with the resources of the Role. Its prompt contains bounded saved status, including retained paths, but not the raw launch environment or session transcripts. The analysis stays in that tab and does not queue a Main turn. The tab shows in the direct list with its own reference and name, such as `D9.1 · scout · Inspect <request ID>`. The extension records the tab and session file for recovery, even if the launch becomes uncertain.
|
|
255
257
|
|
|
256
258
|
Before any stage or recovery action, the read-only `subagent_status` tool stays Main's authority for exact identities. If Herdr, the `scout` Role, or the fast route is not available, inspection fails and does not change the request. Main can then use `subagent_status`.
|
|
@@ -357,6 +359,20 @@ If another productive run holds the lease, the command only reads status and rep
|
|
|
357
359
|
|
|
358
360
|
Main uses `subagent_status` for exact identities. Main uses the recovery tools in the interface table above for deliberate decisions. The command never stages or promotes. Direct Herdr tab recovery stays in `/subagent`.
|
|
359
361
|
|
|
362
|
+
### Close and delete isolated worktrees
|
|
363
|
+
|
|
364
|
+
Open `/subagent`, select an isolated request, then select **Close and delete worktrees (discard work)**. This is a user action, not an agent tool. Without interactive UI, `/subagent` only reports requests.
|
|
365
|
+
|
|
366
|
+
The read-only review groups resources by worker attempt or integration generation. It shows each recorded checkout path, branch, tip, commits ahead of the recorded base, dirty state, ignored entries, and Herdr resources. Text attempts do not save their base; their commit count is shown as unknown. The review also includes owned branches with no checkout. If a complete resource group does not fit, the report gives the number of groups not shown. Those groups are not removed.
|
|
367
|
+
|
|
368
|
+
One confirmation permits permanent deletion of committed, uncommitted, and ignored content in the listed checkouts. It also permits closure of the listed Herdr tabs and workspaces. Cancel makes no change. The command uses the existing abort termination and host cleanup paths before forced checkout removal. It deletes a branch only at its reviewed tip. It saves each completed cleanup step.
|
|
369
|
+
|
|
370
|
+
Each resource is checked again before removal. A live productive lease or local job blocks the action. Uncertain allocations, changed branch tips, locked or redirected checkouts, and other Herdr panes inside a checkout block that resource. Initialized submodules and index flags that hide clean-state evidence also require separate inspection. If Herdr cannot be inspected, checkouts stay in place.
|
|
371
|
+
|
|
372
|
+
Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<24hex>` branches and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
|
|
373
|
+
|
|
374
|
+
The report lists removed, skipped, and failed resources. After complete cleanup, the request is `aborted`, candidates are `released`, and integration generations are superseded with completed cleanup. Partial cleanup remains visible as retained work. Reopen the request to review the remaining resources. Abort, prune, and release keep their existing preservation rules.
|
|
375
|
+
|
|
360
376
|
### Prune leftover worktrees
|
|
361
377
|
|
|
362
378
|
Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent-` plus 24 hex characters. The command also cleans up such branches that have no checkout. The review lists each item with its path, branch, and tip. For each item, it also shows why the command can remove the item, or it shows every exact blocker. The command can remove an item only when all of these conditions are true:
|
package/extensions/isolated.ts
CHANGED
|
@@ -38,7 +38,8 @@ import {
|
|
|
38
38
|
} from "../dist/schema.js";
|
|
39
39
|
import { FileRunStore } from "../dist/store.js";
|
|
40
40
|
import { workerOutputForMain } from "./result-transport.ts";
|
|
41
|
-
import type { IsolatedInventory, PrunePlan } from "./subagent-command.ts";
|
|
41
|
+
import type { IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
|
|
42
|
+
import type { DiscardPlan } from "../dist/discard.js";
|
|
42
43
|
|
|
43
44
|
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
44
45
|
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
@@ -471,6 +472,8 @@ export interface IsolatedSurface {
|
|
|
471
472
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
472
473
|
recover(cwd: string): Promise<string>;
|
|
473
474
|
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
475
|
+
discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
|
|
476
|
+
discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<string>;
|
|
474
477
|
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
|
|
475
478
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
476
479
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
@@ -790,7 +793,8 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
790
793
|
try { renderWorkspaceWidget(latestContext(), workspaceRowsByRequest); }
|
|
791
794
|
catch (error) { console.error("Pi Subagent workspace widget update failed.", error); }
|
|
792
795
|
}
|
|
793
|
-
|
|
796
|
+
const orphans = await getComponents().runner.listOrphanRequests(root);
|
|
797
|
+
return { root, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
|
|
794
798
|
},
|
|
795
799
|
async recover(cwd) {
|
|
796
800
|
const root = await lookupRoot(cwd);
|
|
@@ -823,6 +827,22 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
823
827
|
];
|
|
824
828
|
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
825
829
|
},
|
|
830
|
+
async discardPlan(cwd, request) {
|
|
831
|
+
return await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
|
|
832
|
+
panes: () => listHerdrPanes(cwd), current: () => !sessionClosed,
|
|
833
|
+
blocked: async () => jobOwners.has(`${request.root}\0${request.id}`) ? "A local job is active for this request." : undefined,
|
|
834
|
+
});
|
|
835
|
+
},
|
|
836
|
+
async discard(cwd, plan, current) {
|
|
837
|
+
requireCurrent(current);
|
|
838
|
+
const removals = await getComponents().runner.discard(cwd, plan, {
|
|
839
|
+
panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
|
|
840
|
+
blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
|
|
841
|
+
});
|
|
842
|
+
return boundedReport([`Close and delete ${plan.requestId}:`,
|
|
843
|
+
...removals.map((item) => `${item.outcome}: ${item.key}: ${item.reason}`),
|
|
844
|
+
], "removal report lines omitted; reopen /subagent to inspect retained resources.");
|
|
845
|
+
},
|
|
826
846
|
async prunePlan(cwd) {
|
|
827
847
|
const root = await lookupRoot(cwd);
|
|
828
848
|
return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DiscardPlan } from "../dist/discard.js";
|
|
2
3
|
|
|
3
4
|
/** Saved before launch for user matching only; never ownership or mutation authority. */
|
|
4
5
|
export interface DirectDisplay {
|
|
@@ -39,6 +40,10 @@ export interface IsolatedTask {
|
|
|
39
40
|
}
|
|
40
41
|
export interface IsolatedRequest {
|
|
41
42
|
id: string;
|
|
43
|
+
/** Saved state address for a removed parent checkout. */
|
|
44
|
+
directory?: string;
|
|
45
|
+
root?: string;
|
|
46
|
+
orphaned?: boolean;
|
|
42
47
|
name: string;
|
|
43
48
|
status: string;
|
|
44
49
|
tasks: readonly IsolatedTask[];
|
|
@@ -75,6 +80,8 @@ export interface SubagentCommandAdapter {
|
|
|
75
80
|
recover(cwd: string): Promise<string>;
|
|
76
81
|
/** Read-only review of pi-subagent worktrees and branches in the canonical repository. */
|
|
77
82
|
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
83
|
+
discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
|
|
84
|
+
discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<string>;
|
|
78
85
|
/** Re-verify and remove reviewed prunable items; returns a bounded report. */
|
|
79
86
|
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
|
|
80
87
|
/** Read-only Role catalog report with routes and per-file load errors. */
|
|
@@ -99,11 +106,11 @@ const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
|
|
|
99
106
|
const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
|
|
100
107
|
|
|
101
108
|
/** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
|
|
102
|
-
function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number): { text: string; shown: T[] } {
|
|
109
|
+
function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number, omitted = NOT_SHOWN): { text: string; shown: T[] } {
|
|
103
110
|
const lines = [header];
|
|
104
111
|
const shown: T[] = [];
|
|
105
112
|
let size = [...header].length;
|
|
106
|
-
const reserve = [...
|
|
113
|
+
const reserve = [...omitted].length + 32;
|
|
107
114
|
for (const item of items) {
|
|
108
115
|
const text = line(item);
|
|
109
116
|
const length = [...text].length + 1;
|
|
@@ -112,7 +119,7 @@ function fitItems<T>(header: string, items: readonly T[], line: (item: T) => str
|
|
|
112
119
|
size += length;
|
|
113
120
|
shown.push(item);
|
|
114
121
|
}
|
|
115
|
-
if (shown.length < items.length) lines.push(`+${items.length - shown.length} more not shown; ${
|
|
122
|
+
if (shown.length < items.length) lines.push(`+${items.length - shown.length} more not shown; ${omitted}`);
|
|
116
123
|
return { text: lines.join("\n"), shown };
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -126,6 +133,18 @@ function pruneReview({ root, items }: PrunePlan): { text: string; shown: PruneIt
|
|
|
126
133
|
ROLES_REPORT_CODE_POINTS);
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
function discardReview(plan: DiscardPlan) {
|
|
137
|
+
if (!plan.items.length) return { text: `No remaining owned resources for ${plan.requestId}.`, shown: [] };
|
|
138
|
+
return fitItems(`Close and delete request ${plan.requestId}:`, plan.items, (item) => clean([
|
|
139
|
+
`${item.key}: ${item.blockers.length ? `blocked: ${item.blockers.join("; ")}` : "can delete"}`,
|
|
140
|
+
`path: ${item.path ?? "none"}; checkout: ${item.checkout ? "yes" : "no"}; branch: ${item.branch ?? "none"}; tip: ${item.tip ?? "none"}`,
|
|
141
|
+
`commits ahead of recorded base: ${item.commits ?? "unknown"}; dirty: ${item.dirty === undefined ? "not inspected" : item.dirty ? "yes" : "no"}`,
|
|
142
|
+
`ignored entries (including all content inside them): ${item.ignored.length ? item.ignored.join(", ") : "none"}`,
|
|
143
|
+
`Herdr resources: ${item.hosts.length ? item.hosts.join("; ") : "none"}`,
|
|
144
|
+
].join("\n"), Number.MAX_SAFE_INTEGER, true), ROLES_REPORT_CODE_POINTS,
|
|
145
|
+
"they are not removed; reopen this request to review them.");
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
type Choice<T> = { label: string; value: T };
|
|
130
149
|
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
|
|
131
150
|
// Numeric suffixes are UI-only. Never parse display text into an identity.
|
|
@@ -147,7 +166,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
147
166
|
else pi.sendMessage({ customType: "pi-subagent-roles", content: report, display: true });
|
|
148
167
|
return;
|
|
149
168
|
}
|
|
150
|
-
|
|
169
|
+
|
|
151
170
|
const manager = ctx.sessionManager;
|
|
152
171
|
const session = manager.getSessionId();
|
|
153
172
|
const file = manager.getSessionFile();
|
|
@@ -207,6 +226,14 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
207
226
|
} catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
|
|
208
227
|
return;
|
|
209
228
|
}
|
|
229
|
+
if (!ctx.hasUI) {
|
|
230
|
+
const inventory = await adapter.isolated(ctx.cwd, current);
|
|
231
|
+
const report = fitItems("Isolated requests (report only):", inventory.requests,
|
|
232
|
+
(request) => clean(`${request.orphaned ? "orphaned · " : ""}${request.name} · ${request.status} · ${request.id}`, 500), ROLES_REPORT_CODE_POINTS,
|
|
233
|
+
"open /subagent with interactive UI to inspect them.");
|
|
234
|
+
pi.sendMessage({ customType: "pi-subagent-discard", content: `${report.text}\nUnreadable state files preserved: ${inventory.invalidIds.length}.\nRemoval requires interactive confirmation.`, display: true });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
210
237
|
let history = false;
|
|
211
238
|
for (;;) {
|
|
212
239
|
if (!valid()) return;
|
|
@@ -223,7 +250,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
223
250
|
const selections: Choice<Selection>[] = [
|
|
224
251
|
// One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
|
|
225
252
|
...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${directLabel(task)} · ${task.status}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct" as const, task } })),
|
|
226
|
-
...(inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
|
|
253
|
+
...(inventory?.requests ?? []).filter((request) => request.orphaned || history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.orphaned ? "orphaned · " : ""}${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
|
|
227
254
|
{ label: "Refresh", value: { kind: "refresh" as const } },
|
|
228
255
|
{ label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" as const } },
|
|
229
256
|
{ label: "Close", value: { kind: "close" as const } },
|
|
@@ -275,16 +302,35 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
275
302
|
const { root, request } = selected;
|
|
276
303
|
for (;;) {
|
|
277
304
|
if (!valid()) return;
|
|
278
|
-
type Action = "inspect" | "send" | "edit" | "back";
|
|
305
|
+
type Action = "inspect" | "send" | "edit" | "discard" | "back";
|
|
279
306
|
const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
280
307
|
const actions: Choice<Action>[] = [
|
|
281
|
-
{ label: "Inspect status and recovery", value: "inspect" },
|
|
308
|
+
...(!request.orphaned ? [{ label: "Inspect status and recovery", value: "inspect" as const }] : []),
|
|
309
|
+
{ label: "Close and delete worktrees (discard work)", value: "discard" },
|
|
282
310
|
...(eligible.length ? [{ label: "Send follow-up instructions", value: "send" as const }, { label: "Edit queued instructions (withdraw ALL pending; Cancel leaves withdrawn)", value: "edit" as const }] : []),
|
|
283
311
|
{ label: "Back / refresh", value: "back" },
|
|
284
312
|
];
|
|
285
313
|
const action = await choose(ctx, `Isolated · ${clean(request.name)} · ${request.id}`, actions);
|
|
286
314
|
if (!valid() || !action) return;
|
|
287
315
|
if (action === "back") break;
|
|
316
|
+
if (action === "discard") {
|
|
317
|
+
try {
|
|
318
|
+
const plan = await adapter.discardPlan(ctx.cwd, request);
|
|
319
|
+
const reviewed = discardReview(plan);
|
|
320
|
+
if (!valid()) return;
|
|
321
|
+
ctx.ui.notify(reviewed.text, "warning");
|
|
322
|
+
const items = reviewed.shown.filter((item) => !item.blockers.length);
|
|
323
|
+
if (!items.length) continue;
|
|
324
|
+
const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)",
|
|
325
|
+
`Stop the listed workers and delete the ${items.length} unblocked resource group(s) shown above? Committed, uncommitted, and ignored content in the listed checkouts is permanently deleted. Their listed branches, Herdr tabs, and workspaces are removed. Items not shown or blocked are kept.`);
|
|
326
|
+
if (!valid()) return;
|
|
327
|
+
if (confirmed) {
|
|
328
|
+
const report = await adapter.discard(ctx.cwd, { ...plan, items }, current);
|
|
329
|
+
if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
|
|
330
|
+
}
|
|
331
|
+
} catch (error) { if (valid()) ctx.ui.notify(`Close and delete failed: ${errorText(error)}`, "error"); }
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
288
334
|
if (action === "inspect") {
|
|
289
335
|
try {
|
|
290
336
|
const tab = await adapter.inspectInTab(root, request.id, ctx, current);
|
package/extensions/subagent.ts
CHANGED
|
@@ -331,6 +331,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
331
331
|
isolated: isolatedSurface.inventory,
|
|
332
332
|
recover: (cwd) => isolatedSurface.recover(cwd),
|
|
333
333
|
prunePlan: (cwd) => isolatedSurface.prunePlan(cwd),
|
|
334
|
+
discardPlan: (cwd, request) => isolatedSurface.discardPlan(cwd, request),
|
|
335
|
+
discard: (cwd, plan, current) => isolatedSurface.discard(cwd, plan, current),
|
|
334
336
|
prune: (cwd, items, allowIgnored, current) => isolatedSurface.prune(cwd, items, allowIgnored, current),
|
|
335
337
|
async inspectInTab(root, id, ctx, current) {
|
|
336
338
|
const notices = await isolatedSurface.inspect(root, id);
|