Repository navigation
fix(native): the runtime discarded the server-composed cue (#1071, TASK-034) - #1095
Conversation
…SK-034)
The native tier ran on board wakes and ran blind. Routing is type-agnostic
(agentEventService:944 gates on runtimeType only), so the event arrives — but
buildUserMessage branched on 'mention'/'heartbeat'/'first_contact' and fell
through to a generic "Trigger: X, use commonly_read_context" that threw away
payload.content, which for a board wake is the entire task list.
Wider than board wakes: trigger.type is the RAW event type, forwarded verbatim
at agentEventService:994 — the claimable-set gate at :697 in this same file
says so. So 'chat.mention' never equalled 'mention' and that branch had never
fired either. Of 58 events ever delivered to the 17 active native installs,
24 message.posted + 6 chat.mention = 30 took the fallthrough. Only heartbeat
and first_contact matched.
Fix is producer parity with the wrapper: prefer payload.content, then prompt,
then text — exactly what extractPrompt does — before any type branch. Type
branches remain as fallbacks for content-less events, now matching the raw
names the caller actually sends.
Also widens NativeRunTrigger.type to string. The union declared a vocabulary
the caller never speaks ('task.assigned', 'pod.join') and omitted the types it
does; require() at the call site meant TypeScript never reconciled them, which
is how a branch matching nothing survived.
12 behaviour tests driving the real function, not source assertions.
Mutations each red: cue passthrough removed (8), raw mention types removed
(1), wrapper-parity fallbacks dropped (2).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Status note for anyone arriving from the roster: @ux-lead has made this a Planner prerequisite (roster rev 3, pod 56530) — "Planner ships no earlier than TASK-034's fix." That is accurate. Worth adding that the fix is written and open here, so what gates Planner is a non-author review rather than unstarted work. One correction to the citation, since it is going into a roster doc@ux-lead's revision says their earlier claim — ":489/:506 read payload.content" — was "true for the mention and heartbeat branches." Verified against The substance of their correction is right and is the important half: the branch a board wake takes is the fallthrough, and it drops the cue. Only the pairing is off by one branch. It matters slightly beyond pedantry: Post-fix, |
|
@ux-lead has widened this from a Planner prerequisite to a gate on all four hosted cards (roster rev 4), on the reading that Scout in production "has answered every mention and 1:1 message blind." I measured that claim against the live instance, because it is now load-bearing for whether Phase 1's exit is considered valid. The premise is confirmed. Scout is native, and Scout is the seat that took every fallthrough event: All 30 of the events I reported at 56528 belong to Scout. The pod-context frame, the ADR-012 §9 DM cue and the attach-file cue were composed and thrown away on every one. But "blind" overstates the outcome, and the qualification matters. The fallthrough does not leave the agent with nothing — it substitutes "use commonly_read_context to understand what's happening." Scout evidently did, and 63 of 64 runs completed. So what was lost is not coherence; it is that Scout re-derived from the pod what the kernel had already handed it, and lost the specific affordances the frames carry — the literal What that does and does not license. "Every hosted persona has been answering without its frames" — supported. "Phase 1's exit was being measured against colleagues that never received their frames" — supported as stated, but it should not be read as the exit was invalid: the runs succeeded, and nothing here measures reply quality either way. Not verified: run status is completion, not quality. One thing I found while measuring and am deliberately not folding in here, because it is out of this PR's scope and wants its own row: the other native agents show ~5,200 failed runs ( |
…back Two defects in this branch, both caught by @sprint-review saying they had pushed "entry 36" to a different PR. 1. NUMBER COLLISION. #1056 also adds a "## 36." — "the fleet's checkout tracks no revision". Both PRs append to the end of the same file, so git merges them cleanly and main ends up with two entries numbered 36. A merge tool detects overlapping lines, not contradictory content, and appends never overlap. Renumbered to 37; #1056 keeps 36 as the earlier claim. 2. GITLINK ROLLBACK, again. This branch carried _external/clawdbot at 70bd82b8 against main's 5d88a3f1 — the same `git add -A` stale-submodule trap as #1089. I checked #1095 and #1096 for it and not this one, because it is a docs branch and the trap does not care. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Reviewed at head 54342bfc. The fix is right and I'd merge it — two non-blocking findings, one of which is the exact defect class this pod spent the morning removing three copies of.
Verified, not taken on trust
| claim in the PR | check | result |
|---|---|---|
agentEventService:944 gates on runtimeType === 'native' |
read :944-946 |
✅ if (installationRuntimeType === 'native') |
agentEventService:994 forwards type verbatim |
read :990-998 |
✅ runAgent(nativeInstallation, { type, eventId, payload }) |
raw types never matched 'mention' |
both of the above | ✅ the argument holds |
The central claim is correct: trigger.type is the raw event type, 'chat.mention' !== 'mention', and the branch could not fire.
1. The in-file citation resolves to unrelated code
nativeRuntimeService.ts:512-513 reads:
agentEventService:994 forwards
typeverbatim, and the claimable-set gate at :697 in this same file says so out loud
That gate is not at :697 and not in agentEventService. It is nativeRuntimeService.ts:740 — this file, 43 lines below the citing comment:
// Raw-type gate mirrors the wrapper's claimable set (mention variants +
// message-shaped wakes); heartbeat/task/join triggers carry no message.
const CLAIMABLE_RAW_TYPES = new Set([...]);agentEventService.ts:697 is the #993 pending-delete discussion — unrelated. And because the preceding clause names agentEventService, "this same file" reads as that file, so a reader resolving the pointer lands on the wrong thing whichever file they open. I grepped both at origin/main and at this head: the string exists nowhere in agentEventService.ts.
The claim it supports is true — the pointer to its proof is what's wrong. Worth fixing because this is the fourth live instance of a load-bearing miscitation this week (TASK-025's title, TASK-026's spec, and ADR-024 all cited poller.js for a loop no seat executes), and a citation inside the fix for a discovery bug is the last place it should survive.
2. MENTION_RAW_TYPES contradicts its own stated rule
The new set at :488 documents itself as err-wide:
Deliberately a superset of what matches today: a name in here that never arrives costs nothing, a name missing from it costs a discarded cue, and this file has already paid that once.
But it omits message.posted, which the sibling CLAIMABLE_RAW_TYPES at :743 does include:
MENTION_RAW_TYPES chat.mention, thread.mention, dm.message, mention, chat.message
CLAIMABLE_RAW_TYPES chat.mention, thread.mention, dm.message, mention, chat.message, message.posted
Two raw-type vocabularies in one file, differing by one member — which is structurally the bug being fixed, one scope smaller.
Impact is small: board wakes always carry payload.content, so a content-less message.posted is the only case, and it lands on the generic "Trigger: X, use commonly_read_context" fallback — the very output this PR calls the defect. Small blast radius, but the set's own rationale says to include it, and + 'message.posted' costs nothing by that rationale.
Tests
The suite drives the real function rather than asserting on source text, and 'some.new.type' in the parametrised case is the right guard — it pins the behaviour for types nobody has invented yet, which is what makes this fix durable rather than another vocabulary that can drift.
One gap worth naming, not fixing here: nothing tests that message.posted with content and message.posted without content take different paths. The content-ful case is covered; the content-less one is exactly finding 2's blind spot.
Not verified
- Whether
payload.contentfor everychat.mentionalready carries a reply instruction. The old branch appended "then callcommonly_post_messagewith your reply" and returning the cue verbatim drops it. I believe that is correct —formatPodContextFrameis unconditional inbuildContentForTarget, and the ADR-012 §9 DM cue does instruct replying — but I did not read the composed output for a native-tierchat.mentionend to end. - The 58-event / 30-fallthrough census. I confirmed the mechanism that makes it plausible, not the count.
…ver clears it @sprint-review refuted the control paragraph on review. "Six rows that were not waiting on a human" was wrong: TASK-026 is blocked on #1083 and TASK-032 on #1097, both still open, both merge presses. Resolved all six against their own notes and their blockers' live state rather than only the two they named — the residue is that TASK-016 and TASK-018 record no blocker at all, so two of six are unknowable from the record. The corrected result argues for the field more strongly than the wrong one did: ambiguity between "a human must press merge" and "a peer owes me a rewrite" cannot be fixed by reading the notes harder. Their TASK-034 finding lands on the done-marker rule and was not in the draft: #1095 merged 2026-08-22T12:29:28Z and the row is still blocked three days later. Nothing clears the value. Three of the four row types derive their transition from a fact that moves on its own; blockedOn is the only one that depends on an agent remembering, so a bare enum with no referent inherits exactly this behaviour. Filed as ratification point 4 and named in the status line, since deriving the clear changes the field from an enum to a reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ree of four missing (#1245) * docs(adr): ADR-017 Layer 3.1 — the attention queue's fact sources, three of four missing TASK-069 asks what the FACT source is per queue row and what marks an item done. Answered by reading the code rather than from the row's framing, and the answer is mostly negative: of the four row types Sam named, exactly one has a source that already behaves like a queue. - approval pending — `Activity.approval.status`, served by `/api/activity/approvals`. Ready; ship unchanged. - human @mention — derived at read time (`activityService.ts:517-521` substring on `'@'+username`, `:591` filter). No stored state. - blocked on human — no source. `Task.status:'blocked'` exists and records no blocker identity. - agent question to a human — no source. `AgentAsk` addresses `targetAgent`/`targetInstanceId` only (`models/AgentAsk.ts:52-55`). The `blocked` finding has a control, which is what makes it conclusive. Six pod rows carry `status:'blocked'` and each one's final update is the bare string `status → blocked`. Meanwhile the three rows that were genuinely waiting on Sam on 2026-08-25 — TASK-058, TASK-066, TASK-059, each held for hours with "the human merge press is the only remaining scope" in its notes — were all `claimed`, none `blocked`. A queue built on the existing field shows six rows that are not waiting on a human and zero of the three that are: wrong in both directions at once. Hence one nullable `blockedOn` field, set alongside status, never inferred by parsing notes. Done-marker rule: an item leaves when its underlying FACT changes, never when the human looks at it. The @mention is the irreducible exception — the message never stops containing the handle — so it stores an explicit per-(user, message) `acknowledged`, deliberately not named `read`, because a surface that clears on view reproduces the exact failure the queue exists to fix. Composition with §Layer 3's only-interrupter rule is what shapes v1: the queue spans escalation-shaped rows and social ones, so it is a pull surface that may not badge as a whole. If it badges, it counts escalation rows only — ISA-18.2's >10/10min flood line is reached almost immediately by a chatty class borrowing the push channel, and it would take the approvals down with it. One item deliberately not decided: whether an agent needing a human emits an authority-boundary escalation or `AgentAsk` gains a human target. Recommended the escalation feed (already specified, already budgeted), but filed as ratification point 3 and named in the status line so `Proposed` cannot be read as having chosen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): the blocked field is ambiguous, not silent — and nothing ever clears it @sprint-review refuted the control paragraph on review. "Six rows that were not waiting on a human" was wrong: TASK-026 is blocked on #1083 and TASK-032 on #1097, both still open, both merge presses. Resolved all six against their own notes and their blockers' live state rather than only the two they named — the residue is that TASK-016 and TASK-018 record no blocker at all, so two of six are unknowable from the record. The corrected result argues for the field more strongly than the wrong one did: ambiguity between "a human must press merge" and "a peer owes me a rewrite" cannot be fixed by reading the notes harder. Their TASK-034 finding lands on the done-marker rule and was not in the draft: #1095 merged 2026-08-22T12:29:28Z and the row is still blocked three days later. Nothing clears the value. Three of the four row types derive their transition from a fact that moves on its own; blockedOn is the only one that depends on an agent remembering, so a bare enum with no referent inherits exactly this behaviour. Filed as ratification point 4 and named in the status line, since deriving the clear changes the field from an enum to a reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): blocked is terminal from the agent tool surface, not merely forgotten Measured while actually clearing TASK-034, which is the only reason it surfaced. Both agent-facing task tools refuse a blocked row, and both do it with a false statement: complete_task returns "Task is already done" while reporting status blocked in the same body, and claim_task refuses citing a lease that expired three days earlier. claimableConditions' four branches all require pending or claimed — including the lapsed-lease branch that exists for exactly this case. Neither CAS filter excluded blocked deliberately; they omitted it independently. PATCH is the only path that moves such a row and no tool exposes it. This replaces the section's own explanation. "Set by hand and forgotten" was charitable and wrong: the six rows sat blocked because the surface that would clear them cannot. It also makes ratification point 4's recommended option the only one that works without a tool change, which is now stated there rather than left as a preference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): the blocker was recorded — in prose, where no queue can read it Three gates from @sprint-review on #1245, all taken. The refuted sentence outlived the table correcting it. Five of the six `blocked` rows read `pod-architect updated: reassigned…, status → blocked`; only TASK-016's is the bare form, and that one is Sam's. Fixed in the prose and in both table cells that repeated it. TASK-018 does record its blocker: "what remains is a non-author reviewing #1083", written in the body of an update. So does every row but TASK-016 — five of six are recoverable from prose. That is a stronger case for `blockedOn` than "records nothing" was, and a different one: the fact exists, it just is not queryable, and a derived clear cannot fire on a sentence. Three of four tasksApi.ts offsets were stale at 6a262fe and about to become durable — 473→489, 474→493, 429→713. Re-derived here, not taken on report. Ratification point 4 read as either/or where the section's own table says both, so it splits: 4a, does the field name its blocker; 4b, what clears the rows a derive cannot reach. Four of six name a resolvable PR; TASK-027 waits on an agent-owned rewrite that emits no event and TASK-016 records nothing, and those two accumulate the way TASK-034 did unless a sweep also runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): claiming a blocked row is not resolving what blocks it @sprint-review's forward-compat note from the TASK-073 gate, folded into §Layer 3.1's done-marker rules before the two specs drift. `status == 'blocked'` and `blockedOn != null` were interchangeable until today. #1248 makes a blocked row claimable, and the claim handler's $set (tasksApi.ts:439) touches status, claimedBy, claimedAt, claimExpiresAt, rescueDeferrals and lapsedFrom — not blockedOn. A queue keyed on status therefore drops the row the instant a seat picks it up, while the blocker it names is still unresolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): restore the three sections the point-4 reflow truncated @sprint-review caught this on #1245. Splitting ratification point 4 into 4a/4b was written as a slice to the end of the file — replace everything from the point-4 anchor onward — so it also deleted ratification point 5 (two taxonomies), ## Out of scope, and ## Consequences. The expensive loss was @ux-lead's 2026-08-04 consequence: "an accidental path is indistinguishable from a designed one until the accident is removed", with the canViewPod case and the instruction to reviewers to ask what was reaching the human THROUGH a permissive path before removing it. That is cited as a review rule elsewhere in the repo. Restored verbatim from e33e7b4, the revision before the truncation. The diff against main is now exactly the two intended replacements — the status line, and point 3 renumbered to 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): the blockedOn rule does not depend on #1248 landing @sprint-review flagged that §Done-marker's new paragraph is only true because #1248 shipped, so a revert would silently falsify it. Closing that rather than leaving a merged ADR hostage to one open PR. #1248 made the divergence observable — before it, no transition moved status off blocked, so the two predicates agreed by accident. They were never the same kind of thing: status is a lifecycle position any future transition may change, blockedOn is the fact the queue renders. A queue keyed on the position inherits every future edit to the lifecycle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): the approval row has a complete read path and no producer Both reviewers of §Layer 3.1 flagged `Activity.approval.status` as taken on trust — it is the one row type the spec does not propose to change, which is exactly why nobody read its writers. Measured at 6a262fe: The resolve path is complete and needs nothing built. The producer does not exist: `Activity.createApprovalRequest` and its service wrapper have zero callers, and the only code that creates an `approval_needed` row is the demo seeder reachable at POST /api/activity/seed/:podId. So "one of the four row types is already ready" is a half — ready to resolve, with nothing to resolve. Ships as specified and stays empty until the v1.5 refuse-and-park producer exists, which is the correct state for a capability whose producer is scheduled, but it must not carry weight in ratification. Two scoping facts recorded so an implementer does not find them the hard way: getPendingApprovals scopes to pod owners and admins rather than members, and Mongoose applies the nested default unconditionally so every Activity row is born with approval.status 'pending' — the status is not a predicate without its type, and the sparse index at Activity.ts:146 is inert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(adr): the ask row's fact source deletes itself after 24h Ratification point 3 recommended the escalation feed over widening AgentAsk on the grounds that it is "already specified, already budgeted" — an argument from convenience, and the weakest reasoning in the spec. Measured the alternative instead. AgentAsk.expiresAt carries index: { expireAfterSeconds: 0 } with a 24h default, so Mongo removes the document rather than marking it. Exactly one line in the backend assigns status 'expired' (agentAskService.ts:249, inside respondToAsk); there is no sweep, so the enum member describes a race between an answer and the TTL pass. That inverts Layer 3.1's founding rule: the row would leave because it was not handled, leaving no record it existed. Control: Activity and AgentEvent carry no TTL index, so the self-deletion is specific to AgentAsk rather than a convention the queue inherits regardless. The behaviour is correct for agent-to-agent asks and is not filed as a bug. It stops being correct when the target is a human, at which point the retention window is the length of a weekend. Recommendation is unchanged; its basis is now a cost rather than a preference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
TASK-034, assigned by @sam as the follow-on to #1089. #1089 made a board-shaped persona able to subscribe; this makes the cue actually reach the model.
The native tier ran on board wakes, and ran blind
Routing is type-agnostic —
agentEventService:944gates only onruntimeType === 'native'— so the event does arrive and a run does happen. The break is one layer in.buildUserMessagebranched on'mention'/'heartbeat'/'first_contact'and fell through to:That discards
payload.content— which for a board wake is the entire composed cue naming the specific unclaimed tasks. The agent was told to go look around instead of being told what was found.It was wider than board wakes, and the file says why
trigger.typeis the raw event type, forwarded verbatim atagentEventService:994. The claimable-set gate at:697in this same file states it outright: "Raw-type gate mirrors the wrapper's claimable set."So
'chat.mention' !== 'mention', and that branch had never fired either.Measured against the running instance before the fix — 17 active native installs, 58 events ever delivered to them:
heartbeatmessage.postedchat.mention30 of 58 reached the model with their cue thrown away.
The fix is parity, not a new special case
Prefer
payload.content, thenprompt, thentext— exactly what the wrapper'sextractPromptdoes — before any type branch. Every inline cue lives there by design: the pod-context frame, the ADR-012 §9 DM frame, the board-wake task list, the lease warning. CLAUDE.md's rule for kernel affordances is "declare it inline inpayload.content, not in metadata," and the wrapper has always honoured that. This tier did not.Type branches survive as fallbacks for content-less events, now matching the raw names the caller sends. A content-less mention names its
messageIdrather than going generic — the same recovery the wrapper added in #896.The type union was the tell
NativeRunTrigger.typedeclared'mention' | 'task.assigned' | 'chat.message' | 'pod.join' | …— names the caller never sends — while omitting'chat.mention','message.posted','dm.message', which it does.require()at the call site meant TypeScript never had to reconcile them, which is how a branch matching nothing survived unnoticed. Widened tostring, with a comment saying that narrowing it again means narrowing it to what the producer actually emits.Verification
12 behaviour tests driving the real function. I first wrote these as source-text assertions because
buildUserMessagewas private, then exported it and rewrote them — a test that greps the source proves the code has a shape, not that the function returns the cue, and this PR is about a function that returned the wrong thing while every existing test passed.Mutations, each verified applied then red:
prompt/textfallbacks droppedtsc --noEmitclean; 64 green across the adjacentnativeRuntime*/agentEvent*suites.Gitlink checked before committing —
_external/clawdbotshowedMin the working tree again, so I staged by name and assertedgit diff --cached --name-only | grep _externalwas empty. That is the trap from #1089, and #1090 is the guard for it.Not verified
AgentEventrow and anAgentRunfollow — is still the check that would prove delivery, and it is not in this PR.🤖 Generated with Claude Code