@ilya-lesikov/pi-pi 0.7.0 → 0.9.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/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +27 -11
- package/extensions/orchestrator/agents/constraints.test.ts +82 -0
- package/extensions/orchestrator/agents/constraints.ts +66 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +39 -1
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +44 -28
- package/extensions/orchestrator/config.test.ts +40 -1
- package/extensions/orchestrator/config.ts +18 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +81 -2
- package/extensions/orchestrator/custom-footer.test.ts +91 -0
- package/extensions/orchestrator/custom-footer.ts +20 -20
- package/extensions/orchestrator/event-handlers.test.ts +412 -2
- package/extensions/orchestrator/event-handlers.ts +556 -227
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +411 -20
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +197 -60
- package/extensions/orchestrator/phases/brainstorm.ts +20 -27
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/machine.test.ts +36 -0
- package/extensions/orchestrator/phases/machine.ts +11 -1
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +47 -22
- package/extensions/orchestrator/phases/review.test.ts +34 -0
- package/extensions/orchestrator/phases/review.ts +44 -6
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.test.ts +207 -1
- package/extensions/orchestrator/pp-menu.ts +514 -402
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/state.test.ts +9 -0
- package/extensions/orchestrator/state.ts +15 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
- package/AGENTS.md +0 -28
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { tmpdir } from "os";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
3
2
|
import { resolve, basename, join, relative, dirname, isAbsolute, sep } from "path";
|
|
4
3
|
import { validateUserRequest, validateResearch, validateArtifact } from "./validate-artifacts.js";
|
|
5
4
|
import { Type } from "@sinclair/typebox";
|
|
@@ -14,28 +13,33 @@ import {
|
|
|
14
13
|
loadAllContextFiles,
|
|
15
14
|
getPhaseArtifacts,
|
|
16
15
|
getLatestSynthesizedPlan,
|
|
16
|
+
getArtifactManifest,
|
|
17
17
|
loadBrainstormReviewOutputs,
|
|
18
18
|
loadCodeReviewOutputs,
|
|
19
19
|
loadPlanReviewOutputs,
|
|
20
20
|
} from "./context.js";
|
|
21
|
-
import { PRINCIPLES_BLOCK, TOOLS_BLOCK } from "./agents/tool-routing.js";
|
|
21
|
+
import { PRINCIPLES_BLOCK, TOOLS_BLOCK, DELEGATION_BLOCK } from "./agents/tool-routing.js";
|
|
22
22
|
import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
|
|
23
23
|
import { registerCbmTools } from "./cbm.js";
|
|
24
24
|
import { registerExaTools } from "./exa.js";
|
|
25
25
|
import { registerAstSearchTool } from "./ast-search.js";
|
|
26
26
|
import { SUBAGENT_SESSION_KEY } from "./index.js";
|
|
27
|
-
import { registerCommandHandlers } from "./command-handlers.js";
|
|
27
|
+
import { registerCommandHandlers, runAfterImplementForActive } from "./command-handlers.js";
|
|
28
|
+
import { registerStateFileTools } from "./pp-state-tools.js";
|
|
29
|
+
import { isAiCommentOnlyChange } from "./ai-comment-cleanup.js";
|
|
30
|
+
import { handleMainRateLimit, handleSubagentRateLimit, isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
|
|
28
31
|
import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
|
|
29
32
|
import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
|
|
30
33
|
import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
|
|
31
34
|
import { spawnCodeReviewers } from "./phases/review.js";
|
|
32
35
|
import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
|
|
33
36
|
import { reviewPassUnanimousApprove } from "./phases/verdict.js";
|
|
34
|
-
import { validateExitCriteria } from "./phases/machine.js";
|
|
37
|
+
import { nextPhase, validateExitCriteria } from "./phases/machine.js";
|
|
35
38
|
import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
|
|
39
|
+
import { advanceBanner } from "./messages.js";
|
|
36
40
|
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
37
|
-
import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
|
|
38
|
-
import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
|
|
41
|
+
import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
|
|
42
|
+
import { createUsageTracker, dumpUsageSummary, loadUsageSummary, isSubscriptionRouted, type UsageTracker } from "./usage-tracker.js";
|
|
39
43
|
import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
|
|
40
44
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
41
45
|
import { findRootRepo, normalizeRepoPath, resolveRepoForFile, type RepoInfo } from "./repo-utils.js";
|
|
@@ -51,6 +55,34 @@ function isPathInside(basePath: string, targetPath: string): boolean {
|
|
|
51
55
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
// Builds the spawn-time context block appended to a free agent's spawn prompt:
|
|
59
|
+
// inlines USER_REQUEST + RESEARCH (always present by spawn time) and appends a
|
|
60
|
+
// path-aware manifest of additional on-demand documents (artifacts + plan).
|
|
61
|
+
function buildSpawnContextBlock(taskDir: string): string {
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
|
|
64
|
+
const userRequestPath = join(taskDir, "USER_REQUEST.md");
|
|
65
|
+
if (existsSync(userRequestPath)) {
|
|
66
|
+
parts.push("=== USER REQUEST ===\n" + readFileSync(userRequestPath, "utf-8").trimEnd());
|
|
67
|
+
}
|
|
68
|
+
const researchPath = join(taskDir, "RESEARCH.md");
|
|
69
|
+
if (existsSync(researchPath)) {
|
|
70
|
+
parts.push("=== RESEARCH ===\n" + readFileSync(researchPath, "utf-8").trimEnd());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const manifest = getArtifactManifest(taskDir);
|
|
74
|
+
if (manifest.length > 0) {
|
|
75
|
+
const lines = manifest.map((m) => `- ${m.path} — ${m.title}`);
|
|
76
|
+
parts.push(
|
|
77
|
+
"=== ADDITIONAL DOCUMENTS (read from disk if relevant) ===\n" +
|
|
78
|
+
lines.join("\n") +
|
|
79
|
+
"\nDo NOT re-read USER_REQUEST/RESEARCH from disk (already above).",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parts.join("\n\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
export async function detectDefaultBranch(orchestrator: Orchestrator, repos: RepoInfo[], repoPath: string): Promise<string> {
|
|
55
87
|
const normalizedPath = normalizeRepoPath(repoPath);
|
|
56
88
|
const repo = repos.find((r) => r.path === normalizedPath);
|
|
@@ -89,15 +121,21 @@ export async function detectDefaultBranch(orchestrator: Orchestrator, repos: Rep
|
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
124
|
+
const orchestrator = Orchestrator.current;
|
|
125
|
+
if (orchestrator) orchestrator.interactivePromptOpen = true;
|
|
126
|
+
try {
|
|
127
|
+
const result = await askUser(ctx, {
|
|
128
|
+
question,
|
|
129
|
+
options,
|
|
130
|
+
allowFreeform: false,
|
|
131
|
+
allowComment: false,
|
|
132
|
+
allowMultiple: false,
|
|
133
|
+
});
|
|
134
|
+
if (!result || isCancel(result) || result.kind !== "selection") return undefined;
|
|
135
|
+
return result.selections[0];
|
|
136
|
+
} finally {
|
|
137
|
+
if (orchestrator) orchestrator.interactivePromptOpen = false;
|
|
138
|
+
}
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
function resolveReviewers(
|
|
@@ -206,7 +244,7 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: n
|
|
|
206
244
|
},
|
|
207
245
|
"instruction",
|
|
208
246
|
);
|
|
209
|
-
orchestrator.safeSendUserMessage(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state)));
|
|
247
|
+
orchestrator.safeSendUserMessage(advanceBanner(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state))));
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
function reviewReadyMessage(phase: string, mode: TaskMode): string {
|
|
@@ -286,7 +324,9 @@ export async function enterReviewCycle(
|
|
|
286
324
|
orchestrator.active.state.reviewCycle = null;
|
|
287
325
|
orchestrator.active.state.step = "llm_work";
|
|
288
326
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
289
|
-
|
|
327
|
+
// Implement/review-phase Plannotator is driven entirely by the menu's per-repo
|
|
328
|
+
// interleaved loop (pp-menu runPlannotatorCursor); it never enters this path.
|
|
329
|
+
return "Plannotator code review runs per-repo from the /pp Review menu. Open /pp → Review → Review in Plannotator.";
|
|
290
330
|
}
|
|
291
331
|
|
|
292
332
|
const phase = orchestrator.active.state.phase;
|
|
@@ -379,6 +419,14 @@ export async function stopTask(orchestrator: Orchestrator): Promise<string> {
|
|
|
379
419
|
return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
|
|
380
420
|
}
|
|
381
421
|
|
|
422
|
+
// A review is LIVE when its cycle exists and reviewers are still being awaited.
|
|
423
|
+
// Guards the menu/autonomous callers against re-spawning reviewers on top of a
|
|
424
|
+
// running cycle (the "6 agents" double-spawn). NOT "a cycle object exists": a
|
|
425
|
+
// completed cycle is finalized (and nulled) legitimately before the next pass.
|
|
426
|
+
export function isReviewCycleLive(task: ActiveTask): boolean {
|
|
427
|
+
return !!task.state.reviewCycle && task.state.reviewCycle.step === "await_reviewers";
|
|
428
|
+
}
|
|
429
|
+
|
|
382
430
|
export function finalizeReviewCycle(task: ActiveTask): void {
|
|
383
431
|
if (!task.state.reviewCycle) return;
|
|
384
432
|
const kind = task.state.reviewCycle.kind;
|
|
@@ -416,11 +464,174 @@ export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
|
|
|
416
464
|
saveTask(task.dir, task.state);
|
|
417
465
|
}
|
|
418
466
|
|
|
467
|
+
export function registerOrchestratorToolsForTest(orchestrator: Orchestrator): void {
|
|
468
|
+
registerOrchestratorTools(orchestrator);
|
|
469
|
+
}
|
|
470
|
+
|
|
419
471
|
function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
420
472
|
registerRepoTool(orchestrator);
|
|
473
|
+
registerCheckoutPrHeadTool(orchestrator);
|
|
421
474
|
registerPhaseCompleteTool(orchestrator);
|
|
422
475
|
registerCommitTool(orchestrator);
|
|
423
|
-
|
|
476
|
+
registerStateFileTools(orchestrator);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Extension-side PR-head checkout for the review phase. Performed here (never via the
|
|
480
|
+
// agent prompt) so REVIEW_READONLY_CONSTRAINT stays truthful — the agent never runs
|
|
481
|
+
// `git checkout` itself. Lands the repo on its PR head under strict safety rules (no stash,
|
|
482
|
+
// no --force, no branch switch, no worktree, no detached checkout): when the tree is clean
|
|
483
|
+
// and on the PR head's branch but merely behind, it fast-forwards the branch to the head
|
|
484
|
+
// (`git merge --ff-only`, which never detaches, never rewrites, and refuses a non-ff move).
|
|
485
|
+
// A dirty tree, a different branch, or a non-fast-forwardable divergence all HALT with a
|
|
486
|
+
// message for the user. Checking HEAD's SHA first makes this re-entrant and detached-safe.
|
|
487
|
+
export async function checkoutPrHead(
|
|
488
|
+
orchestrator: Orchestrator,
|
|
489
|
+
repoPath: string,
|
|
490
|
+
headRefName: string,
|
|
491
|
+
headRefOid: string,
|
|
492
|
+
): Promise<{ ok: boolean; message: string }> {
|
|
493
|
+
const pi = orchestrator.pi;
|
|
494
|
+
const name = (headRefName ?? "").trim();
|
|
495
|
+
const oid = (headRefOid ?? "").trim();
|
|
496
|
+
if (!name && !oid) {
|
|
497
|
+
return { ok: true, message: `No PR head provided for ${repoPath}; skipping (non-PR review scope).` };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const run = (args: string[]) => pi.exec("git", args, { cwd: repoPath, timeout: 15000 });
|
|
501
|
+
const target = `${name || oid}${name && oid ? ` @ ${oid}` : ""}`;
|
|
502
|
+
|
|
503
|
+
let status;
|
|
504
|
+
try {
|
|
505
|
+
status = await run(["status", "--porcelain"]);
|
|
506
|
+
} catch (err: any) {
|
|
507
|
+
return { ok: false, message: `Cannot inspect ${repoPath}: ${err?.message ?? "git status failed"}` };
|
|
508
|
+
}
|
|
509
|
+
if (status.code !== 0) {
|
|
510
|
+
return { ok: false, message: `Cannot inspect ${repoPath}: ${status.stderr?.trim() || status.stdout?.trim() || "git status failed"}` };
|
|
511
|
+
}
|
|
512
|
+
if (status.stdout.trim()) {
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
message:
|
|
516
|
+
`HALT: ${repoPath} has uncommitted changes. Commit or stash them, then bring the repo to its PR head (${target}) and ask me to continue.\n\n${status.stdout.trim()}`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!oid) {
|
|
521
|
+
return { ok: true, message: `${repoPath}: no PR head commit provided; left as-is (clean tree).` };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let head;
|
|
525
|
+
try {
|
|
526
|
+
head = await run(["rev-parse", "HEAD"]);
|
|
527
|
+
} catch (err: any) {
|
|
528
|
+
return { ok: false, message: `Cannot read HEAD of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
|
|
529
|
+
}
|
|
530
|
+
if (head.stdout.trim() === oid) {
|
|
531
|
+
return { ok: true, message: `${repoPath} is on PR head ${oid}.` };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let branch;
|
|
535
|
+
try {
|
|
536
|
+
branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
537
|
+
} catch (err: any) {
|
|
538
|
+
return { ok: false, message: `Cannot read current branch of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
|
|
539
|
+
}
|
|
540
|
+
const currentBranch = branch.stdout.trim();
|
|
541
|
+
if (currentBranch === "HEAD") {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
message:
|
|
545
|
+
`HALT: ${repoPath} has a detached HEAD, not at the PR head commit ${oid}. ` +
|
|
546
|
+
`Check out the PR head branch${name ? ` "${name}"` : ""} at ${oid}, then ask me to continue. I will not move HEAD for you.`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (name && currentBranch !== name) {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
message:
|
|
553
|
+
`HALT: ${repoPath} is on "${currentBranch}", not the PR head branch "${name}" (target commit ${oid}). ` +
|
|
554
|
+
"Check out that branch at its PR head, then ask me to continue. I will not switch branches for you.",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Clean tree, on the PR head's branch, but behind: advance the branch to the head with a
|
|
559
|
+
// fast-forward-only merge. This moves the branch ref without detaching, forcing, or stashing,
|
|
560
|
+
// and fails safely if the branch has diverged from the PR head.
|
|
561
|
+
let ff;
|
|
562
|
+
try {
|
|
563
|
+
ff = await run(["merge", "--ff-only", oid]);
|
|
564
|
+
} catch (err: any) {
|
|
565
|
+
return { ok: false, message: `Fast-forward of ${repoPath} to PR head ${oid} failed: ${err?.message ?? "git merge failed"}` };
|
|
566
|
+
}
|
|
567
|
+
if (ff.code !== 0) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
message:
|
|
571
|
+
`HALT: ${repoPath} (branch "${currentBranch}") cannot fast-forward to PR head ${oid} — it has diverged. ` +
|
|
572
|
+
`Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you.` +
|
|
573
|
+
`${ff.stderr?.trim() ? `\n\n${ff.stderr.trim()}` : ""}`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// `git merge --ff-only <ancestor>` returns exit 0 ("Already up to date") WITHOUT moving HEAD
|
|
578
|
+
// when the branch is already ahead of the PR head, so a zero exit is not proof we landed on it.
|
|
579
|
+
// Re-read HEAD and HALT if the branch carries commits beyond the PR head.
|
|
580
|
+
let after;
|
|
581
|
+
try {
|
|
582
|
+
after = await run(["rev-parse", "HEAD"]);
|
|
583
|
+
} catch (err: any) {
|
|
584
|
+
return { ok: false, message: `Cannot confirm HEAD of ${repoPath} after fast-forward: ${err?.message ?? "git rev-parse failed"}` };
|
|
585
|
+
}
|
|
586
|
+
if (after.stdout.trim() !== oid) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
message:
|
|
590
|
+
`HALT: ${repoPath} (branch "${currentBranch}") is ahead of the PR head ${oid} — it has commits the PR head does not. ` +
|
|
591
|
+
"Push them to the PR (or check out the exact PR head), then ask me to continue. I will not move HEAD backwards for you.",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return { ok: true, message: `${repoPath} fast-forwarded to PR head ${oid}.` };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function registerCheckoutPrHeadTool(orchestrator: Orchestrator): void {
|
|
598
|
+
const pi = orchestrator.pi;
|
|
599
|
+
|
|
600
|
+
pi.registerTool({
|
|
601
|
+
name: "pp_checkout_pr_head",
|
|
602
|
+
label: "pi-pi",
|
|
603
|
+
description:
|
|
604
|
+
"Land a registered repository on its PR head commit before reviewing it. " +
|
|
605
|
+
"Call this ONLY for a PR-scoped review, once per repo, AFTER you have resolved the PR " +
|
|
606
|
+
"(e.g. via `gh pr view --json headRefName,headRefOid`). Do NOT call it for a branch, " +
|
|
607
|
+
"commit-range, or uncommitted-changes review. The extension does this safely (no worktree, " +
|
|
608
|
+
"no stash, no --force, no branch switch, no detached checkout): a clean repo already on the " +
|
|
609
|
+
"PR head succeeds; a clean repo on the PR head's branch but behind is fast-forwarded to the " +
|
|
610
|
+
"head; a dirty tree, a different branch, or a diverged branch HALTS with a message for you to " +
|
|
611
|
+
"relay to the user. You never run `git checkout` yourself.",
|
|
612
|
+
parameters: Type.Object({
|
|
613
|
+
repoPath: Type.String({ description: "Absolute path to the registered repository" }),
|
|
614
|
+
headRefName: Type.String({ description: "PR head branch name (headRefName)" }),
|
|
615
|
+
headRefOid: Type.String({ description: "PR head commit SHA (headRefOid)" }),
|
|
616
|
+
}),
|
|
617
|
+
async execute(_toolCallId, params: any) {
|
|
618
|
+
if (!orchestrator.active) {
|
|
619
|
+
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
620
|
+
}
|
|
621
|
+
const pathInput = typeof params.repoPath === "string" ? params.repoPath.trim() : "";
|
|
622
|
+
if (!pathInput) {
|
|
623
|
+
return { content: [{ type: "text" as const, text: "Missing repoPath." }], isError: true as const, details: {} };
|
|
624
|
+
}
|
|
625
|
+
const normalized = normalizeRepoPath(pathInput);
|
|
626
|
+
const repos = orchestrator.active.state.repos ?? [];
|
|
627
|
+
const registered = repos.find((repo) => repo.path === normalized);
|
|
628
|
+
if (!registered) {
|
|
629
|
+
return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repoPath}` }], isError: true as const, details: {} };
|
|
630
|
+
}
|
|
631
|
+
const result = await checkoutPrHead(orchestrator, registered.path, params.headRefName ?? "", params.headRefOid ?? "");
|
|
632
|
+
return { content: [{ type: "text" as const, text: result.message }], isError: result.ok ? undefined : (true as const), details: {} };
|
|
633
|
+
},
|
|
634
|
+
});
|
|
424
635
|
}
|
|
425
636
|
|
|
426
637
|
function registerRepoTool(orchestrator: Orchestrator): void {
|
|
@@ -562,190 +773,6 @@ function registerRepoTool(orchestrator: Orchestrator): void {
|
|
|
562
773
|
});
|
|
563
774
|
}
|
|
564
775
|
|
|
565
|
-
function openCodeReviewDirect(
|
|
566
|
-
pi: ExtensionAPI,
|
|
567
|
-
payload: Record<string, unknown>,
|
|
568
|
-
): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
|
|
569
|
-
return new Promise((resolve) => {
|
|
570
|
-
pi.events.emit("plannotator:request", {
|
|
571
|
-
requestId: crypto.randomUUID(),
|
|
572
|
-
action: "code-review",
|
|
573
|
-
payload,
|
|
574
|
-
respond: (response: any) => {
|
|
575
|
-
if (response.status === "handled") {
|
|
576
|
-
resolve({ approved: !!response.result?.approved, feedback: response.result?.feedback });
|
|
577
|
-
} else {
|
|
578
|
-
resolve({ error: response.error || "Plannotator not available" });
|
|
579
|
-
}
|
|
580
|
-
},
|
|
581
|
-
});
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
586
|
-
const pi = orchestrator.pi;
|
|
587
|
-
|
|
588
|
-
pi.registerTool({
|
|
589
|
-
name: "pp_specify_reviews",
|
|
590
|
-
label: "pi-pi",
|
|
591
|
-
description:
|
|
592
|
-
"Specify which repositories and commit ranges to open in Plannotator for code review. " +
|
|
593
|
-
"Called when the user requests a Plannotator code review. " +
|
|
594
|
-
"Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
|
|
595
|
-
"Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
|
|
596
|
-
parameters: Type.Object({
|
|
597
|
-
reviews: Type.Array(Type.Object({
|
|
598
|
-
cwd: Type.String({ description: "Absolute path to the git repository" }),
|
|
599
|
-
range: Type.String({ description: "Git commit range, e.g. 'origin/main..HEAD', 'abc123..def456', 'HEAD~3..HEAD'" }),
|
|
600
|
-
})),
|
|
601
|
-
}),
|
|
602
|
-
async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
|
|
603
|
-
if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
|
|
604
|
-
return { content: [{ type: "text" as const, text: "Plannotator review is not available in autonomous mode. Continue with automated reviews via pp_phase_complete." }], isError: true as const, details: {} };
|
|
605
|
-
}
|
|
606
|
-
if (!params.reviews || params.reviews.length === 0) {
|
|
607
|
-
return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const results: string[] = [];
|
|
611
|
-
let hasNeedsChanges = false;
|
|
612
|
-
for (const review of params.reviews) {
|
|
613
|
-
ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
|
|
614
|
-
const range = String(review.range ?? "").trim();
|
|
615
|
-
let compareBase = range;
|
|
616
|
-
let compareTarget = "HEAD";
|
|
617
|
-
const rangeSeparatorIdx = range.indexOf("..");
|
|
618
|
-
if (rangeSeparatorIdx >= 0) {
|
|
619
|
-
compareBase = range.slice(0, rangeSeparatorIdx).trim();
|
|
620
|
-
compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (!compareBase) {
|
|
624
|
-
results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
let reviewCwd = review.cwd;
|
|
629
|
-
let tempWorktreePath: string | null = null;
|
|
630
|
-
let tempWorktreeParent: string | null = null;
|
|
631
|
-
let setupError: string | null = null;
|
|
632
|
-
|
|
633
|
-
if (compareTarget !== "HEAD") {
|
|
634
|
-
tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
|
|
635
|
-
tempWorktreePath = join(tempWorktreeParent, "checkout");
|
|
636
|
-
try {
|
|
637
|
-
const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
|
|
638
|
-
cwd: review.cwd,
|
|
639
|
-
timeout: 20000,
|
|
640
|
-
});
|
|
641
|
-
if (addResult.code !== 0) {
|
|
642
|
-
setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
|
|
643
|
-
} else {
|
|
644
|
-
reviewCwd = tempWorktreePath;
|
|
645
|
-
}
|
|
646
|
-
} catch (error: any) {
|
|
647
|
-
setupError = error?.message ?? String(error);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if (setupError) {
|
|
652
|
-
results.push(`${review.cwd} (${review.range}): ${setupError}`);
|
|
653
|
-
if (tempWorktreePath) {
|
|
654
|
-
try {
|
|
655
|
-
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
656
|
-
cwd: review.cwd,
|
|
657
|
-
timeout: 20000,
|
|
658
|
-
});
|
|
659
|
-
} catch {}
|
|
660
|
-
}
|
|
661
|
-
if (tempWorktreePath) {
|
|
662
|
-
try {
|
|
663
|
-
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
664
|
-
} catch {}
|
|
665
|
-
}
|
|
666
|
-
if (tempWorktreeParent) {
|
|
667
|
-
try {
|
|
668
|
-
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
669
|
-
} catch {}
|
|
670
|
-
}
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const result = await openCodeReviewDirect(pi, {
|
|
675
|
-
cwd: reviewCwd,
|
|
676
|
-
diffType: "branch",
|
|
677
|
-
defaultBranch: compareBase,
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
if (tempWorktreePath) {
|
|
681
|
-
try {
|
|
682
|
-
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
683
|
-
cwd: review.cwd,
|
|
684
|
-
timeout: 20000,
|
|
685
|
-
});
|
|
686
|
-
} catch {}
|
|
687
|
-
try {
|
|
688
|
-
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
689
|
-
} catch {}
|
|
690
|
-
if (tempWorktreeParent) {
|
|
691
|
-
try {
|
|
692
|
-
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
693
|
-
} catch {}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
if ("error" in result) {
|
|
698
|
-
results.push(`${review.cwd} (${review.range}): ${result.error}`);
|
|
699
|
-
} else {
|
|
700
|
-
const status = result.approved ? "APPROVED" : "NEEDS_CHANGES";
|
|
701
|
-
if (!result.approved) hasNeedsChanges = true;
|
|
702
|
-
const feedback = result.feedback ? `\nFeedback: ${result.feedback}` : "";
|
|
703
|
-
results.push(`${review.cwd} (${review.range}): ${status}${feedback}`);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
ctx.ui?.setWorkingMessage?.();
|
|
707
|
-
|
|
708
|
-
const summary = results.join("\n\n");
|
|
709
|
-
|
|
710
|
-
if (hasNeedsChanges) {
|
|
711
|
-
if (orchestrator.active) {
|
|
712
|
-
orchestrator.active.state.step = "llm_work";
|
|
713
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
714
|
-
}
|
|
715
|
-
return {
|
|
716
|
-
content: [{ type: "text" as const, text: `Plannotator review complete.\n\n${summary}\n\nAddress the user's feedback. If the feedback contains questions, answer them. If it requests changes, make the changes. Then call pp_phase_complete when done.` }],
|
|
717
|
-
details: {},
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
ctx.ui?.setWorkingMessage?.("Waiting for user input…");
|
|
722
|
-
try {
|
|
723
|
-
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
724
|
-
const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
|
|
725
|
-
// Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
|
|
726
|
-
// reminder text that would start a new LLM turn.
|
|
727
|
-
if (text === USER_CANCELLED) {
|
|
728
|
-
ctx.abort?.();
|
|
729
|
-
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
730
|
-
}
|
|
731
|
-
// A transition may have started while the menu was open. The controller
|
|
732
|
-
// is the source of truth; abort the agent's pending turn so it doesn't
|
|
733
|
-
// race the transition. (Interactive-UX abort — stays local, not routed.)
|
|
734
|
-
if (!orchestrator.transitionController.isRunning()) {
|
|
735
|
-
ctx.abort?.();
|
|
736
|
-
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
737
|
-
}
|
|
738
|
-
if (!text) {
|
|
739
|
-
return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
|
|
740
|
-
}
|
|
741
|
-
return { content: [{ type: "text" as const, text }], details: {} };
|
|
742
|
-
} finally {
|
|
743
|
-
ctx.ui?.setWorkingMessage?.();
|
|
744
|
-
}
|
|
745
|
-
},
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
|
|
749
776
|
function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
|
|
750
777
|
if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
|
|
751
778
|
if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
|
|
@@ -915,6 +942,33 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
915
942
|
}
|
|
916
943
|
}
|
|
917
944
|
|
|
945
|
+
// Terminal handoff (#1): an autonomous IMPLEMENT phase whose next phase is
|
|
946
|
+
// "done" should NOT auto-complete. Run afterImplement (transitionToNextPhase
|
|
947
|
+
// is skipped here, so run it explicitly — once), then open the GUIDED
|
|
948
|
+
// implement menu and wait, instead of tearing the task down.
|
|
949
|
+
if (phase === "implement" && nextPhase(orchestrator.active.type, phase) === "done") {
|
|
950
|
+
if (!orchestrator.active.state.afterImplementRan) {
|
|
951
|
+
const afterError = runAfterImplementForActive(orchestrator);
|
|
952
|
+
if (afterError) {
|
|
953
|
+
return { content: [{ type: "text" as const, text: `${afterError}\n\nDo NOT wait for the user — fix these and call pp_phase_complete again.` }], details: {} };
|
|
954
|
+
}
|
|
955
|
+
orchestrator.active.state.afterImplementRan = true;
|
|
956
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
957
|
+
}
|
|
958
|
+
ctx.ui.setWorkingMessage?.("Waiting for user approval…");
|
|
959
|
+
try {
|
|
960
|
+
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
961
|
+
const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool", true);
|
|
962
|
+
if (text === USER_CANCELLED) {
|
|
963
|
+
ctx.abort?.();
|
|
964
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
965
|
+
}
|
|
966
|
+
return { content: [{ type: "text" as const, text: text ?? "" }], details: {} };
|
|
967
|
+
} finally {
|
|
968
|
+
ctx.ui.setWorkingMessage?.();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
918
972
|
const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
|
|
919
973
|
const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
|
|
920
974
|
if (!result.ok) {
|
|
@@ -989,31 +1043,49 @@ function registerMainTraceHooks(orchestrator: Orchestrator): void {
|
|
|
989
1043
|
tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
|
|
990
1044
|
});
|
|
991
1045
|
pi.on("message_start", async (event) => {
|
|
1046
|
+
markMainTurnActivity(orchestrator);
|
|
992
1047
|
const tracer = getTracer();
|
|
993
1048
|
tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
|
|
994
1049
|
});
|
|
995
1050
|
pi.on("message_update", async (event) => {
|
|
1051
|
+
markMainTurnActivity(orchestrator);
|
|
996
1052
|
const tracer = getTracer();
|
|
997
1053
|
tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
|
|
998
1054
|
});
|
|
999
1055
|
pi.on("message_end", async (event) => {
|
|
1056
|
+
markMainTurnActivity(orchestrator);
|
|
1000
1057
|
const tracer = getTracer();
|
|
1001
1058
|
tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
|
|
1002
1059
|
});
|
|
1003
1060
|
pi.on("tool_execution_start", async (event) => {
|
|
1061
|
+
markMainTurnActivity(orchestrator);
|
|
1004
1062
|
const tracer = getTracer();
|
|
1005
1063
|
tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
|
|
1006
1064
|
});
|
|
1007
1065
|
pi.on("tool_execution_update", async (event) => {
|
|
1066
|
+
markMainTurnActivity(orchestrator);
|
|
1008
1067
|
const tracer = getTracer();
|
|
1009
1068
|
tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
|
|
1010
1069
|
});
|
|
1011
1070
|
pi.on("tool_execution_end", async (event) => {
|
|
1071
|
+
markMainTurnActivity(orchestrator);
|
|
1012
1072
|
const tracer = getTracer();
|
|
1013
1073
|
tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
|
|
1014
1074
|
});
|
|
1015
1075
|
}
|
|
1016
1076
|
|
|
1077
|
+
// Main-turn watchdog activity helpers (BUG-2). Kept module-level so they can be
|
|
1078
|
+
// called from the several existing main-session handlers without registering a
|
|
1079
|
+
// second handler per event.
|
|
1080
|
+
function markMainTurnActivity(orchestrator: Orchestrator): void {
|
|
1081
|
+
orchestrator.mainTurnLastActivity = Date.now();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function endMainTurn(orchestrator: Orchestrator): void {
|
|
1085
|
+
orchestrator.mainTurnInFlight = false;
|
|
1086
|
+
orchestrator.mainTurnRecovering = false;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1017
1089
|
export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
1018
1090
|
const pi = orchestrator.pi;
|
|
1019
1091
|
|
|
@@ -1044,6 +1116,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1044
1116
|
// the subagent branch, so these only ever see root-session events.
|
|
1045
1117
|
pi.on("agent_end", async (_event, ctx) => {
|
|
1046
1118
|
if (ctx) orchestrator.lastCtx = ctx;
|
|
1119
|
+
endMainTurn(orchestrator);
|
|
1047
1120
|
orchestrator.transitionController.onAgentEnd();
|
|
1048
1121
|
});
|
|
1049
1122
|
pi.on("session_compact", async (_event, ctx) => {
|
|
@@ -1051,6 +1124,65 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1051
1124
|
orchestrator.transitionController.onSessionCompact();
|
|
1052
1125
|
});
|
|
1053
1126
|
|
|
1127
|
+
// BUG-2: main-turn stall watchdog. A main turn can start (turn_start / manual
|
|
1128
|
+
// "continue" / SDK auto-retry) and then wedge with no terminal turn_end, so the
|
|
1129
|
+
// session sits "Working…" forever with nothing to recover it (the SDK-retryable
|
|
1130
|
+
// deferral below arms nothing, and startStaleAgentWatchdog only watches
|
|
1131
|
+
// subagents). Activity is marked from the existing main-session handlers
|
|
1132
|
+
// (turn_start below, tool_call, tool_result, turn_end) so we do NOT register a
|
|
1133
|
+
// second handler per event (the runtime allows it, but keeping one owner per
|
|
1134
|
+
// event is simpler and test-mock-friendly). When a turn is in flight with NO
|
|
1135
|
+
// activity beyond the configured threshold, abort + recover via the idle-gated
|
|
1136
|
+
// single-send path so recovery can't itself race into "Agent is already
|
|
1137
|
+
// processing".
|
|
1138
|
+
const startMainTurnWatchdog = () => {
|
|
1139
|
+
if (orchestrator.mainTurnTimer) return;
|
|
1140
|
+
orchestrator.mainTurnTimer = setInterval(() => {
|
|
1141
|
+
if (!orchestrator.active) {
|
|
1142
|
+
clearInterval(orchestrator.mainTurnTimer!);
|
|
1143
|
+
orchestrator.mainTurnTimer = null;
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (!orchestrator.mainTurnInFlight || orchestrator.mainTurnRecovering) return;
|
|
1147
|
+
// Only a genuinely stuck turn is a target: an idle session (turn already
|
|
1148
|
+
// settled), an in-progress transition, or an await_* step is not a stall.
|
|
1149
|
+
if (orchestrator.transitionController.isTransitioning()) return;
|
|
1150
|
+
const step = orchestrator.active.state.step;
|
|
1151
|
+
if (step === "await_planners" || step === "await_reviewers") return;
|
|
1152
|
+
// A turn parked on an open user-facing dialogue (ask_user / the /pp menu /
|
|
1153
|
+
// any interactive selectOption / the rate-limit fallback switch) is
|
|
1154
|
+
// user-gated, not wedged — do not abort it out from under the user.
|
|
1155
|
+
if (orchestrator.interactivePromptOpen || orchestrator.subFallbackDialogPending) return;
|
|
1156
|
+
const staleMs = orchestrator.config.performance.internals.mainTurnStale;
|
|
1157
|
+
if (Date.now() - orchestrator.mainTurnLastActivity <= staleMs) return;
|
|
1158
|
+
|
|
1159
|
+
orchestrator.mainTurnRecovering = true;
|
|
1160
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
1161
|
+
const phase = orchestrator.active.state.phase;
|
|
1162
|
+
getLogger().warn({ s: "watchdog", phase, staleMs }, "main turn wedged — aborting and recovering");
|
|
1163
|
+
orchestrator.lastCtx?.ui?.notify?.(
|
|
1164
|
+
`Main turn stalled with no activity for ${Math.round(staleMs / 1000)}s — recovering.`,
|
|
1165
|
+
"warning",
|
|
1166
|
+
);
|
|
1167
|
+
try {
|
|
1168
|
+
orchestrator.transitionController.abortMainAgent(orchestrator.lastCtx?.abort?.bind(orchestrator.lastCtx));
|
|
1169
|
+
} catch {}
|
|
1170
|
+
orchestrator.mainTurnInFlight = false;
|
|
1171
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
1172
|
+
`[PI-PI] The previous turn stalled without completing. Continue working on the current phase (${phase}).`,
|
|
1173
|
+
taskToken,
|
|
1174
|
+
);
|
|
1175
|
+
}, 30000);
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
1179
|
+
if (ctx) orchestrator.lastCtx = ctx;
|
|
1180
|
+
orchestrator.mainTurnInFlight = true;
|
|
1181
|
+
orchestrator.mainTurnRecovering = false;
|
|
1182
|
+
markMainTurnActivity(orchestrator);
|
|
1183
|
+
startMainTurnWatchdog();
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1054
1186
|
// Expose the event-driven planner-completion check so initial plan-entry
|
|
1055
1187
|
// spawns (in command-handlers / orchestrator) can wire it as their onSettled
|
|
1056
1188
|
// safety net (the deleted poller's former role). checkPlannerCompletion is a
|
|
@@ -1168,7 +1300,12 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1168
1300
|
if (data.description) {
|
|
1169
1301
|
orchestrator.agentDescriptions.set(data.id, data.description);
|
|
1170
1302
|
}
|
|
1303
|
+
|
|
1171
1304
|
trackSubagentEvent(data, "created");
|
|
1305
|
+
// Nudge acceptance/suppression is driven from the root `Agent` tool-call observation
|
|
1306
|
+
// (main-initiated by construction), NOT here — this event also fires for lineage-less
|
|
1307
|
+
// nested spawns, which must not count as the main agent following a nudge.
|
|
1308
|
+
|
|
1172
1309
|
startStaleAgentWatchdog();
|
|
1173
1310
|
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
1174
1311
|
mgr?.refreshWidget?.(orchestrator.lastCtx?.ui);
|
|
@@ -1214,7 +1351,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1214
1351
|
|
|
1215
1352
|
const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
|
|
1216
1353
|
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1217
|
-
|
|
1354
|
+
// Do NOT auto-retry failed variants while a subscription-429 fallback decision
|
|
1355
|
+
// is in flight: re-spawning now would use the still-sub-routed model and
|
|
1356
|
+
// re-hit the limit. Once the user decides, the fallback nudge re-drives this.
|
|
1357
|
+
if (
|
|
1358
|
+
effectiveMode === "autonomous" &&
|
|
1359
|
+
failedPlannerVariants.length > 0 &&
|
|
1360
|
+
!orchestrator.subFallbackPendingDecision
|
|
1361
|
+
) {
|
|
1218
1362
|
const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
|
|
1219
1363
|
if (!alreadyRetried) {
|
|
1220
1364
|
const failedSet = new Set(failedPlannerVariants);
|
|
@@ -1386,7 +1530,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1386
1530
|
|
|
1387
1531
|
const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
|
|
1388
1532
|
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1389
|
-
|
|
1533
|
+
// See the planner path: suppress auto-retry while a sub-429 fallback decision
|
|
1534
|
+
// is pending so we don't re-spawn on the still-sub-routed model.
|
|
1535
|
+
if (
|
|
1536
|
+
effectiveMode === "autonomous" &&
|
|
1537
|
+
failedReviewerVariants.length > 0 &&
|
|
1538
|
+
!orchestrator.subFallbackPendingDecision
|
|
1539
|
+
) {
|
|
1390
1540
|
const cycle = orchestrator.active.state.reviewCycle;
|
|
1391
1541
|
const phase = orchestrator.active.state.phase;
|
|
1392
1542
|
const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
|
|
@@ -1641,6 +1791,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1641
1791
|
orchestrator.abortAllSubagents();
|
|
1642
1792
|
}
|
|
1643
1793
|
|
|
1794
|
+
// Subscription rate-limit (429) on a sub-routed subagent: offer the ONE
|
|
1795
|
+
// global switch-to-non-sub dialogue (never per-subagent). subagents:failed
|
|
1796
|
+
// carries the subagent's resolved model id.
|
|
1797
|
+
const failedModelId = typeof data.modelId === "string" ? data.modelId : undefined;
|
|
1798
|
+
const subRateLimited =
|
|
1799
|
+
isRateLimitError(data.error) &&
|
|
1800
|
+
isSubscriptionRouted(failedModelId) &&
|
|
1801
|
+
!orchestrator.subFallbackActive;
|
|
1802
|
+
if (subRateLimited) {
|
|
1803
|
+
// Set the decision-pending flag SYNCHRONOUSLY (before the completion checks
|
|
1804
|
+
// below) so the autonomous planner/reviewer auto-retry does NOT re-spawn
|
|
1805
|
+
// the failed variant on the still-sub-routed model while the fallback
|
|
1806
|
+
// dialogue is in flight.
|
|
1807
|
+
orchestrator.subFallbackPendingDecision = true;
|
|
1808
|
+
void handleSubagentRateLimit(orchestrator, orchestrator.lastCtx, failedModelId);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1644
1811
|
orchestrator.transitionController.sendCustom(
|
|
1645
1812
|
{
|
|
1646
1813
|
customType: "pp-subagent-error",
|
|
@@ -1725,6 +1892,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1725
1892
|
(globalThis as any)[USAGE_TRACKER_KEY] = tracker;
|
|
1726
1893
|
setFooterContext(ctx);
|
|
1727
1894
|
setFooterTracker(tracker);
|
|
1895
|
+
setFooterOrchestrator(orchestrator);
|
|
1728
1896
|
ctx.ui.setFooter(createCustomFooter);
|
|
1729
1897
|
}
|
|
1730
1898
|
|
|
@@ -1810,6 +1978,30 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1810
1978
|
if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
|
|
1811
1979
|
return;
|
|
1812
1980
|
}
|
|
1981
|
+
|
|
1982
|
+
// Stale continuation-nudge guard. A `[PI-PI] Continue the <phase> phase…`
|
|
1983
|
+
// nudge is a followUp queued at turn_end; by the time it is delivered the
|
|
1984
|
+
// phase may have advanced (plan→implement) or the task may have changed
|
|
1985
|
+
// (old plan-task → new plan-task). Re-validate against the phase/token
|
|
1986
|
+
// captured at generation time and abort the turn on any mismatch — a stale
|
|
1987
|
+
// nudge is not genuine user re-engagement, so this runs BEFORE the no-active
|
|
1988
|
+
// early return and does NOT clear the nudge/error guards. Checked only for
|
|
1989
|
+
// the tracked "Continue the <phase> phase" shape; other [PI-PI] handoffs are
|
|
1990
|
+
// untouched.
|
|
1991
|
+
const nudgeMeta = orchestrator.pendingNudges.get(event.prompt ?? "");
|
|
1992
|
+
if (nudgeMeta !== undefined) {
|
|
1993
|
+
orchestrator.pendingNudges.delete(event.prompt ?? "");
|
|
1994
|
+
const livePhase = orchestrator.active?.state.phase;
|
|
1995
|
+
if (!orchestrator.active || nudgeMeta.phase !== livePhase || nudgeMeta.taskToken !== orchestrator.activeTaskToken) {
|
|
1996
|
+
getLogger().debug(
|
|
1997
|
+
{ s: "hook", hook: "before_agent_start", nudgePhase: nudgeMeta.phase, livePhase, nudgeToken: nudgeMeta.taskToken, liveToken: orchestrator.activeTaskToken },
|
|
1998
|
+
"dropping stale continuation nudge",
|
|
1999
|
+
);
|
|
2000
|
+
ctx.abort();
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1813
2005
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1814
2006
|
|
|
1815
2007
|
// Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
|
|
@@ -1821,6 +2013,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1821
2013
|
if (!isControllerInjected) {
|
|
1822
2014
|
orchestrator.nudgeHalted = false;
|
|
1823
2015
|
orchestrator.consecutiveNudges = 0;
|
|
2016
|
+
// Genuine user re-engagement also clears the API-error retry halt, so a
|
|
2017
|
+
// fresh request can auto-retry again from a clean counter.
|
|
2018
|
+
orchestrator.errorNudgeHalted = false;
|
|
2019
|
+
orchestrator.errorRetryCount = 0;
|
|
1824
2020
|
}
|
|
1825
2021
|
orchestrator.updateStatus(ctx);
|
|
1826
2022
|
|
|
@@ -1839,6 +2035,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1839
2035
|
const projectContext = systemSnippets
|
|
1840
2036
|
? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
|
|
1841
2037
|
: "";
|
|
2038
|
+
const agentsMdPath = join(orchestrator.cwd, "AGENTS.md");
|
|
2039
|
+
const agentsMd =
|
|
2040
|
+
orchestrator.config.general.injectAgentsMd && existsSync(agentsMdPath)
|
|
2041
|
+
? [`<agents_md source="${agentsMdPath}">`, readFileSync(agentsMdPath, "utf-8"), "</agents_md>"].join("\n")
|
|
2042
|
+
: "";
|
|
1842
2043
|
const checklistLine =
|
|
1843
2044
|
phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
|
|
1844
2045
|
const taskBlock = [
|
|
@@ -1855,7 +2056,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1855
2056
|
constraintsBlock(phase as Phase, effectiveMode),
|
|
1856
2057
|
PRINCIPLES_BLOCK,
|
|
1857
2058
|
TOOLS_BLOCK,
|
|
2059
|
+
DELEGATION_BLOCK,
|
|
1858
2060
|
projectContext,
|
|
2061
|
+
agentsMd,
|
|
1859
2062
|
taskBlock,
|
|
1860
2063
|
]
|
|
1861
2064
|
.filter(Boolean)
|
|
@@ -1867,6 +2070,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1867
2070
|
});
|
|
1868
2071
|
|
|
1869
2072
|
pi.on("tool_call", async (event, _ctx) => {
|
|
2073
|
+
markMainTurnActivity(orchestrator);
|
|
1870
2074
|
getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
|
|
1871
2075
|
if (event.toolName === "ask_user" && orchestrator.active) {
|
|
1872
2076
|
if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
|
|
@@ -1877,24 +2081,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1877
2081
|
if (event.toolName === "Agent" && orchestrator.active) {
|
|
1878
2082
|
const input = event.input as Record<string, unknown>;
|
|
1879
2083
|
const requestedType = ((input.subagent_type as string) || "").toLowerCase();
|
|
2084
|
+
const validTypes = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
|
|
1880
2085
|
if (!requestedType) {
|
|
1881
|
-
return { block: true, reason: "subagent_type is required.
|
|
2086
|
+
return { block: true, reason: "subagent_type is required. Valid types: explore (codebase research), librarian (external docs), task (implementation subtask), advisor (design/'why is this broken' judgment), deep-debugger (hard persistent failures), reviewer (code review — only when the user explicitly asks)." };
|
|
1882
2087
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2088
|
+
if (!validTypes.includes(requestedType)) {
|
|
2089
|
+
return { block: true, reason: `Unknown subagent_type "${requestedType}". Valid types: ${validTypes.join(", ")}.` };
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const simple = orchestrator.config.agents.subagents.simple;
|
|
2093
|
+
const role = requestedType as keyof typeof simple;
|
|
2094
|
+
input.subagent_type = requestedType;
|
|
2095
|
+
input.model = resolveModel(simple[role].model);
|
|
2096
|
+
input.thinking = simple[role].thinking;
|
|
2097
|
+
|
|
2098
|
+
// Spawn-time context injection: append USER_REQUEST + RESEARCH content and a
|
|
2099
|
+
// path-aware manifest of on-demand documents to the LLM-supplied spawn prompt.
|
|
2100
|
+
// explore/librarian intentionally get nothing (fast, scoped retrieval).
|
|
2101
|
+
if (["task", "advisor", "deep-debugger", "reviewer"].includes(requestedType)) {
|
|
2102
|
+
const contextBlock = buildSpawnContextBlock(orchestrator.active.dir);
|
|
2103
|
+
if (contextBlock) {
|
|
2104
|
+
const existingPrompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
2105
|
+
input.prompt = existingPrompt ? `${existingPrompt}\n\n${contextBlock}` : contextBlock;
|
|
2106
|
+
}
|
|
1898
2107
|
}
|
|
1899
2108
|
}
|
|
1900
2109
|
|
|
@@ -1919,11 +2128,49 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1919
2128
|
if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
|
|
1920
2129
|
return { block: true, reason: "config.json is managed by the user, not the LLM" };
|
|
1921
2130
|
}
|
|
2131
|
+
|
|
2132
|
+
// Review-phase read-only exception is scoped to AI_COMMENT markers: the main
|
|
2133
|
+
// agent may only insert/remove `AI_COMMENT:` markers in source files. Enforce
|
|
2134
|
+
// it at the gate (the constraint is otherwise prompt-level only): a source
|
|
2135
|
+
// write/edit is allowed only when it changes nothing but AI_COMMENT markers.
|
|
2136
|
+
// Publishing findings as file comments goes through this path.
|
|
2137
|
+
if (
|
|
2138
|
+
orchestrator.active?.state.phase === "review" &&
|
|
2139
|
+
!isPathInside(ppDir, resolvedPath)
|
|
2140
|
+
) {
|
|
2141
|
+
const denial = {
|
|
2142
|
+
block: true as const,
|
|
2143
|
+
reason: "Review phase is read-only except for inserting/removing AI_COMMENT: markers. This edit changes non-AI_COMMENT content — record the finding instead of applying a fix.",
|
|
2144
|
+
};
|
|
2145
|
+
if (event.toolName === "edit") {
|
|
2146
|
+
const edits = Array.isArray((event.input as any)?.edits) ? (event.input as any).edits : [];
|
|
2147
|
+
const legacy = event.input as any;
|
|
2148
|
+
const allEdits = [
|
|
2149
|
+
...edits,
|
|
2150
|
+
...(typeof legacy?.oldText === "string" && typeof legacy?.newText === "string" ? [{ oldText: legacy.oldText, newText: legacy.newText }] : []),
|
|
2151
|
+
];
|
|
2152
|
+
for (const e of allEdits) {
|
|
2153
|
+
if (typeof e?.oldText !== "string" || typeof e?.newText !== "string") continue;
|
|
2154
|
+
if (!isAiCommentOnlyChange(e.oldText, e.newText)) return denial;
|
|
2155
|
+
}
|
|
2156
|
+
} else {
|
|
2157
|
+
// Anchoring inserts markers into EXISTING code; creating a new source
|
|
2158
|
+
// file is never a valid AI_COMMENT-only operation.
|
|
2159
|
+
if (!existsSync(resolvedPath)) return denial;
|
|
2160
|
+
const newContent = typeof (event.input as any)?.content === "string" ? (event.input as any).content : "";
|
|
2161
|
+
let before = "";
|
|
2162
|
+
try {
|
|
2163
|
+
before = readFileSync(resolvedPath, "utf-8");
|
|
2164
|
+
} catch {}
|
|
2165
|
+
if (!isAiCommentOnlyChange(before, newContent)) return denial;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
1922
2168
|
}
|
|
1923
2169
|
return;
|
|
1924
2170
|
});
|
|
1925
2171
|
|
|
1926
2172
|
pi.on("tool_result", async (event, ctx) => {
|
|
2173
|
+
markMainTurnActivity(orchestrator);
|
|
1927
2174
|
// ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
|
|
1928
2175
|
// "stop, I want to type"). Only a deliberate user cancel aborts — timeout
|
|
1929
2176
|
// and programmatic signal-aborts carry a non-"user" reason and must not.
|
|
@@ -1992,6 +2239,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1992
2239
|
orchestrator.active.modifiedFiles.add(resolvedWrite);
|
|
1993
2240
|
orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
|
|
1994
2241
|
orchestrator.active.state.reviewApprovedClean = false;
|
|
2242
|
+
// New implement edits invalidate a prior afterImplement run (e.g. fixes made
|
|
2243
|
+
// via the guided menu after the autonomous terminal handoff), so the hooks
|
|
2244
|
+
// re-run and validate the final code on the next completion.
|
|
2245
|
+
orchestrator.active.state.afterImplementRan = false;
|
|
1995
2246
|
try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
|
|
1996
2247
|
|
|
1997
2248
|
const repos = orchestrator.active.state.repos ?? [];
|
|
@@ -2052,10 +2303,21 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2052
2303
|
// so this works whether or not `active` is still set (done cleanup nulls it).
|
|
2053
2304
|
if (transitioning) {
|
|
2054
2305
|
const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
|
|
2306
|
+
// Task-boundary discard: keep NO verbatim transcript beyond the summary so
|
|
2307
|
+
// the finished/replaced task cannot leak into the next task. Point
|
|
2308
|
+
// firstKeptEntryId at the newest branch entry (nothing before it is kept
|
|
2309
|
+
// verbatim). Phase transitions keep the default recent window so
|
|
2310
|
+
// legitimate context carries forward.
|
|
2311
|
+
const branchEntries = (event as any).branchEntries as Array<{ id: string }> | undefined;
|
|
2312
|
+
const lastEntryId = branchEntries && branchEntries.length > 0 ? branchEntries[branchEntries.length - 1].id : undefined;
|
|
2313
|
+
const firstKeptEntryId =
|
|
2314
|
+
orchestrator.transitionController.isDiscardTransition() && lastEntryId
|
|
2315
|
+
? lastEntryId
|
|
2316
|
+
: event.preparation.firstKeptEntryId;
|
|
2055
2317
|
return {
|
|
2056
2318
|
compaction: {
|
|
2057
2319
|
summary,
|
|
2058
|
-
firstKeptEntryId
|
|
2320
|
+
firstKeptEntryId,
|
|
2059
2321
|
tokensBefore: event.preparation.tokensBefore,
|
|
2060
2322
|
},
|
|
2061
2323
|
};
|
|
@@ -2085,6 +2347,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2085
2347
|
});
|
|
2086
2348
|
|
|
2087
2349
|
pi.on("turn_end", async (event, ctx) => {
|
|
2350
|
+
// The turn produced a terminal event — it is no longer wedged. If the SDK
|
|
2351
|
+
// auto-retries a retryable error it will emit a fresh turn_start, which
|
|
2352
|
+
// re-arms the watchdog.
|
|
2353
|
+
endMainTurn(orchestrator);
|
|
2088
2354
|
const msg = event.message as any;
|
|
2089
2355
|
const usageTracker = getUsageTracker();
|
|
2090
2356
|
if (usageTracker && msg?.usage) {
|
|
@@ -2146,6 +2412,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2146
2412
|
contentBlocks: msg.content?.length ?? 0,
|
|
2147
2413
|
contentSummary,
|
|
2148
2414
|
}, "turn ended with error");
|
|
2415
|
+
// Subscription rate-limit (429) on a sub-routed main turn: retrying the
|
|
2416
|
+
// same sub model is futile against an account-level limit. Offer a
|
|
2417
|
+
// user-gated switch to non-sub Claude instead of the generic backoff.
|
|
2418
|
+
const activeModelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id;
|
|
2419
|
+
const activeProvider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider;
|
|
2420
|
+
if (
|
|
2421
|
+
isRateLimitError(errorMsg) &&
|
|
2422
|
+
isSubscriptionRouted(activeModelId, activeProvider) &&
|
|
2423
|
+
!orchestrator.subFallbackActive
|
|
2424
|
+
) {
|
|
2425
|
+
void handleMainRateLimit(orchestrator, ctx, activeModelId, activeProvider);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
// The SDK already auto-retries this class of error itself (abortable
|
|
2429
|
+
// backoff bound to ESC, continuing the SAME turn). Running pi-pi's OWN
|
|
2430
|
+
// independent retry on top would double-fire: its followUp races the SDK's
|
|
2431
|
+
// continue() into "Agent is already processing", and it re-nudges a still-
|
|
2432
|
+
// failing model. So for SDK-retryable errors we defer entirely to the SDK
|
|
2433
|
+
// and do nothing here. pi-pi's own idle-gated retry remains only as the
|
|
2434
|
+
// fallback for errors the SDK does NOT retry.
|
|
2435
|
+
if (isSdkRetryableError(errorMsg)) {
|
|
2436
|
+
getLogger().debug({ s: "turn", err: errorMsg }, "deferring to SDK auto-retry; pi-pi retry skipped");
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
// Halt guard: once the consecutive-error cap is exceeded we stop auto-
|
|
2440
|
+
// retrying until the user re-engages. errorRetryCount is NO LONGER reset on
|
|
2441
|
+
// benign intervening turns (see below) — otherwise a retried turn that ends
|
|
2442
|
+
// as a harmless text-only reply would reset the counter and the cap could
|
|
2443
|
+
// never accumulate, letting transient errors nudge unbounded.
|
|
2444
|
+
if (orchestrator.errorNudgeHalted) {
|
|
2445
|
+
getLogger().debug({ s: "turn", err: errorMsg }, "error auto-retry halted; awaiting user re-engagement");
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2149
2448
|
orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
|
|
2150
2449
|
const maxRetries = 5;
|
|
2151
2450
|
if (orchestrator.errorRetryCount <= maxRetries) {
|
|
@@ -2153,18 +2452,44 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2153
2452
|
ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
|
|
2154
2453
|
const taskToken = orchestrator.activeTaskToken;
|
|
2155
2454
|
if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
|
|
2455
|
+
// Arm a direct ESC interrupt for this retry window — no SDK/interactive
|
|
2456
|
+
// binding covers pi-pi's own timer (the turn already ended in error).
|
|
2457
|
+
orchestrator.armRetryEscInterrupt(ctx);
|
|
2156
2458
|
orchestrator.pendingRetryTimer = setTimeout(() => {
|
|
2157
2459
|
orchestrator.pendingRetryTimer = null;
|
|
2158
|
-
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active)
|
|
2159
|
-
|
|
2460
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) {
|
|
2461
|
+
orchestrator.disarmRetryEscInterrupt();
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
// Defer until the main session is idle: sending a followUp while the SDK
|
|
2465
|
+
// still has an active run throws an async, runtime-swallowed "Agent is
|
|
2466
|
+
// already processing" error. sendUserMessageWhenIdle polls (reusing
|
|
2467
|
+
// pendingRetryTimer so ESC/abort still cancels) and disarms the ESC hook
|
|
2468
|
+
// once delivered.
|
|
2469
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
2470
|
+
`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`,
|
|
2471
|
+
taskToken,
|
|
2472
|
+
);
|
|
2160
2473
|
}, delay);
|
|
2161
2474
|
} else {
|
|
2162
|
-
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
|
|
2163
|
-
|
|
2475
|
+
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry — send any message to resume.`, "error");
|
|
2476
|
+
// Halt (do NOT reset the counter) so no further error turn re-arms a retry
|
|
2477
|
+
// until the user re-engages. cancelPendingRetry would reset the count and
|
|
2478
|
+
// re-open the floodgate, so only clear the live timer/ESC hook here.
|
|
2479
|
+
orchestrator.errorNudgeHalted = true;
|
|
2480
|
+
if (orchestrator.pendingRetryTimer) {
|
|
2481
|
+
clearTimeout(orchestrator.pendingRetryTimer);
|
|
2482
|
+
orchestrator.pendingRetryTimer = null;
|
|
2483
|
+
}
|
|
2484
|
+
orchestrator.disarmRetryEscInterrupt();
|
|
2164
2485
|
}
|
|
2165
2486
|
return;
|
|
2166
2487
|
}
|
|
2167
|
-
|
|
2488
|
+
// NOTE: errorRetryCount is intentionally NOT reset here. Resetting on every
|
|
2489
|
+
// non-error turn let a benign nudge-induced turn zero the counter, defeating
|
|
2490
|
+
// the maxRetries cap and allowing unbounded error nudges. The counter is
|
|
2491
|
+
// reset only on genuine user re-engagement (before_agent_start) and on task
|
|
2492
|
+
// reset / cancelPendingRetry.
|
|
2168
2493
|
|
|
2169
2494
|
if ((globalThis as any)[SUBAGENT_SESSION_KEY]) {
|
|
2170
2495
|
return;
|
|
@@ -2229,6 +2554,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2229
2554
|
const nudge = isAutonomous
|
|
2230
2555
|
? `[PI-PI] Continue the ${phase} phase. ${phaseConstraint(phase as Phase)} If the phase's objectives are met, call pp_phase_complete now; otherwise call the next tool. Do NOT apologize or reply with text only — respond with a tool call.`
|
|
2231
2556
|
: `[PI-PI] Continue the ${phase} phase where you left off.`;
|
|
2557
|
+
// Record the nudge's phase/task at generation time so a delivery that lands
|
|
2558
|
+
// after the phase advanced or the task changed can be dropped (the queued
|
|
2559
|
+
// followUp string itself carries no token).
|
|
2560
|
+
orchestrator.pendingNudges.set(nudge, { phase: phase as Phase, taskToken: orchestrator.activeTaskToken });
|
|
2232
2561
|
orchestrator.safeSendUserMessage(nudge);
|
|
2233
2562
|
});
|
|
2234
2563
|
|