@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,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("./log.js", () => ({
|
|
4
4
|
getLogger: () => ({
|
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
getAllAliases,
|
|
12
12
|
getModelFamilies,
|
|
13
13
|
getModelInfo,
|
|
14
|
+
isSubscriptionFallbackActive,
|
|
14
15
|
resolveModel,
|
|
16
|
+
setSubscriptionFallbackActive,
|
|
17
|
+
toNonSubSpec,
|
|
15
18
|
updateRegistryFromAvailableModels,
|
|
16
19
|
} from "./model-registry.js";
|
|
17
20
|
|
|
@@ -299,4 +302,48 @@ describe("model-registry", () => {
|
|
|
299
302
|
expect(findLatestFamilyMatch("anthropic/claude-opus-latest", [])).toBeNull();
|
|
300
303
|
});
|
|
301
304
|
});
|
|
305
|
+
|
|
306
|
+
describe("subscription fallback rewrite", () => {
|
|
307
|
+
beforeEach(() => setSubscriptionFallbackActive(false));
|
|
308
|
+
afterEach(() => setSubscriptionFallbackActive(false));
|
|
309
|
+
|
|
310
|
+
it("toNonSubSpec rewrites provider-prefixed sub specs", () => {
|
|
311
|
+
expect(toNonSubSpec("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("toNonSubSpec rewrites bare sub/ ids", () => {
|
|
315
|
+
expect(toNonSubSpec("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("toNonSubSpec leaves non-subscription specs unchanged", () => {
|
|
319
|
+
expect(toNonSubSpec("pp-flant-anthropic/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
320
|
+
expect(toNonSubSpec("openai/gpt-5.4")).toBe("openai/gpt-5.4");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("resolveModel leaves sub specs unchanged while fallback inactive", () => {
|
|
324
|
+
expect(isSubscriptionFallbackActive()).toBe(false);
|
|
325
|
+
expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("resolveModel rewrites sub specs to non-sub while fallback active", () => {
|
|
329
|
+
setSubscriptionFallbackActive(true);
|
|
330
|
+
expect(isSubscriptionFallbackActive()).toBe(true);
|
|
331
|
+
expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
332
|
+
expect(resolveModel("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("resolveModel does not touch non-sub specs while fallback active", () => {
|
|
336
|
+
setSubscriptionFallbackActive(true);
|
|
337
|
+
expect(resolveModel("pp-flant-openai/gpt-5-4")).toBe("pp-flant-openai/gpt-5-4");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("resolveModel rewrites a subscription alias after registry update while fallback active", () => {
|
|
341
|
+
updateRegistryFromAvailableModels([
|
|
342
|
+
"pp-flant-anthropic-sub/sub/claude-opus-4-7",
|
|
343
|
+
"pp-flant-anthropic-sub/sub/claude-opus-4-8",
|
|
344
|
+
]);
|
|
345
|
+
setSubscriptionFallbackActive(true);
|
|
346
|
+
expect(resolveModel("pp-flant-anthropic-sub/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
302
349
|
});
|
|
@@ -207,12 +207,54 @@ function findFamily(modelId: string): ModelFamilyDefinition | null {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
import { getLogger } from "./log.js";
|
|
210
|
+
import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
|
|
211
|
+
|
|
212
|
+
// Session-scoped subscription fallback. When active (set after the user accepts
|
|
213
|
+
// a sub-429 fallback), every model spec resolved through resolveModel that is
|
|
214
|
+
// subscription-routed (`pp-flant-anthropic-sub/sub/<m>` or a bare `sub/<m>`) is
|
|
215
|
+
// rewritten to the regular per-token form `pp-flant-anthropic/<m>`. This is the
|
|
216
|
+
// single lever every resolution site funnels through (main phase switchModel,
|
|
217
|
+
// subagent tool_call input.model, planner/reviewer specs, registerAgents), so a
|
|
218
|
+
// one-shot switch cannot be undone by a later phase/subagent re-resolving a
|
|
219
|
+
// `sub/` spec. Cleared when the user switches back or the task ends.
|
|
220
|
+
let subscriptionFallbackActive = false;
|
|
221
|
+
|
|
222
|
+
export function setSubscriptionFallbackActive(active: boolean): void {
|
|
223
|
+
subscriptionFallbackActive = active;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function isSubscriptionFallbackActive(): boolean {
|
|
227
|
+
return subscriptionFallbackActive;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Rewrite a subscription-routed spec to its regular per-token equivalent.
|
|
231
|
+
// Handles both `pp-flant-anthropic-sub/sub/<m>` and a bare `sub/<m>` id.
|
|
232
|
+
// Non-subscription specs pass through unchanged.
|
|
233
|
+
export function toNonSubSpec(spec: string): string {
|
|
234
|
+
if (spec.startsWith(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`)) {
|
|
235
|
+
return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`.length)}`;
|
|
236
|
+
}
|
|
237
|
+
if (spec.startsWith(`${SUB_PROVIDER}/`)) {
|
|
238
|
+
return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/`.length)}`;
|
|
239
|
+
}
|
|
240
|
+
if (spec.startsWith(SUB_MODEL_PREFIX)) {
|
|
241
|
+
return `pp-flant-anthropic/${spec.slice(SUB_MODEL_PREFIX.length)}`;
|
|
242
|
+
}
|
|
243
|
+
return spec;
|
|
244
|
+
}
|
|
210
245
|
|
|
211
246
|
export function resolveModel(aliasOrId: string): string {
|
|
212
|
-
|
|
247
|
+
let resolved = aliasMap[aliasOrId] ?? aliasOrId;
|
|
213
248
|
if (resolved !== aliasOrId) {
|
|
214
249
|
getLogger().debug({ s: "model", alias: aliasOrId, resolved }, "resolved model alias");
|
|
215
250
|
}
|
|
251
|
+
if (subscriptionFallbackActive) {
|
|
252
|
+
const rewritten = toNonSubSpec(resolved);
|
|
253
|
+
if (rewritten !== resolved) {
|
|
254
|
+
getLogger().debug({ s: "model", from: resolved, to: rewritten }, "subscription fallback rewrite");
|
|
255
|
+
resolved = rewritten;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
216
258
|
return resolved;
|
|
217
259
|
}
|
|
218
260
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"4.1.4","results":[[":pp-menu.test.ts",{"duration":5.372961000000032,"failed":false}],[":integration.test.ts",{"duration":943.253143,"failed":false}],[":phases/review.test.ts",{"duration":2.5133239999999546,"failed":false}],[":pr-comments.test.ts",{"duration":4.420278999999994,"failed":false}],[":transition-controller.test.ts",{"duration":5.222491000000005,"failed":false}],[":event-handlers.test.ts",{"duration":10.928670000000011,"failed":false}],[":ai-comment-cleanup.test.ts",{"duration":3.3210659999999947,"failed":false}],[":flant-infra.test.ts",{"duration":40.368476,"failed":false}],[":command-handlers.test.ts",{"duration":13.727003000000025,"failed":false}],[":usage-tracker.test.ts",{"duration":7.265218000000004,"failed":false}],[":doctor.test.ts",{"duration":10.992058,"failed":false}],[":context.test.ts",{"duration":8.419984999999997,"failed":false}],[":orchestrator.test.ts",{"duration":12.065533000000016,"failed":false}],[":config.test.ts",{"duration":8.647639000000026,"failed":false}],[":model-registry.test.ts",{"duration":9.934792000000002,"failed":false}],[":state.test.ts",{"duration":13.755476000000002,"failed":false}],[":commands.test.ts",{"duration":270.744763,"failed":false}],[":phases/machine.test.ts",{"duration":8.854923999999983,"failed":false}],[":pp-state-tools.test.ts",{"duration":7.878408999999991,"failed":false}],[":log.test.ts",{"duration":884.438896,"failed":false}],[":repo-utils.test.ts",{"duration":3.7061610000000016,"failed":false}],[":phases/verdict.test.ts",{"duration":4.656940000000006,"failed":false}],[":rate-limit-fallback-flow.test.ts",{"duration":6.88740700000001,"failed":false}],[":agents/prompts.test.ts",{"duration":4.860277999999994,"failed":false}],[":tracer.test.ts",{"duration":3.9392250000000004,"failed":false}],[":rate-limit-fallback.test.ts",{"duration":3.124099000000001,"failed":false}],[":validate-artifacts.test.ts",{"duration":3.718504000000003,"failed":false}],[":plannotator.test.ts",{"duration":6.168539999999993,"failed":false}],[":agents/constraints.test.ts",{"duration":2.458521999999995,"failed":false}],[":custom-footer.test.ts",{"duration":3.8825789999999927,"failed":false}],[":phases/spawn-blocking.test.ts",{"duration":54.621959000000004,"failed":false}],[":exa.test.ts",{"duration":3.8611999999999966,"failed":false}],[":spawn-cleanup.test.ts",{"duration":2.2092779999999976,"failed":false}],[":subagent-session-marker.test.ts",{"duration":1.6192010000000039,"failed":false}],[":model-version.test.ts",{"duration":5.690410999999997,"failed":false}],[":messages.test.ts",{"duration":1.798244000000004,"failed":false}],[":cbm.test.ts",{"duration":2.306488999999999,"failed":false}],[":phases/brainstorm.test.ts",{"duration":1.6732620000000225,"failed":false}],[":phases/planning.test.ts",{"duration":1.6508400000000165,"failed":false}],[":phases/implementation.test.ts",{"duration":2.0228309999999965,"failed":false}]]}
|
|
@@ -123,6 +123,119 @@ describe("Orchestrator.safeSendUserMessage", () => {
|
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
describe("Orchestrator.cancelPendingRetry", () => {
|
|
127
|
+
it("clears the pending timer, disarms the ESC interrupt, and resets the counter", () => {
|
|
128
|
+
vi.useFakeTimers();
|
|
129
|
+
const send = vi.fn();
|
|
130
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
131
|
+
const unsub = vi.fn();
|
|
132
|
+
orchestrator.errorRetryCount = 3;
|
|
133
|
+
orchestrator.pendingRetryEscUnsub = unsub;
|
|
134
|
+
orchestrator.pendingRetryTimer = setTimeout(() => send("fired"), 1000) as any;
|
|
135
|
+
|
|
136
|
+
orchestrator.cancelPendingRetry();
|
|
137
|
+
|
|
138
|
+
expect(orchestrator.pendingRetryTimer).toBeNull();
|
|
139
|
+
expect(orchestrator.pendingRetryEscUnsub).toBeNull();
|
|
140
|
+
expect(orchestrator.errorRetryCount).toBe(0);
|
|
141
|
+
expect(unsub).toHaveBeenCalledTimes(1);
|
|
142
|
+
vi.advanceTimersByTime(2000);
|
|
143
|
+
expect(send).not.toHaveBeenCalled();
|
|
144
|
+
vi.useRealTimers();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("Orchestrator.armRetryEscInterrupt", () => {
|
|
149
|
+
function makeCtxWithTerminal() {
|
|
150
|
+
let handler: ((data: string) => any) | null = null;
|
|
151
|
+
const notify = vi.fn();
|
|
152
|
+
const ctx = {
|
|
153
|
+
ui: {
|
|
154
|
+
notify,
|
|
155
|
+
onTerminalInput: (h: (data: string) => any) => {
|
|
156
|
+
handler = h;
|
|
157
|
+
return () => { handler = null; };
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return { ctx, notify, feed: (data: string) => handler?.(data) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it("cancels the pending retry on a bare ESC only", () => {
|
|
165
|
+
vi.useFakeTimers();
|
|
166
|
+
const orchestrator = new Orchestrator(makePi());
|
|
167
|
+
const { ctx, notify, feed } = makeCtxWithTerminal();
|
|
168
|
+
orchestrator.pendingRetryTimer = setTimeout(() => {}, 10000) as any;
|
|
169
|
+
orchestrator.armRetryEscInterrupt(ctx as any);
|
|
170
|
+
|
|
171
|
+
// Arrow key (ESC-prefixed sequence) must NOT cancel and must NOT be consumed.
|
|
172
|
+
const arrow = feed("\x1b[A");
|
|
173
|
+
expect(arrow).toBeUndefined();
|
|
174
|
+
expect(orchestrator.pendingRetryTimer).not.toBeNull();
|
|
175
|
+
|
|
176
|
+
// Bare ESC cancels and consumes.
|
|
177
|
+
const esc = feed("\x1b");
|
|
178
|
+
expect(esc).toEqual({ consume: true });
|
|
179
|
+
expect(orchestrator.pendingRetryTimer).toBeNull();
|
|
180
|
+
expect(notify).toHaveBeenCalled();
|
|
181
|
+
vi.useRealTimers();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("is a no-op when no retry timer is armed", () => {
|
|
185
|
+
const orchestrator = new Orchestrator(makePi());
|
|
186
|
+
const { ctx, feed } = makeCtxWithTerminal();
|
|
187
|
+
orchestrator.armRetryEscInterrupt(ctx as any);
|
|
188
|
+
expect(feed("\x1b")).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("Orchestrator.sendUserMessageWhenIdle", () => {
|
|
193
|
+
it("sends immediately when idle", () => {
|
|
194
|
+
const send = vi.fn();
|
|
195
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
196
|
+
orchestrator.active = makeActiveTask(null);
|
|
197
|
+
orchestrator.activeTaskToken = 7;
|
|
198
|
+
orchestrator.lastCtx = { isIdle: () => true };
|
|
199
|
+
|
|
200
|
+
orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 7);
|
|
201
|
+
expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("defers while busy, then sends once idle", async () => {
|
|
205
|
+
vi.useFakeTimers();
|
|
206
|
+
const send = vi.fn();
|
|
207
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
208
|
+
orchestrator.active = makeActiveTask(null);
|
|
209
|
+
orchestrator.activeTaskToken = 1;
|
|
210
|
+
let idle = false;
|
|
211
|
+
orchestrator.lastCtx = { isIdle: () => idle };
|
|
212
|
+
|
|
213
|
+
orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
|
|
214
|
+
expect(send).not.toHaveBeenCalled();
|
|
215
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
216
|
+
expect(send).not.toHaveBeenCalled();
|
|
217
|
+
idle = true;
|
|
218
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
219
|
+
expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
|
|
220
|
+
vi.useRealTimers();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("drops (does not send) when the task token changes", async () => {
|
|
224
|
+
vi.useFakeTimers();
|
|
225
|
+
const send = vi.fn();
|
|
226
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
227
|
+
orchestrator.active = makeActiveTask(null);
|
|
228
|
+
orchestrator.activeTaskToken = 1;
|
|
229
|
+
orchestrator.lastCtx = { isIdle: () => false };
|
|
230
|
+
|
|
231
|
+
orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
|
|
232
|
+
orchestrator.activeTaskToken = 2; // task switched
|
|
233
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
234
|
+
expect(send).not.toHaveBeenCalled();
|
|
235
|
+
vi.useRealTimers();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
126
239
|
describe("Orchestrator.switchModel thinking level", () => {
|
|
127
240
|
function makeCtx() {
|
|
128
241
|
return {
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
type TaskState,
|
|
14
14
|
type Phase,
|
|
15
15
|
} from "./state.js";
|
|
16
|
-
import { phasePipeline } from "./phases/machine.js";
|
|
17
16
|
import { getContextDirs, loadAllContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
|
|
18
17
|
import { brainstormSystemPrompt } from "./phases/brainstorm.js";
|
|
19
18
|
import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
|
|
@@ -24,10 +23,14 @@ import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/r
|
|
|
24
23
|
import { createExploreAgent } from "./agents/explore.js";
|
|
25
24
|
import { createLibrarianAgent } from "./agents/librarian.js";
|
|
26
25
|
import { createTaskAgent } from "./agents/task.js";
|
|
27
|
-
import {
|
|
26
|
+
import { createAdvisorAgent } from "./agents/advisor.js";
|
|
27
|
+
import { createDeepDebuggerAgent } from "./agents/deep-debugger.js";
|
|
28
|
+
import { createReviewerAgent } from "./agents/reviewer.js";
|
|
29
|
+
import { resolveModel, getModelInfo, findLatestFamilyMatch, setSubscriptionFallbackActive } from "./model-registry.js";
|
|
28
30
|
import { buildRepoContext } from "./agents/repo-context.js";
|
|
29
31
|
import { getLogger, addTaskDestination, removeTaskDestination, setLogLevel } from "./log.js";
|
|
30
32
|
import { handleSpawnResult } from "./spawn-cleanup.js";
|
|
33
|
+
import { getTracer } from "./tracer.js";
|
|
31
34
|
import { TransitionController, type TransitionHost } from "./transition-controller.js";
|
|
32
35
|
|
|
33
36
|
function isEnabled(value: { enabled?: boolean } | undefined): boolean {
|
|
@@ -70,6 +73,16 @@ export class Orchestrator {
|
|
|
70
73
|
step?: string;
|
|
71
74
|
}>();
|
|
72
75
|
staleAgentTimer: ReturnType<typeof setInterval> | null = null;
|
|
76
|
+
// Main-turn stall watchdog (BUG-2). A main turn that starts but never emits a
|
|
77
|
+
// terminal turn_end/error can wedge the session ("Working…" forever). Unlike
|
|
78
|
+
// staleAgentTimer (subagents only), this watches the MAIN session: any main-
|
|
79
|
+
// session stream/tool/turn activity refreshes mainTurnLastActivity; when a turn
|
|
80
|
+
// is in flight with no activity beyond config.performance.internals.mainTurnStale,
|
|
81
|
+
// the watchdog recovers via the idle-gated single-send path.
|
|
82
|
+
mainTurnTimer: ReturnType<typeof setInterval> | null = null;
|
|
83
|
+
mainTurnLastActivity = 0;
|
|
84
|
+
mainTurnInFlight = false;
|
|
85
|
+
mainTurnRecovering = false;
|
|
73
86
|
// Single consecutive-nudge guard (replaces the old multi-tier throttle). Reset
|
|
74
87
|
// to 0 on any productive turn; once it reaches the cap the nudges halt with one
|
|
75
88
|
// user notification.
|
|
@@ -77,10 +90,49 @@ export class Orchestrator {
|
|
|
77
90
|
nudgeHalted = false;
|
|
78
91
|
pendingSubagentSpawns = 0;
|
|
79
92
|
errorRetryCount = 0;
|
|
93
|
+
// Halts the API-error auto-retry once errorRetryCount exceeds its cap, mirroring
|
|
94
|
+
// nudgeHalted. Without this, a benign intervening turn (e.g. the retried turn
|
|
95
|
+
// ends as a text-only "I'll wait") reset errorRetryCount to 0, so the 5-retry
|
|
96
|
+
// cap never accumulated and the "Previous request failed" nudge could fire
|
|
97
|
+
// unbounded (hundreds of times) against transient errors. Cleared only on
|
|
98
|
+
// genuine (non-[PI-PI]) user re-engagement, like nudgeHalted.
|
|
99
|
+
errorNudgeHalted = false;
|
|
80
100
|
commitReminderSent = false;
|
|
81
101
|
phaseStartTime = 0;
|
|
82
102
|
pendingRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
+
// Unsubscribe for the direct ESC interrupt armed while pendingRetryTimer is
|
|
104
|
+
// live. pi-pi's own post-error retry is NOT covered by any SDK/interactive ESC
|
|
105
|
+
// binding (the turn already ended in error, the session is not streaming), so
|
|
106
|
+
// without this ESC would not cancel it.
|
|
107
|
+
pendingRetryEscUnsub: (() => void) | null = null;
|
|
83
108
|
activeTaskToken = 0;
|
|
109
|
+
// Side-channel for stale-nudge re-validation. A continuation nudge is delivered
|
|
110
|
+
// as a followUp whose prompt STRING carries no phase/task token, and the SDK
|
|
111
|
+
// queue only surfaces the string in before_agent_start. So at nudge-generation
|
|
112
|
+
// time we record {phase, taskToken} keyed by the exact nudge string; at delivery
|
|
113
|
+
// we re-check both against the live phase/token and drop the nudge on mismatch
|
|
114
|
+
// (a nudge generated for an old phase/task must not drive a turn in the new one).
|
|
115
|
+
// Last-wins per distinct string; a stale entry only ever fails-closed to drop.
|
|
116
|
+
pendingNudges = new Map<string, { phase: Phase; taskToken: number }>();
|
|
117
|
+
// Subscription rate-limit fallback (Issue 5). subFallbackActive mirrors the
|
|
118
|
+
// model-registry override flag; subFallbackDialogPending guards against
|
|
119
|
+
// opening more than one switch dialogue at a time (across main + subagents);
|
|
120
|
+
// subFallbackModelId records the sub model that hit the limit (used by the
|
|
121
|
+
// switch-back probe); subSwitchBackTimer is the fixed-interval probe timer.
|
|
122
|
+
subFallbackActive = false;
|
|
123
|
+
subFallbackDialogPending = false;
|
|
124
|
+
// True while a user-facing dialogue (ask_user / the /pp menu / any interactive
|
|
125
|
+
// selectOption) is open. The main-turn watchdog skips while set so a turn
|
|
126
|
+
// legitimately parked on a human is not aborted. Set on dialogue open, cleared
|
|
127
|
+
// in finally on every exit (resolve, ESC/cancel, error).
|
|
128
|
+
interactivePromptOpen = false;
|
|
129
|
+
// Set SYNCHRONOUSLY the moment a sub-429 is detected (before any async dialog),
|
|
130
|
+
// and cleared once the decision resolves. The autonomous planner/reviewer
|
|
131
|
+
// auto-retry consults this to avoid re-spawning a failed variant on the still-
|
|
132
|
+
// sub-routed model while the fallback decision is in flight.
|
|
133
|
+
subFallbackPendingDecision = false;
|
|
134
|
+
subFallbackModelId: string | null = null;
|
|
135
|
+
subSwitchBackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
84
136
|
userGatePending = false;
|
|
85
137
|
lastCtx: any = null;
|
|
86
138
|
failedPlannerVariants: string[] = [];
|
|
@@ -100,10 +152,16 @@ export class Orchestrator {
|
|
|
100
152
|
checkPlannerCompletion: () => void = () => {};
|
|
101
153
|
readonly transitionController: TransitionController;
|
|
102
154
|
|
|
155
|
+
// The single live instance, so module-level dialogue wrappers (selectOption in
|
|
156
|
+
// event-handlers/pp-menu) can toggle interactivePromptOpen without threading a
|
|
157
|
+
// reference through ~70 call sites.
|
|
158
|
+
static current: Orchestrator | null = null;
|
|
159
|
+
|
|
103
160
|
constructor(readonly pi: ExtensionAPI) {
|
|
104
161
|
// The controller calls pi (the main session) directly for sends, and uses the
|
|
105
162
|
// host only for live-ctx-dependent bits (compact/isIdle/currentStep).
|
|
106
163
|
this.transitionController = new TransitionController(this.makeTransitionHost(), this.pi);
|
|
164
|
+
Orchestrator.current = this;
|
|
107
165
|
}
|
|
108
166
|
|
|
109
167
|
// Live-session host the TransitionController uses for compaction/idle/step.
|
|
@@ -123,6 +181,87 @@ export class Orchestrator {
|
|
|
123
181
|
};
|
|
124
182
|
}
|
|
125
183
|
|
|
184
|
+
// Arm a direct ESC interrupt for the post-error retry window. Idempotent: a
|
|
185
|
+
// single onTerminalInput handler stays registered until the retry is delivered,
|
|
186
|
+
// cancelled, or the task is reset. While pendingRetryTimer is live, ESC cancels
|
|
187
|
+
// the pending retry (no other binding covers this window).
|
|
188
|
+
armRetryEscInterrupt(ctx: any): void {
|
|
189
|
+
if (this.pendingRetryEscUnsub) return;
|
|
190
|
+
const onTerminalInput = ctx?.ui?.onTerminalInput;
|
|
191
|
+
if (typeof onTerminalInput !== "function") return;
|
|
192
|
+
const unsub = onTerminalInput.call(ctx.ui, (data: string) => {
|
|
193
|
+
if (!this.pendingRetryTimer) return undefined;
|
|
194
|
+
// Match a STANDALONE ESC only. Arrow/function/mouse sequences also start
|
|
195
|
+
// with 0x1b (e.g. "\x1b[A"), so `includes` would misfire on navigation
|
|
196
|
+
// keys and swallow them; a bare ESC is exactly the one-byte string.
|
|
197
|
+
if (data === "\x1b") {
|
|
198
|
+
this.cancelPendingRetry();
|
|
199
|
+
ctx?.ui?.notify?.("Retry cancelled.", "info");
|
|
200
|
+
return { consume: true };
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
});
|
|
204
|
+
this.pendingRetryEscUnsub = typeof unsub === "function" ? unsub : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
disarmRetryEscInterrupt(): void {
|
|
208
|
+
if (this.pendingRetryEscUnsub) {
|
|
209
|
+
try {
|
|
210
|
+
this.pendingRetryEscUnsub();
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore unsubscribe failures
|
|
213
|
+
}
|
|
214
|
+
this.pendingRetryEscUnsub = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Cancel a pending post-error retry (timer + ESC interrupt) and reset the retry
|
|
219
|
+
// counter. Used by the ESC interrupt handler and by abort paths.
|
|
220
|
+
cancelPendingRetry(): void {
|
|
221
|
+
if (this.pendingRetryTimer) {
|
|
222
|
+
clearTimeout(this.pendingRetryTimer);
|
|
223
|
+
this.pendingRetryTimer = null;
|
|
224
|
+
}
|
|
225
|
+
this.disarmRetryEscInterrupt();
|
|
226
|
+
this.errorRetryCount = 0;
|
|
227
|
+
this.errorNudgeHalted = false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Deliver a queued message only once the main session is idle. Firing a
|
|
231
|
+
// followUp while the SDK still has an active run triggers an async, runtime-
|
|
232
|
+
// swallowed "Agent is already processing" rejection (surfaces as
|
|
233
|
+
// Extension "<runtime>" error), so we PRE-CHECK idle and DEFER (bounded poll)
|
|
234
|
+
// rather than dropping the nudge. Guarded by activeTaskToken; the poll reuses
|
|
235
|
+
// pendingRetryTimer so ESC/abort cancels it.
|
|
236
|
+
sendUserMessageWhenIdle(text: string, taskToken: number, attempt = 0): void {
|
|
237
|
+
const log = getLogger();
|
|
238
|
+
if (this.activeTaskToken !== taskToken || !this.active) {
|
|
239
|
+
this.disarmRetryEscInterrupt();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const idleFn = this.lastCtx?.isIdle;
|
|
243
|
+
const idle = typeof idleFn === "function" ? !!idleFn.call(this.lastCtx) : true;
|
|
244
|
+
if (idle) {
|
|
245
|
+
this.disarmRetryEscInterrupt();
|
|
246
|
+
this.safeSendUserMessage(text);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const MAX_ATTEMPTS = 120; // ~2min at 1s poll
|
|
250
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
251
|
+
log.warn({ s: "orchestrator", attempt }, "sendUserMessageWhenIdle gave up waiting for idle");
|
|
252
|
+
this.disarmRetryEscInterrupt();
|
|
253
|
+
this.lastCtx?.ui?.notify?.(
|
|
254
|
+
"pi-pi stopped waiting for the agent to go idle; auto-continuation was dropped. Send any message to resume.",
|
|
255
|
+
"warning",
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.pendingRetryTimer = setTimeout(() => {
|
|
260
|
+
this.pendingRetryTimer = null;
|
|
261
|
+
this.sendUserMessageWhenIdle(text, taskToken, attempt + 1);
|
|
262
|
+
}, 1000);
|
|
263
|
+
}
|
|
264
|
+
|
|
126
265
|
safeSendUserMessage(text: string): void {
|
|
127
266
|
const log = getLogger();
|
|
128
267
|
const attempt = (retries: number) => {
|
|
@@ -219,67 +358,17 @@ export class Orchestrator {
|
|
|
219
358
|
return true;
|
|
220
359
|
}
|
|
221
360
|
|
|
361
|
+
// The footer's phase/mode display (line 1) reads orchestrator state directly; this only
|
|
362
|
+
// sets a hidden "pp-phase" status whose value changes per transition so the host repaints
|
|
363
|
+
// the footer. Nothing renders this string (footer line 3 was removed), so it stays terse.
|
|
222
364
|
updateStatus(ctx: ExtensionContext): void {
|
|
223
365
|
if (!this.active || this.active.state.phase === "done") {
|
|
224
366
|
ctx.ui.setStatus("pp-phase", undefined);
|
|
225
367
|
return;
|
|
226
368
|
}
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
const step = this.active.state.step;
|
|
231
|
-
const reviewCycle = this.active.state.reviewCycle;
|
|
232
|
-
const effectiveMode = this.active.state.effectiveMode ?? this.active.state.mode;
|
|
233
|
-
const modeLabel = effectiveMode === "autonomous" ? " [autonomous]" : "";
|
|
234
|
-
|
|
235
|
-
if (type === "debug" || type === "brainstorm" || type === "quick") {
|
|
236
|
-
const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
|
|
237
|
-
const suffix = elapsed ? ` (${elapsed})` : "";
|
|
238
|
-
ctx.ui.setStatus("pp-phase", `pp: ${type}${modeLabel}${suffix}`);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const pipeline = phasePipeline(type).filter((p) => p !== "done");
|
|
243
|
-
const currentIdx = pipeline.indexOf(phase as (typeof pipeline)[number]);
|
|
244
|
-
|
|
245
|
-
const parts: string[] = [];
|
|
246
|
-
for (let i = 0; i < pipeline.length; i++) {
|
|
247
|
-
const p = pipeline[i];
|
|
248
|
-
if (i < currentIdx) {
|
|
249
|
-
parts.push(`✔ ${p}`);
|
|
250
|
-
} else if (p === phase) {
|
|
251
|
-
let detail = "";
|
|
252
|
-
if (step === "await_planners") detail = "planners";
|
|
253
|
-
else if (step === "await_reviewers") detail = "reviewers";
|
|
254
|
-
else if (step === "synthesize") detail = "synthesize";
|
|
255
|
-
else if (step === "apply_feedback") detail = "feedback";
|
|
256
|
-
else if (step === "user_gate") detail = "review";
|
|
257
|
-
|
|
258
|
-
if (reviewCycle) {
|
|
259
|
-
const kind = reviewCycle.kind === "plannotator" ? "plannotator" : "review";
|
|
260
|
-
detail = `${kind} #${reviewCycle.pass}`;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
|
|
264
|
-
const sub = [detail, elapsed].filter(Boolean).join(", ");
|
|
265
|
-
parts.push(sub ? `${p} (${sub})` : p);
|
|
266
|
-
} else {
|
|
267
|
-
parts.push(p);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}${modeLabel}`);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private formatElapsed(startTime: number): string {
|
|
275
|
-
const sec = Math.floor((Date.now() - startTime) / 1000);
|
|
276
|
-
if (sec < 60) return `${sec}s`;
|
|
277
|
-
const min = Math.floor(sec / 60);
|
|
278
|
-
const remSec = sec % 60;
|
|
279
|
-
if (min < 60) return remSec > 0 ? `${min}m ${remSec}s` : `${min}m`;
|
|
280
|
-
const hr = Math.floor(min / 60);
|
|
281
|
-
const remMin = min % 60;
|
|
282
|
-
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
|
369
|
+
const s = this.active.state;
|
|
370
|
+
const cycle = s.reviewCycle ? `:${s.reviewCycle.kind}#${s.reviewCycle.pass}` : "";
|
|
371
|
+
ctx.ui.setStatus("pp-phase", `${this.active.type}:${s.phase}:${s.step}:${getEffectivePhaseMode(s)}${cycle}`);
|
|
283
372
|
}
|
|
284
373
|
|
|
285
374
|
getPlanStartState(taskDir: string, plannerPresetName?: string): { step: string; shouldSpawnPlanners: boolean } {
|
|
@@ -370,7 +459,8 @@ export class Orchestrator {
|
|
|
370
459
|
this.lastCtx = ctx;
|
|
371
460
|
await this.transitionController.requestTransition({
|
|
372
461
|
kind: "done",
|
|
373
|
-
|
|
462
|
+
discard: true,
|
|
463
|
+
summary: `A new, unrelated ${type} task is starting. The previous task is finished — DISCARD its entire conversation. Do NOT carry forward, reference, or act on any prior task's messages, phase, plan, or aborted turns; treat the new task as a clean slate.`,
|
|
374
464
|
});
|
|
375
465
|
}
|
|
376
466
|
|
|
@@ -539,9 +629,11 @@ export class Orchestrator {
|
|
|
539
629
|
this.agentLifecycle.clear();
|
|
540
630
|
this.pendingSubagentSpawns = 0;
|
|
541
631
|
this.errorRetryCount = 0;
|
|
632
|
+
this.errorNudgeHalted = false;
|
|
542
633
|
this.commitReminderSent = false;
|
|
543
634
|
this.consecutiveNudges = 0;
|
|
544
635
|
this.nudgeHalted = false;
|
|
636
|
+
this.pendingNudges.clear();
|
|
545
637
|
this.phaseStartTime = 0;
|
|
546
638
|
this.userGatePending = false;
|
|
547
639
|
this.failedPlannerVariants = [];
|
|
@@ -552,10 +644,34 @@ export class Orchestrator {
|
|
|
552
644
|
clearTimeout(this.pendingRetryTimer);
|
|
553
645
|
this.pendingRetryTimer = null;
|
|
554
646
|
}
|
|
647
|
+
this.disarmRetryEscInterrupt();
|
|
555
648
|
if (this.staleAgentTimer) {
|
|
556
649
|
clearInterval(this.staleAgentTimer);
|
|
557
650
|
this.staleAgentTimer = null;
|
|
558
651
|
}
|
|
652
|
+
if (this.mainTurnTimer) {
|
|
653
|
+
clearInterval(this.mainTurnTimer);
|
|
654
|
+
this.mainTurnTimer = null;
|
|
655
|
+
}
|
|
656
|
+
this.mainTurnInFlight = false;
|
|
657
|
+
this.mainTurnRecovering = false;
|
|
658
|
+
this.clearSubscriptionFallback();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Reset the subscription rate-limit fallback: cancel the switch-back probe
|
|
662
|
+
// timer, clear the model-registry override, and reset guards. Called on task
|
|
663
|
+
// reset/cleanup so the sticky override never leaks across tasks.
|
|
664
|
+
clearSubscriptionFallback(): void {
|
|
665
|
+
if (this.subSwitchBackTimer) {
|
|
666
|
+
clearTimeout(this.subSwitchBackTimer);
|
|
667
|
+
this.subSwitchBackTimer = null;
|
|
668
|
+
}
|
|
669
|
+
this.subFallbackActive = false;
|
|
670
|
+
this.subFallbackDialogPending = false;
|
|
671
|
+
this.interactivePromptOpen = false;
|
|
672
|
+
this.subFallbackPendingDecision = false;
|
|
673
|
+
this.subFallbackModelId = null;
|
|
674
|
+
setSubscriptionFallbackActive(false);
|
|
559
675
|
}
|
|
560
676
|
|
|
561
677
|
async cleanupActive(): Promise<void> {
|
|
@@ -578,7 +694,10 @@ export class Orchestrator {
|
|
|
578
694
|
const log = getLogger();
|
|
579
695
|
const explore = createExploreAgent(this.config);
|
|
580
696
|
const librarian = createLibrarianAgent(this.config);
|
|
581
|
-
const taskAgent = createTaskAgent(this.config
|
|
697
|
+
const taskAgent = createTaskAgent(this.config);
|
|
698
|
+
const advisor = createAdvisorAgent(this.config);
|
|
699
|
+
const deepDebugger = createDeepDebuggerAgent(this.config);
|
|
700
|
+
const reviewer = createReviewerAgent(this.config);
|
|
582
701
|
const phase = this.active?.state.phase;
|
|
583
702
|
const repos = this.active?.state.repos ?? [];
|
|
584
703
|
log.debug({ s: "agents", phase, repoCount: repos.length }, "registering agent definitions");
|
|
@@ -615,6 +734,24 @@ export class Orchestrator {
|
|
|
615
734
|
...taskAgent,
|
|
616
735
|
prompt: appendContext("task", taskAgent.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.task.model))),
|
|
617
736
|
},
|
|
737
|
+
{
|
|
738
|
+
type: "advisor",
|
|
739
|
+
variant: null,
|
|
740
|
+
...advisor,
|
|
741
|
+
prompt: appendContext("advisor", advisor.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.advisor.model))),
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
type: "deep-debugger",
|
|
745
|
+
variant: null,
|
|
746
|
+
...deepDebugger,
|
|
747
|
+
prompt: appendContext("deep-debugger", deepDebugger.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple["deep-debugger"].model))),
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
type: "reviewer",
|
|
751
|
+
variant: null,
|
|
752
|
+
...reviewer,
|
|
753
|
+
prompt: appendContext("reviewer", reviewer.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.reviewer.model))),
|
|
754
|
+
},
|
|
618
755
|
]);
|
|
619
756
|
}
|
|
620
757
|
|