Skip to content

Fix gateway thread retention and stale in-progress state - #2517

Merged
ilblackdragon merged 14 commits into
stagingfrom
fix/2285-gateway-in-progress-state
Apr 18, 2026
Merged

ilblackdragon merged 14 commits into
stagingfrom
fix/2285-gateway-in-progress-state

Conversation

@henrypark133

Copy link
Copy Markdown
Collaborator

Summary

This fixes two correctness regressions introduced by the durable web-gateway in-progress state work:

  1. The chat sidebar could silently switch the user away from the thread they were actively viewing if that thread dropped out of the 50-thread /api/chat/threads summary window.
  2. /api/chat/history could keep returning stale persisted in_progress metadata even after the latest turn had already been fully reconstructed from DB messages, causing completed turns to re-render as if they were still processing.

Problem

Active thread could be lost during sidebar refresh

The frontend was clearing currentThreadId whenever the active thread was not present in the latest /api/chat/threads response. That response only includes the most recent 50 threads, so a still-valid open thread could disappear from the sidebar summary while the user was actively viewing it.

In that case the next render fell back to the server-reported active thread or assistant thread, which meant follow-up messages could be sent to the wrong conversation.

Completed turns could remain stuck in "Processing"

The history handler now rehydrates durable in-progress state from conversation metadata. However, the DB-backed history path returned that metadata directly even when build_turns_from_db_messages(...) had already reconstructed a completed final turn.

That could happen if the browser reloaded between the assistant response write and the metadata-clear write, or if the metadata-clear update failed once. The UI would then:

  • re-add the user message for the already-completed turn
  • show a bogus "Processing..." indicator
  • leave the thread appearing stuck indefinitely

What changed

Frontend thread retention

In crates/ironclaw_gateway/static/app.js:

  • removed the logic that nulled out currentThreadId simply because the current thread was absent from the sidebar summary response
  • preserved the currently open thread even when it falls outside the recency window used by /api/chat/threads
  • kept the existing first-load fallback behavior for restoring the server active thread when no thread is currently selected

This keeps history loading and follow-up sends attached to the thread the user is actually viewing.

Server-side in-progress reconciliation

In src/channels/web/server.rs:

  • replaced the previous apply_in_progress_to_turns(...) helper with reconcile_in_progress_with_turns(...)
  • reconciled persisted InProgressInfo against the reconstructed last turn before returning HistoryResponse
  • dropped in_progress entirely when the persisted turn number matches a last turn that already has a response
  • preserved in_progress when it refers to a newer turn that has not yet been persisted into history
  • also dropped stale metadata when the persisted turn number is older than the already-reconstructed history

This ensures durable live-state only survives when it still represents a genuinely incomplete turn.

Tests

Added/updated regression coverage for both paths:

Rust unit/integration coverage

In src/channels/web/server.rs tests:

  • verifies stale in_progress is dropped when the matching last turn is already completed
  • verifies in_progress is preserved for a newer, not-yet-persisted turn
  • verifies /api/chat/history does not return stale in_progress for a completed persisted turn

Browser regression coverage

In tests/e2e/scenarios/test_message_persistence.py:

  • added a regression that keeps one thread open in the page
  • creates enough newer threads to push that thread out of the 50-thread summary window
  • refreshes the sidebar
  • verifies the active page thread does not change
  • verifies the next sent message stays on the originally open thread and does not land on the newer active thread

Verification

Ran:

  • cargo fmt
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --lib test_chat_history_handler_drops_stale_in_progress_for_completed_turn
  • cargo test --lib test_reconcile_in_progress_with_turns

Not run here:

  • the new Playwright regression, because this workspace does not currently have the local Python pytest / Playwright test dependencies installed

Risk

Low and targeted.

The frontend change only affects sidebar refresh behavior when a thread is already selected. The server change only affects how durable in_progress metadata is reconciled with already-persisted history before the response is returned.

Copilot AI review requested due to automatic review settings April 16, 2026 06:12
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: docs Documentation size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 16, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces durable in-flight turn state, allowing the UI to rehydrate and display "Processing..." indicators for active turns after page refreshes or thread switches. This is achieved by persisting a "live_state" in the conversation metadata and reconciling it with the message history. Feedback includes concerns about potential database bloat from large metadata fields and the efficiency of collecting thread states during sidebar refreshes.

Comment thread src/agent/thread_ops.rs Outdated
Comment thread src/channels/web/server.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses two regressions in the durable web-gateway “in-progress” work: (1) the frontend losing the currently viewed thread when it falls outside the 50-thread sidebar window, and (2) /api/chat/history returning stale durable in_progress state after the last turn is already complete.

Changes:

  • Frontend: preserve currentThreadId across sidebar refreshes and rehydrate UI “Processing…” state via HistoryResponse.in_progress.
  • Server: persist/clear durable live_state metadata at turn boundaries and reconcile it against reconstructed DB turns before returning history.
  • Tests/docs: add Rust + e2e regressions and document the in_progress history contract.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
crates/ironclaw_gateway/static/app.js Uses HistoryResponse.in_progress to rehydrate processing UI; preserves active thread during thread-list refresh; adds thread spinners based on server state.
src/channels/web/server.rs Adds InProgressInfo, reads durable metadata, reconciles it with turns, and includes it in /api/chat/history; threads list now merges in-memory state with DB metadata state.
src/channels/web/types.rs Adds HistoryResponse.in_progress and defines InProgressInfo.
src/agent/thread_ops.rs Persists live_state at turn start and clears it on completion/interruption/approval/failure paths.
src/history/store.rs Extends ConversationSummary with live_state extracted from metadata (Postgres store).
src/db/libsql/conversations.rs Extends conversation summary mapping with live_state extracted from metadata (libsql store).
tests/e2e/scenarios/test_message_persistence.py Adds e2e regressions for refresh/switch during in-progress and for sidebar refresh preserving the active thread outside the summary window.
src/channels/web/CLAUDE.md Documents the new HistoryResponse.in_progress contract for UI rehydration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/channels/web/server.rs Outdated
Comment thread crates/ironclaw_gateway/static/app.js Outdated
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Apr 16, 2026

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Size: XL (+784 / -21) | Risk: Low-Medium

Summary

Fixes two correctness regressions in the durable web-gateway in-progress state:

  1. Sidebar thread retention -- the frontend was clearing currentThreadId when the active thread fell outside the 50-thread /api/chat/threads window, causing follow-up messages to land on the wrong thread.
  2. Stale in-progress reconciliation -- /api/chat/history could return stale persisted in_progress metadata even after the turn was already completed in the DB, causing the UI to show a ghost "Processing..." indicator.

Strengths

  • reconcile_in_progress_with_turns is the right abstraction. The logic is clear: if the in-progress turn matches the last DB turn and that turn has a response, the in-progress state is stale and gets dropped. If the in-progress turn number is newer than the last DB turn, it's preserved. Clean state machine.

  • user_message_id as correlation key is a good choice. Turn numbers can collide across reconnects/compaction, but a DB-generated UUID is stable. The fallback to turn number for non-persistent modes is sensible.

  • Frontend fix is minimal and correct. Removing the currentThreadId = null fallback when the thread isn't in the sidebar summary is the right call. The first-load fallback to server_active_thread is preserved.

  • Comprehensive test coverage. Unit tests for reconcile_in_progress_with_turns (both stale and valid cases), integration test with libsql for the full history handler path, E2E Playwright tests for refresh/switch-back/sidebar-overflow scenarios.

Issues

1. live_state metadata is not cleared on AuthPending turns (thread_ops.rs)

The clear_conversation_live_state call is added for Failed, Interrupted, AwaitingApproval, and Completed outcomes. But the AuthPending branch (around line 817 in the original) doesn't clear live state. If an auth-pending turn stays in the DB metadata, a reconnecting client could see stale "Processing..." for an auth-gated turn. This may be acceptable if auth-pending turns don't persist live_state in the first place (they skip persist_user_message in some paths), but worth verifying.

2. user_input truncation at 32KB (thread_ops.rs)

"user_input": truncate_preview(user_input, 32 * 1024),

This is stored in conversation metadata JSON. For typical messages this is fine, but if metadata is stored in a TEXT column, 32KB of user input embedded in the metadata JSON could bloat the row. If this is the same metadata field used for thread_type, title, etc., confirm that downstream queries don't have row-size issues. Might be worth a smaller truncation (e.g., 4KB) since this is only for UI preview on reconnect.

3. live_state persisted even on DB write failure (thread_ops.rs)

In persist_user_message, if add_conversation_message fails, the code still persists live_state to metadata:

Err(e) => {
    tracing::warn!("Failed to persist user message: {}", e);
    let live_state = serde_json::json!({ ... "user_message_id": null ... });
    self.persist_conversation_live_state(&store, thread_id, &live_state).await;
    None
}

If the DB is having issues, the metadata write will likely also fail, which is handled (logged). But the user_message_id: null path means the reconciliation fallback uses turn numbers, which is less reliable. This is fine as a best-effort, but the comment could clarify that this is intentional degradation.

4. Thread state in sidebar uses format!("{:?}", thread.state) (server.rs)

.map(|(id, thread)| (*id, format!("{:?}", thread.state)))

This uses Rust's Debug format for ThreadState, which produces strings like "Processing", "Idle", "AwaitingApproval". The frontend checks thread.state === 'Processing'. This coupling between Rust's Debug output and JS string literals is fragile -- if ThreadState variants are renamed or the Debug impl changes, the sidebar spinners break silently. Consider using a display_name() method or serde serialization instead.

5. E2E test AssertionError typo (test_message_persistence.py)

raise AssertionError(f"Timed out waiting for in-progress turn in thread {thread_id}")

Should be AssertionError -> this is actually builtins.AssertionError... wait, Python doesn't have AssertionError. This should be AssertionError is not a real Python exception. It should be AssertionError -- actually checking: Python has AssertionError which is NOT a builtin. The correct name is AssertionError... no. The correct Python exception is AssertionError. Let me check: Python has AssertionError which is correct only if it's actually AssertionError. The standard Python assertion error is AssertionError. Actually -- the standard is AssertionError. Hmm, let me re-read: the code says AssertionError. The correct Python builtin is AssertionError.

Wait -- I'm going in circles. The Python builtin is AssertionError. The code says AssertionError. These are the same. I was wrong to flag this. Ignore this point.

Minor

  • The isSameInProgressTurn function in app.js mirrors in_progress_matches_turn in server.rs. Good that both sides agree on the matching logic.
  • live_state in ConversationSummary is extracted as metadata.live_state.state (nested), which is correct since the full live_state object has turn_number, user_input, etc.

Overall this is a clean, well-targeted fix. The main concern is item 4 (Debug format coupling) which could cause silent breakage down the road.

Comment thread src/channels/web/server.rs
Comment thread src/agent/thread_ops.rs
Comment thread src/agent/thread_ops.rs
Copilot AI review requested due to automatic review settings April 16, 2026 15:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/channels/web/server.rs
Comment thread src/channels/web/server.rs Outdated
Comment thread tests/e2e/scenarios/test_message_persistence.py Outdated

Copy link
Copy Markdown
Collaborator Author

Follow-up on @zmanian’s review:

Addressed in follow-ups:

  • replaced the sidebar thread-state format!("{:?}", ...) coupling with explicit state-label helpers in server.rs
  • added live_state staleness expiry for abandoned/crashed turns
  • re-persisted live_state when approval resumes processing
  • moved the completion-path DB awaits out from under the session mutex

