Files
qwen-code/docs/design/2026-10-07-managed-automation-runtime.md
T
Shaojin Wen c3968ace2f feat(managed-agent): H6b/H6c automation runtime for persistent definitions (#13598)
* feat(managed-agent): H6b/H6c automation runtime for persistent definitions

The runtime part of slice H6 of #12827 (stage H of #12380), after the H6a
record contract (#13536): definitions, the single-claim scanner, the
occurrence ledger, overlap and catch-up, and persistent execution.

- Definitions are created, revised, read and retired under the
  Workspace-bound Session their creator owns. The control plane relays
  each mutation to the Session's Hosted Harness, whose authority publishes
  the prompt as a Session resource and commits the definition revision;
  the control plane replays by Idempotency-Key and answers 202 with the
  committed resource. The two public automation routes move from planned
  to partial and gain the mutation routes and the detail read.
- The scanner derives each armed definition's due slots on the host tz
  database (a shared slots fixture pins the cron semantics, DST gap and
  fold rules in TypeScript and Java), claims the definition under a lease
  with a fencing token, records every occurrence decision (fired, skipped,
  missed, or unknown after bounded retries) in the V53 ledger, and fires
  one idempotent Harness operation whose claim row was durable first; a
  manual run is the same operation keyed by its Idempotency-Key. A
  scanner that lost its lease can move neither the watermark nor the
  ledger; a manual run holds the same lease.
- Firing commits the run's claim revision and then, in one journal
  transaction, its dispatch revision with the run's input and wake; the
  embedded wake scheduler runs the input as a text turn; the journal's turn
  settle settles the run on the settle path and again on open, and the close
  path ends a never-run input as cancelled with a proven execution.
- Run identity is derived from the definition and the occurrence in both
  languages; the authority and the Java store refuse an underived id or a
  run that does not bind to its live definition at the current revision,
  so two claims of one occurrence meet one committed run.
- Both domains are enabled for submission for the persistent target mode
  only (MANAGED_SESSION_ENABLED_SCHEDULE_SESSION_MODES); per_run and the
  delivery policy stay refused until H4b and H5b/H5c land, and the scanner
  and mutation routes stay behind qwen.managed-agent.automation.enabled.
- Closes the H6a verification follow-ups: promptRef is Session-scoped and
  closed by both stores, the shared fixture gaps are pinned, and the Java
  text validator checks its clauses in the shared order.

Design: docs/design/2026-10-07-managed-automation-runtime.md (bilingual);
the H6 design's status and slice table are updated in both languages.

* fix(managed-agent): close the three runtime findings on H6b/H6c

R2-1 (P1): a mutation relay carried a fresh operationId per attempt and
the funnel deduped on content alone, so a request whose answer was lost
between the Harness commit and the control-plane command row could be
retried into committing its content over a newer revision (create rev 1
→ update rev 2 → retry commits rev 3, replayed=false, in both
languages). The relay now derives the operationId from (tenant,
Idempotency-Key), the funnel defines/retires answer the operation's
originally committed revision from the journal (rebuilt across reopens)
before touching the chain, and the control-plane mirror never regresses
past the revision it tracks while the remembered result is the
operation's own.

R2-2 (P1): runAutomationOperation skipped the Workspace admission every
other new-work verb runs, so a cached attachment kept firing and
mutating after the creator's grant was revoked. It now runs
requireReadyForNewWork before every relay.

R2-3 (P2): a prompt within the raw 64 KiB bound fails its fire when the
wrapped, JSON-escaped input envelope exceeds the 65,536-byte inline
resource bound, stranding an admitted/intent run. Admission now computes
the exact worst-case wrapped input of the merged definition (largest
route-admitted identities, real escaping) and refuses the definition.

One residual is deliberately deferred: a mutation relay that matched
the committed content appends no revision, so it leaves no operation
fact; its lost-answer race with a later commit needs a journal-level
no-event operation record and is out of this fix's scope.

Verified: packages/core managed-runtime 2748/2748; cli funnel 13/13,
hosted-harness-session 238/238, hosted-monitor-wake 18/18; managed-agent-server
1304 tests, 0 failures (109 classes); npm run typecheck/lint, Prettier,
checkstyle and spotbugs green.

* fix(managed-agent): persist no-op mutation honors and bind replay to target and content

R3-1 (P1): a mutation honored without a revision (content identical to
the committed definition) left no operation fact, so its lost-answer
retry against a moved chain re-applied the old content as a new
revision. The honor is now committed as an `operation.replayed` journal
event — the 18th member of the closed kind union, synced across
TypeScript, Java and the store-v1 and event-envelope-v1 contracts —
naming the honored revision's recordRef under the same derived operation
identity, so the retry answers the honored result across restarts.

R3-2 (P2): the operation replay answered by operationId alone, so a
retire re-driven under one id against another target returned the first
definition's cancel (and the control plane even retired the second
one's mirror), and a define under one id with another body got the
original result where a 409 was owed. Every replay now verifies the
record's target, and for defines the merged requested content digest;
mismatches throw AutomationOperationConflictError -> 409
automation_operation_conflict.

Verified: cli funnel 15/15 (two new regressions incl. reopen survival
and both conflict classes), full hosted-harness-session 238/238, core
managed-runtime + contract replays green (three timing flakes re-passed
cold), Java managed-agent-server 1306 tests 0 failures (AutomationScannerTest
23/23; both new service scenarios pinned), typecheck/eslint/checkstyle/
spotbugs green.

* fix(managed-agent): close the review wave, the real-stack findings, and their regression gaps

The second-stage review (33 threads) and the real-stack verification
(F1–F5) land in one round, with regression tests in both languages for
every behavioral change:

Real-stack findings (candidate patch adopted from the real-stack rig):

- F1: the next fire stays suppressed as skipped(overlap) while the target
  Session holds an open public Turn — the wake pump would otherwise start
  a queued automation input ahead of the dispatcher's retried Turn and a
  recurring definition could starve it (128 s / 190 s measured).
- F2: a Session attached before (`harnessBootId` set) and absent from the
  connector cache is re-attached through the same takeover load a Turn
  takes, so a control-plane redeploy never strands automated Sessions on
  hosted_session_already_attached.
- F3: a run whose turn the Harness stopped inside settles once as failed
  with execution outcome_unknown — the H0b pairing rule now admits
  exactly that pair (TS, Java, schema, and the flipped
  failed-with-unknown-outcome fixture) — instead of running forever and
  starving its definition.
- F4: the automation scanner runs on a dedicated
  managedAutomationScheduler, never the one-thread default pool that also
  carries the materializer, retries and recovery ticks (5 ms / 5 ms
  measured).
- F5 was already closed by the N4 round (operation replay markers +
  target/content binding); the client's three wire guards
  (202-only, settled-only, operationId echo) are pinned at the
  qwencode SDK level now.

Review findings:

- Behavior: definitive 4xx route refusals settle the occurrence as
  skipped with the refusal as reason instead of 64 lost-answer retries
  into `unknown` (manual runs answer 409 with the reason first, 202
  replay after); the update's idempotency digest binds session_id so a
  same-key different-session request conflicts instead of replaying;
  CronSlots.compile accepts ASCII digits only (ARABIC-INDIC no longer
  parses to minute 3); the close path skips its whole-transcript
  projection entirely when no notification of the named sources awaits;
  the /automations/operations error classification is typed
  (AutomationOperationConflict, a new ManagedSessionWritesStoppedError
  -> 503, a new ManagedSessionModeGateError -> 409 mode_disabled,
  ManagedSessionConflictError -> 409, plain record errors -> 400),
  deleting five hand-listed message substrings.
- Contracts and specs: store and event-envelope fixtures gain the 18th
  event kind `operation.replayed`; the slots fixture gains Pacific/Apia
  with whole-day date-line jump witnesses; OpenAPI gains the create
  request's required fields, 503 declarations on all automation
  mutations, automation_busy and invalid_automation code mentions,
  UTF-8-byte units for goal/prompt, the TaskState $ref on run.state,
  occurrence_key cursor wording, and bounded catch_up invariants.
- Docs: the H6 slice table no longer claims the domains stay disabled;
  the zh-CN H6 doc recovers its later-slices paragraph; Files-affected
  lists every touched file; the mode-gate comment and PR deferral stop
  pretending the gate checks only the first revision; the backoff cap
  reads 256 s (not 5 min); the slot-CPU wording is result-bounded rather
  than enumeration-bounded; README says the same.
- Test scaffolding: the H2-backed scanner suite deletes rows between
  methods (tick iterates all schedule tables), the Harness fake answers
  an already-committed run before its refusals (production order),
  fixtures type their ChatRecord payloads, the committed input decodes
  through its own decoder, a multi-definition session distinguishes the
  record revision from the domain counter, a workspace-profile load now
  exercises the restore verifier's automation domains, and the route
  settle wiring gains open-path recovery, close-path model-free settle
  and runner-catch coverage.

Verification: packages/core managed-runtime full suite green
(hook-scale timeouts recur only under parallel load and pass cold);
cli hosted-automation 17/17, hosted-harness-session 242/242,
hosted-monitor-wake 19/19; managed-agent-server module full suite
green (AutomationScannerTest 31, QwenHostedHarnessConnectorTest 40,
Qwencode HostedHarnessClientTest 37); npm run typecheck/lint,
Prettier, checkstyle and spotbugs all green.

* fix(managed-agent): enforce the advertised definition goal bound

maxGoalBytes was exported as one of this module's limits but had no
read site: the goal bound arrived only incidentally from the record
contract's generic text bound. assertAutomationDefinition now enforces
its own constant directly, with a refusal named for it (the parse probe
behind it keeps the same invariant if the constants ever diverge).

* docs(managed-agent): sync the EN runtime design's backoff clamp with the code and the ZH twin

* test(managed-agent): admit V53 in the W1c migration upgrade pin

* fix(managed-agent): keep store faults retryable and bind idempotency across operation kinds

Two findings from the fourth review wave on PR #13598:

A store HTTP or transport fault surfaced to the route through the
ManagedSessionRecordError superclass, so a fire_run whose prompt read hit
a transient 503 was answered 400 — and the scanner's definitive-4xx
branch settled the occurrence skipped forever, while redrive only visits
firing rows. assertReplayContent's validation catch likewise wrapped
promptOf's read into a conflict, so an honest retry of the identical
request became a 409. The route now classifies both store fault classes
as a retryable 503 and the content check reads the prompt outside the
validation try; only the request's own shape is still a 400/409.

The mutation replay looked up history by (operation, operationId), so a
cross-kind retry under one Idempotency-Key — a retire(K) after a
committed create(K), when the control plane lost its command row before
remember() — slipped past and really cancelled the definition. The
funnel now consults the journal for any committed operation under the
derived identity and refuses another kind with
automation_operation_conflict, matching the public one-key-one-operation
constraint; the journal rebuild makes the check hold across a Harness
restart, and the fake mirrors the rule at control-plane level.

Regression tests in both languages: funnel cross-kind refusal (same
session and across a reopen), a store fault passing through the replay
content check as itself, a route-level fire answered 503 whose claim
still dispatches after the store recovers, a scanner fire answered 503
staying firing for the redrive, and a service-level cross-kind retry
conflicting with the mirror left live.

* fix(managed-agent): refuse a cross-Session idempotency-key reuse before relaying

The operation identity derived from (tenant, Idempotency-Key) is tenant-scoped, while the journal proving it is Session-scoped: create(K, S1) committed and mirrored, the command row lost before remember(), a create(K, S2) would relay to S2's Harness and mint a second definition under the same scheduleId, which the mirror then shadows behind the first Session's resource. create() now checks the durable mirror for the derived scheduleId first and, when it landed on another Session, answers automation_operation_conflict instead of relaying; identical retries on the same Session still pass through to the funnel's replay. Regression: the lost-row cross-session create conflicts with nothing relayed and the mirror still naming the first Session.

* fix(managed-agent): claim automation operation identities before the side effect

The mirror check added for the cross-Session reuse only sees answers that already landed: a create whose Harness committed but whose answer was lost has neither a mirror nor a command row, and a concurrent request can pass the check before the first one mirrors — both windows then committed the same derived scheduleId a second time, shadowing the first definition.

create() now claims (tenant, Idempotency-Key) with an atomic pre-relay insert into the command ledger: the primary key arbitrates concurrent racing, a claim held by a different request body conflicts idempotency_conflict before anything relays, and the identical request falling back into an unanswered claim redrives it so the funnel replays what it committed. remember() settles the claim row rather than adding a second, and the same-Session mirror guard stays for rows the control plane lost on its own.

Regression: a lost-answer create keeps its claim so the cross-Session retry conflicts while the owner redrives to its committed revision, a foreign mid-flight claim conflicts with zero side effects, and the lost-answer retry path asserts the claim-shaped row.

* fix(managed-agent): guard claim settlements by owner, claim every ledger mutation, release on definitive refusals

The shared remember() settled by (tenant, key) alone while update and retire did not claim first: an update that had passed its replay check before a concurrent create could overwrite the create's completed result_json — the row kept the create's digest and scheduleId, so the create's honest retry was then answered with another automation's resource with replayed=true.

update() and retire() now join the pre-relay claim protocol create() introduced: every mutation using the command ledger binds (tenant, Idempotency-Key) to its request before any side effect. The settlement itself is guarded twice — request_digest and actor must match the row's owner, and only an unanswered claim can be filled — so a late re-driven mutation can neither steal the row nor rewrite a completed result; the guarded settle is pinned red against the pre-fix unguarded form. Since only commit-proven refusal answers exist on the Harness, a definitive 4xx now releases its own unanswered claim instead of burning the key, so a fixed re-send of a refused request succeeds; the retired-definition replay branch settles its own claim the same way.

* fix(managed-agent): release claims only for refusals proven pre-commit

A blanket 4xx release read too much into the answer: invalid_automation_operation answers both a malformed new request — committed nothing — and an integrity failure while reading a previously committed resource, where the original operation may well have landed. Releasing the claim in the second case orphaned exactly the definition the claim was binding, opening the same-key-on-another-Session window the protocol exists to close.

Claims are now released only for 4xx codes that answer exclusively before any commit (not found, mode gate, conflict, stale revision, retired, quota); a proofless answer keeps the claim, so the other request conflicts and the owner still re-drives. The fake gains a pre-commit mutation-refusal seam, and the regression pins the proofless 400 keeping its claim, blocking the second Session and settling on the owner's retry — verified red against the blanket form first.

* fix(managed-agent): release claims only on refusals audited pre-commit

The 4xx-minus-400 partition still read proof into answers it cannot have: a re-attach that finds the Session running a Turn answers 409 hosted_session_already_attached — an attachment state, not a commit verdict — and a takeover that misses the Session answers hosted_session_not_found, equally mute on whether the mutation landed. Releasing the claim on either orphans a definition the original Session may well hold.

The release set is now an audited enumeration of the funnel's own business refusals, each with a single pre-commit answering path — automation_not_found, mode gate, operation conflict, stale revision, retired, count limit — closer to exhaustive proof than to convenience; every other answer keeps the binding. The attach-conflict regression mirrors the proofless-read one: same claim surviving, second Session conflicting, owner re-driving to its own answer; verified red against the loose form first.

* fix(managed-agent): release claims only on request-determined verdicts

A quota refusal is a verdict about the moment, not about the key: the claim row is shared by every attempt under one Idempotency-Key, so delivering an early attempt's automation_count_limit 409 and releasing the row dissolves the binding its later sibling may already have committed against — after which a third body under the key mints a second definition and the sibling's delayed success settles to the wrong mirror.

What may release is now exactly what cannot move between attempts: the mode gate, decided by the definition's sessionMode against the deployment's admitted set, identically every time the same request is sent. Capacity, existence, binding, revision and terminal verdicts all evaluate shared state and keep the claim; the quota regression mirrors the family's other pins — refused attempt, foreign body conflicting, sibling committing and settling — and was verified red with quota back in the set first.

* fix(managed-agent): map wake-turn ids to path-safe Broker sessions, and refuse fires a blocked Session can never run

The round-2 real-stack pass found two walls. Every automation turn whose model calls a Workspace tool died at the Broker's path-safe Runtime Session id rule (`arun_…:input` rejected at acquire), blocking its Session: wake input ids are committed as the turn id, so every Broker handle built from a prompt id now maps a non-path-safe one to a stable `wake-<sha256>` instead (`hostedRuntimeSessionId`); the mapped form is itself path-safe, so the map stays idempotent wherever lease/record paths already carry it. And a recovery-blocked Session kept answering 202 to fires, committing runs that can never start while its slots kept scheduling: `fire_run` now answers 409 `hosted_session_blocked` unless the occurrence's run is already dispatched (a pure replay answer carrying no new fact), the scanner recording the slot skipped with that reason. The run the F3 path settles `failed`/`outcome_unknown` now consumes its crashed input too — a `turn_result` settles its turnId — so a reload stops classifying the same crash as recovery and re-blocking the Session every time.

Regression: the id mapping (stable, idempotent) and a route-level wake turn whose tool call reaches acquire under the mapped id (red on the raw form); a blocked Session refusing a fresh fire while replaying its crashed run, consuming the crash, and firing after reload; the scanner recording `skipped(hosted_session_blocked)` on the new refusal. Real-stack F1/F4/F5 and the claim protocol were re-verified by the report; the dead journal after a missed activation renewal is pre-existing on main and goes to a separate issue.

* fix(managed-agent): settle the runtime park and silence of a crashed wake turn

The round-3 real-stack pass found two new walls past F6/F7. When the Harness crashes inside an automation turn's tool call, the F3 settlement left the checkpoint parked at await_runtime — every later turn on that Session refused (await_runtime is not a model-start phase) and the wake Broker session held the Workspace execution lease for every Session on it. When the crashed turn's prompt was text, its naked user record merged into the next turn's model history, so the crashed instruction ran a second time inside an unrelated run.

In the F3 recovery path: when the crashed turn's checkpoint holds in-progress Runtime items, the existing stop/cancel helpers drive them to terminal, settleParkedTurnCancelled records each cancellation as its cancelled tool_result — so the checkpoint advances out of await_runtime and the history stays well-formed — and the wake session's Broker lease is released. The Session unblocks only once that park is provably settled: a failure to even read the checkpoint, or to stop or release any part of it, keeps the block rather than letting a fire run against an unproven park. The consume write of the crashed input's turn_result gains bounded retries on SessionTranscriptChangedError, so a concurrent route claim cannot drop it and resurrect every reload classifying the same crash as recovery. The fire refusal itself is unchanged: while blocked, anything that would commit a new fact answers 409 hosted_session_blocked.

For the history side, runHostedHarnessTextTurn's assembly now excludes every user record whose prompt id ended with an error or cancelled turn_result (excludeTerminallyEndedUserRecords): a turn that ended without an answer is over, so the next turn never meets the crashed instruction as live work — pinned one level up in the runner's own suite (the crashed prompt is absent from the chat's setHistory while an answered older prompt passes through).

* fix(managed-agent): one aftermath for a crashed wake turn, reconciled and retried until proven

Round 4 of the real-stack pass found the round-3 recovery still wedging in three shapes and the F9 fix dead on the real path. A crash inside a wake turn's Runtime call whose park settlement fails once consumed the input anyway — after any restart nothing classified the crash again, so the Session and its Workspace lease wedged for good (one transient Broker 503 sufficed). With overlap skip, Spring skipped every later slot, so nothing ever loaded the crashed Session at all and its wake Broker session kept the Workspace execution lease while every other Session span on workspace_busy with no upper bound. The first user Turn to load a crashed Session raced the recovery behind the load and failed post-admission. And the round-3 history exclusion read turn_result records from a projection that never carries them, so the crashed prompt still merged into the next turn's model history.

The crashed wake turn now has one aftermath, keyed on the journal and the checkpoint rather than on which settle ran before it: the run fails with its execution unknown; the parked Runtime executions stop to terminal and settle cancelled — each cancelled tool_result committed once so the checkpoint leaves await_runtime with the call and its answer paired; the wake session releases its Broker lease, re-attempted on every pass since the journal cannot record whether an earlier pass landed it; and only then does the input's own turn_result consume it, with a transient journal conflict retried. An unproven settle keeps the block and the input pending, so the wake pump's blocked cycle — re-armed across a dead pass — the next load, and the fire that loads the Session all retry the same sequence instead of hinging a permanent wedge on one transient fault. For the case nothing loads the Session — overlap skip darkens the definition — the scanner, before recording a count-based refusal (never the open-Turn one), asks the Harness a fourth verb, reconcile_run, which commits no new fact, classifies and settles the crashed wake turn, and answers its run; the decision prices the count off those answers rather than the lagging mirror. A prompt arriving while the park is unsettled is refused pre-admission with the retriable 409 hosted_turn_recovery_required. And a turn that ended without an answer keeps its prompt out of every later turn's model history, filtered against the journal's turn.settled events at both history builders — the dead record-layer exclusion is gone, replaced by a pin through the real builder path: with the exclusion deleted, the new test fails.

Witnesses: six new Harness pins (park settle after a Broker fault with cancelled tool_result / lease release / in-process unblock, the reconcile route both polarities, consume retry, history exclusion, pre-admission prompt refusal) and three Java scanner pins (a wedged run reconciled into the very slot that asked, an unanswered reconcile keeping the refusal, an open Turn never triggering a reconcile); the fake gains the reconcile verb. The AutomationScannerTest shared-row hygiene now sweeps the extension record/resource tables, closing a latent stale-mirror revival the new pins exposed.

* fix(managed-agent): relay automation operations through #13163's workspace admission split

#13163 split requireReadyForNewWork by intent (action responses probe the mount, new work takes the full admission). The automation relay added by this branch still called the two-arg form, so main's build broke on the merge: fires, defines, retires and the new reconcile_run all drive new work — take the full `workspaceExecution.authorize` admission, the same check a fire's relay took before the split.

* fix(managed-agent): re-check the turn slot after the wake-park probe before claiming

The prompt route's admission mutex was atomic because no await stood between its `session.active` refusal and its claim. The wake-park probe inserted one: while the route awaited, the pump's wake turn could pass its busy re-check and start, and the route then overwrote the claim unconditionally — two concurrent turns on one journal, the exact wedge the unsettled-inputs guard exists to prevent. Re-read `session.active` synchronously right before the claim, restoring the check-then-claim pair's atomic discipline (the wake pump's own runner follows the same re-read-after-await rule).

A deterministic interleave pin is not offered here: the window needs an injection point inside the route's async span, which the route does not have today; the guarantee rides on the re-established discipline and the file's existing concurrency witnesses.

* fix(managed-agent): price a reconciled refusal off what the mirror has not caught, never doubly-subtracting

Audit round 1 (lane D). Two questions in the rebuilt refusal price after a reconcile:
1. The settlement the reconcile just made the Harness commit IS also the record mirror's correction: the host store's commit path applies the extension record synchronously (ManagedSessionStore. The mirror is not lagging the answer it just answered. Pricing by `countActive − resolved.size()` therefore subtracted a repaired run twice whenever a second run was genuinely live — one corrected count below the policy bound admits a fresh fire the policy forbids (witness: overlap skip with a wedged run and a live one).
   Price instead off the recounted active, subtracting only the occurrences the mirror still lists blocking — in production nothing, in the unit fake (which never writes the mirror) exactly the faked answer, so the earlier witnesses hold under both shapes.
2. Resolution must key on the ASKED occurrence's run going terminal (journal vocabulary 'settled' plus the mirror's 'completed'), never on the route's bare repaired flag: the harness keeps the oldest pending wake input, which can be another occurrence's crashed turn while the asked run stays live — the flag alone would resolve it anyway.

Witness: aSettledRunIsNeverSubtractedAgainWhenTheMirrorAlreadyDropsIt — a wrapped connector applies the failed mirror row inside the reconcile answer (production shape), beside a second live run; the slot 45/46th test stays refused where the old arithmetic fired. Old witnesses re-run green: the wedged run still reconciles into the very slot that asked, an unanswered reconcile still keeps its refusal, an open Turn still triggers none.

* fix(managed-agent): settle the journal side of a deterministically declined wake park

Audit round 1 (lane B). For a wake turn whose park the Broker cannot identify — a Shell item on a hooks/MCP-defined Session, a runtime route committed for another Session — the recovery machinery answers a typed, deterministic RecoveryDeclined, the same verdict a user Turn's takeover gets. The crashed wake turn's aftermath read every such answer as 'pending': one decline wedged the Session in an endless 500 ms retry (pump cycle, next load, reconcile_run) before any reload could classify the crash away. Now an inner decline path settles what the journal can settle anyway: the cancelled tool results are committed once (their idempotent settle is what the stop usually proves first), the checkpoint advances out of await_runtime, the consume write follows its retries, and the aftermath answers settled — the run ends failed with its execution unknown and the Session unblocks in-process. The wake session's own lease, undeliverable without its broker identity, stays the system's re-admitted kind, exactly as the takeover path leaves an undeliverable release documented at runtimeLeaseHeld, rather than pretending an infinity of retries. Witness: a shell-parked fixture wakes the decline; the aftermath answers settled, the execution proves outcome_unknown, the checkpoint leaves await_runtime, one cancelled tool_result pairs with the parked call, the journal consumes the crash. Old witnesses re-run green, including the latched-broker park settle that relies on 'pending' for every transient fault. 317/317.

* fix(managed-agent): refresh-retire a definition gone under the scanner's feet, size the reconcile fan-out to the cap

Audit round 1, lane A's two reconcilable minors. A reconcile that meets the chain's death — the definition retired, unknown, or its Session gone — must do what a fired answer's definitive refusal does: retire the mirror and record the slot skipped(definition_retired). Reading it as 'no answer' and keeping skipped-overlap would now stand every future slot forever, and a retired definition never frees its ledger. And the fan-out bound was a hard 5 — below any configured allow concurrency: wedged runs beyond it second a refusal the next tick could have finished this tick. Size it by the configured bound (at least two), exactly how many runs the refusal itself can still count at `settings.getConcurrency()`. Witness: a wedged minute definition meets its journal-death over a reconcile — the mirror retires, the occurrence reads skipped(definition_retired), the next tick never asks again. The earlier pins (wedged repair, unanswered reconcile, live-turn untouched, mirror-lagging math) stay green at 47/47.

* docs(managed-agent): cover the definitive-4xx settlement and the audit-corrected reconcile math

Lane C found decision 5 silent on the generic definitive-4xx arm the scanner shipped (typed code recorded as the skip reason; the code's name alone records it, hosted_session_blocked included), and the audit round of the same pass corrected how decision 15's reconciliation maths should read: the recount after a reconcile subtracts only what still reads blocked (the settle and the mirror row one commit together), a run resolves only by its own terminal answer (never the session-scoped repaired flag), a dead chain or Session refresh-retires the mirror as skipped(definition_retired), and the fan-out sizes itself by the configured allow bound (2 at least).

* fix(managed-agent): take over the cold Broker before a parked crash's stop and release, and publish wake Shell turns under their mapped identity

Round 5 review, findings 1-2. For finding 2 (the parked crash's aftermath dies against a cold Broker): the Harness and its Broker expire together, so the replacement owns an empty Session table — the status poll on the persisted READY wake session answers 404 runtime_session_not_found, the release 503 runtime_reconciliation_required, and the consume never runs, so the Session stays blocked in whatever loop its pump, next load or reconcile keeps walking. stopParkedRuntimeExecutions now adopts before any status question, as the passive takeover does — re-acquiring a READY session under this identity is idempotent server-side, and a 409 runtime_session_not_acquirable is proof the session is already released, so nothing remains to ask; the aftermath's release block adopts before releasing the same way. The cancelled tool results pick up the same bounded transcript-conflict retry the consume write carries.

For finding 1 (Tool v3 automations cannot publish a wake Shell turn): the Tool turn's publisher and binding references carried the raw prompt id while prepareV3 and encodeToolInput speak the mapped wake-<sha256>, so the real ToolPublicationStore answers 'Broker execution identity conflicts' at reserve. The reference surface across prepare / publisher / /grants now mirrors the Broker's stored identities (this.broker.runtimeSessionId), with the logical turn id kept only where the checkpoint asks for it.

Witnesses: a wake shell turn's publication binding mirrors wake-<sha256> across reference.sessionId/promptId with the raw id never admissible, refuting the mapped assumption; the folded park settle proves an adoption precedes every status and release on the cold surface (acquire < status, acquire < release), beside the settled crash, the released lease and the replayed failing run. Suite 318/318.

* fix(managed-agent): keep the cold-Broker adoption at the wake path's own sequence, out of the shared recovery step

Review round 6 (the red unit lane). stopParkedRuntimeExecutions is shared by the user-Turn cancellation takeover, which already adopts the parked session inside its own passive branch — inserting an adopt step into the shared function made that ownership double: takeover-callers hit their no-server doubles on a real fetch and died outright, and in the two 'outcome is unknown' cases the new call pre-empted the typed refusal with a transport error. The adopt obligation belongs to whichever caller starts from a Session that no harness session ever owned — the crashed wake turn's aftermath — so it now lives there alone, before the stop and before the release; the recovery function itself returns to its base shape. The cancelled tool results keep the bounded transcript-conflict retry. The lane 44/44 backs this in context with the folded cold-surface pin (318/318), whose order pins (acquire < status, acquire < release) continue to hold.

* fix(managed-agent): read the role column for automation access checks after the roles migration

* fix(managed-runtime): close the R3 correctness and pin-hardening findings

- assertAutomationDefinition refuses a non-string goal with the typed
  contract error instead of a raw TypeError (R3-9)
- automationTurnText and the fire route bound firedAt to the renderable
  Date range, answered as invalid_automation_operation (R3-14)
- drop the manualOccurrenceKey export nobody consumes (R3-17)
- pin the two terminal states outcome_unknown must never certify
  (R3-29), the definition digest pre-image byte-for-byte (R3-30), and
  the Legacy control sentence in full (R3-31)
- resolvesTimezone matches the JDK acceptance set for the three short
  ids a full host-tz sweep found diverging (R3-33)
- wallClock aligns to the minute before subtracting, so a sub-minute
  fraction no longer biases the zone offset (R3-34)
- compileCron shape-refuses the atoms the contract and the Java
  evaluator refuse instead of coercing them with Number() (R3-35)
- a fresh-command re-claim of one occurrence meets the committed run
  instead of appending a no-op revision (R3-36)
- pin the one-transaction dispatch span (R3-37), fence the
  operation.replayed answer to the committing Session and journal the
  honored revision's authoritative ref (R3-38, R3-39)

* fix(serve): close the R3 findings in the automation and wake surfaces

- the publisher's foreground register compares the mapped Runtime
  identity on wake turns, never the raw logical id (R3-2)
- an erased checkpoint verdict fails the wake park probe and the crash
  aftermath closed instead of answering over state nobody read (R3-6,
  R3-7)
- the F8d prompt refusal carries hosted_turn_recovery_in_progress, the
  one code the coordinator outlives past its budget until the pump
  settles the park (decision 15)
- pin the already-released adoption, the dead-writer 503, the mapped
  Shell owner, the resume builder's unanswered exclusion, the cold
  surface's acquire ordering, the reserve reference presence, the
  firedAt admission bound, and the mode-gated define publishing nothing
  (R3-20, R3-54, R3-56, R3-53, R3-5, R3-24, R3-14, R3-50)
- one wake-source predicate instead of three copies (R3-25), keyed run
  lookups on the fire and reconcile paths (R3-26), and the quota
  message naming the code the route emits (R3-49)

* feat(managed-agent): split the wake-park refusal code so the user Turn outlives the pump window

A prompt refused because a crashed wake turn is still unsettled clears
under the wake pump's own recovery channel, so the coordinator's
five-retry pre-admission budget is a lifetime for a transient state.
The route now answers hosted_turn_recovery_in_progress for exactly that
window and the coordinator retries it without spending the shared
budget; hosted_turn_recovery_required keeps the durable meaning and
still meets the budget (decision 15; R3-1).

* fix(serve): dedupe the merged records imports and finish the wake vocabulary union

* fix(managed-agent): close the R3 findings across the automation control plane

- the scanner settles skipped only for the route's definitive refusal
  codes; a transient session-state 4xx (hosted_session_blocked most of
  all) defers and re-drives under the bounded backoff (R3-11)
- create() exempts its own landed definition from the capacity check,
  so a lost-answer retry at the 32-cap re-drives instead of being
  refused by the very definition it committed (R3-3)
- retire() retires locally when the Session is not ACTIVE, the fallback
  a forever-skipped definition otherwise never meets (R3-12)
- the automation relay resolves the attachment before reading the
  client, as doContinueManagedRuntime already requires, so an adoption
  landing mid-load can never strand the call on the closed instance —
  and the child relay takes the same order (R3-10)
- the automation-run binding check and the scanner's mirror read both
  converge on ManagedExtensionRecords' terminal set (R3-43)
- automation tunables bind with startup bounds exactly like the sibling
  harness settings, and the shipped YAML's flattened keys are pinned
  (R3-40, R3-41)
- countActive, findBlockingRuns, listOccurrences and findOccurrenceView
  read each run's mirror state by its exact projection PK instead of a
  whole-table join; the mirror refresh sweep gains its
  (domain, created_at, record_id) index in V55 (R3-45)
- the fake's definition field set is one list (R3-48)

* docs(design): sync the actor-roles and automation documents with the landed surface

The public surface is eleven controllers with 39 public + 24 WebShell +
24 internal routes (87 total): the overview and the per-class tallies
read that, both languages (R3-22, R3-32). The 2026-10-04 automation
document's H6a-era domain bullet names what H6b enabled, both
languages (R3-21).

* style(serve): prettier-normalize the merged wake surfaces

* fix(managed-agent): close the review's three P1 findings at 786c319111

- R3-8/P1-3: both disabled-domain fixtures move past every name the two
  landings enabled (schedule by H6b, child_acceptance by H4b) to
  team_state — in the domain vocabulary, never in the plain enabled
  list and not in the extension-record body index; the comments name
  why child_run and monitor_run cannot serve (per-kind exemption, or
  the extension-funnel refusal answering first)
- P1-2: runtime_session_not_acquirable proves nothing about release: a
  RELEASING session refuses the adopt too, and only an explicit release
  completes the condition, so the release pass always attempts it
  instead of skipping on the 409. Pin: a dying worker release completes
  in the very next pass (stops, settles, consumes, no released
  identity ever skipped), and skipping it goes red
- P1-1: the wake Shell publication reference is one pair on two axes —
  sessionId = the mapped Broker Runtime Session (the execution's own
  owner) and promptId = the logical prompt id (the checkpoint's
  identity) — so the reserve's execution compare and the store's
  requireCheckpoint both approve what wave 8 aligned too far

* style(core): prettier-normalize managed-session-resources after the probe cleanup

* fix(managed-agent): close the R4 findings in the wake aftermath and the prompt latch

* fix(managed-agent): carry the logical turn identity through prepareV3 and refuse cron over the declared 400 bound

* test(serve): gate the released-Broker aftermath pin on the consume, not the block window

* fix(managed-agent): release a wake session the live Broker still holds as RELEASING

A worker release that fails on a running Broker leaves the wake session
RELEASING while that Broker still holds it in process, so the
aftermath's adopt is refused with 409 runtime_session_not_ready rather
than the cold Broker's runtime_session_not_acquirable. Both adopt sites
in settleCrashedWakeTurnAftermath accepted only the cold code, so every
pass threw before reaching the release, and the Workspace lease stayed
held until Spring restarted or someone released the session by hand.

Accept both refusals as "released or mid-release", as
hosted-mcp-session.ts already does for its owner release; the Broker's
release of a RELEASING row is idempotent once nothing is active. The
transient-release-failure pin now runs with both Broker answers, and the
live one goes red without this change.

* test(serve): pin the logical promptId on the foreground Shell publisher reference

The wake-turn register test only checked that register ran once. Reverting
the reference's promptId to the mapped Runtime Session passed the whole
tool-turn and broker suites, while on a real stack the store refuses that
reference and the wake Shell's execution goes unknown. Assert the pair:
the mapped Runtime Session as sessionId, the logical prompt id as promptId.

* style(managed-agent): restore the final newline of ManagedAgentProperties

The main merge in 9f809670c7 dropped it, and Checkstyle's
NewlineAtEndOfFile fails the Hosted process fault gates job on it.
2026-10-10 00:26:17 +00:00

36 KiB
Raw Blame History

Managed automation runtime (H6b, H6c persistent arm)

English | 简体中文

Status: implemented in this change for the persistent target mode; the per_run target (H4 child Sessions) and the delivery policy (H5 channel deliveries) stay refused until their runtime slices land, and production enablement stays behind a deployment opt-in. This is the runtime part of slice H6 of #12827, stage H of the Managed Agent proposal #12380: slice H6b (definitions, manual runs, the single-claim scanner, the run ledger, overlap and catch-up) and the persistent arm of slice H6c of the H6 design. It follows H6a (the managed-schedule and managed-automation_run record contract, PR #13536) and extends the obligations of H0b (record contract), H0c (authority) and H3 (background Shell and Monitor, whose embedded wake scheduler this slice reuses). Below, "the reference design" is sections 1, 7, 11, 12 and 14 of the extension runtime design, and "the automation design" is section 4 of the automation, Channels and child delivery design, at the commit that #12827 pins. Wherever this document and the H6 design disagree, this document records the supersession in "Decisions".

Problem and scope

H6a pinned what may be committed: a definition chain keyed by scheduleId with append-only definition revisions, and a run chain keyed by automationRunId with a closed occurrence identity. Nothing produces either: no Session may submit the domains, no public route creates a definition, no scanner derives slots or claims occurrences, and the two planned routes answer nothing. Issue #12380's H6 line is the exit this slice works toward: "Durable schedule/run claims, overlap/catch-up, persistent/per-run execution and delivery without model rerun."

This slice delivers the persistent vertical end to end:

  • Definitions (H6b). The public routes create, revise, retire and list definitions. Every mutation is relayed by the control plane to the target Session's Hosted Harness, whose authority commits the definition revision — the prompt published as a Session resource first — and replays by Idempotency-Key.
  • Occurrences (H6b). A control-plane scanner derives the due slots of every armed definition on a pinned tz database, claims each definition under a lease with a fencing token, records every occurrence decision in its own ledger (fired, skipped, missed), enforces the overlap and catch-up policies, and fires an occurrence as one idempotent Harness operation. A manual run is the same operation keyed by its Idempotency-Key.
  • Execution (H6c, persistent). Firing commits the run's claim revision and then, in one journal transaction, its dispatch revision together with the run's notification input and the authority-generated wake; the Session's embedded wake scheduler runs the input as an ordinary text turn; the settled turn settles the run, on the settle path and again on the next open.
  • Public reads. GET /v1/agent-automations and GET /v1/agent-automations/{automationId}/runs move from planned to partial, and gain the mutation routes and the definition detail read; definitions and occurrences page separately.
  • Enablement. Both domains become submittable, gated per target mode: only persistent definitions are admitted.

Still deferred (not this slice): the per_run target through H4's child Session pipeline (#13550); the delivery policy and its H5 channel_delivery projection (#13572); webhook triggers; Goal/Live/channel-loop migration; new budget kinds; WebShell mirror routes; production AgentBundle enablement.

Current state

The facts below are from this slice's base, main at e92b60a282.

  • Records. managed-automation-record.ts and the Java ManagedExtensionRecords mirror define both closed bodies and replay the shared managed-automation-record-v1 fixtures. Neither domain is in MANAGED_SESSION_ENABLED_DOMAINS; commitExtensionRecord refuses both. The authority's verifyExtensionResources closes no schedule reference, so a promptRef outside the Session commits on the local store and is refused after publication by the HTTP store (H6a verification, finding F1). No cross-record check ties a run to its definition, and a second run naming an already committed occurrenceKey under a new automationRunId commits.
  • Public contract. The two automation routes are planned at 1.33.0 with no response shape; H0a assigned definition CRUD, manual run and run query to H6.
  • Harness side. hosted-harness-session.ts hosts per-capability funnels and one control-plane operation route per capability; the H3 embedded wake scheduler runs pending notification inputs of source monitor as text turns, deriving the pending set from the journal (pendingSessionInputs), and the close path settles pending monitor inputs model-free.
  • Control plane. HarnessConnector/HostedHarnessClient carry one verb per capability; @Scheduled workers reconcile from committed rows; public mutations validate an Idempotency-Key, answer 202 and mark replays with X-Qwen-Idempotent-Replay (the AgentDefinition routes, D8a). Flyway stands at V47; open branches claim V48–V52.
  • Legacy scheduler. packages/core/src/utils/cronParser.ts evaluates five-field cron in the daemon host's local time with Vixie day semantics; its state is daemon-local and nothing survives a node replacement as a reconcilable fact.

Decisions

  1. A definition's home is its target Session. A persistent definition's schedule chain and every automation_run it fires live in the target Session's journal, and both the authority and the Java store refuse a persistent body whose targetSessionId is not the committing Session. The prompt is published into that Session (managed-automation-prompt) before the definition revision commits, and verifyExtensionResources and the Java store close promptRef over the Session's own store — F1 of the H6a verification is decided for Session scope. The automation design's rule follows: the scanner only discovers candidates; the target Session's authority admits the run and its wake, with or without a live Harness attachment (the control plane loads one).
  2. Identities are derived once and agree in both languages. automationRunId = arun_<sha256(scheduleId NUL occurrenceKey)>. The authority and the Java store refuse a first automation_run revision whose id is not the derivation of its own scheduleId and occurrenceKey, so two claims of one occurrence collide on the record key and the second claimant reads the committed run — the structural dedup the H6a verification asked H6b for. The run's input is <automationRunId>:input (its turn id is the same), run command ids are <automationRunId>:<revision>, and the control plane derives scheduleId as asch_<sha256(tenantId NUL Idempotency-Key)[0:32]>, so a create retried after a crash between the Harness answer and the command row meets the same definition instead of minting a second one. Every definition mutation commits under the relayed operation id the control plane derives from the same Idempotency-Key (a name-based UUID), so a re-driven relay re-sends it: the authority replays the operation's originally committed revision from the journal — however the chain moved since — instead of committing the retried content over a newer revision, and the control plane records the replayed answer as the operation's result while its mirror never regresses past the revision it already tracks. A mutation honored without a revision (its content identical to the committed definition) commits an operation.replayed marker naming the honored revision, so its lost-answer retry answers the honored result instead of re-applying old content over the moved chain. Every replay verifies the operation's target and, for defines, its requested content; a retry under one identity naming another target or another content is refused 409 automation_operation_conflict rather than answered with a different request's result.
  3. A run binds to the live definition at the current revision. A first automation_run revision must name a committed, non-terminal schedule of the same Session at exactly its current definitionRevision, with the same sessionMode and targetSessionId. A scanner that derived a slot from an older revision is refused and re-reads; the per-definition watermark (decision 7) keeps the slot covered or not regardless of which revision reads it.
  4. The run's revision chain, persistent arm. Revision 1 (claim): run admitted, execution intent, dispatchId = automationRunId, no runtime, no delivery. Revision 2 (dispatch): run running, execution dispatch_started, committed in one transaction with input.accepted (source automation, the envelope resource holding the turn text) and the generated wake.requested. Revision 3 (settle): run settled (turn completed), failed (turn error) or cancelled (turn cancelled), execution settled; an input the close path settled model-free ends cancelled with execution not_started_proven. Each step is one allowed move per state line (H0b), so a crash between any two leaves a chain the next step completes: a claim without its dispatch is re-driven by the scanner's firing row, and a settled turn without its settle revision is reconciled when the Session opens. A crash inside the turn has no lawful proof either way: the run ends once, failed with execution outcome_unknown (the H0b pairing admits exactly that pair, widened here from recovery-blocked-only) — visible beside the definition instead of running forever, and the slot frees.
  5. One Harness operation fires; it is complete before any effect and replay-safe. POST /session/:id/automations/operations with kind: fire_run commits revisions 1 and 2 in the funnel's serial chain and answers the committed run; a replay answers the same run and completes a missing revision 2. The scanner inserts the occurrence row as firing before the call and marks it fired only after the answer, so a crash anywhere before that re-drives the same operation and never a second run. Every occurrence write names the lease its writer holds (owner and fence), so a scanner whose lease expired records and settles nothing; a manual run takes the same lease (409 automation_busy when a tick keeps it). A lost answer is re-driven with backoff (2s doubling, the shift clamped at 256 seconds — the 64 attempts total close to a four-hour window, not a 5-minute cap per attempt) and after 64 attempts the row is recorded unknown: visible, never re-driven, not counted as active. A replay of an occurrence whose run is already dispatched answers that run even after the definition moved or retired; a claim whose definition ended before its dispatch settles cancelled with execution not_started_proven. A revision_stale answer refreshes the mirror from the committed record and re-pins the claim to the live revision once before the row is recorded skipped(revision_stale). Any other definitive 4xx answer settles the occurrence skipped with the route's typed code recorded as its reason — a verdict the request alone decides ends the slot — while store and transport faults answer a retryable 503 the backoff re-drives; the reason recorded is the answer's own code, hosted_session_blocked included. A run settles from the journal's own turn.settled event of its input. define_schedule and retire_schedule commit definition revisions the same way, replaying by the relayed operation id (decision 2). The verb route's connector re-runs the Workspace admission submit performs before every relay, attachment cached or not, so a grant revoked after the Session attached stops fires and definition mutations alike. When the connector's cache misses but the Session was attached before (its record carries harnessBootId), the relay re-attaches through the same takeover load a Turn uses, so a control-plane redeploy never strands every automated Session on hosted_session_already_attached.
  6. Exactly one scanner claims, and the fence guards the ledger. Each definition has one ledger row (qwen_managed_automation_schedule) with a lease (lease_owner, lease_until) and a monotonic fence; a scanner claims a row only when the lease is free or expired, bumps the fence, and writes every later ledger step with WHERE lease_owner = ? AND fence = ?. A scanner that lost its lease therefore cannot move the watermark or record occurrences, and could at most re-drive a Harness operation that is idempotent by the derived run id — the effect is guarded by the journal, the ledger by the fence. The scanner runs on a dedicated managedAutomationScheduler pool, never the one-thread default scheduler that also carries the message materializer, the turn-retry dispatch and the recovery ticks, because its blocking Harness calls would otherwise delay unrelated work by a request timeout at a time.
  7. Slots, watermark, late slots and catch-up. A tick derives the slots of a definition in the window (from, now], where from is the greater of the covered watermark and the arming instant, and never older than the lookback horizon (qwen.managed-agent.automation.lookback, default 24 h). A slot is timely when now − slot ≤ late-tolerance (default 5 min) and late otherwise. Timely slots fire. Late slots are missed under none; under latest the newest late slot fires as a catch-up run and the rest are recorded missed; under bounded: N the newest N fire oldest first. Every slot in the window gets a ledger row, and the watermark advances to the newest, so a later revision — or a later tick — never re-arms a covered slot (H6 decision 2). The result set is bounded (max-slots-per-tick, default 1,000) — the minute walk itself follows the lookback horizon, so raising it widens per-tick work, never trims it; a window beyond the result bound is cut at the oldest end and the cut is logged — unbounded catch-up has no path here.
  8. Overlap is the Session's input-queue admission. A persistent target runs one turn at a time, so an overlapping occurrence is one more pending input in the journal. active counts the definition's occurrences whose run is not terminal (firing rows included). skip fires only at active = 0, queue_one only at active ≤ 1 (one running, one waiting in the journal), allow up to the concurrency quota (default 4); a dropped occurrence is recorded skipped with reason overlap or count_limit, never as a run. A target Session that is not ACTIVE records skipped with reason session_not_active. An open public Turn on the target suppresses every fire under any policy as skipped with reason overlap: the wake pump starts a queued automation input the moment the Session idles, while the dispatcher retries the Turn with backoff, so a recurring definition could otherwise starve the Turn indefinitely; the Turn goes first and the slot fires when it ends. The count joins the hosted Session store's record projection, so mutations answer 409 automation_unavailable unless both qwen.managed-agent.automation.enabled and qwen.managed-agent.session-store.enabled are on.
  9. Cron evaluation is pinned by a shared fixture. Both languages enumerate minute instants in UTC, read each one's wall clock in the definition's zone, and match it with the H6a grammar under Vixie day semantics (either day field matches when neither is *), 7 as Sunday, and N/step as N..max/step. A wall-clock minute inside a DST gap fires once at the first instant after the gap; a minute repeated by a DST fold fires once, at its first instant. managed-automation-slots-v1.fixtures.json pins the tables — including gap and fold witnesses in the seven H6a anchor zones — and TypeScript and Java both replay them. The Java scanner is the production evaluator; the TypeScript twin exists so the semantics are a contract, not an implementation detail.
  10. Timezones resolve at admission on a pinned database. The H6a contract checks the name's shape only; the control plane refuses a definition whose zone the host tz database does not resolve (400 automation_timezone_unknown), and the scanner parks a definition whose zone stops resolving (blocked_reason) rather than firing it on a guess.
  11. Definitions are Session-creator mutations, answered synchronously with 202. POST /v1/agent-automations creates a definition for a Workspace-bound Session the caller created (the same authorization as later Turns and cwd changes); POST /v1/agent-automations/{automationId} revises it; DELETE retires it (the chain ends cancelled and freezes); POST /v1/agent-automations/{automationId}/runs fires a manual run. Each takes an Idempotency-Key, is relayed to the Harness synchronously, answers 202 with the resource, and replays by key through the control plane's command ledger with X-Qwen-Idempotent-Replay: true; a different body under the same key answers 409 idempotency_conflict. The reference design's 202 + operationId is honoured as D8a honours it: the answer is the committed resource, and the control plane never holds a half-committed operation, because the Harness answers only after the journal did.
  12. Three ledgers, one authority. The journal's schedule and automation_run chains are the only business facts. The control plane keeps three rebuildable indexes: the definition row (the scanner's view of the latest revision, refreshed from the record when its mirrored revision lags, plus the lease and watermark), the occurrence row (one per decision, keyed by occurrenceKey, carrying the derived run id, the trigger, the outcome and its reason), and the command row (public idempotency). None holds a second copy of a journal fact that the journal does not settle.
  13. Enablement is mode-scoped and deployment-gated. schedule and automation_run join MANAGED_SESSION_ENABLED_DOMAINS; a new gate beside it, MANAGED_SESSION_ENABLED_SCHEDULE_SESSION_MODES = ['persistent'], is checked by the authority on every schedule revision (the mode is not a fixed key of the chain, so checking only the first would let a later revision name a mode no first revision may open), and a run inherits it through decision 3. The control plane's scanner and the mutation routes stay inert unless qwen.managed-agent.automation.enabled is true (default false); the read routes always answer. The server-first order holds: the Java store has validated both bodies since H6a and gains the cross-record checks here.
  14. No WebShell surface, no Legacy change. The daemon's /scheduled-tasks routes, cronScheduler.ts and cronParser.ts keep their behavior; a Managed definition never fires through them.
  15. A crashed wake turn has one aftermath, retried until provably complete. A wake turn its Harness died inside (classified recovery, never re-driven) ends through one idempotent sequence keyed on the journal and the checkpoint, not on which settle ran before it: its run fails with execution outcome_unknown; when its checkpoint holds in-progress Runtime executions they are stopped to terminal, settled cancelled — each cancelled tool_result committed once, so the checkpoint leaves await_runtime and the history stays shaped — and the wake session hands its Broker lease back, with the release attempted on every pass since the journal cannot say whether an earlier pass landed it; only then does the crashed input's own turn_result consume it, with a transient journal conflict retried, because an unproven consume keeps the input pending and every later load and every blocked pump pass re-runs the same settle — it is never dropped, or the re-block loop returns. The blocked pump's cycle re-arms itself across a dead pass, so a transient fault cannot take the reminders down with it. A crash whose run keeps the overlap count closed may have nothing left to load the Session (no slot fires, no Turn comes): the scanner's decision against a count-based refusal — never the open-Turn one — first asks the Harness a fourth verb, reconcile_run, for the blocking occurrences, committing no new fact; the verb's own load classifies and settles the crashed wake turn and answers its run, and the decision prices the admission off those answers rather than the lagging mirror. A prompt arriving while such a park is still unsettled is refused before admission with 409 hosted_turn_recovery_in_progress — the coordinator retries it past the pre-admission budget until the pump settles the park, since this is the only refusal that clears under the daemon's own recovery channel, while the durable hosted_turn_recovery_required still meets the budget; post-admission is exactly where no retry can reach. And a turn that ended without an answer (error or cancelled) keeps its prompt out of every later turn's model history, filtered against the journal at both history builders, so a crashed instruction never merges into an unrelated turn — the no-toolProfile path's older rule, now structural for every profile. Resolved runs price the retry at the mirror's own truth, never beside it: a reconcile's settle commits its mirror row in the same transaction that answers, so the recount subtracts only what still reads blocked rather than the answered runs once more; a reconcile counts an occurrence resolved only when its own run answers terminal — never on the route's bare repaired flag, whose session-scoped wake recovery may have settled an older neighbour while the asked run still lives; a reconcile that meets the definition's or the Session's death refresh-retires the mirror and records the slot skipped(definition_retired) instead of standing forever; and each decision's reconcile fan-out scales to the configured allow bound, at least two.

The pipeline, revision by revision

Step Where Commit
1 control plane create command row; Harness define_schedule → prompt resource + schedule rev 1 (admitted, definitionRevision 1); definition row mirrored
2 control plane update Harness define_schedule → schedule rev n (definitionRevision n, run unchanged); definition row refreshed
3 scanner tick claim the row (lease + fence); derive slots; one occurrence row per slot of the window (firing, skipped or missed; a re-driven claim may end unknown)
4 Harness fire_run automation_run rev 1 (admitted/intent), then rev 2 (running/dispatch_started) + input.accepted + wake.requested in one transaction
5 scanner occurrence row fired; watermark advanced under the fence; lease released
6 Harness (wake) the automation turn runs; turn.settled
7 Harness (settle / open) automation_run rev 3 (settled, failed or cancelled, execution settled)
8 Harness (close) pending automation inputs settle model-free; their runs end cancelled with execution not_started_proven
9 control plane retire Harness retire_schedule → schedule terminal revision (cancelled); definition row retired

Each commit moves every state line at most one allowed step; every control-plane verb re-reads the committed record before acting and treats "already there" as its own replay.

Quotas and configuration

Bound Value Refusal / effect
Prompt ≤ 64 KiB UTF-8, and the wrapped execution input — the turn text plus the envelope, under the largest identity fields the route admits and real JSON escaping — ≤ the 65,536-byte inline resource bound, computed exactly at admission so a definition never commits whose fire could not publish the input 400 invalid_automation pre-relay; 400 invalid_automation_operation at the funnel
Goal ≤ 4,096 UTF-8 bytes, no control characters (H6a contract) 400 invalid_automation
Concurrent runs under allow 4 (qwen.managed-agent.automation.concurrency) occurrence skipped, reason count_limit
Late tolerance 5 min (late-tolerance) older slots follow the catch-up policy
Lookback horizon 24 h (lookback) older slots are neither fired nor recorded
Slots per tick 1,000 (max-slots-per-tick) the window is cut at its oldest end, logged
Scan period / lease 10 s (scan-delay) / 60 s (lease) a stalled scanner yields its rows
Definitions per Session 32 409 automation_count_limit

Files affected

  • packages/core/src/managed-runtime/: managed-session-records.ts (enablement and the mode gate), managed-automation-operations.ts (new: identities, digest, envelope, turn text, body builders, the final-input budget), managed-automation-slots.ts (new: the cron slot evaluator), managed-session-authority.ts (the mode gate, the run→definition cross-record check, the derived-id check, promptRef closure, the committed-operation read), the shared slots fixture, collocated tests.
  • packages/cli/src/serve/: hosted-automation-session.ts (new funnel), hosted-harness-session.ts (funnel wiring, the operations route with reconcile_run, wake admission of automation inputs, the crashed wake turn's aftermath on the wake path and on open, close-time settlement, the prompt route's wake-park refusal, the journal-keyed history exclusion, the restore verifier's domain list), hosted-monitor-wake.ts (the turn's source on close, the blocked cycle's re-arm and crash-survival), hosted-harness-model.ts (history assembly consumes the builders' filtered records).
  • packages/sdk-java/managed-agent-server: V57__managed_automation_ledger.sql, AutomationLedgerStore (plus the blocking-runs read), CronSlots, AutomationScanner (the reconcile fan-out before count-based refusals), ManagedAutomationService, ManagedAutomationController, HarnessConnector.runAutomationOperation and its connector implementation, the cross-record checks in ManagedExtensionRecordStore, the H0b pairing rule in ManagedExtensionRecords (the failed + outcome_unknown pair), PublicSurface, properties and application.yml, the OpenAPI routes and schemas, ApiModels records, and tests (slots, ledger, scanner, service, store, contract).
  • packages/sdk-java/qwencode: HostedHarnessClient.runAutomationOperation.
  • This design in both languages; the H6 design's status line and slice table in both languages; the H0b record contract's shared fixtures (managed-extension-record-v1.fixtures.json: the failed-with-unknown-outcome case flips as the F3 pairing lands); the actor-roles design pair's /v1/agent-automations surface-matrix rows in both languages.

Validation plan

  • TypeScript: builder suites (every body the H6a parser accepts, every successor the rule admits, the derived identities, the digest, the turn text); authority suites for explicit enablement, the mode gate, the run→definition binding (missing, retired, stale revision, other target, other mode), the derived-id refusal, the structural occurrence dedup, promptRef closure, and reopen rebuild; funnel suites for define/revise/retire and their replays, a mutation retried after its answer was lost answering its committed revision while the moved chain stays put (also across a reopen), a definition whose wrapped input cannot be published refused at admission, fire_run committing revision 2 with its wake in one transaction, replay after a revision-1-only crash, settle from the turn result, reconciliation on open and after a close-time settlement; the slots fixture replay. The crashed wake turn's aftermath: the block survives an unsettled park and the pump's cycle finishes it (cancelled tool_result, lease released, consume retried), reconcile_run repairs a blocked Session and reports a consumed one, the consume's transient-conflict retry, the prompt route's pre-admission refusal while the park stands, and the unanswered turn's prompt absent from the next turn's model history.
  • Java: the slots fixture replay; ledger suites for claim contention (two owners, one claimant), lease expiry handover, fenced writes refused after loss, occurrence uniqueness; scanner suites for the policy matrix (every overlap × catch-up pair against scripted timely, late and overlapping windows, asserting the exact occurrence population and outcomes), firing re-drive after a simulated crash, the non-active target, a wedged run reconciled into the very slot that asked, an unanswered reconcile keeping the refusal for the next slot, and an open Turn never triggering a reconcile; service suites for create/update/retire/manual with replay and conflict, a mutation retried after its answer was lost answering its committed result while the mirror keeps the newer revision; the connector suite pinning the Workspace admission gate over a cached attachment; store suites for the cross-record checks; the contract test exercising every new route and response.
  • Fault injection (store/authority level): crash before the occurrence row; between the row and the Harness answer; between revision 1 and revision 2; between the turn settle and the settle revision; between the Harness answer and the watermark — each ends in at most one run per occurrence and one model turn per run.
  • Mutation checks: the derivation, the binding check, the mode gate, each policy branch, the fence guard and the DST rules, disabled in turn, fail a test in both languages.

Acceptance criteria

  • Reference design section 14, item 6: two scanners claim one run per occurrence; a Channel send failure after model completion never re-runs the model (no delivery exists in this slice, so nothing can).
  • Section 14, item 10: every occurrence resolves to fired, skipped or missed, and every fired run to settled, failed or cancelled with a proven execution; a claim whose answer was lost is re-driven, never guessed.
  • Section 11: definitions and occurrences page separately on the public routes; mutations are Idempotency-Key based and answer 202.
  • Both domains are submittable only for the persistent mode; the contract test proves the gate is explicit and mode-scoped.
  • All existing suites stay green — every H1–H5a/H6a contract replay, authority, store, tool-turn and lifecycle test, npm run typecheck && npm run lint && npm run build, and the managed-agent-server surefire suite.
  • Still separate: real multi-instance control planes, the §13 product-stack acceptance recorded in #12380, and the per_run and delivery arms.

Open questions

  1. Catch-up age bound (H6 open question 2): the lookback horizon bounds bounded: N by age as well as by count; whether a definition should carry its own horizon is left to usage.
  2. Lease and scan sizing (H6 open question 3): the defaults above are deployment configuration; measured values precede enablement.
  3. Manual-run scope (H6 open question 4): decided as the Session creator for all mutations; a reader role waits for the actor-role work.
  4. Equal-ended cron ranges: the H6a contract refuses 5-5; the evaluator inherits that and the fixture pins it.

Follow-up work

Slice Scope
H6c-run The per_run target through H4b's child Session pipeline, once #13550 lands.
H6c-dlv The delivery policy and its projection onto H5 channel_delivery entries, once #13572 lands.
Webhook webhook:<eventId> triggers with a verified ingress surface.
API WebShell mirror routes; a public operation query for automation mutations if clients need one.