@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { Orchestrator } from "./orchestrator.js";
|
|
2
|
+
import { getLogger } from "./log.js";
|
|
3
|
+
import { isSubscriptionRouted } from "./usage-tracker.js";
|
|
4
|
+
import { setSubscriptionFallbackActive, toNonSubSpec } from "./model-registry.js";
|
|
5
|
+
import { loadFlantSettings, probeSubscriptionCleared } from "./flant-infra.js";
|
|
6
|
+
import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
|
|
7
|
+
|
|
8
|
+
// Recognise a rate-limit / 429 error from a turn's or subagent's error message.
|
|
9
|
+
export function isRateLimitError(message?: string): boolean {
|
|
10
|
+
if (typeof message !== "string") return false;
|
|
11
|
+
return /\b429\b|rate.?limit|too many requests|exceed your account/i.test(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Mirror of the SDK's AgentSession._isRetryableError classifier
|
|
15
|
+
// (agent-session.js: overloaded/rate-limit/5xx/network/stream-ended/timeout/...).
|
|
16
|
+
// When true, the SDK auto-retries the SAME turn itself (abortable backoff bound
|
|
17
|
+
// to ESC, visible countdown). pi-pi must NOT ALSO schedule its own post-error
|
|
18
|
+
// retry for these: a second, independent retry races the SDK's continue() and
|
|
19
|
+
// reproduces "Agent is already processing", and re-nudges a futile sub-429. This
|
|
20
|
+
// list is kept in sync with the SDK regex; if they drift, pi-pi at worst falls
|
|
21
|
+
// back to its own idle-gated retry (still safe, just not unified).
|
|
22
|
+
export function isSdkRetryableError(message?: string): boolean {
|
|
23
|
+
if (typeof message !== "string" || !message) return false;
|
|
24
|
+
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
|
|
25
|
+
message,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SWITCH_DIALOG_CONTEXT =
|
|
30
|
+
"Switching between the personal subscription and regular flant Claude changes the provider/endpoint, " +
|
|
31
|
+
"so the prompt cache is LOST and the full conversation context is re-sent on the next call. " +
|
|
32
|
+
"Regular (non-subscription) flant Claude is billed PER TOKEN (paid).";
|
|
33
|
+
|
|
34
|
+
// Handle a subscription-routed 429 on the MAIN turn. Stops futile retries on the
|
|
35
|
+
// still-limited sub model, then (once, session-sticky) asks the user whether to
|
|
36
|
+
// fall back to non-sub Claude. On confirm: activates the session-scoped override,
|
|
37
|
+
// switches the active model, arms the switch-back probe, and nudges to continue.
|
|
38
|
+
export async function handleMainRateLimit(
|
|
39
|
+
orchestrator: Orchestrator,
|
|
40
|
+
ctx: any,
|
|
41
|
+
modelId: string | undefined,
|
|
42
|
+
provider: string | undefined,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const log = getLogger();
|
|
45
|
+
if (!isSubscriptionRouted(modelId, provider)) return;
|
|
46
|
+
|
|
47
|
+
// Stop the futile same-model retry (SDK backoff + pi-pi's own timer): retrying
|
|
48
|
+
// the sub model against an account-level limit cannot succeed.
|
|
49
|
+
ctx?.abort?.();
|
|
50
|
+
orchestrator.cancelPendingRetry();
|
|
51
|
+
|
|
52
|
+
if (orchestrator.subFallbackActive) return; // sticky — already on non-sub
|
|
53
|
+
await offerFallback(orchestrator, ctx, modelId ?? orchestrator.subFallbackModelId ?? "", "main");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle a subscription-routed 429 reported via subagents:failed. Uses ONE
|
|
57
|
+
// global dialogue (never per-subagent) — the same offerFallback path as the
|
|
58
|
+
// main turn, guarded so only one dialogue is open at a time.
|
|
59
|
+
export async function handleSubagentRateLimit(
|
|
60
|
+
orchestrator: Orchestrator,
|
|
61
|
+
ctx: any,
|
|
62
|
+
modelId: string | undefined,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
if (!isSubscriptionRouted(modelId)) return;
|
|
65
|
+
if (orchestrator.subFallbackActive) return; // sticky
|
|
66
|
+
await offerFallback(orchestrator, ctx, modelId ?? "", "subagent");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function offerFallback(
|
|
70
|
+
orchestrator: Orchestrator,
|
|
71
|
+
ctx: any,
|
|
72
|
+
subModelId: string,
|
|
73
|
+
origin: "main" | "subagent",
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const log = getLogger();
|
|
76
|
+
if (orchestrator.subFallbackDialogPending) return;
|
|
77
|
+
if (!ctx?.hasUI) {
|
|
78
|
+
// No UI to ask — leave sub routing in place; the error is surfaced elsewhere.
|
|
79
|
+
log.debug({ s: "ratelimit" }, "no UI available to offer subscription fallback");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
orchestrator.subFallbackDialogPending = true;
|
|
83
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
84
|
+
try {
|
|
85
|
+
const result = await askUser(ctx, {
|
|
86
|
+
question: "Personal Claude subscription is rate-limited. Switch to regular (paid) flant Claude?",
|
|
87
|
+
context: SWITCH_DIALOG_CONTEXT,
|
|
88
|
+
options: [
|
|
89
|
+
{ title: "Switch to non-sub Claude", description: "Continue on regular flant Claude (paid per token) until you switch back." },
|
|
90
|
+
{ title: "Stay on subscription", description: "Do not switch. Work stays paused until the limit clears." },
|
|
91
|
+
],
|
|
92
|
+
allowFreeform: false,
|
|
93
|
+
allowComment: false,
|
|
94
|
+
allowMultiple: false,
|
|
95
|
+
});
|
|
96
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
|
|
97
|
+
const chose = result && !isCancel(result) && result.kind === "selection" ? result.selections[0] : undefined;
|
|
98
|
+
if (chose !== "Switch to non-sub Claude") {
|
|
99
|
+
ctx.ui?.notify?.("Staying on subscription. Auto-continuation paused until you resume or the limit clears.", "info");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await activateFallback(orchestrator, ctx, subModelId, origin);
|
|
103
|
+
} finally {
|
|
104
|
+
orchestrator.subFallbackDialogPending = false;
|
|
105
|
+
orchestrator.subFallbackPendingDecision = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function activateFallback(
|
|
110
|
+
orchestrator: Orchestrator,
|
|
111
|
+
ctx: any,
|
|
112
|
+
subModelId: string,
|
|
113
|
+
origin: "main" | "subagent",
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const log = getLogger();
|
|
116
|
+
orchestrator.subFallbackActive = true;
|
|
117
|
+
orchestrator.subFallbackModelId = subModelId || orchestrator.subFallbackModelId;
|
|
118
|
+
// Activate the session-scoped override so EVERY future model resolution
|
|
119
|
+
// (phase switches, new subagents, planner/reviewer specs) rewrites sub→non-sub.
|
|
120
|
+
// This is what actually re-routes future subagent spawns, regardless of origin.
|
|
121
|
+
setSubscriptionFallbackActive(true);
|
|
122
|
+
|
|
123
|
+
// Switch the CURRENT main model to the non-sub equivalent ONLY when the 429 was
|
|
124
|
+
// on the main turn. For a SUBAGENT 429 the failing model is the subagent's, not
|
|
125
|
+
// the main session's — switching the main model here would change the active
|
|
126
|
+
// orchestrator model the user never touched (e.g. debug's GPT -> Claude). The
|
|
127
|
+
// session override above already re-routes the retried/next subagent.
|
|
128
|
+
if (origin === "main" && subModelId) {
|
|
129
|
+
const nonSub = toNonSubSpec(subModelId);
|
|
130
|
+
const ok = await orchestrator.switchModel(ctx, nonSub, currentThinking(orchestrator));
|
|
131
|
+
if (!ok) log.warn({ s: "ratelimit", nonSub }, "failed to switch main model to non-sub");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ctx.ui?.notify?.("Switched to regular flant Claude (paid per token). Will periodically check if the subscription limit has cleared.", "info");
|
|
135
|
+
|
|
136
|
+
armSwitchBackProbe(orchestrator);
|
|
137
|
+
|
|
138
|
+
// Nudge to continue — retries were cancelled, so the turn is stopped. Idle-gated
|
|
139
|
+
// (same guard as the post-error nudge) so it never races the SDK into an
|
|
140
|
+
// "Agent is already processing" error.
|
|
141
|
+
const phase = orchestrator.active?.state.phase ?? "current";
|
|
142
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
143
|
+
`[PI-PI] Switched to regular (non-subscription) flant Claude after a rate limit. Continue working on the current phase (${phase}).`,
|
|
144
|
+
orchestrator.activeTaskToken,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function currentThinking(orchestrator: Orchestrator): string {
|
|
149
|
+
const phase = orchestrator.active?.state.phase;
|
|
150
|
+
const orchestrators = orchestrator.config?.agents?.orchestrators as Record<string, { thinking?: string }> | undefined;
|
|
151
|
+
const key = phase === "debug" || phase === "brainstorm" || phase === "review" || phase === "quick" ? phase : "implement";
|
|
152
|
+
return orchestrators?.[key]?.thinking ?? "high";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Arm the fixed-interval switch-back probe. On each interval an out-of-band probe
|
|
156
|
+
// checks whether the sub limit cleared; a 429/failure silently re-arms, a success
|
|
157
|
+
// opens the switch-back dialogue. Only one timer runs at a time.
|
|
158
|
+
export function armSwitchBackProbe(orchestrator: Orchestrator): void {
|
|
159
|
+
if (orchestrator.subSwitchBackTimer) clearTimeout(orchestrator.subSwitchBackTimer);
|
|
160
|
+
const minutes = Math.max(1, loadFlantSettings().switchBackIntervalMinutes || 30);
|
|
161
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
162
|
+
orchestrator.subSwitchBackTimer = setTimeout(() => {
|
|
163
|
+
orchestrator.subSwitchBackTimer = null;
|
|
164
|
+
void runSwitchBackProbe(orchestrator, taskToken);
|
|
165
|
+
}, minutes * 60 * 1000);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runSwitchBackProbe(orchestrator: Orchestrator, taskToken: number): Promise<void> {
|
|
169
|
+
const log = getLogger();
|
|
170
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active || !orchestrator.subFallbackActive) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const modelId = orchestrator.subFallbackModelId;
|
|
174
|
+
if (!modelId) {
|
|
175
|
+
armSwitchBackProbe(orchestrator);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const outcome = await probeSubscriptionCleared(modelId);
|
|
179
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active || !orchestrator.subFallbackActive) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (outcome !== "ok") {
|
|
183
|
+
// Still limited (or transient error) — stay on non-sub, silently re-arm.
|
|
184
|
+
log.debug({ s: "ratelimit", outcome }, "switch-back probe: not cleared, re-arming");
|
|
185
|
+
armSwitchBackProbe(orchestrator);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await offerSwitchBack(orchestrator, modelId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function offerSwitchBack(orchestrator: Orchestrator, subModelId: string): Promise<void> {
|
|
192
|
+
const log = getLogger();
|
|
193
|
+
const ctx = orchestrator.lastCtx;
|
|
194
|
+
if (orchestrator.subFallbackDialogPending || !ctx?.hasUI) {
|
|
195
|
+
armSwitchBackProbe(orchestrator);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
orchestrator.subFallbackDialogPending = true;
|
|
199
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
200
|
+
try {
|
|
201
|
+
const result = await askUser(ctx, {
|
|
202
|
+
question: "Your Claude subscription limit appears to have cleared. Switch back to the subscription?",
|
|
203
|
+
context: SWITCH_DIALOG_CONTEXT,
|
|
204
|
+
options: [
|
|
205
|
+
{ title: "Switch back to subscription", description: "Resume on the personal Claude subscription (flat-rate)." },
|
|
206
|
+
{ title: "Stay on non-sub Claude", description: "Keep using regular flant Claude; check again later." },
|
|
207
|
+
],
|
|
208
|
+
allowFreeform: false,
|
|
209
|
+
allowComment: false,
|
|
210
|
+
allowMultiple: false,
|
|
211
|
+
});
|
|
212
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
|
|
213
|
+
const chose = result && !isCancel(result) && result.kind === "selection" ? result.selections[0] : undefined;
|
|
214
|
+
if (chose !== "Switch back to subscription") {
|
|
215
|
+
// Stay on non-sub; re-arm so we check again after the interval.
|
|
216
|
+
armSwitchBackProbe(orchestrator);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await switchBackToSub(orchestrator, ctx, subModelId);
|
|
220
|
+
} finally {
|
|
221
|
+
orchestrator.subFallbackDialogPending = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function switchBackToSub(orchestrator: Orchestrator, ctx: any, subModelId: string): Promise<void> {
|
|
226
|
+
const log = getLogger();
|
|
227
|
+
// Clear the override FIRST so switchModel resolves the sub spec unrewritten.
|
|
228
|
+
orchestrator.subFallbackActive = false;
|
|
229
|
+
setSubscriptionFallbackActive(false);
|
|
230
|
+
if (orchestrator.subSwitchBackTimer) {
|
|
231
|
+
clearTimeout(orchestrator.subSwitchBackTimer);
|
|
232
|
+
orchestrator.subSwitchBackTimer = null;
|
|
233
|
+
}
|
|
234
|
+
const ok = await orchestrator.switchModel(ctx, subModelId, currentThinking(orchestrator));
|
|
235
|
+
if (!ok) log.warn({ s: "ratelimit", subModelId }, "failed to switch back to sub model");
|
|
236
|
+
orchestrator.subFallbackModelId = null;
|
|
237
|
+
ctx.ui?.notify?.("Switched back to the personal Claude subscription.", "info");
|
|
238
|
+
const phase = orchestrator.active?.state.phase ?? "current";
|
|
239
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
240
|
+
`[PI-PI] Switched back to the personal Claude subscription. Continue working on the current phase (${phase}).`,
|
|
241
|
+
orchestrator.activeTaskToken,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import lockfile from "proper-lockfile";
|
|
6
6
|
import {
|
|
7
7
|
createTask,
|
|
8
|
+
formatModeIndicator,
|
|
8
9
|
getActiveTask,
|
|
9
10
|
getEffectiveMode,
|
|
10
11
|
getFirstPhase,
|
|
@@ -410,4 +411,12 @@ describe("getEffectiveMode", () => {
|
|
|
410
411
|
|
|
411
412
|
expect(getEffectiveMode(state)).toBeUndefined();
|
|
412
413
|
});
|
|
414
|
+
|
|
415
|
+
it("formatModeIndicator: autonomous stays autonomous, quick shows nothing, else guided (#4)", () => {
|
|
416
|
+
expect(formatModeIndicator({ ...baseState(), mode: "autonomous" }, "implement")).toBe("autonomous");
|
|
417
|
+
expect(formatModeIndicator({ ...baseState(), effectiveMode: "autonomous" }, "implement")).toBe("autonomous");
|
|
418
|
+
expect(formatModeIndicator({ ...baseState(), mode: "guided" }, "implement")).toBe("guided");
|
|
419
|
+
expect(formatModeIndicator({ ...baseState() }, "implement")).toBe("guided");
|
|
420
|
+
expect(formatModeIndicator({ ...baseState(), mode: "autonomous" }, "quick")).toBeUndefined();
|
|
421
|
+
});
|
|
413
422
|
});
|
|
@@ -58,6 +58,14 @@ export interface TaskState {
|
|
|
58
58
|
autonomousConfig?: AutonomousConfig;
|
|
59
59
|
plannerFailureAutoRetried?: boolean;
|
|
60
60
|
reviewerFailureAutoRetried?: boolean;
|
|
61
|
+
// Set once afterImplement has run for the current implement phase (either at
|
|
62
|
+
// the guided/autonomous transition or the autonomous terminal handoff), so the
|
|
63
|
+
// hooks never run twice for the same completion.
|
|
64
|
+
afterImplementRan?: boolean;
|
|
65
|
+
// Per-repo interleaved Plannotator review cursor (#3a). repoPaths is the ordered
|
|
66
|
+
// set of repos to review; index is the next repo. Persisted so the loop resumes
|
|
67
|
+
// on the next /pp after the agent fixes one repo's feedback. Undefined = no loop.
|
|
68
|
+
plannotatorCursor?: { repoPaths: string[]; index: number };
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
export interface TaskInfo {
|
|
@@ -93,6 +101,13 @@ export function getEffectivePhaseMode(state: TaskState): TaskMode {
|
|
|
93
101
|
return getEffectiveMode(state) ?? "guided";
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
// Display-only: reflects the task's chosen mode regardless of per-phase interactivity.
|
|
105
|
+
// Returns undefined for quick tasks so the footer omits the mode segment entirely.
|
|
106
|
+
export function formatModeIndicator(state: TaskState, type: TaskType): TaskMode | undefined {
|
|
107
|
+
if (type === "quick") return undefined;
|
|
108
|
+
return getEffectiveMode(state) === "autonomous" ? "autonomous" : "guided";
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
export function createTask(cwd: string, type: TaskType, description: string, mode?: TaskMode): string {
|
|
97
112
|
const log = getLogger();
|
|
98
113
|
const id = crypto.randomUUID().slice(0, 12);
|
|
@@ -215,9 +215,12 @@ export function expectReviewAuto(menu: AskUserHarness, preset = "regular"): void
|
|
|
215
215
|
menu.expect({ question: "Review preset", options: { include: [m.preset(preset), "Back"] }, choose: m.preset(preset) });
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
export function expectReviewOnMyOwn(menu: AskUserHarness): void {
|
|
218
|
+
export function expectReviewOnMyOwn(menu: AskUserHarness, editorGate?: "Done" | "Skip markers"): void {
|
|
219
219
|
menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
|
|
220
220
|
menu.expect({ question: "Review", options: { include: ["Review on my own"] }, choose: "Review on my own" });
|
|
221
|
+
if (editorGate) {
|
|
222
|
+
menu.expect({ question: "Editor review", options: { include: [editorGate] }, choose: editorGate });
|
|
223
|
+
}
|
|
221
224
|
}
|
|
222
225
|
|
|
223
226
|
export function expectPpStartImplementAutonomous(menu: AskUserHarness): void {
|
|
@@ -205,3 +205,103 @@ describe("TransitionController phase transition flow", () => {
|
|
|
205
205
|
expect(onResume).toHaveBeenCalledOnce();
|
|
206
206
|
});
|
|
207
207
|
});
|
|
208
|
+
|
|
209
|
+
describe("TransitionController done supersession (task-boundary discard cannot be swallowed)", () => {
|
|
210
|
+
it("a done request supersedes a PENDING transition in place (no swallowed discard)", async () => {
|
|
211
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: false });
|
|
212
|
+
const c = new TransitionController(host, session);
|
|
213
|
+
// A phase transition is pending (not yet compacting: waiting for agent_end).
|
|
214
|
+
const staleResume = vi.fn();
|
|
215
|
+
const pPhase = c.requestTransition({ kind: "phase", summary: "phase", onResume: staleResume, instruction: "Begin working." });
|
|
216
|
+
expect(c.getState()).toBe("pending");
|
|
217
|
+
// A new-task done arrives before agent_end. It must replace the pending req.
|
|
218
|
+
const pDone = c.requestTransition({ kind: "done", discard: true, summary: "DISCARD" });
|
|
219
|
+
expect(c.getState()).toBe("pending");
|
|
220
|
+
expect(c.currentSummary()).toBe("DISCARD");
|
|
221
|
+
expect(c.isDiscardTransition()).toBe(true);
|
|
222
|
+
|
|
223
|
+
c.onAgentEnd();
|
|
224
|
+
expect(calls.compactStarted).toBe(1);
|
|
225
|
+
completeCompaction();
|
|
226
|
+
await Promise.all([pPhase, pDone]);
|
|
227
|
+
// The superseded phase resume/instruction must NOT have run.
|
|
228
|
+
expect(staleResume).not.toHaveBeenCalled();
|
|
229
|
+
expect(calls.userMessages).toHaveLength(0);
|
|
230
|
+
expect(c.getState()).toBe("running");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("queues a done request behind a COMPACTING transition and runs its discard after", async () => {
|
|
234
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: false });
|
|
235
|
+
const c = new TransitionController(host, session);
|
|
236
|
+
const staleResume = vi.fn();
|
|
237
|
+
const pPhase = c.requestTransition({ kind: "phase", summary: "phase", onResume: staleResume, instruction: "Begin working." });
|
|
238
|
+
c.onAgentEnd();
|
|
239
|
+
expect(c.getState()).toBe("compacting");
|
|
240
|
+
|
|
241
|
+
// done arrives mid-compaction — must be queued, not dropped, not double-resumed.
|
|
242
|
+
let doneResolved = false;
|
|
243
|
+
const pDone = c.requestTransition({ kind: "done", discard: true, summary: "DISCARD" }).then(() => { doneResolved = true; });
|
|
244
|
+
expect(calls.compactStarted).toBe(1);
|
|
245
|
+
|
|
246
|
+
// First (phase) compaction settles. The superseded phase instruction must be
|
|
247
|
+
// suppressed because a done is queued behind it; the queued done then runs.
|
|
248
|
+
completeCompaction();
|
|
249
|
+
await pPhase;
|
|
250
|
+
expect(staleResume).toHaveBeenCalledOnce(); // phase onResume still ran (it was the active req)
|
|
251
|
+
// The queued done must not have resolved yet — its own compaction must run.
|
|
252
|
+
expect(doneResolved).toBe(false);
|
|
253
|
+
expect(calls.compactStarted).toBe(2);
|
|
254
|
+
expect(c.currentSummary()).toBe("DISCARD");
|
|
255
|
+
expect(c.isDiscardTransition()).toBe(true);
|
|
256
|
+
|
|
257
|
+
completeCompaction();
|
|
258
|
+
await pDone;
|
|
259
|
+
expect(doneResolved).toBe(true);
|
|
260
|
+
expect(c.getState()).toBe("running");
|
|
261
|
+
// The superseded phase instruction ("Begin working.") must NOT have been sent.
|
|
262
|
+
expect(calls.userMessages).toHaveLength(0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("queues a done request behind a RESUMING transition", async () => {
|
|
266
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: false });
|
|
267
|
+
const c = new TransitionController(host, session);
|
|
268
|
+
// onResume that lets us inject the done request while state === "resuming".
|
|
269
|
+
let injected: Promise<void> | null = null;
|
|
270
|
+
const pPhase = c.requestTransition({
|
|
271
|
+
kind: "phase",
|
|
272
|
+
summary: "phase",
|
|
273
|
+
onResume: () => {
|
|
274
|
+
expect(c.getState()).toBe("resuming");
|
|
275
|
+
injected = c.requestTransition({ kind: "done", discard: true, summary: "DISCARD" });
|
|
276
|
+
},
|
|
277
|
+
instruction: "Begin working.",
|
|
278
|
+
});
|
|
279
|
+
c.onAgentEnd();
|
|
280
|
+
completeCompaction();
|
|
281
|
+
await pPhase;
|
|
282
|
+
// A done was queued during resuming — it must run its own compaction now.
|
|
283
|
+
expect(injected).not.toBeNull();
|
|
284
|
+
expect(calls.compactStarted).toBe(2);
|
|
285
|
+
completeCompaction();
|
|
286
|
+
await injected!;
|
|
287
|
+
expect(c.getState()).toBe("running");
|
|
288
|
+
expect(c.isDiscardTransition()).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("last-wins when two done requests queue behind an in-flight transition", async () => {
|
|
292
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: false });
|
|
293
|
+
const c = new TransitionController(host, session);
|
|
294
|
+
const pPhase = c.requestTransition({ kind: "phase", summary: "phase", onResume: () => {}, instruction: "go" });
|
|
295
|
+
c.onAgentEnd();
|
|
296
|
+
const pDone1 = c.requestTransition({ kind: "done", discard: true, summary: "FIRST" });
|
|
297
|
+
const pDone2 = c.requestTransition({ kind: "done", discard: true, summary: "SECOND" });
|
|
298
|
+
|
|
299
|
+
completeCompaction(); // phase settles -> queued done runs
|
|
300
|
+
await pPhase;
|
|
301
|
+
expect(c.currentSummary()).toBe("SECOND");
|
|
302
|
+
completeCompaction(); // done settles -> both queued callers resolve
|
|
303
|
+
await Promise.all([pDone1, pDone2]);
|
|
304
|
+
expect(c.getState()).toBe("running");
|
|
305
|
+
expect(calls.compactStarted).toBe(2);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -26,6 +26,13 @@ export interface TransitionRequest {
|
|
|
26
26
|
kind: TransitionKind;
|
|
27
27
|
// Compaction summary used by the session_before_compact handler.
|
|
28
28
|
summary?: string;
|
|
29
|
+
// Task-boundary discard: the previous task is finished/replaced and its
|
|
30
|
+
// conversation must be genuinely dropped (not softly summarized) so it cannot
|
|
31
|
+
// leak into the next task. Drives session_before_compact to keep no verbatim
|
|
32
|
+
// transcript beyond the summary. Only set on finished/replaced-task "done"
|
|
33
|
+
// transitions (startTask/finishTask/task-complete) — NOT pause/stop (those
|
|
34
|
+
// resume later and must preserve context).
|
|
35
|
+
discard?: boolean;
|
|
29
36
|
// Runs after compaction completes (or is skipped), before the resume message.
|
|
30
37
|
// Used for phase transitions to switch model + inject context/artifacts and to
|
|
31
38
|
// spawn planners at the right moment. Throwing here is logged, not fatal.
|
|
@@ -80,6 +87,11 @@ export class TransitionController {
|
|
|
80
87
|
private active: TransitionRequest | null = null;
|
|
81
88
|
// Resolvers for callers that await the transition (done/stop/new-task paths).
|
|
82
89
|
private waiters: Array<() => void> = [];
|
|
90
|
+
// A single "done" transition queued behind an in-flight (compacting/resuming)
|
|
91
|
+
// transition it supersedes. Run once the current transition settles so its
|
|
92
|
+
// discard compaction is guaranteed to execute. Last-wins (never a queue).
|
|
93
|
+
private pendingNext: TransitionRequest | null = null;
|
|
94
|
+
private pendingNextWaiters: Array<() => void> = [];
|
|
83
95
|
|
|
84
96
|
constructor(
|
|
85
97
|
private readonly host: TransitionHost,
|
|
@@ -141,6 +153,13 @@ export class TransitionController {
|
|
|
141
153
|
return this.active?.summary ?? "";
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
// True when the in-flight transition is a task-boundary discard (see
|
|
157
|
+
// TransitionRequest.discard). Lets session_before_compact drop the verbatim
|
|
158
|
+
// transcript window instead of keeping the default recent tokens.
|
|
159
|
+
isDiscardTransition(): boolean {
|
|
160
|
+
return this.active?.discard === true;
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
// Outbound plain user-message. The ONLY path for main-session user messaging.
|
|
145
164
|
// Plain user messages (pi.sendUserMessage) ALWAYS start a turn — even with
|
|
146
165
|
// deliverAs:"steer" when idle (SDK prompt()) — so this only serves the
|
|
@@ -168,8 +187,29 @@ export class TransitionController {
|
|
|
168
187
|
requestTransition(req: TransitionRequest): Promise<void> {
|
|
169
188
|
const log = getLogger();
|
|
170
189
|
if (this.state !== "running") {
|
|
171
|
-
// A transition is already in flight.
|
|
172
|
-
//
|
|
190
|
+
// A transition is already in flight. A task-boundary discard (kind:"done")
|
|
191
|
+
// must NOT be silently skipped by an in-flight/aborted transition — that
|
|
192
|
+
// is the concrete cause of prior-task context leaking into the next task.
|
|
193
|
+
if (req.kind === "done") {
|
|
194
|
+
if (this.state === "pending") {
|
|
195
|
+
// Not compacting yet (waiting for agent_end). Replace the pending
|
|
196
|
+
// request in place: its onResume/instruction is dropped (superseded),
|
|
197
|
+
// its waiters ride along and resolve when the done transition settles.
|
|
198
|
+
log.debug({ s: "controller", superseded: this.active?.kind }, "done supersedes pending transition");
|
|
199
|
+
this.active = req;
|
|
200
|
+
const promise = new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
201
|
+
if (this.host.isIdle()) this.beginCompaction();
|
|
202
|
+
return promise;
|
|
203
|
+
}
|
|
204
|
+
// compacting/resuming: a compaction is already in flight for the old
|
|
205
|
+
// request. Queue the done request to run once it settles so its discard
|
|
206
|
+
// compaction actually executes; suppress the superseded instruction.
|
|
207
|
+
// Last-wins if one is already queued (never an unbounded queue).
|
|
208
|
+
log.debug({ s: "controller", state: this.state }, "done queued behind in-flight transition");
|
|
209
|
+
this.pendingNext = req;
|
|
210
|
+
return new Promise<void>((resolve) => this.pendingNextWaiters.push(resolve));
|
|
211
|
+
}
|
|
212
|
+
// phase (or other) while not running: keep conservative coalescing.
|
|
173
213
|
if (req.kind === "phase") {
|
|
174
214
|
log.warn({ s: "controller", state: this.state }, "phase transition coalesced while not running — onResume/instruction dropped");
|
|
175
215
|
} else {
|
|
@@ -247,7 +287,10 @@ export class TransitionController {
|
|
|
247
287
|
} catch (err: any) {
|
|
248
288
|
getLogger().error({ s: "controller", err: err?.message ?? String(err) }, "onResume failed");
|
|
249
289
|
}
|
|
250
|
-
|
|
290
|
+
// Suppress the superseded transition's resume instruction when a done
|
|
291
|
+
// request is queued to supersede it (its followUp would resume a task that
|
|
292
|
+
// is about to be discarded).
|
|
293
|
+
if (req?.instruction && !this.pendingNext) {
|
|
251
294
|
this.send(req.instruction, "instruction");
|
|
252
295
|
}
|
|
253
296
|
this.active = null;
|
|
@@ -255,5 +298,20 @@ export class TransitionController {
|
|
|
255
298
|
const waiters = this.waiters;
|
|
256
299
|
this.waiters = [];
|
|
257
300
|
for (const w of waiters) w();
|
|
301
|
+
// A done transition queued while this one was in flight: run it now. We are
|
|
302
|
+
// post-compaction with no agent turn in flight, so begin its compaction
|
|
303
|
+
// directly rather than through the isIdle/agent_end gate. Its queued callers
|
|
304
|
+
// ride the new transition's waiters, so they resolve only AFTER the discard
|
|
305
|
+
// compaction settles.
|
|
306
|
+
if (this.pendingNext) {
|
|
307
|
+
const next = this.pendingNext;
|
|
308
|
+
const resolvers = this.pendingNextWaiters;
|
|
309
|
+
this.pendingNext = null;
|
|
310
|
+
this.pendingNextWaiters = [];
|
|
311
|
+
this.active = next;
|
|
312
|
+
this.state = "pending";
|
|
313
|
+
for (const r of resolvers) this.waiters.push(r);
|
|
314
|
+
this.beginCompaction();
|
|
315
|
+
}
|
|
258
316
|
}
|
|
259
317
|
}
|
|
@@ -12,7 +12,11 @@ import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export function isSubscriptionRouted(modelId?: string, provider?: string): boolean {
|
|
14
14
|
if (provider === SUB_PROVIDER) return true;
|
|
15
|
-
|
|
15
|
+
if (typeof modelId !== "string") return false;
|
|
16
|
+
// Accept a bare `sub/<m>` id OR a full provider-prefixed spec
|
|
17
|
+
// `pp-flant-anthropic-sub/sub/<m>` passed as a single id (the form callers
|
|
18
|
+
// store/pass without a separate provider field).
|
|
19
|
+
return modelId.startsWith(SUB_MODEL_PREFIX) || modelId.startsWith(`${SUB_PROVIDER}/`);
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface ModelUsage {
|
|
@@ -41,6 +41,26 @@ Ship minimal fix.
|
|
|
41
41
|
expect(result.ok).toBe(true);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
it("accepts a Pattern constraints section", () => {
|
|
45
|
+
const content = `# Plan
|
|
46
|
+
|
|
47
|
+
## Scope
|
|
48
|
+
Add a new annotation.
|
|
49
|
+
|
|
50
|
+
## Checklist
|
|
51
|
+
- [ ] Add annotation — Done when: tests pass
|
|
52
|
+
|
|
53
|
+
## Pattern constraints
|
|
54
|
+
- Mirror deletePolicies: one typed slice, kebab-case values.
|
|
55
|
+
|
|
56
|
+
## Blockers
|
|
57
|
+
- none
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const result = validatePlan(content);
|
|
61
|
+
expect(result.ok).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
44
64
|
it("accepts multiline Done when continuation", () => {
|
|
45
65
|
const content = `# Plan
|
|
46
66
|
|
|
@@ -5,7 +5,7 @@ const USER_REQUEST_ALLOWED_SECTIONS = ["Problem", "Constraints"] as const;
|
|
|
5
5
|
const RESEARCH_REQUIRED_SECTIONS = ["Affected Code", "Architecture Context", "Constraints & Edge Cases"] as const;
|
|
6
6
|
const RESEARCH_ALLOWED_SECTIONS = ["Affected Code", "Architecture Context", "Constraints & Edge Cases", "Open Questions"] as const;
|
|
7
7
|
const PLAN_REQUIRED_SECTIONS = ["Scope", "Checklist"] as const;
|
|
8
|
-
const PLAN_ALLOWED_SECTIONS = ["Scope", "Checklist", "Blockers"] as const;
|
|
8
|
+
const PLAN_ALLOWED_SECTIONS = ["Scope", "Checklist", "Pattern constraints", "Blockers"] as const;
|
|
9
9
|
const PLACEHOLDER_PATTERNS = /^(?:[-*.…—]|tbd|todo|n\/a|na|none|\.{2,})$/i;
|
|
10
10
|
|
|
11
11
|
function splitLines(content: string): string[] {
|
|
@@ -186,7 +186,7 @@ export function validatePlan(content: string): ValidationResult {
|
|
|
186
186
|
const errors: string[] = [];
|
|
187
187
|
const h1 = getH1(content);
|
|
188
188
|
const firstHeading = getFirstHeading(content);
|
|
189
|
-
const expectedSections = formatSectionList(PLAN_REQUIRED_SECTIONS, ["Blockers"]);
|
|
189
|
+
const expectedSections = formatSectionList(PLAN_REQUIRED_SECTIONS, ["Pattern constraints", "Blockers"]);
|
|
190
190
|
|
|
191
191
|
if (!firstHeading) {
|
|
192
192
|
errors.push("Missing required heading: # Plan. Expected first heading: # Plan");
|
package/package.json
CHANGED
package/AGENTS.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Repository Guidelines
|
|
2
|
-
|
|
3
|
-
All rules in this document are requirements — not suggestions. ALWAYS follow them.
|
|
4
|
-
|
|
5
|
-
## Highest-priority rule (MANDATORY)
|
|
6
|
-
|
|
7
|
-
- NEVER add comments unless they document a non-obvious public API or explain genuinely non-obvious logic. NEVER add comments that restate what the code does, repeat the field/function name, describe obvious error handling, or act as section separators. When in doubt, don't comment.
|
|
8
|
-
- ALWAYS verify, don't assume — check the actual state before making changes.
|
|
9
|
-
- ALWAYS start with the simplest possible solution. If it works, stop. Add complexity only when justified by a concrete, current requirement — NEVER for hypothetical future needs.
|
|
10
|
-
- NEVER leave TODOs, stubs, or partial implementations.
|
|
11
|
-
- ALWAYS stay within the scope of what was asked. When asked to update a plan — only update the plan, don't change code. When asked to brainstorm/discuss — only discuss, don't write code. When asked to do X — do X and nothing else. NEVER make unsolicited changes.
|
|
12
|
-
|
|
13
|
-
## Code style
|
|
14
|
-
|
|
15
|
-
### Design (MANDATORY)
|
|
16
|
-
|
|
17
|
-
- ALWAYS prefer stupid and simple over abstract and extendable.
|
|
18
|
-
- ALWAYS prefer a bit of duplication over complex abstractions.
|
|
19
|
-
- ALWAYS prefer clarity over brevity in names.
|
|
20
|
-
- ALWAYS minimize interfaces, generics, embedding.
|
|
21
|
-
- ALWAYS prefer fewer types. Prefer no types over few. Prefer data types over types with behavior.
|
|
22
|
-
- ALWAYS prefer functions over methods. ALWAYS prefer public fields over getters/setters.
|
|
23
|
-
- ALWAYS keep everything private/internal as much as possible.
|
|
24
|
-
- ALWAYS validate early, validate a lot. ALWAYS keep APIs stupid and minimal.
|
|
25
|
-
- NEVER prefer global state. ALWAYS prefer simplicity over micro-optimizations.
|
|
26
|
-
- ALWAYS use libraries for complex things instead of reinventing the wheel.
|
|
27
|
-
- NEVER add comments unless they document a non-obvious public API or explain genuinely non-obvious logic. NEVER add obvious/redundant comments, NEVER add comments restating what code does. When in doubt, don't comment.
|
|
28
|
-
|