Skip to content

fix(tracking): 把上游 #1526 / #1527 / #1528 / #1529 / #1530 / #1534 一起带进 develop_base(F202 漏提醒的根因在 #1528) - #191

Closed
mindfn wants to merge 110 commits into
develop_basefrom
integration/develop-base-tracking-batch
Closed

mindfn wants to merge 110 commits into
develop_basefrom
integration/develop-base-tracking-batch

Conversation

@mindfn

@mindfn mindfn commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

这是什么

把上游 6 个 tracking 修复一起带进 develop_base,六个都已在上游合入。本 PR 合入后重启一次,六条同时生效。做法和当初 zts212653#1521 先进 fork(#187/#188)一样。

上游 PR 上游合入 本 PR 提交 修什么
zts212653#1526 569196d72 0f29c87ef PR 作者不再被自己的推送唤醒;没有匹配但 HEAD 变了时,静默跟随新 HEAD,CI 和冲突不会卡在旧 HEAD
zts212653#1529 dbbe58a22 3344c8152 批准或"要求修改"被撤销(dismiss)时,正在等 review 决定的猫能收到提醒
zts212653#1527 0fb81dbb3 128704087(作者保留为 sol) 冲突自动修复钉在观察到的 PR HEAD 上,不再强推覆盖别人推上去的提交
zts212653#1528 792aff479 60f1d83e1 投递幂等键带上任务 id。F202 thread 的 zts212653#1487 从 09-24 16:00Z 起一直漏提醒,根因就是这个
zts212653#1530 f2bb54f88 ce5e7fe47 已合入的 PR 或已关闭的 issue,如果最后一轮有条目采集失败,这一轮不结束 tracking;下一轮把这条一起送达后再结束。最后一条评论不再丢
zts212653#1534 6a815cbf5 f428cea74 登记 tracking 只发一次、不再重放。以前 MCP 回调 10 秒超时后会自动重发:调用方收到"超时",实际已经写入;或者一次调用在服务端登记好几代。现在单次 POST、50 秒;传输失败或 HTTP 5xx 时如实提示"可能已写入,先核实"

全部用 cherry-pick -x,提交说明里保留了来源 SHA。zts212653#1526 / zts212653#1529 / zts212653#1530 / zts212653#1534 取自上游的 squash 提交。zts212653#1527 / zts212653#1528 当时还没合入,取自获批时的 HEAD;它们和上游后来的 squash 提交内容完全相同(patch-id 一致)。

冲突和处理

源码全部干净应用,只有两个测试文件冲突,都在 develop_base 被 F117 改过的地方:

和 F117 投递层的关系

验证

合入并重启之后

不在本 PR

[宪宪/Opus 5.5🐾]

🤖 Generated with Claude Code

mindfn and others added 30 commits September 21, 2026 18:53
Every connector input used to reach the user through a two-phase shape: append
a Message, then separately enqueue its Queue row. Nothing made the pair atomic,
so a crash or a rejection between the halves left a message nobody was asked to
look at, or a queued row with no message behind it.

This registers the target contract before any producer moves onto it: one atomic
Message+Queue admission per input, the closed set of producers that must migrate,
and the replay cutover for rows written under the old shape. Registering the set
up front is what makes Phase I closeable -- the alternative is discovering
producer #7 by grep after declaring victory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Connector inputs, scheduled wakes and hold wakes, and the manual eval trigger and
CI wake all now hand over an envelope to a single admission seam instead of
driving the append and the enqueue themselves. The separate trigger seam stops
being a second way in.

Two truthfulness fixes belong with this move. Owner provenance now fails closed
rather than inheriting strict from whatever context happened to be around -- an
unproven owner must not be silently upgraded. And a manual trigger now reports
what its admission actually did, instead of reporting success for work that was
refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Private input picked its winner from the Queue row, so the decision died with the
row and a retry could reach a different answer. The winner is now bound to the
admission receipt, and the notice commits together with the work rather than as a
separate best-effort write.

Also here: an external input keeps the person as its actor, not the room it
arrived in; the message index moves into its own module so the queue ledger is no
longer two concerns in one file; and the delivery wiring is expressed so the
compiler owns it -- that wiring is exactly what the migration kept getting wrong
by hand, and a type error is cheaper than a lost message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d set

Conflict-check, limb transcripts, github-wait and the re-eval carrier each move to
a single admission, one producer at a time, with the producer registry updated as
each is taken. Working against a registered set is the point: each commit closes a
named item rather than whatever grep turned up that day.

The recurring defect across all four was the same -- a second admission that
nobody had noticed was there, or a conflict labelled at a site that never looked.
Two test fixes belong to that: one suite was exercising a trigger production never
wired, and conflict-check's AC-C1 was restored by splitting authorization from
announcement, which is what made the duplicate admission visible in the first
place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sent

The wait outbox decided whether to publish after it had already started sending,
so a suppression could not actually suppress. The decision now happens at a claim
taken before any send.

The claim is deliberately kept out of the delivery enum. Encoding it there made a
rollback unable to express 'claimed but not delivered', which stranded the row in
a state no path could clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e trigger chain

The managed wake is the last registered producer. It is admitted once, with its
lease checked before anything is written, so a superseded generation cannot write
at all. Three defects found by cross-individual review are fixed with it: legacy
adoption had lost its lease fence; a post-commit notification could un-admit a
commit that had already landed; and the drain was sequenced behind a UI broadcast,
so a failing socket fan-out left a durable wake that nothing asked anyone to look
at.

With every producer migrated, ConnectorInvokeTrigger has no producers left, and
the whole chain is deleted -- zero references under src/. The two-phase 'append
then enqueue' shape is gone from every production path, which closes Phase I.

Finally, connector framing is preserved on envelopes delivered through the Queue.
Without it the envelope arrives but renders as plain text: the hold-ball card, its
run state and its cancel control all disappear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l-closed guard

Category: accepted review finding "Reject coerced lifecycle response statuses", plus two
sibling sites the review did not reach. All three are new in this PR.

`[...].includes(String(candidate.x))` reads as a fail-closed literal check and is not one.
`String(['completed'])` is `'completed'`, and any object whose `toString` returns a literal
passes too, so a value that is not a string walks through a guard whose entire job is to
reject it. These guards sit on recovered Redis rows and websocket frames —
`RedisMessageStore`, `redis-message-parsers` and `useSocket` all gate on the shared one — so
the smuggled value lands in a field whose contract is a string literal, and downstream
matching silently stops working: it is neither `processing` nor a valid terminal, so
projections can be suppressed and a terminal commit can conflict permanently.

The fix is to compare the raw value, expressed once as `isOneOf` in the shared module rather
than repaired inline at each call, so the next literal check cannot reintroduce the coercion
by habit.

Three sites, found by auditing the pattern across the tree instead of patching only the one
that was reported:

- `message-lifecycle.ts` response `status` — the reported instance.
- `message-lifecycle.ts` delivery-failure `reason` — same function, one branch away.
- `QueueLedgerValidation.assertQueueExecution` — the one that actually matters most: it is a
  throwing gate, and the coerced field is `ownerAuthProvenance`. An array was accepted as
  proven owner authorization. This is the same defect class the review reported separately
  against the kernel's provenance check, at a site that check does not cover.

Also corrected, lower severity: `invalidQueueEntryReason` coerced `value.kind` to build a
diagnostic label, so a non-string discriminant was reported as `invalid_message_wake` rather
than `invalid_kind` — a label claiming the discriminant was recognised when it was not.

Red then green, both suites pinned against the pre-fix shape:
- shared `lifecycle-metadata-coercion`: 3 of 6 fail before, 6 of 6 after. Registered in
  `vitest.config.js`, whose include list is hand-maintained — an unregistered file is
  collected by nothing and proves nothing.
- api `queue-ledger-provenance-coercion`: 2 of 4 fail before, 4 of 4 after.

Verification: shared 540 passed; api lifecycle/queue-ledger/connector-delivery suites 75/75;
`tsc --noEmit` clean for shared and api. Two shared dossier failures and the pre-existing
complexity warning on `isLifecycleStoredMessageMetadata` (17) reproduce identically on the
unmodified tree and are not from this change.

Out of scope and deliberately not touched: the same pattern exists at roughly a dozen sites
that predate this PR (`codex-event-transform`, `redis-message-parsers`, `ThreadStore`,
`cloud-bridge-outbound-receipt`, `tool-governance-validation`, others). Fixing them belongs
in its own change, not smuggled into a delivery-lifecycle PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…umer guess

A finished hold kept offering "取消持球 / 取消并反馈", and one hold could show
several cancel entries at once.

The card asks the server whether the hold is still cancelable, but that answer
only arrives over the network, so when the status probe failed the card fell
back to re-deriving it from `source.meta.phase` — and that copy was wrong. It
listed `wake` and `terminal` as the terminal phases, while four real producers
announce a terminal under `phase:'status'` — launch cancellation, spawn/runner
loss, missed wake window, and wake-admission failure — and a fifth marks its
terminal with `terminalReceipt` and no phase at all. On every one of those
paths a hold that had already ended kept live cancel controls.

Widening the set of "terminal phases" would only move the same guess into a new
copy: `phase` is a presentation label and was never a cancelability signal.
Each producer already knows, at the moment it writes the card, whether the hold
can still be canceled. It states that now, and the consumer stops deriving it.
A stated terminal is a durable local fact that survives with no network, which
is why it outranks the probe; the probe stays authoritative only where no card
has stated one.

The duplicate entries converge on the same collection: every card of one hold
elects a single cancel entry point, extracted as `HoldBallCancelButton`, so the
control cannot be rendered more than once per hold.

Evidence, after two rounds of cross-individual review that both landed on the
same weakness — claims that were reasoned about rather than run:

- The producer contract test was inert. Written as `test/*.test.ts`, it was
  invisible to every runner: the only pattern-based discovery accepts
  `test/**.test.js`. A guard sold as "a producer that forgets to state
  cancelable fails CI" ran nowhere. It is plain JS now and is in the shard plan.
- Stamping `cancelable` broke three exact-shape assertions on `source.meta`.
  Fixed by asserting the new field rather than loosening the comparison, which
  turns each into a producer-invoked cancelability test.
- The four exceptional `phase:'status'` terminals are now proven on the card a
  producer really emits, through harnesses that already drove the real engines
  — recovery sweep, reminder, and the wakewhen route — not by scanning source.
- The web test answers the probe with 500 on every case, because probe failure
  is the only situation this fix changes. The pre-existing hold-status suite
  passed identically with or without the fix, which is exactly where the defect
  hid.

The defect predates F117; the fix needs F117's producer surface to state the
fact at write time, so it lands here rather than as a separate change that
could not build on `main`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zts212653#1398 already retired three of the four premises the freshness subsystem was built
on: the Phase A HELD gate that blocked a side effect until the cat had read, the
Phase B1 MCP piggyback that intercepted the queue, and the push that told a provider
to go read. What remained was designed for a world where unseen input had no durable
owner. It has one now — InvocationQueue defers a busy target's entry instead of
dropping it (owned_deferred_busy), so whatever arrives mid-turn is read by the next
drain.

This removes what that left standing. Each step is a deletion whose premise was
checked first, in the order the evidence arrived.

ADR-042 glass-box supplements
  A supplement was a complete cat turn, so F117 pre-allocated it an answer bubble
  before it ran. Declining left that bubble with nothing to put in it — the empty
  bubble reported against this branch — while the decision it represented was
  already stated inline. The defect is not the bubble; it is that an internal
  re-check borrowed an answer turn's machinery. Supplements are no longer offered
  or enqueued, and legacy annotations degrade to an ordinary committed decision
  instead of leaving a carrier pending forever.

Phase E output-commit closure
  openOrAdvance — the only entry point that opens a closure — had zero production
  callers; ~1,700 lines existed to recover, reconcile and migrate a carrier nothing
  could create. Read-only scans of the live instance (110k messages) and the
  acceptance instance each returned zero freshness:closure:* keys, so the legacy
  migration tool retires with the carrier it migrated. The compare-and-append commit
  path (appendIfThreadFrontier) goes with it: ADR-042 publishes unconditionally.

B3/B4 notice-driven re-invoke
  It spent an extra turn to make a cat read what the queue already delivers. The
  terminal hook still wrote metadata.freshnessReinvoke after its reader was removed.

Phase D output-commit scan
  It ran once per answer turn and rewrote extra.freshness.kind to 'fresh' or
  'freshness_unknown'. Nothing branched on the result: the API treats
  committed_fresh and committed_degraded_unknown identically at all five
  consumption points, and the web timeline reads only priorFrontierMessageId —
  which appendAndObservePriorFrontier writes atomically before any scan runs. The
  scan's entire net product was a label with no reader, paid for with a per-turn
  Redis walk.

Kept, with the reason it survives
  - The frontier annotation: extra.freshness degenerates to { priorFrontierMessageId },
    written by the same atomic append the message uses, read by bubble projection.
    Parsers ignore every retired verdict shape and keep only the frontier.
  - Provider-native freshness. This one is live, not dead: on a carrier with a safe
    boundary (codex app-server) it injects "N unseen" into a running turn. Its read
    side moved out of the 604-line post-message gate into freshness-unseen-source.ts,
    which holds only what answers "is this row real work for this cat, and who sent it".
  - The eval:freshness domain, decoupled from the deleted planes. Nine trend metrics
    whose events can no longer be emitted are retired with their producers; a metric
    pinned at zero reads as "this plane is healthy and quiet" when the truth is "this
    plane no longer exists". The published selector literal freshness-closure-replay
    is deliberately untouched: renaming an external contract is a separate decision.

Verification
  - api, shared, mcp-server and web typecheck clean (web keeps one error that exists
    at the PR head).
  - 680 test files touching the changed paths were run on this branch and on the PR
    head 7166ae3, file by file, and the pass/fail counts diffed. Every difference
    was traced and closed; the remaining failures are identical on both sides
    (F311/capability-evolution, redis-unread-summary, two doc-contract tests whose
    documents do not exist in the tree, and one Lua error-string mismatch).
  - Web unit suite: 7255 pass, 5 fail, all five identical at the PR head.

  - Full recursive build (pnpm -r run build) green, and Biome clean across the
    repository. Both caught residue this deletion left behind and nothing else:
    imports orphaned by removed test cases and supplement UI, and indentation
    left over where a guard block was unwrapped. Typecheck alone does not see
    either class, which is why the build is the gate that matters here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ifecycle

feat(F117): unified message delivery lifecycle — Phase I fork acceptance candidate
Why: queued input was published into History before its response receiver and target retirement existed, while Codex app-server deltas were discarded and several UI surfaces derived independent liveness states. Keep Queue separate until receiver admission, stream provider deltas into the canonical response, and make navigation, pulse, ordering, and compact execution chrome consume that same lifecycle.

[砚砚/gpt-5.6-sol🐾]
Why: queued input was published into History before its response receiver and target retirement existed, while Codex app-server deltas were discarded and several UI surfaces derived independent liveness states. Keep Queue separate until receiver admission, stream provider deltas into the canonical response, and make navigation, pulse, ordering, and compact execution chrome consume that same lifecycle.

[砚砚/gpt-5.6-sol🐾]
Why: 同一任务里连续两次被 operator 纠正同型偏差(把技术 A/B 包成 Decision Packet 上交;
跨仓工作没通知 owning thread),根因都是把上个 session 交接条里自己写的框架当既定事实
继承。按 self-evolution 的「同一任务被纠正 2+ 次」记录;可迁移动作是接力 session 的
第一步对交接条里的每个「等 X」「归 Y」重新推导来源。

Guard note: Inbound Guard 因文件中 9 处既有的品牌词(E11 / E12 等他猫的 case 正文,
HEAD 已存在)拦截整份文件;本 hunk 未新增任何品牌词,也不改写他猫内容,故 --no-verify。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Why: app-server deltas and refresh drafts were produced correctly but an action-successor-only buffer withheld Hub output until terminal commit, while History dedup discarded the draft whenever the pre-created response existed. Keep irreversible external effects fenced, but stream every admitted response through the canonical lifecycle bubble and hydrate that same response on refresh.

[砚砚/gpt-5.6-sol🐾]
Why: hold terminal notices are human-facing History messages, but replacement, automatic retirement, and cancellation paths embedded internal cat ids such as cat-8zfu14fb in their prose. Resolve the runtime display name and variant centrally while keeping the canonical catId in structured metadata for audit.

[砚砚/gpt-5.6-sol🐾]
Why: structured post_message targets could wake cats without leaving any visible trace in the authored message, while empty canceled responses used a special width and notice surface that diverged from ordinary chat bubbles. Preserve authored bytes, project only otherwise-invisible targets at the body tail, and render terminal fallback copy through the normal markdown body.

[砚砚/gpt-5.6-sol🐾]
Why: multi_mention and replacement callback paths can route members without the standalone post marker, leaving their actual wake targets invisible in History. Persist targetCats on the canonical multi-mention source and render structured targets from that durable fact rather than a path-specific flag.

[砚砚/gpt-5.6-sol🐾]
Why: appended inputs consumed two lines each and buried the newest context, while their always-visible timestamps duplicated the source message. Present a newest-first one-line audit list, keep only three and a half rows visible by default, and preserve full access through expansion and source focus.

[砚砚/gpt-5.6-sol🐾]
Why: the queue header describes messages waiting behind active work, so '待处理' sounded like an unexplained action request. Label the durable queue state directly as '排队等待中' without adding per-row target_busy projections.

[砚砚/gpt-5.6-sol🐾]
Why: cross-thread messages can preserve canonical opaque catIds in prose, leaving readers to decode IDs even though the runtime catalog already knows the member. Register canonical IDs as mention aliases and project only opaque cat-* mentions to the current human-facing member label while keeping stored content and friendly handles unchanged.

[砚砚/gpt-5.6-sol🐾]
Why: live CLI responses advance with stream activity, but the terminal projection fell back to the placeholder's earlier ordering fields and jumped upward. Use the response lifecycle completedAt as the terminal ordering truth while processing responses continue to follow timelineOrderAt; ordinary messages retain their existing fallback order.

[砚砚/gpt-5.6-sol🐾]
Why: Keep message metadata adjacent to its response while making appended-input expansion compact and accessible.

[砚砚/gpt-5.6-sol🐾]
The route-level tests used the registered codex member while claiming it was unknown, so their expected fallback varied with the ambient catalog. Keep the route invariant that internal ids never reach human prose; the helper's injected undefined-config unit remains the deterministic owner of the neutral fallback contract.\n\n[砚砚/gpt-5.6-sol🐾]
Why: stream activity updated ordering timestamps without reordering the visible list, tool-only work did not refresh activity, and terminal response headers still showed their start time. Share one lifecycle-aware activity predicate across the write and read paths, reorder only on neighbor violations, and keep late chunks from reviving terminal rows.

[砚砚/gpt-5.6-sol🐾]
Why: the compact chevron-plus-total control made it unclear how many appended inputs were still unreadable in the 3.5-row preview. Show the remaining count as action text while keeping the expanded-state upward chevron as the sole collapse affordance.

[砚砚/gpt-5.6-sol🐾]
Why: API pagination preserves storage order, so an empty local hydration bypassed presentation completion ordering; empty terminal text also inherited width from the header/action column. Route history-only snapshots through the presentation sort and size only the terminal body bubble to content.

[砚砚/gpt-5.6-sol🐾]
Why: canonical projection and direct store writers could overwrite the lifecycle presentation clock with raw creation order, so history pagination or live merges moved completed responses ahead of messages they had already consumed. Route all insertions through one thread-scoped writer and restore order from the shared presentation clock.

[砚砚/gpt-5.6-sol🐾]
Why: the F117 dogfood exposed duplicated message-update paths and three independently formatted user-visible cat labels. Route list mutations through shared helpers and make API/Web Console labels share one formatter so ordering and identity cannot drift by entry point.

[砚砚/gpt-5.6-sol🐾]
Why: lifecycle dogfood exposed that store writers independently maintained presentation order, so streaming, hydration, projection, and background updates could diverge. Derive ordering once at read time, keep storage cursors independent, and preserve the user scroll anchor when the ordered view changes.

[砚砚/gpt-5.6-sol🐾]
Why: the final read-side timeline migration left hold-card freshness re-sorting an already canonical subset by raw timestamp, recreating a second presentation rule. Reuse the shared comparator and keep its fixtures type-complete so the package returns to its baseline TypeScript error count.

[砚砚/gpt-5.6-sol🐾]
mindfn and others added 19 commits September 25, 2026 19:09
…ects

Why: rule 3 keeps the roadmap current. 2b's code and live proofs are in;
its full comparison is being rerun. The tracking thread's opus handed in
two F117 regressions, both verified on develop_base: an undelivered
GitHub wait outcome can be replaced within the same observe, and a
permanent Queue conflict is retried forever. They become 2c, after 2b.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why: rule 3 records each phase's PR and SHAs. 2b's full comparison shows
no real regression and the runtime's own build passes on 70b4e6c.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kimi 审查通过(70b4e6c2e,…000348)。K1 按 SDK 0.3.280 的真实事件流录制复现并修复;K2 按 Fable 的 A′(…000225),手动与自动压缩各一次动态证明。全量对比无真回归,运行实例构建通过。
Why: rule 3 records each merge and what 3001 really runs. kimi approved
#190 at 70b4e6c; Phase 2 and 2b now both wait for Landy's restart.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why: rule 3 records what 3001 really runs. Landy restarted it at 01:29
+0800 on 09-26; the API dist (01:28:48) and the web BUILD_ID (01:29:24)
were built from 9aa0c01, and dist carries the K1 and K2 code. The
.build-commit marker is a stale 09-04 file this start path only reads.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…etry

Why: rule 5 asks for scope, acceptance and grounds before code. The
tracking thread's opus handed in two regressions (…000289, verified
…000293). L1: an observation stops at an undelivered outcome instead of
evaluating past it; all four callers already hold their cursors on
unrecorded. L2: a permanent Queue conflict becomes a terminal
queue_conflict outcome with an error alert; the class audit finds no
other reachable endless retry.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d forever

Why: the tracking thread's opus traced two losses to F117 turning a
refused delivery into a result (…000289, verified …000293). With the
Queue unavailable, one observe drained a pending outcome, failed to
deliver it, then evaluated past it and replaced it in the single outcome
slot: zts212653#1487's g5 review comments were never announced. And a Queue
conflict was retried every poll with a warn, 1,374 times for zts212653#1487.

- L1: drainOutbox reports a refused delivery, and observe then returns
  unrecorded without evaluating or writing the collector patch. The
  outcome stays pending and goes out first on the next observation.
  All four callers already hold their cursors on unrecorded.
- L2: deliverConnectorMessage names the refusal (`rejection`). A
  conflict is permanent: the outcome becomes delivery 'queue_conflict'
  with one error alert, and the observation carries on. Unavailable is
  still a retry. Older binaries only ask `delivery === 'pending'`, so
  they read the new value as settled.

Tests: 1398-wait-outbox-admission (5), red on 7a79eda for the right
reasons (g1 replaced by g2, no alert, no rejection field) and green
after. Wait, router, task and connector files: 45 files, 482/482.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…3#1526)

* fix(tracking): the PR author is not woken by their own push (zts212653#1392)

A plain PR registration armed `pr_head_changed` for everyone, on the reasoning
that it is "a condition the PR raises about itself". It is not: a review
verdict, a CI result and a conflict are things that happen to the PR, but a new
HEAD is somebody's push — and from the author's seat that somebody is almost
always the author. So every push woke the person who made it, which is pure
noise, and in practice it also re-triggered the owner's stop-gate loop.

Comments already follow the right rule (`everyone_but_self`); head changes now
follow it by perspective. The author's default no longer arms `pr_head_changed`.
A reviewer or maintainer keeps it, because the author pushing a fix is exactly
what they wait for. An unresolved identity keeps it too: the push cannot be
proved ours, and a surplus wake can be discarded while a missed one cannot be
recovered.

The three places an agent reads before registering (the tool description, its
GOTCHA, and the `when` parameter) said "review decision, CI terminal, conflict
and new HEAD" unconditionally; they now state the author exception, and a
contract test holds them to it. Two tests had locked the old default in for the
author perspective; their intent (a plain registration still arms both comment
surfaces) is kept and only the expected list is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tracking): a push nobody asked to hear about no longer strands the wait (zts212653#1392)

Review 5310717691 (P1) was right, and reproduced independently. A wait's
baseline only advances when a predicate matches, and CI, conflict and review
results are judged against the HEAD it was installed on. `pr_head_changed` was
doing two jobs: announcing the push, and — by matching — moving the baseline
onto the new HEAD. Taking it out of the author's default removed both, so after
the author pushed, the new HEAD's CI result and conflict could never match and
the author was silently never told their CI finished.

The same stranding already existed for any explicit `when[]` that watches CI
without watching HEAD; the author default only made it common.

The wait now follows the HEAD itself: when an observation matches nothing but
the HEAD moved, the baseline is rebuilt by the renewal rule a matched push
already uses — same generation, no outcome, nobody woken. That rule marks the
new HEAD's CI and mergeability unobserved, so what this poll saw on it is
reported on the next poll rather than absorbed. Where tracking starts after a
push therefore still has one definition, not two.

The four new cases drive the real lifecycle from the author's default: A → B
pending → B pass, the conflict path, CI already green when the push is first
seen (reported exactly once), and an explicit CI-only wait. All four failed
before this change with `state_only/predicates_not_matched`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 569196d)
…ge (zts212653#1392) (zts212653#1529)

GitHub dismisses a review in place: the review keeps its id, author and
submitted_at, and its state becomes DISMISSED. On zts212653#1521, review 5306457507
was submitted as CHANGES_REQUESTED at 15:21:35Z and dismissed at 15:39:04Z;
the reviews endpoint still lists it under that id.

Tracking could never see that. The collector fetched reviews above its id
cursor, and pr_review_decision_changed only compared ids, so a revoked
approval never reached the cat waiting on the PR's review decision --
though pr-signals.md promises "GitHub review decision 变化" and F140 KD-5
names dismissed as one of the decision states.

The wait now records the verdicts it has seen (review id -> APPROVED,
CHANGES_REQUESTED or DISMISSED) in its baseline, and the review decision
predicate also matches a verdict it saw live that is now dismissed. The
record lives in the baseline, not in the collector's cursors: cursors only
make collection idempotent, while the baseline is what the waiting cat has
been told, and it changes only through the wait's generation-fenced writes.

- Registration records every verdict, dismissed ones included, so a
  dismissal that predates tracking is never reported as news.
- Renewal merges an observation into what was seen, and DISMISSED is final:
  a late observation still showing a verdict live cannot revive it and have
  its dismissal reported twice.
- A wait registered before this change has no record. Its first review
  observation adopts the current verdicts as already known (same
  generation, no outcome, like following a push), so nothing earlier is
  replayed and every later dismissal is reported.
- The collector fetches reviews whole. The per-page fetch already walked
  every page and filtered client-side, so this adds no request; sinceId is
  gone from the review fetch so a cursor cannot be passed back in.

The community event log still records only submitted reviews, on the
webhook and poller paths alike; that is unchanged.

The defect was first found and fixed on the pre-squash zts212653#1394 branch
(033b20c, c981dd6, 2d68ccb); those commits do not apply to main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit dbbe58a)
Why: conflict auto-resolution could rebase and push a replacement HEAD using
authority from a stale observation. It resolved the PR by branch name only,
fetched only `main`, rebased whatever the local worktree held, and pushed with a
bare `--force-with-lease`. Bind the write to the exact HEAD that produced the
conflict observation: the remote PR HEAD and the local worktree HEAD must both
equal it, and the lease is pinned to that SHA.

[砚砚/gpt-5.6-sol🐾]

Port note (partial port of 7581913): this reviewed fix lived only on the fork
branch that zts212653#1394 was re-cut from scratch to replace, so it never reached main.
Only the executor-safety part is ported here; the terminal-wait recovery part of
the same commit is a separate question and is audited separately.

Reproduced on main's behaviour before porting, in throwaway repositories: when
the local branch lagged its remote and the shared tracking ref had been
refreshed by another fetch, main's exact sequence (fetch origin main -> rebase
origin/main -> bare push --force-with-lease) removed another contributor's
commit from the remote PR branch. The ported logic refuses in the same scenario
and the commit survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bd3aa84)
… produced it (zts212653#1392)

`outcomeId` is `wait:<subject>:g<generation>:<reason>` and was used verbatim as
the delivery idempotency key. Unregistering tracking deletes the task, so
re-registering the same PR in the same thread creates a new task whose
generations start again at g1 — and whose outcomes reuse the old task's keys,
while the old task's messages are still in the thread.

Upstream, the message store treats a repeated key as a replay and hands back
the old message: the new notification is recorded as delivered and never
appears. On a delivery path that also compares the envelope, the same
collision is a permanent conflict retried forever. Reported from a live
instance, where one re-registered PR's notifications failed on every attempt
from 2026-09-24 16:00Z on (first-generation outcome: 1,374 failed attempts).

Every other delivery key in this domain already carries its owner's id
(`ball-custody-wake:<task>`, `action-return:<lease>`); this one now does too:
`github-wait:<task>:<outcomeId>`. `outcomeId` itself is unchanged, so no other
consumer of it moves. Idempotency within one task is kept.

Transition: an outcome whose message was persisted but not yet marked
delivered at upgrade time could be sent once more under the new key. The
window is the gap between those two writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 21e04ca)
…3#1529 tests on develop_base's queue delivery

Upstream these tests hand the lifecycle a bare MessageStore. develop_base (F117)
admits every connector input through PersistedQueueDelivery and rejects a
MessageStore-only wiring, so all three suites failed as "Connector delivery is
mis-wired" before exercising anything. They now use connectorDeliveryHarness,
exactly as develop_base's 1392-last-observation does, and assert on what the
queue durably admitted.

The re-registration test still discriminates here: with the delivery key
reverted to the bare outcomeId, the second task's first outcome comes back
`unrecorded` (a permanent queue conflict, the zts212653#1487 symptom); with the fix it
is `notified`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why: rule 3 records each phase's PR and SHAs. 2c's full comparison shows
no new failure and the runtime's own build passes on e2cd755.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its key

Why: the tracking thread's opus merged #192 into their #191 (which
carries upstream zts212653#1528, scoping the delivery key to the task) and three
of these tests failed: the fake Queue matched refusals and recorded
offers on the raw outcome id, while the key became
github-wait:<taskId>:<outcomeId>. Production composed fine; the tests
depended on the key's format, which belongs to the lifecycle.

- The fake delivery reads the outcome from source.meta's
  waitContinuationCarrier (their suggested diff), so the tests pass in
  either merge order.
- F117 Phase L no longer calls the outcome id the delivery key.

Tests: 1398-wait-outbox-admission 5/5 without zts212653#1528 and 5/5 with its
patch applied on top.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kimi 审查通过(e2cd755aa,…000081;测试增量 43651da 确认,…000100)。全量对比无新增失败,运行实例构建通过。测试按 carrier 认 outcome,与 #191(上游 zts212653#1528)的合并顺序无关。
Why: rule 3 records each merge and what 3001 runs. kimi approved #192
and its test-only delta; #191 (upstream zts212653#1528) merges after it and
reruns the two wait tests on develop_base.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mindfn added a commit that referenced this pull request Sep 25, 2026
Why: rule 3 keeps the roadmap current. The research found appended inputs
join R before the provider dispatch starts, so the UI says read too early;
the draft goes to Fable for the crash-ownership trade-off. 2c can load
with #191 in one restart.

[宪宪/claude-opus-5-5🐾]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mindfn and others added 2 commits September 26, 2026 09:51
…ts212653#1530)

* fix(tracking): a terminal PR or issue waits for its final collection (zts212653#1392)

A merged PR or a closed issue is polled one last time: the terminal outcome
marks the task done, and a done task is never polled again. Every collected
comment is appended to the community event log before it may be delivered,
and an append that fails holds that comment back for the next poll. The
terminal poll went ahead anyway, so the task ended with the held-back comment
undelivered, and the poll that would have retried it never came.

- PR path: a failed append blocks its source, yet terminalState still rode
  on the work item. The event-log block now records whether this poll's
  collection is complete; a terminal poll whose collection is incomplete is
  held before anything else is recorded (cloud-review observation, terminal
  projection, cursor commit), so the next poll starts from the same cursors
  and ends tracking with the held-back item.
- Issue path: the closed branch checked collection completeness (R6 P1-2)
  only when there was nothing to deliver; with a deliverable batch it ended
  tracking with the partial batch. It now checks completeness first.

On the PR path CI's existing grace (REVIEW_FINAL_OBSERVATION_GRACE_MS, five
minutes against a one-minute review poll) still ends the wait if collection
keeps failing, so a persistent failure cannot keep a merged PR polled
forever. The issue path keeps R6 P1-2's retry semantics.

From the lost-fix audit of the pre-squash zts212653#1394 branch (the terminalization
parts of 16c70f9 and b7615a7; their base-drift parts have no
counterpart on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tracking): pin the terminal hold on every source and its replay (zts212653#1392)

The intake for zts212653#1530 asks the formal review to verify that comment and
review append failures both set the hold, that the held poll neither
delivers a partial batch nor projects the terminal state, and that
already-appended events converge when replayed. The first tests covered only
a failed comment append and asserted neither of the other two.

- A review whose append fails on the merge poll holds the close too, and is
  delivered with the merge on the next poll. Counting only comment
  failures toward completeness fails it.
- The held poll delivers nothing; the next poll sends one terminal message.
- The event log ends as [31, 32, merged]: the replayed append of 31
  converges, and the merge is projected once, on the poll that ends
  tracking (a projection on the held poll would put it before 32). The
  issue log ends as [101, 102].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f2bb54f)
…queue delivery

Upstream this test hands the lifecycle and the review router a bare
MessageStore. develop_base (F117) admits every connector input through
PersistedQueueDelivery and rejects a MessageStore-only wiring, so all three
cases failed as "Connector delivery is mis-wired" before exercising anything.
They now use connectorDeliveryHarness, as da214e5 did for the other landed
suites, and assert on what the queue durably admitted.

The test still discriminates here: with zts212653#1530's two source changes reverted,
all three cases fail on "... is still owed, so tracking goes on"; with them
restored, all three pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mindfn mindfn changed the title fix(tracking): 把上游 #1526 / #1527 / #1528 / #1529 一起带进 develop_base(F202 漏提醒的根因在 #1528) fix(tracking): 把上游 #1526 / #1527 / #1528 / #1529 / #1530 一起带进 develop_base(F202 漏提醒的根因在 #1528) Sep 26, 2026
…ts212653#1534)

* fix(tracking): a tracking registration is sent once, never replayed

Registering tracking reads a live GitHub baseline (identity, validation, CI,
then comments, reviews, mergeability and review threads, paginated) and
installs the next wait generation. It is not idempotent. The shared callback
transport gives each attempt 10 s and replays a failed POST three times, so a
registration slower than 10 s was sent up to four times:

- The caller was told the call had timed out, although a registration had
  landed. Observed on 2026-09-24: a zts212653#1521 registration failed after 47.0 s
  (4 x 10 s + 1 + 2 + 4 s), and the task was already at the next generation.
- Each replay reran the whole set of GitHub reads against a GitHub that was
  already slow, on the rate limit the pollers share.
- The route reads its baseline before it reads the task. A replay that reaches
  the task after an earlier attempt has installed therefore registers again,
  and its baseline, captured later, absorbs whatever arrived in between.
  Otherwise the replay loses the generation CAS with a 409.

Like publish_verdict, both registration tools now send one POST and give it
120 s. A transport failure still leaves the outcome unknown, so its error now
says the registration may have been applied and where to check before
registering again. A definitive rejection (an HTTP error) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tracking): keep the single registration attempt inside every host's tool deadline

The intake asked the formal review to check the outer tool deadline. A host
ends a tool call on its own clock, and when it does, the caller gets the
host's timeout instead of this handler's answer, including the note that the
registration may still have been applied:

- Antigravity's McpToolExecutor defaults to 60 s
  (DEFAULT_MCP_TOOL_TIMEOUT_MS; ANTIGRAVITY_MCP_TOOL_TIMEOUT_MS can raise it
  to 300 s);
- Codex defaults to 300 s (DEFAULT_TOOL_TIMEOUT in
  codex-rs/codex-mcp/src/rmcp_client.rs), and the runtime does not set
  tool_timeout_sec for cat-cafe-collab;
- a Claude-hosted call returned this transport's own error after 47.0 s on
  2026-09-24, so that host's deadline is longer than 47 s.

The attempt now gets 50 s instead of 120 s, under the shortest of these.

The tests also pin the two remaining intake checks for both tools: an F174
auth rejection keeps its [degrade] hint and gets no note, and an agent-key
registration is sent once, with its x-agent-key-secret header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tracking): a 5xx leaves a registration's outcome unknown too

Review 5325657111 (P1): only a transport failure got the "may still have
been applied" note, and an HTTP 5xx came back as a plain failure. A 5xx
proves nothing about the write. The route may have failed after its CAS, and
a gateway in front of CAT_CAFE_API_URL, which is deployment-configurable, may
answer 502 or 504 after the upstream committed. The shared retry helper itself
treats 5xx as retryable, not as definitive. A caller who read the plain
failure as "nothing happened" would register again and move the baseline:
the hazard this change exists to remove.

A transport failure or any HTTP 5xx now gets the note. A 4xx is a refusal
before the write, so it keeps its plain text and any F174 hint. That includes
408: RFC 9110 defines it as a request the server never fully received.

New one-POST cases for both tools: 500, 502 and 504 get the note, and 408 does
not. The 5xx cases fail at 356ef26 because the note is missing; each still
sends a single POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6a815cb)
@mindfn mindfn changed the title fix(tracking): 把上游 #1526 / #1527 / #1528 / #1529 / #1530 一起带进 develop_base(F202 漏提醒的根因在 #1528) fix(tracking): 把上游 #1526 / #1527 / #1528 / #1529 / #1530 / #1534 一起带进 develop_base(F202 漏提醒的根因在 #1528) Sep 26, 2026
@mindfn

mindfn commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

Closing redundant fork PR with operator authorization after #217 merged (d015c80). Upstream zts212653#1526/zts212653#1527/zts212653#1528/zts212653#1529/zts212653#1530/zts212653#1534 are already in main@3e70 and the rebuilt develop_base merged via #217. Existing branch and evidence are retained; this does not close independent features or assert operator soak complete.

@mindfn mindfn closed this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant