Skip to content

fix(loop): restore Slack feedback for phantom_loop runs#7

Merged
electronicBlacksmith merged 3 commits intomainfrom
fix/loop-slack-feedback
Apr 5, 2026
Merged

fix(loop): restore Slack feedback for phantom_loop runs#7
electronicBlacksmith merged 3 commits intomainfrom
fix/loop-slack-feedback

Conversation

@electronicBlacksmith
Copy link
Copy Markdown
Owner

Closes #5.

Problem

When a phantom_loop is started from a Slack message, the operator sees one "Loop started" confirmation and then nothing for the rest of the run. DB rows confirmed every loop ended with channel_id=NULL, conversation_id=NULL, status_message_ts=NULL.

Root cause: The feedback pipeline in src/loop/runner.ts already existed (postStartNotice / postTickUpdate / postFinalNotice) but every method early-returned on !loop.channelId. The agent never forwarded channel_id/conversation_id into the in-process MCP tool call, that context lived in the router layer and was never plumbed into tool handlers.

Solution

1. Runtime context injection via AsyncLocalStorage

New src/agent/slack-context.ts exposes slackContextStore: AsyncLocalStorage<SlackContext>. The router in src/index.ts wraps runtime.handleMessage(...) in slackContextStore.run({ slackChannelId, slackThreadTs, slackMessageTs }, ...) for Slack-origin turns. The phantom_loop start handler reads the store when the agent omits channel_id/conversation_id/trigger_message_ts. Explicit tool arguments still win. Non-Slack turns leave the store empty and hit existing null-guards cleanly.

2. Reaction ladder on the operator's original message

  • Start: :hourglass_flowing_sand:
  • First tick: swap to :arrows_counterclockwise: (keyed on iteration === 1, restart-safe by construction, no in-memory flag)
  • Terminal: :white_check_mark: (done), :octagonal_sign: (stopped), :warning: (budget), :x: (failed)

3. Inline unicode progress bar in the edited status message

:repeat: Loop `abcdef01` · [████░░░░░░] 4/10 · $1.20/$5.00 · in-progress

4. New trigger_message_ts column

Appended as migration #11 (ALTER TABLE loops ADD COLUMN trigger_message_ts TEXT) so deployed instances with migrations 0-10 applied are safe. Internal plumbing, deliberately not exposed in serializeLoop so the agent can't clobber it on subsequent calls.

5. Extracted LoopNotifier into src/loop/notifications.ts

runner.ts was already 303 lines (over the 300-line cap from CONTRIBUTING.md). The new responsibilities (progress bar, reaction ladder in three methods) pushed it to ~350. Split into a new module; runner.ts is now 248 lines, notifications.ts is 152 lines.

Design notes

  • Why not reuse createStatusReactionController? That controller debounces per-tool-call runtime events via a promise-chain serializer. The loop ladder is three lifecycle states (start / first tick / terminal), no debouncing needed, and coupling the two would tangle unrelated lifecycles. Inline comment in notifications.ts documents this.
  • Single source of truth for emoji mapping. TERMINAL_REACTION: Partial<Record<LoopStatus, string>> keyed by bare emoji name. terminalEmoji() derives the :colon: format from it, so a new LoopStatus requires updating one map, not two.
  • Reaction error handling: SlackChannel.addReaction/removeReaction already swallow already_reacted/no_reaction internally; notifications.ts correctly relies on that rather than adding redundant try/catch.

Test plan

  • bun run typecheck passes
  • bun run lint passes (Biome)
  • bun test 938 pass / 0 fail (+34 new tests: slack-context propagation across await/setImmediate/async-generator boundaries, LoopNotifier unit tests for progress bar/terminal emoji/reaction ladder/guards, tool.ts context-fallback + explicit-override precedence, runner.ts triggerMessageTs round-trip through store/findById, migrate.test.ts updated for 12 migrations)
  • Manual Slack reproduction: phantom_loop start, max_iterations 3, max_cost_usd 2, goal=Say hello three times... and verify (a) status message edits on every tick with advancing progress bar, (b) reaction on original message transitions hourglass → cycle → check, (c) SQLite row has all four channel_id/conversation_id/status_message_ts/trigger_message_ts columns populated
  • Stop button: start 10-iter loop, click Stop, verify final reaction is :octagonal_sign:
  • Budget: max_cost_usd=0.01, verify final reaction is :warning:
  • Non-Slack non-regression: trigger via /trigger HTTP endpoint, verify loop runs with channel_id=NULL and no Slack API calls
  • Deployment: check no running loops (SELECT id, status FROM loops WHERE status='running';) before restarting the container

Closes #5. The feedback pipeline in LoopRunner already existed but was
gated on loop.channelId, which was always null because the agent never
plumbed channel_id/conversation_id into the in-process MCP tool call,
that context only lived in the router.

- AsyncLocalStorage<SlackContext> captures the Slack channel/thread/
  trigger-message for the current turn so phantom_loop can auto-fill
  them when the agent omits them. Explicit tool args still win.
- Reaction ladder on the operator's original message: hourglass ->
  cycle -> terminal (check/stop/warning/x). Restart-safe via
  iteration === 1 check, no in-memory flag.
- Inline unicode progress bar in the edited status message.
- New trigger_message_ts column on loops, appended as migration #11.
- Extracted LoopNotifier into src/loop/notifications.ts, runner.ts
  was already at the 300-line cap.

34 new tests, 938 pass / 0 fail.
…tion

Two defects surfaced during the first Slack end-to-end test of the loop
feedback fix:

1. Stop button disappeared after the first tick. Slack's chat.update
   replaces the message wholesale and strips any blocks the caller does
   not include. postStartNotice attached the button but postTickUpdate
   called updateMessage without blocks, so the button was wiped on the
   first progress edit. Extract buildStatusBlocks() and re-send it on
   every tick edit. Final notice still omits blocks intentionally so the
   button disappears when the loop is no longer interruptible.

2. No end-of-loop summary. The agent curates the state.md body every
   tick (Goal, Progress, Next Action, Notes), but that content never
   reached the operator. Post it as a threaded reply when the loop
   finalizes. No extra agent cost: we surface content the agent already
   wrote. Frontmatter stripped, truncated at 3500 chars, silently
   skipped if the file is missing or empty.

+7 tests covering both regressions. 945 pass / 0 fail.
…l message

1. Tick update race: postTickUpdate was fire-and-forget, so a stop on
   tick N+1 could race with tick N's Slack write. If the tick update's
   HTTP response arrived after postFinalNotice, it overwrote the final
   message and re-sent the Stop button blocks. Awaiting postTickUpdate
   serializes Slack writes so finalize always runs after the last tick
   update completes.

2. Final message now includes the progress bar at its halted position,
   visually consistent with tick updates. A stopped loop at 3/10 shows
   the bar frozen at 3/10 with "stopped" instead of a terse one-liner.
@electronicBlacksmith electronicBlacksmith merged commit 97fc5c2 into main Apr 5, 2026
@electronicBlacksmith
Copy link
Copy Markdown
Owner Author

End-to-end verification results (2026-04-05)

All tests run against a live Phantom container on the fix/loop-slack-feedback branch, with a real Slack workspace.

Automated tests

  • bun run typecheck — pass
  • bun run lint — pass (Biome)
  • bun test945 pass / 0 fail (+41 new tests over baseline)

Slack end-to-end (manual, 4 runs)

# Scenario Loop ID Result
1 Happy path — 3-tick done 2a7b7310 Status message edited on each tick with progress bar. Final: :white_check_mark: reaction on original message. State.md summary posted as threaded reply.
2 Stop button — 10-iter loop, clicked Stop at tick 3 4c5a9dcc Before fix: Stop button disappeared on tick edits and reappeared on finalize (race). After fix: button persists during ticks, disappears cleanly on stop. Final: :octagonal_sign: reaction, progress bar frozen at [███░░░░░░░] 3/10 · stopped. Summary posted with partial progress.
3 Budget exceeded — $0.20 budget, hit at tick 2 ec9a7a6c Final: :warning: reaction, budget_exceeded status, progress bar at [██░░░░░░░░] 2/10. Summary posted.
4 Stop button persistence — 10-iter loop, clicked Stop at tick 3 (post-race-fix) d36e4e41 Stop button visible throughout ticks, no reappearance after finalize. Clean :octagonal_sign:.

Reaction ladder verified across all Slack runs

  • Start: :hourglass_flowing_sand: on operator's original message
  • First tick: swaps to :arrows_counterclockwise:
  • Terminal: :white_check_mark: (done) / :octagonal_sign: (stopped) / :warning: (budget_exceeded)

SQLite verification

SELECT channel_id, conversation_id, trigger_message_ts FROM loops ORDER BY started_at DESC LIMIT 3;
-- All Slack-origin loops: all four columns populated (non-NULL)

Non-Slack non-regression (HTTP /trigger endpoint)

# Scenario Loop ID Result
5 HTTP trigger — 2-tick done, no Slack origin 06e36e33 channel_id=NULL, conversation_id=NULL, trigger_message_ts=NULL. Completed with status=done, 2 iterations. Zero Slack API errors in container logs. Null guards short-circuited cleanly.

Bugs found and fixed during verification

  1. Stop button disappearing on tick edits — Slack's chat.update strips blocks not re-sent. Fixed: buildStatusBlocks() helper re-sent on every postTickUpdate call.
  2. No end-of-loop summary — State.md body (agent's curated working memory) posted as threaded reply on finalize. Zero extra agent cost.
  3. Tick/finalize race — Fire-and-forget postTickUpdate raced with postFinalNotice on stop, causing Stop button to reappear. Fixed: await tick update so finalize always runs after the last Slack write.
  4. Final message lacked progress context — Added progress bar to terminal message so operator sees where the loop halted.

Migration safety

  • ALTER TABLE loops ADD COLUMN trigger_message_ts TEXT appended as migration index 11 (verified: _migrations count = 12, no mid-array insertion)
  • Deployed to running container with existing data; migration applied cleanly on restart

electronicBlacksmith added a commit that referenced this pull request Apr 6, 2026
PR #7 was squash-merged into main while PR #9's branch still had the
original commits. Conflicts were all additive - kept PR #9's features
(checkpoint_interval, memory context, critique, post-loop pipeline)
while adopting main's improved error formatting and race condition
comment in the tick update await.
electronicBlacksmith added a commit that referenced this pull request Apr 6, 2026
PR #7 was squash-merged into main while this branch still had the
original commits. Kept all PR #9 features (checkpoint_interval,
memory context, critique, post-loop pipeline) while adopting main's
improved error formatting and race condition comment.
@electronicBlacksmith electronicBlacksmith deleted the fix/loop-slack-feedback branch April 6, 2026 23:49
electronicBlacksmith added a commit that referenced this pull request Apr 8, 2026
Closes #5.

AsyncLocalStorage context injection, reaction ladder, progress bar,
state.md summary on completion. Stop button persists across tick edits.
Tick/finalize race eliminated. LoopNotifier extracted from runner.ts.

Verified end-to-end in Slack + non-Slack trigger. 945 tests passing.
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.

Loop progress feedback in Slack is silent after start message

1 participant