@henryqw/pi-subagent 27.9.0 → 27.10.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 +7 -1
- package/dist/git-runtime.d.ts +1 -0
- package/dist/git-runtime.js +1 -1
- package/dist/herdr-runtime.d.ts +1 -1
- package/dist/herdr-runtime.js +10 -5
- package/dist/integration-git.d.ts +1 -1
- package/dist/integration-git.js +2 -2
- package/dist/prune.d.ts +1 -1
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +13 -10
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +6 -2
- package/dist/worktree.d.ts +3 -2
- package/dist/worktree.js +12 -4
- package/docs/orchestration.md +11 -5
- package/extensions/subagent-command.ts +8 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,6 +136,8 @@ Use isolated mode for checked implementation. An isolated request has one durabl
|
|
|
136
136
|
|
|
137
137
|
Workers use Main's pinned committed branch, HEAD, and tree, not staged, unstaged, or untracked edits. Branch or HEAD changes block new waves. Candidates must be committed with no tracked or untracked changes. Ignored generated files permit candidacy but prevent automatic cleanup.
|
|
138
138
|
|
|
139
|
+
New isolated worktree names use the request and task IDs, plus an identity suffix. Integration names use the request ID and `integration-<generation>`. Herdr workspace and tab labels show the worker worktree name. Names do not use goals or requirements. Saved worktrees and labels keep their exact names for recovery and cleanup.
|
|
140
|
+
|
|
139
141
|
Keep the durable ID; Main can work or end its turn without polling. Inspect ready candidates with `subagent_status`.
|
|
140
142
|
|
|
141
143
|
- `subagent_stage` stages exact candidates while siblings run; resolve conflicts in the integration checkout.
|
|
@@ -161,7 +163,11 @@ A direct workflow is working only when this Pi process owns it and has a running
|
|
|
161
163
|
|
|
162
164
|
Each row starts with a status symbol: `●` active, `✓` completed, `⚠` needs attention, `✗` failed. In the terminal UI, `/subagent` reports (Roles, details, reviews, and results) show as colored transcript entries. The model does not get these entries. In RPC mode, they show as notifications. Without interactive UI, they show as plain text messages.
|
|
163
165
|
|
|
164
|
-
Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository.
|
|
166
|
+
Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Saved `running` or `pending` states show as `saved running · unverified` or `saved pending · unverified`, not as live work. The report lists them under **Unverified**. This does not prove completion.
|
|
167
|
+
|
|
168
|
+
Both isolated groups hide terminal requests by default: `completed`, `aborted`, `final_failed`, and `superseded`. Select **Completed / history** to see them. Requests with retained resources or unresolved recovery stay visible. This filter does not delete saved records. Without interactive UI, the request list includes history.
|
|
169
|
+
|
|
170
|
+
Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
|
|
165
171
|
|
|
166
172
|
Select **Select multiple…** to apply one action to many rows. Select a row to mark it `[x]` or clear it, or use **Select all** or **Clear**, then select **Continue**. The next menu shows only the actions that apply. **Close and delete worktrees (discard work)** applies to isolated requests. **Close/cancel-and-release ALL owned workers** applies to direct workflows that can close. Each request shows its review, then one confirmation covers all of them.
|
|
167
173
|
|
package/dist/git-runtime.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ export declare class CheckedGitRuntime implements GitRuntime, TaskCandidateInspe
|
|
|
46
46
|
root: string;
|
|
47
47
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
48
48
|
allocateWorktree(input: {
|
|
49
|
+
requestId: string;
|
|
49
50
|
root: string;
|
|
50
51
|
baseRoot?: string;
|
|
51
52
|
intent: WorktreeAllocationIntent;
|
package/dist/git-runtime.js
CHANGED
|
@@ -111,7 +111,7 @@ export class CheckedGitRuntime {
|
|
|
111
111
|
if (!sameIdentity(current, input.attempt.waveBase)) {
|
|
112
112
|
throw new Error("Recorded wave snapshot drifted after worktree preparation and before git worktree add.");
|
|
113
113
|
}
|
|
114
|
-
});
|
|
114
|
+
}, [input.requestId, input.task.id]);
|
|
115
115
|
if (!created)
|
|
116
116
|
return { kind: "worktree", outcome: "absent", failure: "Git worktree allocation requires a committed repository." };
|
|
117
117
|
const worktree = cloneWorktree(created);
|
package/dist/herdr-runtime.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface HerdrHostRuntimeOptions {
|
|
|
24
24
|
lsofCommand?: string;
|
|
25
25
|
executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
|
|
26
26
|
}
|
|
27
|
-
export declare function workspaceLabel(attempt:
|
|
27
|
+
export declare function workspaceLabel(attempt: TaskAttempt): string;
|
|
28
28
|
export declare class HerdrHostRuntime implements HostRuntime {
|
|
29
29
|
private readonly inspectInFlightCandidate;
|
|
30
30
|
private readonly execute;
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runProcess as defaultRunProcess } from "./process.js";
|
|
|
11
11
|
import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
|
|
12
12
|
import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
|
|
13
13
|
import { exactDirectTerminalTurn } from "./direct-herdr.js";
|
|
14
|
+
import { READABLE_BRANCH_PATTERN } from "./worktree.js";
|
|
14
15
|
const MIN_HERDR_VERSION = [0, 9, 3];
|
|
15
16
|
const MIN_HERDR_PROTOCOL = 22;
|
|
16
17
|
const HERDR_OPERATION_CAP_MS = 30_000;
|
|
@@ -153,10 +154,14 @@ function worktreeIntent(attempt) {
|
|
|
153
154
|
return intent.worktree;
|
|
154
155
|
}
|
|
155
156
|
export function workspaceLabel(attempt) {
|
|
156
|
-
|
|
157
|
+
const { branch } = worktreeIntent(attempt);
|
|
158
|
+
return READABLE_BRANCH_PATTERN.test(branch) ? basename(branch) : attempt.correlationToken.slice(-6);
|
|
157
159
|
}
|
|
158
|
-
function expectedWorkerLabel(task) {
|
|
159
|
-
|
|
160
|
+
function expectedWorkerLabel(task, attempt) {
|
|
161
|
+
const { branch } = worktreeIntent(attempt);
|
|
162
|
+
return READABLE_BRANCH_PATTERN.test(branch)
|
|
163
|
+
? `${workspaceLabel(attempt)}/${task.modelClass}`
|
|
164
|
+
: `${task.role}/${task.modelClass}`;
|
|
160
165
|
}
|
|
161
166
|
function expectedAgentName(token) {
|
|
162
167
|
if (!CORRELATION_TOKEN_PATTERN.test(token))
|
|
@@ -187,7 +192,7 @@ function assertWorkspaceIntent(intent, attempt) {
|
|
|
187
192
|
function assertWorkerTabIntent(intent, task, attempt) {
|
|
188
193
|
const worktree = worktreeIntent(attempt);
|
|
189
194
|
const workspace = ownedIntent(attempt, "workspace");
|
|
190
|
-
if (exactString(intent.label, "worker-tab label") !== expectedWorkerLabel(task)
|
|
195
|
+
if (exactString(intent.label, "worker-tab label") !== expectedWorkerLabel(task, attempt)
|
|
191
196
|
|| exactString(intent.workspaceId, "worker-tab workspace ID") !== workspace.workspaceId
|
|
192
197
|
|| exactString(intent.workspaceRootTabId, "workspace root tab ID") !== workspace.rootTabId
|
|
193
198
|
|| exactString(intent.workspaceRootPaneId, "workspace root pane ID") !== workspace.rootPaneId
|
|
@@ -340,7 +345,7 @@ export class HerdrHostRuntime {
|
|
|
340
345
|
this.assertLeasePath(leasePath, input.attempt.correlationToken);
|
|
341
346
|
return {
|
|
342
347
|
kind: "worker_tab",
|
|
343
|
-
label: expectedWorkerLabel(input.task),
|
|
348
|
+
label: expectedWorkerLabel(input.task, input.attempt),
|
|
344
349
|
workspaceId,
|
|
345
350
|
workspaceRootTabId: exactString(workspace.rootTabId, "owned workspace root tab ID"),
|
|
346
351
|
workspaceRootPaneId: exactString(workspace.rootPaneId, "owned workspace root pane ID"),
|
|
@@ -17,7 +17,7 @@ export declare class IntegrationGit {
|
|
|
17
17
|
/** Refresh is allowed only for the exact clean checked-out Main and a strict same-branch descendant. */
|
|
18
18
|
inspectMainAdvance(root: string, from: WorkspaceIdentity, to: WorkspaceIdentity, signal: AbortSignal): Promise<void>;
|
|
19
19
|
/** Persist the prepared path/branch/base before Git is allowed to create anything. */
|
|
20
|
-
allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal): Promise<GitOutcome<WorktreeInfo>>;
|
|
20
|
+
allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal, nameParts?: readonly string[]): Promise<GitOutcome<WorktreeInfo>>;
|
|
21
21
|
/** Prove each recorded merge before any new merge or final gate. */
|
|
22
22
|
inspectCombined(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], signal: AbortSignal): Promise<WorkspaceIdentity>;
|
|
23
23
|
/** Main's correction is one exact committed child of the checked staged tip. */
|
package/dist/integration-git.js
CHANGED
|
@@ -158,7 +158,7 @@ export class IntegrationGit {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
/** Persist the prepared path/branch/base before Git is allowed to create anything. */
|
|
161
|
-
async allocate(root, childId, base, onPrepared, signal) {
|
|
161
|
+
async allocate(root, childId, base, onPrepared, signal, nameParts) {
|
|
162
162
|
if (!sameIdentity(await inspect(root, signal), base))
|
|
163
163
|
return { outcome: "drift", failure: "Main drifted before integration allocation." };
|
|
164
164
|
let prepared;
|
|
@@ -170,7 +170,7 @@ export class IntegrationGit {
|
|
|
170
170
|
await onPrepared(info);
|
|
171
171
|
if (!sameIdentity(await inspect(root, signal), base))
|
|
172
172
|
throw new Error("Main drifted before worktree add.");
|
|
173
|
-
});
|
|
173
|
+
}, nameParts);
|
|
174
174
|
if (!added)
|
|
175
175
|
return { outcome: "blocked", failure: "Integration requires committed Git Main." };
|
|
176
176
|
if (!sameIdentity(await inspect(root, signal), base))
|
package/dist/prune.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface PruneItem {
|
|
|
33
33
|
ignored: readonly string[];
|
|
34
34
|
}
|
|
35
35
|
export interface PruneOwnership {
|
|
36
|
-
/** Every request or state that may still own
|
|
36
|
+
/** Every request or state that may still own an exact resource name; unreadable state has no request. */
|
|
37
37
|
owners(name: string): readonly {
|
|
38
38
|
label: string;
|
|
39
39
|
request?: PruneRequest;
|
package/dist/runner.d.ts
CHANGED
package/dist/runner.js
CHANGED
|
@@ -5,7 +5,7 @@ import { utf8Prefix } from "./ephemeral.js";
|
|
|
5
5
|
import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
|
|
6
6
|
import { runGit } from "./git-process.js";
|
|
7
7
|
import { IntegrationGit } from "./integration-git.js";
|
|
8
|
-
import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, } from "./schema.js";
|
|
8
|
+
import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, TERMINAL_RUN_STATUSES, } from "./schema.js";
|
|
9
9
|
import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
|
|
10
10
|
const TRUNCATION_MARKER = "\n[truncated]";
|
|
11
11
|
const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
|
|
@@ -427,15 +427,17 @@ function correctionEligible(request, attempt) {
|
|
|
427
427
|
&& sameIdentity(attempt.preliminaryReview.identityAfter, attempt.candidate)));
|
|
428
428
|
}
|
|
429
429
|
function terminal(state) {
|
|
430
|
-
return
|
|
430
|
+
return TERMINAL_RUN_STATUSES.some((status) => status === state.status);
|
|
431
431
|
}
|
|
432
|
-
function inventoryStatus(state) {
|
|
432
|
+
function inventoryStatus(state, orphaned = false) {
|
|
433
|
+
if (orphaned && ["pending", "running"].includes(state.status))
|
|
434
|
+
return `saved ${state.status} · unverified`;
|
|
433
435
|
const retained = state.tasks.some((task) => task.kind === "changeset"
|
|
434
436
|
? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
|
|
435
437
|
: task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
|
|
436
438
|
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
437
439
|
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true));
|
|
438
|
-
return
|
|
440
|
+
return terminal(state) && retained ? `${state.status} · retained` : state.status;
|
|
439
441
|
}
|
|
440
442
|
/** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
|
|
441
443
|
export function buildChangesetTaskPrompt(input) {
|
|
@@ -670,7 +672,7 @@ export class IsolatedRunner {
|
|
|
670
672
|
}
|
|
671
673
|
const requests = [];
|
|
672
674
|
for (const request of inventory.requests) {
|
|
673
|
-
if (["pending", "running", "needs_attention"
|
|
675
|
+
if (["pending", "running", "needs_attention"].includes(request.status) || request.status.endsWith(" · retained")) {
|
|
674
676
|
requests.push(await this.status(request.id, root));
|
|
675
677
|
}
|
|
676
678
|
}
|
|
@@ -683,7 +685,7 @@ export class IsolatedRunner {
|
|
|
683
685
|
const owners = [];
|
|
684
686
|
let lease;
|
|
685
687
|
const request = ({ state, path }, orphaned) => ({ id: state.request.id, name: state.request.goal,
|
|
686
|
-
status: inventoryStatus(state), directory: dirname(path), root: state.root, orphaned });
|
|
688
|
+
status: inventoryStatus(state, orphaned), directory: dirname(path), root: state.root, orphaned });
|
|
687
689
|
const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
|
|
688
690
|
for (const handle of orphans) {
|
|
689
691
|
const { state } = handle;
|
|
@@ -1179,7 +1181,7 @@ export class IsolatedRunner {
|
|
|
1179
1181
|
};
|
|
1180
1182
|
generations.push(generation);
|
|
1181
1183
|
await this.saveProductive(handle); // Before worktree add.
|
|
1182
|
-
}, context.signal));
|
|
1184
|
+
}, context.signal, [action.id, `integration-${action.generation}`]));
|
|
1183
1185
|
if (result.outcome !== "ready") {
|
|
1184
1186
|
if (!generations.at(-1))
|
|
1185
1187
|
throw new Error("Integration allocation did not persist an owned worktree plan.");
|
|
@@ -1380,7 +1382,7 @@ export class IsolatedRunner {
|
|
|
1380
1382
|
state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation,
|
|
1381
1383
|
status: "unknown", failure: "Integration allocation has not been proved; retain the recorded checkout." };
|
|
1382
1384
|
await this.saveProductive(handle);
|
|
1383
|
-
}, context.signal));
|
|
1385
|
+
}, context.signal, [action.id, `integration-${action.generation}`]));
|
|
1384
1386
|
}
|
|
1385
1387
|
catch (error) {
|
|
1386
1388
|
// A prepared checkout remains recorded; no repeated allocation from this intent.
|
|
@@ -1786,7 +1788,7 @@ export class IsolatedRunner {
|
|
|
1786
1788
|
const primary = await primaryCheckout(cwd);
|
|
1787
1789
|
const { states, invalidIds } = await this.store.listOrphans(primary);
|
|
1788
1790
|
return { invalidIds, requests: states.map((handle) => ({
|
|
1789
|
-
id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state),
|
|
1791
|
+
id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state, true),
|
|
1790
1792
|
directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
|
|
1791
1793
|
})) };
|
|
1792
1794
|
}
|
|
@@ -2301,6 +2303,7 @@ export class IsolatedRunner {
|
|
|
2301
2303
|
try {
|
|
2302
2304
|
if (intent.kind === "worktree") {
|
|
2303
2305
|
result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.allocateWorktree({
|
|
2306
|
+
requestId: state.request.id,
|
|
2304
2307
|
root: state.root,
|
|
2305
2308
|
...(attempt.waveBase.head !== state.main.head ? { baseRoot: state.integration.generations.at(-1)?.worktree?.path } : {}),
|
|
2306
2309
|
intent,
|
|
@@ -2426,7 +2429,7 @@ export class IsolatedRunner {
|
|
|
2426
2429
|
if (worktree.baseCommit !== wave.base.head || !sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
|
|
2427
2430
|
throw new Error("Staged dependency snapshot drifted before text worktree creation.");
|
|
2428
2431
|
}
|
|
2429
|
-
});
|
|
2432
|
+
}, [state.request.id, task.taskId, `text-${attempt.number}`]);
|
|
2430
2433
|
if (!isolated)
|
|
2431
2434
|
throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
|
|
2432
2435
|
let result;
|
package/dist/schema.d.ts
CHANGED
|
@@ -1791,6 +1791,8 @@ export type IntegrationCandidate = Static<typeof IntegrationCandidateSchema>;
|
|
|
1791
1791
|
export type IntegrationStage = Static<typeof IntegrationStageSchema>;
|
|
1792
1792
|
export type IntegrationGeneration = Static<typeof IntegrationGenerationSchema>;
|
|
1793
1793
|
export type IntegrationState = Static<typeof IntegrationStateSchema>;
|
|
1794
|
+
declare const TerminalRunStatusSchema: Type.TUnion<[Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
|
|
1795
|
+
export declare const TERMINAL_RUN_STATUSES: readonly Static<typeof TerminalRunStatusSchema>[];
|
|
1794
1796
|
declare const RunStateSchema: Type.TObject<{
|
|
1795
1797
|
version: Type.TLiteral<5>;
|
|
1796
1798
|
request: Type.TObject<{
|
package/dist/schema.js
CHANGED
|
@@ -412,6 +412,10 @@ export const IntegrationStateSchema = Type.Object({
|
|
|
412
412
|
failure: OptionalRuntimeTextSchema,
|
|
413
413
|
}, { additionalProperties: false })),
|
|
414
414
|
}, { additionalProperties: false });
|
|
415
|
+
const TerminalRunStatusSchema = Type.Union([
|
|
416
|
+
Type.Literal("completed"), Type.Literal("final_failed"), Type.Literal("superseded"), Type.Literal("aborted"),
|
|
417
|
+
]);
|
|
418
|
+
export const TERMINAL_RUN_STATUSES = TerminalRunStatusSchema.anyOf.map((status) => status.const);
|
|
415
419
|
const RunStateSchema = Type.Object({
|
|
416
420
|
version: Type.Literal(RUN_STATE_VERSION),
|
|
417
421
|
request: ExecuteRequestSchema,
|
|
@@ -421,8 +425,8 @@ const RunStateSchema = Type.Object({
|
|
|
421
425
|
requestStartMain: WorkspaceSchema,
|
|
422
426
|
main: WorkspaceSchema,
|
|
423
427
|
status: Type.Union([
|
|
424
|
-
Type.Literal("pending"), Type.Literal("running"), Type.Literal("needs_attention"),
|
|
425
|
-
|
|
428
|
+
Type.Literal("pending"), Type.Literal("running"), Type.Literal("needs_attention"),
|
|
429
|
+
...TerminalRunStatusSchema.anyOf,
|
|
426
430
|
]),
|
|
427
431
|
tasks: Type.Array(TaskStateSchema, { minItems: 1, maxItems: MAX_TASKS }),
|
|
428
432
|
waves: Type.Array(WaveStateSchema, { maxItems: MAX_TASKS }),
|
package/dist/worktree.d.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type WorktreePayload = {
|
|
|
25
25
|
dirty?: boolean;
|
|
26
26
|
};
|
|
27
27
|
export declare const WORKTREES_DIRNAME = ".worktrees";
|
|
28
|
-
|
|
28
|
+
export declare const READABLE_BRANCH_PATTERN: RegExp;
|
|
29
|
+
/** Keep saved opaque names eligible for exact recovery and cleanup. */
|
|
29
30
|
export declare const OWNED_BRANCH_PATTERN: RegExp;
|
|
30
31
|
export declare class WorktreeSetupError extends Error {
|
|
31
32
|
name: string;
|
|
@@ -39,7 +40,7 @@ export declare class WorktreeSetupError extends Error {
|
|
|
39
40
|
* repository (unwritable path, stale branch, git lock) throw so an isolated
|
|
40
41
|
* role never silently loses its isolation.
|
|
41
42
|
*/
|
|
42
|
-
export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void
|
|
43
|
+
export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void>, nameParts?: readonly string[]): Promise<WorktreeInfo | undefined>;
|
|
43
44
|
export interface WorktreeDirtyInspection {
|
|
44
45
|
dirty: boolean;
|
|
45
46
|
failure?: string;
|
package/dist/worktree.js
CHANGED
|
@@ -5,8 +5,11 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
import { runGit } from "./git-process.js";
|
|
6
6
|
export const WORKTREES_DIRNAME = ".worktrees";
|
|
7
7
|
const BRANCH_NAMESPACE = "pi-subagent";
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const NAME_COMPONENT_LIMIT = 24;
|
|
9
|
+
const NAME_LIMIT = 72;
|
|
10
|
+
export const READABLE_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[a-z0-9](?:[a-z0-9-]{0,${NAME_LIMIT - 2}}[a-z0-9])?-[0-9a-f]{24}$`);
|
|
11
|
+
/** Keep saved opaque names eligible for exact recovery and cleanup. */
|
|
12
|
+
export const OWNED_BRANCH_PATTERN = new RegExp(`${READABLE_BRANCH_PATTERN.source}|^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
|
|
10
13
|
export class WorktreeSetupError extends Error {
|
|
11
14
|
name = "WorktreeSetupError";
|
|
12
15
|
worktree;
|
|
@@ -16,6 +19,11 @@ export class WorktreeSetupError extends Error {
|
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
const sanitizeShortId = (childId) => createHash("sha256").update(childId).digest("hex").slice(0, 24);
|
|
22
|
+
function nameSlug(parts) {
|
|
23
|
+
return parts.map((part) => part.toLowerCase().replace(/[^a-z0-9]+/g, "-")
|
|
24
|
+
.slice(0, NAME_COMPONENT_LIMIT).replace(/^-+|-+$/g, "") || "task")
|
|
25
|
+
.join("-").slice(0, NAME_LIMIT).replace(/-+$/, "") || "task";
|
|
26
|
+
}
|
|
19
27
|
const stripGitLineEnd = (value) => value.replace(/\r?\n$/, "");
|
|
20
28
|
function hasRepositoryMarker(cwd) {
|
|
21
29
|
for (let directory = resolve(cwd);; directory = dirname(directory)) {
|
|
@@ -60,7 +68,7 @@ async function ensureLocalExclude(gitDir) {
|
|
|
60
68
|
* repository (unwritable path, stale branch, git lock) throw so an isolated
|
|
61
69
|
* role never silently loses its isolation.
|
|
62
70
|
*/
|
|
63
|
-
export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared) {
|
|
71
|
+
export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared, nameParts = [childId]) {
|
|
64
72
|
const root = await run(["rev-parse", "--show-toplevel"], cwd, signal);
|
|
65
73
|
signal?.throwIfAborted();
|
|
66
74
|
if (root.code === -1)
|
|
@@ -130,7 +138,7 @@ export async function createChildWorktree(cwd, childId, run = runGit, signal, on
|
|
|
130
138
|
}
|
|
131
139
|
signal?.throwIfAborted();
|
|
132
140
|
const worktreesRoot = join(stableRepoRoot, WORKTREES_DIRNAME);
|
|
133
|
-
const name = `subagent-${sanitizeShortId(childId)}`;
|
|
141
|
+
const name = `subagent-${nameSlug(nameParts)}-${sanitizeShortId(childId)}`;
|
|
134
142
|
const branch = `${BRANCH_NAMESPACE}/${name}`;
|
|
135
143
|
const path = join(worktreesRoot, name);
|
|
136
144
|
const worktree = { path, cwd: join(path, relativeCwd), branch, repoRoot: stableRepoRoot, baseCommit };
|
package/docs/orchestration.md
CHANGED
|
@@ -194,7 +194,13 @@ Activity uses the existing session sample and file-size cache. The extension ign
|
|
|
194
194
|
|
|
195
195
|
## Isolated contract
|
|
196
196
|
|
|
197
|
-
An isolated request has one durable ID, one goal, and 1 to 8 typed tasks
|
|
197
|
+
An isolated request has one durable ID, one goal, and 1 to 8 typed tasks.
|
|
198
|
+
|
|
199
|
+
New worktrees use `.worktrees/subagent-<name>-<24hex>` and branch `pi-subagent/subagent-<name>-<24hex>`. The name uses the request and task IDs. Text tasks also include `text-<attempt>`. Integration checkouts use the request ID and `integration-<generation>`. Each name component has at most 24 characters; the full readable part has at most 72. Names use lowercase ASCII letters, digits, and hyphens. The 24-hex identity suffix keeps names distinct when readable parts are shortened or normalized.
|
|
200
|
+
|
|
201
|
+
Herdr workspace labels use the worker worktree name. Worker tab labels add the model class. Names never use goals, requirements, or other free-form task text. Saved opaque worktree names and their Herdr labels stay unchanged. Resume and cleanup use the exact saved allocations; the extension does not rename existing resources.
|
|
202
|
+
|
|
203
|
+
For example:
|
|
198
204
|
|
|
199
205
|
```json
|
|
200
206
|
{
|
|
@@ -271,13 +277,13 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
|
|
|
271
277
|
|
|
272
278
|
To select work by label, open `/subagent` without arguments. First, a **Current subagent work** report shows the work that local and saved evidence proves is running: locally owned direct workflows with a running worker, and active isolated requests while a live run holds the repository lease. The report lists active requests without a live lease as interrupted, and recorded-only direct work as unverified. A live lease does not identify its request, so several active requests in one checkout all show as working. 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**.
|
|
273
279
|
|
|
274
|
-
The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. 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.
|
|
280
|
+
The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. Saved `running` and `pending` states have a `saved` prefix and an `unverified` suffix. The report lists them under **Unverified**, never **Working**, even if the current checkout has a live lease. These labels do not change saved state or prove completion. Orphaned requests with retained resources or `needs_attention` show under **Needs attention**. 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.
|
|
275
281
|
|
|
276
282
|
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.
|
|
277
283
|
|
|
278
284
|
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`.
|
|
279
285
|
|
|
280
|
-
|
|
286
|
+
Both isolated groups hide terminal requests by default: `completed`, `aborted`, `final_failed`, and `superseded`. Requests with retained resources or unresolved recovery stay visible. Select **Completed / history** to see all saved requests; **Refresh** reloads the current view. The filter does not delete records. Without interactive UI, the request list includes history. The extension shows a warning for invalid state IDs and preserves them. Direct recovery still works outside Git or when isolated inventory is not available. The browse action does not resume work or change resources. Direct **Close/cancel-and-release** is an explicit termination action.
|
|
281
287
|
|
|
282
288
|
For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor. A submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text shows in FIFO order, with blank lines between them. An explicit submission queues **one replacement** at the end. If you cancel, the withdrawn instructions stay removed.
|
|
283
289
|
|
|
@@ -400,7 +406,7 @@ One confirmation permits permanent deletion of committed, uncommitted, and ignor
|
|
|
400
406
|
|
|
401
407
|
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.
|
|
402
408
|
|
|
403
|
-
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.
|
|
409
|
+
Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<name>-<24hex>` branches, saved legacy `pi-subagent/subagent-<24hex>` branches, and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
|
|
404
410
|
|
|
405
411
|
To close and delete many requests, select **Select multiple…** in `/subagent`, select the requests, then select **Continue** and **Close and delete worktrees (discard work)**. The command shows the review of each request. Then one confirmation covers the unblocked resource groups of all of them. The same menu can close all owned workers of each selected direct workflow that can close. The command reports the number of selected rows that the action skipped.
|
|
406
412
|
|
|
@@ -408,7 +414,7 @@ The report lists removed, skipped, and failed resources. After complete cleanup,
|
|
|
408
414
|
|
|
409
415
|
### Prune leftover worktrees
|
|
410
416
|
|
|
411
|
-
Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent
|
|
417
|
+
Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent-<name>-<24hex>`, or the saved legacy name `pi-subagent/subagent-<24hex>`. The command also cleans up such branches that have no checkout. The review starts with the number of removable and blocked items. It groups the items by the exact reason that the command can remove them, or by their exact blockers, and shows each reason once. Under each reason, it shows the readable name, if present, and the first 8 characters of the identity suffix. The command can remove an item only when all of these conditions are true:
|
|
412
418
|
|
|
413
419
|
- Its branch tip is already in Main's HEAD or in the local `origin` default-branch ref. The command does not fetch.
|
|
414
420
|
- Its checkout has no tracked or untracked changes. No merge, rebase, cherry-pick, revert, or bisect is in progress.
|
|
@@ -2,6 +2,7 @@ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type Theme
|
|
|
2
2
|
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { DiscardPlan } from "../dist/discard.js";
|
|
4
4
|
import type { PruneItem } from "../dist/prune.js";
|
|
5
|
+
import { TERMINAL_RUN_STATUSES } from "../dist/schema.js";
|
|
5
6
|
import { compactCost } from "./result-transport.ts";
|
|
6
7
|
|
|
7
8
|
/** Saved before launch for user matching only; never ownership or mutation authority. */
|
|
@@ -192,7 +193,7 @@ const summary = (text: string, max = 60): string => {
|
|
|
192
193
|
};
|
|
193
194
|
// Identity and status first so clipping cuts only the goal summary.
|
|
194
195
|
const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
|
|
195
|
-
const completed = (status: string) => [
|
|
196
|
+
const completed = (status: string) => [...TERMINAL_RUN_STATUSES, "success", "failed", "failure", "integrated"].includes(status);
|
|
196
197
|
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
197
198
|
// Recorded-only and unverified work is uncertain, not active.
|
|
198
199
|
const statusTone = (status: string): Tone => /attention|blocked|retained|recorded only|unverified/i.test(status) ? "warning"
|
|
@@ -202,7 +203,7 @@ const glyph = (status: string) => TONES[statusTone(status)].glyph;
|
|
|
202
203
|
const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
|
|
203
204
|
|
|
204
205
|
const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
|
|
205
|
-
const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").
|
|
206
|
+
const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").replace(/([0-9a-f]{8})[0-9a-f]{16}$/, "$1");
|
|
206
207
|
const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
|
|
207
208
|
|
|
208
209
|
/** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
|
|
@@ -246,7 +247,7 @@ function pruneReview({ root, items }: PrunePlan): { report: Report; shown: Prune
|
|
|
246
247
|
const prunable = items.filter((item) => item.prunable).length;
|
|
247
248
|
const head: Report = { tone: "info", title: "Subagent prune review", sections: [{ rows: [
|
|
248
249
|
{ label: "root", value: root, code: true }, { label: "prunable", value: String(prunable) }, { label: "blocked", value: String(items.length - prunable) },
|
|
249
|
-
{ label: "IDs", value: "first 8 characters
|
|
250
|
+
{ label: "IDs", value: "readable name, if present, and first 8 suffix characters" },
|
|
250
251
|
] }] };
|
|
251
252
|
// Each line measures its exact Markdown, so the cap never cuts a shown item.
|
|
252
253
|
const reviewed = fitItems(reportMarkdown(head), rows, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
|
|
@@ -311,7 +312,8 @@ export function workingReport(direct: readonly DirectTask[], inventory: Isolated
|
|
|
311
312
|
const active = !request.orphaned && ["pending", "running"].includes(request.status);
|
|
312
313
|
if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
|
|
313
314
|
else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
|
|
314
|
-
else if (
|
|
315
|
+
else if (request.status === "needs_attention" || /retained/.test(request.status)) attention.push({ text: isolatedLabel(request) });
|
|
316
|
+
else if (request.orphaned && /unverified/.test(request.status)) unverified.push({ text: isolatedLabel(request) });
|
|
315
317
|
}
|
|
316
318
|
const sections: Section[] = [
|
|
317
319
|
working.length ? { heading: `Working (${working.length})`, tone: "info", rows: working } : { rows: [{ text: inventory ? "No subagents are working." : "No direct subagents are working. Isolated status is unavailable." }] },
|
|
@@ -531,13 +533,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
531
533
|
const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
|
|
532
534
|
items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
|
|
533
535
|
...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
|
|
534
|
-
const requests = inventory?.requests ?? [];
|
|
536
|
+
const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
|
|
535
537
|
const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
|
|
536
538
|
// One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
|
|
537
539
|
const directChoice = (task: DirectTask): Choice<Selection> => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } });
|
|
538
540
|
const groups = [
|
|
539
541
|
group("Direct", direct.filter((task) => history || !completed(task.status)), directChoice),
|
|
540
|
-
group("Isolated", requests.filter((request) => !request.orphaned
|
|
542
|
+
group("Isolated", requests.filter((request) => !request.orphaned), isolatedChoice),
|
|
541
543
|
group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
|
|
542
544
|
].flat();
|
|
543
545
|
const rows = groups.filter(({ value }) => value.kind === "direct" || value.kind === "isolated");
|