From a402fd95fc74354514452d9f3dd905647263222d Mon Sep 17 00:00:00 2001 From: selimerunkut Date: Mon, 27 Jul 2026 19:23:20 +0300 Subject: [PATCH] fix: pause blocked goal inspection loops --- CHANGELOG.md | 5 ++++ src/goal-runtime-agent-handlers.ts | 36 +++++++++++++++++++++++++ src/goal-runtime-event-handler-types.ts | 5 +++- test/continuation.test.ts | 30 +++++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f62b38e..54e2b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Pause active goals when an automatic continuation only inspects goal status and makes no actionable progress, preventing blocked goals from requeueing hidden continuations indefinitely. +- Add regression coverage for goal-only inspection continuation loops. + ## 0.1.38 - 2026-07-19 - Make Pi the sole compaction owner: remove the extension's hardcoded 50k `turn_end` trigger so Pi's effective compaction settings determine threshold behavior and host/extension races cannot produce `Already compacted` or interrupt an active goal. diff --git a/src/goal-runtime-agent-handlers.ts b/src/goal-runtime-agent-handlers.ts index d82ad7e..81f9a7e 100644 --- a/src/goal-runtime-agent-handlers.ts +++ b/src/goal-runtime-agent-handlers.ts @@ -9,6 +9,34 @@ import { } from "./goal-runtime-event-utils.js"; import type { GoalRuntimeAgentHandlerContext } from "./goal-runtime-event-handler-types.js"; +function isGoalInspectionToolName(name: unknown): boolean { + return name === "get_goal" || (typeof name === "string" && name.endsWith("__get_goal")); +} + +function hasOnlyGoalInspectionToolCalls(messages: readonly { role?: string; content?: unknown }[]): boolean { + let toolCallCount = 0; + + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + continue; + } + for (const block of message.content) { + if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "toolCall") { + continue; + } + toolCallCount += 1; + if (!isGoalInspectionToolName((block as { name?: unknown }).name)) { + return false; + } + } + } + + return toolCallCount > 0; +} + +const BLOCKED_GOAL_INSPECTION_REASON = + "active goal continuation made no actionable progress; user input is required"; + export function createAgentEventHandlers(deps: GoalRuntimeAgentHandlerContext) { const { runtimeState, stateController, continuation, goalAccounting, resetErrorRecovery } = deps; @@ -48,6 +76,14 @@ export function createAgentEventHandlers(deps: GoalRuntimeAgentHandlerContext) { if (lastAssistant && recordAssistantContextOverflow(lastAssistant, ctx, deps)) { return; } + if (hasOnlyGoalInspectionToolCalls(event.messages)) { + stateController.applyGoalTransition( + { kind: "recovery_pause", recoveryReason: BLOCKED_GOAL_INSPECTION_REASON }, + ctx, + ); + return; + } + resetErrorRecovery(); continuation.maybeContinue(ctx); }) satisfies ExtensionHandler, diff --git a/src/goal-runtime-event-handler-types.ts b/src/goal-runtime-event-handler-types.ts index b5d21ec..9f404b8 100644 --- a/src/goal-runtime-event-handler-types.ts +++ b/src/goal-runtime-event-handler-types.ts @@ -131,7 +131,10 @@ export interface GoalRuntimeTurnHandlerContext extends StaleQueuedWorkEffectCont export interface GoalRuntimeAgentHandlerContext extends StaleQueuedWorkEffectContext { runtimeState: Pick; - stateController: Pick; + stateController: Pick< + GoalStateController, + "applyGoalTransition" | "beginOverflowRecovery" | "flushGoalPersistence" | "pauseForAbort" + >; continuation: Pick< GoalRuntimeContinuationPort, "clearPassthroughContinuationInput" | "maybeContinue" diff --git a/test/continuation.test.ts b/test/continuation.test.ts index ffd6cea..81af13e 100644 --- a/test/continuation.test.ts +++ b/test/continuation.test.ts @@ -299,6 +299,36 @@ test("agent_end, not agent_settled, drives deliberate per-run goal continuation" assert.equal(harness.sentMessages.length, 1); }); +test("goal-only inspection turns pause active goals instead of looping", async () => { + const harness = createRuntimeHarness(); + await harness.runCommand("ship it"); + harness.sentMessages.length = 0; + + const inspectionTurn = { + ...assistantMessage("toolUse", { input: 20, output: 4 }), + content: [ + { + type: "toolCall" as const, + id: "get-goal-call", + name: "get_goal", + arguments: {}, + }, + ], + }; + + await harness.emit("agent_end", { + type: "agent_end", + messages: [inspectionTurn, assistantMessage("stop", { input: 30, output: 12 })], + }); + + assert.equal(harness.snapshot().goal?.status, "paused"); + assert.equal(harness.sentMessages.length, 0); + assert.match( + harness.footerStatuses.at(-1) ?? "", + /Goal needs attention.*no actionable progress/, + ); +}); + test("agent end waits for idle before continuing active goals", async () => { mock.timers.enable({ apis: ["setTimeout"] }); try {