Skip to content

feat(ios): durable offline command outbox for chat sends#100331

Merged
steipete merged 3 commits into
mainfrom
feat/ios-offline-outbox
Jul 6, 2026
Merged

feat(ios): durable offline command outbox for chat sends#100331
steipete merged 3 commits into
mainfrom
feat/ios-offline-outbox

Conversation

@steipete

@steipete steipete commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stacked on the iOS chat offline cache branch (#100219, feat/ios-chat-offline-cache); lands after it.

Part of #46664

What Problem This Solves

Text sent while the gateway is unreachable is refused and lost. Users who think of things for their agent while offline (transit, dead zones, gateway restarts) have no way to hand them off — the thought has to survive in their head until connectivity returns.

Why This Change Was Made

Adds a durable offline command outbox to the shared chat pipeline, wired up for iOS. It extends the existing transcript-cache SQLite database (same DB, new table) rather than adding a store, and uses the existing health transition as the flush trigger rather than a second reachability source. Each queued command's UUID rides as the chat.send idempotency key, so flushing is at-least-once with gateway-side dedup. Conservative contract: text-only v1, 50 queued per gateway, 48h expiry to a visible "expired" state instead of silently firing stale commands, 3 attempts for gateway rejections with visible failure + tap-to-retry, transport-level failures never burn attempts (they climb an in-memory backoff ladder, then drop health so the reconnect machinery owns pacing), sending rows recover to queued after a crash, and rows purge with the cache on gateway resets.

Ordering is a first-class guarantee: the outbox is the single ordering authority for a session's turns. New live sends route behind queued/sending rows (including immediately after a cold open, before durable rows are re-adopted), the flush is strictly createdAt-ordered and single-flight, and a just-flushed turn stays visible and cached until a history snapshot confirms its idempotency key — a lagging gateway snapshot cannot evict it. Deliberately session-scoped: other sessions' queued rows are separate conversations with no ordering contract against a new send (documented inline).

User Impact

  • Sending while offline queues the message durably: it renders immediately with a "queued" state, survives app restarts, and delivers in order on reconnect with no user action.
  • Failures are visible and recoverable: expired/rejected commands park as "failed" with Retry/Delete on the bubble; Retry refreshes the timestamp so expired items can resend. Delete is hidden once a row is in flight (the send can no longer be prevented).
  • Bounded and private: 50 commands per gateway, text only, stored in the same protected per-gateway database as the transcript cache.

Evidence

  • cd apps/shared/OpenClawKit && swift test --filter "ChatViewModelOutboxTests|ChatTranscriptCacheStoreTests" — full outbox suite green (16 VM scenarios + store round-trip/claim tests), including deterministic tests for: enqueue-offline persistence across VM recreation, ordered flush with idempotency keys, ack-removes-row without duplicate bubbles, rejection→failed-after-3 with tap retry, transport-failure backoff ladder then health drop, 48h expiry boundary, MAX_QUEUED refusal + draft preservation, crash recovery, delete-vs-flush races (snapshot window AND claim-await window via a held-delete/claim-hook harness), double-submit during the health probe, post-reconnect live sends queueing behind draining rows, cold-open sends queueing behind not-yet-adopted durable rows, and stale-history-after-ack cache preservation.
  • swift test --filter "ChatViewModelTests|ChatViewModelTranscriptCacheTests" — 111+ existing tests green.
  • Codex autoreview: 8 rounds. Seven accepted findings fixed, each with a regression test (delete-vs-flush race, claim-window delete race, Delete exposed on in-flight rows, duplicate enqueue on double submit, unbounded first-rung transport retries, live sends overtaking the queue after reconnect, stale history evicting just-acked turns from cache). One finding-half consciously rejected with inline rationale (gateway-wide send blocking across sessions — separate conversations have no ordering contract, and wedging them on another session's backlog is worse product behavior). Final round clean.

Follow-ups noted: voice-capture queue unification (WatchReplyCoordinator overlap documented in code), macOS wiring rides the stacked macOS cache PR, and attachment sends remain online-only v1 (outside the ordering guarantee, documented inline).

Update (post-#100219 landing): rebuilt as a single squashed commit on main, re-expressing the outbox on the landed cache rewrite (per-gateway SHA256-named DB files under chat-cache/, retired/async-handle store API, main's onboarding purge redesign). All seven review-hardened behaviors preserved with their regression tests, plus four more fixes from this pass's review rounds: session-switch FIFO-gate reset, model-patch ordering during flush, delete-durability window (bubble persists until the durable row is confirmed gone — no resurrect-after-kill), and crash-recovery gating under a locked store. Validation: swift test across the five affected suites — 150 tests green (three consecutive runs); final Codex autoreview clean (0.84).

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation app: ios App: ios size: XL maintainer Maintainer-authored PR labels Jul 5, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b134bfa3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1491 to +1493
if self.outbox != nil, self.attachments.isEmpty,
!self.hasRestoredOutboxMessages
|| self.outboxStatesByMessageID.values.contains(where: { !$0.isFailed })

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.

P2 Badge Reset the outbox restore gate per session

When a user has already restored one session, hasRestoredOutboxMessages remains true across applySessionSwitch; if they switch to another session that has durable queued commands and submit text before that session's async restore finishes, this condition can bypass the outbox and send the new text live ahead of the older queued row, violating the FIFO guarantee for that session. Reset the restore/mapping state when changing sessions so the gate assumes a backlog until the new session's rows are adopted.

Useful? React with 👍 / 👎.

@clawsweeper

clawsweeper Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Codex review: stale review; fresh review needed.

Summary
The latest durable ClawSweeper review was for head 45d05538d2d4a310b855c9badbabb048f609f6ab, but the PR head is now a503a7b8b67c6508e5b31d925406e2d2bcd2d4d8. Its old verdict and PR readiness labels are no longer current.

Next step
Run or wait for a fresh ClawSweeper review on the current PR head.

Review history (3 earlier review cycles)
  • reviewed 2026-07-05T13:29:25.036Z sha 5b134bf :: needs real behavior proof before merge. :: [P2] Reset the restore gate when switching sessions | [P2] Keep ChatViewModel.swift under the SwiftLint limit
  • reviewed 2026-07-06T00:21:12.310Z sha 5b134bf :: needs real behavior proof before merge. :: [P2] Reset the outbox gate when switching sessions | [P2] Keep ChatViewModel.swift under the SwiftLint limit
  • reviewed 2026-07-06T02:37:06.169Z sha f0c7687 :: needs real behavior proof before merge. :: [P2] Keep the composer enabled for offline outbox sends | [P2] Fix the schema-version SwiftFormat violation | [P2] Remove the extra blank line before the final brace

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. labels Jul 5, 2026
@steipete
steipete force-pushed the feat/ios-chat-offline-cache branch 3 times, most recently from 4d38e7f to 7551c63 Compare July 5, 2026 23:36
@steipete
steipete force-pushed the feat/ios-chat-offline-cache branch from 7551c63 to ec1ee2b Compare July 5, 2026 23:49
Base automatically changed from feat/ios-chat-offline-cache to main July 6, 2026 00:02
@steipete
steipete force-pushed the feat/ios-offline-outbox branch from 5b134bf to f0c7687 Compare July 6, 2026 02:19

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0c7687184

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

sessionKey: sessionKey,
transport: self.appModel.makeChatTransport(),
transcriptCache: offlineStore,
outbox: offlineStore,

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.

P2 Badge Keep the composer enabled for offline outbox sends

When the operator gateway is actually disconnected (gatewayConnected == false), the surrounding ChatProTab still passes that value as isComposerEnabled, and OpenClawChatComposer disables editing and sendDraftIfEnabled before OpenClawChatViewModel.send() can enqueue anything. That means the outbox wired here only helps stale-healthy transport failures; in the documented dead-zone/disconnected case users cannot type or tap Send to create a durable queued row. Enable the text composer when an offline store/outbox exists (while still keeping attachment sends online-only) so disconnected text can reach this outbox path.

Useful? React with 👍 / 👎.

@steipete
steipete force-pushed the feat/ios-offline-outbox branch from f0c7687 to 36c882d Compare July 6, 2026 03:50

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36c882dda1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +447 to +453
private func recoverInterruptedOutboxSendsIfNeeded() async {
guard let outbox, !self.hasRecoveredInterruptedOutboxSends else { return }
// Burn the once-per-launch gate only when the store was reachable:
// with Complete file protection the database is legitimately
// unavailable while the device is locked, and skipping recovery then
// would leave crashed 'sending' rows stuck forever after unlock.
if await outbox.recoverInterruptedSends() {

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.

P2 Badge Keep recovery from requeueing active flushes

When the iOS chat model is rebuilt on reconnect, NodeAppModel reuses the same offline store but this recovery gate is per view model, so the new model's restore can run recoverInterruptedSends() while the old model still has a command marked sending and is awaiting sendMessage. That flips an actively in-flight row back to queued, allowing the new model to flush/delete it as a duplicate (for example after an in_flight ack) before the original send returns; if that original send later errors, the durable queued command has been lost. Make interrupted-send recovery store/process-scoped, or otherwise ensure it only runs on true cold-start recovery rather than during same-process view-model replacement.

Useful? React with 👍 / 👎.

@steipete
steipete force-pushed the feat/ios-offline-outbox branch from 36c882d to 1ea0410 Compare July 6, 2026 04:01
Text messages sent while the gateway is unreachable queue in a durable
per-gateway outbox (new outbox_commands table in the per-gateway chat
cache SQLite store, schema v2) instead of failing. Queued bubbles render
with visible Queued/Sending/Not sent states and flush strictly in
createdAt order once transport health recovers; each command's client
UUID rides as the send idempotency key, so at-least-once delivery plus
gateway dedupe keeps the transcript exact.

Contract summary:
- Bounds: 50 queued commands per gateway; refused enqueues keep the
  draft. Queued rows older than 48h expire to failed("expired") rather
  than silently sending stale commands; tap-to-retry refreshes
  createdAt so an expired row can resend as new intent.
- Failure taxonomy: transport-level failures keep rows queued without
  burning retry attempts (backoff ladder, then health drops so the
  reconnect machinery owns pacing); gateway rejections burn attempts
  and fail terminally after 3, with context-menu retry/delete.
- Deletes are tombstoned synchronously and rechecked after the claim
  await so an active flush can never send a removed command; Delete is
  hidden while a bubble is already in flight; offline enqueue is
  guarded against double submit during the health probe.
- Post-reconnect live sends route behind draining outbox rows (FIFO),
  and a cold open assumes a backlog until restore adopts durable rows.
- Crash safety: 'sending' rows revert to 'queued' at startup; flushed
  turns are spliced into the session's cached transcript before their
  outbox row is deleted, and stale history snapshots cannot evict a
  just-flushed turn until a snapshot confirms it.
- Per-gateway scoping and purge ride the transcript cache: one SQLite
  file per gateway; reset/forget drops the queue with the cache.
  Fixture/unpaired transports get no outbox.

Part of #46664

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ea0410eec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

nonisolated static let maxOutboxSendAttempts = 3
@ObservationIgnored
var pendingCacheWriteTask: Task<Void, Never>?
<<<<<<< HEAD

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.

P1 Badge Resolve conflict markers before merging

This file still contains unresolved merge-conflict markers (<<<<<<<, =======, >>>>>>>) in the committed diff, so Swift cannot parse ChatViewModel.swift and the shared OpenClawKit/iOS targets will fail to build until the conflict is resolved. The same marker pattern appears in multiple touched blocks of this file, so this is not just a stray comment.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6497b8cf8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

guard self.isCurrentSession(session) else { return }
self.presentOutboxCommands(commands.filter { $0.sessionKey == session.key })
// The FIFO send gate assumes a backlog until this point.
self.hasRestoredOutboxMessages = true

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.

P2 Badge Keep the restore gate closed when the store is unreadable

When this restore runs while the iOS protected SQLite file is unavailable, OpenClawChatSQLiteTranscriptCache.loadCommands() returns [] because handle() returns nil, but this line still marks hasRestoredOutboxMessages true. If the app/view model was created before protected data became available, the first unlocked send can then take the live path with no adopted outboxStatesByMessageID, bypassing older durable rows that were merely unreadable rather than absent; keep the gate closed unless the outbox read actually succeeded.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45d05538d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
self.runMessageScopesByRunID.removeAll()
self.provisionalFinalMessagesByID.removeAll()
self.recentlySentOutboxUserKeys.removeAll()

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.

P2 Badge Preserve sent-outbox keys across session switches

When an outbox flush succeeds but gateway history is still lagging, recentlySentOutboxUserKeys is the only guard that keeps the just-spliced user turn from being evicted by a stale history snapshot. Clearing the whole set on every session switch means a user who switches away/back (or switches to a background session whose queued command just flushed) before history contains that idempotency key will pre-paint the cached sent turn, then the next stale live history response will replace it and overwrite the cache without the turn, making the acknowledged send disappear until a later fresh history load. Keep this protection scoped per session instead of dropping it during switches.

Useful? React with 👍 / 👎.

@clawsweeper clawsweeper Bot removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 6, 2026
@steipete
steipete merged commit 4b7661e into main Jul 6, 2026
74 of 79 checks passed
@steipete
steipete deleted the feat/ios-offline-outbox branch July 6, 2026 04:23
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 6, 2026
…0331)

* feat(ios): durable offline command outbox for chat sends

Text messages sent while the gateway is unreachable queue in a durable
per-gateway outbox (new outbox_commands table in the per-gateway chat
cache SQLite store, schema v2) instead of failing. Queued bubbles render
with visible Queued/Sending/Not sent states and flush strictly in
createdAt order once transport health recovers; each command's client
UUID rides as the send idempotency key, so at-least-once delivery plus
gateway dedupe keeps the transcript exact.

Contract summary:
- Bounds: 50 queued commands per gateway; refused enqueues keep the
  draft. Queued rows older than 48h expire to failed("expired") rather
  than silently sending stale commands; tap-to-retry refreshes
  createdAt so an expired row can resend as new intent.
- Failure taxonomy: transport-level failures keep rows queued without
  burning retry attempts (backoff ladder, then health drops so the
  reconnect machinery owns pacing); gateway rejections burn attempts
  and fail terminally after 3, with context-menu retry/delete.
- Deletes are tombstoned synchronously and rechecked after the claim
  await so an active flush can never send a removed command; Delete is
  hidden while a bubble is already in flight; offline enqueue is
  guarded against double submit during the health probe.
- Post-reconnect live sends route behind draining outbox rows (FIFO),
  and a cold open assumes a backlog until restore adopts durable rows.
- Crash safety: 'sending' rows revert to 'queued' at startup; flushed
  turns are spliced into the session's cached transcript before their
  outbox row is deleted, and stale history snapshots cannot evict a
  just-flushed turn until a snapshot confirms it.
- Per-gateway scoping and purge ride the transcript cache: one SQLite
  file per gateway; reset/forget drops the queue with the cache.
  Fixture/unpaired transports get no outbox.

Part of openclaw#46664

* chore(ios): sync native i18n inventory

* style(chat-ui): satisfy strict SwiftFormat lint (doc comment, scope blank line)
giodl73-repo pushed a commit to giodl73-repo/openclaw that referenced this pull request Jul 8, 2026
…0331)

* feat(ios): durable offline command outbox for chat sends

Text messages sent while the gateway is unreachable queue in a durable
per-gateway outbox (new outbox_commands table in the per-gateway chat
cache SQLite store, schema v2) instead of failing. Queued bubbles render
with visible Queued/Sending/Not sent states and flush strictly in
createdAt order once transport health recovers; each command's client
UUID rides as the send idempotency key, so at-least-once delivery plus
gateway dedupe keeps the transcript exact.

Contract summary:
- Bounds: 50 queued commands per gateway; refused enqueues keep the
  draft. Queued rows older than 48h expire to failed("expired") rather
  than silently sending stale commands; tap-to-retry refreshes
  createdAt so an expired row can resend as new intent.
- Failure taxonomy: transport-level failures keep rows queued without
  burning retry attempts (backoff ladder, then health drops so the
  reconnect machinery owns pacing); gateway rejections burn attempts
  and fail terminally after 3, with context-menu retry/delete.
- Deletes are tombstoned synchronously and rechecked after the claim
  await so an active flush can never send a removed command; Delete is
  hidden while a bubble is already in flight; offline enqueue is
  guarded against double submit during the health probe.
- Post-reconnect live sends route behind draining outbox rows (FIFO),
  and a cold open assumes a backlog until restore adopts durable rows.
- Crash safety: 'sending' rows revert to 'queued' at startup; flushed
  turns are spliced into the session's cached transcript before their
  outbox row is deleted, and stale history snapshots cannot evict a
  just-flushed turn until a snapshot confirms it.
- Per-gateway scoping and purge ride the transcript cache: one SQLite
  file per gateway; reset/forget drops the queue with the cache.
  Fixture/unpaired transports get no outbox.

Part of openclaw#46664

* chore(ios): sync native i18n inventory

* style(chat-ui): satisfy strict SwiftFormat lint (doc comment, scope blank line)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: ios App: ios docs Improvements or additions to documentation maintainer Maintainer-authored PR P2 Normal backlog priority with limited blast radius. size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant