fix(FN-1989): stabilize triage fallback
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
|||||||
|
|
||||||
> fusion-workspace@0.0.0 typecheck /Users/eclipxe/Projects/kb/.worktrees/swift-finch
|
|
||||||
> pnpm -r typecheck
|
|
||||||
|
|
||||||
Scope: 12 of 13 workspace projects
|
|
||||||
packages/core typecheck$ tsc --noEmit
|
|
||||||
packages/desktop typecheck$ tsc --noEmit
|
|
||||||
packages/mobile typecheck$ tsc --noEmit
|
|
||||||
packages/mobile typecheck: Done
|
|
||||||
packages/desktop typecheck: Done
|
|
||||||
packages/core typecheck: Done
|
|
||||||
packages/engine typecheck$ tsc --noEmit
|
|
||||||
packages/plugin-sdk typecheck$ tsc --noEmit --skipLibCheck
|
|
||||||
packages/tui typecheck$ tsc --noEmit
|
|
||||||
packages/plugin-sdk typecheck: Done
|
|
||||||
packages/tui typecheck: Done
|
|
||||||
packages/engine typecheck: Done
|
|
||||||
packages/dashboard typecheck$ tsc --noEmit && tsc --noEmit -p tsconfig.app.json
|
|
||||||
packages/dashboard typecheck: Done
|
|
||||||
packages/cli typecheck$ tsc --noEmit
|
|
||||||
packages/cli typecheck: Done
|
|
||||||
EXIT_CODE: 0
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# Quality Gate Inventory - FN-1756
|
|
||||||
|
|
||||||
**Task:** Rebaseline ESLint scope and capture failing quality-gate inventory
|
|
||||||
**Date:** 2026-04-15
|
|
||||||
**Captured at:** 01:01 UTC
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-Change Baseline (Before ESLint Config Changes)
|
|
||||||
|
|
||||||
### `pnpm lint`
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| **Exit Code** | 0 (pass) |
|
|
||||||
| **Total Warnings** | 1,149 |
|
|
||||||
| **Errors** | 0 |
|
|
||||||
| **Potentially Fixable** | 6 warnings with `--fix` |
|
|
||||||
|
|
||||||
#### Top Warning Categories
|
|
||||||
|
|
||||||
| Rule | Count | Files Affected |
|
|
||||||
|------|-------|----------------|
|
|
||||||
| `@typescript-eslint/no-explicit-any` | ~900+ | Multiple files |
|
|
||||||
| `@typescript-eslint/no-unused-vars` | ~100+ | Multiple files |
|
|
||||||
| `no-empty` | ~40+ | usage.ts, executor.ts |
|
|
||||||
| `no-control-regex` | ~15 | usage.ts |
|
|
||||||
| `no-useless-escape` | 2 | agent-instructions.ts |
|
|
||||||
| `prefer-const` | 1 | executor.ts |
|
|
||||||
| `no-useless-catch` | 1 | executor.ts |
|
|
||||||
|
|
||||||
#### High-Volume Files by Warning Count
|
|
||||||
|
|
||||||
| File | Warning Count | Primary Issue |
|
|
||||||
|------|---------------|---------------|
|
|
||||||
| `packages/engine/src/executor.ts` | ~60+ | `no-explicit-any` in mock/test helpers |
|
|
||||||
| `packages/dashboard/src/usage.ts` | ~40+ | `no-explicit-any`, `no-empty`, `no-control-regex` |
|
|
||||||
| `packages/dashboard/src/sse.ts` | ~30+ | `no-explicit-any` in SSE type handlers |
|
|
||||||
| `packages/engine/src/merger.ts` | ~15+ | `no-explicit-any` in helpers |
|
|
||||||
| `packages/engine/src/self-healing.ts` | ~25+ | `no-explicit-any` in healing logic |
|
|
||||||
| `packages/dashboard/vitest.setup.ts` | ~10+ | `no-explicit-any` in test setup |
|
|
||||||
| `packages/engine/src/project-engine.ts` | ~20+ | `no-explicit-any` in engine wiring |
|
|
||||||
|
|
||||||
### `pnpm test`
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| **Exit Code** | 0 (all pass) |
|
|
||||||
| **Total Test Suites** | 440 passed |
|
|
||||||
| **Total Tests** | 10,743 passed, 11 skipped |
|
|
||||||
| **Failed Tests** | 0 |
|
|
||||||
|
|
||||||
#### Package Breakdown
|
|
||||||
|
|
||||||
| Package | Test Files | Tests | Duration |
|
|
||||||
|---------|------------|-------|----------|
|
|
||||||
| @fusion/core | 58 | 2,409 | 41s |
|
|
||||||
| @fusion/desktop | 14 | 136 | 1.5s |
|
|
||||||
| @fusion/mobile | 3 | 58 | 0.3s |
|
|
||||||
| @fusion/engine | 57 | 2,165 | 9.7s |
|
|
||||||
| @fusion/plugin-sdk | 1 | 9 | 0.2s |
|
|
||||||
| @fusion/tui | 4 | 79 | 1.9s |
|
|
||||||
| @fusion/dashboard | 253 | 7,132 | 136s |
|
|
||||||
| @fusion-plugin-examples/* | 4 | 82 | 0.8s |
|
|
||||||
| @gsxdsm/fusion (cli) | 35 | 699 | 18s |
|
|
||||||
|
|
||||||
### `pnpm typecheck`
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| **Exit Code** | 0 (pass) |
|
|
||||||
| **Packages Checked** | 12 |
|
|
||||||
| **Type Errors** | 0 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-Change Status (After ESLint Config Changes)
|
|
||||||
|
|
||||||
### Changes Made
|
|
||||||
|
|
||||||
1. **Added `packages/dashboard/app/test/**` to global ignores**
|
|
||||||
- Test support files are now completely excluded from linting
|
|
||||||
|
|
||||||
2. **Moved `packages/dashboard/vitest.setup.ts` to dedicated test-support override**
|
|
||||||
- Created a separate config block BEFORE production config
|
|
||||||
- Disabled `@typescript-eslint/no-explicit-any` for test setup files
|
|
||||||
- Test setup files still get other lint checks (unused vars, etc.)
|
|
||||||
|
|
||||||
3. **Removed `vitest.setup.ts` from production files block**
|
|
||||||
- Prevents test-support file from being linted with production rules
|
|
||||||
|
|
||||||
4. **Added comprehensive inline comments**
|
|
||||||
- Documented why each scope exists
|
|
||||||
- Explained global-ordering rule, test-only relaxations, Node globals, SW globals
|
|
||||||
|
|
||||||
### `pnpm lint` - After Changes
|
|
||||||
|
|
||||||
| Metric | Before | After | Change |
|
|
||||||
|--------|--------|-------|--------|
|
|
||||||
| **Total Warnings** | 1,149 | 1,139 | -10 (0.9% reduction) |
|
|
||||||
| **Errors** | 0 | 0 | — |
|
|
||||||
| **vitest.setup.ts** | 9 warnings | 0 warnings | REMOVED |
|
|
||||||
| **app/test/** | 1 warning | 0 warnings | REMOVED |
|
|
||||||
|
|
||||||
### Verification Tests
|
|
||||||
|
|
||||||
| Test | Result |
|
|
||||||
|------|--------|
|
|
||||||
| `pnpm exec eslint scripts/dev-with-memory.mjs fix.cjs packages/dashboard/app/public/sw.js --max-warnings=0` | ✅ PASS |
|
|
||||||
| `pnpm exec eslint packages/dashboard/vitest.setup.ts` | ✅ No no-explicit-any warnings |
|
|
||||||
| `pnpm exec eslint packages/engine/src/runtimes/child-process-runtime.test.ts` | ✅ Properly ignored |
|
|
||||||
| `pnpm exec eslint packages/mobile/src/plugins/deep-links.ts` | ✅ No warnings (properly typed) |
|
|
||||||
|
|
||||||
### Remaining Pre-Existing Warnings (Production Source)
|
|
||||||
|
|
||||||
The remaining 1,139 warnings are from **production source files** that legitimately use `any` types:
|
|
||||||
- Event handlers and SSE type signatures
|
|
||||||
- Error handling and plugin runner utilities
|
|
||||||
- Mock helper functions in engine utilities
|
|
||||||
|
|
||||||
These are intentional and represent production code quality debt, not test noise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Analysis Notes
|
|
||||||
|
|
||||||
### Primary Lint Noise Source
|
|
||||||
The dominant lint warning is `@typescript-eslint/no-explicit-any`, accounting for ~900+ of the 1,149 total warnings. These occur in:
|
|
||||||
|
|
||||||
1. **Test files** (`*.test.ts`, `*.spec.ts`, `vitest.setup.ts`) - where `any` types are common in mocks and test helpers
|
|
||||||
2. **Dashboard SSE handlers** (`sse.ts`) - type-safe event handling requires `any` for generic payloads
|
|
||||||
3. **Engine utility functions** - Error handling, event wiring, and plugin runners commonly use `any`
|
|
||||||
|
|
||||||
### Strategy for Scoping
|
|
||||||
The current `eslint.config.mjs` already has some scoping in place:
|
|
||||||
- Global `ignores` at top
|
|
||||||
- Production TS rules with `no-explicit-any` set to `warn`
|
|
||||||
- Node scripts with relaxed rules
|
|
||||||
- Demo/Plugin files with `no-explicit-any` disabled
|
|
||||||
- Service worker files with browser globals
|
|
||||||
|
|
||||||
### Remaining Work
|
|
||||||
- Test files under `packages/engine/src/**/*.test.ts` are still linted with `no-explicit-any` warnings
|
|
||||||
- Dashboard test support files (`vitest.setup.ts`, `app/test/**`) need explicit scoping
|
|
||||||
- Some production utility files legitimately use `any` and should remain with warnings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Commands
|
|
||||||
|
|
||||||
### Targeted Lint Tests
|
|
||||||
```bash
|
|
||||||
# Test Node scripts and SW
|
|
||||||
pnpm exec eslint scripts/dev-with-memory.mjs fix.cjs packages/dashboard/app/public/sw.js --max-warnings=0
|
|
||||||
|
|
||||||
# Verify test file is scoped out
|
|
||||||
pnpm exec eslint packages/engine/src/runtimes/child-process-runtime.test.ts
|
|
||||||
|
|
||||||
# Verify production source still lints
|
|
||||||
pnpm exec eslint packages/mobile/src/plugins/deep-links.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Artifacts Location
|
|
||||||
|
|
||||||
- `artifacts/pnpm-lint.before.log` - Full lint output
|
|
||||||
- `artifacts/pnpm-test.before.log` - Full test output
|
|
||||||
- `artifacts/pnpm-typecheck.before.log` - Full typecheck output
|
|
||||||
@@ -3385,7 +3385,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
const task = await scopedStore.getTask(req.params.id);
|
const task = await scopedStore.getTask(req.params.id);
|
||||||
const retrySpecification =
|
const retrySpecification =
|
||||||
task.column === "triage" &&
|
task.column === "triage" &&
|
||||||
(task.status === "specifying" || task.status === "needs-respecify" || (task.stuckKillCount ?? 0) > 0);
|
(task.status === "failed" ||
|
||||||
|
task.status === "specifying" ||
|
||||||
|
task.status === "needs-respecify" ||
|
||||||
|
(task.stuckKillCount ?? 0) > 0);
|
||||||
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification) {
|
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification) {
|
||||||
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
|
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,6 +507,54 @@ describe("createKbAgent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back during prompt when the primary model has an auth failure", async () => {
|
||||||
|
const primaryPrompt = vi.fn().mockRejectedValue(new Error("401 unauthorized: invalid api key"));
|
||||||
|
const fallbackPrompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const primaryDispose = vi.fn();
|
||||||
|
|
||||||
|
createAgentSessionMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
session: {
|
||||||
|
prompt: primaryPrompt,
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
dispose: primaryDispose,
|
||||||
|
setThinkingLevel: vi.fn(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
session: {
|
||||||
|
prompt: fallbackPrompt,
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
setThinkingLevel: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createKbAgent } = await import("./pi.js");
|
||||||
|
|
||||||
|
const { session } = await createKbAgent({
|
||||||
|
cwd: "/tmp",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
defaultProvider: "zai",
|
||||||
|
defaultModelId: "glm-5.1",
|
||||||
|
fallbackProvider: "openai-codex",
|
||||||
|
fallbackModelId: "gpt-5.3-codex",
|
||||||
|
});
|
||||||
|
|
||||||
|
await (session as any).promptWithFallback("make a spec");
|
||||||
|
|
||||||
|
expect(primaryPrompt).toHaveBeenCalledWith("make a spec");
|
||||||
|
expect(primaryDispose).toHaveBeenCalled();
|
||||||
|
expect(fallbackPrompt).toHaveBeenCalledWith("make a spec");
|
||||||
|
expect(createAgentSessionMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
model: { provider: "zai", id: "glm-5.1" },
|
||||||
|
}));
|
||||||
|
expect(createAgentSessionMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
model: { provider: "openai-codex", id: "gpt-5.3-codex" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("enables auto-compaction to prevent context-window overflow", async () => {
|
it("enables auto-compaction to prevent context-window overflow", async () => {
|
||||||
const { createKbAgent } = await import("./pi.js");
|
const { createKbAgent } = await import("./pi.js");
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ function isRetryableModelSelectionError(message: string): boolean {
|
|||||||
return normalized.includes("rate limit")
|
return normalized.includes("rate limit")
|
||||||
|| normalized.includes("too many requests")
|
|| normalized.includes("too many requests")
|
||||||
|| normalized.includes("429")
|
|| normalized.includes("429")
|
||||||
|
|| normalized.includes("401")
|
||||||
|
|| normalized.includes("403")
|
||||||
|
|| normalized.includes("unauthorized")
|
||||||
|
|| normalized.includes("forbidden")
|
||||||
|
|| normalized.includes("authentication")
|
||||||
|
|| normalized.includes("invalid api key")
|
||||||
|
|| normalized.includes("invalid key")
|
||||||
|
|| normalized.includes("api key")
|
||||||
|| normalized.includes("overloaded")
|
|| normalized.includes("overloaded")
|
||||||
|| normalized.includes("quota")
|
|| normalized.includes("quota")
|
||||||
|| normalized.includes("capacity")
|
|| normalized.includes("capacity")
|
||||||
|
|||||||
@@ -1385,6 +1385,55 @@ describe("taskCreate tool model inheritance", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("bounded recovery retries for triage", () => {
|
describe("bounded recovery retries for triage", () => {
|
||||||
|
it("marks triage failed when the agent exits without calling review_spec", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "FN-202",
|
||||||
|
description: "Test triage task",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as unknown as Task;
|
||||||
|
|
||||||
|
const store = createMockStore({
|
||||||
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateKbAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
sessionManager: {
|
||||||
|
getLeafId: vi.fn().mockReturnValue(null),
|
||||||
|
navigateTree: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { promptWithFallback } = await import("./pi.js");
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, "/test/root", {
|
||||||
|
pollIntervalMs: 100_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await processor.specifyTask(task);
|
||||||
|
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-202", expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("review_spec was never called"),
|
||||||
|
recoveryRetryCount: null,
|
||||||
|
nextRecoveryAt: null,
|
||||||
|
}));
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-202",
|
||||||
|
expect.stringContaining("Specification failed: spec review not approved"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("sets recoveryRetryCount and nextRecoveryAt on first transient error via specifyTask", async () => {
|
it("sets recoveryRetryCount and nextRecoveryAt on first transient error via specifyTask", async () => {
|
||||||
const task = {
|
const task = {
|
||||||
id: "FN-200",
|
id: "FN-200",
|
||||||
@@ -1456,6 +1505,38 @@ describe("taskCreate tool model inheritance", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("recovery due-time gating (nextRecoveryAt)", () => {
|
describe("recovery due-time gating (nextRecoveryAt)", () => {
|
||||||
|
it("skips failed triage tasks until they are explicitly retried", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "FN-102",
|
||||||
|
description: "Failed triage task",
|
||||||
|
column: "triage",
|
||||||
|
status: "failed",
|
||||||
|
error: "Specification failed",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as unknown as Task;
|
||||||
|
|
||||||
|
const store = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue([task]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, "/test/root", {
|
||||||
|
pollIntervalMs: 100_000,
|
||||||
|
});
|
||||||
|
const specifySpy = vi.spyOn(processor, "specifyTask");
|
||||||
|
|
||||||
|
processor.start();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
processor.stop();
|
||||||
|
|
||||||
|
expect(specifySpy).not.toHaveBeenCalled();
|
||||||
|
specifySpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips triage tasks whose nextRecoveryAt is in the future", async () => {
|
it("skips triage tasks whose nextRecoveryAt is in the future", async () => {
|
||||||
const future = new Date(Date.now() + 60_000).toISOString();
|
const future = new Date(Date.now() + 60_000).toISOString();
|
||||||
const task = {
|
const task = {
|
||||||
@@ -2043,6 +2124,8 @@ describe("pause-abort status clearing (bug fix)", () => {
|
|||||||
navigateTree: vi.fn(),
|
navigateTree: vi.fn(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { promptWithFallback } = await import("./pi.js");
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockReturnValueOnce(disposePromise);
|
||||||
|
|
||||||
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
||||||
const processor = new TriageProcessor(store, "/tmp/root");
|
const processor = new TriageProcessor(store, "/tmp/root");
|
||||||
@@ -2093,6 +2176,8 @@ describe("stuck task detector integration", () => {
|
|||||||
navigateTree: vi.fn(),
|
navigateTree: vi.fn(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { promptWithFallback } = await import("./pi.js");
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockReturnValueOnce(disposePromise);
|
||||||
|
|
||||||
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
||||||
const processor = new TriageProcessor(store, "/tmp/root");
|
const processor = new TriageProcessor(store, "/tmp/root");
|
||||||
|
|||||||
@@ -541,6 +541,9 @@ export class TriageProcessor {
|
|||||||
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused
|
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused
|
||||||
// Skip tasks awaiting manual plan approval — they should not be auto-discovered
|
// Skip tasks awaiting manual plan approval — they should not be auto-discovered
|
||||||
&& t.status !== "awaiting-approval"
|
&& t.status !== "awaiting-approval"
|
||||||
|
// Skip failed specifications until the user explicitly retries them.
|
||||||
|
&& t.status !== "failed"
|
||||||
|
&& t.status !== "stuck-killed"
|
||||||
// Skip tasks with a recovery backoff that hasn't elapsed yet
|
// Skip tasks with a recovery backoff that hasn't elapsed yet
|
||||||
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
|
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
|
||||||
);
|
);
|
||||||
@@ -687,7 +690,7 @@ export class TriageProcessor {
|
|||||||
projectRootDir: this.rootDir,
|
projectRootDir: this.rootDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { session } = await createKbAgent({
|
let { session } = await createKbAgent({
|
||||||
cwd: this.rootDir,
|
cwd: this.rootDir,
|
||||||
systemPrompt: triageSystemPrompt,
|
systemPrompt: triageSystemPrompt,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
@@ -790,6 +793,22 @@ export class TriageProcessor {
|
|||||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||||
checkSessionError(session);
|
checkSessionError(session);
|
||||||
|
|
||||||
|
if (this.pauseAborted.has(task.id)) {
|
||||||
|
this.pauseAborted.delete(task.id);
|
||||||
|
triageLog.log(`${task.id} aborted by pause — clearing status`);
|
||||||
|
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||||
|
await this.store.updateTask(task.id, { status: restoreStatus }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stuckAborted.has(task.id)) {
|
||||||
|
this.stuckAborted.delete(task.id);
|
||||||
|
triageLog.log(`${task.id} killed by stuck detector — clearing status for retry`);
|
||||||
|
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
|
||||||
|
await this.store.updateTask(task.id, { status: restoreStatus }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (createdSubtasksRef.current.length > 0) {
|
if (createdSubtasksRef.current.length > 0) {
|
||||||
const childTaskIds = createdSubtasksRef.current.join(", ");
|
const childTaskIds = createdSubtasksRef.current.join(", ");
|
||||||
await this.store.logEntry(
|
await this.store.logEntry(
|
||||||
@@ -801,6 +820,85 @@ export class TriageProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const planningFallbackProvider = settings.planningFallbackProvider;
|
||||||
|
const planningFallbackModelId = settings.planningFallbackModelId;
|
||||||
|
const canRetryWithPlanningFallback =
|
||||||
|
specReviewVerdictRef.current !== "APPROVE" &&
|
||||||
|
planningFallbackProvider &&
|
||||||
|
planningFallbackModelId &&
|
||||||
|
modelDesc !== `${planningFallbackProvider}/${planningFallbackModelId}`;
|
||||||
|
|
||||||
|
if (canRetryWithPlanningFallback) {
|
||||||
|
const verdictDesc =
|
||||||
|
specReviewVerdictRef.current === null
|
||||||
|
? "review_spec was never called"
|
||||||
|
: `verdict was ${specReviewVerdictRef.current}`;
|
||||||
|
const fallbackDesc = `${planningFallbackProvider}/${planningFallbackModelId}`;
|
||||||
|
triageLog.warn(
|
||||||
|
`${task.id} primary planning model produced no approved spec (${verdictDesc}) — retrying with fallback ${fallbackDesc}`,
|
||||||
|
);
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Primary planning model produced no approved spec (${verdictDesc}) — retrying with fallback ${fallbackDesc}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
session.dispose();
|
||||||
|
this.activeSessions.delete(task.id);
|
||||||
|
stuckDetector?.untrackTask(task.id);
|
||||||
|
specReviewVerdictRef.current = null;
|
||||||
|
approvedCommentFingerprintRef.current = "";
|
||||||
|
|
||||||
|
const fallbackResult = await createKbAgent({
|
||||||
|
cwd: this.rootDir,
|
||||||
|
systemPrompt: triageSystemPrompt,
|
||||||
|
tools: "coding",
|
||||||
|
customTools,
|
||||||
|
onText: agentLogger.onText,
|
||||||
|
onThinking: agentLogger.onThinking,
|
||||||
|
onToolStart: agentLogger.onToolStart,
|
||||||
|
onToolEnd: agentLogger.onToolEnd,
|
||||||
|
defaultProvider: planningFallbackProvider,
|
||||||
|
defaultModelId: planningFallbackModelId,
|
||||||
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
|
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
session = fallbackResult.session;
|
||||||
|
const fallbackModelDesc = describeModel(session);
|
||||||
|
triageLog.log(`${task.id}: using fallback model ${fallbackModelDesc}`);
|
||||||
|
await this.store.logEntry(task.id, `Triage using fallback model: ${fallbackModelDesc}`);
|
||||||
|
await this.store.appendAgentLog(
|
||||||
|
task.id,
|
||||||
|
`Triage using fallback model: ${fallbackModelDesc}`,
|
||||||
|
"text",
|
||||||
|
undefined,
|
||||||
|
"triage",
|
||||||
|
);
|
||||||
|
|
||||||
|
sessionRef.current = session;
|
||||||
|
this.activeSessions.set(task.id, session);
|
||||||
|
stuckDetector?.trackTask(task.id, session);
|
||||||
|
stuckDetector?.recordActivity(task.id);
|
||||||
|
|
||||||
|
await promptWithFallback(
|
||||||
|
session,
|
||||||
|
agentPrompt,
|
||||||
|
imageContents.length > 0 ? { images: imageContents } : undefined,
|
||||||
|
);
|
||||||
|
checkSessionError(session);
|
||||||
|
|
||||||
|
if (createdSubtasksRef.current.length > 0) {
|
||||||
|
const childTaskIds = createdSubtasksRef.current.join(", ");
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Converted into subtasks: ${childTaskIds}`,
|
||||||
|
);
|
||||||
|
await this.store.deleteTask(task.id);
|
||||||
|
triageLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Post-session APPROVE gate: only advance to todo when the spec
|
// Post-session APPROVE gate: only advance to todo when the spec
|
||||||
// reviewer explicitly approved. Any other verdict (REVISE,
|
// reviewer explicitly approved. Any other verdict (REVISE,
|
||||||
// RETHINK, UNAVAILABLE) or a missing review (null) keeps the task
|
// RETHINK, UNAVAILABLE) or a missing review (null) keeps the task
|
||||||
@@ -810,17 +908,21 @@ export class TriageProcessor {
|
|||||||
specReviewVerdictRef.current === null
|
specReviewVerdictRef.current === null
|
||||||
? "review_spec was never called"
|
? "review_spec was never called"
|
||||||
: `verdict was ${specReviewVerdictRef.current}`;
|
: `verdict was ${specReviewVerdictRef.current}`;
|
||||||
|
const failureMessage =
|
||||||
|
`Specification failed: spec review not approved (${verdictDesc}). ` +
|
||||||
|
"Retry after adjusting the task prompt or model.";
|
||||||
triageLog.log(
|
triageLog.log(
|
||||||
`${task.id} spec review not approved (${verdictDesc}) — not moving to todo`,
|
`${task.id} spec review not approved (${verdictDesc}) — marking specification failed`,
|
||||||
);
|
);
|
||||||
await this.store.logEntry(
|
await this.store.logEntry(
|
||||||
task.id,
|
task.id,
|
||||||
`Spec review not approved (${verdictDesc}) — specification not approved`,
|
failureMessage,
|
||||||
);
|
);
|
||||||
// For re-specification, keep the needs-respecify status so it can be retried
|
|
||||||
// For new specs, clear the status
|
|
||||||
await this.store.updateTask(task.id, {
|
await this.store.updateTask(task.id, {
|
||||||
status: isRespecify ? "needs-respecify" : null,
|
status: "failed",
|
||||||
|
error: failureMessage,
|
||||||
|
recoveryRetryCount: null,
|
||||||
|
nextRecoveryAt: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user