loopctl-mcp-server 2.100.0 → 2.101.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/index.js +50 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -507,8 +507,8 @@ Enroll the dev machines that run the agent delivery loop, see which are connecte
507
507
  | `runner_enroll` | Enroll this machine as a runner (`POST /api/v1/runners`). Required: `name` (the machine name the runner declares when it joins) and `token_file` (absolute, or starting with `~/`). Optional: `max_sessions` (1..64, default 2) — the CEILING on how many dispatches loopctl will ever keep in flight on this machine at once, and the value it starts at. Since runner contract 1.13.0 every join re-applies the machine's own declared `max_sessions` bounded by this one, so a machine may take itself down below its grant and cannot raise itself above it: to carry FEWER sessions change `control.max_sessions` in the runner's own config and reconnect, to carry MORE `runner_revoke` it first and then enrol it again (nothing widens the ceiling in place, and enrolling the same name while its runner is active is a 422; the revoke invalidates the credential, so the new token file must reach the machine and the runner be restarted). The tenant's total across its runners is capped separately by the server. The credential is written to `token_file` with mode 0600 and is **never returned**: the result is only `{ runner: {id, name, inserted_at}, token_file }`, because a tool result lands in the transcript and the token lets its holder join as that machine. The file is created exclusively, so an existing path (or a symlink there) is refused before anything is enrolled; missing parent directories are created 0700. If the token cannot be written after enrollment, the runner is revoked before the error returns. Only a 4xx is treated as a refusal: any other failure (timeout, 5xx, a 2xx that did not parse) may have enrolled the runner, so its response body is never echoed, the runner is revoked when the response proves its id, and otherwise the error points at `runner_list` and `runner_revoke`. 422 (name malformed or taken) and 403 (`custody_tier_required`, `api_key_mint_forbidden`) pass through with their code. |
508
508
  | `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
509
509
  | `runner_revoke` | Revoke a runner (`DELETE /api/v1/runners/:id`): its credential stops authenticating and its live socket is disconnected. The undo for `runner_enroll`. Idempotent. Required: `id`. |
510
- | `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`. Presence converges only within a cluster. |
511
- | `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. 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), `branch`, `base_branch`, `wall_clock_seconds`, `max_turns`. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
510
+ | `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`. Presence converges only within a cluster. |
511
+ | `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. 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. 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 `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
512
512
  | `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`. |
513
513
  | `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`. |
514
514
  | `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`. **It frees the STAGE; it does not make the story placeable.** `place_dispatch` wants `contracted` + `queued` (see its own row in this table), and the release leaves the story at `pending`, whose only transition is `pending -> contracted` — so running `place_dispatch` next answers the identical 409 `invalid_transition`. The remedy is three calls, in this order: `force_unclaim_story`, then `contract_story`, then `place_dispatch`. 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`. Safe to run twice: on an already-pending story the stage row is written only if it is stranded behind the story's claim epoch or still in flight. No request body. Required: `story_id` (refused locally if it is not a UUID). |
package/index.js CHANGED
@@ -7948,6 +7948,12 @@ const TOOLS = [
7948
7948
  "write has not landed and the row keeps an older, larger number. A reported_max_sessions " +
7949
7949
  "of 0 means the machine is taking no work and place_dispatch refuses a new placement on " +
7950
7950
  "it, like draining. " +
7951
+ "branch_prefixes is what the machine declared it ACCEPTS as a branch-name prefix (runner " +
7952
+ "contract 1.14.0): loopctl derives a branch starting with the FIRST entry, and [] means " +
7953
+ "the machine declared no constraint. Read it when place_dispatch refuses 409 " +
7954
+ "no_conforming_branch, and when a machine refuses dispatches with branch_not_allowed — a " +
7955
+ "runner that ENFORCES a prefix and declares none shows [] here, which is that " +
7956
+ "misconfiguration made visible. Per-CONNECTION, like kinds. " +
7951
7957
  "live_sockets " +
7952
7958
  "above 1 means more than one process holds that runner's credential. A killed runner " +
7953
7959
  "disappears once its socket closes. Presence converges only " +
@@ -7981,7 +7987,26 @@ const TOOLS = [
7981
7987
  "NEW dispatch_id. Read `draining` and `reported_max_sessions` from runner_pool before " +
7982
7988
  "choosing a machine. It refuses a NEW placement only: retrying with a dispatch_id " +
7983
7989
  "loopctl already holds still re-sends that dispatch, because its claim is already " +
7984
- "standing and a draining machine is asked to finish what it holds, not to take more.",
7990
+ "standing and a draining machine is asked to finish what it holds, not to take more.\n\n" +
7991
+ "409 `no_conforming_branch` means the machine declares `branch_prefixes` (runner " +
7992
+ "contract 1.14.0) and none of them can produce a valid branch name carrying the story " +
7993
+ "number and id fragment that keeps two stories off one branch. NOTHING WAS CLAIMED. " +
7994
+ "The body echoes the declared prefixes; the fix is on that machine's own configuration, " +
7995
+ "not in this call. 422 `branch_not_allowed` is the other half: you passed a `branch` " +
7996
+ "outside those prefixes, so the machine would have refused the dispatch — omit it and " +
7997
+ "loopctl derives a conforming one. 422 `invalid_branch_name` is a different refusal " +
7998
+ "again: a ref field you passed — `branch` OR `base_branch`, which reaches git on the " +
7999
+ "runner just as `branch` does — is not a string, or is not a valid git ref name, so no " +
8000
+ "machine could create it; nothing was claimed and the remedy is in this call. 422 " +
8001
+ "`branch_not_unique` means the `branch` you passed does not end with this story's own " +
8002
+ "suffix, so two stories on one repository could share a branch and the second session " +
8003
+ "would find the first's work there: keep your prefix and append the suffix the error " +
8004
+ "names, or omit `branch`. 422 `branch_conflict` means a retry named a different branch " +
8005
+ "from the one this dispatch was already sent on — the branch is recorded at the first " +
8006
+ "push and re-sent verbatim, because a session may be running on it right now. Neither " +
8007
+ "prefix refusal can be raised by a RETRY carrying a dispatch_id loopctl already " +
8008
+ "holds: its claim is already standing, so a declaration that changed under you only " +
8009
+ "steers the name and never strands the story.",
7985
8010
  inputSchema: {
7986
8011
  type: "object",
7987
8012
  properties: {
@@ -8017,8 +8042,30 @@ const TOOLS = [
8017
8042
  "project bound to NO intake source (409 `no_intake_source`) or to TWO (409 " +
8018
8043
  "`ambiguous_intake_source`) — both refusals name these two parameters.",
8019
8044
  },
8020
- branch: { type: "string", description: "Optional. The branch the session works on." },
8021
- base_branch: { type: "string", description: "Optional. The branch it is cut from." },
8045
+ branch: {
8046
+ type: "string",
8047
+ description:
8048
+ "Optional, and you almost certainly should NOT pass it. loopctl derives the " +
8049
+ "branch from the story number and an id fragment, behind a prefix the TARGET " +
8050
+ "RUNNER declared it accepts on join (`branch_prefixes`, runner contract 1.14.0) " +
8051
+ "— a per-machine fact only the server can read, and the reason this is derived " +
8052
+ "rather than named. A branch you pass is never rewritten, so one outside that " +
8053
+ "machine's prefixes is refused 422 `branch_not_allowed`, one that is not a valid " +
8054
+ "git ref name (or is not a string at all, such as null) is refused 422 " +
8055
+ "`invalid_branch_name`, and one that does not END WITH THIS STORY'S OWN SUFFIX " +
8056
+ "is refused 422 `branch_not_unique` — you may choose the prefix, you may not " +
8057
+ "drop the suffix, which is what keeps two stories on one repository off one " +
8058
+ "branch. Nothing is claimed on any of them. runner_pool shows each machine's " +
8059
+ "`branch_prefixes`.",
8060
+ },
8061
+ base_branch: {
8062
+ type: "string",
8063
+ description:
8064
+ "Optional. The ref the session cuts FROM; defaults to the project's intake " +
8065
+ "source. Judged as a git ref name exactly as `branch` is (422 " +
8066
+ "`invalid_branch_name`, nothing claimed), but NOT story-unique — every dispatch " +
8067
+ "cutting from master is the normal case.",
8068
+ },
8022
8069
  wall_clock_seconds: { type: "integer", description: "Optional. Overrides the default." },
8023
8070
  max_turns: { type: "integer", description: "Optional. Overrides the default." },
8024
8071
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.100.0",
3
+ "version": "2.101.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",