Skip to content

fix(hooks): block to_in_progress for completed tasks (fixes #218)#491

Open
groot-guo wants to merge 1 commit into
cline:mainfrom
groot-guo:fix/auto-review-state-flip-flop
Open

fix(hooks): block to_in_progress for completed tasks (fixes #218)#491
groot-guo wants to merge 1 commit into
cline:mainfrom
groot-guo:fix/auto-review-state-flip-flop

Conversation

@groot-guo

Copy link
Copy Markdown

After a completion hook event (TaskComplete, stop, subagentstop, afteragent) triggers to_review with reviewReason=hook, the auto-commit PreToolUse hook fires to_in_progress which incorrectly resumes the task. Both used reviewReason=hook, so the old guard could not tell apart ask_followup_question (should resume) from completion events (should NOT resume).

Add a guard that checks latestHookActivity.hookEventName against a set of completion hook event names. Block to_in_progress for these; allow for PreToolUse (ask_followup_question), null (backward compat), attention, and error review reasons.

After a completion hook event (TaskComplete, stop, subagentstop,
afteragent) triggers to_review with reviewReason=hook, the auto-commit
PreToolUse hook fires to_in_progress which incorrectly resumes the
task. Both used reviewReason=hook, so the old guard could not tell
apart ask_followup_question (should resume) from completion events
(should NOT resume).

Add a guard that checks latestHookActivity.hookEventName against a
set of completion hook event names. Block to_in_progress for these;
allow for PreToolUse (ask_followup_question), null (backward compat),
attention, and error review reasons.
@greptile-apps

greptile-apps Bot commented May 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a bug where a completed task could be incorrectly flipped back to in_progress by the auto-commit PreToolUse hook. The root cause was that both the completion path and the ask_followup_question path set reviewReason = \"hook\", making them indistinguishable by the old guard.

  • Adds a COMPLETION_HOOK_EVENT_NAMES set (TaskComplete, stop, subagentstop, afteragent) and checks latestHookActivity.hookEventName inside the reviewReason === \"hook\" branch to block to_in_progress for completion events while allowing it for PreToolUse and null (backward compat).
  • Adds 8 new focused unit tests covering every branch of the new guard (all four completion names blocked, PreToolUse allowed, null allowed, attention and error allowed).

Confidence Score: 4/5

The fix correctly addresses the specific auto-commit regression; the only concern is that the guard reads from a mutable field that later activity events can overwrite.

The fix works well for the described scenario. The fragility is that applyHookActivity merges string fields from any subsequent hook event, meaning an intermediate activity or blocked to_in_progress carrying a non-completion hookEventName in its metadata would silently overwrite the field and let the next to_in_progress through. This is a latent issue rather than a currently broken path.

src/trpc/hooks-api.ts — specifically the interaction between the new guard and applyHookActivity in the blocked path

Important Files Changed

Filename Overview
src/trpc/hooks-api.ts Adds COMPLETION_HOOK_EVENT_NAMES set and refactors canTransitionTaskForHookEvent to block to_in_progress when latestHookActivity.hookEventName is a completion event; guard state is mutable via applyHookActivity from subsequent hook calls
test/runtime/trpc/hooks-api.test.ts Adds comprehensive tests for the new guard covering all four completion event names, PreToolUse, null backward-compat, attention, and error; missing coverage for exit and interrupted review reasons
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/trpc/hooks-api.ts:44-53
**Guard relies on mutable `latestHookActivity`**

The `canTransitionTaskForHookEvent` guard reads `latestHookActivity.hookEventName` to distinguish completion hooks from PreToolUse hooks, but `applyHookActivity` merges any string-valued `hookEventName` from subsequent events into that same field (line 842–843 of `session-manager.ts`). Any blocked `to_in_progress` or `activity` event that arrives with metadata containing a `hookEventName` outside `COMPLETION_HOOK_EVENT_NAMES` will silently overwrite the field, causing the next `to_in_progress` to pass the guard even on a completed task. A safer approach is to store the hook event name that triggered the review as a dedicated, write-once field (e.g. `reviewHookEventName`) so it cannot be clobbered by later activity updates.

### Issue 2 of 2
test/runtime/trpc/hooks-api.test.ts:169-257
**Missing tests for `exit` and `interrupted` review reasons**

The new test block covers `attention`, `error`, and `hook`, but `reviewReason` can also be `"exit"` or `"interrupted"` (per `runtimeTaskSessionReviewReasonSchema`). Both fall through all the `if` branches in the new code and return `false`, matching the old behavior. Tests for these cases would lock in that expectation and prevent a future contributor from accidentally adding them to the allow-list.

Reviews (1): Last reviewed commit: "fix(hooks): block to_in_progress for com..." | Re-trigger Greptile

Comment thread src/trpc/hooks-api.ts
Comment on lines +44 to +53
// Hook-triggered reviews: disallow to_in_progress when the hook that
// triggered review was a completion event (TaskComplete, stop, etc.).
// This prevents auto-commit PreToolUse from flipping completed tasks back to in_progress.
if (summary.reviewReason === "hook") {
const hookEventName = summary.latestHookActivity?.hookEventName ?? null;
if (hookEventName && COMPLETION_HOOK_EVENT_NAMES.has(hookEventName)) {
return false;
}
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Guard relies on mutable latestHookActivity

The canTransitionTaskForHookEvent guard reads latestHookActivity.hookEventName to distinguish completion hooks from PreToolUse hooks, but applyHookActivity merges any string-valued hookEventName from subsequent events into that same field (line 842–843 of session-manager.ts). Any blocked to_in_progress or activity event that arrives with metadata containing a hookEventName outside COMPLETION_HOOK_EVENT_NAMES will silently overwrite the field, causing the next to_in_progress to pass the guard even on a completed task. A safer approach is to store the hook event name that triggered the review as a dedicated, write-once field (e.g. reviewHookEventName) so it cannot be clobbered by later activity updates.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/trpc/hooks-api.ts
Line: 44-53

Comment:
**Guard relies on mutable `latestHookActivity`**

The `canTransitionTaskForHookEvent` guard reads `latestHookActivity.hookEventName` to distinguish completion hooks from PreToolUse hooks, but `applyHookActivity` merges any string-valued `hookEventName` from subsequent events into that same field (line 842–843 of `session-manager.ts`). Any blocked `to_in_progress` or `activity` event that arrives with metadata containing a `hookEventName` outside `COMPLETION_HOOK_EVENT_NAMES` will silently overwrite the field, causing the next `to_in_progress` to pass the guard even on a completed task. A safer approach is to store the hook event name that triggered the review as a dedicated, write-once field (e.g. `reviewHookEventName`) so it cannot be clobbered by later activity updates.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 169 to 257
ref: "refs/kanban/checkpoints/task-1/turn/1",
});
});

