@henryqw/pi-subagent 27.0.0 → 27.1.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/CONTEXT.md +1 -1
- package/README.md +17 -4
- package/dist/herdr-runtime.js +3 -1
- package/dist/integration-git.js +3 -3
- package/dist/prune.d.ts +50 -0
- package/dist/prune.js +192 -0
- package/dist/runner.d.ts +13 -0
- package/dist/runner.js +104 -10
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -5
- package/dist/store.d.ts +2 -0
- package/dist/store.js +14 -2
- package/dist/worktree.d.ts +3 -0
- package/dist/worktree.js +3 -1
- package/docs/orchestration.md +4 -2
- package/extensions/isolated.ts +113 -36
- package/extensions/result-transport.ts +22 -2
- package/extensions/subagent-command.ts +88 -2
- package/extensions/subagent.ts +15 -5
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +2 -2
package/CONTEXT.md
CHANGED
|
@@ -61,7 +61,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
61
61
|
- Promotion requires exact unchanged clean Main and a passing combined generation. Durable promotion evidence precedes selected-worker termination and cleanup; recovery never rolls back a proven promotion.
|
|
62
62
|
- Rejected workers and superseded generations stay retained until Main explicitly releases their exact clean, proved-owned resources. At most two unreleased integration checkouts coexist.
|
|
63
63
|
- Failures, ambiguity, interruption, review findings, conflicts, drift, unproved termination, or cleanup failure enter `needs_attention` and preserve evidence.
|
|
64
|
-
- `subagent_status` is read-only, callable from `codemode` scripts, and returns the bounded public projection as structured content, never raw run state. `delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. `subagent_resume` accepts only strict `retry`, `verify`, or `finalize` continuations. `subagent_abort` terminates only exact owned workers.
|
|
64
|
+
- `subagent_status` is read-only, callable from `codemode` scripts, and returns the bounded public projection as structured content, never raw run state. `delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. `subagent_resume` accepts only strict `retry`, `verify`, or `finalize` continuations; only a correcting `retry` takes Main's optional instruction. `subagent_abort` terminates only exact owned workers.
|
|
65
65
|
- The extension never pushes, opens a pull request, publishes, deploys, stashes, resets, force-cleans, or deletes unproved recoverable work.
|
|
66
66
|
|
|
67
67
|
### Resource and launch policy
|
package/README.md
CHANGED
|
@@ -35,10 +35,11 @@ Commands are for you. Delegation and state tools and the packaged skill are for
|
|
|
35
35
|
| --- | --- | --- |
|
|
36
36
|
| `/subagent` | command | Browse work, Close/cancel-and-release locally owned direct workers, stop one running worker of a read workflow, launch isolated status inspection in a separate Herdr tab with the `fast` model, or send/edit pending instructions. Requires interactive TUI or RPC; inspection also requires a Herdr-managed pane and a configured `scout` Role. |
|
|
37
37
|
| `/subagent roles` | command | List each Role with its source (built-in, user override, or user), resolved route model and thinking level or route error, tools, extensions, Skills, MCP servers, and per-file load errors. Read-only; no Herdr pane required. |
|
|
38
|
+
| `/subagent prune` | command | Review leftover worker and integration worktrees and branches in the current canonical repository, then remove the ones already merged after your confirmation. Without interactive UI it only reports. Requires a running Herdr server to prove that no pane uses a checkout. |
|
|
38
39
|
| `/subagent recover` | command | Classify orphaned isolated work in the current canonical repository and send Main one follow-up with request IDs, blockers, and reported next actions. No UI or Herdr pane required. |
|
|
39
40
|
| `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. |
|
|
40
|
-
| `subagent_status` | tool | Read durable isolated state and the exact allowed continuation without replaying work. Declares an output schema, so `codemode` scripts receive the same bounded public projection as structured content. |
|
|
41
|
-
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation; does not replace staging or integration. |
|
|
41
|
+
| `subagent_status` | tool | Read durable isolated state and the exact allowed continuation without replaying work. Declares an output schema, so `codemode` scripts receive the same bounded public projection as structured content. Failed review verdicts from tasks (including tasks whose siblings still run), the final gate, and integration generations appear in its text within one shared 32 KiB budget; omitted verdicts are counted. The structured projection carries each verdict up to 32 KiB, with `verdictTruncated` set when it is cut. |
|
|
42
|
+
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation; does not replace staging or integration. A `retry` that sends a same-worker correction accepts an optional `instruction` (at most 32000 characters), added to that prompt as Main's instruction after the saved failure. Other actions reject it. |
|
|
42
43
|
| `subagent_stage` | tool | Stage or resolve an exact candidate, or reject/revise it; rejection of a staged candidate freezes that generation. |
|
|
43
44
|
| `subagent_integrate` | tool | Advance staged dependents, refresh after clean Main drift or evidence-less validation failure, validate in the background, record one correction, promote, reconcile interrupted promotion, clean up proven promotion, or explicitly release rejected/superseded resources. |
|
|
44
45
|
| `subagent_abort` | tool | Abort an isolated request only when no retained candidates or integration worktrees remain; cannot discard them. |
|
|
@@ -110,7 +111,7 @@ If tab creation times out or returns incomplete or mismatched identity, `/subage
|
|
|
110
111
|
|
|
111
112
|
Tasks in a write request must return one JSON completion object, such as `{"outcome":"succeeded","answer":"Scoped commit completed."}`. `outcome` must be `succeeded`, `failed`, or `blocked`, and `answer` must be non-empty. A normal final Pi turn is not enough: failure, blocked work, plain text, or malformed completion stops later dispatch, but admission depends on proved termination, not the reported outcome. Success is the worker's explicit report, not independent validation of its changes.
|
|
112
113
|
|
|
113
|
-
The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. When a task fails after its worker produced text, the result keeps a bounded excerpt marked as partial output; it is never success. Every worker result delivered to Main starts with a header stating that the text is the worker's output and carries no user authority. Text that imitates harness framing, such as `<system-reminder>`-style tags or lines starting with `Human:` or `Assistant:`, gets an inserted backslash; nothing else changes. Isolated status and follow-up answer previews use the same header. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. The menu has one row per workflow: a single worker shows its reference, Role, and name (`D7.1 · worker · Search audit`); a multi-worker workflow shows its reference and worker count (`D8 · 3 workers`), and selecting it lists each recorded worker by reference, Role, and name with its exact IDs. References and names help you match rows; they are never used to decide ownership. Older records without saved references show their saved allocation label or exact task ID. A `recorded only` row is not proof that its workers are running or finished; locally observed work is labelled separately.
|
|
114
|
+
The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. Each launched worker's line in the result shows its model, thinking level, measured session tokens (`tokens unknown` when Pi recorded no usage), and elapsed time, such as `✓ [1/2] Check · worker · provider/model · thinking high · 1.5k tok · 42s`. Failed and stopped workers show the usage measured until they ended. A worker skipped before launch shows no usage. The result details carry the same values in each entry as `model`, `thinkingLevel`, `tokens`, and `elapsedMs`; `tokens` is absent when unknown. When a task fails after its worker produced text, the result keeps a bounded excerpt marked as partial output; it is never success. Every worker result delivered to Main starts with a header stating that the text is the worker's output and carries no user authority. Text that imitates harness framing, such as `<system-reminder>`-style tags or lines starting with `Human:` or `Assistant:`, gets an inserted backslash; nothing else changes. Isolated status and follow-up answer previews use the same header. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. The menu has one row per workflow: a single worker shows its reference, Role, and name (`D7.1 · worker · Search audit`); a multi-worker workflow shows its reference and worker count (`D8 · 3 workers`), and selecting it lists each recorded worker by reference, Role, and name with its exact IDs. References and names help you match rows; they are never used to decide ownership. Older records without saved references show their saved allocation label or exact task ID. A `recorded only` row is not proof that its workers are running or finished; locally observed work is labelled separately.
|
|
114
115
|
|
|
115
116
|
### Continue or stop one direct worker
|
|
116
117
|
|
|
@@ -167,7 +168,7 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
|
|
|
167
168
|
|
|
168
169
|
`dependsOn` controls scheduling. `contextFrom` also schedules dependencies and carries completed text outputs in the declared order. Do not repeat the same task in both fields. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
|
|
169
170
|
|
|
170
|
-
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. Session replacement or shutdown suppresses stale delivery and logs the request ID and reason, without prompts or credentials. Synchronous delivery failures are reported without automatic replay; Pi reports asynchronous `send_message` failures through its native runtime. If delivery is missed, inspect `subagent_status`.
|
|
171
|
+
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If the turn ends with tracked or untracked changes left in the worktree, the coordinator first sends that same worker one saved reminder per attempt to commit them or explain why not. The reminder does not use a correction or a dispatch attempt, and the coordinator never commits, stages, stashes, or discards the changes itself. If the worktree is still dirty after the reminder, the task needs attention and the changes stay in place. Follow-ups show only a short preview of a failed review verdict and tell Main to run `subagent_status` for the full text. Session replacement or shutdown suppresses stale delivery and logs the request ID and reason, without prompts or credentials. Synchronous delivery failures are reported without automatic replay; Pi reports asynchronous `send_message` failures through its native runtime. If delivery is missed, inspect `subagent_status`.
|
|
171
172
|
|
|
172
173
|
Completed text answers are saved independently of cleanup. Status and terminal follow-ups include a bounded accepted-answer preview and cleanup evidence. Ignored automatic artifacts can retain a checkout without violating the read-only contract; no ignored files are deleted to make cleanup pass. Tracked or untracked changes, changed checkout identity, or unproved inspection still require attention: the completed answer remains saved but is not accepted as dependency context or retried automatically. Retained text checkouts require deliberate manual inspection and cleanup; a retained checkout alone is not evidence that the Role edited source.
|
|
173
174
|
|
|
@@ -187,6 +188,18 @@ If validation was interrupted, first prove the old coordinator/check processes h
|
|
|
187
188
|
|
|
188
189
|
After a coordinator interruption, run `/subagent recover` once. It acquires the repository productive lease before changing any orphaned `pending` or `running` state, preserves unreadable state files and retained candidates, and reports blockers and existing continuations to Main without replaying prompts, merges, promotion, or cleanup. If another productive run holds the lease, it only reads status and reports the live owner; wait rather than taking over. Repeating the command is safe. Main uses `subagent_status` for exact identities and the recovery tools in the interface table above for deliberate decisions; the command never stages or promotes. Direct Herdr tab recovery remains in `/subagent`.
|
|
189
190
|
|
|
191
|
+
### Prune leftover worktrees
|
|
192
|
+
|
|
193
|
+
Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/` (branches named `pi-subagent/subagent-` plus 24 hex characters) and such branches without a checkout. The review lists each item with its path, branch, tip, and either why it can be removed or every exact blocker. An item can be removed only when all of these are true:
|
|
194
|
+
|
|
195
|
+
- Its branch tip is already in Main's HEAD or in the local `origin` default-branch ref. Nothing is fetched.
|
|
196
|
+
- Its checkout has no tracked or untracked changes and no merge, rebase, cherry-pick, revert, or bisect in progress.
|
|
197
|
+
- No unfinished isolated request, in any checkout of this repository, records it. An unreadable state file blocks every item it mentions.
|
|
198
|
+
- No productive run holds a repository lease.
|
|
199
|
+
- No Herdr pane works inside the checkout. If Herdr cannot be inspected, every checkout is refused.
|
|
200
|
+
|
|
201
|
+
One confirmation removes every listed removable item. Removing a checkout also deletes its ignored files, such as `node_modules` or `dist`, so checkouts with ignored files are included only after a second confirmation that names each ignored entry. That approval covers everything inside each named entry; a new or changed top-level ignored entry skips the checkout. If a checkout's full list of ignored entries does not fit in that dialog, the checkout is left out of the run. Checkouts with initialized submodules, and owned checkouts that are detached or on another branch, are blocked; remove them manually. Removable items are listed first. A long list is never cut silently: it ends with `+N more not shown`, and items that were not shown are not removed in that run. Run `/subagent prune` again to review them. Each item is checked again just before removal, and any change skips it. If the session or branch changes during removal, the remaining steps are skipped. The branch is deleted only if it still points at the reviewed tip. Afterwards, each aborted request that recorded a removed resource is reconciled through the same guarded path as repeating `subagent_abort`, so its cleanup no longer shows as pending. The report lists removed, skipped, and failed items and any request that could not be reconciled. Without interactive UI, the command only reports.
|
|
202
|
+
|
|
190
203
|
If an isolated worker produced no candidate, `subagent_abort` terminates it and removes only proven unchanged, clean owned resources. Cleanup includes ignored-file checks and never discards unique commits. If productive work is still settling, termination is uncertain, or cleanup fails, the request stays aborted with retained-resource evidence. Inspect `subagent_status`, resolve the reported blocker, then repeat `subagent_abort` to reconcile cleanup; it does not replay worker prompts. Dirty work, unique commits, and uncertain allocations remain for deliberate manual recovery.
|
|
191
204
|
|
|
192
205
|
The following details describe exact identity and retained-work requirements.
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -922,7 +922,9 @@ export class HerdrHostRuntime {
|
|
|
922
922
|
if (SETTLED_AGENT_STATES.has(lifecycle.status)) {
|
|
923
923
|
try {
|
|
924
924
|
if (await this.workerTurnFinished(allocation, prompt)) {
|
|
925
|
-
return
|
|
925
|
+
return inspection.valid && !inspection.clean
|
|
926
|
+
? { outcome: "uncommitted", diagnostic: "Worker finished with uncommitted changes in its owned worktree." }
|
|
927
|
+
: { outcome: "blocked", diagnostic: "Worker finished without an exact changed clean committed candidate." };
|
|
926
928
|
}
|
|
927
929
|
}
|
|
928
930
|
catch (error) {
|
package/dist/integration-git.js
CHANGED
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { runGit } from "./git-process.js";
|
|
5
5
|
import { CheckedGitRuntime } from "./git-runtime.js";
|
|
6
|
-
import { createChildWorktree, inspectWorktreeDirty, WorktreeSetupError } from "./worktree.js";
|
|
6
|
+
import { createChildWorktree, inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME, WorktreeSetupError } from "./worktree.js";
|
|
7
7
|
import { checkBatchPasses, reviewEvidencePasses, sameIdentity, } from "./schema.js";
|
|
8
8
|
const oidPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
9
9
|
const diagnostic = (value) => value.trim().slice(0, 500);
|
|
@@ -52,8 +52,8 @@ async function ownershipMetadata(root, info, signal) {
|
|
|
52
52
|
const repoRoot = await realpath(primary.slice("worktree ".length));
|
|
53
53
|
if (await realpath(await requireGit(["rev-parse", "--show-toplevel"], root, signal)) !== canonical
|
|
54
54
|
|| info.repoRoot !== repoRoot || info.cwd !== info.path
|
|
55
|
-
||
|
|
56
|
-
|| info.path !== join(repoRoot,
|
|
55
|
+
|| !OWNED_BRANCH_PATTERN.test(info.branch)
|
|
56
|
+
|| info.path !== join(repoRoot, WORKTREES_DIRNAME, basename(info.branch))) {
|
|
57
57
|
throw new Error("Git worktree is not an exact owned checkout of Main.");
|
|
58
58
|
}
|
|
59
59
|
}
|
package/dist/prune.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type GitRunner } from "./git-process.ts";
|
|
2
|
+
/** One reviewed pi-subagent branch, with its registered checkout when one exists. */
|
|
3
|
+
export interface PruneItem {
|
|
4
|
+
branch: string;
|
|
5
|
+
tip: string;
|
|
6
|
+
path?: string;
|
|
7
|
+
prunable: boolean;
|
|
8
|
+
/** Why the item is prunable, or every exact blocker. */
|
|
9
|
+
reason: string;
|
|
10
|
+
/** Ignored entries as Git collapses them; removing the checkout deletes them. */
|
|
11
|
+
ignored: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
export interface PruneOwnership {
|
|
14
|
+
/** The request or state that may still own a resource name (`subagent-<hex>`), if any. */
|
|
15
|
+
owner(name: string): string | undefined;
|
|
16
|
+
/** The live productive lease that blocks pruning, if any. */
|
|
17
|
+
lease?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PruneGuards {
|
|
20
|
+
/** Ownership and lease evidence across the repository's existing checkouts. */
|
|
21
|
+
ownership(checkouts: readonly string[]): Promise<PruneOwnership>;
|
|
22
|
+
/** Every Herdr pane; rejects when Herdr cannot be inspected. */
|
|
23
|
+
panes(): Promise<readonly {
|
|
24
|
+
paneId: string;
|
|
25
|
+
cwds: readonly string[];
|
|
26
|
+
}[]>;
|
|
27
|
+
/** The confirming session still owns the command; checked immediately before each removal step. */
|
|
28
|
+
current?(): boolean;
|
|
29
|
+
}
|
|
30
|
+
export type PruneRemoval = {
|
|
31
|
+
outcome: "removed" | "skipped" | "failed";
|
|
32
|
+
item: PruneItem;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
36
|
+
export declare function listHerdrPanes(cwd: string): Promise<{
|
|
37
|
+
paneId: string;
|
|
38
|
+
cwds: string[];
|
|
39
|
+
}[]>;
|
|
40
|
+
/** Read-only review of every pi-subagent worker or integration branch and its checkout. */
|
|
41
|
+
export declare function inspectPruneCandidates(root: string, mainCwd: string, guards: PruneGuards, run?: GitRunner): Promise<PruneItem[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
|
|
44
|
+
* branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
|
|
45
|
+
* them were in the review. Returns the repository checkouts that still exist.
|
|
46
|
+
*/
|
|
47
|
+
export declare function removePruneCandidates(root: string, mainCwd: string, reviewed: readonly PruneItem[], allowIgnored: boolean, guards: PruneGuards, run?: GitRunner): Promise<{
|
|
48
|
+
removals: PruneRemoval[];
|
|
49
|
+
checkouts: string[];
|
|
50
|
+
}>;
|
package/dist/prune.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, join, sep } from "node:path";
|
|
3
|
+
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
4
|
+
import { runGit } from "./git-process.js";
|
|
5
|
+
import { runProcess } from "./process.js";
|
|
6
|
+
import { inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
7
|
+
const HERDR_TIMEOUT_MS = 30_000;
|
|
8
|
+
const IN_PROGRESS_MARKERS = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
|
|
9
|
+
async function requireGit(run, args, cwd) {
|
|
10
|
+
const result = await run(args, cwd);
|
|
11
|
+
if (result.code !== 0)
|
|
12
|
+
throw new Error(`git ${args[0]} failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
13
|
+
return result.stdout.trimEnd();
|
|
14
|
+
}
|
|
15
|
+
const within = (directory, path) => path === directory || path.startsWith(`${directory}${sep}`);
|
|
16
|
+
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
17
|
+
export async function listHerdrPanes(cwd) {
|
|
18
|
+
const response = await createHerdrClient(runProcess).json(["pane", "list"], { cwd, timeoutMs: HERDR_TIMEOUT_MS });
|
|
19
|
+
const result = response.result;
|
|
20
|
+
if (result?.type !== "pane_list" || !Array.isArray(result.panes))
|
|
21
|
+
throw new Error("Herdr pane list response is malformed.");
|
|
22
|
+
return result.panes.map((pane) => {
|
|
23
|
+
const cwds = [pane.cwd, pane.foreground_cwd].filter((cwd) => typeof cwd === "string");
|
|
24
|
+
if (typeof pane.pane_id !== "string" || !cwds.length)
|
|
25
|
+
throw new Error("Herdr listed a pane without an ID or working directory.");
|
|
26
|
+
return { paneId: pane.pane_id, cwds };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function snapshot(root, mainCwd, run) {
|
|
30
|
+
const checkouts = [];
|
|
31
|
+
let current;
|
|
32
|
+
for (const field of (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], root)).split("\0")) {
|
|
33
|
+
if (field.startsWith("worktree "))
|
|
34
|
+
checkouts.push(current = { path: field.slice("worktree ".length), locked: false, missing: false });
|
|
35
|
+
else if (current && field.startsWith("branch refs/heads/"))
|
|
36
|
+
current.branch = field.slice("branch refs/heads/".length);
|
|
37
|
+
else if (current && (field === "locked" || field.startsWith("locked ")))
|
|
38
|
+
current.locked = true;
|
|
39
|
+
else if (current && (field === "prunable" || field.startsWith("prunable ")))
|
|
40
|
+
current.missing = true;
|
|
41
|
+
}
|
|
42
|
+
if (!checkouts[0])
|
|
43
|
+
throw new Error("Primary Git worktree is unavailable.");
|
|
44
|
+
const branches = new Map();
|
|
45
|
+
for (const line of (await requireGit(run, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", "refs/heads/"], root)).split("\n")) {
|
|
46
|
+
const [branch, tip] = line.split(" ");
|
|
47
|
+
if (branch && tip && OWNED_BRANCH_PATTERN.test(branch))
|
|
48
|
+
branches.set(branch, tip);
|
|
49
|
+
}
|
|
50
|
+
const targets = [{ label: "Main HEAD", head: await requireGit(run, ["rev-parse", "--verify", "HEAD^{commit}"], mainCwd) }];
|
|
51
|
+
const origin = await run(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], root);
|
|
52
|
+
if (origin.code === 0) {
|
|
53
|
+
const ref = origin.stdout.trim();
|
|
54
|
+
targets.push({ label: ref.replace(/^refs\/remotes\//, ""), head: await requireGit(run, ["rev-parse", "--verify", `${ref}^{commit}`], root) });
|
|
55
|
+
}
|
|
56
|
+
return { primary: checkouts[0].path, checkouts, branches, targets };
|
|
57
|
+
}
|
|
58
|
+
async function classify(snap, branch, mainCwd, ownership, panes, run, root) {
|
|
59
|
+
const tip = snap.branches.get(branch);
|
|
60
|
+
const name = basename(branch);
|
|
61
|
+
const ownedPath = join(snap.primary, WORKTREES_DIRNAME, name);
|
|
62
|
+
// A detached or switched owned checkout no longer names the branch; find it by its owned path too.
|
|
63
|
+
const checkout = snap.checkouts.find((item) => item.branch === branch) ?? snap.checkouts.find((item) => item.path === ownedPath);
|
|
64
|
+
const blockers = [];
|
|
65
|
+
if (checkout && checkout.branch !== branch)
|
|
66
|
+
blockers.push(`owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
|
|
67
|
+
let ignored = [];
|
|
68
|
+
const owner = ownership.owner(name);
|
|
69
|
+
if (owner)
|
|
70
|
+
blockers.push(owner);
|
|
71
|
+
if (ownership.lease)
|
|
72
|
+
blockers.push(ownership.lease);
|
|
73
|
+
let merged;
|
|
74
|
+
for (const target of snap.targets) {
|
|
75
|
+
const result = await run(["merge-base", "--is-ancestor", tip, target.head], root);
|
|
76
|
+
if (result.code === 0) {
|
|
77
|
+
merged = target.label;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (result.code !== 1)
|
|
81
|
+
throw new Error(`git merge-base failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!merged)
|
|
84
|
+
blockers.push(`branch has commits not in ${snap.targets.map(({ label }) => label).join(" or ")}`);
|
|
85
|
+
if (checkout) {
|
|
86
|
+
const path = checkout.path;
|
|
87
|
+
if (path !== ownedPath)
|
|
88
|
+
blockers.push("checkout is not at its owned .worktrees path");
|
|
89
|
+
if (checkout.locked)
|
|
90
|
+
blockers.push("checkout is locked");
|
|
91
|
+
if (checkout.missing || !existsSync(path))
|
|
92
|
+
blockers.push("checkout directory is missing; inspect it, then run git worktree prune");
|
|
93
|
+
else {
|
|
94
|
+
const real = realpathSync.native(path);
|
|
95
|
+
if (within(real, realpathSync.native(mainCwd)))
|
|
96
|
+
blockers.push("Main runs inside the checkout");
|
|
97
|
+
const gitDir = await requireGit(run, ["rev-parse", "--absolute-git-dir"], path);
|
|
98
|
+
const markers = IN_PROGRESS_MARKERS.filter((marker) => existsSync(join(gitDir, marker)));
|
|
99
|
+
if (markers.length)
|
|
100
|
+
blockers.push(`Git operation in progress (${markers.join(", ")})`);
|
|
101
|
+
const dirty = await inspectWorktreeDirty(path, run, false);
|
|
102
|
+
if (dirty.failure)
|
|
103
|
+
blockers.push(`checkout could not be inspected: ${dirty.failure}`);
|
|
104
|
+
else if (dirty.dirty)
|
|
105
|
+
blockers.push("checkout has tracked or untracked changes");
|
|
106
|
+
else if (dirty.initializedSubmodules)
|
|
107
|
+
blockers.push("checkout has initialized submodules; Git cannot remove it without force, so remove it manually");
|
|
108
|
+
if (typeof panes === "string")
|
|
109
|
+
blockers.push(`Herdr unavailable (${panes}); cannot prove no pane uses the checkout`);
|
|
110
|
+
else {
|
|
111
|
+
const used = panes.filter((pane) => pane.cwds.some((cwd) => within(path, cwd) || within(real, cwd)));
|
|
112
|
+
if (used.length)
|
|
113
|
+
blockers.push(`Herdr pane ${used.map(({ paneId }) => paneId).join(", ")} works inside the checkout`);
|
|
114
|
+
}
|
|
115
|
+
ignored = (await requireGit(run, ["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], path))
|
|
116
|
+
.split("\0").filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
branch, tip, ...(checkout ? { path: checkout.path } : {}), prunable: !blockers.length, ignored,
|
|
121
|
+
reason: blockers.length ? blockers.join("; ") : `merged into ${merged}; ${checkout ? "clean checkout" : "no checkout"}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const existing = (snap) => snap.checkouts.filter((item) => !item.missing && existsSync(item.path)).map(({ path }) => path);
|
|
125
|
+
async function evidence(root, mainCwd, guards, run) {
|
|
126
|
+
const snap = await snapshot(root, mainCwd, run);
|
|
127
|
+
const ownership = await guards.ownership(existing(snap));
|
|
128
|
+
let panes;
|
|
129
|
+
try {
|
|
130
|
+
panes = await guards.panes();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
panes = (error instanceof Error ? error.message : String(error)).slice(0, 200);
|
|
134
|
+
}
|
|
135
|
+
return { snap, ownership, panes };
|
|
136
|
+
}
|
|
137
|
+
/** Read-only review of every pi-subagent worker or integration branch and its checkout. */
|
|
138
|
+
export async function inspectPruneCandidates(root, mainCwd, guards, run = runGit) {
|
|
139
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
140
|
+
const items = [];
|
|
141
|
+
for (const branch of [...snap.branches.keys()].sort())
|
|
142
|
+
items.push(await classify(snap, branch, mainCwd, ownership, panes, run, root));
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
async function removeOne(root, mainCwd, reviewed, allowIgnored, guards, run) {
|
|
146
|
+
const skip = (reason) => ({ outcome: "skipped", item: reviewed, reason });
|
|
147
|
+
const fail = (reason) => ({ outcome: "failed", item: reviewed, reason });
|
|
148
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
149
|
+
if (snap.branches.get(reviewed.branch) !== reviewed.tip)
|
|
150
|
+
return skip("branch was deleted or moved since review");
|
|
151
|
+
const fresh = await classify(snap, reviewed.branch, mainCwd, ownership, panes, run, root);
|
|
152
|
+
if (!fresh.prunable)
|
|
153
|
+
return skip(fresh.reason);
|
|
154
|
+
if (fresh.path !== reviewed.path)
|
|
155
|
+
return skip("checkout registration changed since review");
|
|
156
|
+
if (fresh.ignored.some((entry) => !allowIgnored || !reviewed.ignored.includes(entry)))
|
|
157
|
+
return skip("ignored content was not approved or changed since review");
|
|
158
|
+
const stale = () => guards.current !== undefined && !guards.current();
|
|
159
|
+
if (fresh.path) {
|
|
160
|
+
if (stale())
|
|
161
|
+
return skip("session or branch changed before removal; reopen /subagent prune");
|
|
162
|
+
// Git removes ignored content without --force; the checks above approved it.
|
|
163
|
+
const removed = await run(["worktree", "remove", fresh.path], root);
|
|
164
|
+
if (removed.code !== 0)
|
|
165
|
+
return fail(`git worktree remove failed: ${removed.stderr.trim().slice(0, 200)}`);
|
|
166
|
+
if (existsSync(fresh.path))
|
|
167
|
+
return fail("git worktree remove succeeded but the checkout remains");
|
|
168
|
+
}
|
|
169
|
+
if (stale())
|
|
170
|
+
return skip(`${fresh.path ? "checkout removed; " : ""}session or branch changed before branch deletion; branch kept`);
|
|
171
|
+
const deleted = await run(["update-ref", "-d", `refs/heads/${reviewed.branch}`, reviewed.tip], root);
|
|
172
|
+
if (deleted.code !== 0)
|
|
173
|
+
return fail(`${fresh.path ? "checkout removed; " : ""}branch kept: ${deleted.stderr.trim().slice(0, 200)}`);
|
|
174
|
+
return { outcome: "removed", item: reviewed, reason: fresh.reason };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
|
|
178
|
+
* branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
|
|
179
|
+
* them were in the review. Returns the repository checkouts that still exist.
|
|
180
|
+
*/
|
|
181
|
+
export async function removePruneCandidates(root, mainCwd, reviewed, allowIgnored, guards, run = runGit) {
|
|
182
|
+
const removals = [];
|
|
183
|
+
for (const item of reviewed) {
|
|
184
|
+
try {
|
|
185
|
+
removals.push(await removeOne(root, mainCwd, item, allowIgnored, guards, run));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
removals.push({ outcome: "failed", item, reason: (error instanceof Error ? error.message : String(error)).slice(0, 300) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { removals, checkouts: existing(await snapshot(root, mainCwd, run)) };
|
|
192
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { EphemeralSubagentExecutor } from "./ephemeral.ts";
|
|
2
|
+
import type { PruneOwnership } from "./prune.ts";
|
|
2
3
|
import { IntegrationGit } from "./integration-git.ts";
|
|
3
4
|
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";
|
|
4
5
|
import { FileRunStore } from "./store.ts";
|
|
6
|
+
/** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
|
|
7
|
+
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.";
|
|
5
8
|
export declare const CLEANUP_SAFETY_BUDGET_MS = 30000;
|
|
6
9
|
export declare const TERMINATION_SAFETY_BUDGET_MS = 15000;
|
|
7
10
|
export declare const STATUS_INSPECTION_BUDGET_MS = 5000;
|
|
@@ -65,6 +68,11 @@ export type WorkerResult = {
|
|
|
65
68
|
} | {
|
|
66
69
|
outcome: "blocked" | "not_prompted";
|
|
67
70
|
diagnostic: string;
|
|
71
|
+
}
|
|
72
|
+
/** The exact turn finished in its valid owned worktree with tracked or untracked changes. */
|
|
73
|
+
| {
|
|
74
|
+
outcome: "uncommitted";
|
|
75
|
+
diagnostic: string;
|
|
68
76
|
} | {
|
|
69
77
|
outcome: "unknown" | "interrupted";
|
|
70
78
|
diagnostic: string;
|
|
@@ -314,6 +322,11 @@ export declare class IsolatedRunner {
|
|
|
314
322
|
invalidIds: string[];
|
|
315
323
|
leaseBusy: boolean;
|
|
316
324
|
}>;
|
|
325
|
+
/** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
|
|
326
|
+
* mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
|
|
327
|
+
pruneOwnership(checkouts: readonly string[]): Promise<PruneOwnership>;
|
|
328
|
+
/** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
|
|
329
|
+
reconcilePrunedAborts(checkouts: readonly string[], names: readonly string[]): Promise<string[]>;
|
|
317
330
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
318
331
|
drainFollowups(root: string, requestId: string, taskId: string): string[];
|
|
319
332
|
queueFollowup(root: string, requestId: string, taskId: string, instruction: string): string;
|
package/dist/runner.js
CHANGED
|
@@ -7,8 +7,11 @@ import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
|
|
|
7
7
|
const TRUNCATION_MARKER = "\n[truncated]";
|
|
8
8
|
const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
|
|
9
9
|
const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
|
|
10
|
+
const CORRECTION_DELIVERY_RESERVE_BYTES = 1024;
|
|
10
11
|
const MAX_WORKER_PROMPTS = 32;
|
|
11
12
|
const MAX_QUEUED_FOLLOWUPS = 16;
|
|
13
|
+
/** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
|
|
14
|
+
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.";
|
|
12
15
|
export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
|
|
13
16
|
export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
|
|
14
17
|
export const STATUS_INSPECTION_BUDGET_MS = 5_000;
|
|
@@ -369,6 +372,9 @@ function exactCommandResults(results, checks) {
|
|
|
369
372
|
&& result.args.every((arg, argIndex) => arg === check.args[argIndex]);
|
|
370
373
|
});
|
|
371
374
|
}
|
|
375
|
+
function isCommitNudge(prompt) {
|
|
376
|
+
return prompt.kind === "followup" && prompt.instruction === COMMIT_NUDGE_INSTRUCTION;
|
|
377
|
+
}
|
|
372
378
|
function correctionEligible(request, attempt) {
|
|
373
379
|
const initial = attempt.prompts[0];
|
|
374
380
|
if (initial?.kind !== "initial" || attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
|
|
@@ -377,11 +383,13 @@ function correctionEligible(request, attempt) {
|
|
|
377
383
|
const latestPrompt = attempt.prompts.at(-1);
|
|
378
384
|
if (latestPrompt.status !== "settled")
|
|
379
385
|
return false;
|
|
386
|
+
// A commit nudge continues its turn; it never consumes the correction window.
|
|
387
|
+
const assignments = attempt.prompts.filter((prompt) => !isCommitNudge(prompt)).length;
|
|
380
388
|
if (!attempt.candidate) {
|
|
381
|
-
return
|
|
389
|
+
return assignments === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
|
|
382
390
|
}
|
|
383
391
|
const checks = attempt.preliminaryChecks;
|
|
384
|
-
return
|
|
392
|
+
return assignments === 1 && Boolean(checks
|
|
385
393
|
&& checks.phase === "preliminary"
|
|
386
394
|
&& exactCommandResults(checks.results, request.checks)
|
|
387
395
|
&& sameIdentity(checks.candidate, attempt.candidate)
|
|
@@ -403,10 +411,10 @@ export function buildChangesetTaskPrompt(input) {
|
|
|
403
411
|
&& (typeof input.failure !== "string" || !input.failure.trim() || input.failure.trim() !== input.failure || input.failure.includes("\0"))) {
|
|
404
412
|
throw new Error("correction failure must be a non-empty exact string.");
|
|
405
413
|
}
|
|
406
|
-
if (input.kind === "followup"
|
|
414
|
+
if ((input.kind === "followup" || input.instruction !== undefined)
|
|
407
415
|
&& (typeof input.instruction !== "string" || !input.instruction.trim() || input.instruction.trim() !== input.instruction
|
|
408
416
|
|| input.instruction.includes("\0") || input.instruction.length > 32_000)) {
|
|
409
|
-
throw new Error("follow-up instruction must be non-empty exact text of at most 32000 characters
|
|
417
|
+
throw new Error(`${input.kind === "followup" ? "follow-up" : "correction"} instruction must be non-empty exact text of at most 32000 characters.`);
|
|
410
418
|
}
|
|
411
419
|
const checks = input.task.checks.map((check) => JSON.stringify({ command: check.command, args: check.args })).join("\n");
|
|
412
420
|
const upstreamTaskData = input.contexts.length
|
|
@@ -440,6 +448,7 @@ export function buildChangesetTaskPrompt(input) {
|
|
|
440
448
|
"Required checks (direct command/argv):",
|
|
441
449
|
checks,
|
|
442
450
|
...(input.kind === "correction" ? ["", "Correction failure:", input.failure] : []),
|
|
451
|
+
...(input.kind === "correction" && input.instruction ? ["", "Main's instruction:", input.instruction] : []),
|
|
443
452
|
"",
|
|
444
453
|
"Work only in the exact worktree above. Commit the complete result and leave that worktree clean.",
|
|
445
454
|
].join("\n");
|
|
@@ -572,6 +581,55 @@ export class IsolatedRunner {
|
|
|
572
581
|
}
|
|
573
582
|
return { requests, invalidIds: inventory.invalidIds, leaseBusy };
|
|
574
583
|
}
|
|
584
|
+
/** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
|
|
585
|
+
* mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
|
|
586
|
+
async pruneOwnership(checkouts) {
|
|
587
|
+
const owners = [];
|
|
588
|
+
let lease;
|
|
589
|
+
for (const checkout of checkouts) {
|
|
590
|
+
const root = realpathSync.native(checkout);
|
|
591
|
+
if (!lease && await this.store.hasProductiveRunLease(root))
|
|
592
|
+
lease = `productive lease is held by a live run in ${root}`;
|
|
593
|
+
const { states, invalidIds } = await this.store.list(root);
|
|
594
|
+
for (const { state } of states) {
|
|
595
|
+
if (!terminal(state))
|
|
596
|
+
owners.push({ label: `owned by ${state.status} request ${state.request.id}`, text: JSON.stringify(state) });
|
|
597
|
+
}
|
|
598
|
+
for (const id of invalidIds) {
|
|
599
|
+
let text;
|
|
600
|
+
try {
|
|
601
|
+
text = await this.store.readRaw(root, id);
|
|
602
|
+
}
|
|
603
|
+
catch { /* It may own any name. */ }
|
|
604
|
+
owners.push({ label: `unreadable state ${id} in ${root} may own it`, text });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return { ...(lease ? { lease } : {}), owner: (name) => owners.find(({ text }) => text === undefined || text.includes(name))?.label };
|
|
608
|
+
}
|
|
609
|
+
/** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
|
|
610
|
+
async reconcilePrunedAborts(checkouts, names) {
|
|
611
|
+
const lines = [];
|
|
612
|
+
for (const checkout of checkouts) {
|
|
613
|
+
const root = realpathSync.native(checkout);
|
|
614
|
+
const { requests, states } = await this.listRequests(root);
|
|
615
|
+
for (const [index, state] of states.entries()) {
|
|
616
|
+
const id = state.request.id;
|
|
617
|
+
const text = JSON.stringify(state);
|
|
618
|
+
if (requests[index].status !== "aborted · retained" || !names.some((name) => text.includes(name)))
|
|
619
|
+
continue;
|
|
620
|
+
try {
|
|
621
|
+
await this.abort(id, root);
|
|
622
|
+
const after = (await this.listRequests(root)).requests.find((request) => request.id === id);
|
|
623
|
+
lines.push(after?.status === "aborted" ? `Reconciled cleanup of aborted request ${id}.`
|
|
624
|
+
: `Aborted request ${id} still has pending cleanup; inspect subagent_status.`);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
lines.push(`Aborted request ${id} could not be reconciled: ${errorText(error)}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return lines;
|
|
632
|
+
}
|
|
575
633
|
canFollowup(root, requestId, taskId) {
|
|
576
634
|
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
577
635
|
return active !== undefined && active.task.status === "working" && !active.control.isSealed;
|
|
@@ -757,6 +815,8 @@ export class IsolatedRunner {
|
|
|
757
815
|
throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
|
|
758
816
|
if (request.action === "verify")
|
|
759
817
|
throw new Error(`Text task ${task.taskId} cannot be verified.`);
|
|
818
|
+
if (request.instruction !== undefined)
|
|
819
|
+
throw new Error(`Text task ${task.taskId} retry cannot take a worker instruction.`);
|
|
760
820
|
if (!textRetryEligible(state, task)) {
|
|
761
821
|
const attempt = task.attempts.at(-1);
|
|
762
822
|
if ((attempt && !["failed", "superseded"].includes(attempt.status)) || task.attempts.length >= 2) {
|
|
@@ -771,7 +831,7 @@ export class IsolatedRunner {
|
|
|
771
831
|
if (task.status !== "needs_attention")
|
|
772
832
|
throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
|
|
773
833
|
if (request.action === "retry")
|
|
774
|
-
return await this.retry(handle, task, scope);
|
|
834
|
+
return await this.retry(handle, task, scope, request.instruction);
|
|
775
835
|
return await this.verifyRetainedTask(handle, task, scope);
|
|
776
836
|
}
|
|
777
837
|
catch (error) {
|
|
@@ -2179,6 +2239,8 @@ export class IsolatedRunner {
|
|
|
2179
2239
|
let kind = initialKind;
|
|
2180
2240
|
let failure = task.failure;
|
|
2181
2241
|
let instruction = initialInstruction;
|
|
2242
|
+
// A nudge keeps its dirty turn's exact clean pre-prompt lineage.
|
|
2243
|
+
let nudgeFrom;
|
|
2182
2244
|
for (;;) {
|
|
2183
2245
|
if (kind === "followup" && instruction === undefined) {
|
|
2184
2246
|
throw new Error("Follow-up worker execution requires an exact queued instruction.");
|
|
@@ -2195,11 +2257,12 @@ export class IsolatedRunner {
|
|
|
2195
2257
|
}
|
|
2196
2258
|
let preCandidate;
|
|
2197
2259
|
try {
|
|
2198
|
-
preCandidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
|
|
2260
|
+
preCandidate = nudgeFrom ?? runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
|
|
2199
2261
|
root: state.root,
|
|
2200
2262
|
task: request,
|
|
2201
2263
|
attempt,
|
|
2202
2264
|
}, context)), "Pre-prompt candidate identity");
|
|
2265
|
+
nudgeFrom = undefined;
|
|
2203
2266
|
}
|
|
2204
2267
|
catch (error) {
|
|
2205
2268
|
this.rethrowStopped(error);
|
|
@@ -2233,7 +2296,7 @@ export class IsolatedRunner {
|
|
|
2233
2296
|
kind,
|
|
2234
2297
|
status: "submitting",
|
|
2235
2298
|
preCandidate,
|
|
2236
|
-
...(
|
|
2299
|
+
...(instruction !== undefined ? { instruction } : {}),
|
|
2237
2300
|
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2238
2301
|
};
|
|
2239
2302
|
attempt.prompts.push(prompt);
|
|
@@ -2278,6 +2341,19 @@ export class IsolatedRunner {
|
|
|
2278
2341
|
return;
|
|
2279
2342
|
}
|
|
2280
2343
|
prompt.status = "settled";
|
|
2344
|
+
if (worker.outcome === "uncommitted") {
|
|
2345
|
+
prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, "uncommitted worker diagnostic");
|
|
2346
|
+
if (attempt.prompts.some(isCommitNudge)) {
|
|
2347
|
+
this.attention(task, "The commit nudge was sent, but the worker turn still left uncommitted changes in its worktree; they are retained for inspection.");
|
|
2348
|
+
await this.saveProductive(handle);
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
await this.saveProductive(handle);
|
|
2352
|
+
kind = "followup";
|
|
2353
|
+
instruction = COMMIT_NUDGE_INSTRUCTION;
|
|
2354
|
+
nudgeFrom = preCandidate;
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2281
2357
|
if (worker.outcome === "blocked") {
|
|
2282
2358
|
failure = boundedRuntimeDiagnostic(worker.diagnostic, "blocked worker diagnostic");
|
|
2283
2359
|
prompt.failure = failure;
|
|
@@ -2522,8 +2598,11 @@ export class IsolatedRunner {
|
|
|
2522
2598
|
await this.saveProductive(handle);
|
|
2523
2599
|
return this.response(handle.state);
|
|
2524
2600
|
}
|
|
2525
|
-
async retry(handle, task, scope) {
|
|
2601
|
+
async retry(handle, task, scope, instruction) {
|
|
2526
2602
|
const attempt = task.attempts.at(-1);
|
|
2603
|
+
if (instruction !== undefined && !attempt?.prompts.length) {
|
|
2604
|
+
throw new Error("A retry instruction requires a same-worker correction of a prompted attempt.");
|
|
2605
|
+
}
|
|
2527
2606
|
if (!attempt) {
|
|
2528
2607
|
for (const pending of handle.state.tasks) {
|
|
2529
2608
|
if (pending.status === "needs_attention" && pending.attempts.length === 0) {
|
|
@@ -2538,11 +2617,26 @@ export class IsolatedRunner {
|
|
|
2538
2617
|
if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
|
|
2539
2618
|
throw new Error("An ambiguous delivered prompt is never replayed.");
|
|
2540
2619
|
}
|
|
2541
|
-
|
|
2620
|
+
const request = changesetTaskRequest(handle.state, task.taskId);
|
|
2621
|
+
if (!this.correctionAllowed(handle.state, request, attempt)) {
|
|
2542
2622
|
throw new Error("The same-agent correction is unavailable or already used.");
|
|
2543
2623
|
}
|
|
2624
|
+
// Build the correction before it is recorded: an oversized prompt must not use up the only correction.
|
|
2625
|
+
// The reserve covers the worktree path and turn identity the host adds before delivery.
|
|
2626
|
+
const assignment = buildChangesetTaskPrompt({
|
|
2627
|
+
goal: handle.state.request.goal,
|
|
2628
|
+
contexts: resolveTextTaskContexts(handle.state, request),
|
|
2629
|
+
task: request,
|
|
2630
|
+
kind: "correction",
|
|
2631
|
+
worktreeCwd: allocationByKind(attempt, "agent")?.worktreeCwd ?? "",
|
|
2632
|
+
failure: task.failure,
|
|
2633
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
2634
|
+
});
|
|
2635
|
+
if (Buffer.byteLength(assignment, "utf8") + CORRECTION_DELIVERY_RESERVE_BYTES > CHANGESET_TASK_PROMPT_MAX_BYTES) {
|
|
2636
|
+
throw new Error(`Worker assignment exceeds ${CHANGESET_TASK_PROMPT_MAX_BYTES} bytes with delivery overhead; shorten the retry instruction.`);
|
|
2637
|
+
}
|
|
2544
2638
|
task.status = "working";
|
|
2545
|
-
await this.driveWorkerSafely(handle, task, scope, "correction");
|
|
2639
|
+
await this.driveWorkerSafely(handle, task, scope, "correction", instruction);
|
|
2546
2640
|
}
|
|
2547
2641
|
else {
|
|
2548
2642
|
if (attempt.termination || allocationByKind(attempt, "agent")) {
|