loopctl-mcp-server 2.92.1 → 2.94.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 +3 -2
- package/index.js +75 -2
- package/lib/escalation.js +69 -0
- package/lib/runners.js +19 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -220,6 +220,7 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
|
|
|
220
220
|
| `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. |
|
|
221
221
|
| `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. |
|
|
222
222
|
| `renew_story_claim` | Renew your claim's lease (`POST /stories/:id/renew-claim`, agent key). A claim not renewed before `claimed_until` (default 24 hours, `STORY_CLAIM_LEASE_SECONDS`) is released back to `pending` under you, so renew well inside it on any story held longer. Required: `story_id`, `claim_epoch` (from the claim). Refusals pass through: 400 epoch missing or malformed, 422 `not_claimed`, 409 `stale_claim_epoch` (the claim has ended: stop working it), 409 `not_claimant`. Needs a loopctl server with claim leases. |
|
|
223
|
+
| `escalate_story` | Park a story you hold for a HUMAN and stop (`POST /stories/:id/escalate`, agent key). This is what an unattended session calls INSTEAD of asking a question — a headless run has no way to ask, so escalating is the affordance. Use it when the request contradicts an existing story, KB decision or documented behaviour, when it inverts or removes behaviour a previous story deliberately added, when it is a workflow change rather than a defect fix, or when it needs a business call — not for an ordinary blocker. ONE-WAY from your side: only a human moves the story off `escalated`, so on success STOP and report. Required: `story_id`, `claim_epoch`, `reason` (verbatim, treated as untrusted data, length capped by the server in codepoints — a 400 names the bound); optional `payload` object. Idempotent under the same epoch. Refusals pass through: 409 `stale_claim_epoch`, 409 `not_claimant`, 404 `unknown_story_stage`, 400 for a malformed epoch or reason. An orchestrator or user key is 403'd on purpose. |
|
|
223
224
|
| `start_story` | Agent starts work on a claimed story. Transitions assigned -> implementing. |
|
|
224
225
|
| `request_review` | Agent signals implementation is complete and ready for review. |
|
|
225
226
|
|
|
@@ -471,10 +472,10 @@ Enroll the dev machines that run the agent delivery loop, and see which are conn
|
|
|
471
472
|
|
|
472
473
|
| Tool | Description |
|
|
473
474
|
|---|---|
|
|
474
|
-
| `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 `~/`). 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. |
|
|
475
|
+
| `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) — how many dispatches loopctl keeps in flight on this machine at once; 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. |
|
|
475
476
|
| `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
|
|
476
477
|
| `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`. |
|
|
477
|
-
| `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `
|
|
478
|
+
| `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, a hint. Presence converges only within a cluster. |
|
|
478
479
|
|
|
479
480
|
### Dispatch & Chain of Custody (v2) Tools
|
|
480
481
|
|
package/index.js
CHANGED
|
@@ -46,6 +46,7 @@ import { createHandoff } from "./lib/handoff.js";
|
|
|
46
46
|
import { readPayloadFile } from "./lib/payload-path.js";
|
|
47
47
|
import { enrollRunner, listRunners, revokeRunner, runnerPool } from "./lib/runners.js";
|
|
48
48
|
import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./lib/claim-lease.js";
|
|
49
|
+
import { escalateStory as escalateStoryRequest, escalationNotice } from "./lib/escalation.js";
|
|
49
50
|
|
|
50
51
|
// Single source of truth for the server version: the package.json this file
|
|
51
52
|
// ships with (npm always includes package.json in the published tarball).
|
|
@@ -1151,6 +1152,15 @@ async function renewStoryClaim(args) {
|
|
|
1151
1152
|
return withClaimLeaseNotice(await renewStoryClaimRequest(args, { apiCall }));
|
|
1152
1153
|
}
|
|
1153
1154
|
|
|
1155
|
+
// #803: escalation leads with a STOP line. A session that escalates and keeps working is
|
|
1156
|
+
// the failure the affordance exists to prevent, and the JSON body alone does not say so.
|
|
1157
|
+
async function escalateStory(args) {
|
|
1158
|
+
const result = await escalateStoryRequest(args, { apiCall });
|
|
1159
|
+
const notice = escalationNotice(result);
|
|
1160
|
+
if (!notice) return toContent(result);
|
|
1161
|
+
return { content: [{ type: "text", text: notice }, ...toContent(result).content] };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1154
1164
|
async function startStory({ story_id, capability }) {
|
|
1155
1165
|
// Cache first, then DELIVERY of the token claim already minted, then recovery
|
|
1156
1166
|
// (which mints a fresh one). Delivery also covers a legacy env-var key, whose
|
|
@@ -4085,6 +4095,56 @@ const TOOLS = [
|
|
|
4085
4095
|
required: ["story_id", "claim_epoch"],
|
|
4086
4096
|
},
|
|
4087
4097
|
},
|
|
4098
|
+
{
|
|
4099
|
+
name: "escalate_story",
|
|
4100
|
+
description:
|
|
4101
|
+
"Park a story you hold for a HUMAN to decide, and stop (POST /api/v1/stories/:id/escalate). " +
|
|
4102
|
+
"This is what you call instead of asking a question: an unattended session has no way to " +
|
|
4103
|
+
"ask, so escalating is the affordance. Use it when the request contradicts an existing " +
|
|
4104
|
+
"story, KB decision or documented behaviour; when it inverts or removes behaviour a " +
|
|
4105
|
+
"previous story deliberately added; when it is a workflow change rather than a defect fix; " +
|
|
4106
|
+
"or when it needs a business call. Do NOT use it for an ordinary blocker you can work " +
|
|
4107
|
+
"through. It is ONE-WAY from your side: only a human moves the story off `escalated`, so " +
|
|
4108
|
+
"after a success, stop working the story and report that you escalated and why. Uses the " +
|
|
4109
|
+
"AGENT key, the same key as claim_story; an orchestrator or user key is 403'd on purpose. " +
|
|
4110
|
+
"IDEMPOTENT: repeating it under the same claim_epoch returns the same stage row and writes " +
|
|
4111
|
+
"nothing twice. Refusals pass through: 409 stale_claim_epoch (the claim has ENDED, stop " +
|
|
4112
|
+
"working it), 409 not_claimant (your key's agent is not the story's), 404 " +
|
|
4113
|
+
"unknown_story_stage (the story is not in the delivery loop), 400 for a missing or " +
|
|
4114
|
+
"malformed claim_epoch or reason.",
|
|
4115
|
+
inputSchema: {
|
|
4116
|
+
type: "object",
|
|
4117
|
+
properties: {
|
|
4118
|
+
story_id: {
|
|
4119
|
+
type: "string",
|
|
4120
|
+
description: "The UUID of the story you hold.",
|
|
4121
|
+
},
|
|
4122
|
+
claim_epoch: {
|
|
4123
|
+
type: "integer",
|
|
4124
|
+
minimum: 0,
|
|
4125
|
+
description: "The claim_epoch your claim_story result returned.",
|
|
4126
|
+
},
|
|
4127
|
+
reason: {
|
|
4128
|
+
type: "string",
|
|
4129
|
+
minLength: 1,
|
|
4130
|
+
description:
|
|
4131
|
+
"What a human has to decide, in your own words. Recorded verbatim and treated as " +
|
|
4132
|
+
"untrusted data everywhere it is read. The SERVER caps its length, in codepoints " +
|
|
4133
|
+
"(what Postgres counts, not graphemes) — no bound is repeated here, because a " +
|
|
4134
|
+
"number baked into a published npm package goes stale against the server that " +
|
|
4135
|
+
"enforces it. Over the cap is a 400 naming the bound.",
|
|
4136
|
+
},
|
|
4137
|
+
payload: {
|
|
4138
|
+
type: "object",
|
|
4139
|
+
additionalProperties: true,
|
|
4140
|
+
description:
|
|
4141
|
+
"Optional structured detail — the story or KB article it contradicts, the two " +
|
|
4142
|
+
"options you are between. Recorded on the stage event; at most 8000 bytes encoded.",
|
|
4143
|
+
},
|
|
4144
|
+
},
|
|
4145
|
+
required: ["story_id", "claim_epoch", "reason"],
|
|
4146
|
+
},
|
|
4147
|
+
},
|
|
4088
4148
|
{
|
|
4089
4149
|
name: "start_story",
|
|
4090
4150
|
description:
|
|
@@ -7492,6 +7552,14 @@ const TOOLS = [
|
|
|
7492
7552
|
description:
|
|
7493
7553
|
"Absolute path, or one starting with ~/, for the token file. Must not exist yet.",
|
|
7494
7554
|
},
|
|
7555
|
+
max_sessions: {
|
|
7556
|
+
type: "integer",
|
|
7557
|
+
minimum: 1,
|
|
7558
|
+
maximum: 64,
|
|
7559
|
+
description:
|
|
7560
|
+
"How many dispatches loopctl keeps in flight on this machine at once (default 2). " +
|
|
7561
|
+
"The tenant's total across all its runners is capped separately by the server.",
|
|
7562
|
+
},
|
|
7495
7563
|
},
|
|
7496
7564
|
required: ["name", "token_file"],
|
|
7497
7565
|
},
|
|
@@ -7530,8 +7598,11 @@ const TOOLS = [
|
|
|
7530
7598
|
name: "runner_pool",
|
|
7531
7599
|
description:
|
|
7532
7600
|
"The tenant's CONNECTED runners, read from Presence (GET /api/v1/runners/pool): per machine " +
|
|
7533
|
-
"name, runner_id, joined_at,
|
|
7534
|
-
"
|
|
7601
|
+
"name, runner_id, joined_at, draining, the latest health sample, live_sockets, and the node " +
|
|
7602
|
+
"and machine_id (Fly Machine) holding the socket. in_flight and max_sessions are the " +
|
|
7603
|
+
"CAPACITY loopctl holds in Postgres — the slots dispatch reserves against — and are null " +
|
|
7604
|
+
"only for a runner revoked while its socket drains; reported_in_flight and " +
|
|
7605
|
+
"reported_max_sessions are what the runner itself last reported, a hint. live_sockets " +
|
|
7535
7606
|
"above 1 means more than one process holds that runner's credential. A killed runner " +
|
|
7536
7607
|
"disappears once its socket closes. Presence converges only " +
|
|
7537
7608
|
"within a cluster, so on an unclustered multi-node deployment a runner on another node is " +
|
|
@@ -8279,6 +8350,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
8279
8350
|
|
|
8280
8351
|
case "renew_story_claim":
|
|
8281
8352
|
return await renewStoryClaim(args);
|
|
8353
|
+
case "escalate_story":
|
|
8354
|
+
return await escalateStory(args);
|
|
8282
8355
|
|
|
8283
8356
|
case "start_story":
|
|
8284
8357
|
return await startStory(args);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story escalation (loopctl #803, design §8; MCP side under #809).
|
|
3
|
+
*
|
|
4
|
+
* `escalate_story` is the POSITIVE AFFORDANCE an unattended session has instead of a
|
|
5
|
+
* question. A headless run has no AskUserQuestion at all, and nothing fires when the model
|
|
6
|
+
* wanted to ask — no tool call is attempted, so no hook sees anything — which is why the
|
|
7
|
+
* session must be able to DO something rather than be detected wanting to.
|
|
8
|
+
*
|
|
9
|
+
* It is one-way from the session's side: the story parks at the `escalated` delivery stage
|
|
10
|
+
* and only a HUMAN principal moves it off. So the result leads with a line saying to STOP,
|
|
11
|
+
* because a session that escalates and keeps working is the failure this exists to prevent
|
|
12
|
+
* and a bare JSON body does not say so.
|
|
13
|
+
*
|
|
14
|
+
* SINGLE SOURCE OF TRUTH. index.js injects `apiCall`; the unit suite runs this code with a
|
|
15
|
+
* recording fake. The KEY is selected here, from the injected env, so the key an escalation
|
|
16
|
+
* travels on is testable behaviour rather than a source pattern — and it must be the AGENT
|
|
17
|
+
* key, because the endpoint is `exact_role: :agent`: the human key that RESOLVES an
|
|
18
|
+
* escalation is 403'd, which is the separation the route exists inside.
|
|
19
|
+
*
|
|
20
|
+
* Only `story_id` and a non-empty `reason` are validated client-side. Everything else is the
|
|
21
|
+
* server's judgement and passes through unchanged: 409 stale_claim_epoch (the claim ended —
|
|
22
|
+
* stop working it), 409 not_claimant, 404 unknown_story_stage, 400 for a malformed epoch.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function escalatePath(storyId) {
|
|
26
|
+
return `/api/v1/stories/${encodeURIComponent(storyId)}/escalate`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `POST /api/v1/stories/:id/escalate {claim_epoch, reason, payload?}` on the AGENT key.
|
|
31
|
+
* `claim_epoch` is forwarded exactly as given, and `payload` is omitted when absent rather
|
|
32
|
+
* than sent as null.
|
|
33
|
+
*/
|
|
34
|
+
export async function escalateStory(
|
|
35
|
+
{ story_id, claim_epoch, reason, payload } = {},
|
|
36
|
+
{ apiCall, env = process.env } = {},
|
|
37
|
+
) {
|
|
38
|
+
if (typeof story_id !== "string" || story_id.trim() === "") {
|
|
39
|
+
return { error: true, status: 0, body: "`story_id` is required." };
|
|
40
|
+
}
|
|
41
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
42
|
+
return {
|
|
43
|
+
error: true,
|
|
44
|
+
status: 0,
|
|
45
|
+
body: "`reason` is required: say what a human has to decide, in your own words.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = { claim_epoch, reason };
|
|
50
|
+
if (payload !== undefined && payload !== null) body.payload = payload;
|
|
51
|
+
|
|
52
|
+
return apiCall("POST", escalatePath(story_id), body, env.LOOPCTL_AGENT_KEY);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The line that leads a successful escalation, or null on a refusal. It says STOP, because
|
|
57
|
+
* the whole point of the call is that the session hands the decision over and does no more
|
|
58
|
+
* work on the story.
|
|
59
|
+
*/
|
|
60
|
+
export function escalationNotice(result) {
|
|
61
|
+
const stage = result && result.error !== true ? result.stage : null;
|
|
62
|
+
if (!stage || stage.stage !== "escalated") return null;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
"ESCALATED: this story is parked for a human and only a human moves it off. " +
|
|
66
|
+
"STOP working it now — do not implement, do not open a PR, do not pick the decision " +
|
|
67
|
+
"yourself. Report that you escalated and why."
|
|
68
|
+
);
|
|
69
|
+
}
|
package/lib/runners.js
CHANGED
|
@@ -65,12 +65,18 @@ export function expandHome(p, homedir = os.homedir()) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* `POST /api/v1/runners {name}`, with the returned token written to
|
|
69
|
-
* never returned. Resolves to
|
|
68
|
+
* `POST /api/v1/runners {name, max_sessions?}`, with the returned token written to
|
|
69
|
+
* `token_file` and never returned. Resolves to
|
|
70
|
+
* `{ runner: {id, name, max_sessions, inserted_at}, token_file }` or an
|
|
70
71
|
* `{ error: true, status, body }` shape. It never throws.
|
|
72
|
+
*
|
|
73
|
+
* `max_sessions` is how many dispatches loopctl will keep in flight on this machine at
|
|
74
|
+
* once (loopctl #803). It is sent ONLY when given, so the server's own default applies
|
|
75
|
+
* otherwise, and the server refuses one out of range with a 422 that passes straight
|
|
76
|
+
* through — this does not second-guess the bound.
|
|
71
77
|
*/
|
|
72
78
|
export async function enrollRunner(
|
|
73
|
-
{ name, token_file } = {},
|
|
79
|
+
{ name, token_file, max_sessions } = {},
|
|
74
80
|
{ userKey, apiCall, fs = defaultFs, homedir = os.homedir() } = {},
|
|
75
81
|
) {
|
|
76
82
|
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
@@ -115,7 +121,8 @@ export async function enrollRunner(
|
|
|
115
121
|
|
|
116
122
|
let result;
|
|
117
123
|
try {
|
|
118
|
-
|
|
124
|
+
const body = max_sessions === undefined ? { name } : { name, max_sessions };
|
|
125
|
+
result = await apiCall("POST", RUNNERS_PATH, body);
|
|
119
126
|
} catch {
|
|
120
127
|
result = { error: true, status: 0, body: "Enrollment request failed." };
|
|
121
128
|
}
|
|
@@ -153,8 +160,15 @@ export async function enrollRunner(
|
|
|
153
160
|
);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
// `max_sessions` only when the server sent one, so a server without loopctl #803 returns
|
|
164
|
+
// exactly the shape it always did rather than a key that is always undefined.
|
|
156
165
|
return {
|
|
157
|
-
runner: {
|
|
166
|
+
runner: {
|
|
167
|
+
id: runner.id,
|
|
168
|
+
name: runner.name,
|
|
169
|
+
...(runner.max_sessions === undefined ? {} : { max_sessions: runner.max_sessions }),
|
|
170
|
+
inserted_at: runner.inserted_at,
|
|
171
|
+
},
|
|
158
172
|
token_file: tokenPath,
|
|
159
173
|
};
|
|
160
174
|
}
|