describe("to_in_progress guard for hook-triggered reviews", () => {
function makeManager(summary: RuntimeTaskSessionSummary) {
return {
getSummary: vi.fn(() => summary),
transitionToReview: vi.fn(),
transitionToRunning: vi.fn(() => summary),
applyHookActivity: vi.fn(),
} as unknown as TerminalSessionManager;
}

async function ingestToInProgress(summary: RuntimeTaskSessionSummary) {
const manager = makeManager(summary);
const api = createHooksApi({
getWorkspacePathById: vi.fn(() => "/tmp/repo"),
ensureTerminalManagerForWorkspace: vi.fn(async () => manager),
broadcastRuntimeWorkspaceStateUpdated: vi.fn(),
broadcastTaskReadyForReview: vi.fn(),
});

const response = await api.ingest({
taskId: "task-1",
workspaceId: "workspace-1",
event: "to_in_progress",
});

return { response, manager };
}

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=TaskComplete", async () => {
const summary = createAwaitingReviewSummary("hook", "TaskComplete");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=stop", async () => {
const summary = createAwaitingReviewSummary("hook", "stop");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=afteragent", async () => {
const summary = createAwaitingReviewSummary("hook", "afteragent");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=subagentstop", async () => {
const summary = createAwaitingReviewSummary("hook", "subagentstop");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=PreToolUse (ask_followup_question)", async () => {
const summary = createAwaitingReviewSummary("hook", "PreToolUse");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=hook and latestHookActivity=null (backward compat)", async () => {
const summary = createAwaitingReviewSummary("hook", null);
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=attention (user returned)", async () => {
const summary = createAwaitingReviewSummary("attention", null);
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=error (error recovery)", async () => {
const summary = createAwaitingReviewSummary("error", null);
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing tests for exit and interrupted review reasons

The new test block covers attention, error, and hook, but reviewReason can also be "exit" or "interrupted" (per runtimeTaskSessionReviewReasonSchema). Both fall through all the if branches in the new code and return false, matching the old behavior. Tests for these cases would lock in that expectation and prevent a future contributor from accidentally adding them to the allow-list.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/runtime/trpc/hooks-api.test.ts
Line: 169-257

Comment:
**Missing tests for `exit` and `interrupted` review reasons**

The new test block covers `attention`, `error`, and `hook`, but `reviewReason` can also be `"exit"` or `"interrupted"` (per `runtimeTaskSessionReviewReasonSchema`). Both fall through all the `if` branches in the new code and return `false`, matching the old behavior. Tests for these cases would lock in that expectation and prevent a future contributor from accidentally adding them to the allow-list.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant