Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0944004
fix: ingest feishu media as chat attachments
beastpu Jul 9, 2026
56e5795
fix: ingest feishu post embedded media
beastpu Jul 9, 2026
88651c3
fix(lark): make inbound media retries safe
beastpu Jul 17, 2026
e153277
fix lark media resource limit
beastpu Jul 17, 2026
d90a46d
fix(lark): move inbound media off ack path
beastpu Jul 20, 2026
11e1a32
fix(channel): make inbound media runs durable
beastpu Jul 20, 2026
a8cb5c1
fix(channel): close enqueue-vs-append race on media deferral
beastpu Jul 21, 2026
b397289
fix(channel): keep committed chat task out of enqueue error path
beastpu Jul 21, 2026
9df4fd1
fix(channel): cap global media resolution concurrency
beastpu Jul 21, 2026
b6605ca
fix(chat): keep channel-sealed user messages on task cancel
beastpu Jul 21, 2026
0773182
fix(channel): skip the media pipeline for messages without media
beastpu Jul 21, 2026
8e7cfbc
fix(chat): gate cancel restore on immutable channel provenance
beastpu Jul 21, 2026
fc07261
fix(channel): reclaim media uploads that never gain an attachment row
beastpu Jul 21, 2026
ecfc917
docs(server): refresh comments stale after detached media ingestion
beastpu Jul 21, 2026
8b04669
fix(chat): stop keying channel empty-completion silence off chat_inpu…
beastpu Jul 21, 2026
e2e9c7a
chore(migrations): renumber to 203/204 after upstream took 202
beastpu Jul 21, 2026
2bf6894
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 21, 2026
f5e61b1
fix(channels): gate outbound delivery on channel provenance, not owner
beastpu Jul 21, 2026
a707af1
fix(channel): discard media orphans on a fresh context, after finalize
beastpu Jul 21, 2026
8dad947
fix(channel): compensate result-uncertain media uploads and commits
beastpu Jul 22, 2026
047ea61
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 22, 2026
a61de8d
chore(migrations): renumber to 207/208 after upstream took 203-206
beastpu Jul 22, 2026
8d40a35
docs(channel): note DiscardMedia self-invocation and the unknown-outc…
beastpu Jul 22, 2026
4dc8c42
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 23, 2026
64f090e
chore(migrations): renumber to 212/213 after upstream took 207-211
beastpu Jul 23, 2026
f5f6a06
feat(channel): replace inline media compensation with an intent ledge…
beastpu Jul 23, 2026
f277648
fix(channel): never build or sweep the media reconciler without storage
beastpu Jul 23, 2026
3783405
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 23, 2026
aa6a5aa
chore(migrations): renumber to 213-216 after upstream took 212
beastpu Jul 23, 2026
1857d50
refactor(channel): remove the dead pre-resolved MediaRefs ingress path
beastpu Jul 23, 2026
ada5380
fix(channel): enforce workspace tenancy on every ledger query
beastpu Jul 23, 2026
7737b6d
fix(migrations): build the ledger primary key via a concurrent index
beastpu Jul 23, 2026
833413f
fix(channel): bound each reconciler object delete with its own timeout
beastpu Jul 23, 2026
e614ee6
fix(channel): anchor the media deadline to the DB clock and bound que…
beastpu Jul 23, 2026
7d53f79
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 24, 2026
ce31076
chore(migrations): renumber to 216-221 after upstream took 213-215
beastpu Jul 24, 2026
334e514
fix(channel): start the local media budget before the append transaction
beastpu Jul 24, 2026
2e9268c
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 25, 2026
ea41afe
chore(migrations): renumber to 224-229 after upstream took 216-223
beastpu Jul 25, 2026
c73b756
fix(channel): heartbeat the reconciler lease per row
beastpu Jul 25, 2026
ba49dc6
fix(channel): dedup post media resources and make local writes atomic
beastpu Jul 27, 2026
4c42d96
fix(channel): fence late-materializing PUTs with a tombstone schedule
beastpu Jul 27, 2026
58f8ff4
fix(channel): tombstones must re-delete, not re-ask the reference que…
beastpu Jul 27, 2026
374a9fc
fix(channel): keep the tombstone schedule position in its own column
beastpu Jul 27, 2026
9113493
fix(lark): derive media object keys per chat message
beastpu Jul 27, 2026
1814a7a
refactor(storage): route both local upload paths through one atomic w…
beastpu Jul 27, 2026
9c6d5db
chore(channel): gofmt the media-pending append fields
beastpu Jul 27, 2026
d27f953
fix(storage): keep the local upload chmod best-effort
beastpu Jul 27, 2026
5d4002c
fix(channel): never re-delete an object an attachment references
beastpu Jul 27, 2026
ce894b2
fix(storage): make the local staging file reclaimable after a crash
beastpu Jul 27, 2026
4141f2c
Merge remote-tracking branch 'origin/main' into codex/fix-lark-inboun…
beastpu Jul 27, 2026
e7c7375
chore(channel): renumber the media migrations after merging main
beastpu Jul 27, 2026
d69c77d
fix(channel): let Postgres compute every reconciler deadline
beastpu Jul 27, 2026
958b2fc
chore(lark): drop unrelated gofmt-only churn from this PR
Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion server/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,11 @@ func main() {
)
}
if h.ChannelRouter != nil {
h.ChannelRouter.Drain()
drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second)
if !h.ChannelRouter.Drain(drainCtx) {
slog.Warn("channel router: drain deadline reached; deferred media fallback remains durable")
}
drainCancel()
}
}

Expand Down
3 changes: 2 additions & 1 deletion server/cmd/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,9 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
Credentials: installSvc,
Logger: slog.Default(),
})
mediaResolver := lark.NewFeishuMediaResolver(larkClient, installSvc, store, slog.Default())
channelRouter.Register(channel.TypeFeishu, lark.NewFeishuResolverSet(
cs, feishuSession, auditLogger, resolverReplier, typingIndicator,
cs, feishuSession, auditLogger, resolverReplier, typingIndicator, mediaResolver,
))
slog.Info("lark inbound pipeline wired", "connector", connectorLabel)

Expand Down
13 changes: 12 additions & 1 deletion server/internal/handler/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2644,6 +2644,9 @@ func (h *Handler) ResolveTaskSkillBundles(w http.ResponseWriter, r *http.Request
// these, not just the latest. Every completed or failed run writes an
// assistant row, so the anchor advances one turn at a time; the result is the
// whole slice on the first turn and exactly the new message(s) thereafter.
// Legacy channel tasks also stop at the first unexpired media marker so an old
// run cannot consume a placeholder that has not been bound yet. New channel
// tasks use task-owned input batches and do not depend on this fallback.
func trailingUserMessages(msgs []db.ChatMessage) []db.ChatMessage {
start := 0
for i := len(msgs) - 1; i >= 0; i-- {
Expand All @@ -2652,7 +2655,15 @@ func trailingUserMessages(msgs []db.ChatMessage) []db.ChatMessage {
break
}
}
return msgs[start:]
msgs = msgs[start:]
now := time.Now()
for i := range msgs {
pending := msgs[i].ChannelMediaPendingUntil
if pending.Valid && pending.Time.After(now) {
return msgs[:i]
}
}
return msgs
}

// ListPendingTasksByRuntime returns queued/dispatched tasks for a runtime.
Expand Down
27 changes: 27 additions & 0 deletions server/internal/handler/daemon_chat_prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@ package handler

import (
"testing"
"time"

"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)

func msg(role, content string) db.ChatMessage {
return db.ChatMessage{Role: role, Content: content}
}

func mediaPendingMsg(content string, until time.Time) db.ChatMessage {
return db.ChatMessage{
Role: "user",
Content: content,
ChannelMediaPendingUntil: pgtype.Timestamptz{Time: until, Valid: true},
}
}

func contents(msgs []db.ChatMessage) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
Expand Down Expand Up @@ -76,6 +86,23 @@ func TestTrailingUserMessages(t *testing.T) {
in: []db.ChatMessage{msg("user", "hi")},
want: []string{"hi"},
},
{
name: "stops before media that is still pending",
in: []db.ChatMessage{
msg("user", "ready"),
mediaPendingMsg("[Image]", time.Now().Add(time.Minute)),
msg("user", "after image"),
},
want: []string{"ready"},
},
{
name: "expired media placeholder is ready",
in: []db.ChatMessage{
mediaPendingMsg("[Image]", time.Now().Add(-time.Minute)),
msg("user", "after image"),
},
want: []string{"[Image]", "after image"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
46 changes: 41 additions & 5 deletions server/internal/integrations/channel/engine/resolvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,40 @@ type EnsureSessionParams struct {
// AppendParams carries the inputs for SessionBinder.AppendMessage. ClaimToken
// is the dedup owner-fence token; the binder runs the dedup Mark INSIDE its
// chat_message+session tx so the durable write and the Mark commit atomically.
// MediaPendingUntil persists the placeholder fallback deadline.
type AppendParams struct {
SessionID pgtype.UUID
Sender pgtype.UUID
InstallationID pgtype.UUID
Message channel.InboundMessage
ClaimToken pgtype.UUID
SessionID pgtype.UUID
Sender pgtype.UUID
InstallationID pgtype.UUID
Message channel.InboundMessage
ClaimToken pgtype.UUID
MediaPendingUntil pgtype.Timestamptz
}

// AppendResult reports what AppendMessage decided.
type AppendResult struct {
// MessageID is the durable chat_message row created by AppendMessage.
// Detached media processing uses it to link attachments after the
// connector ACK path has completed.
MessageID pgtype.UUID
// IssueCommand is non-nil when the message was an /issue command.
IssueCommand *IssueCommand
// DedupMarked is true when AppendMessage finalized the dedup claim in its
// own tx; the Router then skips the post-pipeline finalize.
DedupMarked bool
}

// BindMediaParams carries stored media references to the post-append
// attachment transaction. MessageID is the durable chat_message created by
// AppendMessage; media downloads must never run inside this transaction.
type BindMediaParams struct {
MessageID pgtype.UUID
SessionID pgtype.UUID
WorkspaceID pgtype.UUID
Sender pgtype.UUID
MediaRefs []channel.MediaRef
}

// IssueCommand is the parsed /issue command.
type IssueCommand struct {
Title string
Expand Down Expand Up @@ -161,6 +178,23 @@ type Deduper interface {
type SessionBinder interface {
EnsureSession(ctx context.Context, p EnsureSessionParams) (pgtype.UUID, error)
AppendMessage(ctx context.Context, p AppendParams) (AppendResult, error)
BindMedia(ctx context.Context, p BindMediaParams) error
}

// MediaResolver resolves platform media after the user message and dedup mark
// are durable. The Router runs it off the connector ACK path and binds any
// returned MediaRefs; the independently scheduled task remains deferred until
// binding finishes or the persisted deadline expires. Implementations are
// best-effort: failures leave the stored placeholder text intact.
type MediaResolver interface {
// HasMedia reports whether msg references platform media that
// ResolveMedia would fetch. The Router calls it synchronously on the
// connector ACK path to decide whether to persist a media deadline and
// queue a resolution job at all, so implementations must be pure
// in-memory checks (no I/O). A false result keeps the message on the
// plain ingest path: no marker, no deferred run, no semaphore slot.
HasMedia(msg channel.InboundMessage) bool
ResolveMedia(ctx context.Context, inst ResolvedInstallation, sender ResolvedIdentity, sessionID pgtype.UUID, msg channel.InboundMessage) channel.InboundMessage
}

// Auditor records a dropped inbound event (no message body — drop-audit
Expand Down Expand Up @@ -200,6 +234,7 @@ type ResolverSet struct {
Identity IdentityResolver
Dedup Deduper
Session SessionBinder
Media MediaResolver
Audit Auditor
Replier OutboundReplier
Typing TypingNotifier
Expand All @@ -216,6 +251,7 @@ type IssueCreator interface {
// trigger a chat run. Shared across platforms.
type TaskEnqueuer interface {
EnqueueChatTask(ctx context.Context, session db.ChatSession, initiatorUserID pgtype.UUID, forceFreshSession bool) (db.AgentTaskQueue, error)
PromoteChannelChatTasksIfMediaReady(ctx context.Context, sessionID pgtype.UUID) error
}

// SessionReader reads the rows the debounced flush + /issue identifier need.
Expand Down
Loading
Loading