Reviewed but intentionally left out of scope for this patch set:

  • the 32KB user_input preview cap in live_state metadata
  • the best-effort user_message_id: null fallback when the initial user-message DB write fails

I re-checked both of those and still do not consider them correctness blockers for this PR:

  • live_state is a single overwritten metadata field per conversation, not an accumulating per-turn payload
  • the truncation already uses the shared UTF-8-safe truncate_preview(...) helper
  • the null-ID path is a degraded fallback only when the initial persistence write fails, and later reconciliation still prefers the stable persisted ID path when available

The AssertionError note in that review was self-retracted and was not an issue.

Copy link
Copy Markdown
Collaborator Author

Also addressed the newer active_thread race note in chat_threads_handler(...) in 86b72a0b.

The handler no longer returns the earlier active_thread snapshot after the DB query / fallback re-lock. In the DB-backed path it now re-reads session.active_thread just before building the response, and in the in-memory fallback it returns sess.active_thread from the same lock scope used to build that response.

Copilot AI review requested due to automatic review settings April 16, 2026 16:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/e2e/scenarios/test_message_persistence.py Outdated
Comment thread src/channels/web/server.rs Outdated
Comment thread src/channels/web/server.rs
…n-progress-state

# Conflicts:
#	src/tools/builtin/glob_tool.rs
#	src/tools/builtin/grep_tool.rs
Copilot AI review requested due to automatic review settings April 16, 2026 22:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

nickpismenkov
nickpismenkov previously approved these changes Apr 16, 2026
…n-progress-state

# Conflicts:
#	src/agent/thread_ops.rs
#	src/channels/web/server.rs
@ilblackdragon

Copy link
Copy Markdown
Member

Code Review: PR #2517 — Fix gateway thread retention and stale in-progress state

Overview

Fixes two regressions introduced by the durable web-gateway in-progress state work:

  1. Sidebar refresh losing active thread — frontend was clearing currentThreadId whenever the current thread dropped out of the 50-thread /api/chat/threads summary window.
  2. Completed turns stuck "Processing" — /api/chat/history returned stale persisted in_progress metadata even after DB reconstruction showed the turn was complete.

Scope: +1207 / -25 across 14 files. The server-side fix is reconcile_in_progress_with_turns(...); the client-side fix is preserving currentThreadId across sidebar refreshes plus defensive turn matching in loadHistory(). Durable live state is persisted via live_state in conversation metadata and cleared on completion/interrupt/reset/approval/error paths.

Strengths

  • Lock hygiene. All clear_conversation_live_state calls correctly drop(sess) before .await, avoiding lock-across-await bugs.
  • Stable state serialization. Good move replacing format!("{:?}", t.state) with turn_state_label/thread_state_label — decouples the wire contract from enum Debug output.
  • Staleness guard. IN_PROGRESS_STALE_AFTER_MINUTES = 10 catches crash-without-clear cases; unparseable timestamps conservatively return true (drop).
  • Dual-backend coverage. Both libsql (src/db/libsql/conversations.rs) and postgres (src/history/store.rs) paths extract live_state / live_state_started_at — satisfies the CLAUDE.md invariant.
  • Strong tests. 6 Rust unit/integration tests exercise each branch of reconcile_in_progress_with_turns; 3 Playwright scenarios cover refresh, thread-switch, and the sidebar window regression. #[cfg(feature = "libsql")] handler tests drive the actual HTTP path (per CLAUDE.md: "Test Through the Caller").

Issues & Suggestions

Scope creep — unrelated hunks

Four unrelated match-exhaustiveness fixes are in this PR but not mentioned in the description:

  • src/cli/tool.rs:1107 — KeyCode::Backspace => {}
  • src/setup/prompts.rs:173,257 — KeyCode::Down => {}, KeyCode::Backspace => {}
  • src/llm/rig_adapter.rs:847 — AssistantContent::Text(_) => {}
  • src/channels/web/responses_api.rs:1230 — \"assistant\" => {}

These look like clippy fallout from a separate rebase. They're harmless but muddy review and git blame. Consider splitting into a trivial follow-up.

Duplicated reconciliation logic (JS ↔ Rust)

isSameInProgressTurn in app.js is a near-mirror of in_progress_matches_turn in server.rs, but with subtly different rules:

  • Rust in_progress_matches_turn returns true for the both-null-ID case as long as turn_number matches, regardless of response.
  • JS isSameInProgressTurn additionally requires !lastTurn.response in the both-null case.

The server already filters stale state via reconcile_in_progress_with_turns, so the JS check is a redundant belt. The divergence is probably harmless today but risks drift. Either (a) trust the server and simplify the JS to lastTurn?.user_message_id === inProgress.user_message_id, or (b) mirror the Rust logic exactly and cite the server as authoritative in a comment.

Reconciler edge case

```rust
} else if completed_turn_is_newer_than_in_progress(last_turn, &in_progress)
|| last_turn.turn_number >= in_progress.turn_number
{
None
```

The last_turn.turn_number >= in_progress.turn_number branch fires even when the IDs don't match and the last turn is NOT completed. In practice build_turns_from_db_messages always emits completed turns (assistant message present) so this is safe — but it's a latent coupling. Worth a comment explaining why >= is correct (DB-reconstructed turns are by definition complete).

Duplication in summary extraction

live_state / live_state_started_at extraction is copy-pasted 4 times across libsql/conversations.rs and history/store.rs. Consider a small helper (extract_live_state(&metadata) -> (Option<String>, Option<String>)) — would also centralize the "state" vs "started_at" key names.

E2E flakiness risk

test_sidebar_refresh_keeps_active_thread_outside_summary_window creates 55 threads sequentially via /api/chat/thread/new. That's ~55 round-trips per run and likely the slowest new test. Also, _start_thread_and_wait_for_in_progress retries up to 3 times for a "fast completion" race — fine, but log the attempt count so flake triage is easier.

Minor

  • src/channels/web/server.rs:3400 — the sidebar sess lock is taken, mapped, dropped, then re-taken at 3462 to read active_thread. Could be captured in the first pass.
  • summary_live_state synthesizes a fake InProgressInfo just to reuse is_stale_in_progress. Extracting the staleness check to work on &str (started_at) would avoid the awkward placeholder struct.
  • ProcessingLiveState.user_input: &'a str is truncated at 32KiB inside persist_processing_live_state via truncate_preview — good, but worth a comment on why 32KiB (appears to match the message preview convention elsewhere).

Security & Correctness

  • ensure_writable_conversation is called on every live-state write/clear — ownership gate is preserved. ✓
  • No new .unwrap() / .expect() in production paths. ✓
  • truncate_preview on user_input caps the metadata column size; avoids unbounded growth. ✓
  • CLAUDE.md invariant ("LLM data is never deleted") respected — live_state is cleared, not the conversation messages. ✓

Verdict

Approve with minor revisions. The core reconciliation and retention logic is sound, well-tested, and correctly addresses both regressions. The main asks are: (1) split the unrelated match-exhaustiveness hunks into their own PR, (2) reduce JS/Rust logic drift around in_progress_matches_turn, and (3) add a one-line comment on the turn_number >= branch. Nothing blocking.

ilblackdragon
ilblackdragon previously approved these changes Apr 18, 2026
Resolve conflicts in crates/ironclaw_gateway/static/app.js and
src/channels/web/server.rs.

app.js loadHistory():
- welcome-card guard now checks both `!data.in_progress` (this PR) and
  `freshPending.length === 0` (staging)
- processing indicator branch uses staging's i18n'd
  `ActivityEntry.t('activity.processing', 'Processing...')` for both
  the new `data.in_progress` path and the legacy `lastTurn.state`
  fallback

server.rs test module:
- keep this PR's new `test_gateway_state_with_store_and_session_manager`
  helper and the three `test_chat_history_handler_*` integration tests
- keep staging's new `test_auth_manager` helper
- both are purely additive and coexist under the existing `mod tests`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 18, 2026 06:44
@ilblackdragon
ilblackdragon merged commit 12fb3b1 into staging Apr 18, 2026
11 checks passed
@ilblackdragon
ilblackdragon deleted the fix/2285-gateway-in-progress-state branch April 18, 2026 06:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +4245 to +4248
// Preserve the currently open thread even when it falls outside the
// sidebar's recency window. The history view can still load that thread
// directly, and follow-up sends must stay attached to it.

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preserving currentThreadId when it falls outside the 50-thread summary window can break the read-only thread safety guard: later in loadThreads() the channel is derived from the thread object in the returned list, and if the active thread isn’t present it falls back to treating it as a gateway thread (enabling input). Please ensure the read-only/disabled-input logic remains correct when the active thread is outside the summary list (e.g., keep the last known channel/read-only flag, disable input until resolved, or have the server include the active thread entry even if it’s outside the recency window).

Copilot uses AI. Check for mistakes.
ilblackdragon added a commit that referenced this pull request Apr 19, 2026
…s enum

Producers at 7 sites and consumers at 3 sites previously agreed by
convention only. Promotes the status field to a typed enum with
snake_case serde — wire format preserved.

Maps to bug pattern from #2570, #2531, #2517 where status transitions
drifted between producer and consumer.

