M2-11: Follow-ups with steer and queue¶
Status: ready | Depends on: M2-10 | Milestone: M2
Goal¶
After this ticket a human message that would trigger the agent of the thread's active run becomes a follow-up of that run (run.followup.create through createMessage) instead of a second run: a row in run_followups with state queued and trigger_state = received on the message. When the agent's effective capability followups is steer, the server writes the personal event agent.followup, which M2-08 delivers with a fresh invocation token after re-checking agent.invoke for the follow-up's author; the adapter acknowledges delivered (the message shows followup_delivered) and then absorbed through a run patch, forward only. With queue or without the capability the server sends nothing, except to a run in waiting_input, where the follow-up is the answer to the open question and resumes the run with the token of its delivery. When the run ends, every follow-up still queued or delivered without absorbed becomes deferred, and the first of them, in thread order, triggers a new run whose follow-ups are the rest; after a stop by a person (cancelled with user_cancelled) they become discarded and nothing runs. This is 09 "Follow-ups during a run" and AG-13: people can add to a running turn without losing what they wrote, a stop really stops, and no follow-up of another person ever reaches an agent on a personal subscription.
Read first¶
docs/spec/09-agents.md: 2.2 "Features" (row "Follow-ups", effective capability = owner permission and adapter report, R-AG-01); 3.4 "Decision per message" (point 6); 3.5 "Reasons for rejection at acceptance"; 4.3 "Connection and cursor"; 4.4 "Events to the adapter" (rowagent.followup); 4.5 "Invocation token" (claimtrigger_kind = followup); 4.6 "Run context" (already_delivered); 4.7 "Adapter operations" (row "Acknowledge follow-up"); 4.8 "Heartbeat and reported state" (capabilities); 5.1 "State machine" (which deliveries replaceruns.invocation_jti); 5.2 "Reasons (runs.state_reason)"; 5.3 "Run document (run_document.v1)" (path/followups/<message_id>/state); 5.4 "Live box content" (row "Follow-ups"); 5.5 "Stall, interruption, orphaned runs"; 6 "Trigger state (trigger_state)"; the whole of 7 "Follow-ups during a run"; 8 "Stop" (point 5); 11.6 "Questions"; 15 "Reliability" (row "Adapter dies during the run"); 16.2 "Required scenarios" (AG-13, AG-14, AG-31); 17.5 "Follow-ups:busy_input_mode" and 18.5 "Follow-ups via stdin" (context for the reference adapters); 19 (R-AG-01, R-AG-08, R-AG-09, R-AG-10, R-AG-17).docs/spec/04-sync-protocol.md: 2.2 "Envelope" (payload rules 1, 3 and 4:agent.followupnames the message only by id); 4 "Thread sequence"; 9 "Delivery guarantee" point 3; 11.1 "Run document"; 11.3 "From the server to the clients"; 15.2 "Ack and redelivery"; 15.3 "Invocation and run start" (points 2, 5 and 6); 15.4 "Follow-ups" (all six points); 15.5 "Cancellation"; 16.5 "Agent run with follow-up"; 17 (R-SY-05, R-SY-07, R-SY-17, R-SY-22).docs/spec/03-data-model.md(core contract, read only): 4.8 "Runs and interactions (runs)" withruns(trigger_kind,triggering_message_id"for a run from deferred follow-ups the first of these messages",triggered_by,version,invocation_jti, the partial unique index of active runs, the transition list) andrun_followups(all columns, CHECK, PK, FKs, unique(message_id), index(run_id, state), retention); 4.6messages(trigger_state,trigger_state_reason, the checkstrigger_state IS NULL OR origin = 'human'andtrigger_state_reason IS NULL OR trigger_state IN ('failed','cancelled'),deleted_at,version); 4.4agents(operator_config,reported_details, the paragraph on heartbeat persistence); 6.3 "Lock order"; R-DM-14.docs/spec/05-actions.md: 4.10 rowsagent.invoke,run.followup.create,run.update,run.start,run.cancel; 5 "Agent invocationagent.invoke" (points 1 to 8, the claim table, "Consequences on denial"); 6 rowsagent.followup,message.updated,run.state_changedand the ephemeral rowrun.updated.docs/spec/06-api.md: 2 "Basic rules" (row "No effect": unknown transition gives409 invalid_state_transition); 4 "Authorization in the API" points 6 and 7 (run.followup.createis checked insidecreateMessage); 5 "Errors" (points 3 and 7); 7 "Idempotency" (client_msg_id).docs/spec/07-permissions.md: 2.3 "Levels" (triggerincludeswrite); 2.9 "Agents as principals"; 4.1 "Decisions and reasons"; "SR1 Backend policy (I1, I2)"; "SR4 Only humans trigger"; 7.4 "Query pattern" rule 1; 8.1 "Delivery filter" (row "Agent consumers"); 8.2 "Immediate effect on revocation" (points 4 and 5); 10 "Examples" and 11.1 "Test matrix" (the test world).docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowsrunsandmessaging:Messages.SetTriggerState); 4.4 "Allowed dependencies" (runsmay useinstance,chat,agents,messaging,billing); 4.7 (paragraph:message.updatedfortrigger_statecomes frommessaging); 5 "Layers within a module"; 6 "Code layout" (test helpers ininternal/<module>/adapters/<module>test, cross-module tests intest/); 8.1 "Flow"; 8.2 "Lock order"; 8.3 "In-Tx events"; 11.6 "Ephemeral events" (rowrun.updated).docs/spec/01-product.md: 6.2 "Live box" (bullet "Follow-ups"), 6.3 "Trigger state as icons".docs/spec/20-privacy.md: rowrun_followups(indirect, classcontent, deleted with the run).docs/api/openapi.yaml: operationscreateMessage,getRun,patchRun,startRun,resumeRun,awaitRunInput,finishRun,cancelRun,pollAgentEvents,ackAgentEvents,putChannelBehavior,sendHeartbeat; schemasRun(followups),RunFollowup,RunPatch,JsonPatchOperation,RunSummary,RunFinish,RunCancelRequestedPayload,AgentFollowupPayload,AgentFollowupEvent,EventEnvelope(audience,invocation_token,redelivered),Message,MessagePayload,MessageUpdatedEvent,TriggerState,RunStateChangedPayload,Heartbeat(detailscarriescapabilities),ChannelBehavior;x-problem-codesofProblem(invalid_state_transition,base_version_conflict,not_found).docs/api/asyncapi.yaml: messageevent.agent.followup; operationreceive.run.update.docs/api/webhooks.md: rowagent.followup(agent subscriptions only; the webhook transport is not built here).- ADRs 0025 (run as its own object with
run_followups), 0023 (invocation token), 0022 (agents outside the server), 0017 (one authorization function), 0052 (one envelope, delivery fields), 0053 (no JSONnull), 0054 (trust marks: follow-ups of other people reach the runtime as marked data, M2-13), 0008 (module layers), 0007 (migrations). docs/DECISIONS.md: D-B7, D-AG-07, D-AG-08, D-AG-09 (R-AG-10), D-K-D3, D-K-D34, D-W-44, D-W-100; section 10 "Rulings of the build milestones" (row formatD-M2-11-<n>).docs/security/threat-model.md: T-AI-01 (content of other people), T-AI-07 (one active run per thread), T-AI-08 (personal subscription used by a second person).docs/RISKS.md: RK-01, RK-12 (Hermes Agent internals behindsteer), RK-18 (a run twice).docs/OPEN-QUESTIONS.md: OQ-01 (default: strict I1 without owner exception, so no second person ever hastriggerwhere a personal agent operates).docs/observability/signals.md4.3: the run metrics belong to M2-05 and the counterbothy.agent.invocation.deniedto M2-08; the catalogue names no metric for follow-ups, so this ticket adds none.docs/operations/configuration.md:database.tx_retry_max,agent.cancel_grace,agent.ack_timeout,agent.invocation_token_ttl,run.queued_max_age,agent.heartbeat_persist_interval(all read, none new).docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria points 1, 5 and 10, Test strategy, Red team: "another person triggering a personal subscription through every path (... follow-up ...)", Risks) and the rows M2-04, M2-05, M2-08, M2-09, M2-10, M2-11, M2-12, M2-13, M2-15, M2-16, M2-20, M2-29, M2-31, M2-33, M2-35.- M2 briefs this ticket builds on (all in
docs/briefs/):M2-04-trigger-decision.md(Decide, outcomefollowupwithRunID,Check = CheckFollowUp,Settle),M2-05-runs-state-machine.md(Open,Transition,ErrActiveRunExists, the acceptanceOnMessagePosted,Messages.SetTriggerState,Run.followupsempty until this ticket, the pinning testTestAcceptance_ConcurrentMessagesOneRun),M2-06-invocation-token.md(claims fortrigger_kind = followup:message_idis the follow-up message),M2-07-agent-consumers.md(pollAgentEvents,DeliveryHook),M2-08-invocation-delivery.md(portruns.DeliverywithPrepare, the delivery matrix rowagent.followup, the resolver "trigger = author of the follow-up message", the re-check principal of its note 4, the reason mapping of its note 6, the acceptance of its note 10 with branch e left to this ticket, the payload builder foragent.followup, the pinning testTestAcceptance_FollowupBranchUntilM211, jobruns.queued_expiry),M2-09-run-lifecycle.md(startRun,resumeRun,awaitRunInput,finishRun,cancelRun,requestCancel, the jobsruns.cancel_timeoutandruns.stall_check, the one end-of-run function of its note 6f with its extension point),M2-10-run-document-live-box.md(patchRun, oprun.update, the path/followups/<message_id>/state, the outgoing portFollowupStateswith its empty default, note 7). - Dependencies: the header names only M2-10 (D-W-107). Through it the closure holds M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M1-65, M1-56, M1-55, M1-54, M1-51, M1-28, M1-24, M1-16, M1-14, M1-13, M1-12, M1-06, M1-03, M1-01, and also M1-68, M1-69 and M1-71 through M2-02.
- M1 briefs whose interfaces you use: M1-12 (
pgtest, migration numbering), M1-13 (unit of work,txn.Registry,database.tx_retry_max), M1-16 (eventlog.EventwithAudiencePrincipalIDandReceiveAction,eventlog.Appender), M1-24 (auditing.Recorder), M1-25 (action catalogue in code), M1-28 (authorize.Resource,InvokeFacts{AgentID, TriggerOrigin, MentionsAgent, ActiveRunAgentID}, the deferred entryrun.followup.create, the guard ofagent.invoke,TestDeferredActionsGolden), M1-51 (agents portAgents,operator_configschemaagent_operator_config.v1withsupports_followups), M1-55 (chat.Threads.Info), M1-56 (messaging.MessagePostedwithMentionedIDs), M1-64 (wstest); M1-58 (messaging.MessageDeleted) is not in the closure and is used only if merged (Design note 9). - Code on the base branch:
services/server/internal/runs(port,domain,app,adapters),internal/authz,internal/agents,internal/messaging,internal/gateway,internal/kernel/eventlog,internal/kernel/txn,internal/wire,internal/platform/pg/pgtest,architecture/modules.yaml(modulerunsalready lists tablerun_followups, actionrun.followup.createand eventagent.followup),migrations/,docs/spec/actions.yaml(entryrun.followup.create:right: write and `agent.invoke`,audit: denials; typeagent.followup:resource_version: false).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Only a message that would trigger the agent of the thread's active run becomes a follow-up; a message without a trigger (no
trigger, mention rule not met) stays a normal message and reaches the agent only as context of the next run; a message for another agent is rejected withrun_active_other_agent; control commands are never follow-ups (09 7, 09 3.4 point 6; 04 15.4 points 1 and 6; 05 5 point 8; R-SY-22, D-K-D34). run.followup.create: resourcethread, right "write andagent.invoke" (so I2, SR2 and SR4 hold for every follow-up), Auditdenials, classrl.messagethroughcreateMessage, no route of its own (05 4.10; 06 4 point 7). It replaces the deferred entry of M1-28.- A follow-up is one row in
run_followups(state = queued, unique per message) andtrigger_state = receivedon its message (03run_followups; 09 6; 04 15.4 point 1). - The effective capability
followupsis the owner's permissionsupports_followups(absent means allowed) and the adapter's reportsteerorqueue(09 2.2, R-AG-01). Withsteerthe server writes the personal eventagent.followup(payloadrun_id,thread_id,message_id, never content); withqueueor none it sends nothing and the follow-up staysqueueduntil the run ends (09 7; 04 2.2 rule 4, 15.4 point 1). - A follow-up to a run in
waiting_inputis the answer to the open question: the adapter hands it over and resumes the run with the token of its delivery (resumeRun, actionrun.start) (09 7 last paragraph, 5.1; 04 15.3 point 5, 15.4 point 5; R-AG-08). - The adapter acknowledges
delivered(handed to the runtime) andabsorbed(taken up by the runtime) throughrun.updateon/followups/<message_id>/state, forward only; atdeliveredthe message getstrigger_state = followup_delivered(09 4.7, 5.3; 04 15.4 point 3; 01 6.2). - At the end of a run every follow-up in
queuedordeliveredwithoutabsorbedbecomesdeferredaftercompleted,failed, andcancelledwith any reason other thanuser_cancelled; the first becomes the triggering message of a new run and the rest its follow-ups; aftercancelledwithuser_cancelledthey becomediscarded, and the message shows "not processed, run stopped" (09 7, 8 point 5, 15; 04 15.4 point 4; 03runs.triggering_message_id; R-AG-10, D-AG-09). - Every delivery of
agent.followupre-checksagent.invokefor the follow-up's author with the rights of that moment and carries a fresh token withtrigger_kind = followupand the follow-up message asmessage_id; a denial delivers nothing and never ends the run of another person (04 15.4 point 2, 15.3 point 2; R-SY-07; 05 5 claims; 07 8.1). - The run document lists the follow-ups keyed by message id with their state (09 5.3 and 5.4; 04 11.1; openapi
Run.followups,RunFollowup). - No follow-up of another person reaches an agent on a personal subscription: I1 keeps
triggerwith the holder and I2 is part of every check of this ticket (05 5 point 4; 07 SR1; T-AI-08; OQ-01; M2 Red Team).
Scope¶
All paths under services/server/ unless noted. Where M2-05, M2-08, M2-09 or M2-10 already created a file, type or function named here under another name, extend theirs (Boundaries).
Create:
migrations/<next free number>_runs_run_followups.sql(goose, numbering rule of M1-12): tablerun_followupsexactly as 03 (columnsrun_id,message_id,statewith default'queued'andCHECK (state IN ('queued','delivered','absorbed','deferred','discarded')),created_atandupdated_atwith defaultnow(); PK(run_id, message_id); FKrun_id -> runs(id) ON DELETE CASCADE; FKmessage_id -> messages(id) ON DELETE CASCADE; unique(message_id); index(run_id, state)), privileges for the application role following the pattern of the existing migrations, a comment naming the retention classcontent, and a Down section that drops the table. Thenmake migrations-lock.internal/runs/domain/followup.go(pure,kernel-coreonly):FollowupStatewith the five values;FollowupMode(steer,queue,none);EffectiveFollowupMode(permitted *bool, reported string) FollowupMode;FollowupEventNeeded(mode FollowupMode, runState State) bool;AdvanceFollowup(from, to FollowupState) (changed bool, err error)(Design notes 2 and 5).internal/runs/domain/followup_end.go(pure):RunEnd{State State; Reason string},OpenFollowup{MessageID ids.ID; ThreadSeq int64; State FollowupState; Deleted bool},PlanRunEnd(end RunEnd, open []OpenFollowup) (RunEndPlan, error)withRunEndPlan{Deleted []ids.ID; Stopped []ids.ID; Candidates []ids.ID}(Design note 7).internal/runs/adapters/postgres/queries/run_followups.sql: insert of a follow-up, lock of a run's open follow-ups in ascendingmessage_id, states of a run (document), states of given message ids of a run, lock and update of one row, the move of rows to another run, the lookup by message id (deletion), and the raise ofruns.versionif the M2-05 or M2-08 repository has no function for it.- Tests:
internal/runs/domain/followup_test.go,followup_end_test.go,followup_fuzz_test.go; app tests next to the service; integration tests undertest/integration/followups/(componenttestsupport, 02 "Code layout"), or next to the integration tests of M2-08 and M2-09 if they chose another place.
Change:
internal/runs/app/followups.go(M2-10 created it with the portFollowupStatesand its empty default): the serviceFollowupswithAccept,OnRunEnded,ForDelivery,RefuseDelivery,OnMessageDeleted,Documentand the methods ofFollowupStates(Design notes 3 to 10). Keep M2-10's port signature.internal/runs/appacceptance (OnMessagePostedof M2-05 and M2-08): the follow-up branch (M2-05 note 6 step 3, M2-08 note 10 e), the lock of the active run, and the follow-up outcome afterErrActiveRunExists(Design note 3). Replace the pinning testsTestAcceptance_ConcurrentMessagesOneRun(M2-05) andTestAcceptance_FollowupBranchUntilM211(M2-08) by the follow-up behaviour.internal/runs/append-of-run function of M2-09 note 6f: callFollowups.OnRunEndedat its extension point (Design note 7).internal/runs/appdelivery (Delivery.Prepareof M2-08): the follow-up facts and the row state on a denial (Design note 6).internal/runs/apprun document projection of M2-05 (used bygetRun, the run instartRun,resumeRun,awaitRunInput,finishRunanswers,currentofbase_version_conflictandLiveBoxof M2-10):followupsfromrun_followups(Design note 10).internal/runs(port): the DTO of one follow-up (Followup{MessageID ids.ID; State string}) inside the run document DTO of M2-05; no new in-transaction event.internal/authz: the requirement ofrun.followup.createinstead of the deferred entry, the mapping row for the right text "write andagent.invoke", theagent.invokeguard applied to it, the deferred golden list without it, README (Design note 11).internal/agents(port, adapter, README): a read of the effective capabilityfollowupsif the base branch has none, and a test helper ininternal/agents/adapters/agentstestthat sets the reported capabilities (Design note 15).internal/messaging: only if the merged port lacks it, a read that returns for given message ids the author,thread_seq,origin, the mention ids and whether the message is deleted (Design notes 6 to 9); README if changed.internal/wire: bindFollowupsasFollowupStatesinstead of M2-10's empty default; whenmessaging.MessageDeletedexists on the base branch (M1-58), registermessaging.MessageDeleted -> runs.OnMessageDeleted(or extend the runs subscriber ofMessageDeletedif one exists); update the golden files of subscriptions and bindings.tools/lint/sql_visibility.yaml(only if it exists on the base branch, M1-70): each read query ofrun_followupsunder categoryafter_authorizewith its reason (Design note 14).docs/spec/09-agents.md(not core contract, English, like the chapter): 6 (diagram and table: the transitions of a follow-up message of Design note 13) and 7 (the API-visible rulings of Design notes 3, 4, 5, 6, 7, 8 and 9).docs/api/openapi.yaml: the description ofpatchRungets one sentence on follow-up states (forward only; a backward change gives409 invalid_state_transition); runmake api-checks(regenerate withmake api-tablesif it reports drift in 06).docs/security/threat-model.md: nameTestFollowupNeverReachesPersonalAgentForOthersin the test column of T-AI-08.- READMEs of
runs(English, like the file: follow-ups, states, end of run, the follow-on run, versions, queries and their visibility category),authz,agents;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10, rowsD-M2-11-1and following (Design note 16).
This ticket enables no operation in api/operations.yaml (run.followup.create has no route), adds no configuration key, no job, no metric and no new top-level make target.
Out of scope¶
- The trigger decision function (M2-04), the run creation
Open, the transition primitive and the acceptance of invocations (M2-05); the invocation token issuer (M2-06); agent consumers,ackand redelivery (M2-07); the delivery check itself, its audit, its counter and theagent.followuppayload builder (M2-08); start, resume, renewal, finish, cancel, revocation and the jobs (M2-09); the patch engine, the path rules,run.updatedand the coalescer (M2-10). This ticket fills the follow-up parts these tickets left open. - Disabling the open card when a follow-up answers a waiting run first (09 11.6 "Whatever arrives first counts; the card then becomes
disabled") and every other component rule: M2-15, which ownsmessage_components(plan gap reported). already_deliveredin the run context (09 4.6) and the trust marks of follow-up content: M2-13 (it readsrun_followupsthrough this ticket'sFollowupStates; plan gap reported).- Agent sessions and
context_floor_seqof a follow-on run: M2-12. The heartbeat that reportscapabilities(sendHeartbeat): M2-20. - The control command
/stopand model commands during a run (run_active): M2-16. - Follow-ups whose attachments or transcript are not final (the hold of 04 15.3 point 1,
ReleaseInvocation): M3-07 and M3-09. - Delivery of
agent.followupover an agent webhook subscription (04 15.6,webhooks.md): not owned here; this ticket only writes the log event, every transport goes throughruns.Delivery. - The data-driven scenario AG-13 with the fake agent (M2-31), the reference adapters with
busy_input_modeand stdin (M2-33, M2-35), the SDK helpers that patch follow-up states (M2-29, M2-30). - Personal data participants for
run_followups(M7-06). The live-box view in the apps (M4, M5). - Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M2-11-run-followups; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(runs): ...,feat(authz): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. Never log, print or persist an invocation token; tests compare tokens in memory. No network calls except package downloads for pinned tools and Go modules.
- No agent runtime, no model call and no third-party service in any test: the adapter side is plain Go HTTP code with the agent's API token (REST poll of M2-07, the run operations of M2-09,
patchRunof M2-10) or the gateway test clientwstestof M1-64. Time only throughkernel/clock; tests move a fake clock and call job handlers directly, they never sleep for a window. - Never lower a threshold, disable a gate or delete a test (replacing the two pinning tests named in Scope by tests of the specified behaviour is required, not a deletion).
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M2-04 to M2-10: follow its names and list each difference. Where it lacks a function this brief expects from it (for example the end-of-run extension point or the port
FollowupStates), add it in the module that owns it and report it. - M2-10 must be merged into the base branch before this ticket starts, because the acknowledgement
deliveredandabsorbedis a run patch on/followups/<message_id>/stateand the portFollowupStatesthat this ticket implements is M2-10's (M2-10 note 7). IfpatchRunis not enabled on the base branch, stop withVerdict: BLOCKED M2-10 not merged (patch path /followups/<message_id>/state and port FollowupStates). - The make target of the visible-channel lint (M1-70) belongs to a ticket outside this closure: do not call it; the lint file is updated only if it exists (Scope). The signal check of M1-68 is in the closure;
make checkruns whatever the base branch has.
Design notes¶
-
Placement. Follow-ups belong to
runs(02 4.2 and 4.3;modules.yamllistsrun_followups,run.followup.createandagent.followupthere). Pure rules live ininternal/runs/domain; the serviceFollowupsininternal/runs/appuses the unit of work of its caller (txn, never its ownDowhen called from a subscriber, the end-of-run function orPrepare). Other modules are reached only through their ports:messaging(SetTriggerState, message facts),chat(Threads.Infofor scope),agents(capability, status),authzthroughkernel/authorize. No other module changes a follow-up state (Ruling for docs/DECISIONS.md,D-M2-11-1). -
Domain rules (table tests in Tests):
EffectiveFollowupMode(permitted, reported):nonewhenpermittedis false; otherwisesteerorqueuewhenreportedis exactly that value;nonefor an absent or unknown report (fail closed, 09 1 point 4).permittednil means the key is absent inoperator_config, which allows (09 2.2).FollowupEventNeeded(mode, runState): true forsteerinqueued,runningandwaiting_input; true forqueueandnoneonly inwaiting_input(requirement 15); false for every end state.AdvanceFollowup(from, to)for adapter patches:queued -> delivered,queued -> absorbed,delivered -> absorbedchange;to == fromfordeliveredorabsorbedis no change (06 2 "No effect"); every other pair (backward, or out ofdeferredordiscarded) isErrFollowupTransition, which wrapserrs.CodeInvalidStateTransition.toother thandeliveredorabsorbednever reaches here (M2-10's value check).
-
Acceptance of a follow-up (in
OnMessagePosted, inside the unit of work ofcreateMessage): a. The active run of the target thread is read withSELECT ... FOR UPDATEbefore M2-04'sDecideis called, so the fact stays true until the commit (the thread row lock ofcreateMessagealready serializes messages of one thread, M1-55; the run row lock serializes against the end of the run). If the merged acceptance reads it without a lock, add the lock. b.DecidereturnsfollowupwithRunIDandCheck = CheckFollowUp(M2-04). Where M2-05 and M2-08 return without writing (M2-05 note 6 step 3, M2-08 note 10 e), continue here.ErrActiveRunExistsfromOpen(a concurrent acceptance created the run, M2-05 note 6 step 7) leads here as well when the active run belongs to the candidate. c. Checkauthorize(P, run.followup.create, Resource{Kind: thread, ID: thread of M, Invoke: &InvokeFacts{AgentID: run.agent_id, TriggerOrigin: human, MentionsAgent: the decision's Mentioned, ActiveRunAgentID: run.agent_id}})in the same mode and with the same audit pattern the merged acceptance uses foragent.invoke(M2-08 notes 8 and 10 d: one deny entry withstage = acceptance), with actionrun.followup.createin the entry. d. Denial: map the reason with the acceptance mapping of M2-08 note 10 d (for examplelevel_insufficientwithout mention writes nothing, with mentionno_trigger_right;billing_mode_personal_not_holderstays) and callMessages.SetTriggerState(M, failed, reason); no row, no event. e. Allow, in this order:INSERT INTO run_followups (run_id, message_id)(statequeued);UPDATE runs SET version = version + 1(note 10);Messages.SetTriggerState(M, received); read the effective mode (note 15); whenFollowupEventNeeded(mode, run.state), appendagent.followup(note 4). Nothing else happens to the run: its state,invocation_jtiandbilling_mode_expectedchange only at a delivery (M2-08). f. A replay ofcreateMessagepublishes noMessagePosted(M1-56), so it creates no second row; the unique(message_id)is the last line. Ruling for docs/DECISIONS.md (D-M2-11-2): the mode is evaluated once at acceptance under the run lock; a follow-up to awaiting_inputrun gets its event also withqueueor without the capability, because 09 7 makes it the answer and only a delivery's token can resume the run (R-AG-08); a follow-up written while the run wasqueuedorrunningwithqueueis not delivered later, even if the run then waits for input (it was not an answer). -
Event
agent.followupthrough the payload builder of M2-08 (note 14):eventlog.Eventwith typeagent.followup,schema_versionfrom the event register, actor P, scope{space_id, channel_id, thread_id}of the run's thread now (chat.Threads.Info), resource{kind: run, id: run_id}without version (04 9 point 3; actions.yamlresource_version: false),AudiencePrincipalID = run.agent_id, receive action from the catalogue (agent.events.consume), the request'scorrelation_id, payloadAgentFollowupPayload {run_id, thread_id, message_id}: no content, no prompt, no JSONnull(04 2.2 rules 1 and 4, ADR 0053). The token is never in the log row (R-SY-17); M2-08 adds it per delivery. -
Acknowledgement by the adapter (implementation of M2-10's port
FollowupStates, called inside M2-10's patch unit of work with the run row locked):States(runID, messageIDs): the stored states; ids that are not follow-ups of this run are absent, so M2-10 answerspatch_target_missing.Set(runID, messageID, to): lock the row (FOR UPDATE),AdvanceFollowup; on changeUPDATE run_followups SET state = $to, updated_at = now(); when the row leavesqueued(todelivered, or straight toabsorbed),Messages.SetTriggerState(message, followup_delivered), which writesmessage.updated(02 4.7).absorbedafterdeliveredchanges notrigger_state.ErrFollowupTransitionreaches the client as409 invalid_state_transition(06 2 "No effect"); if the merged patch use case turns every port error into400 validation_failed, add this exception and its test. The patch is all or nothing.- The
message.updatedoffollowup_deliveredis a durable event inside a patch: its count is bounded by the number of follow-ups, not by the patch rate, so R-SY-05 and M2-10's load test still hold. Ruling for docs/DECISIONS.md (D-M2-11-3): the forward-only transitions includingqueued -> absorbed, no change on the same state,409 invalid_state_transitionfor every other pair, andfollowup_deliveredwhen a follow-up first leavesqueued.
-
Delivery of
agent.followup(the rowagent.followupof M2-08's matrix; M2-08 note 3 f leaves "the state of therun_followupsrow" to this ticket):ForDelivery(runID, messageID)gives the facts for M2-08's resolver: author (the trigger,subof the token), origin, mention of the run's agent, and whether the follow-up is deliverable. Deliverable means the row exists for this run inqueuedordeliveredand the message is not deleted; otherwisePreparereturnsSend = falsewithout check, token or audit (the event counts as checked, M2-07DeliveryHook), like a terminal run in M2-08's matrix. Ruling for docs/DECISIONS.md (D-M2-11-4).- Denial (M2-08 note 3 f: the message gets
failedwith the mapped reason of M2-08 note 6, audit, counter, run unchanged):RefuseDelivery(runID, messageID)sets the row todiscarded,updated_at = now(), and raisesruns.versionby one (note 10), in the same unit of work. Reason: the follow-up will never be processed, andrun_followupshas nofailedstate;trigger_state = failedshows the reason. Ruling for docs/DECISIONS.md (part ofD-M2-11-4). - Allow: nothing changes here; M2-08 issues the token with
trigger_kind = followup,message_id= the follow-up andsub= its author, and records thejtionly while the run waits inwaiting_input(M2-08 note 2, 09 5.1). A delivery ofagent.followupnever setstrigger_state = queued: 09 6 setsqueuedonly foragent.invocation(M2-08 note 12). If the merged resolver takessubfromruns.triggered_byor setsqueuedfor follow-ups, correct it and list it.
-
End of a run (
OnRunEnded, called at the extension point of M2-09's end-of-run function, in the same unit of work, after the transition withversion + 1andrun.state_changed; every path tocompleted,failedorcancelledpasses there:finishRun,cancelRunon aqueuedrun, the jobruns.cancel_timeout,runs.stall_checkwithinterruptedandstale,OnAgentDisabled, M2-08's denial of a queued invocation and its expiryruns.queued_expiry; route any path that bypasses it through it): a. Lock the open follow-ups:SELECT ... FROM run_followups WHERE run_id = $1 AND state IN ('queued','delivered') ORDER BY message_id FOR UPDATE(03 6.3). None: return. b. Load their facts throughmessaging(author,thread_seq, deleted).PlanRunEnd: a follow-up whose message is deleted goes toDeleted. Endcancelledwith reasonuser_cancelled: every other open follow-up goes toStopped. Every other end: all others go toCandidatesordered by ascendingthread_seq(04 4; Ruling for docs/DECISIONS.md,D-M2-11-5).absorbed,deferredanddiscardedrows are never touched. c.Deleted:state = discarded, notrigger_statechange (note 9).Stopped:state = discarded;Messages.SetTriggerState(message, cancelled, user_cancelled). Reason: the message must show "not processed, run stopped" (09 7), 09 6 definescancelledas "Run stopped" with the reason intrigger_state_reason, and 03 listsuser_cancelledas such a reason. Ruling for docs/DECISIONS.md (part ofD-M2-11-5). d. Defer:state = deferredfor all candidates; then the follow-on run of note 8. e. No extraruns.versionstep and norun.updatedfor the ended run: the follow-up changes are part of the stored change of the final transition, and clients load the ended run per REST (04 11.3 point 6). This is literal 09 7:cancelledwithcancel_timeout,access_revoked,agent_disabled,channel_read_onlyoruser_disableddefers;failedwithinterruptedorstaledefers (09 15). Deferred follow-ups are new, unprocessed requests (09 7 "Reason"), so a follow-on run is no retry of the failed run's own trigger (R-AG-17 holds). The surprise after a stop that ends withcancel_timeoutis a spec gap (reported), not changed here. -
Follow-on run (04 15.4 point 4; 03
runs.triggering_message_id), in the same unit of work, for the candidates in order: a. For candidate M with author P:authorizewith the re-check principal of M2-08 note 4 for P (P is not the actor of this unit), actionagent.invoke,Resource{Kind: thread, ID: run.thread_id, Invoke: &InvokeFacts{AgentID: run.agent_id, TriggerOrigin: origin of M, MentionsAgent: M mentions the agent, ActiveRunAgentID: zero}}(the ended run is no longer active), in the mode and with the audit pattern of M2-08's delivery check (note 8, attributestage = run_end). Reason: the follow-on run is an acceptance of M as a trigger (09 3.4 point 7); without the check a run would be created for a disabled agent or in a read-only channel. b. Denial:state = discarded;Messages.SetTriggerState(M, failed, reason)with the reason mapping of M2-08 note 6; continue with the next candidate. c. Allow: M is the trigger. Create the run through the invoke step of the acceptance (M2-05OpenwithTriggerKind = message,TriggeringMessageID = M,TriggeredBy = P,AgentID = run.agent_id,ThreadID = run.thread_id; the anchor;SetTriggerState(M, received); jobruns.queued_expiry;agent.invocationlast, M2-08 note 10 f). M's row staysdeferredon the ended run. d. The remaining candidates after M become follow-ups of the new run:UPDATE run_followups SET run_id = <new>, state = 'queued', updated_at = now()(one row per message; 03 unique(message_id)allows no second row),Messages.SetTriggerState(message, received)(a no-op when alreadyreceived, M2-05 ruling 17); whenFollowupEventNeeded(mode, queued)(mode read now), appendagent.followupfor each, inthread_seqorder, after the new run'sagent.invocation. Ruling for docs/DECISIONS.md (D-M2-11-6): the first staysdeferredon the ended run, the rest move. e. No candidate allowed: no run; all candidates arediscardedwithfailedon their messages. f. The new run is an ordinary run: its own end runs this note again, so the process ends after at most one run per open follow-up.ErrActiveRunExistscannot occur, because the ended run is no longer active and the thread's acceptances wait at the run row lock (note 12); treat it as an internal error. -
Deleted follow-up message (
OnMessageDeleted(messaging.MessageDeleted), in the deleting unit of work; M1-58 is not in this ticket's closure: ifMessageDeletedis not on the base branch, leave this subscriber out and name it as a follow-up in the report; notes 6 and 7 treat a deleted message as not deliverable and discard it at the end in any case): if the message has a row inqueuedordelivered, lock the run row, set the rowdiscarded, raiseruns.version(note 10); notrigger_statechange (the tombstone keeps its representation, 03messages). Reason: deleted content must never become the trigger of a new run, and redaction (03 6.5) would leave the adapter an empty text. Ruling for docs/DECISIONS.md (D-M2-11-7; spec gap reported). A deleted triggering message of an active run is not handled here. -
Run document and versions. The projection of M2-05 fills
followupswith every row of the run ({message_id, state}keyed by message id; rows moved to another run belong to that run).runs.version"rises with every stored change" (03) and the document contains the follow-ups (09 5.3, 04 11.1), so every server-side change of a follow-up of an active run raisesruns.versionby one: acceptance (note 3 e), refused delivery (note 6), deletion (note 9). The adapter's next patch then meets409 base_version_conflictand rebases oncurrent(04 11.2; the SDK handles it, M2-29). These server-side changes publish norun.updated: they happen inside the unit of work of another use case, andrun.updatedmay only leave after a commit (M2-10 note 1h). Focused clients see the follow-up at once throughmessage.createdandmessage.updated(trigger_state), and the box catches up with the next frame of the run, which M2-10's coalescer sends as a snapshot after a version step without patch (M2-10 note 9), or withgetRun. Ruling for docs/DECISIONS.md (D-M2-11-8). The adapter's own acknowledgement goes out asrun.updatedthrough M2-10. -
Rights (05 4.10, 07 2.3, M1-28):
- Requirement of
run.followup.create:All(Level(write), Level(trigger))plus theagent.invokeguard of M1-28 with the descriptor'sInvoke(required, else internal error); mapping row "write andagent.invoke" in the requirement source table.Level(trigger)is what "agent.invoke" requires (05 4.10 rowagent.invoke);Level(write)is kept so the requirement reads like the catalogue. - Guard step 7 passes because
ActiveRunAgentIDequals the agent; another agent's active run givesrun_active_other_agent(05 5 point 8). I2 (billing_mode_personal_not_holder), SR2 (tool_class_owner_only), SR4 (origin_not_human),channel_read_onlyand the project checks apply unchanged. - Remove
run.followup.createfrom the deferred entries and from the golden list ofTestDeferredActionsGolden. The action has no route (06 4 point 7), so AZ-P1 is unaffected; no rights version, noaccess.changed(05: Permissionsno). Ruling for docs/DECISIONS.md (D-M2-11-9): the expression and the mapping row.
- Requirement of
-
Locks and concurrency (03 6.3, 02 8.2): acceptance holds the thread row (M1-55), then the run row (note 3 a), then inserts the follow-up, then updates
messages. The end of a run holds the run row (M2-05Transition), then the follow-up rows in ascendingmessage_id, thenmessages, and for a follow-on run the thread row through the anchor. An acceptance and the end of the same run can therefore deadlock; PostgreSQL detects it and the unit of work retries the whole unit up todatabase.tx_retry_max(02 8.2). Every interleaving ends in one of two consistent outcomes: the follow-up exists before the end (it is settled by note 7) or the message comes after the end (it triggers a new run or becomes a follow-up of the follow-on run). Never aqueuedfollow-up on an ended run.Set(note 5) runs under M2-10's run row lock;Prepare(note 6) under M2-08's. -
Trigger state of a follow-up message (09 6; every change through
Messages.SetTriggerState):Moment trigger_statetrigger_state_reasonaccepted as follow-up receivedrefused at acceptance failedacceptance mapping (M2-08 note 10 d) first leaves queuedby the adapter's patchfollowup_deliveredrefused at delivery faileddelivery mapping (M2-08 note 6) discarded at the end after a stop cancelleduser_cancelledtrigger of the follow-on run received, then as any run trigger (queued,working,done, ...)moved to the follow-on run receivedrefused as trigger of the follow-on run faileddelivery mapping (M2-08 note 6) message deleted unchanged Update the diagram and table of 09 6 with the edges
followup_delivered -> received,followup_delivered -> failed,received -> cancelledandfollowup_delivered -> cancelledfor follow-ups. -
Queries, audit, telemetry. Reads of
run_followupshave no visible-channel predicate by design (03 gives the table nochannel_id): each reads the rows of one run (or one message) whose run was authorized in the same use case or is being changed by it (07 7.4 rule 1, exception "loading a resource that has already been checked"; the rule M2-05 note 10 wrote into 07 7.4). Name them in the runs README and, if the lint file exists, underafter_authorize(Scope). Audit: only denials (denials), written as described in notes 3 c, 6 and 8; allowed follow-ups and state changes write none. No new metric (signals.md has none for follow-ups); M2-08 counts delivery denials. No log line, span, audit detail or problem body contains message content or a token. -
Effective capability source (09 2.2): the owner's
operator_config.supports_followups(M1-51) and the adapter's reportedcapabilities.followupsfrom the heartbeat (reported_details, schemaagent_heartbeat.v1, 09 4.8; written bysendHeartbeatof M2-20, which may hold newer state in memory between persists, 03agents). Read it through theagentsport: if M2-05, M2-08 or M2-20 already added a capability read (M2-04 listsVoiceInputas such a fact), addfollowupsto it; otherwise addAgents.EffectiveCapabilities(ctx, agentID ids.ID) (agents.Capabilities, error)withCapabilities{Followups string}that returns the reported value only when permitted, and document that M2-20 keeps it current. Until M2-20 is merged no adapter reports anything, so the mode isnone, the safe default. Tests set the report throughagentstest(orsendHeartbeatwhen present). -
Rulings for
docs/DECISIONS.mdsection 10, one row each with reason and place:D-M2-11-1placement (note 1);D-M2-11-2mode at acceptance and thewaiting_inputexception (note 3);D-M2-11-3forward-only transitions and their error (note 5);D-M2-11-4deliverable rows and the refused follow-up (note 6);D-M2-11-5thread order and thetrigger_stateof discarded follow-ups (note 7);D-M2-11-6the check of the follow-on run, its stagerun_end, and the move of the rest (note 8);D-M2-11-7deleted follow-ups (note 9);D-M2-11-8version steps withoutrun.updated(note 10);D-M2-11-9the requirement ofrun.followup.create(note 11). Rulings 2 to 7 also go into 09 6 and 7 (Scope).
Tests (write them first)¶
Test world: 07 11.1 (spaces Support with channels Allgemein and Eskalation, Marketing, Research with channel Notizen; agents Lotse operating Support (organization), Texter operating Marketing, Assistent operating Research in mode personal with the Owner as holder; G-1 Alex trigger on Support; group Leser with Sam, G-4 read on Support/Allgemein; the Owner), extended for this ticket by: Sam with trigger on Support/Allgemein (a second person who writes follow-ups), Robin with write on Support/Eskalation, Texter with write on Support/Eskalation, allow_mentioned_agents = true and thread_require_mention = true on Support/Eskalation (putChannelBehavior), Sam with write on Research/Notizen. Lotse's effective followups is set per test (steer, queue, or none by supports_followups = false). A run is triggered by Alex in a thread of Support/Allgemein and delivered through M2-08 and pollAgentEvents; the adapter side is plain HTTP with Lotse's token (startRun, resumeRun, awaitRunInput, finishRun, patchRun). Tokens are verified with the reference verifier of M2-06. Payloads and responses validate against their openapi schemas with the validator the M1 and M2 event tests use; none contains JSON null.
Unit, internal/runs/domain:
TestEffectiveFollowupMode: permission absent, true and false times reportsteer,queue, absent and an unknown value;steerorqueueonly when permitted and reported exactly; otherwisenone.TestFollowupEventNeeded: every mode timesqueued,running,waiting_inputand the three end states; true forsteerin the active states and for every mode inwaiting_inputonly.TestAdvanceFollowup: all pairs of the five states asfromwithdeliveredandabsorbedasto: change exactly forqueued -> delivered,queued -> absorbed,delivered -> absorbed; no change fordelivered -> deliveredandabsorbed -> absorbed;ErrFollowupTransitionwith codeinvalid_state_transitionfor the rest (absorbed -> delivered, out ofdeferredanddiscarded).TestPlanRunEnd: endscompleted,failed(stale,interrupted,runtime_error,agent_unavailable),cancelledwithuser_cancelled,cancel_timeout,access_revoked,agent_disabled,channel_read_only,user_disabled; open sets withqueuedanddeliveredrows in shuffled order and one deleted message: the deleted one is always inDeleted;user_cancelledputs all others intoStopped; every other end puts all others intoCandidatesby ascendingthread_seq; an empty set gives an empty plan; a non-end state is an error.FuzzPlanRunEnd(seed corpus from the table): every open follow-up appears in exactly one ofDeleted,StoppedandCandidates;Candidatesis sorted bythread_seq;Stoppedis non-empty only foruser_cancelledandCandidatesonly for every other end; the input is unchanged.
Unit, internal/authz:
TestRunFollowupCreateRequirement(fake sources of M1-28): Alex withtrigger, Lotse's own active run:allow; Robin withwriteonly:forbiddenlevel_insufficient; Sam with only G-4:forbiddenlevel_insufficient; a thread of Marketing for Alex:not_found; active run of Texter:forbiddenrun_active_other_agent; Assistentpersonaland a fake snapshot that gives Samtriggeron Research/Notizen (I1 bypassed in the fake):forbiddenbilling_mode_personal_not_holder; disabled Lotse:forbiddenagent_not_reachable; read-only channel:forbiddenchannel_read_only;origin = agent:forbiddenorigin_not_human; missingInvoke: internal error.TestDeferredActionsGolden: the golden list no longer containsrun.followup.create.
Unit, internal/runs/app with fakes:
TestAccept_FollowupBranch: decisionfollowupcallsauthorizewith actionrun.followup.createand the descriptor of note 3 c; allow inserts the row, raises the run version, setsreceivedand appendsagent.followupexactly whenFollowupEventNeeded; denial setsfailedwith the mapped reason and writes nothing else.TestOnRunEnded_ChecksAuthorOfCandidate: the fake authorizer records the re-check principal of the first candidate's author (notruns.triggered_by) andActiveRunAgentIDzero; a denial of the first makes the second the trigger.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, fake clock):
TestMigration_RunFollowups: columns, defaults, CHECK (an unknown state fails), PK, unique(message_id)(a second row for the same message under another run fails), both FKs cascade (deleting the run removes its rows), index(run_id, state)exists;migrations.sha256has the line.TestAG13_Steer: withsteer, Sam writes in the thread of Alex'srunningrun: one rowqueued,trigger_state = received, run version raised by one, exactly oneagent.followupinevent_logwith audience Lotse, resource{kind: run, id}without version, payload{run_id, thread_id, message_id}and no content; the poll delivers it with a token whose claims aretrigger_kind = followup,subSam,message_idthe follow-up,run_id, andinvocation_jtiof the run is unchanged;patchRunreplace /followups/<id>/statedelivered(after rebasing on the conflict) gives the rowdeliveredand onemessage.updatedwithfollowup_delivered(followup_deliveredin the trigger state);absorbedgives the rowabsorbedand no furthermessage.updated;finishRun(completed)leaves itabsorbed, the triggering messagedone, and creates no new run.TestAG13_Queue: withqueue, two follow-ups by Sam and Alex: two rowsqueued, noagent.followup;finishRun(completed): bothdeferredfirst, then the earlier one bythread_seqistriggering_message_idof a newqueuedrun for Lotse withtriggered_byits author, an anchor,trigger_state = received,agent.invocationand aruns.queued_expiryjob; its row staysdeferredon the ended run; the later one is now a row of the new run inqueuedwithtrigger_state = received;getRunof the ended run lists only the first asdeferred,getRunof the new run lists the second asqueued.TestAG13_QueueWithoutCapability:supports_followups = falsewith asteerreport behaves asqueue.TestAG13_DeliveredNotAbsorbedIsDeferred: withsteer, a follow-up acknowledgeddeliveredand a run endingfailedwithruntime_error: the follow-up becomesdeferredand triggers the new run; a second follow-up that wasabsorbedstaysabsorbedand moves nowhere.TestAG13_FollowOnRunMovesRestWithSteer: withsteer, three follow-ups, run endscompletedbefore the adapter acknowledged any: new run with the first as trigger; the other two move asqueuedwithtrigger_state = received(also the one that hadfollowup_delivered), and two newagent.followupevents for the new run follow itsagent.invocationinthread_seqorder.TestAG13_StopDiscardsOpenFollowups(AG-13 "stop with open follow-ups"): Alex callscancelRunon arunningrun with two open follow-ups; the adapter answersfinishRun(cancelled, user_cancelled): both rowsdiscarded, both messagestrigger_state = cancelledwithuser_cancelled, no new run, noagent.invocation; variant:cancelRunon aqueuedrun without delivery ends it at once withuser_cancelledand discards its follow-ups the same way.TestAG13_CancelForOtherReasonDefers: withqueue, Alex losestriggeron Support (G-1 revoked) while Lotse runs her run and Sam has aqueuedfollow-up; Lotse answersfinishRun(cancelled, access_revoked): Sam's follow-up becomes the trigger of a new run withtriggered_bySam; variant with the jobruns.cancel_timeoutafteragent.cancel_graceon the fake clock: the same (literal 09 7, spec gap in the report).TestAG13_AnswerToWaitingInput(AG-13 "answer towaiting_input"), once withsteerand once withqueue: the adapter starts with token S and callsawaitRunInput; Alex writes a follow-up:agent.followupis written in both modes; its delivery records the token'sjtiinruns.invocation_jti;resumeRunwith that token gives200running;resumeRunwith S gives403 invocation_token_invalidwithreason: superseded; withqueuea follow-up written while the run was stillrunninggets no event and isdeferredat the end.TestFollowupStatesForwardOnly:absorbed -> deliveredgives409 invalid_state_transitionand changes nothing (row, run version, events);deliveredtwice gives no secondmessage.updated; a message id that is no follow-up of this run gives400 validation_failedwithpatch_target_missing(M2-10); the same over the gateway oprun.updateanswerserrorwith the same code and keeps the connection.TestFollowupDeliveryRechecksAuthor: withsteer, Sam's follow-up is accepted, then Sam'striggergrant is revoked before the adapter polls: the poll does not deliver it, the row isdiscarded, the messagefailedwithaccess_revoked(M2-08 note 6), one audit deny entry, the run staysrunningand its version rose by one; Alex's run is not cancelled.TestFollowupNotDeliverableAfterEnd: withsteer, the run ends before the adapter polls theagent.followup: the poll skips it without token and without audit, and the later cumulativeackcovers it.TestMessageWithoutTriggerIsNoFollowup: in a thread of Support/Eskalation (thread_require_mention = true) with Lotse's active run, Alex writes without mention: no row,trigger_stateNULL, no event; Robin (notrigger) mentions Lotse:trigger_state = failedwithno_trigger_right, no row; Alex mentions Texter (guests allowed there, Texter haswrite):failedwithrun_active_other_agent, no row.TestFollowupNeverReachesPersonalAgentForOthers(M2 Red Team, T-AI-08, OQ-01): the Owner's run of Assistent isrunningin a thread of Research/Notizen withsteer; Sam (write) writes with a mention of Assistent:failedwithno_trigger_right, no row, noagent.followupinevent_log; Sam without mention: notrigger_state, no row; the Owner's own follow-up becomes a row and an event; no event in the log ever has audience Assistent and a follow-up message by Sam.TestFollowupDeletedIsDiscarded(with M1-58 merged): withqueue, Sam deletes hisqueuedfollow-up: rowdiscarded, run version raised, the tombstone'strigger_stateunchanged; at the end of the run no new run is created from it.TestRunEndSettlesOnEveryEndPath(table, each with one openqueuefollow-up):finishRuncompleted,failedandcancelled(user_cancelled);cancelRunon aqueuedrun; jobruns.cancel_timeout; jobruns.stall_checkwithinterruptedand withstale; agent disabled (OnAgentDisabled); M2-08's denial of a queued invocation;runs.queued_expiry: each ends with the follow-updiscardedordeferredexactly asPlanRunEndsays, and a follow-on run where deferred and allowed.TestFollowOnRunRefusedWhenAgentDisabled: Lotse disabled while two follow-ups are open: the run endscancelledwithagent_disabled, both follow-ups enddiscardedwithfailedagent_disabled, no new run.TestRepeatedFinishCreatesOneFollowOnRun:finishRun(completed)twice: the second answers200without change and no second follow-on run exists.TestFollowupRacesRunEnd: in a loop, Sam'screateMessageand the adapter'sfinishRun(completed)run concurrently: afterwards no row inqueuedordeliveredbelongs to an ended run, at most one active run exists in the thread, Sam's message is either a follow-up (of the ended run, settled, or of the follow-on run) or the trigger of a run, and both requests succeeded (deadlocks retried).TestConcurrentMessagesOneRunOneFollowup(replaces M2-05's pinning test): Alex and the Owner write in the same thread without a run at the same time: one run, the other message is its follow-up withtrigger_state = received.TestCreateMessageReplayNoSecondFollowup: the sameclient_msg_idagain returns the original and leaves one row.TestRunDocumentListsFollowups:getRunlistsfollowupskeyed by message id with the states of note 13, validates againstRunandRunFollowup,ETagequals the version; the snapshot sent onfocus(M2-10LiveBox) carries the samefollowups.TestFocusedClientSeesFollowupAfterVersionStep: Alex focuses the thread (wstest); a follow-up raises the version withoutrun.updated; the adapter's next patch reaches Alex as arun.updatedsnapshot that contains the follow-up.
Contract:
TestFollowupSchemas:agent.followupvalidates againstAgentFollowupEvent,message.updatedagainstMessageUpdatedEvent,getRunagainstRun; the delivered envelope carriesinvocation_tokenonly as a delivery field and theevent_logrow has none.TestTxnRegistrationsGolden(M1-13) shows the subscription ofmessaging.MessageDeletedtorunsafter the existing ones (with M1-58 merged).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/... ./internal/authz/... ./internal/agents/... ./internal/messaging/... ./internal/gateway/... ./internal/kernel/... ./internal/wire/... ./migrations/... ./test/integration/...make migrations-lock,make migrations-lint,make codegen-check,make arch,make register-check,make api-checks,make lint,make docsmake check
Acceptance criteria¶
- Migration
run_followupsmatches 03 (columns, CHECK, PK, unique(message_id), cascading FKs, index) and has its line inmigrations.sha256. - AG-13 with
steer: a follow-up during a run is aqueuedrow withreceived,agent.followupis written and delivered with a fresh token (trigger_kind = followup,subthe author), the adapter'sdeliveredandabsorbedchange the row forward only. -
followup_deliveredin the trigger state: the patch todeliveredsetstrigger_state = followup_deliveredwith onemessage.updated. - AG-13 with
queue(and without the capability): nothing is sent; at the end of the run the open follow-ups becomedeferred, the first in thread order triggers a new run, the rest are its follow-ups. - AG-13 stop with open follow-ups:
cancelledwithuser_cancelledmakes themdiscardedwithcancelled/user_cancelledon the messages and starts nothing. - AG-13 answer to
waiting_input: a follow-up to a waiting run is delivered in every mode, its token resumes the run, the consumed start token does not. - A refused follow-up (at acceptance, at delivery, or as trigger of the follow-on run) never ends the run of another person and is never processed; no follow-up of another person reaches a personal agent.
- Every path that ends a run settles its follow-ups exactly once; concurrent follow-ups and run ends leave no open follow-up on an ended run.
-
run.followup.createis a real requirement and no longer deferred; the run document lists the follow-ups; 09 6 and 7, the runs, authz and agents READMEs, the CHANGELOG andD-M2-11-1toD-M2-11-9are written. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
runs,authzandagents(andmessagingif changed) updated (follow-up states and transitions, end of run and follow-on run, version steps, the capability read, the requirement ofrun.followup.create, the query visibility of note 14). - Rulings of note 16 recorded in
docs/DECISIONS.md(one row each with reason); rulings 2 to 7 also indocs/spec/09-agents.md6 and 7. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.