fix(lark): ingest inbound images and videos as chat attachments (MUL-4934) - #5580
Conversation
|
@beastpu is attempting to deploy a commit to the IndexLabs Team on Vercel. A member of the Team first needs to authorize it. |
|
Addressed the retry-safety and test-coverage review in
Validation: |
Bohan-J
left a comment
There was a problem hiding this comment.
Thanks for this — the product direction is right (delivering inbound images/videos as real chat attachments, with a graceful placeholder fallback on failure), and the retry-safe object keying, the "resolve media only after routing/dedup/identity/session checks" ordering, the in-tx attachment binding, and the engine/http-layer test coverage are all solid. The whole-message media budget (MediaTimeout capping ResolveMedia) also nicely closes the earlier multi-attachment vs. dedup-claim-window concern.
Two blocking issues remain before merge, both around where/how the download happens.
1. Media resolution runs on the synchronous pre-ACK path and can breach the Lark ~3s ACK deadline
The inbound path is synchronous on the single connection receive goroutine: WSLongConnConnector reads the frame → Enricher.Enrich (bounded by EnrichTimeout, default 2s, explicitly documented to stay "well under Lark's ~3s ACK window") → emit → Router.Handle → dispatch → processClaimed. This PR inserts resolveMedia (up to MediaTimeout = 2.5s) synchronously between EnsureSession and AppendMessage, and the ACK frame (NewAckFrame(frame, true)) is only written after Handle returns.
So worst-case pre-ACK latency becomes enrich (≤2s) + media (≤2.5s) + identity/session/append DB time, which can exceed 3s. Even a standalone image with no enrichment is media 2.5s + DB, right at the edge. When the ACK is late, Lark redelivers the event; the 60s dedup guard keeps that from double-processing, but repeated late ACKs risk long-connection churn/reconnects — the exact thing the codebase avoids elsewhere by detaching the reply/typing calls.
Note the 2.5s default matches ReplyTimeout, but ReplyTimeout runs on a detached goroutine off the ACK path, whereas media runs on it — they can't share the same budget. Suggested direction: move media resolution off the pre-ACK path (e.g. append the message first so it's durable, then download/upload asynchronously and link the attachment to the persisted message before the debounced run flush), or bound it strictly within the ACK headroom left after enrichment + DB and reconcile it with EnrichTimeout.
2. S3Storage.UploadStream streams an unknown-length, non-seekable reader into PutObject — likely rejected by real S3/MinIO, and untested
UploadStream passes the download body straight into s3.PutObject as Body with no ContentLength set (the existing Upload uses a seekable, known-length bytes.NewReader). With aws-sdk-go-v2 v1.97's default RequestChecksumCalculation=WhenSupported, PutObject auto-selects CRC32 and takes the aws-chunked trailing-checksum path; for an unknown-length body it emits Transfer-Encoding: chunked without x-amz-decoded-content-length, which real S3 (and MinIO) require for aws-chunked uploads and will reject at the service.
Because media_ingest.go's uploadResource prefers the streaming path whenever the backend implements it (both local and S3 do), S3-backed deployments would hit this failing path — and since upload failure is non-fatal, media would silently never attach in production. The tests only exercise a fake storage (io.ReadAll), so S3Storage.UploadStream has no real-backend coverage and CI stays green.
The download side usually already knows the size (resp.ContentLength), but it isn't threaded into UploadStream. Fix options: set ContentLength on PutObjectInput (plumb the known size through), buffer into a bytes.Reader like the existing Upload, or use feature/s3/manager.Uploader. Please validate against a real S3/MinIO before merge.
Nits
maxMessageResourceBytesis 100 MiB, but the ~2.5s synchronous window realistically only fits a few MiB of download+upload. Since the headline case is MP4 video, anything moderately large will hit the timeout and fall back to the[Video]placeholder. The 100 MiB cap only becomes meaningful once media moves off the ACK path (see #1).- The PR description mentions a "20 MiB limit", but the code now uses 100 MiB — worth updating.
looksLikeJSONinhttp_client.gois defined but unused (the actual detection is the inlinestrings.Contains(contentType, "json")check) — dead code, can be removed.
EnqueueChatTask read the session-wide media deadline in one statement and sealed the input batch in a later one. Under READ COMMITTED a media message committing between the two got sealed into a task the deadline read had already decided was 'queued', so the daemon could claim it before its attachment bound — the agent received the bare placeholder, and the later media-ready promotion was a no-op against a non-deferred task. After the seal, re-derive the deferral from the sealed batch itself in the same transaction (DeferChatTaskForSealedPendingMedia): if any sealed message still carries an unexpired media marker, flip the task to deferred with fire_at aligned to the latest marker. The existing post-commit promote fence already covers the opposite direction (marker cleared mid-transaction). Adds a deterministic regression test that injects the media append between the deadline read and the seal via a wrapped pgx.Tx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-commit media-ready fence returned its error from EnqueueChatTask even though the deferred task was already durably committed. The router flush treats any enqueue error as "no task exists": it clears the typing indicator and logs an enqueue failure while the run still happens at its fire_at deadline. Log the fence failure instead — the claim-path deferred promoter re-queues the task regardless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Media jobs were serialized per session but unbounded across sessions: a burst could open arbitrarily many concurrent 45s Lark downloads, and each unknown-length upload may buffer up to the 100 MiB resource cap in memory. Gate resolveAndBindMedia behind a global slot semaphore (default 8, RouterConfig.MediaConcurrency). Per-session ordering is unchanged; on shutdown a job cancelled while waiting for a slot proceeds straight to the bounded DB finalize so marker clearing stays prompt. Also document that the per-message media budget spans queue/slot waits (it must match the persisted fire_at) and why timed-out uploads cannot leak unbounded orphans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed both blocking issues, plus a race a follow-up self-review pass surfaced. Changes since the review, in order: 1. Media resolution is off the pre-ACK path entirely (
2.
3. Follow-up: mid-enqueue media-append race ( Self-review found a READ COMMITTED window in 4. Follow-up: enqueue error contract + concurrency bound ( A post-commit promote-fence failure no longer surfaces as an enqueue error (the task is already durable; the router would otherwise clear the typing indicator and log a spurious failure). Cross-session media work is now capped by Validation: engine/lark/slack/storage/service/handler package tests (including the DB-backed ones on a freshly migrated database), |
Sealing the channel input batch stamps task_id onto channel user messages, which exposed them to the cancel draft-restore path: an empty-transcript cancel would DeleteUserChatMessageByTask the sealed Feishu/Slack messages and detach their attachments. Those messages are the durable record of what the platform sender wrote — the sender has no Multica composer to restore a draft into. Gate the restore-delete on ChatSessionHasChannelBinding in both the synchronous finalize and the deferred finalize (the latter covers markers left by an older replica during a rolling deploy); a bound session now settles as "Stopped." instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every inbound message on a Media-enabled platform persisted a 45s media deadline and queued a resolution job, so a plain text message could wait behind the global media semaphore (its task deferred while other sessions download 100 MiB videos) and a crash between append and clear delayed a pure-text run to the full 45s fallback. Add MediaResolver.HasMedia — a pure in-memory probe the Router calls on the ACK path — and only persist the deadline / enqueue the job when the message actually references platform media. The Feishu resolver decodes the already-received payload and reports standalone image or video keys and post-embedded img/media spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bohan-J
left a comment
There was a problem hiding this comment.
Reviewed at 07731828bbfcacd9669a6edf0ceeeb96c0dc0f63. Both blockers from the previous round are properly closed:
- ACK path — media work is out of
Handleentirely now. The inbound path routes, persists the message, and returns; download/upload/binding run on a detached queue, withchannel_media_pending_until+ a deferred task providing a durable 45s fallback. The enqueue-vs-append and clear-vs-create races are each fenced (in-tx re-derivation viaDeferChatTaskForSealedPendingMedia, post-commitPromoteChannelChatTasksIfMediaReady), and the new router/concurrency tests cover the boundaries. - S3 streaming —
ContentLengthis now threaded through, the optional trailing-checksum/aws-chunked path is disabled, and unknown-length bodies fall back to a size-capped buffered upload, withTestS3StorageUploadStreamUsesFixedLengthRequestpinning the request shape for a non-seekable reader. The deadlooksLikeJSONand the stale 20 MiB figure are gone too.
The new persistence design is a sound direction. Two issues in it still block merge — both are about production data lifecycle rather than the happy path.
1. Cancel-path provenance is derived from a deletable binding, so archived sessions lose inbound channel messages
EnqueueChatTask now sets a self input-owner and seals the inbound user message into the task via LinkUnownedChannelChatMessagesToTask, so channel messages carry a task_id. To stop cancellation from treating those as web drafts, finalizeCancelledChatMessage (and FinalizeDeferredCancelledChat) gate the restore-delete on ChatSessionHasChannelBinding.
The problem is that a binding is not an immutable fact about where a message came from — it only says a binding exists right now. Archiving a session deletes it (handler/chat.go, the req.Archived branch calls DeleteChannelChatSessionBindingBySession), and installation reclaim/rebind deletes bindings while deliberately preserving the chat_session rows for history (DeleteChannelChatSessionBindingsByInstallation in pkg/db/queries/channel.sql). So this sequence loses data:
- A Feishu/Slack message is ingested and sealed into a task.
- The session is archived (or the installation is rebound) → binding deleted.
- The still-queued/running task is cancelled.
ChatSessionHasChannelBindingreturns false →restorable = true→DeleteUserChatMessageByTaskdeletes the original inbound message.
This is a regression introduced here: before this PR the channel message carried no task_id, so that delete could never match it. The sender has no composer for the "restored draft" to land in, so the message is simply gone.
Please persist channel provenance as an immutable property of the message or task (not something a routing binding implies) and gate the cancel path on that. Worth a regression test covering ingest → archive/unbind → cancel, for both a queued and a started task.
2. Media timeout / bind failure leaves unreclaimable object-storage orphans
In resolveAndBindMedia, when the deadline fires the already-resolved refs are dropped wholesale (resolved.MediaRefs = nil) — the comment there acknowledges those refs "may already sit in object storage" — and a BindMedia failure is likewise only logged as a warning. In both cases the uploaded objects have no attachment row, so nothing can enumerate them later during workspace/session deletion.
The deterministic-object-key rationale (redelivery overwrites the same key) no longer applies under the new ordering: dedup is marked in-tx alongside the chat message before the detached media work runs, so a redelivered event is dropped as a duplicate and the resolver never runs again for it. That means a multi-resource rich post where the first uploads succeed and a later one times out, or any upload that succeeds before the bind fails, leaves objects behind permanently — up to the per-resource cap each, with no aggregate bound across messages.
Please add explicit compensation for "uploaded but not bound": either delete by StorageKey on timeout/bind failure, or persist the pending objects for a reaper to reclaim. A test asserting that a partial-upload + deadline/bind-failure run leaves nothing outside the attachment table would lock this in.
Nits
handler/daemon.go(~L2112) still says "Legacy and channel (Slack/Lark) tasks carry a NULL owner and keep the trailing-message selector" — new channel tasks now get a self owner, so this will mislead the next reader about the rolling-deploy branch.content_flatten.go(~L25) still says "Attachment ingestion is explicitly out of scope (tracked as a separate attachment-pipeline issue)" — that's what this PR implements.feishu_types.goMediaRefsdoc says "feishuChannel fills these before handing the normalized message to the channel engine", which predates the detached resolver; media is now bound after the message is appended.
CI is green (6/6) and the branch merges cleanly, so these are the remaining items from my side.
The previous guard keyed the cancel restore-delete off ChatSessionHasChannelBinding, but a binding only proves routing exists right now: archiving a session and rebinding an installation both delete the binding while preserving chat history, so a still-cancellable sealed task could again restore-delete the original inbound messages. Persist provenance on the message instead: migration 203 adds chat_message.channel_ingested, stamped inside the channel append transaction and never mutated, and both cancel finalize paths now gate on TaskHasChannelIngestedMessages over the task's sealed batch. The binding-existence query is removed. Regression tests cover ingest -> archive/unbind -> cancel for a queued and a started task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deadline expiry dropped already-resolved refs and a BindMedia failure was log-only, leaving uploaded objects with no attachment row and no reclaim path — the dedup mark commits with the message before media runs, so a redelivery is dropped as a duplicate and never re-resolves (and thus never overwrites) those keys, and workspace/session deletion only enumerates the attachment table. Add MediaResolver.DiscardMedia — a best-effort delete by StorageKey — and call it from both failure paths in resolveAndBindMedia. The Feishu resolver forwards to the storage backend's Delete. Tests cover a partial upload discarded at the deadline, discard on bind failure, and key-level deletion in the resolver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Channel tasks now seal a self-owned input batch, media ingestion is no longer out of scope for the flattener, and MediaRefs are filled by the detached resolver after append rather than by feishuChannel pre-engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t_task_id Sealing gave channel tasks a self input-owner, which broke writeChatCompletionOutcome's discriminator: it treated any owned task as direct, so an empty channel completion wrote the no_response fallback row and the outbound patcher — which forwards any non-empty chat:done content verbatim — pushed the English fallback body to Feishu/Slack, violating the MUL-4351 contract. Silence is now decided by the immutable channel_ingested provenance of the task's input batch, looked up by the batch OWNER id (chat_input_task_id): auto-retry clones inherit the owner while their sealed messages stay tagged with the parent's id, so keying off the task's own id would misread a channel retry as direct. The cancel-path provenance gates switch to the same owner key via chatInputOwnerID. chat_input_task_id is back to meaning only "input batch owner". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream main merged 202_runtime_profile_add_qwen while this branch held 202/203, tripping TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the CI merge tree. channel_media_pending becomes 203 and channel_ingested becomes 204; no content changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merging main brought multica-ai#5645 (keep direct chat replies in Multica), whose outbound gate assumed channel tasks leave chat_input_task_id NULL. Sealed channel tasks own an input batch too, so on the merge tree every channel reply and failure notice was classified as direct and silently dropped — agents stopped replying in Feishu/Slack. Both outbound gates now call engine.TaskInputIsChannelIngested: a NULL owner keeps multica-ai#5645's deliver-by-default for pre-sealing tasks, an owned batch delivers only when it carries the immutable channel_ingested stamp (keyed by the owner id, so auto-retry clones inherit the verdict). Direct replies stay in Multica; sealed channel replies reach the platform. Tests cover both directions on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DiscardMedia shared finalizeCtx with BindMedia, so a bind that failed because the finalize deadline expired handed the storage deletes an already-dead context — the compensation silently no-opped and the orphans leaked anyway. The deadline path also ran S3 deletes before the marker clear, eating the same 5s budget the user-facing bind/promotion needed. Collect the refs from both failure paths, run bind + promotion on the finalize budget first, then delete on a fresh discard context. The bind-failure test now pins that discard receives a live context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…attachments (MUL-4934) (multica-ai#5580) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYStwEXJFCJLokYqKVUBUg
…ed pull path (#5022)
Reconciled #5022's channel-agnostic chat-history refactor against our branch:
- lark/{client,http_client}.go + 3 test mocks: keep both #5580's
DownloadMessageResource family and #5022's ListContainerMessages.
- handler.go: take theirs' ChatHistory (channel dispatcher) rename, keep ours'
LLM field that #5022's older base predated.
- handler/daemon.go: keep ours' is_agent_intro/ChatIntro gate, take theirs'
GetChannelChatSessionBindingBySessionAny lookup; drop now-unused channel/slack
imports.
- daemon/prompt.go: keep ours' execenv.ChannelDisplayName (centralized mapping).
- daemon_claim_channel_type_test.go: ChatInThread is now channel-agnostic — a
Feishu topic session reports true (Feishu history reader exists via
NewChatHistoryRouter, MUL-4166); updated the assertion + doc accordingly.
Also applied #5580's pending migrations (203/204 chat_message channel columns)
to the local DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NYStwEXJFCJLokYqKVUBUg
|
Addressed both blockers, plus two interactions a follow-up pass surfaced. Reviewed at 1. Cancel provenance is now immutable ( 2. Uploaded-but-unbound objects are reclaimed ( 3. Also merged Two known residuals, both bounded: a rolling-deploy window where messages appended by a pre-upgrade replica carry no stamp (transient, minutes), and a crash between upload and bind still orphans objects (inherent to the delete-on-failure option; a reaper would close it if you'd prefer that instead). Validation: full |
Bohan-J
left a comment
There was a problem hiding this comment.
Reviewed at a707af10a302f226fb9b58355e26e367bf6e5366. Both blockers from the previous round are correctly closed:
- Cancel-path provenance — migration adds an immutable
chat_message.channel_ingestedstamp, written inside the message transaction on channel append. Cancel / deferred-cancel / retry-clone now gate the restore-delete onTaskHasChannelIngestedMessages(chatInputOwnerID(task))— the sealed input batch keyed bychat_input_task_id— instead of the deletablechannel_chat_session_binding. Retry clones inherit the owner, so they land on the same discriminator. Regression tests cover ingest → unbind → cancel for both queued and started tasks. - Known-ref compensation — on deadline or bind failure the already-uploaded refs are collected and deleted by
StorageKey(DiscardMedia) under a fresh budget, and the stale comments are updated. - The post-
#5645merge also stops discriminating direct vs. channel outbound onchat_input_task_idpresence, so a sealed channel reply isn't silently dropped.
One issue remains, in the compensation protocol itself: it treats "the call returned an error" as "the side effect definitely did not happen." Both the S3 upload and the DB COMMIT have a result-uncertain window where that assumption is wrong, and the two failure modes corrupt state in opposite directions.
Must-fix — wrong compensation for result-uncertain uploads / commits
1. Upload error → permanent orphan (no crash required). In media_ingest.go, the resolve loop only appends the (deterministic, already-known) StorageKey to MediaRefs when uploadResource returns nil; on error it continues. But PutObject can succeed server-side and still surface a client error — a lost response, a dropped connection, or the media context deadline firing after the object was written. The key never reaches the router, so DiscardMedia is never called for it; and because dedup is now committed in-tx with the chat message before the detached media work runs, the redelivered event is dropped as a duplicate and the resolver never runs again. So an ordinary timeout leaves a permanent, unreclaimable object (up to the per-resource cap), with no aggregate bound across messages.
2. Commit error → deletion of a live, referenced object. ChatSession.BindMediaRefs returns the tx.Commit(...) error verbatim as a bind failure, and resolveAndBindMedia responds to any bind failure by adding every ref to discardRefs and deleting them. But a Commit that returns an error is not a guarantee that the transaction rolled back — a lost commit ack, or the finalize context expiring mid-commit, can return an error while Postgres has durably committed the attachment + link rows. The discard then runs on a fresh context (by design, so it survives a finalize-deadline bind failure — which is exactly the ambiguous-commit case) and deletes the underlying objects. The result is a persisted attachment row pointing at an object that no longer exists — a broken attachment the user will hit.
Please make the compensation distinguish "known-failed" from "unknown-result":
- Upload error path: retain the attempted
StorageKeyand idempotently delete it under a fresh context (the key is deterministic and already computed, so this is cheap). - Unknown commit result: before deleting, verify the durable link by message/key (or an equivalent persisted identifier) and only discard when no attachment references the object; otherwise keep it.
- If a reliable decision isn't feasible inline, a persisted pending/outbox row reclaimed by a reaper converges both directions.
Two fault-injection tests would lock this down: (a) storage accepted the object but the client got an upload error → assert no orphan; (b) the DB committed but the client got a commit error → assert the bound object is not deleted.
CI is green (6/6) and the branch merges cleanly, so this is the one item left from my side.
The compensation protocol treated "the call returned an error" as "the side effect did not happen", which is wrong in both directions across the result-uncertain windows: - An upload error can follow a server-side write (lost response, deadline mid-write). The attempted key never reached the router, so nothing could reclaim it and dedup guarantees no re-resolve. The resolver now idempotently deletes the deterministic key on a fresh budget right at the failure site. - A commit error is not a rollback guarantee: a lost ack can report failure after Postgres durably committed the attachment rows, and the router's discard would then delete objects those rows reference. BindMediaRefs now converges the ambiguity on a fresh budget — any of the batch's URLs present proves the atomic commit landed (bind reports success); none proves the rollback (discard stays safe); a failed verification returns ErrMediaBindResultUnknown and the router keeps the uploads, preferring a rare orphan over a broken attachment. Fault-injection coverage: an upload error deletes the attempted key; a lost-ack commit keeps the bound attachment and reports success; a verified rollback stays a discardable error; the router keeps uploads on the unknown-outcome sentinel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Rebased on today's One more hardening found in a self-review pass ( On the late-PUT residual: a design that fences it exists (post-PUT ownership re-confirmation + the uploader's own ordered self-delete, shrinking the settle assumption to only client-aborted PUTs materializing >15min after abort), but given the object store's bounded request lifetime we've judged the current exposure acceptable and left it unimplemented — happy to add it if you'd rather close that too. |
Bohan-J
left a comment
There was a problem hiding this comment.
Reviewed at c73b75628de46b7c696e11cdc683ce973e0c3598. The intent-ledger + reconciler is now substantially in place and gets most of the hard parts right: the intent row is written before the PUT; bind only recognizes and deletes a pending row inside the attachment transaction; once the reconciler claims a key (deleting) neither upload nor bind can resurrect or link it; object-store I/O is kept out of DB transactions; failed DELETEs retain the row and back off; and lease tokens, expiry reclaim, tenant predicates, metrics, and the standalone worker are all there. Migrations rebased cleanly to 224–229 with per-statement CREATE INDEX CONCURRENTLY and no FKs/cascades. This is close.
Two blocking issues remain.
1. The late/aborted PUT still isn't fenced — settle is carrying correctness weight the design says it must not
channel_media_reconciler.go states, as the load-bearing property, that the settle delay "carries NO correctness weight" and that correctness comes from the ledger state machine. But the state machine only fences bind vs. DELETE (via the deleting claim that upload/bind check before resurrecting a key). It does not fence a PUT that is still in flight at the object store. The upload-error path deliberately leaves the pending row and returns (its own comment notes "the store may still be processing the PUT"), handing the row to the reconciler. So this sequence still leaks:
- Resolver writes
pending, starts the PUT; the client times out / disconnects and gets an error. - After the settle delay the reconciler claims
deleting, finds no attachment, DELETEs the key, and clears the ledger row. - The original PUT then durably lands at the store — now an object with no ledger row and no attachment, which nothing will ever reclaim.
The only thing preventing step 3 is the assumption that a client-aborted PUT won't materialize after the settle window — i.e. settle is the correctness barrier for the PUT/DELETE race, which contradicts the invariant the file asserts. This is the same residual acknowledged in the design discussion, just relocated into the reconciler.
To close it, the uncertain PUT needs a durable fence that survives the ledger row's deletion: e.g. keep a tombstone / re-delete step keyed by the object so a late materialization is caught, or upload under a staging key reclaimed by a store lifecycle rule and only promote on confirmed bind. Whatever the mechanism, add an interleaving test where the DELETE completes first and the original PUT lands afterward, asserting no surviving object.
2. Duplicate rich-post resources can produce a dangling (or duplicate) attachment on LocalStorage
mediaResourcesFromPost in media_ingest.go appends every img/media span with no dedup, so a post that references the same image_key/file_key twice yields two resources with the same deterministic mediaObjectKey. Combined with LocalStorage.UploadStream, which os.Creates the destination (truncating any existing file) and os.Removes it if the copy errors, this happens:
- First span uploads successfully → file written, ref added to
MediaRefs. - Second span (same key)
os.Create-truncates that just-written file, then its download stream errors mid-copy →os.Removedeletes the file entirely. - The first ref is still in
MediaRefs, so bind creates an attachment row pointing at a file that no longer exists — a dangling attachment.
If instead the second upload succeeds, you get two refs with the same key/URL and two attachment rows for one object (duplicate attachment).
Fix: dedup/reuse by deterministic storage key before entering the upload loop, and make LocalStorage.UploadStream write to a temp file in the same directory and atomically rename into place, so a failed write can never destroy an already-successful object. A regression test with a duplicated span (first upload succeeds, second stream fails) would lock this in.
Non-blocking
The reconciler claims a batch of 50 rows under one 2-minute lease and then processes serially. The per-row heartbeat correctly stops a stale owner from continuing, but it doesn't stop a tail row from repeatedly expiring and being reclaimed (bumping attempt) before its turn comes up — the new test even asserts a tail row going from attempt 1 to 2. Consider per-row (on-demand) claiming or bounded concurrency so rows that have never actually had a DELETE attempted don't accrue attempt/backoff. Not a blocker.
CI is green (6/6) and the branch merges cleanly. Fix the two above and it should be in good shape for a final pass.
A rich post may reference the same image_key/file_key in several spans. The object key derives from (message, type, key), so duplicates uploaded to the SAME key twice: LocalStorage.UploadStream truncated the destination up front and removed it outright on a copy error, so a second failing attempt destroyed the object the first success had produced — leaving an attachment row pointing at nothing. A second succeeding attempt instead produced two attachment rows for one object. Collapse duplicate spans by (fetch type, platform key) before the upload loop, and write local uploads through a temp file renamed into place so a failed write can only discard its own temp file. Tests cover a duplicated span uploading once and a failed re-upload leaving the previous object intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DELETE cannot be ordered against a PUT the client already abandoned:
the store may materialize the object after the delete completes. The
reconciler cleared the ledger row right after deleting, so such an
object had no row and nothing to reclaim it — which made the settle
delay the de-facto correctness barrier for the PUT/DELETE race, exactly
what the design says it must not be.
The row is now kept as a tombstone ('tombstoned' state, migration 226's
CHECK) and re-deleted on a widening schedule (15m, 1h, 6h, 24h, the
pass index carried in last_error), so a late materialization is
reclaimed by a later pass; only after the schedule is exhausted is the
row dropped. Claim, heartbeat, lease, and tenancy predicates are
unchanged — a tombstone is claimed exactly like any other due row. A
separate gauge reports tombstones so they cannot be mistaken for a
backlog of objects awaiting reclaim, and the header comment now states
precisely what state fences (bind/commit) versus what the schedule
fences (late PUTs).
Tests: the reviewer's interleaving — DELETE completes, the abandoned PUT
materializes right after, and the object is gone by the end of the
schedule — plus a full schedule walk asserting the object is counted
once and the row clears at the end.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stion A tombstone revisit ran the same reference check as a first settle, so an attachment carrying the same URL — a re-ingested copy of the object — sent the row down the "referenced, keep it" branch: the object was kept and the row cleared, abandoning the re-delete schedule that fences the ORIGINAL object against an abandoned PUT. A tombstone has already been judged unreferenced and deleted; it exists only to re-delete whatever materializes later, so it now goes straight to the delete + schedule tail (extracted as settleDeletedObject, shared with the first settle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both blockers addressed at 1. Late/aborted PUT is now fenced by a tombstone schedule, not by settle ( While re-checking that change I found it had a hole of its own, fixed in 2. Duplicate post resources and local overwrite ( The non-blocking batch/lease point is noted as a follow-up — per-row on-demand claiming so a tail row can't accrue attempt/backoff before its first delete is even tried. Validation: full affected packages, |
The re-delete pass index was encoded into last_error, which the failure path also writes: one failed re-delete erased the position and restarted the walk. A store failing intermittently could therefore keep a tombstone alive indefinitely — every recovery would resume at pass 1 and the row would never reach the end of the schedule to be dropped. tombstone_pass is now its own column (the table is introduced in this PR, so migration 226 carries it), advanced only by a successful delete, and the tombstone write clears the now-stale last_error. Test walks the schedule across a failed re-delete and asserts it resumes rather than restarts, and that the row still terminates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The object key was derived from the platform message alone, so a second ingest of the same Feishu message reused the first ingest's ledger row. That row can be a tombstone (up to ~31h while the re-delete schedule runs), and the intent upsert refuses anything that has left 'pending', so the second ingest skipped the upload and silently produced a placeholder with no attachment. A re-ingest is reachable: the inbound dedup claim is reclaimable once 60s stale and the dedup row is only vacuumed after 24h. Keying on the chat message the object will attach to keeps the two ingests independent, and nothing leaks: each one's objects are covered by its own ledger row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rite UploadStream wrote through a temp file and renamed into place, but the buffered Upload path still truncated the destination up front — the destructive shape the stream path exists to avoid, one caller away from coming back. Both now share writeAtomic, which also restores the 0644 the direct write used (CreateTemp makes files 0600). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bohan-J
left a comment
There was a problem hiding this comment.
Reviewed at 58f8ff492bc99c123b129d681ea8b16db649e10c. Both items from the previous round are properly closed: rich-post resources are deduped by (fetch type, platform key) before the upload loop, and LocalStorage.UploadStream now writes to a temp file and atomically renames, so a failed second upload can no longer destroy a prior success (with a regression test that pins it). The late-PUT case is also no longer resting on a single 15-minute settle — the row is kept as a tombstoned entry and re-deleted on a widening 15m/1h/6h/24h schedule, with claim, lease, tenant predicate, and metrics intact. Good progress.
Two new must-fixes, both in the material added this round.
1. The tombstone re-delete deletes the object even when an attachment references it — encoding the one outcome the ledger exists to prevent
On a tombstone revisit, settle in channel_media_reconciler.go skips the reference check and calls settleDeletedObject directly, and TestChannelMediaReconciler_TombstoneIgnoresLaterAttachmentWithSameURL pins this: it creates an attachment carrying the row's URL and asserts the object is deleted anyway. Since the storage key/URL is the same string whether it came from the abandoned PUT or a later write, deleting it removes the only object that attachment can read — a dangling attachment, which is precisely the failure the whole intent-ledger was built to avoid, and it contradicts the principle used everywhere else here ("a reclaimable orphan beats a broken attachment").
Worth being precise about reachability, because it points at the right fix: the ledger PK is storage_key, mediaObjectKey is per-(message, resource), and dedup blocks redelivery of the same message — so a legitimate same-URL rebind during the tombstone window is effectively unreachable. That's the argument for the opposite posture, not this one: a tombstone pass that finds a durable attachment reference is observing a state that "can't happen," i.e. a broken invariant — and the safe response to that is to keep the object and surface an anomaly, never to delete it and manufacture the broken attachment. The comment justifies the delete as "the attachment belongs to a re-ingested copy," but with a deterministic key a re-ingest can't even create a second pending row (PK conflict) or attach to the tombstoned one, so that copy can't get an attachment in the first place.
So: keep the reference check on tombstone passes and treat a positive result as keep-and-alert rather than delete. If re-ingestion at the same key is genuinely intended, the keys must be generation/version-scoped so an old tombstone can only ever delete its own generation — deleting a current, referenced object is not an acceptable branch either way. Please flip the test's contract to assert the referenced object survives.
2. The atomic-write temp file becomes an untracked orphan on a crash
The temp-file + rename change is right for the mid-copy-failure case. But LocalStorage.UploadStream names the temp file with os.CreateTemp(..., "."+base+".tmp-*") — a random suffix. The error paths remove it, but a process crash between CreateTemp and Rename leaves it behind, and nothing can reclaim it: the ledger records only the final storage_key, and DeleteObject removes only the final file and its sidecar. Each such file can approach the 100 MiB cap, and repeated crashes accumulate with no bound. (S3 is unaffected; this is local-storage only.)
Make the temp path deterministic from the storage key so DeleteObject/the reconciler can clean it, or add a safe stale-temp sweep — a defer os.Remove can't cover a crash. A regression test that plants a leftover crash-temp and asserts the reconcile/delete path removes it would lock it in.
Verification
CI is green (6/6), migrations 224–229 keep unique prefixes with per-statement CREATE INDEX CONCURRENTLY and no FKs/cascades, and the branch merges cleanly.
The ledger is close. These two are bounded fixes — a posture change on the tombstone reference check, and bringing the crash-temp into durable cleanup — and after them it should be ready for a final pass.
The rename-into-place rewrite made a failed chmod fail the whole upload. CreateTemp's 0600 has to be widened to the 0644 the direct write used, but an upload dir on a mount that ignores chmod (SMB/NFS/FUSE) accepted the old direct write fine — turning those deployments' uploads into hard errors would be a regression for a cosmetic property. Log and continue. Tests pin 0644 on both upload paths, and that a failed buffered upload leaves no temp litter and no damage to a previous object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tombstone pass skipped the reference check and deleted unconditionally,
so a durable attachment carrying that URL lost the only object it can read
— the dangling attachment the intent ledger exists to prevent, and the
opposite of the posture every other path here takes ("a reclaimable orphan
beats a broken attachment").
The check now runs on every pass. A positive result on a tombstone is
unreachable by design — keys are per (chat message, resource) and a bind
cannot attach a key that has left 'pending' — so reaching it means an
invariant broke: keep the object, clear the row, log it, and count it on
a dedicated reconciler_tombstone_referenced_total counter. The test's
contract is flipped to assert the referenced object survives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
os.CreateTemp's random suffix meant a crash between the staging write and the rename left a file nothing could name: the ledger records only the final storage key, and DeleteObject removed only the object and its sidecar. Each leftover can approach the 100 MiB resource cap and they accumulate without bound. The staging path is now derived from the object key, so DeleteObject removes it alongside the object — which makes the media reconciler reclaim it too, since the intent row is written before the upload. Opening it 0644 directly also drops the chmod the previous commit had to make best-effort. Both read paths refuse the staging name (keys come from the request URL, and a half-written body should not be readable); a user-supplied ".tmp" extension is unaffected, since object keys are generated and never dot-prefixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main took 224 (agent_task_session_rollout_missing), so the ledger group moves to 225-230 and the cross-references inside the table migration follow. main's CompleteTask also grew a sessionRolloutMissing parameter; the three call sites this PR added to chat_input_ownership_test.go pass false. Verified the way the numbering is meant to be verified: full migration set applied from scratch on the merged tree, and the whole server suite run against that database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both must-fixes are in, at 1. The tombstone re-delete no longer deletes a referenced object ( 2. The keys are now generation-scoped too ( 3. The staging file is reclaimable after a crash ( Correction to my previous comment. I wrote that the tombstone's pass index is carried in Also in this batch: Merge mechanics. Worth noting for your next pass: The non-blocking batch/lease point is still open as a follow-up. Validation: full server suite against a database migrated from scratch on the merged tree (225–230 apply cleanly), |
The reconciler built settle cutoffs, lease expiry, backoff and re-delete times from the process clock and compared them against the database's now(). A replica whose clock had drifted would therefore settle rows whose upload was still in flight (the object is deleted and the bind then refuses to attach — media silently lost), hand out leases that are born expired (rows churn between workers, attempt/backoff inflate), or compress the tombstone schedule that fences a late-materializing PUT. The four settle queries now take durations and derive their timestamps from now(), so every replica reads one clock. The parameter types are the guard: an app-side timestamp can no longer be passed. Test asserts the persisted lease, backoff and re-delete deadlines all track the database's now(). The generated code also picks up main's new agent_task_queue column in the two RETURNING task.* queries this PR adds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six files carried whitespace/comment-reformatting with no functional change, unrelated to the inbound media pipeline. Reverting them to the base revision keeps the diff focused on the feature (75 -> 69 files): server/internal/service/empty_claim_cache.go server/internal/integrations/lark/markdown_detect.go server/internal/integrations/lark/ws_chunk_assembler.go server/internal/integrations/lark/ws_chunk_assembler_test.go server/internal/integrations/lark/ws_frame_test.go server/internal/integrations/lark/registration_test.go Verified: `git diff -w` against these files was already empty, so no behavior is affected. go vet clean; tests covering these files pass. Co-authored-by: multica-agent <github@multica.ai>
|
Heads-up: I pushed one small commit to this branch ( Six files carried whitespace / comment-reformatting with no functional change, so I reverted them to the base revision:
Likely cause is a format-on-save / repo-wide One related nit I left alone rather than deciding for you: |
|
Merged — thank you for sticking with this one. 🙏 This went through eight review rounds and two architecture reworks, on something that started as "let the agent see an image." I want to be straight that most of that growth came from our review bar, not from you over-scoping: you shipped a working media pipeline early, and then we kept pulling on the thread behind it — first getting the download off the Lark ACK path, then the S3 fixed-length upload, then immutable provenance so cancellation couldn't delete an inbound message, and finally the two-system atomicity problem between object storage and Postgres. That last one is genuinely hard, and it wasn't visible from the original issue. A few things you did that made this land well:
One optional follow-up, non-blocking and already noted: the reconciler claims a batch of rows under a single lease and processes them serially, so a tail row can expire and be reclaimed before its turn, inflating its I also pushed one commit of my own to drop unrelated gofmt churn before merge — nothing behavioral, just to keep the diff reviewable. Thanks again for the patience and the care; Feishu/Lark users get working image and video handling out of this. |
Follow-up to multica-ai#5580. The reconciler claimed up to 50 rows in one statement and then processed them serially, so a tail row sat in 'deleting' with its attempt already bumped while earlier rows ran. If the batch outlived the lease, that row could be reclaimed and bumped again before its own DELETE had ever been tried — attempt and backoff counted work that never happened, and the backoff they produced delayed the first real attempt. Rows are now claimed one at a time, immediately before their own settle, so a claim is always a promise the sweep keeps in the next few hundred milliseconds. The per-sweep limit is unchanged (50), and so are the settle, lease, tenancy and state predicates. The per-row lease heartbeat goes with it: it existed because one lease had to span a whole batch. A claim now precedes its row's work directly (delete timeout 30s << lease 2m), and every settle write is already lease-token guarded, so a row reclaimed after an expiry ignores the old owner's writes rather than being corrupted by them. Test replaces the mid-batch reclaim case with the invariant behind it: while the first row's delete runs, the next row is still unclaimed ('pending', attempt 0), and both are still settled by the same sweep. It fails against the batch-claiming implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to multica-ai#5580. The reconciler claimed up to 50 rows in one statement and then processed them serially, so a tail row sat in 'deleting' with its attempt already bumped while earlier rows ran. If the batch outlived the lease, that row could be reclaimed and bumped again before its own DELETE had ever been tried — attempt and backoff counted work that never happened, and the backoff they produced delayed the first real attempt. Rows are now claimed one at a time, immediately before their own settle, so a claim is always a promise the sweep keeps in the next few hundred milliseconds. The per-sweep limit is unchanged (50), and so are the settle, lease, tenancy and state predicates. The per-row lease heartbeat goes with it: it existed because one lease had to span a whole batch. A claim now precedes its row's work directly (delete timeout 30s << lease 2m), and every settle write is already lease-token guarded, so a row reclaimed after an expiry ignores the old owner's writes rather than being corrupted by them. Test replaces the mid-batch reclaim case with the invariant behind it: while the first row's delete runs, the next row is still unclaimed ('pending', attempt 0), and both are still settled by the same sweep. It fails against the batch-claiming implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What does this PR do?
Delivers inbound Feishu/Lark images and videos to agents as real Multica chat attachments instead of leaving only
[Image]or[Video]placeholders. It covers standalone media messages and media embedded in rich-textpostmessages.Thinking path:
channel_media_pending_until, migration 224), the chat task is createddeferredwithfire_aton that deadline, and media completion promotes it early — so a process crash still yields the placeholder fallback at the deadline instead of losing the run.queuedtask and be claimed before its attachment binds.Related Issue
Closes #5579
Type of Change
Changes Made
MediaResolverseam and detach media processing from the connector ACK path.chat_message.channel_media_pending_until, migration 224) and create channel chat tasksdeferredwithfire_aton it; media completion promotes the task early, and the claim-path deferred promoter covers crashes.DeferChatTaskForSealedPendingMedia) so a task is never claimable while its own input still has an unexpired media marker.RouterConfig.MediaConcurrency(default 8).imgandmediaspans in rich-text posts.chat_message.channel_ingested, migration 225), stamped inside the channel append transaction; cancel finalization gates its restore-delete onTaskHasChannelIngestedMessagesover the task's sealed batch, so channel messages survive cancellation even after session archiving or installation rebinds delete the routing binding. Channel tasks settle as "Stopped." on both the synchronous and deferred finalize paths.chat_input_task_idto a single meaning (input-batch owner): empty-completion silence and cancel provenance now both derive fromchannel_ingestedlooked up by the batch owner id, so sealed channel tasks — and their auto-retry clones, which inherit the owner while messages stay tagged with the parent — keep the Slack/Lark silent-drop contract instead of pushing theno_responsefallback body externally.mainand fix the collision with fix(channels): keep direct chat replies in Multica #5645's outbound gate: both Lark and Slack outbound now classify task origin viaengine.TaskInputIsChannelIngested(channel provenance by batch owner) instead ofchat_input_task_idpresence, so sealed channel replies and failure notices still reach the platform while direct web/mobile replies stay in Multica.channel_media_pending_object(migrations 226-228: table, CONCURRENTLY-built unique index, primary key USING INDEX per repo convention; claim index 229) is upserted before each PUT, cleared inside the attachment-bind transaction (commit landed ⇔ intents gone, atomically), and every failure path deletes nothing inline — an independent reconciler worker claims settled rows via apending → deletinglease state machine, checks the durable attachment reference only after the claim, deletes unreferenced objects with attempt-based backoff, and reclaims crashed workers via lease expiry. Settle delay is a fixed constant with no correctness weight (invariant-tested at ≥10× every pipeline budget); metrics cover deletes, referenced clears, failures, and backlog.(workspace_id, storage_key, lease_token)— tenancy is never derived from the key string. The reconciler is built and started only when a storage backend exists (a nil deleter would have panicked the worker on pre-existing rows).tombstonedand is re-deleted at widening intervals (15m/1h/6h/24h) before the row is dropped, so a PUT the client abandoned that lands after the DELETE is still reclaimed — the settle delay no longer carries the PUT/DELETE race.img/mediaspans in a rich post before uploading (same derived key), and write local-storage uploads through a temp file renamed into place, so a failed re-upload can no longer destroy an already-bound object.now() + make_interval) so writer and readers share one clock; and bound the media queue waits by the message's own deadline — an expired queued job skips the resolver and finalizes the placeholder immediately.MediaRefsingress path (a vestige of the synchronous design):channel.InboundMessage.MediaRefsis now solelyResolveMedia's output channel, always empty on ingress, attachable only through a claimed ledger intent.MediaResolver.HasMedia(pure in-memory probe on the ACK path) so only messages that actually reference platform media persist a media deadline and enter the media queue — plain text never waits behind the media semaphore and never inherits the 45s crash fallback.How to Test
make setup-worktree.cd server && go test ./internal/integrations/channel/engine ./internal/integrations/lark ./internal/integrations/slack ./internal/storage ./internal/service ./internal/handler -count=1(service/handler DB-backed tests needDATABASE_URLon a migrated database and skip otherwise).cd server && go test -race ./internal/integrations/channel/engine ./internal/integrations/lark ./internal/storage -count=1.cd server && go vet ./....Checklist
Documentation and UI screenshots are not applicable because this changes the server-side inbound adapter without adding configuration or UI.
AI Disclosure
AI tool used: OpenAI Codex; follow-up review-response commits co-authored with Claude Code.
Prompt / approach: Ported a proven Feishu/Lark media-ingestion fix onto the latest upstream
main, traced the adapter-to-channel-to-attachment path, then revised it from maintainer feedback to keep remote media I/O off the ACK path and make non-seekable S3 uploads explicit and testable. A self-review pass then found and fixed the mid-enqueue media-append race with a deterministic regression test, plus the enqueue error-contract and media-concurrency issues.