Skip to content

fix(api,org): stop auto-wake cutting short long tool-call sessions - #2584

Merged
philwinder merged 4 commits into
mainfrom
feature/002091-stop-auto-wake-from
Jun 11, 2026
Merged

fix(api,org): stop auto-wake cutting short long tool-call sessions#2584
philwinder merged 4 commits into
mainfrom
feature/002091-stop-auto-wake-from

Conversation

@philwinder

@philwinder philwinder commented Jun 11, 2026

Copy link
Copy Markdown
Member

Summary

Worker sessions running any tool call longer than ~60s (docs-writing,
git push, npm install, large file writes) were being interrupted by
a "↻ Retried" / "Incomplete interaction" banner and re-prompted. The
agent was healthy — the chat UI was showing the rendered transcript of
events that already arrived — but the API saw no streamed ACP events
during the tool's execution and concluded the session was stuck.

Two compounding bugs, fixed at the right layer each:

  1. Org layer: Spawner.ActivationTimeout = 5 min was applied to
    both ensureSession and pollUntilDone. When the timer fired on a
    long-running healthy session, the per-Worker activation.Queue
    lane released and the next pending trigger spawned a fresh "decoy"
    interaction on top of the still-running session. That decoy
    (state=waiting, empty response_message, NULL response_entries)
    matched the auto-wake worker's SQL filter perfectly.
  2. Session layer: defaultAutoWakeStuckThreshold = 60s is shorter
    than typical synchronous tool durations. The streaming-context
    gate's lastPublish decayed past 60s during a normal 90s tool call,
    and the gate failed by ~1 second.

Changes

api/pkg/org/infrastructure/runtime/helix/spawner.go

  • Renamed SpawnerConfig.ActivationTimeoutSessionStartupTimeout
    (default 5 min). Applied only to ensureSession and pre-session
    work (project apply, MCP attach, secret injection).
  • Added SpawnerConfig.ActivationRunawayGuard (default 24h, not
    operator-tunable). Applied only to pollUntilDone. Pure
    resource-safety backstop, not a liveness threshold.
  • Split the context in Spawner body: shared parentCtx carries the
    bearer token; startupCtx bounds startup; pollCtx bounds the
    poll loop. Lane stays held until the session API reports terminal
    status — correct serialisation behaviour.
  • Org layer no longer makes any decision about session liveness. That
    responsibility lives at the session layer.

api/pkg/server/auto_wake_stuck_interactions.go

  • defaultAutoWakeStuckThreshold: 60s → 180s. Covers typical
    synchronous tool durations with 3× margin on the observed ~61s gap.
  • Rewrote the comment block above the constant to document why 180s
    was picked and what the empirical false-positive mode was.
  • Rewrote the comment block at maybeAutoWake's streaming-context
    gate to retract the claim that "tool-call cascades touch lastPublish
    on every event" — true for cascades of short tools, false for a
    single long tool.

Tests

  • Two new focused unit tests on Spawner pin the startup/poll split:
    • TestSpawnerSessionStartupTimeoutBoundsStartup — hanging
      StartSession fires SessionStartupTimeout before the much
      larger ActivationRunawayGuard would.
    • TestSpawnerPollPhaseNotBoundedBySessionStartupTimeout — poll
      loop runs past SessionStartupTimeout boundary and terminates
      only at ActivationRunawayGuard. Direct regression test for
      the decoy-spawning bug.
  • TestSpawnerTimeoutEmitsExitError retargeted at
    ActivationRunawayGuard.
  • Two new unit tests on autoWakeStuckThreshold() pin the 180s
    default and the env-var override path.
  • Two cold-start tests' Created fixtures bumped from -90s to
    -4 * time.Minute so they still clear the new threshold.

Docs

  • design/2026-06-11-auto-wake-tool-call-fix.md captures root-cause
    analysis, why the layering matters, the rejected LivenessProbe
    approach, and test coverage.

Verification

  • go build ./api/pkg/org/... ./api/pkg/server/... — clean.
  • All 15 TestSpawner* tests pass (including the two new ones).
  • All AutoWake* tests pass.
  • E2E verified in inner Helix (after restarting api + frontend to
    pick up the changes):
    • Startup log confirms Phase 1 live:
      [AUTO_WAKE] Started auto-wake worker ... stuck_threshold=180000.
    • Created spec task Website #1 with a sleep 200 && echo "tool finished"
      tool-call payload. Agent ran the full 200-second sleep.
    • Throughout ~10 minutes of session lifetime: exactly one interaction
      row, auto_wake_count=0, transitioned cleanly from state=waiting
      state=complete. No decoy interaction ever spawned, including
      well past the old 5-minute ActivationTimeout boundary.
    • Zero AUTO_WAKE log entries in the 12-minute test window.
    • UI shows the agent's final reply ("It printed: tool finished")
      with no "↻ Retried" or "Incomplete interaction" banner.
    • Screenshots in
      design/tasks/002091_the-worker-sessions-are/screenshots/.

Operator notes

  • Operators currently running with
    HELIX_AUTO_WAKE_STUCK_THRESHOLD_SECONDS=600 (or any value above
    180) as a manual mitigation can drop the override after merge —
    180s is the new safe default.
  • ActivationTimeout was a code-level config field with no documented
    env-var binding (verified via grep -rn HELIX_ACTIVATION_TIMEOUT .). The rename is invisible to operators.
  • ActivationRunawayGuard is intentionally NOT operator-tunable.
    24h is generous-but-finite; tuning it shorter re-introduces the
    decoy-spawning failure mode.

Spec task

002091
design.md, requirements.md, tasks.md and the reviewer-flagged
relayering of the fix.


🔗 Open in Helix

📋 Spec:

🚀 Built with Helix

The 60s threshold false-positives on common synchronous tool calls
(git push, npm install, find /, gh pr view) because ACP session/update
events fire around tool calls, not during them. A single long tool
produces no streamed events for its entire duration, so the
streaming-context gate decays past 60s and the worker re-prompts a
healthy in-flight session.

180s covers the empirically observed gaps with a 3x margin on common
slow-tool durations. The load-bearing fix is at the org layer
(spawner no longer releases its serialisation lane on a stale
5-minute ActivationTimeout); this is defence in depth at the
session layer.

Refs: helix-specs design/tasks/002091_the-worker-sessions-are/

Spec-Ref: helix-specs@3eefc4b9b:002091_the-worker-sessions-are
The 5-minute ActivationTimeout was applied to both ensureSession and
pollUntilDone via a single shared context. When the deadline fired on
a long-running but healthy session (docs-writing, large bundles, slow
remote pushes), the spawner returned context.DeadlineExceeded, the
per-Worker Queue lane released, and the next pending trigger spawned
a decoy interaction on top of the still-running session. The
session-layer auto-wake worker then mistook that decoy for a stuck
row and fired an unnecessary re-prompt, interrupting the agent
mid-flight.

The fix splits the deadline by phase:

- SessionStartupTimeout (5min default) bounds ensureSession and the
  pre-session work (project apply, MCP attach, secret injection).
  Five minutes is generous; a hang here is a real failure.
- ActivationRunawayGuard (24h default, not operator-tunable) bounds
  pollUntilDone as a pure resource-safety backstop. The Queue lane
  stays held until the session API reports terminal status, which is
  the correct serialisation behaviour.

Stuck-session detection lives at the session layer (auto-wake worker),
not the org layer. The org layer's job is to serialise per-Worker and
trust the session API.

Production wiring (helix_org.go) does not set ActivationTimeout —
inherits the new defaults transparently. Tests that set
ActivationTimeout=N (test fixture safety blanket) are remapped to
ActivationRunawayGuard=N since they were exercising the poll-loop
deadline. The startErr-path test maps to SessionStartupTimeout
because ensureSession returns immediately via error.

New tests TestSpawnerSessionStartupTimeoutBoundsStartup and
TestSpawnerPollPhaseNotBoundedBySessionStartupTimeout pin the split.

Refs: helix-specs design/tasks/002091_the-worker-sessions-are/

Spec-Ref: helix-specs@46785c552:002091_the-worker-sessions-are
Captures the root cause (decoy interaction + sub-tool-duration
threshold), the layered fix (org-layer split, session-layer bump),
the rejected LivenessProbe approach, and the test coverage. Per
CLAUDE.md convention so the notes survive context compaction.

Spec-Ref: helix-specs@6840a3a7b:002091_the-worker-sessions-are
…uto-wake-from

Spec-Ref: helix-specs@793ab1e40:002091_the-worker-sessions-are
@philwinder
philwinder merged commit d1ed0c1 into main Jun 11, 2026
1 check passed
@philwinder
philwinder deleted the feature/002091-stop-auto-wake-from branch June 11, 2026 09:31
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