Skip to content

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.

  1. 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 as signature_mismatch, stale_timestamp or unsupported_route). A missing, malformed or public-fixture key is 503 not_configured. Only SignedTaskRequestReader reads the body.
  2. Repo tenant. repoTenant is OperatorTaskTenancy.RepoTenant of the resolver's answer for dashecorp/dashe-website. This is the webhook's coalesce: an unresolved repo is tenant-0 invotek, 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):

  1. ConfirmAsync. Proposed at expectedRevision becomes Approved. A replay of an Approved task (a confirm cut short) goes on to step 2.
  2. TryBeginAttemptAsync at 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 where AuthorizesIssueCreate(attemptId) is true goes on. An Uncertain claim never POSTs.
  3. One POST of the fixed issue template (title plus body with the requestId/attemptId marker), as the dev-e-bot App. A throw counts as Ambiguous.
  4. Created: ReconcileAsync with 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): state is the exact enum name, and revision and expiresAt are the stored values. Then the stage projector: stage (snake_case), detail, terminal (the Worker stops polling), and the parts, each null until 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:

  1. prNumber fits a GitHub PR number. The codec accepts any positive safe integer, so a value above int.MaxValue is 401 invalid_body, like any other invalid body.
  2. 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 409 not_submitted.
  3. The PR, read with GITHUB_TOKEN (the token MergeGate uses), must be open, based on main, link the task issue with a closing keyword (OperatorReleaseHoldPolicy.LinkedIssueNumbers), and have head.sha == headSha. Otherwise it is 409 pr_not_open, pr_wrong_base, pr_not_linked or head_moved. A PR that GitHub answers 404 for is 409 pr_not_found. Any other failed read is 502 {"error":"pr_unavailable","status":<GitHub status or null>}.
  4. MergeGate.ReleaseHeldAsync(repo, pr, headSha, requestId) (hold details). Its first step requires release-hold.sha == headSha, and it writes nothing when they differ. That Held("not_held") is this route's 409 not_held. It then requires review-e-bot's latest verdict to approve headSha and again writes nothing otherwise: 409 review_not_at_head, or 502 with reason review_lookup_failed when 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.