fix(loop): restore Slack feedback for phantom_loop runs#7
Merged
electronicBlacksmith merged 3 commits intomainfrom Apr 5, 2026
Merged
fix(loop): restore Slack feedback for phantom_loop runs#7electronicBlacksmith merged 3 commits intomainfrom
electronicBlacksmith merged 3 commits intomainfrom
Conversation
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.
Owner
Author
End-to-end verification results (2026-04-05)All tests run against a live Phantom container on the Automated tests
Slack end-to-end (manual, 4 runs)
Reaction ladder verified across all Slack runs
SQLite verificationSELECT 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)
Bugs found and fixed during verification
Migration safety
|
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 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5.
Problem
When a
phantom_loopis 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 withchannel_id=NULL, conversation_id=NULL, status_message_ts=NULL.Root cause: The feedback pipeline in
src/loop/runner.tsalready existed (postStartNotice/postTickUpdate/postFinalNotice) but every method early-returned on!loop.channelId. The agent never forwardedchannel_id/conversation_idinto 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
AsyncLocalStorageNew
src/agent/slack-context.tsexposesslackContextStore: AsyncLocalStorage<SlackContext>. The router insrc/index.tswrapsruntime.handleMessage(...)inslackContextStore.run({ slackChannelId, slackThreadTs, slackMessageTs }, ...)for Slack-origin turns. The phantom_loop start handler reads the store when the agent omitschannel_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
:hourglass_flowing_sand::arrows_counterclockwise:(keyed oniteration === 1, restart-safe by construction, no in-memory flag):white_check_mark:(done),:octagonal_sign:(stopped),:warning:(budget),:x:(failed)3. Inline unicode progress bar in the edited status message
4. New
trigger_message_tscolumnAppended 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 inserializeLoopso the agent can't clobber it on subsequent calls.5. Extracted
LoopNotifierintosrc/loop/notifications.tsrunner.tswas 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.tsis now 248 lines,notifications.tsis 152 lines.Design notes
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 innotifications.tsdocuments this.TERMINAL_REACTION: Partial<Record<LoopStatus, string>>keyed by bare emoji name.terminalEmoji()derives the:colon:format from it, so a newLoopStatusrequires updating one map, not two.SlackChannel.addReaction/removeReactionalready swallowalready_reacted/no_reactioninternally;notifications.tscorrectly relies on that rather than adding redundant try/catch.Test plan
bun run typecheckpassesbun run lintpasses (Biome)bun test938 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)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 fourchannel_id/conversation_id/status_message_ts/trigger_message_tscolumns populated:octagonal_sign:max_cost_usd=0.01, verify final reaction is:warning:/triggerHTTP endpoint, verify loop runs withchannel_id=NULLand no Slack API callsSELECT id, status FROM loops WHERE status='running';) before restarting the container