@hcflabs/pi-subagent 27.11.0 → 28.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/README.md +27 -3
- package/dist/full-suite.d.ts +34 -0
- package/dist/full-suite.js +142 -0
- package/dist/herdr-runtime.d.ts +7 -1
- package/dist/herdr-runtime.js +11 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +37 -0
- package/dist/launch-runtime.d.ts +2 -0
- package/dist/launch-runtime.js +4 -1
- package/dist/runner.d.ts +32 -5
- package/dist/runner.js +45 -35
- package/dist/schema.d.ts +38 -3
- package/dist/schema.js +23 -1
- package/dist/store.d.ts +10 -2
- package/dist/store.js +20 -11
- package/docs/orchestration.md +2 -2
- package/extensions/isolated.ts +177 -63
- package/extensions/subagent-command.ts +12 -3
- package/extensions/subagent.ts +4 -2
- package/extensions/workflow.ts +8 -5
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +1 -1
package/extensions/isolated.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
1
|
+
import { basename, relative, sep } from "node:path";
|
|
2
2
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { discoverRepos, gitTopLevel, isWithin, resolveRepoTarget, type RepoDiscovery } from "@hcflabs/pi-repo";
|
|
6
|
+
import { Type, type TObject, type TProperties } from "typebox";
|
|
5
7
|
import { capEphemeralSubagentOutput, utf8Prefix, type EphemeralSubagentExecutor } from "../dist/ephemeral.js";
|
|
6
8
|
import {
|
|
7
9
|
createCanonicalGitRootResolver,
|
|
@@ -16,6 +18,7 @@ import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type Pru
|
|
|
16
18
|
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
17
19
|
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
18
20
|
import { IsolatedRunner, nextIntegrationAction, type ExecutePlan, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
21
|
+
import { describeFullSuite } from "../dist/full-suite.js";
|
|
19
22
|
import {
|
|
20
23
|
IdOnlySchema,
|
|
21
24
|
ResumeRequestParameters,
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
sameIdentity,
|
|
30
33
|
StatusOutputSchema,
|
|
31
34
|
type CheckBatchEvidence,
|
|
35
|
+
type CheckCommand,
|
|
32
36
|
type ExecuteRequest,
|
|
33
37
|
type ExecutionPolicySnapshot,
|
|
34
38
|
type ModelClass,
|
|
@@ -41,6 +45,7 @@ import { FileRunStore } from "../dist/store.js";
|
|
|
41
45
|
import { workerOutputForMain } from "./result-transport.ts";
|
|
42
46
|
import type { CleanupReport, IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
|
|
43
47
|
import type { DiscardPlan } from "../dist/discard.js";
|
|
48
|
+
import { normalizeRepo } from "./workflow.ts";
|
|
44
49
|
|
|
45
50
|
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
46
51
|
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
@@ -61,12 +66,14 @@ export type CreateIsolatedComponents = (options: {
|
|
|
61
66
|
executor: EphemeralSubagentExecutor;
|
|
62
67
|
policy: ExecutionPolicySnapshot;
|
|
63
68
|
currentPolicy(): ExecutionPolicySnapshot;
|
|
69
|
+
/** The configured Full suite overrides. Admission reads them after the policy check. */
|
|
70
|
+
repoSuites?(): Readonly<Record<string, CheckCommand>>;
|
|
64
71
|
onStateSaved(state: RunState): void;
|
|
65
72
|
}) => IsolatedExtensionComponents;
|
|
66
73
|
|
|
67
74
|
/** Construct the checked runtime while reusing the unified productive-assignment executor. */
|
|
68
75
|
export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
69
|
-
pi, context, executor, policy, currentPolicy, onStateSaved,
|
|
76
|
+
pi, context, executor, policy, currentPolicy, repoSuites, onStateSaved,
|
|
70
77
|
}) => {
|
|
71
78
|
const runProcess: DirectProcessRunner = async (command, args, options) => options.stdin === undefined
|
|
72
79
|
? await pi.exec(command, args, { cwd: options.cwd, signal: options.signal, timeout: options.timeoutMs })
|
|
@@ -83,7 +90,7 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
83
90
|
pi,
|
|
84
91
|
context,
|
|
85
92
|
resolveRoot,
|
|
86
|
-
preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
|
|
93
|
+
preflightHost: async ({ root, targeted }, operation) => await host.preflightHost({ root, ...(targeted ? { targeted } : {}) }, operation),
|
|
87
94
|
inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
|
|
88
95
|
});
|
|
89
96
|
return {
|
|
@@ -96,6 +103,8 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
96
103
|
executor,
|
|
97
104
|
policy,
|
|
98
105
|
currentPolicy,
|
|
106
|
+
undefined,
|
|
107
|
+
repoSuites,
|
|
99
108
|
),
|
|
100
109
|
};
|
|
101
110
|
};
|
|
@@ -473,7 +482,8 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
473
482
|
}
|
|
474
483
|
|
|
475
484
|
/** Bounded dry-run plan; a route missing from the coordinator report shows as unresolved. */
|
|
476
|
-
export function formatExecutePlan(
|
|
485
|
+
export function formatExecutePlan(plan: ExecutePlan, hostChecked: boolean): { content: [{ type: "text"; text: string }]; details: unknown } {
|
|
486
|
+
const { request, root, main, routes } = plan;
|
|
477
487
|
const route = (role: string, modelClass: ModelClass) => {
|
|
478
488
|
const found = routes.find((candidate) => candidate.role === role && candidate.modelClass === modelClass);
|
|
479
489
|
return found ? { model: found.model, thinkingLevel: found.thinkingLevel } : undefined;
|
|
@@ -495,6 +505,7 @@ export function formatExecutePlan({ request, root, main, routes }: ExecutePlan,
|
|
|
495
505
|
...(task.kind === "changeset" ? [` checks: ${task.checks.map(formatCheckCommand).join("; ")}`,
|
|
496
506
|
...(task.judgment ? [` judgment: ${label(task.judgment.role, task.judgment.modelClass)}`] : [])] : []),
|
|
497
507
|
].join("\n")),
|
|
508
|
+
...(plan.fullSuite ? [`Full suite: ${describeFullSuite(plan.fullSuite)}`] : []),
|
|
498
509
|
`Final checks: ${request.finalChecks.map(formatCheckCommand).join("; ") || "none"}`,
|
|
499
510
|
...(request.finalJudgment ? [`Final judgment: ${label(request.finalJudgment.role, request.finalJudgment.modelClass)}`] : []),
|
|
500
511
|
...notChecked.map((reason) => `Not checked: ${reason}.`),
|
|
@@ -507,16 +518,39 @@ export function formatExecutePlan({ request, root, main, routes }: ExecutePlan,
|
|
|
507
518
|
...route(task.role, task.modelClass), dependsOn: task.dependsOn, contextFrom: task.contextFrom,
|
|
508
519
|
...(task.kind === "changeset" ? { checks: task.checks, ...(task.judgment ? { judgment: { ...task.judgment, ...route(task.judgment.role, task.judgment.modelClass) } } : {}) } : {}) })),
|
|
509
520
|
finalChecks: request.finalChecks,
|
|
521
|
+
...(plan.fullSuite ? { fullSuite: plan.fullSuite } : {}),
|
|
510
522
|
...(request.finalJudgment ? { finalJudgment: { ...request.finalJudgment, ...route(request.finalJudgment.role, request.finalJudgment.modelClass) } } : {}),
|
|
511
523
|
notChecked,
|
|
512
524
|
},
|
|
513
525
|
};
|
|
514
526
|
}
|
|
515
527
|
|
|
528
|
+
const RepoParameter = Type.Optional(Type.String({
|
|
529
|
+
minLength: 1,
|
|
530
|
+
description: "Repository target of the request: the same repo that delegate_task used, as a path relative to the Pi working directory. Omit it when the request runs in the checkout of the Pi working directory.",
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
/** Add the optional `repo` selector to a flat tool schema. The selector is not part of the durable request. */
|
|
534
|
+
function withRepo<P extends TProperties>(schema: TObject<P>): TObject<P & { repo: typeof RepoParameter }> {
|
|
535
|
+
return Type.Object({ ...schema.properties, repo: RepoParameter }, { additionalProperties: false }) as unknown as TObject<P & { repo: typeof RepoParameter }>;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Parse the request without `repo`, then add `repo` back as a separate selector. */
|
|
539
|
+
function parseWithRepo<T extends object>(parse: (value: unknown) => T): (value: unknown) => T & { repo?: string } {
|
|
540
|
+
return (value) => {
|
|
541
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "repo")) return parse(value);
|
|
542
|
+
const { repo, ...rest } = value as Record<string, unknown>;
|
|
543
|
+
if (typeof repo !== "string") throw new Error("repo must be a relative path.");
|
|
544
|
+
// Normalize like delegate_task, so a recovery call selects the same Repository target.
|
|
545
|
+
return { ...parse(rest), repo: normalizeRepo(repo, "repo") };
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
516
549
|
export interface IsolatedSurface {
|
|
517
550
|
/** Dry run of execute: validates and resolves without durable state or Herdr calls. */
|
|
518
|
-
|
|
519
|
-
|
|
551
|
+
/** `repo` selects a Repository target below the Pi working directory. */
|
|
552
|
+
plan(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext, repo?: string): Promise<ReturnType<typeof formatExecutePlan>>;
|
|
553
|
+
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext, repo?: string): Promise<ReturnType<typeof toolResult>>;
|
|
520
554
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
521
555
|
recover(cwd: string): Promise<string>;
|
|
522
556
|
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
@@ -533,6 +567,8 @@ export interface RegisterIsolatedOptions {
|
|
|
533
567
|
executor: EphemeralSubagentExecutor;
|
|
534
568
|
policy: ExecutionPolicySnapshot;
|
|
535
569
|
currentPolicy(): ExecutionPolicySnapshot;
|
|
570
|
+
/** The configured Full suite overrides for isolated changesets. */
|
|
571
|
+
repoSuites?(): Readonly<Record<string, CheckCommand>>;
|
|
536
572
|
/** Report new config issues, and throw when the config now blocks delegation, before each deferred Role launch. */
|
|
537
573
|
beforeRoleLaunch?(ctx: ExtensionContext): void;
|
|
538
574
|
componentsFactory?: CreateIsolatedComponents;
|
|
@@ -566,6 +602,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
566
602
|
executor: options.executor,
|
|
567
603
|
policy: options.policy,
|
|
568
604
|
currentPolicy: options.currentPolicy,
|
|
605
|
+
...(options.repoSuites ? { repoSuites: options.repoSuites } : {}),
|
|
569
606
|
onStateSaved: (state) => {
|
|
570
607
|
savedRevision += 1;
|
|
571
608
|
for (const listener of stateListeners) listener(state);
|
|
@@ -575,14 +612,20 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
575
612
|
}
|
|
576
613
|
},
|
|
577
614
|
});
|
|
578
|
-
|
|
615
|
+
/** The canonical checkout root: the Repository target `repo` below `cwd`, or the checkout that contains `cwd`. */
|
|
616
|
+
const lookupRoot = async (cwd: string, signal?: AbortSignal, repo?: string): Promise<string> => {
|
|
579
617
|
const startedAt = Date.now();
|
|
580
618
|
const context: OperationContext = {
|
|
581
619
|
signal: signal ?? new AbortController().signal,
|
|
582
620
|
timeoutMs: LOOKUP_ROOT_TIMEOUT_MS,
|
|
583
621
|
deadline: startedAt + LOOKUP_ROOT_TIMEOUT_MS,
|
|
584
622
|
};
|
|
585
|
-
return await getComponents().resolveRoot(cwd, context);
|
|
623
|
+
if (repo === undefined) return await getComponents().resolveRoot(cwd, context);
|
|
624
|
+
const target = (await resolveRepoTarget(cwd, repo, { signal: context.signal, timeoutMs: LOOKUP_ROOT_TIMEOUT_MS })).root;
|
|
625
|
+
const root = await getComponents().resolveRoot(target, context);
|
|
626
|
+
// The checkout can change between two Git probes. Use only the root that the target validation proved.
|
|
627
|
+
if (root !== target) throw new Error(`Repository target ${repo} resolved to ${target}, but Git now reports ${root}. Nothing was changed; retry.`);
|
|
628
|
+
return root;
|
|
586
629
|
};
|
|
587
630
|
|
|
588
631
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -744,12 +787,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
744
787
|
name: "subagent_status",
|
|
745
788
|
label: "Subagent status",
|
|
746
789
|
description: "Read one durable isolated request without reconciling or changing resources.",
|
|
747
|
-
parameters: IdOnlySchema,
|
|
748
|
-
prepareArguments: parseIdOnly,
|
|
790
|
+
parameters: withRepo(IdOnlySchema),
|
|
791
|
+
prepareArguments: parseWithRepo(parseIdOnly),
|
|
749
792
|
outputSchema: StatusOutputSchema,
|
|
750
793
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
751
794
|
latestCtx = ctx;
|
|
752
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
795
|
+
const root = await lookupRoot(ctx.cwd, signal, params.repo);
|
|
753
796
|
const response = await getComponents().runner.status(params.id, root, signal);
|
|
754
797
|
const result = toolResult(response, ctx, workspaceRowsByRequest);
|
|
755
798
|
const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
|
|
@@ -761,12 +804,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
761
804
|
name: "subagent_resume",
|
|
762
805
|
label: "Subagent resume",
|
|
763
806
|
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count. For a retry that sends a same-worker correction, optional instruction (at most 32000 characters) is appended to that prompt as Main's instruction after the saved check or review failure; use it to name the exact fixes, including the review findings the worker must address. Other actions reject instruction.",
|
|
764
|
-
parameters: ResumeRequestParameters,
|
|
765
|
-
prepareArguments: parseResumeRequest,
|
|
807
|
+
parameters: withRepo(ResumeRequestParameters),
|
|
808
|
+
prepareArguments: parseWithRepo(parseResumeRequest),
|
|
766
809
|
exposure: "model-only",
|
|
767
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
810
|
+
async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
|
|
768
811
|
latestCtx = ctx;
|
|
769
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
812
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
770
813
|
return await startInSession(params.id, root, signal, ctx,
|
|
771
814
|
(state) => state.recovery?.kind === "resume" && state.recovery.action === params.action
|
|
772
815
|
&& (!("taskId" in params) || state.recovery.taskId === params.taskId),
|
|
@@ -777,12 +820,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
777
820
|
name: "subagent_stage",
|
|
778
821
|
label: "Subagent stage",
|
|
779
822
|
description: "Main stages/resolves an exact candidate, or rejects/revises one. Rejection of a staged candidate freezes the old generation; explicitly restage chosen candidates in a new generation. Never writes Main.",
|
|
780
|
-
parameters: StageRequestSchema,
|
|
781
|
-
prepareArguments: parseStageRequest,
|
|
823
|
+
parameters: withRepo(StageRequestSchema),
|
|
824
|
+
prepareArguments: parseWithRepo(parseStageRequest),
|
|
782
825
|
exposure: "model-only",
|
|
783
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
826
|
+
async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
|
|
784
827
|
latestCtx = ctx;
|
|
785
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
828
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
786
829
|
return toolResult(await getComponents().runner.stage(params, root, signal), ctx, workspaceRowsByRequest);
|
|
787
830
|
},
|
|
788
831
|
});
|
|
@@ -790,12 +833,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
790
833
|
name: "subagent_integrate",
|
|
791
834
|
label: "Subagent integrate",
|
|
792
835
|
description: "Main advances staged dependents, refreshes after clean Main drift or evidence-less validation failure, validates in the background, corrects, promotes, reconciles interrupted validation/promotion, cleans up promoted resources, or explicitly releases rejected/superseded owned resources. Never replays an uncertain mutation.",
|
|
793
|
-
parameters: IntegrationActionParameters,
|
|
794
|
-
prepareArguments: parseIntegrationAction,
|
|
836
|
+
parameters: withRepo(IntegrationActionParameters),
|
|
837
|
+
prepareArguments: parseWithRepo(parseIntegrationAction),
|
|
795
838
|
exposure: "model-only",
|
|
796
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
839
|
+
async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
|
|
797
840
|
latestCtx = ctx;
|
|
798
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
841
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
799
842
|
if (params.action === "validate") {
|
|
800
843
|
return await startInSession(params.id, root, signal, ctx,
|
|
801
844
|
(state) => {
|
|
@@ -818,18 +861,87 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
818
861
|
name: "subagent_abort",
|
|
819
862
|
label: "Subagent abort",
|
|
820
863
|
description: "Abort an isolated request; reject and release retained candidates and integration checkouts first unless their checkouts and branches are proved absent. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged allocations; preserve dirty or committed work.",
|
|
821
|
-
parameters: IdOnlySchema,
|
|
822
|
-
prepareArguments: parseIdOnly,
|
|
864
|
+
parameters: withRepo(IdOnlySchema),
|
|
865
|
+
prepareArguments: parseWithRepo(parseIdOnly),
|
|
823
866
|
exposure: "model-only",
|
|
824
867
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
825
868
|
latestCtx = ctx;
|
|
826
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
869
|
+
const root = await lookupRoot(ctx.cwd, signal, params.repo);
|
|
827
870
|
return toolResult(await getComponents().runner.abort(params.id, root, signal), ctx, workspaceRowsByRequest);
|
|
828
871
|
},
|
|
829
872
|
});
|
|
873
|
+
/** Recovery report lines for one canonical checkout root. */
|
|
874
|
+
const recoveryLines = async (root: string): Promise<string[]> => {
|
|
875
|
+
const { requests, invalidIds, leaseBusy } = await getComponents().runner.recoverRepository(root);
|
|
876
|
+
const lines = [
|
|
877
|
+
`Pi Subagent recovery in ${root}: ${leaseBusy ? "productive lease is live; no state changed" : "orphaned interruptions classified; no work replayed"}.`,
|
|
878
|
+
...(requests.length ? requests.map((response) => {
|
|
879
|
+
const state = response.state;
|
|
880
|
+
const blockers = state.tasks.flatMap((task) => [
|
|
881
|
+
...(task.failure ? [`${task.taskId}: ${boundedPublicText(task.failure)}`] : []),
|
|
882
|
+
...(task.kind === "changeset" && task.attempts.some((attempt) => attempt.prompts.some((prompt) => prompt.status === "ambiguous"))
|
|
883
|
+
? [`${task.taskId}: ambiguous worker prompt; do not replay`] : []),
|
|
884
|
+
]);
|
|
885
|
+
const generation = state.integration.generations.at(-1);
|
|
886
|
+
const readyCandidates = state.integration.candidates.filter((candidate) => candidate.worker === "retained" && !candidate.decision);
|
|
887
|
+
return [
|
|
888
|
+
`${state.request.id}: ${state.status}; Main ${response.main?.status ?? "not checked"}.`,
|
|
889
|
+
...blockers.map((blocker) => ` Blocker: ${blocker}`),
|
|
890
|
+
...(state.final.failure ? [` Final blocker: ${boundedPublicText(state.final.failure)}`] : []),
|
|
891
|
+
...(generation?.failure ? [` Integration blocker: ${boundedPublicText(generation.failure)}`] : []),
|
|
892
|
+
...(state.status !== "completed" && readyCandidates.length ? [` ${readyCandidates.length} retained candidate(s); Main selects exact candidates with subagent_stage after subagent_status.`] : []),
|
|
893
|
+
...(state.status === "completed" ? [" Retained resources after completion; inspect subagent_status for guarded cleanup."] : []),
|
|
894
|
+
...(generation ? [` Generation ${generation.number}: ${generation.status}; inspect subagent_status before any subagent_integrate action.`] : []),
|
|
895
|
+
...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
|
|
896
|
+
].join("\n");
|
|
897
|
+
}) : ["No unfinished or retained isolated requests."]),
|
|
898
|
+
...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
|
|
899
|
+
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
900
|
+
: "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
|
|
901
|
+
];
|
|
902
|
+
return lines;
|
|
903
|
+
};
|
|
904
|
+
/** Report lines for discovery issues and truncation, because an incomplete search cannot prove absence. */
|
|
905
|
+
const discoveryWarnings = (found: RepoDiscovery): string[] => [
|
|
906
|
+
...(found.truncated ? [`Repository discovery below ${found.base} stopped at a limit; some repositories were not examined.`] : []),
|
|
907
|
+
...found.issues.slice(0, 20).map(({ path, reason }) => `Repository discovery could not examine ${path}: ${boundedPublicText(reason)}`),
|
|
908
|
+
...(found.issues.length > 20 ? [`${found.issues.length - 20} more repository discovery issues omitted.`] : []),
|
|
909
|
+
];
|
|
910
|
+
/** Read-only inventory across the Repository targets below a parent directory that is not a Git repository. */
|
|
911
|
+
const childInventory = async (cwd: string, current: () => boolean = () => true): Promise<IsolatedInventory> => {
|
|
912
|
+
const found = await discoverRepos(cwd);
|
|
913
|
+
const requests: IsolatedRequest[] = [];
|
|
914
|
+
const invalidIds: string[] = [];
|
|
915
|
+
const selectorOf = (root: string) => found.repos.find((item) => item.root === root)?.repo
|
|
916
|
+
?? (isWithin(found.base, root) ? relative(found.base, root).split(sep).join("/") : root);
|
|
917
|
+
for (const { repo, root } of found.repos) {
|
|
918
|
+
if (!current()) throw new Error("Session or branch changed; reopen /subagent.");
|
|
919
|
+
const listed = await getComponents().runner.listRequests(root);
|
|
920
|
+
const leaseLive = await getComponents().runner.hasProductiveRunLease(root);
|
|
921
|
+
for (const request of listed.requests) requests.push({ ...request, root: request.root ?? root, repo, leaseLive, checkout: root });
|
|
922
|
+
invalidIds.push(...listed.invalidIds.map((id) => `${repo}/${id}`));
|
|
923
|
+
}
|
|
924
|
+
// One scan of the whole state store. An orphan's primary checkout is live, so Git commands run there.
|
|
925
|
+
const orphans = await getComponents().runner.listAllOrphanRequests();
|
|
926
|
+
for (const { primary, ...request } of orphans.requests) {
|
|
927
|
+
if (!isWithin(found.base, primary)) continue;
|
|
928
|
+
requests.push({ ...request, repo: selectorOf(primary), leaseLive: false, checkout: primary });
|
|
929
|
+
}
|
|
930
|
+
invalidIds.push(...orphans.invalidIds);
|
|
931
|
+
return { root: found.base, leaseLive: requests.some((request) => request.leaseLive), requests,
|
|
932
|
+
invalidIds: [...new Set(invalidIds)], warnings: discoveryWarnings(found) };
|
|
933
|
+
};
|
|
830
934
|
return {
|
|
831
935
|
async inventory(cwd, current) {
|
|
832
|
-
|
|
936
|
+
let root: string;
|
|
937
|
+
try {
|
|
938
|
+
root = await lookupRoot(cwd);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
// Outside a Git repository, list the requests of each Repository target below cwd.
|
|
941
|
+
// Any other failure, such as a Git timeout, stays a failure.
|
|
942
|
+
if (await gitTopLevel(cwd).then((top) => top !== undefined, () => true)) throw error;
|
|
943
|
+
return await childInventory(cwd, current);
|
|
944
|
+
}
|
|
833
945
|
const revision = savedRevision;
|
|
834
946
|
const { requests, invalidIds, states } = await getComponents().runner.listRequests(root);
|
|
835
947
|
if (current?.() && !sessionClosed && revision === savedRevision) {
|
|
@@ -846,44 +958,42 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
846
958
|
return { root, leaseLive, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
|
|
847
959
|
},
|
|
848
960
|
async recover(cwd) {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
|
|
871
|
-
].join("\n");
|
|
872
|
-
}) : ["No unfinished or retained isolated requests."]),
|
|
873
|
-
...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
|
|
874
|
-
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
875
|
-
: "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
|
|
876
|
-
];
|
|
877
|
-
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
961
|
+
let root: string;
|
|
962
|
+
try {
|
|
963
|
+
root = await lookupRoot(cwd);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (await gitTopLevel(cwd).then((top) => top !== undefined, () => true)) throw error;
|
|
966
|
+
// Outside a Git repository, recover each Repository target that has saved requests.
|
|
967
|
+
const found = await discoverRepos(cwd);
|
|
968
|
+
const lines: string[] = [];
|
|
969
|
+
for (const { repo, root: child } of found.repos) {
|
|
970
|
+
const listed = await getComponents().runner.listRequests(child);
|
|
971
|
+
if (!listed.requests.length && !listed.invalidIds.length) continue;
|
|
972
|
+
lines.push(`repo ${repo}:`, ...await recoveryLines(child));
|
|
973
|
+
}
|
|
974
|
+
const warnings = discoveryWarnings(found);
|
|
975
|
+
// An incomplete discovery is not proof that no request exists.
|
|
976
|
+
if (!lines.length) lines.push(warnings.length ? `No isolated requests in the ${found.repos.length} repositories that discovery examined below ${found.base}.`
|
|
977
|
+
: `No isolated requests in the ${found.repos.length} repositories below ${found.base}.`);
|
|
978
|
+
lines.push(...warnings);
|
|
979
|
+
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
980
|
+
}
|
|
981
|
+
return boundedReport(await recoveryLines(root), "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
878
982
|
},
|
|
879
983
|
async discardPlan(cwd, request) {
|
|
880
|
-
|
|
984
|
+
// A request listed from a parent directory runs Git in a live checkout of its own repository.
|
|
985
|
+
if (request.checkout !== undefined) cwd = request.checkout;
|
|
986
|
+
const plan = await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
|
|
881
987
|
panes: () => listHerdrPanes(cwd), current: () => !sessionClosed,
|
|
882
988
|
blocked: async () => jobOwners.has(`${request.root}\0${request.id}`) ? "A local job is active for this request." : undefined,
|
|
883
989
|
});
|
|
990
|
+
return request.checkout === undefined ? plan : { ...plan, checkout: request.checkout };
|
|
884
991
|
},
|
|
885
992
|
async discard(cwd, plan, current) {
|
|
886
993
|
requireCurrent(current);
|
|
994
|
+
// The plan keeps the live checkout that it used. The saved request root can be removed.
|
|
995
|
+
const checkout = (plan as DiscardPlan & { checkout?: string }).checkout;
|
|
996
|
+
if (checkout !== undefined) cwd = checkout;
|
|
887
997
|
const removals = await getComponents().runner.discard(cwd, plan, {
|
|
888
998
|
panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
|
|
889
999
|
blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
|
|
@@ -925,21 +1035,25 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
925
1035
|
requireCurrent(current);
|
|
926
1036
|
return getComponents().runner.drainFollowups(root, requestId, taskId);
|
|
927
1037
|
},
|
|
928
|
-
async plan(request, signal, ctx) {
|
|
1038
|
+
async plan(request, signal, ctx, repo) {
|
|
929
1039
|
latestCtx = ctx;
|
|
930
1040
|
latestContext();
|
|
931
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
1041
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
932
1042
|
if (jobOwners.has(`${root}\0${request.id}`)) throw new Error(`Pi Subagent request ${request.id} is already active in this session runtime.`);
|
|
933
|
-
|
|
1043
|
+
const cwd = repo === undefined ? ctx.cwd : root;
|
|
1044
|
+
return formatExecutePlan(await (repo === undefined ? getComponents().runner.plan(request, cwd, signal) : getComponents().runner.plan(request, cwd, signal, { targeted: true })), false);
|
|
934
1045
|
},
|
|
935
|
-
async execute(request, signal, ctx) {
|
|
1046
|
+
async execute(request, signal, ctx, repo) {
|
|
936
1047
|
latestCtx = ctx;
|
|
937
1048
|
latestContext();
|
|
938
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
1049
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
1050
|
+
// The runner resolves the same root again from this cwd. A Repository target is its own root.
|
|
1051
|
+
const cwd = repo === undefined ? ctx.cwd : root;
|
|
939
1052
|
return await startInSession(request.id, root, signal, ctx,
|
|
940
1053
|
(state) => state.status === "pending" && state.createdAt === state.updatedAt
|
|
941
1054
|
&& state.tasks.every((task) => task.status === "pending" && task.attempts.length === 0),
|
|
942
|
-
(runSignal) => getComponents().runner.execute(request,
|
|
1055
|
+
(runSignal) => repo === undefined ? getComponents().runner.execute(request, cwd, runSignal)
|
|
1056
|
+
: getComponents().runner.execute(request, cwd, runSignal, { targeted: true }));
|
|
943
1057
|
},
|
|
944
1058
|
};
|
|
945
1059
|
}
|
|
@@ -50,6 +50,12 @@ export interface IsolatedRequest {
|
|
|
50
50
|
/** Saved state address for a removed parent checkout. */
|
|
51
51
|
directory?: string;
|
|
52
52
|
root?: string;
|
|
53
|
+
/** The Repository target selector, when /subagent runs in a parent directory of repositories. */
|
|
54
|
+
repo?: string;
|
|
55
|
+
/** The productive lease state of this request's root, when the inventory spans several roots. */
|
|
56
|
+
leaseLive?: boolean;
|
|
57
|
+
/** A live checkout of the request's repository for Git commands, when the inventory spans several roots. */
|
|
58
|
+
checkout?: string;
|
|
53
59
|
orphaned?: boolean;
|
|
54
60
|
name: string;
|
|
55
61
|
status: string;
|
|
@@ -61,6 +67,8 @@ export interface IsolatedInventory {
|
|
|
61
67
|
leaseLive: boolean;
|
|
62
68
|
requests: readonly IsolatedRequest[];
|
|
63
69
|
invalidIds: readonly string[];
|
|
70
|
+
/** Incomplete inventory evidence, such as discovery issues or truncation. */
|
|
71
|
+
warnings?: readonly string[];
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
export type { PruneItem };
|
|
@@ -192,7 +200,7 @@ const summary = (text: string, max = 60): string => {
|
|
|
192
200
|
return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : chars.join("");
|
|
193
201
|
};
|
|
194
202
|
// Identity and status first so clipping cuts only the goal summary.
|
|
195
|
-
const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
|
|
203
|
+
const isolatedLabel = ({ id, repo, status, name }: IsolatedRequest): string => `${id}${repo ? ` · repo ${repo}` : ""} · ${status} · ${summary(name)}`;
|
|
196
204
|
const completed = (status: string) => [...TERMINAL_RUN_STATUSES, "success", "failed", "failure", "integrated"].includes(status);
|
|
197
205
|
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
198
206
|
// Recorded-only and unverified work is uncertain, not active.
|
|
@@ -310,7 +318,7 @@ export function workingReport(direct: readonly DirectTask[], inventory: Isolated
|
|
|
310
318
|
}
|
|
311
319
|
for (const request of inventory?.requests ?? []) {
|
|
312
320
|
const active = !request.orphaned && ["pending", "running"].includes(request.status);
|
|
313
|
-
if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
|
|
321
|
+
if (active && (request.leaseLive ?? inventory!.leaseLive)) working.push({ text: isolatedLabel(request) });
|
|
314
322
|
else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
|
|
315
323
|
else if (request.status === "needs_attention" || /retained/.test(request.status)) attention.push({ text: isolatedLabel(request) });
|
|
316
324
|
else if (request.orphaned && /unverified/.test(request.status)) unverified.push({ text: isolatedLabel(request) });
|
|
@@ -558,6 +566,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
558
566
|
if (report) deliver(ctx, workingReport(direct, inventory));
|
|
559
567
|
report = false;
|
|
560
568
|
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
|
569
|
+
for (const warning of inventory?.warnings ?? []) ctx.ui.notify(warning, "warning");
|
|
561
570
|
if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
|
|
562
571
|
type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "many" } | { kind: "close" };
|
|
563
572
|
// A header row selects Refresh; rows sort by status priority within their group.
|
|
@@ -565,7 +574,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
565
574
|
items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
|
|
566
575
|
...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
|
|
567
576
|
const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
|
|
568
|
-
const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
|
|
577
|
+
const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: request.root ?? inventory!.root } });
|
|
569
578
|
// One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
|
|
570
579
|
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 } });
|
|
571
580
|
const groups = [
|
package/extensions/subagent.ts
CHANGED
|
@@ -255,6 +255,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
255
255
|
executor,
|
|
256
256
|
policy: initialPolicy,
|
|
257
257
|
currentPolicy,
|
|
258
|
+
// Admission reads these after currentPolicy, which throws for a disabled config.
|
|
259
|
+
repoSuites: () => { const loaded = configStore.loadSync(); return loaded.status === "ok" ? loaded.value.repoSuites : {}; },
|
|
258
260
|
beforeRoleLaunch,
|
|
259
261
|
});
|
|
260
262
|
let directSequence = 0;
|
|
@@ -701,7 +703,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
701
703
|
try {
|
|
702
704
|
const parsed = parseDelegateTask(args);
|
|
703
705
|
const dryRun = parsed.dryRun ? { dryRun: true } : {};
|
|
704
|
-
if (parsed.mode === "isolated") return { ...parsed.request, ...dryRun };
|
|
706
|
+
if (parsed.mode === "isolated") return { ...parsed.request, ...(parsed.repo === undefined ? {} : { repo: parsed.repo }), ...dryRun };
|
|
705
707
|
const workflow = parsed.workflow;
|
|
706
708
|
if (workflow.mode === "single") return { mode: "direct" as const, ...workflow.delegations[0], ...dryRun };
|
|
707
709
|
if (workflow.mode === "parallel") return { mode: "direct" as const, tasks: workflow.delegations, ...dryRun };
|
|
@@ -714,7 +716,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
714
716
|
reportConfig(ctx);
|
|
715
717
|
const parsed = parseDelegateTask(params);
|
|
716
718
|
if (parsed.mode === "isolated") {
|
|
717
|
-
return parsed.dryRun ? await isolatedSurface.plan(parsed.request, signal, ctx) : await isolatedSurface.execute(parsed.request, signal, ctx);
|
|
719
|
+
return parsed.dryRun ? await isolatedSurface.plan(parsed.request, signal, ctx, parsed.repo) : await isolatedSurface.execute(parsed.request, signal, ctx, parsed.repo);
|
|
718
720
|
}
|
|
719
721
|
const workflow = parsed.workflow;
|
|
720
722
|
const catalog = loadRoleCatalog();
|
package/extensions/workflow.ts
CHANGED
|
@@ -35,7 +35,7 @@ const ContinueFromSchema = Type.String({ pattern: "^D[1-9][0-9]*\\.[1-9][0-9]*$"
|
|
|
35
35
|
|
|
36
36
|
const RepoSchema = Type.String({
|
|
37
37
|
minLength: 1,
|
|
38
|
-
description: "Optional Repository target: a child Git repository as a path relative to the Pi working directory, such as team/service.
|
|
38
|
+
description: "Optional Repository target: a child Git repository as a path relative to the Pi working directory, such as team/service. Direct workers run there, and admission reserves that checkout; a direct task value overrides the request value. Mode isolated accepts it only on the request, and the whole graph, its state, and its promotion use that repository. Omit it to use the Pi working directory. Use subagent_repos to list targets.",
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
const PathsSchema = Type.Array(Type.String({ minLength: 1 }), {
|
|
@@ -94,7 +94,8 @@ export type ParsedWorkflow =
|
|
|
94
94
|
| { mode: "single"; delegations: [Delegation] }
|
|
95
95
|
| { mode: "parallel"; delegations: Delegation[] }
|
|
96
96
|
| { mode: "chain"; delegations: Delegation[] };
|
|
97
|
-
|
|
97
|
+
/** An isolated `repo` selects the one Repository target of the whole graph. It is not part of the durable request. */
|
|
98
|
+
export type ParsedDelegateTask = ({ mode: "direct"; workflow: ParsedWorkflow } | { mode: "isolated"; request: ExecuteRequest; repo?: string }) & { dryRun: boolean };
|
|
98
99
|
|
|
99
100
|
type DirectInput = Static<typeof DirectWorkflowSchema>;
|
|
100
101
|
const DELEGATION_KEYS = ["role", "name", "task", "access", "paths", "continueFrom", "kind", "model", "modelClass"] as const;
|
|
@@ -122,7 +123,7 @@ function normalizePaths(value: Static<typeof DelegationSchema>, path: string): s
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
/** Check the selector shape here. Admission and launch resolve it against the Pi working directory. */
|
|
125
|
-
function normalizeRepo(value: string, path: string): string {
|
|
126
|
+
export function normalizeRepo(value: string, path: string): string {
|
|
126
127
|
const normalized = text(value, path);
|
|
127
128
|
if (/[\u0000-\u001f\u007f-\u009f]/.test(normalized) || normalized.startsWith("/") || normalized.split(/[\\/]/).includes("..")) {
|
|
128
129
|
throw new Error(`${path} must be a relative path without control characters or .. components.`);
|
|
@@ -207,8 +208,10 @@ export function parseDelegateTask(value: unknown): ParsedDelegateTask {
|
|
|
207
208
|
value = rest;
|
|
208
209
|
}
|
|
209
210
|
if (value && typeof value === "object" && !Array.isArray(value) && (value as { mode?: unknown }).mode === "isolated") {
|
|
210
|
-
|
|
211
|
-
return { mode: "isolated", request: parseExecuteRequest(
|
|
211
|
+
const { repo, ...request } = value as Record<string, unknown>;
|
|
212
|
+
if (repo === undefined) return { mode: "isolated", request: parseExecuteRequest(request), dryRun };
|
|
213
|
+
if (typeof repo !== "string") throw new Error("repo must be a relative path.");
|
|
214
|
+
return { mode: "isolated", request: parseExecuteRequest(request), repo: normalizeRepo(repo, "repo"), dryRun };
|
|
212
215
|
}
|
|
213
216
|
return { mode: "direct", workflow: parseWorkflow(value), dryRun };
|
|
214
217
|
}
|
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ To build on a finished direct worker, add `continueFrom` with its reference (suc
|
|
|
30
30
|
|
|
31
31
|
# Isolated requests
|
|
32
32
|
|
|
33
|
-
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks.
|
|
33
|
+
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. Admission adds the repository's Full suite to final checks if missing: the `repoSuites` config entry, `pnpm test` for a root `package.json` test script, or `make test` for a Makefile `test` target. The suite runs against the combined tip before Main promotion. If no suite exists, tell the user to add one or configure `repoSuites`. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
|
|
34
34
|
|
|
35
35
|
Every changeset becomes ready automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
36
36
|
|