loopctl-mcp-server 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/index.js +48 -0
- package/lib/change-bindings.js +47 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -527,6 +527,7 @@ The order to wire it up, the stage machine these tools move a story through, and
|
|
|
527
527
|
| `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `draining`, the latest `sample`, `live_sockets` (above 1 means more than one process holds the credential), and the `node` and `machine_id` (Fly Machine) holding the socket. `in_flight` and `max_sessions` are the capacity loopctl holds in Postgres — what dispatch reserves against — and are null only for a runner revoked while its socket drains; `reported_in_flight` and `reported_max_sessions` are the runner's own last report: `reported_in_flight` is a hint (the runner counts sessions, loopctl counts reservations), while `reported_max_sessions` is what `max_sessions` is copied from on every join (contract 1.13.0), capped at `enrolled_max_sessions` which is also returned. The two can differ in EITHER direction. `max_sessions` BELOW it happens when the machine is declaring above its enrolled ceiling (`enrolled_max_sessions` is what makes that one visible in the payload; the rest are not), when the declaration write has not LANDED — it can fail under lock contention on the runner row and is retried on that socket's 30-second recheck, downward only — or when the socket joined a node older than contract 1.13.0 and has not reconnected since. `max_sessions` ABOVE it happens when the machine declared `0`, held as `1` because the column is 1..64 while this field shows the raw `0`, and again when a declaration write has not landed and the row keeps an older, larger number. A `reported_max_sessions` of 0 means the machine is taking no work and `place_dispatch` refuses a new placement on it, like `draining`. `branch_prefixes` is what the machine declared it ACCEPTS as a branch-name prefix (runner contract 1.14.0): loopctl derives a branch starting with the FIRST entry, and `[]` means it declared no constraint. Read it when `place_dispatch` refuses 409 `no_conforming_branch`, and when a connected machine refuses every dispatch with `branch_not_allowed` — a runner that ENFORCES a prefix and declares none shows `[]` here, which is that misconfiguration made visible. Per-CONNECTION, like `kinds`. `usage_exhausted_until` (runner contract 1.17.0) is until when the machine's SUBSCRIPTION is exhausted, or null: while it is in the future nothing is placed on it and `place_dispatch` refuses 409 `runner_exhausted`. It is the EFFECTIVE value, the latest of the machine's own and every machine's sharing its account, so a machine that never reported anything can show one; capacity returns at that instant on its own. Presence converges only within a cluster. |
|
|
528
528
|
| `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. 409 `runner_declines_work` when the machine declares `draining` or `max_sessions` 0 — refused before the mint, so nothing was claimed; a retry carrying a `dispatch_id` loopctl already holds is re-sent anyway, because its claim is already standing. 409 `runner_exhausted` when the machine's subscription (its own, or one it shares an account with) is exhausted — also refused before the mint, and the body carries `usage_exhausted_until`, when it clears on its own. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `base_branch`, `wall_clock_seconds`, `max_turns`, and `branch` — **which you should not send**. Since runner contract 1.14.0 loopctl DERIVES the branch from the story number and an id fragment, behind a prefix the target runner declared it accepts (`branch_prefixes`, visible on `runner_pool`), which is a per-machine fact only the server can read. A branch you name is never rewritten, only judged, and it must satisfy three things: it must be a valid git ref name (422 `invalid_branch_name`, which also refuses a non-string such as `null`), it must start with one of that machine's declared prefixes (422 `branch_not_allowed`), and it must END WITH THIS STORY'S OWN SUFFIX (422 `branch_not_unique`) so that two stories on one repository can never be given one branch — you may choose the prefix, you may not drop the suffix. Nothing is claimed on any of them, and the fix is to drop the field. `base_branch` is judged as a git ref name too (422 `invalid_branch_name`): it reaches git on the runner just as `branch` does. It is NOT story-unique — every dispatch cutting from `master` is the normal case. A retry naming a different `branch` from the one this dispatch was already sent on is 422 `branch_conflict`: the branch is recorded at the first push and re-sent verbatim, because a session may be running on it right now, and a retry naming a different `base_branch` from the recorded one is 422 `base_branch_conflict`, because the merge gate judges against the recorded base. 409 `no_conforming_branch` is the machine's problem rather than the request's: it declares prefixes and none of them can produce a valid branch name carrying the story number and id fragment that keeps two stories off one branch, so nothing was claimed and an operator has to fix `branch_prefixes` on that box — the body echoes the declared prefixes. A retry carrying a `dispatch_id` loopctl already holds is never refused for either prefix reason: its claim is already standing, so the declaration only steers the name. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `pending` or `contracted` (a pending one is contracted by the placement), its dependencies met, with its stage at `queued`. A retry also moves the claim's deadline to now + its `wall_clock_seconds` + `DISPATCH_LEASE_GRACE_SECONDS` (never earlier than the one already sent), and 409 `dispatch_claim_ended` means the claim that `dispatch_id` was placed under has ended — nothing was pushed or written; place again with a new `dispatch_id`. Requires `LOOPCTL_USER_KEY`. |
|
|
529
529
|
| `story_stage` | **Where a story is in the delivery machine** (`GET /api/v1/stories/:id/stage`): stage, `claim_epoch`, `lock_version`, `attempts`, the runner holding it, and the escalation reason when parked. `stage: null` means the loop has never touched it. This is how you watch a run, and the only way to see why a stage report was refused `stale_stage`. `escalation_reason` is untrusted session text. Required: `story_id`. |
|
|
530
|
+
| `change_binding_get` | **The chg changes bound to a story** (`GET /api/v1/stories/:id/changes`, loopctl US-47.7): every change a chg `submitted` event bound to the story, OLDEST FIRST so the current one is last, each with `repo`, `change_id`, `current`, `superseded_at`, `submitter_not_claimant`, `inserted_at`, and its APPLIED events in `event_seq` order (`event_seq`, `kind`, `revision`, `sha`, `occurred_at`, `applied_at`; never the event `data` payload). Bounded by the server, bounds in `meta` (`max_bindings`, `max_events_per_change`): the newest of each are kept, so the current binding is always listed, and `binding_count` / `applied_event_count` / `events_truncated` say when more exist. Any tenant key reads it (`role: :agent` with the hierarchy), so nothing is pinned: `LOOPCTL_API_KEY` when set, else `LOOPCTL_AGENT_KEY`, else `LOOPCTL_ORCH_KEY`. 404 when the story is not your tenant's — another tenant's id answers exactly like an unknown one; 401 without a valid key; 400 for a superadmin key without `X-Impersonate-Tenant`; no role is refused. Required: `story_id` (refused locally if not a UUID). |
|
|
530
531
|
| `resolve_escalation` | **Move an escalated story off `escalated`, as a human** (`POST /api/v1/stories/:id/stage/resolve`): `to` is `queued` (work it again), `done` or `failed`. The other half of `escalate_story` — without it a parked story stays parked for ever. Requires `LOOPCTL_USER_KEY` on a key no dispatch minted: a session cannot resolve the escalation it raised. Optional: `reason`. |
|
|
531
532
|
| `force_unclaim_story` | **Take a story back from the agent holding it** (`POST /api/v1/stories/:id/force-unclaim`). Resets it to `agent_status: pending` with `assigned_agent_id` cleared AND makes the delivery stage row follow the release back to `queued`. **A delivery story then goes to `escalated`, not back to the queue** (loopctl US-44.4): an operator taking a story back is a human decision, so a release that leaves the stage row at `queued` escalates it over `operator_released` in the same transaction, spending no attempt against the retry ceiling — it is not left in the queue behind the human's back. Put it back to work with `resolve_escalation` `to: queued`, which releases (a no-op by then) AND re-contracts. A story with no delivery stage row is simply left `pending`. A story parked at `claimed` with nobody on it is the residue of a compensation that did not complete, not what a refused dispatch normally leaves: placement releases the claim inline when a runner refuses, and the claim lease releases it unattended once `claimed_until` passes — reach for this to get it back now, or when both of those left it held. Requeues from any stage a claim holds; a stage no claim holds keeps its stage and is rebound to the new epoch; `done` and `failed` are untouched. For an ESCALATED story use `resolve_escalation` instead — that one releases AND re-contracts, so its `queued` really is placeable. Requires an orchestrator-ROLE key: the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like any other non-member. Put it in `LOOPCTL_ORCH_KEY` (which is then the key sent, and a global `LOOPCTL_API_KEY` does not displace it) or, with no orchestrator key set, in `LOOPCTL_API_KEY`. The orchestrator key must be linked to a registered agent (400 otherwise) and the tenant must be human-anchored (403 `custody_tier_required` otherwise). Does not touch `verified_status`. Run again on an already-pending story it is still an operator's release: a row stranded behind the story's claim epoch is rebound, an in-flight row is requeued, and a row then at `queued` — including one already sitting there — is escalated over `operator_released`, as on the first run; a row already escalated, or anywhere else at that epoch, is left alone. Every 500 means the whole release rolled back and the story is still claimed: `audit_chain_append_failed` (the escalation's chain entry was refused) is a server-side condition a re-run meets again until an operator acts; `force_unclaim_failed` (the server log names the step) — call it again. A 422 means the release write itself was rejected. No request body. Required: `story_id` (refused locally if it is not a UUID). |
|
|
532
533
|
| `merge_precondition` | **Run the merge gate over a story's real pull request** (`POST /api/v1/stories/:id/merge-precondition`). Both delivery gates run again over the diff that exists, plus custody, the 12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy exclusion; the story must be at stage `ci`. **Gate A reads what triage persisted, never the caller**: the lens verdicts recorded with the story's most recent triage, or a human's re-queue of a Gate A escalation — `gate_a_inputs` on the answer says which, and `missing` refuses with `gate_a_inputs_missing`. This tool sends no trio. Decisions: `allow` (recorded against the head; the only thing that licenses a merge), `refuse` (the story is escalated before this returns), `already_merged`, `head_moved`, `unevaluated` (503 with `Retry-After`). Requires an orchestrator- or user-ROLE key (`exact_role: [:orchestrator, :user]`, so an agent key is 403'd); `LOOPCTL_ORCH_KEY` is sent when set, else `LOOPCTL_API_KEY`. 403 `custody_tier_required` without a human anchor, 404 for an unknown story, 409 `change_protocol_enrolled` when the story's intake source is in mode `chg` (the PR route is closed; land with `chg submit` — decided before any GitHub read, nothing escalated), 422 when the story is not at `ci`. Required: `story_id` (refused locally if not a UUID) and `claim_epoch` (a non-negative integer, refused locally otherwise); optional `effect_proof`. |
|
package/index.js
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
} from "./lib/intake-sources.js";
|
|
71
71
|
import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
|
|
72
72
|
import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
|
|
73
|
+
import { changeBindingGet as changeBindingGetRequest } from "./lib/change-bindings.js";
|
|
73
74
|
import {
|
|
74
75
|
clearGithubCredential as clearGithubCredentialRequest,
|
|
75
76
|
githubCredential as githubCredentialRequest,
|
|
@@ -3432,6 +3433,22 @@ async function storyStage(args) {
|
|
|
3432
3433
|
);
|
|
3433
3434
|
}
|
|
3434
3435
|
|
|
3436
|
+
// US-47.7: a READ, `role: :agent` with the hierarchy, so any role's key clears the gate and
|
|
3437
|
+
// nothing is pinned. LOOPCTL_AGENT_KEY is passed as this tool's own key for the reason
|
|
3438
|
+
// storyStage gives above — `resolveKey` never reads it by name, and a session on an
|
|
3439
|
+
// agent-key-only config is exactly the one asking which change its story is bound to. It is an
|
|
3440
|
+
// OVERRIDE, not `exactKey`, so `resolveKey`'s order holds: LOOPCTL_API_KEY when set, else
|
|
3441
|
+
// LOOPCTL_AGENT_KEY, else LOOPCTL_ORCH_KEY.
|
|
3442
|
+
async function changeBindingGet(args) {
|
|
3443
|
+
const key = process.env.LOOPCTL_AGENT_KEY;
|
|
3444
|
+
|
|
3445
|
+
return toContent(
|
|
3446
|
+
await changeBindingGetRequest(args, {
|
|
3447
|
+
apiCall: (method, path, body) => apiCall(method, path, body, key),
|
|
3448
|
+
}),
|
|
3449
|
+
);
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3435
3452
|
async function resolveEscalation(args) {
|
|
3436
3453
|
return toContent(await resolveEscalationRequest(args, deliveryDeps()));
|
|
3437
3454
|
}
|
|
@@ -8614,6 +8631,34 @@ const TOOLS = [
|
|
|
8614
8631
|
required: ["story_id"],
|
|
8615
8632
|
},
|
|
8616
8633
|
},
|
|
8634
|
+
{
|
|
8635
|
+
name: "change_binding_get",
|
|
8636
|
+
description:
|
|
8637
|
+
"THE CHG CHANGES BOUND TO A STORY (GET /api/v1/stories/:id/changes, loopctl US-47.7): " +
|
|
8638
|
+
"every change a `submitted` chg event bound to the story, OLDEST FIRST so the current " +
|
|
8639
|
+
"one is last, each with `repo`, `change_id`, `current`, `superseded_at`, " +
|
|
8640
|
+
"`submitter_not_claimant` and `inserted_at`, plus that change's APPLIED events in " +
|
|
8641
|
+
"`event_seq` order (`event_seq`, `kind`, `revision`, `sha`, `occurred_at`, " +
|
|
8642
|
+
"`applied_at`). An event held ahead of a gap is not listed, and the event `data` " +
|
|
8643
|
+
"payload is never returned.\n\n" +
|
|
8644
|
+
"BOUNDED by the server, and the bounds are in `meta`: at most `max_bindings` bindings " +
|
|
8645
|
+
"and `max_events_per_change` events per change, the NEWEST of each, so the current " +
|
|
8646
|
+
"binding is always listed. `binding_count` and each binding's `applied_event_count` / " +
|
|
8647
|
+
"`events_truncated` say when more exist.\n\n" +
|
|
8648
|
+
"Any key of the tenant may read it (`role: :agent` with the hierarchy), so the key is " +
|
|
8649
|
+
"the usual order with LOOPCTL_AGENT_KEY as this tool's own: LOOPCTL_API_KEY when set, " +
|
|
8650
|
+
"else LOOPCTL_AGENT_KEY, else LOOPCTL_ORCH_KEY. REFUSES: 404 when the story " +
|
|
8651
|
+
"is not your tenant's — another tenant's id answers exactly like an unknown one; 401 " +
|
|
8652
|
+
"for no key or one that does not authenticate; 400 for a superadmin key without " +
|
|
8653
|
+
"X-Impersonate-Tenant; 429 when rate limited. No 403: every key role clears the " +
|
|
8654
|
+
"`role: :agent` gate, so none is refused for its role. A malformed " +
|
|
8655
|
+
"`story_id` is refused locally (status 0, nothing sent).",
|
|
8656
|
+
inputSchema: {
|
|
8657
|
+
type: "object",
|
|
8658
|
+
properties: { story_id: { type: "string", description: "The story UUID." } },
|
|
8659
|
+
required: ["story_id"],
|
|
8660
|
+
},
|
|
8661
|
+
},
|
|
8617
8662
|
{
|
|
8618
8663
|
name: "resolve_escalation",
|
|
8619
8664
|
description:
|
|
@@ -10145,6 +10190,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
10145
10190
|
case "story_stage":
|
|
10146
10191
|
return await storyStage(args);
|
|
10147
10192
|
|
|
10193
|
+
case "change_binding_get":
|
|
10194
|
+
return await changeBindingGet(args);
|
|
10195
|
+
|
|
10148
10196
|
case "resolve_escalation":
|
|
10149
10197
|
return await resolveEscalation(args);
|
|
10150
10198
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `change_binding_get` — `GET /api/v1/stories/:id/changes` (loopctl US-47.7, Epic 47).
|
|
3
|
+
*
|
|
4
|
+
* A story in a `chg`-mode repository is landed by the change protocol: chg-server delivers
|
|
5
|
+
* the change's events to loopctl, and a `submitted` event binds the change to the story. This
|
|
6
|
+
* is the read of that join — the session orchestrating the loop holds a story id and needs to
|
|
7
|
+
* see which change implements it and how far that change has got.
|
|
8
|
+
*
|
|
9
|
+
* ## What the endpoint answers
|
|
10
|
+
*
|
|
11
|
+
* The story's bindings OLDEST FIRST (the current one is last), each with `repo`,
|
|
12
|
+
* `change_id`, `current`, `superseded_at`, `submitter_not_claimant`, `inserted_at`, and its
|
|
13
|
+
* change's APPLIED events in `event_seq` order (`event_seq`, `kind`, `revision`, `sha`,
|
|
14
|
+
* `occurred_at`, `applied_at`). Both lists are bounded by the server and the bounds are in the
|
|
15
|
+
* response's `meta`: a truncated list keeps the NEWEST entries, `binding_count` and each
|
|
16
|
+
* binding's `applied_event_count` / `events_truncated` say when more exist. The event `data`
|
|
17
|
+
* payload is not returned.
|
|
18
|
+
*
|
|
19
|
+
* ## Refusals
|
|
20
|
+
*
|
|
21
|
+
* - `404` — the story is not the caller's tenant's. Another tenant's id answers EXACTLY like
|
|
22
|
+
* an id nobody issued; that is the isolation working, not a lookup failure to retry.
|
|
23
|
+
* - `401` — no key, or one that does not authenticate.
|
|
24
|
+
* - `400` — a superadmin key without `X-Impersonate-Tenant`.
|
|
25
|
+
* - `429` — rate limited.
|
|
26
|
+
*
|
|
27
|
+
* There is NO 403: the endpoint is `role: :agent` WITH the hierarchy, and every key role
|
|
28
|
+
* (agent, orchestrator, user, superadmin) is at least that, so no key is refused for its role.
|
|
29
|
+
*
|
|
30
|
+
* A malformed id is refused HERE with `status: 0` (no request sent), for the reason
|
|
31
|
+
* `delivery-loop.js`'s `uuid()` gives: the server would answer the same 404 as an unknown
|
|
32
|
+
* story, and the two have opposite remedies.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { uuid } from "./delivery-loop.js";
|
|
36
|
+
|
|
37
|
+
export function storyChangesPath(storyId) {
|
|
38
|
+
return `/api/v1/stories/${encodeURIComponent(storyId)}/changes`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `GET /api/v1/stories/:id/changes`: the story's chg change bindings and applied events. */
|
|
42
|
+
export async function changeBindingGet({ story_id } = {}, { apiCall } = {}) {
|
|
43
|
+
const bad = uuid(story_id, "story_id");
|
|
44
|
+
if (bad) return bad;
|
|
45
|
+
|
|
46
|
+
return apiCall("GET", storyChangesPath(story_id), null);
|
|
47
|
+
}
|