Operator Task Routes¶
Issue2104, plans C8b, C9c, C10b and C11b. The Rig Cockpit Worker signs each call with
rig-cockpit-task-v1, and the Conductor answers from the task store.
The code is CockpitTaskEndpoints, and the pure decisions are
CockpitTaskRoutes,
CockpitTaskConfirm and
CockpitTaskRelease and
CockpitTaskStatusRead. They have unit tests
(routes, confirm,
release, status), e2e tests per stage, and a
lifecycle walk of one task from proposed to verified
that asserts the 15-key status contract on every read (why). The seam
to the Worker itself — its real signer and client against these routes — is the
cross-repo contract test, which runs in its own workflow because it needs the
rig-cockpit-worker checkout.
Status: live, with no key yet. Every call answers 503 not_configured until COCKPIT_TASK_SIGNING_KEY holds a real key
(plan G2). The boot log shows [CockpitTask] signing key: <State>, never the key. Once the key is live, confirm (C10b) can
create exactly one real dashecorp/dashe-website issue per task, through the App-token issue creator.
Release (C9c) can merge, but only a PR that MergeGate is holding at exactly the signed head.
Every route, in order¶
Nothing touches the store until all four steps pass.
- Signature. The verifier checks the request with the configured key, for exactly this route. A caller error is 401
{error}, with the verifier's code (such assignature_mismatch,stale_timestamporunsupported_route). A missing, malformed or public-fixture key is 503not_configured. OnlySignedTaskRequestReaderreads the body. - Repo tenant.
repoTenantisOperatorTaskTenancy.RepoTenantof the resolver's answer fordashecorp/dashe-website. This is the webhook's coalesce: an unresolved repo is tenant-0invotek, and a canonical id is itself. A blank or non-canonical answer, or a resolver fault, is refused and never coalesced.
Precondition: pin the repo before a second tenant can claim it (rc#2104 review). These routes and MergeGate resolve
with the repo alone (ResolveAsync(repo, null, null)), while the GitHub webhook resolves with the delivery's
installation id and org too. TenantMatch.Resolve checks a repo pin first, then the installation id, then the org. So for
an unpinned dashecorp/dashe-website the two disagree as soon as an active tenant other than invotek carries the
delivering installation id (for example a dashecorp tenant, OA10), or two active tenants share org dashecorp. The
webhook then writes ISSUE_APPROVED, PR_CREATED and REVIEW_PASSED into one tenant database while MergeGate writes
HUMAN_GATE_TRIGGERED into the other. The task issue stays ready_to_merge where the SLO enforcer looks, and the hold
no longer stops the re-dispatch (B3). Today only invotek is active and it has no installation id, so both paths agree.
Before activating any other tenant in the dashecorp org, or setting any tenant's installation id, pin
dashecorp/dashe-website with a repo-tenant row to the tenant that should own it. A pin is authoritative in
TenantMatch.Resolve, so every path then agrees. The Tenants tab does not show pins (/api/admin/tenants), so check that
no other active tenant has GithubOrg dashecorp or an installation id before relying on the unpinned default.
3. Tenant database. ITenantDatabaseRegistry must list repoTenant exactly. Otherwise the answer is 503
{error:"tenant_unavailable", repoTenant}, with null for a refused resolution. No session ever opens for an unknown tenant.
4. Owner. The owner is (repoTenant, signed operatorId, signed conversationId). The signed owner.tenantId is always
dashecorp, the org that hosts the repo (the hosting scope). It is never the owner. After this step, a store
exception is 503 store_unavailable, except after the confirm route's one POST (see below).
POST /api/cockpit/task/propose¶
The body is {"requestId","templateId"}. The templateId selects a registered template through
OperatorTaskTemplates.ById (#2275). An id nobody registered is 422 unsupported_template, before any probe or store call.
Everything the task will do comes from the registry entry, never from the body. The registry holds three templates:
templateId |
Page (PageUrl) |
Anchor classes | Change |
|---|---|---|---|
reward-e-website-link-v1 |
https://dashecorp.com/ |
app-card rewarde |
Reward-E card: App Store → https://reward-e.com |
sandbox-toggle-one-b-v1 |
https://dashecorp.com/rig-sandbox/ |
rig-toggle toggle-one |
toggle one #toggle-one-a → #toggle-one-b |
sandbox-toggle-one-a-v1 |
https://dashecorp.com/rig-sandbox/ |
rig-toggle toggle-one |
toggle one #toggle-one-b → #toggle-one-a |
The sandbox pair are each other's inverse (why).
Then the live page's pre-state, before anything is stored (rc#2253). One uncached GET of that template's page,
<PageUrl>?rig_propose=<requestId> (the homepage for v1, /rig-sandbox/ for the pair), through the same probe the status route
uses, and RefusePreState judges what it read against the template (why):
| The template's anchor on its page | HTTP | Body |
|---|---|---|
the template's from href (± one trailing slash) |
— | the propose goes ahead |
the template's to href |
422 | {"error":"intent_already_satisfied"} |
| anything else, or no single matching anchor | 422 | {"error":"pre_state_unexpected"} |
| unreadable | 503 | {"error":"pre_state_unreadable"} |
This is what gives verified its meaning. The terminal state is "the page serves the template's to href", so a task proposed
against a page that ALREADY serves it would report success having changed nothing — and no task could fail. "Nothing to do" is a
refusal, not a verified task. pre_state_unexpected catches the other end: the page is at neither state, so the line the
template replaces is not there to replace and Dev-E has nothing to change either. Since its one real run merged on 2026-09-21 the
shipped template has been in the FIRST of those two — intent_already_satisfied, because the page serves exactly its to href —
and that one is recoverable: the gate reads the served page, not the repository, so putting the line back re-arms the template with
no conductor change. Unreadable fails closed — a task proposed against a page nobody could read cannot be verified against
that page later. A propose REPLAY whose page changed in between is refused rather than answered from the store; the refusal is
then the true answer, and the stored proposal expires on its own.
The route then calls ProposeAsync with the resolved template and expiresAt = now + 2 h, and the write
finishes even if the caller disconnects. The store proposes only the registry's own entry. A look-alike template, meaning
a registered id with other anchors, is Forbidden and nothing is written. A replay is Existing only for the same
template, version and digest.
| Store outcome | HTTP | Body |
|---|---|---|
| Committed | 200 | {"state":"Proposed","revision":1,"expiresAt":<ms>} |
| Existing (same owner and intent) | 200 | the stored state, revision and expiresAt, so a replay never moves the expiry |
| NotFound (the id belongs to another owner or intent) | 409 | {"error":"request_conflict"} |
| Forbidden (the repo moved away from the resolved tenant) | 503 | {"error":"tenant_unavailable","repoTenant":…} |
| Uncertain, or anything else | 503 | {"error":"store_uncertain"}. The write may have committed, so read the status. |
POST /api/cockpit/task/confirm¶
The body is {"requestId","templateVersion","intentDigest","expectedRevision"}, with the revision the operator saw. A
templateVersion above int.MaxValue is 401 invalid_body. Then, with no request token (the writes and the POST finish if the
Worker goes away):
ConfirmAsync. Proposed atexpectedRevisionbecomes Approved. A replay of an Approved task (a confirm cut short) goes on to step 2.TryBeginAttemptAsyncat the approved revision, with a new attempt id. The id is a UUIDv7 stamped with the request time, so the stored attempt carries its own start time. Only a result whereAuthorizesIssueCreate(attemptId)is true goes on. An Uncertain claim never POSTs.- One POST of the fixed issue template (title plus body with
the
requestId/attemptIdmarker), as the dev-e-bot App. A throw counts as Ambiguous. - Created:
ReconcileAsyncwith one matching issue, so the task becomes Submitted. Ambiguous or Refused:MarkUnknownAsync, so it becomes OutcomeUnknown. It is never retried.
The body is {"outcome","state","revision","issueNumber"}, with the stored state:
| Case | HTTP | outcome |
|---|---|---|
| This call's POST returned the issue and the store recorded Submitted | 200 | created |
| The POST returned the issue but the Submitted write did not land or threw (the status reconciles it) | 202 | created |
| The task was already claimed (AttemptStarted, OutcomeUnknown or Submitted), or another confirm won the claim. No POST. | 200 | existing |
| This call's POST did not return an issue | 202 | unknown. Never re-confirm; read the status. |
After the POST, a store write that fails or throws never becomes 503 store_unavailable ("not done"): the POST may have
created the issue, so the answer is built from the claim (AttemptStarted, the claim's revision, and the issue number when the
POST returned one). The status route reconciles that claim once it is more than 60 s old.
Nothing is claimed or sent on an error: an unknown id or another owner's is the status route's 404 not_found; a stale
expectedRevision, another template version or digest, or a lost Approve race is 409 confirmation_conflict; an expired
proposal is 409 expired; a remapped repo is 503 tenant_unavailable; an Uncertain confirm or claim is 503 store_uncertain.
A confirm cut after step 1 (an Uncertain claim, a store fault, a restart) leaves the task Approved, shown as submitting.
Only an explicit confirm replay resumes it (step 1 answers Existing and step 2 claims); the status route never creates or
confirms (GET recovery never submits). The Worker must therefore offer and send that replay for an unexpired Approved task,
with expectedRevision at most the stored revision.
GET /api/cockpit/task/status¶
The request has an empty body, and the id and owner come from the signed context. The route reads the store, reconciles it (below), and for a Submitted task reads GitHub, the dashecorp.com probe and the Valkey hold (C11b, status reads). A GitHub, probe or Valkey fault answers the store stage, never 503.
Reconciliation (C10b). For an OutcomeUnknown task, or an AttemptStarted one whose attempt started more than 60 s ago (so its
POST is no longer in flight), the route calls ListSinceAsync once, never search, and at most once per 30 s per requestId
(OperatorTaskStatusMemo.TryBeginReconcile, the status memo's window): unknown is not terminal, since only a matching issue
can end it, so the card keeps polling a Refused create for as long as it is open, and each poll in between answers the store. The window is the last 30 minutes, widened
back to one minute before the attempt started, so a status read long after the attempt still sees its issue. Only issues the
dev-e-bot App filed (user.login dev-e-bot[bot]; a marker copied by anyone else is only text) whose body carries exactly one
well-formed marker for this requestId and attemptId count. An App issue that names the requestId with a damaged,
duplicated or other-attempt marker counts as a second match, so the answer is ambiguous:
- exactly one match: ReconcileAsync makes the task Submitted with that issue;
- none or several on AttemptStarted: the task becomes OutcomeUnknown;
- none or several on OutcomeUnknown, or an unreadable list: nothing is written.
A failed list or write is logged, and the status answers what the store read.
- 200, with these keys in this order. The first six are the store (plan m4):
stateis the exact enum name, andrevisionandexpiresAtare the stored values. Then the stage projector:stage(snake_case),detail,terminal(the Worker stops polling), and the parts, eachnulluntil read. Times are ISO 8601.
{"requestId":"<uuid>","state":"Submitted","revision":5,"expiresAt":<ms>,"hostingScope":"dashecorp","repoTenant":"invotek",
"stage":"approval_needed","detail":"Approve the release of 0123456","terminal":false,
"issue":{"number":201,"url":"https://github.com/dashecorp/dashe-website/issues/201","labels":["tier:t1","agent-ready"],"planned":true},
"pr":{"number":202,"url":"https://github.com/dashecorp/dashe-website/pull/202","headSha":"<40 hex>","state":"open","merged":false,"mergeSha":null,
"reviewApprovedAt":"<iso>"},
"release":{"heldSha":"<40 hex>","heldAt":"<iso>","approvedSha":null,"approvedAt":null},
"deploy":null,"verify":null,
"timeline":[{"stage":"proposed","at":"<iso>","ref":null},{"stage":"queued","at":"<iso>","ref":"issue#201"}]}
pr.state is open or closed (with merged). pr.reviewApprovedAt is when review-e-bot approved exactly headSha (its
latest verdict), else null; approval_needed and releasing require it. release.approvedSha and approvedAt are set
only for an approval whose MAC verifies under the task key, so a value written straight into Valkey never reads as
releasing. deploy is {runUrl, status, conclusion, source}: source is actions when
the deploy.yml run succeeded, and probe when only the live anchor proved the deploy. verify is {ok, href, checkedAt} — from the
task's own recorded delivery once it has one (rc#2271: the FIRST read that projects
verified writes that record to the task's stream, which is why the reply carries one more revision than the read before it),
otherwise from the last probe; ok means the page served the task template's expected href (v1: https://reward-e.com with or without /). A
delivered task is never probed again, in this process or any later one, so checkedAt keeps naming the read that saw it. The Proposed time in the timeline is
expiresAt minus the 2 h the propose route gives.
- 404 {"error":"not_found","hostingScope":"dashecorp","repoTenant":"invotek"}, for an unknown id or a task that another
operator or conversation owns. This is the health signal: the Worker signs a status for a random UUID, and a 404 means
the transport, key, tenant and store all answered.
POST /api/cockpit/task/release¶
The body is {"requestId","prNumber","headSha"} in that order, and the operator approves exactly that head. After the four
shared steps, the route checks, in order:
prNumberfits a GitHub PR number. The codec accepts any positive safe integer, so a value aboveint.MaxValueis 401invalid_body, like any other invalid body.- The task is Submitted with an issue number. An unknown task, or one another owner holds, is the status route's 404
not_found. Any other state is 409not_submitted. - The PR, read with
GITHUB_TOKEN(the token MergeGate uses), must be open, based onmain, link the task issue with a closing keyword (OperatorReleaseHoldPolicy.LinkedIssueNumbers), and havehead.sha == headSha. Otherwise it is 409pr_not_open,pr_wrong_base,pr_not_linkedorhead_moved. A PR that GitHub answers 404 for is 409pr_not_found. Any other failed read is 502{"error":"pr_unavailable","status":<GitHub status or null>}. MergeGate.ReleaseHeldAsync(repo, pr, headSha, requestId)(hold details). Its first step requiresrelease-hold.sha == headSha, and it writes nothing when they differ. ThatHeld("not_held")is this route's 409not_held. It then requires review-e-bot's latest verdict to approveheadShaand again writes nothing otherwise: 409review_not_at_head, or 502 with reasonreview_lookup_failedwhen the reviews cannot be read. The call takes no request token, so an abandoned request still finishes the merge.
ReleaseHeldAsync outcome |
HTTP | Body |
|---|---|---|
Merged |
200 | {"merged":true,"mergeSha":"<squash sha or null>"} |
Held("not_held") |
409 | {"error":"not_held"} |
Held("review_not_at_head") |
409 | {"error":"review_not_at_head"} |
Deferred (a guard reason: a check-run reason, review_not_at_head or review_lookup_failed (rc#2053); or pr_not_open, head_moved_during_merge, merging or blocked), or any other Held |
202 | {"merged":false,"reason":"<reason>"}. release-approval stays, so the next check_run success completes the merge, or the status route retries it after 2 min. |
Failed, or ReleaseHeldAsync threw (merge_exception) |
502 | {"error":"merge_failed","status":<GitHub status or null>,"reason":"<reason>"}. An approval already recorded stays, and the status route retries it after 2 min. |
With LOCATION set and the repo's tenant fenced at this location, Merge() returns Deferred("location_fenced: <reason>")
before its guard, so this route answers 202 with that reason and nothing is merged here. See
multi-location fencing (rig-conductor#2561).
MergeGate, not the route, posts the audit comment Operator release approved in Rig Cockpit for <headSha> (request
<requestId>) on the PR, after every merge pinned to an operator approval: this route's 200, or a later merge that completes
its 202 (the next check_run, or the status route's retry), so acceptance step 13's comment exists either way. It is posted
once per merged SHA (SET NX release-audit:{repo}#{pr}:{sha}, 7 d), names the request the verified approval names (its MAC
covers the request id this route signed, so an edited issue marker cannot change it), and goes out as the dev-e-bot App installation that DispatchTenantPolicy maps for the repo
tenant (the default install for invotek). It never falls back to the PAT. A comment that fails is logged, and the merge
stands. The comment runs off the request path, like the duplicate-PR close: the route answers Merged without waiting for the
App token mint and the POST, so a slow GitHub there cannot turn a real merge into the Worker's 30 s release timeout. The
comment can therefore land a moment after the 200. The PR read and the comment live in CockpitTaskGitHub.
The e2e host copies the unencoded path into RawTarget, which TestHost never sets. SignedTaskRequestReaderTests covers Kestrel.