Skip to content

feat(gadgets): add Finish gadget for early agent termination (#83)#84

Merged
zbigniewsobiecki merged 1 commit intomainfrom
dev
Jan 18, 2026
Merged

feat(gadgets): add Finish gadget for early agent termination (#83)#84
zbigniewsobiecki merged 1 commit intomainfrom
dev

Conversation

@zbigniewsobiecki
Copy link
Copy Markdown
Member

Summary

Merges dev to main with the Finish gadget implementation.

Changes

  • Adds a Finish gadget that allows agents to cleanly end their session early
  • Agents call Finish when they've completed all tasks, which throws TaskCompletionSignal from llmist
  • Prevents agents from looping "I have completed the task" until hitting maxIterations

Implementation

  • Created src/gadgets/Finish.ts using the standard Gadget() factory pattern
  • Added Finish to base gadgets in src/agents/base.ts
  • Uses llmist's built-in TaskCompletionSignal for clean termination

🤖 Generated with Claude Code

Agents that complete work before maxIterations had no way to signal
completion, causing them to loop saying "I have completed the task"
until hitting the iteration limit. The Finish gadget allows agents
to cleanly end their session early by throwing TaskCompletionSignal.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
@zbigniewsobiecki zbigniewsobiecki merged commit 292917c into main Jan 18, 2026
6 checks passed
zbigniewsobiecki added a commit that referenced this pull request Apr 27, 2026
…inish in detached HEAD (#1215)

* fix(finish): use ls-remote SHA comparison so PR-checkout agents can finish in detached HEAD

`hasUnpushedCommits()` was wedging every PR-checkout agent (`respond-to-review`,
`respond-to-pr-comment`, `respond-to-ci`, `review`) when called from the
`cascade-tools session finish` gadget. Workers check out PRs in detached HEAD
via `refs/pull/N/head`, where `git rev-parse --abbrev-ref HEAD` returns the
literal string "HEAD" and `@{upstream}` is unset. The legacy fallback computed
`git rev-list origin/HEAD..HEAD --count` (commits not on the default branch),
which always returns >0 for any feature branch — so finish always rejected
with "Cannot finish session without pushing changes" even when the work was
fully pushed.

Live incident: ucho PR #84 stayed wedged for 22 m on 2026-04-27. The agent
pushed `b10ff8a`, called finish three times, gave up, and emitted text but no
further tool calls. Container stayed alive (lock held); subsequent reviews on
the same PR all hit `Awaiting worker slot: in-memory same-type: 1 enqueued`
until the watchdog backstop killed it at 22 m 44 s.

Fix: thread the PR HEAD branch from `AgentInput.prBranch` into the finish
gadget, then compare local HEAD to the remote tip via `git ls-remote
origin refs/heads/<branch>`. Robust to detached HEAD (never reads the local
branch name).

Plumbing covers both engine paths:
- In-process llmist gadget: `agentInput.prBranch` → `createConfiguredBuilder`
  → `initSessionState` → `SessionState.prBranch` → `validateFinish`.
- CLI subprocess (codex / opencode and the in-container `cascade-tools session
  finish`): `agentInput.prBranch` → sidecarManager sets `CASCADE_PR_BRANCH`
  env → CLI reads env → `validateFinish`.

Security hardening: `prBranch` flows from `payload.pull_request.head.ref`
(GitHub-controlled). Git's ref-format rules permit `;`, `$`, `&`, `|`, `(`,
`)`, backticks etc., so the branch name MUST NOT be shell-interpolated. Both
the new ls-remote call and the legacy fallback's `origin/<branch>..HEAD`
interpolation now go through `execFileSync` — branch is a single argv element,
no `/bin/sh -c`, no metacharacter expansion.

Operator visibility: added `logger.warn` on both fail-closed paths
(ls-remote failure + rev-parse failure) with the underlying error so future
incidents can distinguish network/auth issues from genuinely unpushed work
without re-grepping logs.

Tests:
- `tests/unit/gadgets/session/core/finish.test.ts`: 8 new cases covering the
  ls-remote happy path, mismatch, missing remote branch, fail-closed semantics,
  the warn-log assertion, the detached-HEAD-trap negative assertion, and two
  command-injection regression pins (one for the new path, one for the legacy
  fallback) that assert the branch name lives in its own argv slot and never
  in any `execSync` shell-string call.
- `tests/unit/gadgets/session/core/finish-real-git.test.ts` (NEW): real-git
  regression net — no mocks. Builds a bare remote + working repo, performs
  `git checkout --detach <sha>` to mirror the production `refs/pull/N/head`
  shape, and exercises the production code path end-to-end. Includes a
  documented assertion that the legacy path (no prBranch) IS still broken
  in detached HEAD — pinning the original bug so any future "simplification"
  back into the trap fails loudly.
- `tests/unit/gadgets/sessionState.test.ts`: round-trip + clear-on-reinit
  coverage for the new `prBranch` field.

Also extracts `checkPushedChangesHook` from `validateFinish` to keep the
function under the project's complexity threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(vitest): bump unit hookTimeout to 30s to fix beforeAll dynamic-import flakes

Three PM manifest tests (jira, linear, trello) do `await import('.../index.js')`
in `beforeAll` — ~2.3s when isolated but well over the default 10s under the
parallel-fork CPU pressure of the full pre-push run. Caused intermittent
red builds across the repo (not just this branch).

Matches the integration project's existing 30s `hookTimeout`. `testTimeout`
left at the 5s default — that limit is for per-test logic, not module-load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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