Skip to content

Agent engine: turn & thread state-machine correctness (7 PRs) #20

Description

@ahueb

Verified defects in the agent engine's turn and thread state machine. All live in src/agent/simulation.py (plus state.py, message_log.py, agent.py), all run in the agent-run container, so they share one reviewer and one test surface.

Originally verified at origin/main @ b7edcbc (2026-07-30). Re-verified 2026-08-11 against the open PR-stack tip (issue-29-authorship-grounding @ b1d54da = main + #30/#31/#32); line numbers below refer to that tree. Items marked fixed in stack land when those PRs merge — do not re-fix them.

Priority (triage 2026-08-11)

Tier 1 — live-harm, do next after the #30/#31/#32 stack merges: E4 (COR-10 kills the whole sim on one transient network error; its DB-inbound variant also silently loses the PI message), then E2's COR-2 (a single @-mention closes a thread as "timeout" with zero replies), E3 (any human — including another lab's PI — clears a review block; a duplicate PI-guidance row is persisted per restart), and E5's COR-9(b), which re-verification upgraded to a confidentiality defect.
Tier 3 — batch with other engine work: E7, E1 remainder, E6 remainder, COR-8.

Suggested order: E4 → E2 → E3 → E5 → E7 → E1 → E6. Land against the stack, not main, to avoid simulation.py conflicts.

PR E1 — Thread-outcome state-machine integrity (small)

  • COR-1 — partially fixed in stack. _post_message is now -> bool (:3234) and all four call sites gate on it (:1453, :2329, :2349, :2391); the ThreadNotFound-evict path returns False before any LogEntry, so the phantom ProposalRef/ThreadDecision/PI-DM chain is closed. Remaining: the swallowed-SlackApiError half — _post_one returns None on any non-thread_not_found error (slack_client.py:709-711), after which _post_message mints a local id, persists the row and returns True (:3384-3387), so a connected client whose post failed is indistinguishable from the Slack-off path: the turn counts, threads close and proposals mint for a message absent from Slack. Also _evict_dead_thread (:1747-1783) still never purges the message log and does _closed_thread_ids.discard (it un-closes a Slack-dead thread) — low severity now that no phantom entry is written; and _check_private_channel_outcome runs outside the posted guard (:2415-2420), a latent re-instance of the same mechanism on the private path. Fix: signal the Slack failure distinctly from the mock path; purge the log on evict; move the private outcome check under the posted guard.
  • COR-3_check_thread_outcome (:1486-1497) finalizes on any against the first prior other-agent :memo: in reversed history — no adjacency/recency check, so an incidental "✅ done" finalizes a stale memo. The private sibling (:1638-1651) explicitly added a false-finalize defense ("without this a casual ✅ could finalize the un-revised proposal") — the codebase knows this failure class and hardened only the private path. Fix: require the memo be the most-recent other-agent message / newer than our last.
  • COR-4 — public path matches only raw (:1486); private path matches and :white_check_mark: (:1633). Within the same function the pause signal accepts both forms (:1522), so two of three markers are dual-form and only ✅ is single-form. Consequence is a missed finalization → the thread grinds to the 12-message timeout close — lost yield, not corrupt data (medium). Fix: one shared marker check for both paths.
  • COR-7_close_thread's _prior_threads append (:1544-1548) lacks dedup; the dict carries no thread_id, so dedup isn't even expressible, while the pending_proposals sibling 30 lines down filters by p.thread_id (:1558-1561). The DB-rebuild path is guarded and tested (tests/integration/test_state_rebuild.py:207-231), leaving the in-process append the only hole — and since each reclose writes a fresh ThreadDecision, duplicates legitimately reappear after restart. Severity low (prompt bloat). Fix: carry thread_id in the entry and dedup; idempotency-guard _close_thread.

PR E2 — Scan-cursor semantics + Phase-3 activation offset (small-medium)

  • COR-6_run_turn sets last_seen_cursor = time.time() (:992, wall clock) while MessageLog since-filters use posted_at <= since (message_log.py:198/310/338/426). Narrower than originally filed: turns are serialized, so in-process agents can't append mid-turn — the live loss window is external writers (Slack humans, the web app, and GrantBot, a separate process minting posted_at from its own clock) plus cross-process clock skew (which message_log.py:222 itself flags). PI-origin rows are mitigated (the pollers apply side effects directly); bot-origin rows are not — a GrantBot funding post can be invisible to an agent forever. Phase 4 uses the same cursor (:1261) and its own comment concedes "The cursor advances unconditionally each turn, so has_new can't be relied on" (:1268-1273). Fix: advance from message_log.latest_timestamp (:436-445) — which _rebuild_agent_state (:4286-4289) already uses.
  • COR-2 — Phase-3 activation builds ThreadState with no message_count_offset (:1178-1184 tag path, :1219-1225 reply path), so _reply_to_thread recomputes message_count (:1307) and closes an activated ≥12-message thread as "timeout" (:1320) before composing anything. The reopen paths do set the offset (:2943/:2954/:5017/:5028) — proving the omission. Reachability is strongest where it matters most: funding threads are open-to-all (get_thread_allowed_agents returns None, message_log.py:363), so the allowed-set guard doesn't block a newly tagged agent, and multi-party funding threads cross 12 messages quickly. Net effect of one @-mention: a ThreadDecision(outcome="timeout") row, a PI DM, two memory events — and zero replies. Fix: set the offset on Phase-3 activation.

PR E3 — Durable + authorized PI reviews and reopens (medium)

  • COR-5_check_pi_proposal_review (:2895-2908) matches only thread_id, flips reviewed=True for all agents with no sender check, and persists nothing → dashboard/email keep showing "unreviewed" (both read ProposalReview rows, which this never writes) and _rebuild_agent_state (:4181-4189) re-blocks on restart. The Slack call site runs outside the pi_agent_ids loop (:2734-2760), so any workspace human clears it; on the DB path the web writer takes free-form thread_ts (agent_page.py:955-1010) and pi_may_post_to_channel allows any non-private channel — so any authenticated PI can clear another lab's block. Fix: require the sender be the owning PI; insert a ProposalReview.
  • COR-13 — two DB→memory readers use different keys — rebuild (thread_decision_id, agent_id) (:4144-4151) vs per-tick (agent_id, thread_id) (:4892) — because ProposalRef carries only thread_id (state.py:50-58); after a re-propose cycle the rebuild blocks and the next tick silently unblocks. The rating-0 web reopen is deduped only by the in-memory _db_reopened_thread_ids (initialised empty at :303, never seeded from DB), and the reopen mints a fresh id and appends a synthetic PI-guidance entry that is persisted (:4988-5003) → one extra persisted PI-guidance row per restart, plus a fresh reply budget each time. Slack-native _reopen_thread (:2910-2960) writes nothing durable, and the rebuild re-closes every ThreadDecision thread (:4051-4072), erasing it on restart. Fix: add thread_decision_id to ProposalRef; unify the review key; persist reopen/dedup state.

PR E4 — Poller & LLM-log-flush robustness (trivial)

  • COR-10 — three parts, re-rated individually:
    1. _poll_pi_dms calls poll_dm_messages unguarded (:2982); _call_with_retry catches only SlackApiError (slack_client.py:310-341; the SDK re-raises timeouts/SSL/DNS), and the poller runs outside the turn try (:643 vs :706-710) → a transient socket error kills the whole sim (one-off container → no restart; main.py:268-270 logs and the run ends). Both sibling pollers prove the intended per-item except Exception pattern (:2792, :3160). High.
    2. No DM poll throttle — re-rated low: each agent polls with its own token, the DM channel id is cached, and tick cadence is floored by the idle backoff.
    3. _poll_inbound_from_db advances the cursor (:2832-2833) and appends to the log (:2850) before the unguarded handler call (:2853) → on a raise the run dies and the side effect is permanently lost, because the lookback re-scan dedups on the now-present log entry (:2836-2840). Worse than originally rated: silent data loss on top of the crash. Fix: wrap both like the sibling pollers; apply side effects before (or transactionally with) the cursor advance.
  • COR-11_flush_llm_logs clears the buffer before the write (:4337-4339); the except only logs (:4360-4361) → up to 10 rows lost per failed flush. Re-rated low-medium: not purely observability anymore — the org1 parity: generic blackbird work + prod hardening, agent behaviour frozen (0018→0024) #30 sliding-window rate limiter rebuilds call_times from llm_call_logs on restart (:4250-4276), so dropped rows under-count an agent's in-window calls and let it exceed its allowance after a restart. Fix: mirror PR Database as primary conversations #19's H1 re-queue.

PR E5 — Phase-5 output hygiene + Slack mention resolution (small)

  • COR-9 — one of three fixed:
    • (a) visibility mislabel — fixed in stack: _post_message now resolves visibility from the channel (:3356, :3379) and persists it.
    • (b) reply channel from LLM output — still present, severity raised: the reply channel is action_data.get("channel", "general") (:2223), never reconciled with the target post. A reply targeting a collab_private post while declaring "general" passes the private-reply bypass, posts publicly, and is persisted channel="general" / visibility="public" — which now feeds the public memory-synthesis segment. This is a private→public confidentiality path, not a routing bug; the (a) fix makes the row faithfully record the leak. Fix: derive the reply channel from the target entry.
    • (c) memory-synthesis strip — downgraded to hygiene: the synthesis call's prompt never asks for <slack_message> tags, and the stack now routes the response through strip_ungrounded_authorship_lines (:5225-5231). A defensive strip remains worthwhile; there is no live trigger.
  • COR-8 (remainder) — real Slack <@Uxxx> mentions are undetected (_extract_tagged_agent regex @(\w+[Bb]ot)\b, message_log.py:397-401; PI-tag literal check f"@{bot_name.lower()}", simulation.py:2785-2787) and the regex is case-blind above [Bb]. grep -rn '<@' src/ → zero hits; the bot_uid_to_agent map exists (:3918-3922) but only for authorship attribution. Corroborating evidence that the literal form is the only one understood: the web UI synthesizes it (agent_page.py:979-981). Fix: translate <@Uxxx> via the bot-user-id map; add IGNORECASE.

PR E6 — Budget & rate-limit state integrity (medium)

PR #30 replaced the cumulative --budget cap with a sliding-window limiter (_within_rate_limit, :458-481; budget_cap default now 0 and deprecated, main.py:253-262) and consolidated the five raw increment sites into Agent.record_api_call (agent.py:94-104) — which retires part of the original framing. Remaining:

  1. Still no mid-turn check — limiter and cap are consulted only at selection (_turn_eligible, :841-856); Phase 4 fans out over all of an agent's threads in one gather (:1286-1291) with per-retry booking, so a turn can overshoot the window allowance arbitrarily.
  2. Roster re-add builds a fresh Agent (:4543-4545): an inactive→active flip discards pending_proposals (the unreviewed-proposal block evaporates), active threads, cursors — and now also call_times/throttled, i.e. a status flip is a rate-limiter reset. Severity raised; this is state loss, not accounting. No _rebuild_agent_state() follows the add.
  3. total_api_calls recomputed from the live roster (:3887, main.py:293) is non-monotonic — cosmetic (the code labels it so).
    Fix: one authoritative mid-turn charge check; rebuild re-added agents from the DB instead of fresh.

PR E7 — Liveness / cost loops (small)

All four still present: the daily-cap gate returns before the PI-priority/private/funding bypasses are even computed (:2002-2004 vs :2020-2091); has_pi_directive is cleared unconditionally at _run_turn scope (:989) — a directive arriving on a capped or throttled turn is consumed without ever reaching a prompt; thread.pi_context is set at four sites (:2780/:2885/:2942/:5016) and never cleared in-process (the DB rebuild omits it, so a restart clears it) → re-injected as "authoritative" into every future Phase-4 prompt (agent.py:461-467); the interesting_posts swap/restore both sit before the try (:2093-2094 vs :2174, try at :2177) so an exception in the prompt-build window permanently narrows state. Fix: move the cap gate after bypass evaluation; clear pi_context after consumption; clear has_pi_directive only when acted on; wrap the swap in try/finally.

Definition of done: each PR ships a test that covers its defect line and fails against the pre-fix code. The offline gate now enforces COV_MIN=60 with a src/ lint ratchet (see issue #27 I1); this issue's tests should be written against the #30/#31/#32 stack.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions