Conversation
715c48f to
adc7ca8
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Service side of Personal Weixin (iLink) channels, stacked on #3184: Go control plane (connection/login-flow store with generation fencing, internal managed-session find-or-create, owner-scoped channel sessions via WorkAccess/ChannelSessionOwnerRef), Java scheduler (JdbcWeixinStateStore lease/cursor CAS store, control-plane credential provider, headless login endpoints, runtime status reporting), console QR panel and docs.
Verdict: COMMENT — no blocking defect found in the reviewed scope. The four risk areas held up: session reads are owner-scoped across canAccessSession/memory reader/Postgres filter with an owner-checked internal registration; no credential material in logs or public errors (toString overridden to [redacted]); lease + inbox + cursor mutations are SELECT ... FOR UPDATE-serialized in one transaction with claim CAS, so no cross-replica lost update; login endpoints validate input and sit behind the internal token. Two non-blocking robustness notes are pinned inline.
Caveat: at +8981/-121 across 86 files this review was bounded to the service-side Java and the Go control-plane/authz paths — the extension module (covered in #3184), console/TSX, docs and SQL migrations were not reviewed line by line, and nothing was built or exercised against a live PostgreSQL. Please re-check the unreviewed areas on the next push, and note that this PR inherits the unresolved critical from #3184.
Strengths
- Consistent fencing: lease generation +
operation_id, DB-clock expiry, claim/complete CAS, single-transaction cursor + inbox writes. - Clean secret hygiene in logs and error mapping, with tests asserting secrets never reach public responses.
- Strict channel-session reuse check (binding, origin type/ref and owner must all match) closes the obvious cross-binding reuse hole.
Automated review by github-manager-bot
| runtimeObservations.put( | ||
| channelId, new RuntimeObservation(false, null, lease, 1)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Unguarded credentialRevision parse can abort the whole runtime-status report. When a Weixin channel is built, the credentialRevision property is parsed inside a try/catch; here it is parsed unguarded:
item.put("credentialRevision", Long.parseLong(String.valueOf(entry.getValue().getProperties().get("credentialRevision"))));If the property is missing or non-numeric (config drift, or a channel persisted before the revision was attached), String.valueOf(null) produces "null" and Long.parseLong throws NumberFormatException. That aborts reportRuntimeStatus for all channels, not just the offending one — and since stop() bumps observations and calls reportRuntimeStatus() synchronously, the failure can also propagate out of shutdown. Suggest reusing the guarded parse used at channel-build time (skip the field or the item on failure).
| private WeixinRuntimeListener managedWeixinRuntimeListener( | ||
| String channelId, Object listenerToken) { | ||
| return new WeixinRuntimeListener() { | ||
| @Override |
There was a problem hiding this comment.
Weixin start failures are invisible to the control plane while no lease observation exists. This line skips Weixin entries lacking an observation with a lease (if (weixin && (observation == null || observation.lease() == null)) continue;), and started/error are taken from the observation when present. A Weixin channel that fails to build or start (credential fetch failure, bad revision, provider outage) therefore never produces any status item and its recorded error is never reported — the control plane keeps the previous state (e.g. stuck at STARTING) with no operator-visible failure to act on. Consider emitting a failed/stopped item (or the recorded build error) for skips caused by a failed start instead of continue.
adc7ca8 to
b7d56c3
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review after the push on top of the head I reviewed at 09:12Z. The new commit b7d56c3d5b implements the state-retirement contract: JdbcWeixinStateStore.removeAccount deletes cursor / per-peer context / inbox / lease rows for one account in a single transaction, InMemoryWeixinStateStore keeps accounts that hold real state, and the production plan documents why the Scheduler must never evict on its own (a reversible disable is indistinguishable from a disconnect in the configuration feed, so automatic eviction would duplicate deliveries). That reasoning is sound and the docs now record it, which is the part most likely to be lost later.
Verdict: COMMENT — CI is red on this head, so the PR is not mergeable regardless of review state.
build (ubuntu-latest)andbuild (windows-latest)both fail inagentscope-extensions-channel-weixin:WeixinChannelRuntimeTest.peerThrottleStopsDispatchAndSurfacesATransientFailure:208 throttling must surface a transient failure ==> expected: <false> but was: <true>(Tests run: 56, Failures: 1). Check License, Check Module Sync, validate andsourcepass, and CLA is signed. That test lives in the #3184 extension module, so the break is most likely an interaction between this branch and the sibling PR rather than the Scheduler code added here.- One new blocking-adjacent defect in the retirement path itself, pinned inline: deleting the
builder_weixin_leaserow makes a previously unreachable null dereference inacquireLeasereachable. - Both findings from my 09:12 review are still open on this head, and I have re-pinned them to the correct lines: the previous round's comments were anchored at 430/437 of
SchedulerChannelRuntime.java, which is themanagedWeixinRuntimeListenerblock, not the code the text describes. The findings were right; the anchors were wrong. My mistake, corrected here rather than left to point at unrelated lines.
Strengths
- Retirement is correctly modelled as an explicit, one-way host action, and the reason is written down with the mechanism rather than just asserted — the disable-vs-disconnect ambiguity is the kind of thing that gets "optimized" into an automatic eviction by the next person to read the code.
- The new
removeAccounttests check the durable effect (a freshly constructed store sees no cursor, no context token, no current lease, and zero rows in all four tables) instead of only the in-memory view, and they pin that a second account is untouched — which is the isolation property that actually matters here. - Blank/null account id rejection is asserted rather than assumed.
Scope
adc7ca8eb6 -> b7d56c3d5b is 7 commits / 61 files, but only JdbcWeixinStateStore (+17), its test (+41) and the production-plan doc (+9) carry new Scheduler-side logic; the rest is already-reviewed #3186/#3184 content plus main merges. I read the retirement delta closely and re-checked the two previously reported spots in SchedulerChannelRuntime.java; the console/TSX, SQL migrations and extension module were not reviewed line by line, and nothing was built or run against a live PostgreSQL here.
Automated review by github-manager-bot
| } | ||
|
|
||
| @Override | ||
| public void removeAccount(String accountId) { |
There was a problem hiding this comment.
[Warning] removeAccount opens a null-LeaseRow window in acquireLease, which is the one lease read in this class that is not null-guarded.
acquireLease performs its bootstrap insert ... on conflict do nothing through jdbc.update(...) as a standalone auto-commit statement, and only then opens the transaction that takes the row lock:
jdbc.update("insert into builder_weixin_lease ... on conflict do nothing", accountId); // committed, outside the tx
return transactions.execute(status -> {
LeaseRow row = lock(accountId); // rows.isEmpty() -> null
long timestamp = now();
if (row.expiresAt > timestamp && ...) // NullPointerExceptionUntil this commit no code path ever deleted a builder_weixin_lease row (retirement only touched expires_at = 0), so a null lock() result inside acquireLease was unreachable and the unguarded dereference was harmless. Deleting the lease row makes it reachable: a retirement that commits between the bootstrap insert and the SELECT ... FOR UPDATE leaves row == null and the caller gets an NPE out of lease acquisition instead of the Optional.empty() backoff the rest of the class deliberately returns.
Note that withLease already handles exactly this correctly (if (row == null || !row.matches(lease) || row.expiresAt <= timestamp) return rejected;), so a consumer that loses its lease to a retirement is fenced safely — acquireLease is the single gap.
Either arm is sufficient, and both are small:
- null-guard
rowinacquireLeaseand returnOptional.empty()(the next poll re-bootstraps and converges), or - move the bootstrap insert inside the same transaction as the
FOR UPDATE, so the insert and the lock cannot be split by a concurrent retirement.
The two new tests cover the sequential contract well; a retirement racing a start would pin whichever choice you make.
There was a problem hiding this comment.
Fixed in 1a4c775 by taking your structural option: the bootstrap insert now runs inside the same transaction as the SELECT ... FOR UPDATE. A concurrent retirement cannot delete a row this transaction has not committed yet, so lock() always has a row and the window is closed by construction rather than by a check. ON CONFLICT still keeps a concurrent first acquisition from aborting the transaction.
I kept the null guard as well (returns Optional.empty(), so the next poll re-bootstraps and converges). It costs nothing and a future refactor that moves the insert back out would otherwise reintroduce the NPE.
On the race test you suggested: I did not add one, and I want to be explicit rather than quietly skip it. A genuine interleaving needs two connections with a real concurrent commit; the suite's H2 mode serializes that, so a test would pass with or without the fix while claiming to pin it. The commit message records that instead of the test. If you want it pinned, running WEIXIN_TEST_JDBC_URL against a real PostgreSQL with two connections would do it and I am happy to add it.
| Long.parseLong( | ||
| String.valueOf( | ||
| entry.getValue() | ||
| .getProperties() |
There was a problem hiding this comment.
[Warning] Re-pin of a finding from my adc7ca8eb6 review, still open on this head — note the anchor below is the correction: my previous comment on this point was posted against lines 430/437 of this file, which is the managedWeixinRuntimeListener region, not this code. The finding text was right, the line anchor was not, so please read this as the authoritative location rather than a duplicate.
Long.parseLong(String.valueOf(properties.get("credentialRevision"))) is unguarded inside the per-channel loop of reportRuntimeStatus(). A missing or non-numeric property yields the literal string "null" from String.valueOf, so parseLong throws NumberFormatException and aborts the whole status report — every other channel's state stops reaching the control plane because of one bad config entry. The same unguarded parse also appears on the start path (line ~355), where it can fail channel construction.
Parsing per entry with a fallback (skip the entry, or report a sanitized error code) keeps single-config breakage local, which is the same isolation the weixin && observation == null -> continue guard a few lines above already provides.
There was a problem hiding this comment.
Fixed in b21e0a2. credentialRevision(Map) now reads the value leniently — Number directly, a trimmed numeric string via parseLong, otherwise null — the start path fails with credentialRevision is missing or not a number instead of a raw NumberFormatException, and a report-path entry with an unreadable revision is emitted as a failed entry rather than throwing out of the loop. One bad config entry can no longer stop every other channel's state from reaching the control plane.
Two things fell out of it. First, the reconcile loop was calling lastErrors.put(channelId, "failed to build channel") after buildChannel had already recorded the specific reason, so the report would have said only "failed to build channel"; it is putIfAbsent now and the specific text survives (asserted). Second, credentialRevisionIsReadLenientlyPerEntry covers the read directly, since after this fix the end-to-end path cannot produce an unreadable revision for a started channel.
| RuntimeObservation observation = observations.get(id); | ||
| boolean weixin = WeixinChannel.TYPE.equals(entry.getValue().getType()); | ||
| // A standby has no observation authority and must not overwrite the active replica. | ||
| if (weixin && (observation == null || observation.lease() == null)) continue; |
There was a problem hiding this comment.
[Warning] Re-pin of the second finding from adc7ca8eb6 (same anchor correction as the neighbouring comment).
A Weixin channel whose start failed never acquires a lease, so observation == null here and the entry is dropped from the report entirely. The control plane is then shown nothing for a channel that is actively failing, which is indistinguishable from a channel that was never configured — the one case where an operator most needs the signal.
The errors map this method already snapshots carries the code for exactly this case, and the non-Weixin branch below already uses it (observation != null ? observation.errorCode() : errors.get(id)). Emitting an entry with started=false plus that error for a Weixin channel with no observation would surface failed starts without weakening the guard's actual purpose, which is correctly preventing a standby from overwriting the active replica.
There was a problem hiding this comment.
Fixed in b21e0a2 — but it needed a control-plane change as well, and that is worth flagging because the Java-side report alone would have been invisible.
Java: a Weixin channel with no lease observation but a recorded error now reports {channelId, accountId, credentialRevision?, started: false, error}. A healthy standby has no recorded error, so it stays silent exactly as before and the guard keeps its purpose.
Go: applyChannelRuntimeObservation returned early for any Weixin item without a lease holder, so your suggested entry would have been discarded by the control plane. A lease-less item carrying an error now writes channels.runtime_started/runtime_error and returns, leaving weixin_connections' lease fields to the replica that holds the lease; a lease-less item without an error is still ignored.
Tests on both sides: unreadableCredentialRevisionIsReportedInsteadOfAbortingTheReport (end-to-end, asserts started=false and that the specific reason survives) and TestWeixinRuntimeSurfacesAStartFailureWithoutLeaseAuthority, which additionally asserts the active replica's holder/generation/sequence are unchanged and that a lease-less report without an error is still ignored.
WeixinChannelRuntimeTest.peerThrottleStopsDispatchAndSurfacesATransientFailure asserted the transient-failure notification after calling channel.stop(), which races the poll loop: a stop that lands between failMessage() and the loop's catch makes it skip the notification (correct shutdown behaviour), leaving the list empty and the assertion red. It failed on the agentscope-ai#3186 CI run on both runners. Wait for the report before stopping instead. Same coverage, no race.
… config entry Two report-path defects from the review of agentscope-ai#3186, plus the parse that fed them. `reportRuntimeStatus` skipped every Weixin channel without a lease observation. That guard exists so a standby cannot overwrite the active replica, but a channel that failed to build or start never acquires a lease either, so it was dropped from the report entirely: the control plane was shown nothing for a channel that is actively failing, which is indistinguishable from a channel that was never configured. A recorded error now produces an entry with `started=false` and the error, while a healthy standby (no error) stays silent as before. `Long.parseLong(String.valueOf(properties.get("credentialRevision")))` ran unguarded inside the per-channel loop, so one missing or non-numeric value threw `NumberFormatException` and aborted the whole report — every other channel's state stopped reaching the control plane. Reading the revision is now lenient (`credentialRevision(Map)`, `null` when unreadable) and the start path fails with a described error instead of a raw parse exception, so the failure stays local to that channel. The generic "failed to build channel" text no longer overwrites the specific reason. The control plane must accept that report for it to be visible: `applyChannelRuntimeObservation` required a lease holder for every Weixin item and returned early otherwise, so the new failure entry would have been silently discarded. A lease-less item carrying an error now writes `channels.runtime_started/runtime_error` and returns; the connection's lease fields stay untouched, which is the authority the guard was protecting. A lease-less item without an error is still ignored. Tests: `SchedulerWeixinRuntimeTest` gains the end-to-end failed-start report (asserting the specific reason survives) and a unit test for the lenient read; `TestWeixinRuntimeSurfacesAStartFailureWithoutLeaseAuthority` pins the Go side, including that the active replica's lease holder/generation/sequence are unchanged.
b7d56c3 to
b21e0a2
Compare
|
Follow-up on the 17:02Z review (head The red CI was my test, not the Scheduler code. Lease bootstrap window ( Report path ( This head also rebases onto #3184's current head ( Verified locally: weixin extension 60 tests (1 live smoke skipped), service-scheduler 27 tests, |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Delta re-review of the 11 commits pushed since my previous review on b7d56c3d — the Personal Weixin end-to-end slice: channel-origin session registration in the Go control plane, per-channel runtime credential/lease state in internalChannelsConfig / internalChannelRuntimeReport, and the scheduler-side JDBC weixin state store. Direction looks right and the test additions are substantial (channel_session_test +207, JdbcWeixinStateStoreTest +253, SchedulerWeixinRuntimeTest +246, WeixinControlPlaneAdaptersTest +216). The issues below are all in the new ownership / runtime-state paths, which is exactly where a channel integration tends to hurt in production.
Findings
- [Warning]
httpapi/agent_invocation_handler.go:222—store.ChannelSessionOwnerRef(current) != agent.OwnerRefhard-fails withErrConflictfor any channel row written beforechannelOwnerRefexisted, and this PR does not backfill it. - [Warning]
product/handlers_internal.go:978-993— oneweixinRuntimePropertiesquery per channel on the scheduler's config poll, and a single non-ErrNoRowslookup failure turns the whole config payload into a 500. - [Warning]
product/handlers_internal.go:1063— the lease-less error report writesruntime_started=falsewith no generation/sequence fencing, so a standby that merely failed to build a channel can flap the active lease holder's healthy status. - [Info]
httpapi/channel_session.go:47— an emptyexternalKeymints a fresh UUID, which creates a new session per call behind a long-lived internal token. - [Info]
store/postgres/sessions.go:144— the channel branch matchesaccess.Useronly while the sibling branches matchaccess.Refs; worth confirming every construction site setsUser, and the same forstore/memory/access.go.
Verdict
COMMENT, not REQUEST_CHANGES: CI on this head (build (ubuntu-latest) / build (windows-latest)) is still running, so this is deliberately not a merge-readiness verdict, and the first finding is a data-migration question rather than a code-shape objection. The previous items I raised on b7d56c3d are not re-listed. The auth surface itself looks correctly gated — managedSessions.Use(s.internalTokenMiddleware()) is present in httpapi/server.go:409-411, and the OwnerRef/ManagedDefinitionRef cross-checks plus the RowsAffected()==0 CAS on the lease update are the right instincts; I would just close the migration and flap holes before this is exercised with real history. CLA is signed and mergeable=true. Merging stays with the maintainers.
Automated review by github-manager-bot
| } | ||
| if len(existing) > 0 { | ||
| current := existing[0] | ||
| if len(existing) != 1 || current.BindingID != binding.ID || current.OriginType != originType || current.OriginRef != originRef || store.ChannelSessionOwnerRef(current) != agent.OwnerRef { |
There was a problem hiding this comment.
[Warning] This check makes task_context.channelOwnerRef mandatory for every channel-origin session row, but nothing in the PR can produce it for rows written before this commit, and migrate.go only appends weixinMigrationSQL (no UPDATE sessions … anywhere in the diff).
store.ChannelSessionOwnerRef returns "" both when the key is absent and when session.AgentTaskID != nil, so "" != agent.OwnerRef is always true and the handler answers store.ErrConflict — a permanently stuck conversation rather than a recoverable one. Anyone who ran an earlier head of this branch (b7d56c3d already wrote payload = {"originType","originRef"} for the channel path) hits it, and the same row shape can come back any time the metadata write and the read disagree.
Either backfill in the migration, e.g.
UPDATE sessions SET task_context = jsonb_set(task_context::jsonb,'{channelOwnerRef}',to_jsonb(a.owner_ref))
FROM agents a WHERE sessions.agent_id = a.id AND sessions.origin_type='channel'
AND NULLIF(sessions.task_context->>'channelOwnerRef','') IS NULL;or self-heal on read: when the stored ref is empty, treat the row as unclaimed, write agent.OwnerRef through the existing upsert and continue instead of failing. Self-heal is the safer option under a rolling deploy where the writer and the reader are not the same replica. Note also that this PR changes the channel externalKey from originType|originRef|requestedSessionID to plain originRef (line 183), so rows keyed the old way will not be found and new ones get created next to them — worth a line in the PR description about what happens to those.
There was a problem hiding this comment.
Fixed by self-heal in f502800, which is the arm you preferred for a rolling deploy.
ChannelSessionOwnerRef(current) == "" now means unclaimed rather than someone else's: the binding, origin type and origin ref have already been verified at that point, so the row is adopted for this agent and written back through the existing upsert with channelOwnerRef set. The copy carries the stored phase, busy and timestamps, so the metadata-only change does not disturb a live session; a stored owner belonging to a different account is still a conflict.
I did not add the SQL backfill, and the reason is schema rather than effort: sessions (with task_context/origin_type) is created by store/postgres/migrations/0001_init.up.sql, while agents is created by the control plane's product/migrate.go — the join would have to reach across the two schemas from the product migration set, and self-heal also covers the rolling-deploy window a backfill cannot.
Your note about the key change is right and I have added it to the PR description: externalKey went from originType|originRef|requestedSessionID to plain originRef, so rows written by an earlier head are not found by the new key and a fresh session is created next to them. That affects anyone who ran an earlier head of this branch; the old rows stay orphaned (they are still readable if addressed directly) and I have not attempted a merge of the two.
| for id, value := range out { | ||
| cfg := value.(gin.H) | ||
| if cfg["type"] != "weixin" { | ||
| continue | ||
| } | ||
| props, err := s.weixinRuntimeProperties(c.Request.Context(), id) | ||
| if errors.Is(err, pgx.ErrNoRows) { | ||
| delete(out, id) | ||
| continue | ||
| } | ||
| if err != nil { | ||
| writeErr(c, 500, "channel configuration unavailable") | ||
| return | ||
| } | ||
| cfg["properties"] = props | ||
| } |
There was a problem hiding this comment.
[Warning] Two things in this loop on the scheduler's config poll:
- N+1 — one
weixinRuntimePropertiesquery per weixin channel, on top of the full channel scan above.internalChannelsConfigis polled by every scheduler replica, so the cost grows with channel count × replica count. - all-or-nothing — the
if err != nil { writeErr(500); return }turns a single channel's lookup failure (or a slow row) into a failed config response for every channel, which is worse than the behaviour before this commit: the scheduler then has no configuration at all rather than one missing entry.
Suggest batching the lookup (WHERE channel_id = ANY($1) over the weixin ids collected in the first pass) and, on per-channel error, delete(out, id) + slog/log a warning instead of failing the whole payload — the ErrNoRows branch already does exactly that, so the shape is here.
There was a problem hiding this comment.
Both fixed in 68b5ae0.
N+1: the per-channel four-join query is now one WHERE w.channel_id = ANY($1) batch returning the channel id alongside the properties, called once per config poll instead of once per weixin channel.
All-or-nothing: a channel whose connection is missing or unusable is dropped from the payload with a log line (log.Printf("weixin channel %s has no usable connection; omitted from channel config", id)) rather than failing the response, which is what the ErrNoRows branch already did. A genuine query failure (database down) still fails the payload — with one batched query there is no longer a per-channel failure to isolate, and returning a partial config because the database is unreachable would be worse.
internal/product and internal/httpapi tests pass.
| if item.Error == nil || strings.TrimSpace(*item.Error) == "" { | ||
| return nil | ||
| } | ||
| if _, err = tx.Exec(ctx, `UPDATE channels SET runtime_started=false,runtime_error=$1,runtime_updated_at=$2 WHERE channel_id=$3`, |
There was a problem hiding this comment.
[Warning] The lease-less branch skips the ordering guard that the leased branch is careful to add, so this write is unguarded: any replica (or a replayed/stale report) can stamp runtime_started=false plus an error string onto a channel whose lease is currently held by a healthy leader.
The leased path below protects exactly this with the runtime_lease_generation / runtime_report_sequence CAS, and internalChannelsConfig treats a stale runtime_updated_at as an operator-visible signal — so a standby that once failed to build a channel (config unreadable, MCP probe timeout, startup race) can make a running channel look broken in the console until the next successful report.
Options that keep the intent: require runtime_lease_holder IS NULL (or a lease older than the report interval) in the WHERE clause; or write only when runtime_updated_at IS NULL OR runtime_updated_at < $now so it cannot overwrite a fresher leased report; or land the failure on weixin_connections.last_build_error and let the console read the lease state separately.
There was a problem hiding this comment.
You are right, and this was my change from the previous round — the fence is now there in 68b5ae0.
A lease-less report only writes when no lease holder is recorded, or when the last accepted report is already stale (2 minutes — several scheduler poll intervals wide, since builder.scheduler.channel-refresh-ms defaults to 15s). The first arm is what stops a standby that failed to build from flapping a healthy leader; the second keeps a holder that died without being cleared (the holder is only reset on re-authorization, weixin_connection.go:83) from hiding a failed start indefinitely.
TestWeixinRuntimeSurfacesAStartFailureWithoutLeaseAuthority now covers both directions: the failure lands while the lease is unclaimed, then the active replica reports healthy, then a lease-less failure report is asserted to leave started=true and the leased fields alone.
| args = append(args, access.User) | ||
| conds = append(conds, fmt.Sprintf(`((agent_task_id IS NOT NULL AND EXISTS(SELECT 1 FROM agent_tasks t WHERE t.id=sessions.agent_task_id AND issue_access_allowed(t.issue_id,$%d::text[]))) | ||
| OR (agent_task_id IS NULL AND EXISTS(SELECT 1 FROM chat_conversations ch WHERE ch.session_fk=sessions.id AND ch.creator_ref=ANY($%d::text[]))) | ||
| OR (agent_task_id IS NULL AND origin_type='channel' AND NULLIF(task_context->>'channelOwnerRef','')=$%d))`, n, n, n+1)) |
There was a problem hiding this comment.
[Info] This branch matches only on access.User, while the two branches above match on access.Refs ("legacy actor aliases", per the comment you added in store/access.go). That is defensible — channelOwnerRef stores the stable account id — but it means a caller whose identity only appears in Refs loses visibility of channel conversations that they could see before, and WorkAccess is constructed in more than one place.
Two small hardening ideas: (NULLIF(task_context->>'channelOwnerRef','') = $n OR NULLIF(task_context->>'channelOwnerRef','') = ANY($refs)), or keep the strict match and add a test that builds WorkAccess the way each real caller does (console JWT, legacy actor, internal bridge) so a future WorkAccess{Refs: …} without User cannot silently drop rows. Same note applies to the memory implementation in store/memory/access.go, which reads access.User only.
There was a problem hiding this comment.
Confirmed and fixed in 11e87f7 — both implementations now accept the stable id or an alias.
Worth recording what I found while checking it, because it narrows the impact: namespaceAccess (namespace_access.go:241) seeds Refs with the stable user id and appends the legacy aliases, so on the HTTP path Refs is a superset of User and the strict match was equivalent. The gap was in the other construction sites — WorkAccess is assembled directly in tests and internal paths (for example chat_handler_test.go:175, which sets only Refs) — where a channel conversation would have been invisible. The Postgres filter now matches ...=$user OR ...=ANY($refs) and the memory reader checks both.
I did not add a dedicated WorkAccess-construction test for the three caller shapes; if you want one as a guard against a future WorkAccess{Refs: …} without User, say so and I will add it to internal/store/memory where it can run without Postgres.
| } | ||
| if strings.TrimSpace(req.ExternalKey) == "" { | ||
| // Preserve the bridge's one-off overload: absent keys start a new session. | ||
| req.ExternalKey = "channel:" + uuid.NewString() |
There was a problem hiding this comment.
[Info] Minting a fresh "channel:" + uuid when externalKey is empty makes this endpoint create a new session row on every call for a caller that forgot the key — an unbounded growth path behind a long-lived internal token, and it silently defeats the WithSessionLock dedup you set up just below (each call has a distinct lock name).
The comment says it preserves the bridge's one-off overload; if the bridge always supplies a key now (the product handler builds the stable address key before calling), would a 400 externalKey is required be the safer contract, or at least a check that the caller is the bridge? If one-off sessions are genuinely wanted, a note on retention (who deletes them) would help whoever operates this.
There was a problem hiding this comment.
Fixed in a1eb68a along the lines you suggested — the endpoint now answers 400 externalKey is required and no longer mints anything.
On whether the bridge always supplies one: the production caller does. ChannelWorkBridge calls the four-argument overload with result.externalKey(), and that value is built by ChannelExternalKeys.forInbound(...), which always returns a non-blank key (the channel id falls back to "default"). The only path that relied on minting was the deprecated three-argument dispatchAndAwaitReply, which forwarded a null key. It now fails fast with a message pointing at the four-argument overload, rather than leaning on server-side minting.
TestChannelSessionRequiresAnExternalKey asserts the 400.
…lookup Two findings from the review of agentscope-ai#3186, both in the endpoints the scheduler polls. **The lease-less failure report had no ordering guard.** The branch I added in b21e0a2 wrote `runtime_started=false` plus an error for any report without a lease holder, so a replica that merely failed to *build* the channel could stamp "broken" over the state of the replica that holds the lease — the console then shows a healthy channel as failed until the next successful report. The write is now fenced like the leased path: it only lands when no lease holder is recorded, or when the last accepted report is already stale (two minutes, several scheduler poll intervals wide, which keeps a holder that died without being cleared from hiding a failed start forever). **The weixin lookup was N+1 and all-or-nothing.** `internalChannelsConfig` ran one four-join query per weixin channel, and any failure that was not `ErrNoRows` turned the entire payload into a 500 — leaving every scheduler replica with no configuration at all instead of one missing entry. The lookup is now a single `WHERE channel_id = ANY($1)` batch, and a channel with no usable connection is dropped from the payload with a log line, which is what `ErrNoRows` already did. `TestWeixinRuntimeSurfacesAStartFailureWithoutLeaseAuthority` now covers both arms: the failure lands while the lease is unclaimed, and a lease-less report cannot flap a fresh leased report.
|
All five findings closed on head
Two notes on what I did not do:
Verified locally: |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Delta re-review of the four commits since b21e0a24. All five findings from the previous round are addressed, and not cosmetically: the lease-less failure report is now fenced the same way the leased path is, the per-channel config lookup is a single = ANY($1) batch with per-channel degradation instead of an all-or-nothing 500, pre-channelOwnerRef rows self-heal rather than dead-ending in ErrConflict, the accidental channel:<uuid> minting is gone, and both session readers now accept stable ids and aliases identically. Three of the four carry a test for the new arm, which is the standard this PR has been holding throughout.
The three items below are about the same hazard the new commits are fixing — upgrade skew and constants that assume a default — rather than about the shape of the code.
Findings
- [Warning]
httpapi/channel_session.go:50— the new400for a missingexternalKeybreaks a lagging scheduler replica on the control plane, which is the inverse of the self-healingf502800fdeliberately adds. Pick a transition window or state the deploy order. - [Warning]
product/handlers_internal.go:1090—channelRuntimeStaleReportMsis a fixed 2 minutes measured against a configurable poll interval; past that ratio the fence reopens and standby build failures flap a healthy leader again. - [Warning]
httpapi/agent_invocation_handler.go:269— the adoption path rebuildstask_contextfrom three keys, and the upsert replaces that column, so a legacy row carrying extra keys loses them on the first turn after the upgrade.
What I checked and did not flag
store/postgres/sessions.goandstore/memory/access.gonow match onaccess.Useroraccess.Refs, and thefmt.Sprintfplaceholder numbering for the new= ANY($n::text[])arm lines up with the two appended args — correct as written.- The fence's
NOT EXISTSonweixin_connections.runtime_lease_holderplus theruntime_updated_at IS NULLarm is the right guard given both paths open withSELECT … FROM channels … FOR UPDATE; I did not find an interleaving where a claim that commits before the fencing statement is missed. weixinRuntimePropertieskeepsrows.Err(), closes the rows, and short-circuits on an empty id list; dropping a channel with no usable connection matches whatErrNoRowsdid before.
Verdict
COMMENT. CI is green on this head and license/cla is signed, so the only thing between this and an approval is whether you want the three items above as code or as a recorded deploy precondition — the second one in particular looks like a one-line change. Per the repo rules I am not merging or approving on your behalf, and nothing here claims a local build or test run: I read the head blobs, the four commit patches and the schema/upsert SQL, and the CI statements above are GitHub's own results.
Automated review by github-manager-bot
| // fresh session on every call, which grows the session table without bound behind a | ||
| // long-lived internal token and defeats the find-or-create lock below. The bridge builds | ||
| // the key with ChannelExternalKeys before calling. | ||
| c.JSON(http.StatusBadRequest, ErrorResponse{Error: "externalKey is required"}) |
There was a problem hiding this comment.
[Warning] Closing the accidental path is right, but this is a rolling-deploy break in the opposite direction from the one f502800f just fixed.
f502800f was careful to self-heal rows written by an older head or a replica still running old code, on the explicit reasoning that a control-plane upgrade must not strand a lagging scheduler. This endpoint now answers 400 to exactly that lagging replica: a service-scheduler still building the request without externalKey did work before (server-minted key) and fails permanently afterwards. ManagedSessionChannelBridge.java:101 no longer protects against it, because the failure mode that matters is an already-deployed older replica, not the deprecated overload in the tree.
Two ways to keep both halves consistent:
- accept a missing key and mint one for a transition window, with a WARN naming the caller (and a follow-up to remove it), so the flag becomes observable rather than fatal; or
- state the deploy order in the commit message and in the release note — control plane only after every scheduler replica is on a build that always sends
externalKey.
The unbounded-growth reasoning itself is sound, and TestChannelSessionRequiresAnExternalKey is the right test to have added; this is about which side of the upgrade the 400 lands on.
There was a problem hiding this comment.
Fair point, and I took your second option rather than the transition window — with one addition your note prompted.
Why not mint-for-a-window: this endpoint is new in this change. channel_session.go and the bridge's find-or-create call both arrive in this PR, so the skew is limited to environments that ran an earlier build of this branch — there is no previously released control plane serving this route for a lagging replica to be talking to. Reopening the minted path for that audience would restore exactly the unbounded growth the previous round asked to close, and would then need a removal follow-up of its own.
So the deploy order is stated next to the 400 and in the PR description as a release note: the control plane must not take this build before every scheduler replica sends a key.
One thing your comment made me check and fix: findOrCreateSession still omitted the field when a caller passed a blank key, so an in-tree caller could still produce the 400 after a round trip. It now throws locally instead. That makes the skew strictly about builds that predate this change rather than about the code in the tree.
| AND NULLIF(c.runtime_lease_holder,'') IS NOT NULL) | ||
| OR runtime_updated_at IS NULL | ||
| OR runtime_updated_at < $4)`, | ||
| strings.TrimSpace(*item.Error), now, item.ChannelID, now-channelRuntimeStaleReportMs); err != nil { |
There was a problem hiding this comment.
[Warning] The 2-minute staleness bound is hardcoded against a poll interval that is configurable.
The comment reads "several scheduler poll intervals wide, which keeps a holder that died without being cleared from hiding a failed start forever", and at the default 15s interval that is exactly right — eight intervals of grace, and the healthy leader refreshes runtime_updated_at continuously so a lease-less report cannot flap it.
The bound only holds while interval << channelRuntimeStaleReportMs. A deployment that lengthens the report interval past two minutes (which the config allows, and which a busy or throttled replica can effectively reach on its own) turns every healthy leader into a stale row from the fence's point of view, and standby build failures start overwriting runtime_started=true again — the failure this commit was written to close, returning silently.
Cheap options, in increasing order of effort: derive the bound from the configured interval (e.g. 8 * interval, floored at the current constant); clamp it at startup with a log line when interval exceeds a fraction of the bound; or add the interval to the existing comment as a hard precondition rather than a default assumption. Same for weixin_runtime_test.go — it exercises the two arms at the default interval only.
There was a problem hiding this comment.
Fixed in 3fb5c9e by taking your middle option, plus the documentation.
The precondition is now on the constant itself: the default 15s interval is one eighth of the two-minute bound, the control plane cannot observe the configured value, and a deployment that raises builder.scheduler.channel-refresh-ms into that range would make every healthy leader look stale. SchedulerChannelRuntime additionally warns once at startup when the interval is configured above a minute — a tenth of the bound — naming both numbers in the message, so the coupling is visible at deploy time instead of showing up as a flapping console.
I did not derive the bound from the interval: the control plane cannot see it without adding it to the report protocol, and a per-request interval on a lease-less report is exactly the field a failing replica is least likely to send correctly. Happy to add the protocol field if you would rather have the derivation.
The test still exercises the arms at the default interval; asserting the other end of the ratio would need a second scheduler configured with a long interval, which the fixture does not have.
| // every other column as stored. | ||
| func withChannelOwner(session *store.Session, ownerRef, originRef string) *store.Session { | ||
| adopted := *session | ||
| payload, _ := json.Marshal(gin.H{"originType": "channel", "originRef": originRef, "channelOwnerRef": ownerRef}) |
There was a problem hiding this comment.
[Warning] Adoption rewrites the whole task_context column, so it can drop keys this helper does not know about.
The commit message and the comment here both say the upsert "keeps phase, busy and timestamps as stored", which is true — but sessions.go:87 applies task_context = COALESCE(EXCLUDED.task_context, sessions.task_context), so the payload this line builds replaces the stored JSON wholesale rather than adding one field. For the three keys the row was created with that is a no-op; for a legacy row that also carried anything else under task_context, adoption on the first turn after the upgrade deletes it, and the adoption path is exactly the one that runs on rows written by older code.
Since this is a single-field fix-up on a row already loaded under a List(... Limit: 2) probe, the narrower write is both safer and cheaper to reason about — either merge into the decoded payload before re-marshalling, or a targeted UPDATE ... SET task_context = jsonb_set(task_context, '{channelOwnerRef}', …) on the row key. Worth five lines: the whole point of the commit is that these rows were written by code this branch no longer has.
There was a problem hiding this comment.
Fixed in 6b6f2e9, and you are right that my comment described the wrong mechanism: the upsert replaces the column, and the helper rebuilt it from the three keys this branch knows about — on exactly the rows an older writer produced.
The stored payload is now decoded, channelOwnerRef is set on the decoded map and the result is re-marshalled, so unknown and nested keys survive; an unreadable payload falls back to a payload carrying just the owner rather than failing the turn. TestWithChannelOwnerPreservesExistingMetadata pins that a custom key and a nested conversationTurn object both survive, and that the stored session object is not mutated by adoption.
I took the merge rather than a targeted jsonb_set update because the row is already loaded and the merge needs no new store method — five lines, as you said.
|
All three closed on head
The third one also turned up a real inconsistency while I was checking it: Two deliberate choices worth recording, both explained inline:
Verified locally: |
|
Note on head The Two reasons I read this as a flake rather than a defect:
The windows job was cancelled by the matrix fail-fast rather than by its own failure. If it reproduces, the fix belongs to that test (stub before any worker thread can call the mock, or make the mock thread-safe), not to this PR — happy to file it separately if you want the record. |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Delta re-review of the 3 commits since 11e87f78 (6b6f2e9d, 57f1fdec, 3fb5c9ed + a CI retrigger). All three previous-round findings are addressed substantively: the adoption path now merges into the stored task_context instead of rebuilding it, the deploy order for the required externalKey is documented on both sides of the wire (control-plane comment + scheduler-side fail-fast before the round trip), and the staleness fence's interval precondition is now backed by a startup warning on the scheduler. The merge fix introduces one new edge case worth a follow-up commit — a null-valued task_context panics the adoption path. No blocker otherwise; re-request review once that's in.
CI: the current 4b9ab20f head is a retrigger after a service-dataplane mock race, not a code change.
Automated review by github-manager-bot
| // to be right, so fall back to a payload carrying just it. | ||
| _ = json.Unmarshal(session.TaskContext, &metadata) | ||
| } | ||
| metadata["channelOwnerRef"] = ownerRef |
There was a problem hiding this comment.
[Critical] json.Unmarshal into a *map[string]any follows Go's decoder rules for null: a literal null in task_context unmarshals without error and sets the destination map to nil. The very next line then does metadata["channelOwnerRef"] = ownerRef — assignment to a nil map panics. Adoption is exactly the path that touches legacy rows, and a pre-channelOwnerRef row written by an INSERT ... task_context = NULL-style path (or any writer that persisted SQL NULL into the column, which the read layer surfaces as null) lands right here, so the fallback the comment promises ("an unreadable payload is not worth failing the turn over") turns into a panic in the gin handler instead. One-line guard closes it:
if metadata == nil {
metadata = map[string]any{}
}
metadata["channelOwnerRef"] = ownerRef(or var metadata map[string]any + if metadata == nil { metadata = map[string]any{} } after the unmarshal). For what it's worth, every other malformed shape — 123, "str", [1,2] — does return an UnmarshalTypeError and leaves the initialized map untouched, so null is the single hole here; a json.RawMessage("null") case in TestWithChannelOwnerPreservesExistingMetadata would pin it down.
There was a problem hiding this comment.
Fixed in 3402344. You were exactly right, and I confirmed the decoder behaviour before touching it:
null -> err=<nil> nil=true <- succeeds, destination map becomes nil
{} -> err=<nil> nil=false
123 -> err=json: cannot unmarshal number into Go value of type map[string]interface {}
"str" -> err=json: cannot unmarshal string ...
{not json -> err=invalid character 'n' looking for beginning of object key string
So null is the single shape that reaches the assignment with a nil map — and, as you say, adoption is the path that only ever runs on rows written by older code, which is where an unusual payload is most likely. The guard is in place and TestWithChannelOwnerSurvivesAnUnusablePayload covers null plus the four shapes that error out.
I also checked the test is worth having rather than just describing the fix: with the guard removed, the null subtest fails with panic: assignment to entry in nil map, and with it restored the suite passes. go test ./internal/httpapi/... ./internal/product/... is green locally; CI is running on the new head.
| // long-lived internal token and defeats the find-or-create lock below. The bridge builds | ||
| // the key with ChannelExternalKeys before calling. | ||
| // | ||
| // Deploy order: the control plane must not take this build before every scheduler replica |
There was a problem hiding this comment.
[Info] Deploy-order note accepted — it answers the previous round's constraint, and pairing it with the scheduler-side fail-fast in ManagedSessionChannelBridge.findOrCreateSession (reject before the round trip) closes the skew cleanly. No action needed.
There was a problem hiding this comment.
Thanks — recording the order in the code comment plus the scheduler-side fail-fast is what I kept, so nothing further here.
|
The
So CI is green on |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Delta re-review of 34023440 (the only commit since my 4b9ab20f round). It closes the last open finding: withChannelOwner now re-initialises the metadata map when task_context is a literal null, which json.Unmarshal decodes without an error and previously turned the metadata["channelOwnerRef"] assignment into a nil-map panic on the adoption path. The added table test covers the right set of shapes (null, number, string, array, truncated object) and asserts the owner key survives each one, so the guard is pinned rather than incidental. That was the only blocker; CI is green on this head and CLA is signed → approving.
Findings
None new in this delta.
- [Info] The fix is narrower than the general "unwriter payload" case, which is fine: the other malformed shapes error out and leave the initialised map alone, so they are already safe by construction. Worth keeping that reasoning in the comment (it is there) so a future edit doesn't move the nil check back inside the
if err != nilbranch.
Verification
withChannelOwnerat head34023440: nil-map guard placed afterjson.Unmarshal, before thechannelOwnerRefwrite.- New test
TestWithChannelOwnerSurvivesAnUnusablePayloadexercises 5 payload shapes, including the adopted-session round trip. - CI on
34023440: Check License, build (ubuntu + windows), source, validate, codecov/patch all passing. - CLA: signed (
license/cla=success).
Automated review by github-manager-bot
WeixinChannelRuntimeTest.peerThrottleStopsDispatchAndSurfacesATransientFailure asserted the transient-failure notification after calling channel.stop(), which races the poll loop: a stop that lands between failMessage() and the loop's catch makes it skip the notification (correct shutdown behaviour), leaving the list empty and the assertion red. It failed on the agentscope-ai#3186 CI run on both runners. Wait for the report before stopping instead. Same coverage, no race.
Add `agentscope-extensions-channel-weixin`, a native Java Channel adapter for Tencent's official iLink personal Weixin API, at the same scope as the existing DingTalk, Feishu, WeCom, GitHub, and GitLab channel extensions. The module owns provider protocol and Channel runtime behavior only: - `WeixinLoginClient` — stateless QR login (`start`/`poll`/`verify`) returning a portable `WeixinLoginSession`, so an attempt can be resumed by another process without carrying the QR image along. - Long polling, `get_updates_buf` cursor persistence, `context_token` replies, and direct-text inbound mapping. - Account-scoped leases with at-least-once inbox processing: one active consumer per account, standalone instances keep trying to acquire a released lease, and a lost lease cancels local dispatch before it can send a late reply. - `WeixinEndpointPolicy` — the endpoint allowlist that runs before credentials are sent: https (or loopback http), official `*.weixin.qq.com` hosts on 443, and redirects only within the official provider, between loopback endpoints, or to the same host. - Neutral host seams (`WeixinCredentialProvider`, `WeixinStateStore`, `WeixinRuntimeListener`) plus `WeixinChannel.fromProperties(...)` for standalone use and `WeixinChannel.create(...)` for managed hosts. An in-memory state store is included for standalone development and tests. It carries no AgentScope product concepts: no owner or vault references, no persistence policy, no public routes, no console state. iLink `-14` is reported as a credential-rejection observation and the host decides what that means. Register the module in the channel parent POM, `agentscope-all`, and `agentscope-bom`.
Follow-up to the review on agentscope-ai#3184. - `InMemoryWeixinStateStore` no longer creates accounts from read-only lookups (`loadCursor`, `loadContextToken`, `isLeaseCurrent`, `releaseLease`, claim validation), and every accepted batch now expires completed tombstones across all accounts and forgets accounts whose lease expired with an empty inbox. The javadoc states that this adapter is single-JVM state and not for production. - `WeixinChannel.fromProperties(...)` warns on construction that the standalone factory keeps credentials and runtime state inside that process. - The module README gains a "Compliance and scope" section (personal account, provider terms, host responsibility) and spells out what the in-memory store does and does not provide. The loopback claim in the review is not reproducible: loopback is matched on the literal host (`localhost`/`127.0.0.1`/`::1`), so `localhost.attacker.example` is rejected at `normalizeBaseUrl` and never reaches `validateProviderEndpoint`. Resolving the host instead would let such a name *into* the allowlist, so the literal match is kept and a regression test now pins the rejection. Also covers the remaining functional gaps: `WeixinLoginClient.verify(...)` (blank code, code in the status query, the verification challenge, untrusted redirect, provider failures) and the outbound `notifyStart`/`notifyStop` endpoints. Module line coverage is 93%.
Follow-up to the second review round on agentscope-ai#3184. **Correctness — the eviction rule dropped the cursor.** `prune` forgot any account whose lease had expired, cursor included, so an account idle for longer than the lease TTL would have resumed from `""` and replayed the provider backlog. The automatic sweep now only forgets accounts that hold nothing at all (no cursor, no peer context, no inbox, no live lease) — the ids materialised by validation paths that the previous commit stopped creating in the first place. Retiring real state is now an explicit host action: `WeixinStateStore.removeAccount(accountId)` (default no-op, implemented by the in-memory store), so a host can drop an account without the store guessing. `keepsTheCursorWhenAnAccountGoesIdle` replaces the assertion that used to demand the opposite. **Security — the login transport is query-string based.** The provider protocol puts `qrcode` and `verify_code` in the GET status query, so the status URL is a credential; that is now stated in the README with the operational consequence (no URI-level logging behind this client). The module was verified not to echo anything itself: provider failures report only the status code, and both JSON parse paths (login and outbound) now report line/column instead of Jackson's default message, which quotes the offending input — a partially parsed login response contains `bot_token`, and an inbox response contains message payloads and context tokens. Three tests assert that an HTTP failure, an unparseable login body and a transport failure carry neither the verify code, the QR id, nor body content. Not changed: `fromProperties` still logs WARN on every construction — that is the intent, and `create(...)` with an explicit store is the silent path.
…utcomes Addresses the CHANGES_REQUESTED review on agentscope-ai#3184. - **`longPollTimeoutMs` did nothing.** Every request used `requestTimeoutMs`, so a deadline shorter than the provider's long-poll window aborted every `getupdates` call. The client now derives the deadline per call: `longPollTimeoutMs` plus a short grace for `getupdates`, `requestTimeoutMs` for the control calls. `longPollSettingKeepsASlowGetUpdatesAlive` drives a 900 ms response with a 300 ms request timeout and asserts it still succeeds. - **A failed `notifystart` left the account polling.** The catch reported a transient failure and fell through into the poll loop with `providerStarted == false`, so with the lease still renewing the session was never started again and no `notifystop` was ever owed. The session now leaves the lease, so the outer loop backs off, reacquires and retries startup. `providerSessionStartIsRetriedAfterATransientFailure` fails `notifystart` twice and asserts the third attempt happens. This fix also exposed that `WeixinChannelLoopbackTest`'s fake provider had never implemented `notifystart` — it was returning 404 and the old code swallowed it — so the fixture now answers it. - **A missing provider outcome counted as success.** `asInt(0)` accepted `{}` or any 2xx body without `ret`/`errcode` as a delivered message, after which the inbox claim completed and the reply was dropped. Both `updates(...)` and `assertSuccess(...)` now require an explicit provider result. `responsesWithoutAProviderOutcomeAreRejected` covers poll and send. Provider outcome and credential failures also keep their exception type instead of being wrapped in a generic `RuntimeException` by `sendWithContext`. - **Byte-identical id-less messages collapsed into one.** The fallback inbox id was the payload digest alone, so two identical messages in one batch became one claim while the cursor advanced — a user typing the same thing twice lost one message. The fallback id now includes the position in the batch, which keeps a re-delivered batch deduplicated while separating genuine duplicates. `byteIdenticalMessagesWithoutIdsAreBothDispatched` covers it. Test-design fix along the way: the runtime fixture now emulates a long poll (25 ms) instead of answering instantly, which was spinning the consumer loop and starving the rest of the suite; the lease-lifecycle suite dropped from 17.8 s to 7.0 s.
The Windows build failed in an untouched module: JdkHttpTransportTest .testStreamErrorResponseWithEmptyBody timed out waiting for the stubbed server, so agentscope-core failed first and every later module was skipped — this module never got built. Re-running to get a clean signal.
Channel conversations are registered by the control plane on behalf of a verified account, but session access was still derived from Chat ownership. Attach the authenticated account id to WorkAccess, expose the verified channel owner from session metadata, and let both the memory and Postgres session readers treat that owner as authorized.
… flows Register the personal-weixin channel type, persist Weixin connections and login challenges, and expose the internal APIs the Scheduler uses to start/poll headless login and to fetch runtime credentials. Channel conversations created from a Weixin connection record the verified owner in session metadata so the work-access layer can authorize them.
Add the Scheduler-side adapter for the personal-weixin channel: fetch runtime credentials from the control plane, persist channel state through a Jdbc-backed WeixinStateStore, and keep the managed-session bridge in sync for channel conversations. The login controller proxies headless QR login so the console can drive a scan-to-connect flow.
Surface the personal-weixin channel in the channels hub and detail page with a QR-based connection panel, polling link state, and an identity section that binds the signed-in account to the WeChat bot. Cover the API client, QR image decoding, and the connection hook with unit tests plus a Playwright smoke spec.
Record the channel-as-extension decision in an ADR and add the production integration plan plus the headless login runbook for operators.
The channel extension now exposes `WeixinStateStore.removeAccount` as the explicit, host-driven way to retire an account: the in-memory store no longer forgets accounts that hold real state, because dropping the cursor makes the consumer replay whatever backlog the provider still holds. The Scheduler's JDBC adapter owns the same per-account records, so implement the method: cursor, per-peer context, inbox and lease rows for one account are deleted in a single transaction, leaving other accounts untouched. The production plan records why the Scheduler never retires on its own. A reversible `disable` already removes the Channel from the desired configuration and tears the runtime down, which looks identical to a disconnect in the configuration feed, so an automatic eviction keyed on that feed would silently duplicate delivered messages. Retiring state stays an explicit host action.
WeixinChannelRuntimeTest.peerThrottleStopsDispatchAndSurfacesATransientFailure asserted the transient-failure notification after calling channel.stop(), which races the poll loop: a stop that lands between failMessage() and the loop's catch makes it skip the notification (correct shutdown behaviour), leaving the list empty and the assertion red. It failed on the agentscope-ai#3186 CI run on both runners. Wait for the report before stopping instead. Same coverage, no race.
…ment removeAccount deletes the builder_weixin_lease row, which made an unreachable null dereference in acquireLease reachable: the bootstrap insert ran as a standalone auto-commit statement, so a retirement committing between the insert and the SELECT ... FOR UPDATE left lock() with no row and the caller got an NPE instead of the Optional.empty() backoff every other lease read returns. Move the insert into the same transaction as the lock. A concurrent retirement cannot delete a row the transaction has not committed, so the window is closed by construction; ON CONFLICT still keeps a concurrent first acquisition from aborting the transaction. Keep a null guard as well, so a future refactor cannot turn a missing row into an NPE. Not covered by a test: the interleaving needs two real connections and a true concurrent commit. The suite's H2 mode serializes that away, so a test would not reproduce the race it claims to pin. The sequential contract is already covered by the removeAccount tests.
… config entry Two report-path defects from the review of agentscope-ai#3186, plus the parse that fed them. `reportRuntimeStatus` skipped every Weixin channel without a lease observation. That guard exists so a standby cannot overwrite the active replica, but a channel that failed to build or start never acquires a lease either, so it was dropped from the report entirely: the control plane was shown nothing for a channel that is actively failing, which is indistinguishable from a channel that was never configured. A recorded error now produces an entry with `started=false` and the error, while a healthy standby (no error) stays silent as before. `Long.parseLong(String.valueOf(properties.get("credentialRevision")))` ran unguarded inside the per-channel loop, so one missing or non-numeric value threw `NumberFormatException` and aborted the whole report — every other channel's state stopped reaching the control plane. Reading the revision is now lenient (`credentialRevision(Map)`, `null` when unreadable) and the start path fails with a described error instead of a raw parse exception, so the failure stays local to that channel. The generic "failed to build channel" text no longer overwrites the specific reason. The control plane must accept that report for it to be visible: `applyChannelRuntimeObservation` required a lease holder for every Weixin item and returned early otherwise, so the new failure entry would have been silently discarded. A lease-less item carrying an error now writes `channels.runtime_started/runtime_error` and returns; the connection's lease fields stay untouched, which is the authority the guard was protecting. A lease-less item without an error is still ignored. Tests: `SchedulerWeixinRuntimeTest` gains the end-to-end failed-start report (asserting the specific reason survives) and a unit test for the lenient read; `TestWeixinRuntimeSurfacesAStartFailureWithoutLeaseAuthority` pins the Go side, including that the active replica's lease holder/generation/sequence are unchanged.
…lookup Two findings from the review of agentscope-ai#3186, both in the endpoints the scheduler polls. **The lease-less failure report had no ordering guard.** The branch I added in b21e0a2 wrote `runtime_started=false` plus an error for any report without a lease holder, so a replica that merely failed to *build* the channel could stamp "broken" over the state of the replica that holds the lease — the console then shows a healthy channel as failed until the next successful report. The write is now fenced like the leased path: it only lands when no lease holder is recorded, or when the last accepted report is already stale (two minutes, several scheduler poll intervals wide, which keeps a holder that died without being cleared from hiding a failed start forever). **The weixin lookup was N+1 and all-or-nothing.** `internalChannelsConfig` ran one four-join query per weixin channel, and any failure that was not `ErrNoRows` turned the entire payload into a 500 — leaving every scheduler replica with no configuration at all instead of one missing entry. The lookup is now a single `WHERE channel_id = ANY($1)` batch, and a channel with no usable connection is dropped from the payload with a log line, which is what `ErrNoRows` already did. `TestWeixinRuntimeSurfacesAStartFailureWithoutLeaseAuthority` now covers both arms: the failure lands while the lease is unclaimed, and a lease-less report cannot flap a fresh leased report.
The owner check in resolveAgentConversation hard-failed with ErrConflict whenever `store.ChannelSessionOwnerRef(current)` did not equal `agent.OwnerRef`, but that helper returns "" both when the key is absent and when the row is an agent-task row. A channel session written before channelOwnerRef existed — an earlier head of this branch, or a replica still running the old code during a rolling deploy — therefore produced a permanently stuck conversation rather than a recoverable one. The binding, origin type and origin ref are already verified at that point, so an empty stored owner means "unclaimed", not "someone else's". Those rows are now adopted: the session is written back with the verified owner through the existing upsert, which keeps phase, busy and timestamps as stored, and the turn proceeds. A stored owner that belongs to a different account is still a conflict. No SQL backfill: the two tables live in different schemas (public.sessions, control-plane agents), so the migration would have to reach across them, and self-heal also covers the rolling-deploy case the backfill would miss.
…tration An empty `externalKey` made the endpoint mint `channel:<uuid>` per call, so a caller that forgot the key created a new session row every time behind a long-lived internal token — unbounded growth, and the find-or-create lock below could never deduplicate because each call had a distinct name. The endpoint now answers 400 `externalKey is required`; the bridge builds the key with ChannelExternalKeys before calling, so this only removes the accidental path. The deprecated 3-argument `ManagedSessionChannelBridge.dispatchAndAwaitReply` was the only caller that relied on it (it forwards a null key). It now fails fast with a message pointing at the four-argument overload instead of depending on server-side minting.
The channel-owner branch of the session filter matched `access.User` only, while the two branches above it match `access.Refs`. `namespaceAccess` always seeds `Refs` with the stable id, so the production HTTP path was equivalent, but `WorkAccess` is built in more than one place and a caller carrying only `Refs` (the shape several tests and internal paths use) lost visibility of channel conversations it could see before. Both the Postgres filter and the in-memory reader now accept either the stable id or an alias, matching the sibling branches.
Adoption rebuilt `task_context` from the three keys this branch knows about, and the upsert replaces the whole column (`task_context = COALESCE(EXCLUDED.task_context, sessions.task_context)`), so a legacy row carrying anything else under that column lost it on the first turn after the upgrade — and the adoption path runs precisely on rows an older writer produced. The stored payload is now decoded, `channelOwnerRef` is set on it, and the result is re-marshalled, so unknown and nested keys survive. An unreadable payload falls back to one carrying just the owner rather than failing the turn. `TestWithChannelOwnerPreservesExistingMetadata` pins both the preserved keys and that the stored session object is not mutated.
…arn when it is at risk `channelRuntimeStaleReportMs` (two minutes) is measured against a poll interval the control plane cannot see, so the fence's margin is an assumption rather than an invariant: raise `builder.scheduler.channel-refresh-ms` into that range and every healthy leader looks stale, which reopens the very flap the fence closes. The constant now documents the precondition explicitly, and `SchedulerChannelRuntime` warns once at startup when the interval is configured above a minute — a tenth of the bound — naming both numbers so an operator can see the coupling instead of discovering it as a flapping console.
The 400 that closes the accidental session-minting path also fails a lagging scheduler replica that
still omits the key, which is the opposite direction from the self-healing that `f502800f` added for
rows written by older code. Keeping both halves consistent means choosing a side, and this one is
chosen deliberately: the endpoint is new in this same change, so the skew is limited to environments
that ran an earlier build of this branch, and a minted key is exactly the unbounded growth the
previous round asked to close.
The deploy order is now written next to the 400 ("the control plane must not take this build before
every scheduler replica sends a key") and in the PR description. The bridge also stops sending
key-less requests at all — `findOrCreateSession` throws locally instead of relying on the server to
reject it, so no in-tree caller can produce the 400 after a round trip.
The ubuntu build failed in ToolConfirmationCoordinatorTest .replacementTurnLeaseCannotReleaseOldTicketAndMayReuseToolUseId with "CannotStubVoidMethodWithReturnValue: 'appendSessionEvent' is a void method" while the failing line stubs managedExecutionScope. That is the signature of the shared Mockito mock being called from the coordinator's worker thread while the test thread sets up a stub: the void appendSessionEvent call is recorded and blamed instead of the stub. The test comes from upstream main (agentscope-ai#3104), this branch does not touch service-dataplane, and it passes 10/10 locally. Re-running for a clean signal.
…nnel session The adoption merge decodes the stored payload and then sets `channelOwnerRef` on the resulting map, but `json.Unmarshal` follows the decoder's rule for a JSON `null`: it succeeds and leaves the destination map nil instead of reporting an error. Assigning into that nil map panics, so a legacy channel row whose task_context is the literal `null` took down the gin handler rather than being adopted — and adoption only ever runs on rows written by older code, which is exactly where an unusual payload shape is most likely. Every other unusable shape (`123`, `"str"`, `[1,2]`, truncated JSON) returns an UnmarshalTypeError or syntax error and leaves the initialised map untouched, so `null` was the only hole. `TestWithChannelOwnerSurvivesAnUnusablePayload` covers `null` and the other shapes. Removing the guard makes the `null` case fail with "panic: assignment to entry in nil map", so the test pins the bug rather than the fix.
The channel sent each reply inline, inside the dispatch of the inbound message, and the client required an explicit result code from every provider call. Both were wrong for iLink: an idle long poll and an accepted send report success through their own fields (`get_updates_buf`, `msgs`, `message_id`) with no `ret`, so every reply looked failed, the message returned to PENDING, and the Agent ran again — one user message produced eight Agent executions and eight replies. - Accept the provider's own way of reporting success: batch fields for `getupdates`, the message id for `sendmessage`. A body that carries neither the status fields nor those outcomes is still rejected, so a proxy error page cannot pass as a delivered reply. - Poll and consume fail independently, so a provider outage cannot starve an accepted message and a message that cannot be dispatched cannot freeze the cursor. - Bound dispatch retries and tombstone an abandoned message; report it through the new `onDispatchFailed` (a refused reply stays `onDeliveryFailed`). The Agent is never re-run for a reply the provider refused. - Implement `deliverWithReceipt(...)` so a host that owns durable delivery can send one persisted reply and receive the provider's receipt. The inline path, used by standalone hosts and by the courtesy replies that answer an unbound peer, makes a single attempt. - Report provider outcomes as structured codes; the provider's own error text never reaches a host log or reporter.
Reply delivery belonged to the channel, so a provider rejection failed the inbound dispatch and replayed the Agent that produced the reply. The host already owns a durable delivery queue with attempts, exponential backoff, an attempt cap, a manual re-send and event-key idempotency; the channel's contract already anticipated it with `deliverWithReceipt`. - The scheduler persists an Agent reply through a new internally authenticated operation (`POST /api/internal/channels/replies`, keyed `channel-reply:<channelId>:<accountId>:<messageId>`) and returns no reply to the channel, so the accepted message completes once the reply is durable. - Reply deliveries are exempt from the work-configuration gates that decide whether an issue, comment or approval notification may reach a peer; a reply's authority is the sender's binding, which the queue re-checks. A courtesy reply to an unbound peer stays on the channel's inline path, because the queue refuses to claim it and no Agent ran to replay. - A credential rejection parks the notification without consuming an attempt (`deferSeconds`), because only a human re-authorization clears it. - Abandoned inbound messages are cleaned up on the same seven-day retention as completed ones, and the scheduler reports dispatch failures apart from delivery failures.
DeepSeek is reached through its OpenAI-compatible API, so the data plane needs the OpenAI model extension to serve deepseek:<model> Agent turns. DEEPSEEK_API_KEY is documented alongside the other model credentials.
The image builds pulled from Maven Central and the public npm registry from a network where large artifacts are truncated mid-transfer, so a build could fail minutes in. The service Dockerfile takes an optional MAVEN_MIRROR_URL, the control Dockerfile normalizes the lockfile's developer registry URLs, and both retry their downloads. Compose passes the mirror through and gives the control plane the scheduler URL the Personal Weixin login flow calls.
f52ca2b to
25a0285
Compare
WeixinChannelRuntimeTest.peerThrottleStopsDispatchAndSurfacesATransientFailure asserted the transient-failure notification after calling channel.stop(), which races the poll loop: a stop that lands between failMessage() and the loop's catch makes it skip the notification (correct shutdown behaviour), leaving the list empty and the assertion red. It failed on the agentscope-ai#3186 CI run on both runners. Wait for the report before stopping instead. Same coverage, no race.
… channels end to end
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review of the new push since my last approval (34023440 → 25a02852): reply-delivery semantics in the Weixin extension, replies moved onto the host's durable queue, DeepSeek packaged in the data plane, and build/registry hardening. The ADR argument holds — a provider outage should not fail the inbound message and re-run the Agent that produced the reply — and putting the retry/backoff/attempt-cap policy in the queue instead of the transport is the right place for it, with the extension left owning only the provider protocol. abandonMessage giving the claim back on a fenced update, and the idempotent-enqueue test, both show the crash paths were thought through.
Six findings inline, all on the new commits. Two are worth resolving before merge, and both are about who is trusted to decide:
- [Warning]
channel_work_delivery.go:93— whether a row skips thecfg.Enabled/ route-gate checks is decided by a prefix ofevent_key, which the enqueue endpoint takes from the request body unchanged. Derive the key server-side or branch on a storedkindinstead. - [Warning]
channel_work_delivery.go:201—deferSecondsgives the attempt back with no count or age bound, so any error reported with a defer keeps a notification pending forever and never reaches the terminalfailedstate. - [Warning]
ChannelWorkBridge.java:138— a failed enqueue still propagates, so a control-plane blip re-runs the Agent turn (the replay class this commit removes for provider failures), and after 3 tries the reply is abandoned. - [Info] reply latency (one delivery per poll tick), the random-UUID event-key fallback, and the lockfile
sedinDockerfile.control.
CLA is signed; CI (ubuntu + windows builds, license, module sync, codecov patch) is green. Leaving this as a comment rather than an approval because of the two gate/queue findings above — they are local changes, and I am happy to re-review on the next push.
Automated review by github-manager-bot
| // A reply answers an inbound message that this host already authorized through the sender's | ||
| // binding, so the work-configuration gates below — which decide whether an issue, comment or | ||
| // approval notification may reach the peer — do not apply to it. | ||
| isReply := strings.HasPrefix(event, "channel-reply:") |
There was a problem hiding this comment.
[Warning] The reply bypass is decided from a caller-supplied string. event_key comes from the POST /api/internal/channels/replies body verbatim (channel_work_api.go only checks it is non-blank), and enqueueChannelDelivery stores it unchanged. So the caller — not the queue — decides whether cfg.Enabled, the route/window checks and the transient-settings-error handling apply to a row, and a loadChannelWorkSettings failure is deliberately swallowed for anything whose key starts with channel-reply:.
Today's only caller is the scheduler, so nothing is exploitable in-process, but the invariant is held by convention. Deriving the key server-side ("channel-reply:" + ch.ChannelID + ":" + accountID + ":" + messageID, ignoring the client's value) or persisting an explicit kind column and branching on that would make the gate un-forgeable.
| .bodyValue(body) | ||
| .retrieve() | ||
| .toBodilessEntity() | ||
| .timeout(Duration.ofSeconds(10)) |
There was a problem hiding this comment.
[Warning] A failed enqueue still replays the Agent, which is the failure class this commit set out to remove. enqueueReply is one POST with a 10s timeout and no retry: if the control plane is briefly unreachable or returns 503 (delivery queue unavailable), the error propagates out of receive(...), the provider message is failed, and the next claim re-runs chat.dispatchAndAwaitReply(...) — the model turn and any tool side effects happen again. After 3 such attempts WeixinChannel abandons the inbox row, so the reply is lost anyway.
The queue is idempotent per event key, so a couple of bounded retries on the enqueue itself (or deferring the claim instead of failing it) would keep the "a provider outage cannot re-run the Agent" guarantee true for control-plane outages too.
| } | ||
|
|
||
| /** A claimed delivery is never reported as accepted until the provider returns a message ID. */ | ||
| @Scheduled(fixedDelayString = "${builder.scheduler.channel-delivery-poll-ms:1000}") |
There was a problem hiding this comment.
[Info] Replies now share the queue's one-per-poll throughput. deliverPending claims a single delivery per tick (LIMIT 1 on the Go side) with fixedDelay defaulting to 1000ms, and the inline reply path used to send immediately after the Agent finished. A chat that produces several replies in a burst will therefore drain at roughly one message per second regardless of how fast the provider responds, which is a visible latency change for interactive conversations.
If that is acceptable for now, worth a note in the ADR; otherwise claiming a small batch per tick would keep the durable semantics without the per-message serialization.
| * provider id, so the fallback cannot normally trigger — it is there because a key that is not | ||
| * unique would silently deduplicate a genuine reply rather than queue it. | ||
| */ | ||
| private static String replyEventKey(InboundMessage in, Map<String, Object> metadata) { |
There was a problem hiding this comment.
[Info] The blank-message-id fallback defeats the dedupe it is guarding. key + ":" + UUID.randomUUID() yields a fresh key on every attempt, so a redelivery of the same inbound message queues a second copy of the reply — the comment above describes the risk of a non-unique key as "silently deduplicate a genuine reply", but the chosen fallback trades that for duplicating it.
A deterministic stand-in (hash of channel + account + peer + text, or the inbound claim id if one is reachable here) keeps at-most-one-reply in the abnormal path as well. Separately, :-joining raw ids means an id that contains : can collide across accounts; an encoded or length-prefixed join would remove that class of surprise.
| // A deferred failure is one only a human can clear (an expired channel credential). It keeps the | ||
| // notification pending and gives the attempt back, so a long outage cannot burn the attempt | ||
| // budget and end in `failed`. | ||
| if state == "pending" && req.DeferSeconds > 0 { |
There was a problem hiding this comment.
[Warning] Deferral has no bound, so a stuck notification never becomes terminal. The deferred branch resets attempts (GREATEST(attempts-1,0)) and pushes next_attempt out by up to 3600s, and nothing counts how many times that has happened. The scheduler asks for 300s on a credential rejection, which is the intended case, but deferSeconds is accepted for any error string, so one buggy or over-eager caller keeps a row pending forever — it never reaches failed, never surfaces in the "delivery failed" view, and the only signal is a debug log on the Java side.
Consider a deferred_count (or a total age cap) after which the row goes failed like everything else, and/or restricting deferral to a typed reason the queue recognizes.
| RUN npm ci | ||
| # The checked-in lockfile may contain developer-only Alibaba registry tarball URLs. | ||
| # Normalize those URLs in the image layer so builds also work outside that network. | ||
| RUN sed -i 's#https://registry.anpm.alibaba-inc.com/#https://registry.npmjs.org/#g' package-lock.json \ |
There was a problem hiding this comment.
[Info] Patching the lockfile inside the image layer hides the real problem. After the sed, npm ci verifies integrity hashes that were recorded for the internal mirror against tarballs fetched from npmjs.org, so the build breaks in a confusing way the moment the two registries disagree on a byte. It also means the console that ships is built from a dependency graph that no longer matches the checked-in lockfile — which is exactly what a lockfile is for.
Regenerating package-lock.json once against the public registry (or committing a second, public-resolved lockfile and selecting it by build arg) keeps external builds working and the artifact reproducible.
What this adds
The AgentScope service side of the Personal Weixin (iLink) channel, in nine commits:
1. Session authorization by verified channel owner — channel conversations are registered by the control plane on behalf of an account, but session access was still derived from Chat ownership.
WorkAccessnow carries the authenticated account id,ChannelSessionOwnerRefexposes the account verified at registration, and the memory/Postgres session readers authorize that owner independently of later Chat ownership.2. Control plane (aistio) — registers the
personal-weixinchannel type, persists connections and login challenges with a migration, and exposes the internal APIs the Scheduler needs: start/poll/verify headless QR login, and fetch runtime credentials for a managed channel.3. Scheduler —
JdbcWeixinStateStore(account leases,get_updates_bufcursors, inbox dedup, shared across replicas),WeixinControlPlaneCredentialProvider,WeixinLoginController, and managed-session bridge updates.4. Console — scan-to-connect QR panel with login polling, plus an identity section that binds the signed-in account to the WeChat bot, with unit tests and a Playwright spec.
5. Docs — ADR
0001-personal-weixin-ilink-native-channel, a production integration plan, and a headless login runbook.The channel extension itself (protocol, login primitives, leases, endpoint policy, neutral host seams) is PR #3184 and is not duplicated here.
6. Retirement and review fixes —
JdbcWeixinStateStore.removeAccount(one transaction, oneaccount, other accounts untouched); the lease bootstrap insert moved inside the
FOR UPDATEtransaction so a retirement cannot leave a null lease row; a Weixin channel whose start failed is
reported with
started=falseplus its reason instead of being dropped, and an unreadablecredentialRevisionno longer aborts the whole status report (with the matching control-planechange, since a lease-less item used to be discarded).
Testing
mvn -pl agentscope-service/service-scheduler -am test—BUILD SUCCESS(scheduler 27 tests, weixin extension 60 tests)go test ./internal/product/... ./internal/httpapi/... ./internal/store/...inagentscope-service/aistio— oknpm test146/146,tsc --noEmitclean,eslint0 errorsspotless:checkcleanNot included on purpose
DeepSeek model wiring and local Docker/Maven-mirror build tweaks, which are unrelated to this channel.
Migration note
This branch changes the channel
externalKeyfromoriginType|originRef|requestedSessionIDtoplain
originRef, so an environment that ran an earlier head of this branch will not find itsearlier channel sessions by the new key and will create fresh ones alongside them. The old rows stay
readable but are no longer resolved by find-or-create. Sessions written before
channelOwnerRefexisted are self-healed on first resolution instead of failing with a conflict.
Deploy order
The internal registration endpoint (
POST /api/internal/managed-sessions/find-or-create) now rejectsa request without
externalKeyinstead of mintingchannel:<uuid>per call. A scheduler replicathat still omits the key therefore fails against the new control plane, so the control plane must
not be upgraded before every scheduler replica is on a build that always sends one. Take the
control plane last; nothing else in this change depends on the order.
The runtime-status fence in the control plane treats a report older than two minutes as stale, which
assumes the scheduler's report interval stays well below that (
builder.scheduler.channel-refresh-ms,default 15s).
SchedulerChannelRuntimelogs a warning when the interval is configured above a minute.