Tests cover snake_case serialization, wire-format round-trip, and the
is_success() predicate used at consumer sites.
ilblackdragon added a commit that referenced this pull request Apr 19, 2026
…ndary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion
crossed a layer silently.
ilblackdragon added a commit that referenced this pull request Apr 20, 2026
…s enum (#2678)

* refactor(events): replace JobResult.status String with JobResultStatus enum

Producers at 7 sites and consumers at 3 sites previously agreed by
convention only. Promotes the status field to a typed enum with
snake_case serde — wire format preserved.

Maps to bug pattern from #2570, #2531, #2517 where status transitions
drifted between producer and consumer.

Tests cover snake_case serialization, wire-format round-trip, and the
is_success() predicate used at consumer sites.

* refactor(events): add Stuck variant, accept "error" alias, case-insensitive parse

JobResultStatus now covers the full set of wire values producers emit:
- New `Stuck` variant for worker/job.rs `mark_stuck` path (was coerced
  to Failed + warn log, losing the distinction that job monitor and
  recovery logic care about).
- `FromStr` accepts `"error"` as a legacy alias for `Failed` so
  claude_bridge and acp_bridge wire payloads deserialize cleanly
  instead of hitting the default-on-unknown branch.
- `FromStr` trims whitespace and uses `eq_ignore_ascii_case`, so
  `"  COMPLETED  "` and `"Failed"` parse rather than falling back.
- Empty / whitespace-only input now returns `Err` (distinct from
  "unknown value") so callers can log it separately.

Producer migration: worker/job.rs emits `JobResultStatus::Stuck`
directly via `serde_json::json!` so the wire string stays pinned to
`as_str()`. claude_bridge and acp_bridge keep emitting `"error"` on
the wire; the FromStr alias covers them without churn on those call
sites.

Added unit tests for each variant, the `"error"` alias, case
insensitivity, whitespace trimming, empty-string error, and
preservation of the original input in `JobResultStatusParseError`.

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
ilblackdragon added a commit that referenced this pull request Apr 20, 2026
…ndary (#2685)

* refactor(channels): introduce ExternalThreadId newtype at channel boundary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion
crossed a layer silently.

* fix(bridge): adapt test thread_id to ExternalThreadId newtype

Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module.

* refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate

* refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id

- router.rs: scope_thread_id written to PendingGate was wrapped via
  ExternalThreadId::from_trusted from message.conversation_scope(), which
  can carry untrusted WASM/metadata-sourced strings. Now validates via
  ExternalThreadId::new; invalid values log at debug and store None.
  Applied at both call sites (authentication-fallback path and generic
  gate-insertion path).
- relay/channel.rs: respond() derived thread_id only from response or
  metadata — now also consults the validated msg.thread_id as the second
  fallback (before raw metadata) and filters empty strings so we never
  emit thread_ts: "" to Slack.
@henrypark133 henrypark133 mentioned this pull request Apr 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
* fix(gateway): persist in-progress chat state

* Fix gateway thread retention and stale in-progress state

* Use stable message IDs for gateway in-progress state

* Fix gateway live state review follow-ups

* Fix follow-up PR review comments

* Fix clippy warning in skills catalog

* Fix in-progress review follow-ups

* Fix all-features clippy in TUI renderer

* Fix legacy in-progress reconciliation

* Fix remaining clippy warnings

* Fix gateway review follow-ups

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…s enum (nearai#2678)

* refactor(events): replace JobResult.status String with JobResultStatus enum

Producers at 7 sites and consumers at 3 sites previously agreed by
convention only. Promotes the status field to a typed enum with
snake_case serde — wire format preserved.

Maps to bug pattern from nearai#2570, nearai#2531, nearai#2517 where status transitions
drifted between producer and consumer.

Tests cover snake_case serialization, wire-format round-trip, and the
is_success() predicate used at consumer sites.

* refactor(events): add Stuck variant, accept "error" alias, case-insensitive parse

JobResultStatus now covers the full set of wire values producers emit:
- New `Stuck` variant for worker/job.rs `mark_stuck` path (was coerced
  to Failed + warn log, losing the distinction that job monitor and
  recovery logic care about).
- `FromStr` accepts `"error"` as a legacy alias for `Failed` so
  claude_bridge and acp_bridge wire payloads deserialize cleanly
  instead of hitting the default-on-unknown branch.
- `FromStr` trims whitespace and uses `eq_ignore_ascii_case`, so
  `"  COMPLETED  "` and `"Failed"` parse rather than falling back.
- Empty / whitespace-only input now returns `Err` (distinct from
  "unknown value") so callers can log it separately.

Producer migration: worker/job.rs emits `JobResultStatus::Stuck`
directly via `serde_json::json!` so the wire string stays pinned to
`as_str()`. claude_bridge and acp_bridge keep emitting `"error"` on
the wire; the FromStr alias covers them without churn on those call
sites.

Added unit tests for each variant, the `"error"` alias, case
insensitivity, whitespace trimming, empty-string error, and
preservation of the original input in `JobResultStatusParseError`.

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…ndary (nearai#2685)

* refactor(channels): introduce ExternalThreadId newtype at channel boundary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from nearai#2349, nearai#2444, nearai#2517 where thread-id confusion
crossed a layer silently.

* fix(bridge): adapt test thread_id to ExternalThreadId newtype

Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module.

* refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate

* refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id

- router.rs: scope_thread_id written to PendingGate was wrapped via
  ExternalThreadId::from_trusted from message.conversation_scope(), which
  can carry untrusted WASM/metadata-sourced strings. Now validates via
  ExternalThreadId::new; invalid values log at debug and store None.
  Applied at both call sites (authentication-fallback path and generic
  gate-insertion path).
- relay/channel.rs: respond() derived thread_id only from response or
  metadata — now also consults the validated msg.thread_id as the second
  fallback (before raw metadata) and filters empty strings so we never
  emit thread_ts: "" to Slack.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: high Safety, secrets, auth, or critical infrastructure scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: docs Documentation scope: llm LLM integration scope: setup Onboarding / setup scope: tool/builtin Built-in tools size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants