@ferris1225/pi-subagents 4.1.5 → 4.1.6
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 +28 -3
- package/package.json +1 -1
- package/src/dispatch.ts +8 -3
- package/src/monitor.ts +29 -6
- package/src/rpc-run.ts +26 -2
- package/src/thread-lifecycle.ts +85 -13
- package/src/tools.ts +49 -5
- package/src/widget.ts +35 -5
- package/src/worktree.ts +12 -0
package/README.md
CHANGED
|
@@ -380,16 +380,26 @@ worktree finalization to the same one-time lifecycle owner. `stop-all` interrupt
|
|
|
380
380
|
every lane holder before waiting for finalization, avoiding self-deadlock when an
|
|
381
381
|
isolated apply is queued behind shared work.
|
|
382
382
|
|
|
383
|
+
Every control operation is bounded: park, stop, resume, and fork never wait
|
|
384
|
+
indefinitely on a generation that is still settling (for example an isolated
|
|
385
|
+
apply queued behind the managed repository lane). Stop proceeds after a bounded
|
|
386
|
+
deadline once it owns the lifecycle, a still-running integration continues in the
|
|
387
|
+
background, and a durable recovery record is persisted pointing at the retained
|
|
388
|
+
worktree/patch so stopped work is never lost.
|
|
389
|
+
|
|
383
390
|
## Results and live status
|
|
384
391
|
|
|
385
392
|
The active TUI widget shows standalone runs normally and projects each managed
|
|
386
|
-
workflow as a compact timeline plus its current internal child
|
|
393
|
+
workflow as a compact timeline plus its current internal child. Every row leads
|
|
394
|
+
with its stable run id — the handle for `subagent_control`, `subagent_status`,
|
|
395
|
+
and `subagent_stop`:
|
|
387
396
|
|
|
388
397
|
```text
|
|
389
|
-
◆ worker workflow · src/cache.ts
|
|
398
|
+
◆ #12 worker workflow · src/cache.ts · wt:a91f3c · 42s
|
|
390
399
|
✓ implement ─ ● review ─ ○ docs
|
|
391
|
-
└ ● reviewer · final review · claude-sonnet-4-5/high · 10s
|
|
400
|
+
└ ● #15 reviewer · final review · claude-sonnet-4-5/high · 10s
|
|
392
401
|
git diff
|
|
402
|
+
○ #23 worker · queued · redirect to ripgrep crates · 5m02s
|
|
393
403
|
```
|
|
394
404
|
|
|
395
405
|
Success is green, the active stage uses the accent color and bold text, pending
|
|
@@ -398,6 +408,15 @@ error. Fix paths show their budget (`fix 1/2`, `re-review 1/2`). The timeline
|
|
|
398
408
|
contains only stages that ran or are currently planned; `DOCUMENTATION: CLEAN`
|
|
399
409
|
removes pending docs instead of pretending that stage ran.
|
|
400
410
|
|
|
411
|
+
Worktree-isolated runs carry a group badge on the row that owns the worktree:
|
|
412
|
+
`wt:<id>` while active, `wt:<id> applying` while the settled patch is being
|
|
413
|
+
applied to the original checkout, then `applied`/`clean`, or `retained` when
|
|
414
|
+
integration failed. The short id changes when a resumed generation creates a
|
|
415
|
+
continuation worktree, so a group boundary change is visible at a glance.
|
|
416
|
+
Nested stage rows inherit the group through the tree instead of repeating the
|
|
417
|
+
badge. Queued rows say `queued` and omit model/thinking — the route is
|
|
418
|
+
re-resolved when the run actually starts.
|
|
419
|
+
|
|
401
420
|
A managed root keeps its original top-level role and workflow-wide elapsed time,
|
|
402
421
|
but omits model/thinking because several model stages own it. The active nested
|
|
403
422
|
row shows the current role, relation, selected/fallback model, thinking, stage
|
|
@@ -436,6 +455,12 @@ session. Searches, reads, reasoning, and edits already completed are preserved.
|
|
|
436
455
|
Ordinary tool and test failures remain task failures and do not trigger a model
|
|
437
456
|
handoff.
|
|
438
457
|
|
|
458
|
+
Model changes apply immediately to work that has not started: a run still
|
|
459
|
+
waiting for a concurrency slot re-resolves its route when it actually starts,
|
|
460
|
+
and managed workflow stages (fix rounds, re-reviews, the conditional documenter)
|
|
461
|
+
re-read the config before each stage launches. Only an already-running child
|
|
462
|
+
keeps the model it started with.
|
|
463
|
+
|
|
439
464
|
Thinking defaults to **Auto**. pi-subagents starts from the role's preference and
|
|
440
465
|
chooses only a level the effective model actually supports. A fallback re-checks
|
|
441
466
|
the level for the main model. `documenter` deliberately ships with the same fast,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.6",
|
|
4
4
|
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/dispatch.ts
CHANGED
|
@@ -208,7 +208,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
208
208
|
if (!run) return; // already finished — stay idempotent
|
|
209
209
|
if (opts?.silent || !runtime.sessionActive) return;
|
|
210
210
|
const icon = status === "done" ? "✓" : "✗";
|
|
211
|
-
ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
211
|
+
ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
212
212
|
};
|
|
213
213
|
|
|
214
214
|
// Live sub-agent activity → concise one-line status ("thinking",
|
|
@@ -315,7 +315,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
315
315
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
316
316
|
resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
|
|
317
317
|
const agent = resolveLiveAgentTools(discoveredAgent);
|
|
318
|
-
|
|
318
|
+
// Workflow policy (fix-round caps, agents) stays fixed for the chain,
|
|
319
|
+
// but model/thinking routes are re-read per stage so config edits
|
|
320
|
+
// apply to stages that have not launched yet.
|
|
321
|
+
const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
|
|
322
|
+
const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
|
|
319
323
|
const route = request.isolation === "worktree"
|
|
320
324
|
? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
|
|
321
325
|
: resolvedRoute;
|
|
@@ -323,6 +327,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
323
327
|
const runId = monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
|
|
324
328
|
...meta,
|
|
325
329
|
isolation: request.isolation,
|
|
330
|
+
...(request.worktreeId ? { worktreeId: request.worktreeId } : {}),
|
|
326
331
|
});
|
|
327
332
|
const onLive = makeLiveHandler(runId);
|
|
328
333
|
try {
|
|
@@ -339,7 +344,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
339
344
|
signal: request.signal,
|
|
340
345
|
onLive,
|
|
341
346
|
makeDetails: makeDetails("single", true),
|
|
342
|
-
idleTimeoutMs:
|
|
347
|
+
idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
|
|
343
348
|
},
|
|
344
349
|
route.mainFallbackRef,
|
|
345
350
|
);
|
package/src/monitor.ts
CHANGED
|
@@ -34,13 +34,18 @@ export function isRunActiveStatus(status: RunStatus): boolean {
|
|
|
34
34
|
return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Durable integration projection of a worktree-isolated run: pending before
|
|
38
|
+
* settlement, finalizing while the patch is applied/cleaned up, then the
|
|
39
|
+
* terminal WorktreeFinalizationStatus. */
|
|
40
|
+
export type RunIntegrationStatus = "pending" | "finalizing" | WorktreeFinalizationStatus;
|
|
41
|
+
|
|
37
42
|
export interface RunView {
|
|
38
43
|
id: number;
|
|
39
44
|
agent: string;
|
|
40
45
|
task: string;
|
|
41
46
|
/** Short content label derived from the task (paths/symbols), shown next to
|
|
42
47
|
* the agent name so concurrent same-agent runs are told apart by what they
|
|
43
|
-
* are doing, not just their run id. */
|
|
48
|
+
* are doing, not just by their run id. */
|
|
44
49
|
label?: string;
|
|
45
50
|
model?: string;
|
|
46
51
|
/** Selected model ref when the run handed off to current main. */
|
|
@@ -48,7 +53,10 @@ export interface RunView {
|
|
|
48
53
|
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
49
54
|
thinking?: string;
|
|
50
55
|
isolation?: IsolationMode;
|
|
51
|
-
integrationStatus?:
|
|
56
|
+
integrationStatus?: RunIntegrationStatus;
|
|
57
|
+
/** Short worktree-group identity (mkdtemp suffix) shared by every run inside
|
|
58
|
+
* one isolated worktree; changes when a continuation worktree is created. */
|
|
59
|
+
worktreeId?: string;
|
|
52
60
|
forkedFromRunId?: number;
|
|
53
61
|
forkChildRunIds?: number[];
|
|
54
62
|
status: RunStatus;
|
|
@@ -85,6 +93,7 @@ export interface RunChainMeta {
|
|
|
85
93
|
relationLabel?: string;
|
|
86
94
|
parentRunId?: number;
|
|
87
95
|
isolation?: IsolationMode;
|
|
96
|
+
worktreeId?: string;
|
|
88
97
|
forkedFromRunId?: number;
|
|
89
98
|
continuationKind?: ContinuationKind;
|
|
90
99
|
}
|
|
@@ -458,7 +467,7 @@ export class MonitorStore {
|
|
|
458
467
|
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
459
468
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
460
469
|
...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
|
|
461
|
-
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
|
|
470
|
+
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
|
|
462
471
|
...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
|
|
463
472
|
...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
|
|
464
473
|
});
|
|
@@ -559,11 +568,17 @@ export class MonitorStore {
|
|
|
559
568
|
this.notify();
|
|
560
569
|
}
|
|
561
570
|
|
|
562
|
-
setIsolation(
|
|
571
|
+
setIsolation(
|
|
572
|
+
id: number,
|
|
573
|
+
isolation: IsolationMode,
|
|
574
|
+
integrationStatus?: RunIntegrationStatus,
|
|
575
|
+
worktreeId?: string,
|
|
576
|
+
): void {
|
|
563
577
|
const run = this.find(id);
|
|
564
578
|
if (!run) return;
|
|
565
579
|
run.isolation = isolation;
|
|
566
580
|
run.integrationStatus = integrationStatus;
|
|
581
|
+
if (worktreeId) run.worktreeId = worktreeId;
|
|
567
582
|
this.notify();
|
|
568
583
|
}
|
|
569
584
|
|
|
@@ -608,7 +623,7 @@ export class MonitorStore {
|
|
|
608
623
|
model?: string,
|
|
609
624
|
thinking?: string,
|
|
610
625
|
isolation?: IsolationMode,
|
|
611
|
-
meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
|
|
626
|
+
meta?: { elapsedMs?: number; continuationKind?: ContinuationKind; worktreeId?: string },
|
|
612
627
|
): void {
|
|
613
628
|
const run = this.find(id);
|
|
614
629
|
if (!run) {
|
|
@@ -619,7 +634,13 @@ export class MonitorStore {
|
|
|
619
634
|
label: runLabel(task),
|
|
620
635
|
model,
|
|
621
636
|
thinking,
|
|
622
|
-
...(isolation
|
|
637
|
+
...(isolation
|
|
638
|
+
? {
|
|
639
|
+
isolation,
|
|
640
|
+
integrationStatus: isolation === "worktree" ? "pending" as const : undefined,
|
|
641
|
+
...(isolation === "worktree" && meta?.worktreeId ? { worktreeId: meta.worktreeId } : {}),
|
|
642
|
+
}
|
|
643
|
+
: {}),
|
|
623
644
|
status: "queued",
|
|
624
645
|
usage: emptyUsage(),
|
|
625
646
|
elapsedMs: meta?.elapsedMs ?? 0,
|
|
@@ -635,6 +656,8 @@ export class MonitorStore {
|
|
|
635
656
|
run.thinking = thinking;
|
|
636
657
|
if (isolation) run.isolation = isolation;
|
|
637
658
|
run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
|
|
659
|
+
if (isolation === "worktree" && meta?.worktreeId) run.worktreeId = meta.worktreeId;
|
|
660
|
+
else if (isolation !== "worktree") run.worktreeId = undefined;
|
|
638
661
|
run.status = "queued";
|
|
639
662
|
run.usage = emptyUsage();
|
|
640
663
|
run.activity = undefined;
|
package/src/rpc-run.ts
CHANGED
|
@@ -788,8 +788,32 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
788
788
|
if (!closed) await processClosed.promise;
|
|
789
789
|
return;
|
|
790
790
|
}
|
|
791
|
-
|
|
792
|
-
|
|
791
|
+
// Bound the abort settlement exactly like stop: a child that never
|
|
792
|
+
// settles after abort must not hold the control operation forever.
|
|
793
|
+
let parkTimer: ReturnType<typeof setTimeout> | undefined;
|
|
794
|
+
let parkTimedOut = false;
|
|
795
|
+
const parkDeadline = new Promise<boolean>((resolve) => {
|
|
796
|
+
parkTimer = setTimeout(() => {
|
|
797
|
+
parkTimedOut = true;
|
|
798
|
+
resolve(false);
|
|
799
|
+
}, RPC_ABORT_SETTLE_TIMEOUT_MS);
|
|
800
|
+
if (typeof parkTimer.unref === "function") parkTimer.unref();
|
|
801
|
+
});
|
|
802
|
+
let accepted: boolean;
|
|
803
|
+
try {
|
|
804
|
+
accepted = await Promise.race([abortAcceptedPrompt(), parkDeadline]);
|
|
805
|
+
} catch {
|
|
806
|
+
/* a rejected abort still parks; termination below is the bounded fallback */
|
|
807
|
+
accepted = false;
|
|
808
|
+
} finally {
|
|
809
|
+
if (parkTimer) clearTimeout(parkTimer);
|
|
810
|
+
}
|
|
811
|
+
if (abortSettlement) {
|
|
812
|
+
const stable = abortSettlement;
|
|
813
|
+
abortSettlement = undefined;
|
|
814
|
+
stable.resolve();
|
|
815
|
+
}
|
|
816
|
+
if (!accepted && !parkTimedOut && !closed) await processClosed.promise;
|
|
793
817
|
if (finished && accepted) throw new Error("Thread exited while parking.");
|
|
794
818
|
markParked();
|
|
795
819
|
setAttemptPhase("parked");
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
} from "./spawn.ts";
|
|
68
68
|
import {
|
|
69
69
|
createWorktreeIsolation,
|
|
70
|
+
worktreeGroupId,
|
|
70
71
|
type IsolationMode,
|
|
71
72
|
type WorktreeFinalization,
|
|
72
73
|
type WorktreeIsolation,
|
|
@@ -75,6 +76,24 @@ import {
|
|
|
75
76
|
export const FORK_CONTINUATION_PROMPT =
|
|
76
77
|
"Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
|
|
77
78
|
|
|
79
|
+
/** Control operations must never wait forever on a settling generation: the
|
|
80
|
+
* queue task can legitimately spend minutes in worktree finalization (bounded
|
|
81
|
+
* per-Git-command timeouts) or wait behind the managed repository lane. After
|
|
82
|
+
* this deadline the control path owns the lifecycle synchronously and proceeds
|
|
83
|
+
* while the stuck tail settles silently in the background. */
|
|
84
|
+
export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
|
|
85
|
+
|
|
86
|
+
/** Resolve true when the promise settles, or false after the bounded deadline. */
|
|
87
|
+
export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
|
|
88
|
+
return Promise.race([
|
|
89
|
+
promise.then(() => true, () => true),
|
|
90
|
+
new Promise<boolean>((resolve) => {
|
|
91
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
92
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
const WORKTREE_ISOLATION_INSTRUCTIONS =
|
|
79
98
|
"You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
|
|
80
99
|
|
|
@@ -148,6 +167,8 @@ export interface ManagedWorkflowRequest extends DispatchEnvironment {
|
|
|
148
167
|
executionCwd: string;
|
|
149
168
|
projectCwd: string;
|
|
150
169
|
isolation: IsolationMode;
|
|
170
|
+
/** Short identity of the isolated worktree shared by every workflow stage. */
|
|
171
|
+
worktreeId?: string;
|
|
151
172
|
signal: AbortSignal;
|
|
152
173
|
rememberLatest: (result: SingleResult) => void;
|
|
153
174
|
}
|
|
@@ -294,6 +315,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
294
315
|
}
|
|
295
316
|
}
|
|
296
317
|
const executionCwd = worktree?.cwd ?? originalCwd;
|
|
318
|
+
const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
|
|
297
319
|
const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
|
|
298
320
|
// Isolation is a persistent system-level invariant, not a one-shot task
|
|
299
321
|
// prefix: queued retargets, live retargets, resumes, and main-model
|
|
@@ -310,6 +332,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
310
332
|
}
|
|
311
333
|
const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
|
|
312
334
|
isolation,
|
|
335
|
+
...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
|
|
313
336
|
...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
|
|
314
337
|
...(seed?.continuationKind ? { continuationKind: seed.continuationKind } : {}),
|
|
315
338
|
});
|
|
@@ -329,6 +352,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
329
352
|
monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
|
|
330
353
|
elapsedMs: existingThread.elapsedMs,
|
|
331
354
|
continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
|
|
355
|
+
...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
|
|
332
356
|
});
|
|
333
357
|
runtime.settledRuns.delete(runId);
|
|
334
358
|
}
|
|
@@ -426,12 +450,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
426
450
|
// All normal, destructive-stop, and shutdown owners converge here. Cache
|
|
427
451
|
// the lane-protected apply itself so superseding lifecycle paths can project
|
|
428
452
|
// the same finalization onto their own result without acquiring twice.
|
|
429
|
-
generationFinalization
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
453
|
+
if (!generationFinalization) {
|
|
454
|
+
monitor.setIsolation(
|
|
455
|
+
runId,
|
|
456
|
+
"worktree",
|
|
457
|
+
"finalizing",
|
|
458
|
+
worktreeGroupId(generationWorktree),
|
|
459
|
+
);
|
|
460
|
+
generationFinalization = runInManagedRepositoryLane(
|
|
461
|
+
generationWorktree.originalRoot,
|
|
462
|
+
() => generationWorktree.finalize(),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
433
465
|
const finalization = await generationFinalization;
|
|
434
|
-
monitor.setIsolation(runId, "worktree", finalization.status);
|
|
466
|
+
monitor.setIsolation(runId, "worktree", finalization.status, worktreeGroupId(generationWorktree));
|
|
435
467
|
if (result) {
|
|
436
468
|
result.runId = runId;
|
|
437
469
|
result.isolation = "worktree";
|
|
@@ -565,7 +597,12 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
565
597
|
// control after that child settles, so cancel its queue owner explicitly.
|
|
566
598
|
if (phase === "settled") runtime.backgroundQueue.cancel(controller);
|
|
567
599
|
}
|
|
568
|
-
|
|
600
|
+
// The settling tail may be blocked on worktree finalization or the
|
|
601
|
+
// managed repository lane. Park already owns the lifecycle, so proceed
|
|
602
|
+
// after a bounded wait and let the tail finish silently in the background.
|
|
603
|
+
if (!(await quiesced(completion))) {
|
|
604
|
+
runtime.backgroundQueue.cancel(controller);
|
|
605
|
+
}
|
|
569
606
|
if (
|
|
570
607
|
thread.generation !== generation ||
|
|
571
608
|
thread.lifecycleVersion !== version ||
|
|
@@ -631,7 +668,15 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
631
668
|
let continuationWorktree: WorktreeIsolation | undefined;
|
|
632
669
|
let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
633
670
|
try {
|
|
634
|
-
|
|
671
|
+
// Never wait forever on a previous generation that is still settling
|
|
672
|
+
// (e.g. blocked behind the managed repository lane in finalization).
|
|
673
|
+
if (!(await quiesced(thread.generationCompletion))) {
|
|
674
|
+
return failedStartResult(
|
|
675
|
+
thread.agentName,
|
|
676
|
+
thread.task,
|
|
677
|
+
`Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
635
680
|
if (!ownsResumeReservation(thread, reservation)) {
|
|
636
681
|
return failedStartResult(
|
|
637
682
|
thread.agentName,
|
|
@@ -811,7 +856,15 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
811
856
|
let childWorktree: WorktreeIsolation | undefined;
|
|
812
857
|
let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
813
858
|
try {
|
|
814
|
-
|
|
859
|
+
// Same bounded preflight as resume: a still-settling source generation
|
|
860
|
+
// must not block the control operation forever.
|
|
861
|
+
if (!(await quiesced(thread.generationCompletion))) {
|
|
862
|
+
return failedStartResult(
|
|
863
|
+
thread.agentName,
|
|
864
|
+
thread.task,
|
|
865
|
+
`Run #${runId}'s previous generation is still settling; retry the fork shortly.`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
815
868
|
if (!ownsFork()) {
|
|
816
869
|
return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
|
|
817
870
|
}
|
|
@@ -911,23 +964,41 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
911
964
|
isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
|
|
912
965
|
const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
|
|
913
966
|
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
967
|
+
// Model/thinking config may have changed while this generation sat
|
|
968
|
+
// queued behind the concurrency limit. Re-resolve the route at actual
|
|
969
|
+
// start so /subagents-setup edits apply to not-yet-started runs.
|
|
970
|
+
let activeRoute = route;
|
|
971
|
+
let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
|
|
972
|
+
try {
|
|
973
|
+
const startConfig = await loadConfig(runtime.configPath);
|
|
974
|
+
runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
|
|
975
|
+
const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
|
|
976
|
+
activeRoute = isolation === "worktree"
|
|
977
|
+
? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
|
|
978
|
+
: resolvedStart;
|
|
979
|
+
activeIdleTimeoutMs = startConfig.idleTimeoutSec * 1000;
|
|
980
|
+
monitor.setModel(runId, activeRoute.agent.model);
|
|
981
|
+
monitor.setThinking(runId, activeRoute.thinkingLevel);
|
|
982
|
+
} catch {
|
|
983
|
+
/* keep the dispatch-time route when fresh config is unavailable */
|
|
984
|
+
}
|
|
914
985
|
let result: SingleResult;
|
|
915
986
|
try {
|
|
916
987
|
result = await runSingleAgentWithMainFallback(
|
|
917
988
|
{
|
|
918
989
|
defaultCwd: executionCwd,
|
|
919
|
-
agent:
|
|
990
|
+
agent: activeRoute.agent,
|
|
920
991
|
resolveAgentForAttempt: resolveLiveAgentTools,
|
|
921
992
|
agentName,
|
|
922
993
|
task,
|
|
923
994
|
cwd: executionCwd,
|
|
924
|
-
thinkingLevel,
|
|
925
|
-
thinkingLevelForModel:
|
|
995
|
+
thinkingLevel: activeRoute.thinkingLevel,
|
|
996
|
+
thinkingLevelForModel: activeRoute.thinkingLevelForModel,
|
|
926
997
|
signal: backgroundSignal,
|
|
927
998
|
onLive,
|
|
928
999
|
control,
|
|
929
1000
|
makeDetails: makeDetails("single", true),
|
|
930
|
-
idleTimeoutMs:
|
|
1001
|
+
idleTimeoutMs: activeIdleTimeoutMs,
|
|
931
1002
|
...(priorSessionId && priorSessionDir
|
|
932
1003
|
? {
|
|
933
1004
|
sessionId: priorSessionId,
|
|
@@ -938,7 +1009,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
938
1009
|
}
|
|
939
1010
|
: {}),
|
|
940
1011
|
},
|
|
941
|
-
|
|
1012
|
+
activeRoute.mainFallbackRef,
|
|
942
1013
|
);
|
|
943
1014
|
} catch (error) {
|
|
944
1015
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -1013,6 +1084,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
1013
1084
|
executionCwd: thread.executionCwd,
|
|
1014
1085
|
projectCwd: originalCwd,
|
|
1015
1086
|
isolation,
|
|
1087
|
+
...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
|
|
1016
1088
|
signal: backgroundSignal,
|
|
1017
1089
|
ctx: runCtx,
|
|
1018
1090
|
config: runConfig,
|
package/src/tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
10
11
|
import { Type } from "typebox";
|
|
11
12
|
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
12
13
|
import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
@@ -19,8 +20,11 @@ import {
|
|
|
19
20
|
statusLabel,
|
|
20
21
|
type RunStatus,
|
|
21
22
|
} from "./monitor.ts";
|
|
23
|
+
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
22
24
|
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
25
|
+
import { CONTROL_QUIESCE_TIMEOUT_MS, quiesced } from "./thread-lifecycle.ts";
|
|
23
26
|
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
27
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
24
28
|
|
|
25
29
|
/** In-turn result lookup. Dispatch already ended the turn and results arrive as
|
|
26
30
|
* wake-up messages, so the default must NOT block: a settled run returns its
|
|
@@ -648,6 +652,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
648
652
|
|
|
649
653
|
const stopped: string[] = [];
|
|
650
654
|
const retainedIntegration: string[] = [];
|
|
655
|
+
const pendingIntegration: string[] = [];
|
|
651
656
|
for (const [claimIndex, claim] of claimed.entries()) {
|
|
652
657
|
const {
|
|
653
658
|
runId,
|
|
@@ -663,8 +668,13 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
663
668
|
stopVersion,
|
|
664
669
|
stopMessage,
|
|
665
670
|
} = claim;
|
|
666
|
-
|
|
667
|
-
|
|
671
|
+
// Every wait here is bounded: the queue task can sit for minutes in
|
|
672
|
+
// worktree finalization or behind the managed repository lane, and an
|
|
673
|
+
// unkillable child can stall even the RPC-level stop. Stop owns the
|
|
674
|
+
// lifecycle synchronously, so a stuck tail settles silently after we
|
|
675
|
+
// proceed; none of its late paths can publish a second result.
|
|
676
|
+
await quiesced(interruptionPromises[claimIndex]);
|
|
677
|
+
if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
|
|
668
678
|
if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
|
|
669
679
|
if (thread.queueController === controller) thread.queueController = undefined;
|
|
670
680
|
|
|
@@ -703,8 +713,42 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
703
713
|
runId,
|
|
704
714
|
isolation: thread.isolation,
|
|
705
715
|
};
|
|
706
|
-
const
|
|
707
|
-
|
|
716
|
+
const worktree = thread.worktree;
|
|
717
|
+
let finalization: WorktreeFinalization | undefined;
|
|
718
|
+
try {
|
|
719
|
+
finalization = await Promise.race([
|
|
720
|
+
thread.finalizeIsolation(generation, stoppedResult),
|
|
721
|
+
new Promise<undefined>((resolve) => {
|
|
722
|
+
const timer = setTimeout(() => resolve(undefined), CONTROL_QUIESCE_TIMEOUT_MS);
|
|
723
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
724
|
+
}),
|
|
725
|
+
]);
|
|
726
|
+
} catch {
|
|
727
|
+
/* an unexpected finalize rejection must not block the stop */
|
|
728
|
+
}
|
|
729
|
+
if (finalization === undefined) {
|
|
730
|
+
// Integration is still settling in the background. Point a
|
|
731
|
+
// durable recovery record at the artifacts so the isolated work
|
|
732
|
+
// stays findable even if the background tail later fails; a
|
|
733
|
+
// successful tail removes them and the record self-prunes.
|
|
734
|
+
if (thread.isolation === "worktree" && worktree) {
|
|
735
|
+
stoppedResult.integrationStatus = "pending";
|
|
736
|
+
stoppedResult.integrationWorktreePath = worktree.worktreePath;
|
|
737
|
+
await persistRecoveryRecords(runtime.configPath, [
|
|
738
|
+
recoveryRecordFromFinalization(runId, {
|
|
739
|
+
status: "retained",
|
|
740
|
+
integrated: false,
|
|
741
|
+
hadChanges: false,
|
|
742
|
+
...(existsSync(worktree.worktreePath) ? { worktreePath: worktree.worktreePath } : {}),
|
|
743
|
+
...(existsSync(worktree.patchPath) ? { patchPath: worktree.patchPath } : {}),
|
|
744
|
+
error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
|
|
745
|
+
}),
|
|
746
|
+
]).catch(() => undefined);
|
|
747
|
+
}
|
|
748
|
+
pendingIntegration.push(`#${runId}`);
|
|
749
|
+
} else if (finalization.status === "retained") {
|
|
750
|
+
retainedIntegration.push(`#${runId}`);
|
|
751
|
+
}
|
|
708
752
|
runtime.registerRunResult(runId, stoppedResult);
|
|
709
753
|
thread.lastResult = stoppedResult;
|
|
710
754
|
}
|
|
@@ -730,7 +774,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
730
774
|
return {
|
|
731
775
|
content: [{
|
|
732
776
|
type: "text",
|
|
733
|
-
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}`,
|
|
777
|
+
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}${pendingIntegration.length > 0 ? ` Integration is still settling in the background for ${pendingIntegration.join(", ")}; a recovery record was persisted in case it fails.` : ""}`,
|
|
734
778
|
}],
|
|
735
779
|
details: {},
|
|
736
780
|
};
|
package/src/widget.ts
CHANGED
|
@@ -39,6 +39,26 @@ function formatContinuationTask(label: string, task: string, width: number): str
|
|
|
39
39
|
return summary ? `${label}${separator}${summary}` : label;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Worktree-group badge shown on the row that owns the isolated worktree: the
|
|
43
|
+
* short group identity plus its integration state, so a workflow visibly moves
|
|
44
|
+
* through applying → applied (or retained) and a continuation worktree (new
|
|
45
|
+
* identity) is distinguishable from the original one. */
|
|
46
|
+
function worktreeBadge(run: RunView): string {
|
|
47
|
+
const id = run.worktreeId ?? "?";
|
|
48
|
+
switch (run.integrationStatus) {
|
|
49
|
+
case "finalizing":
|
|
50
|
+
return `wt:${id} applying`;
|
|
51
|
+
case "integrated":
|
|
52
|
+
return `wt:${id} applied`;
|
|
53
|
+
case "no_changes":
|
|
54
|
+
return `wt:${id} clean`;
|
|
55
|
+
case "retained":
|
|
56
|
+
return `wt:${id} retained`;
|
|
57
|
+
default:
|
|
58
|
+
return `wt:${id}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
/** One compact primary line per genuinely active run, plus an optional indented
|
|
43
63
|
* activity line. The primary line reserves stage model/thinking when present and
|
|
44
64
|
* elapsed width before truncating the task. Settled and parked threads never
|
|
@@ -49,6 +69,7 @@ function runPrimaryLine(
|
|
|
49
69
|
width: number,
|
|
50
70
|
now: number,
|
|
51
71
|
prefix: string,
|
|
72
|
+
isGroupOwner: boolean,
|
|
52
73
|
): string {
|
|
53
74
|
const dim = (text: string): string => theme.fg("dim", text);
|
|
54
75
|
const icon = run.managedWorkflow && run.status === "running"
|
|
@@ -56,18 +77,24 @@ function runPrimaryLine(
|
|
|
56
77
|
: statusIcon(run.status, theme);
|
|
57
78
|
const displayName = run.managedWorkflow ? `${run.agent} workflow` : run.agent;
|
|
58
79
|
const name = theme.fg("accent", theme.bold(displayName));
|
|
59
|
-
|
|
80
|
+
// The stable run id is the handle for subagent_control/subagent_status; a
|
|
81
|
+
// queued run has not started, which must be visible at a glance. Its model
|
|
82
|
+
// is omitted too: the route is re-resolved when the run actually starts.
|
|
83
|
+
const queued = run.status === "queued";
|
|
84
|
+
const queuedTag = queued ? ` ${dim("· queued")}` : "";
|
|
85
|
+
const identity = `${prefix}${icon} #${run.id} ${name}${queuedTag}`;
|
|
60
86
|
const elapsed = formatElapsed(run, now);
|
|
61
87
|
// Render only the resolved model id plus thinking level. Provider auth and
|
|
62
88
|
// other configuration never enter monitor state or this line.
|
|
63
89
|
const modelId = run.model?.split("/").at(-1);
|
|
64
|
-
const modelSource = run.managedWorkflow
|
|
90
|
+
const modelSource = run.managedWorkflow || queued
|
|
65
91
|
? ""
|
|
66
92
|
: formatTaskSummary(
|
|
67
93
|
modelId ? `${modelId}${run.thinking ? `/${run.thinking}` : ""}` : run.thinking ? `thinking:${run.thinking}` : "",
|
|
68
94
|
64,
|
|
69
95
|
false,
|
|
70
96
|
);
|
|
97
|
+
const badge = isGroupOwner && run.isolation === "worktree" ? worktreeBadge(run) : "";
|
|
71
98
|
// A chain child shows its role in the chain plus a task-derived label; the
|
|
72
99
|
// templated fix brief itself would only repeat the parent review's content.
|
|
73
100
|
const continuation = run.parentRunId === undefined
|
|
@@ -81,7 +108,7 @@ function runPrimaryLine(
|
|
|
81
108
|
const taskDesiredSource = [continuation, taskSource]
|
|
82
109
|
.filter((part): part is string => Boolean(part))
|
|
83
110
|
.join(" · ");
|
|
84
|
-
const primaryPartCount = 2 + (modelSource ? 1 : 0) + (elapsed ? 1 : 0);
|
|
111
|
+
const primaryPartCount = 2 + (modelSource ? 1 : 0) + (badge ? 1 : 0) + (elapsed ? 1 : 0);
|
|
85
112
|
const contentWidth = Math.max(
|
|
86
113
|
0,
|
|
87
114
|
width -
|
|
@@ -130,6 +157,7 @@ function runPrimaryLine(
|
|
|
130
157
|
identity,
|
|
131
158
|
task ? dim(task) : undefined,
|
|
132
159
|
modelThinking ? dim(modelThinking) : undefined,
|
|
160
|
+
badge ? dim(badge) : undefined,
|
|
133
161
|
].filter((part): part is string => Boolean(part)).join(" · ");
|
|
134
162
|
return compactLine(primaryLeft, elapsed ? dim(`· ${elapsed}`) : "", width);
|
|
135
163
|
}
|
|
@@ -211,7 +239,9 @@ export function formatActiveRunLines(
|
|
|
211
239
|
const lines: string[] = [];
|
|
212
240
|
for (const root of roots) {
|
|
213
241
|
const children = childrenOf.get(root.id) ?? [];
|
|
214
|
-
|
|
242
|
+
// Roots (including orphaned chain children whose parent row is gone) own
|
|
243
|
+
// their worktree group; nested children inherit the group via the tree.
|
|
244
|
+
lines.push(runPrimaryLine(root, theme, width, now, "", true));
|
|
215
245
|
const hasTimeline = Boolean(root.managedWorkflow && root.workflowStages?.length);
|
|
216
246
|
const timeline = hasTimeline ? workflowTimelineLine(root, theme, width) : undefined;
|
|
217
247
|
if (timeline) lines.push(timeline);
|
|
@@ -222,7 +252,7 @@ export function formatActiveRunLines(
|
|
|
222
252
|
}
|
|
223
253
|
children.forEach((child, index) => {
|
|
224
254
|
const connector = index === children.length - 1 ? "└ " : "├ ";
|
|
225
|
-
lines.push(runPrimaryLine(child, theme, width, now, theme.fg("dim", ` ${connector}`)));
|
|
255
|
+
lines.push(runPrimaryLine(child, theme, width, now, theme.fg("dim", ` ${connector}`), false));
|
|
226
256
|
lines.push(...runActivityLine(child, theme, width, " "));
|
|
227
257
|
});
|
|
228
258
|
}
|
package/src/worktree.ts
CHANGED
|
@@ -16,6 +16,18 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
|
|
16
16
|
|
|
17
17
|
export type IsolationMode = "shared" | "worktree";
|
|
18
18
|
|
|
19
|
+
const WORKTREE_TEMP_DIR_PREFIX = "pi-subagent-worktree-";
|
|
20
|
+
|
|
21
|
+
/** Short stable identity of one isolated worktree group (the mkdtemp suffix).
|
|
22
|
+
* Continuation/fork generations create a fresh worktree, so the identity
|
|
23
|
+
* visibly changes when the group's filesystem boundary changes. */
|
|
24
|
+
export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): string {
|
|
25
|
+
const base = worktree.tempDir.split(/[\\/]/).filter(Boolean).pop() ?? worktree.tempDir;
|
|
26
|
+
return base.startsWith(WORKTREE_TEMP_DIR_PREFIX)
|
|
27
|
+
? base.slice(WORKTREE_TEMP_DIR_PREFIX.length)
|
|
28
|
+
: base;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export interface CommandRunOptions {
|
|
20
32
|
cwd: string;
|
|
21
33
|
input?: Buffer;
|