Repository navigation
feat(devloop): dispatch WorkItem execution requests with canonical execution identity #461
Description
Parent roadmap: mctlhq/.github#21 (surfaces)
Problem
Nothing turns "a surface created or opened a WorkItem and asked for it to run" into an execution. mctl-api neither dispatches on create nor signals a DevLoop on resume. The investigator and DevLoop run only when triggered by an issue label, a cron or an operator. The acceptance path of mctl-telegram#443 ("investigator execution launched from Telegram is correlated to WorkItem + execution identity + ContextSnapshot") and of mctl-api#341 has no platform component behind it.
Owner decision (2026-09-23): the bridge belongs to the execution platform, meaning the existing mctl-agents/Temporal orchestration boundary:
- not the Telegram bot;
surface:telegramgets no Temporal or admin privileges.
Goal
A dispatcher in mctl-agents that consumes mctl-api execution requests (mctl-api#368) and runs them through the existing DevLoop / investigator path, owning the canonical execution identity.
execution request (mctl-api, pending)
↓ claim (lease, single holder)
dispatcher (mctl-agents, service principal)
↓ start DevLoop / investigator, bound to exact WorkItem + request
↓ fulfil request: attach canonical execution (engine, engine_ref) → we_ id
investigator run uses that we_ as its execution identity (mctl-agents#455 case a)
↓
ContextSnapshot sealed for that execution (#431 / #456)
Required properties
-
Idempotent and fenced:
- claim through mctl-api's lease CAS;
- the Temporal workflow id is derived from the request id, with a reuse policy that refuses a second start;
- fulfil is idempotent on
(engine, engine_ref).
A crash between start and fulfil re-claims after the lease and converges on the same workflow and execution, never a second run.
-
Bound to the exact WorkItem and request. The workflow carries
work_item_id,execution_request_idand the fulfilledwe_id. The investigator refuses a mismatch; the existingwork-item-mismatchcheck covers it. -
Canonical execution identity. The dispatcher creates it at fulfilment, and nothing downstream invents one. This is consistent with mctl-agents#460 (store-issued
we_). -
Scope for v1: investigator runs for WorkItems bound to a GitHub issue (
issue_urlon the item). A request for an item without one is rejected with a typed reason (no_runnable_target), never guessed. -
Auditable. Claim, start, fulfil and reject are each logged with request, work item, workflow and execution ids. mctl-api records its own events.
-
Resume. A
kind=resumerequest delivers the resume to the item's live DevLoop through the existingresumesignal, or starts a continuation where none is live. The canonical resume path from feat(work-context): resume investigator from canonical WorkItem across surfaces #267 applies. Approval and policy are re-evaluated, not inherited. -
Governance. The dispatcher's mctl-api mutations go through the feat(governance): runtime policy checkpoints for agent tool actions #197 policy checkpoint, like the other platform writes.
Acceptance
- Replay-safe workflow code: a new workflow or
workflow.patchedas needed, with replay tests. - Tests for:
- duplicate claim or start;
- crash between start and fulfil;
- an expired lease;
- a mismatched work item;
- an item with no runnable target;
- resume onto a live loop versus a finished loop.
- An offline end-to-end test with a fake mctl-api covering: request → claim → DevLoop start → fulfil → the investigator sees the
we_→ a snapshot sealed for that execution. - The live end-to-end run is gated on the mctl-api release with A proposal committed straight to accepted with requires_human_approval can never be implemented #349/architecture(lifecycle-ownership): define EntityRef, LifecycleOwnership and ExecutionClaim contract #350/fix: await async-launched sub-agents in the investigator and service-agent drivers #368 and on the dispatcher being enabled in gitops.
Depends on mctl-api#368. Consumed by mctl-telegram#443 and mctl-api#341.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
fix(devloop): end a dispatched execution on every exit and harden dis…
fix(devloop): land the success-path advance before the approval park (#…
mashkovd commentedon Sep 23, 2026
Notes from preparing the remaining gaps (not in #468):
Order for gap 2 (the loop id) and gap 3 (
execution_request_id): gitops CWFT params first (prepared locally), then mctl-api registry declarations (the issue just filed;StripUndeclareddrops anything undeclared), then the investigator CLI flags. The CLI must also use the passed workflow id where it now callsworkflow_id_for(issue_url)(run_issue_investigator.pypost_proposal_comment,context_assembly.py).Risk to fix together with gap 2: once the human-input producer ships, a dispatched loop would reject its own clarification request as belonging to another run. The investigator stamps the issue-keyed id, but the loop compares against
dev-loop-xr_*.Gap 1 design (resume onto a live loop). Recommended:
accept_execution_request(update id = request id), before fulfilling;engine_ref = <loop id>#<xr id>;we_itself through the existing bind activity;workflow.patched("execution-request-resume"), and reconciliation learns#xr_refs.No mctl-api change is needed (Temporal server/SDK 1.31.0 support Update).
Gap 4 (the live-loop race): only a single workflow id per issue, with update-with-start, closes it. That is a larger change, and it also removes gap 2.
fix(devloop): re-attempt a pending dispatched advance before every pa…
mashkovd commentedon Sep 23, 2026
#468 merged: the dispatcher for start requests, crash convergence, typed rejects, reconciliation of dead dispatched loops, and the advance landing before every human-input or approval park. It is off by default (
EXECUTION_REQUEST_DISPATCHER). This issue stays open for the remaining gaps: resume onto a live loop (design above),dev-loop-xr_*visibility andexecution_request_idto the investigator (gitops CWFT params → mctl-api#372 → investigator CLI), the live-loop race, and live e2e.P3s carried from the last review round:
_await_reapprovaland_watch_prget no pending-advance re-attempt. Either add the call before the merge watch or narrow the 'before every park' wording in ADR-011 §8.beforeparameter. It is a free-form string whose only checked value isapproval. Use aLiteral[...]or pass the RetryPolicy.mashkovd commentedon Sep 23, 2026
#475 merged. The investigator takes
--temporal-workflow-id,--temporal-run-idand--execution-request-idfrom the CWFT (mctl-gitops#1345), and the DevLoop passes them. It stays inert until mctl-api declares the params (mctl-api#372), which must happen after the registry-pinned investigator image contains #475.P3 follow-ups from the #475 review:
--temporal-workflow-id ""hard-exits; the sibling id flags treat an empty value as absent. Normalize""toNone, so a missing Argowhenguard cannot break every investigate pod.--execution-request-idorwe_) arrives with no--temporal-workflow-id; the fallback to the derived id is silent.workflow_id/workflow_run_idon the investigator root span (tracing.annotatecatalog keys).investigator_release.version, so the rollout order does not depend on process alone.execution_request_idshould ride along after the identity is dropped.Still open for this issue: #474 (dispatched-loop visibility, required before enabling the dispatcher), resume onto a live loop, the live-loop race, and live e2e.
fix(devloop): end a minted execution whose bind is refused (#461)
test(devloop): use a genuinely unknown surface now that `api` is in t…
fix(devloop): keep a delivery open until its terminal advance lands (#…
mashkovd commentedon Sep 23, 2026
Progress: #480 merged (
4eceb3a) — a resume request is now delivered onto a live DevLoop via theaccept_execution_requestUpdate (gated byexecution-request-resume), with minted-but-refused executions ended Failed and terminal advances carried until they land. Still open for this issue: the start race for a live loop (one workflow id per issue + update-with-start), a live end-to-end run, mctl-api#380 (shepherd alias), and the mctl-api#372 loop-id declaration after an agents release that contains #475/#480. Deferred P3s: #482.mashkovd commentedon Sep 24, 2026
Remaining code gap: the start race needs a decision first
State on
main(4eceb3a, after #480): two starters create DevLoops for the same issue under different workflow ids:agents:intakepoller (start_dev_loop_workflow)dev-loop-<owner>-<repo>-<n>start_dispatched_dev_loop)dev-loop-xr_<request id>_live_loop_candidates→loop_state→startTemporal gives no atomicity across two ids, so a label added while a request is dispatched (or the reverse) can run two loops on one issue: two investigators, two proposals racing on one slug. Today it can't happen in production, because
EXECUTION_REQUEST_DISPATCHERis off.Options:
A. One workflow id per issue, requests delivered by update-with-start (recommended; this is the design sketched in the 2026-09-23 note above).
dev-loop-<issue>withUSE_EXISTING, deliveringaccept_execution_requestin the same call, so the race disappears by construction.dev-loop-xr_*, the devloop: make dispatched dev-loop-xr_* loops visible to shepherd, implement sweep, orphans and lifecycle reconcile #474 memo alias, and the loop-id declaration planned for mctl-api#372 (gap 2).REJECT_DUPLICATEon the xr id) becomes the issue-keyedALLOW_DUPLICATE_FAILED_ONLYplus the request-id dedupe the loop already carries (accepted_request_ids);engine_reffor a start becomes<issue loop>#<xr id>, as a resume's already is;workflow.patchedpath;B. Keep both ids and narrow the window.
loop_active(and terminates its unfulfilled loop) if an older sibling exists.C. Accept the race while the dispatcher is off: document it as a precondition for enabling the dispatcher.
This choice also decides what mctl-api#372 declares, so it should be made before #372 and before an agents release is cut for it. The other open items are live or release-gated: live e2e, mctl-api#380, and #372 after an agents release containing #475/#480.
mashkovd commentedon Sep 24, 2026
Decision (owner, 2026-09-24): option A for the start race
One workflow id per issue:
dev-loop-<owner>-<repo>-<n>.USE_EXISTINGand delivers the request in the same call (update-with-start,accept_execution_request, update id = request id).dev-loop-xr_*is retired, along with the devloop: make dispatched dev-loop-xr_* loops visible to shepherd, implement sweep, orphans and lifecycle reconcile #474 memo alias. The loop-id declaration planned for mctl-api#372 is dropped.accepted_request_ids).engine_refis<issue loop>#<xr id>, like a resume's.workflow.patched.mctl-api#372 waits for this implementation. 1.55.0 (with #475/#480) was released and rolled out 2026-09-24 06:44Z. The dispatcher is still off.
mashkovd commentedon Sep 24, 2026
Option A is implemented in #487. There is one refinement of the detail written here, flagged for owner confirmation:
ALLOW_DUPLICATE, not the intake'sALLOW_DUPLICATE_FAILED_ONLY. Aresumeof a loop that completed has to become a new run of the same issue loop. UnderFAILED_ONLY, Update-with-Start refuses that withWorkflowAlreadyStartedError(verified against a real Temporal server on temporalio 1.31.0), so every resume of a finished loop would be stuck.FAILED_ONLY. A label re-added on a completed issue is still "already handled".test_after_a_completed_loop_the_dispatcher_starts_a_new_run_and_the_intake_does_not.#461 will be closed by hand after #487 merges. Enabling
EXECUTION_REQUEST_DISPATCHERin prod, and any live evidence from it, stays a separate human gate.Merge pull request #487 from mctlhq/feat/461-issue-keyed-dispatch
mashkovd commentedon Sep 24, 2026
Closing on the code acceptance. Option A landed in #487 (merge
b68291c, reviewed head79d5876):engine_refis<loop>#<xr>;issue-keyed-dispatchanddispatched-not-run-fails, with the pre-A fixtures kept;The reuse-policy refinement described above was reviewed in the PR: the dispatcher uses
ALLOW_DUPLICATE, the intake keepsFAILED_ONLY, and a dispatched run that ran nothing ends FAILED so the intake label is never shadowed.The live end-to-end line of the acceptance is not claimed here. It is gated on releases and on enabling
EXECUTION_REQUEST_DISPATCHER, both human decisions, and is tracked in #490. The residual P3s are in #488.