loopctl-mcp-server 2.95.0 → 2.97.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 +37 -6
- package/index.js +143 -13
- package/lib/custody-key.js +112 -0
- package/lib/delivery-loop.js +163 -9
- package/lib/handoff.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,14 +67,44 @@ Or if installed locally:
|
|
|
67
67
|
| Variable | Description | Default |
|
|
68
68
|
|---|---|---|
|
|
69
69
|
| `LOOPCTL_SERVER` | loopctl server URL | `https://loopctl.com` |
|
|
70
|
-
| `LOOPCTL_API_KEY` | Global API key override
|
|
71
|
-
| `LOOPCTL_ORCH_KEY` | Orchestrator role API key (
|
|
70
|
+
| `LOOPCTL_API_KEY` | Global API key override: used for every tool except the exact-key ones below, and for the `exact_role: :orchestrator` custody verbs when `LOOPCTL_ORCH_KEY` is unset | -- |
|
|
71
|
+
| `LOOPCTL_ORCH_KEY` | Orchestrator role API key (report, review, import, and the `exact_role: :orchestrator` custody verbs `verify_story` / `reject_story` / `verify_all_in_epic` / `bulk_mark_complete` / `force_unclaim_story` — when it is set, it is the key those five send) | -- |
|
|
72
72
|
| `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
|
|
73
|
-
| `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` — see [First-time setup](#first-time-setup--provision-your-byo-llm-keys))
|
|
73
|
+
| `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` — see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)), for destructive admin tools like `knowledge_bulk_publish`, and for the `runner_*` tools plus `place_dispatch` and `resolve_escalation` in [Runner and delivery-loop tools](#runner-and-delivery-loop-tools). **Not for the whole of that section:** `story_stage` takes `LOOPCTL_AGENT_KEY`, and `force_unclaim_story` is `exact_role: :orchestrator`, where a user key is 403'd like any other non-member. | -- |
|
|
74
74
|
| `LOOPCTL_STH_STATE_PATH` | Absolute path for the witness-protocol STH cache file (see [Witness protocol](#witness-protocol-sth)). Optional. | per-(server + key) file under the OS temp dir |
|
|
75
75
|
|
|
76
76
|
Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
|
|
77
77
|
|
|
78
|
+
**The exception: tools pinned to an EXACT key.** Some endpoints are gated `exact_role:`,
|
|
79
|
+
where the role hierarchy does NOT apply — a higher-privileged key is refused there exactly
|
|
80
|
+
as a lower one is. For those tools the global override does not silently win over the
|
|
81
|
+
variable the tool names, so a key you set for a specific role is the key that gets sent:
|
|
82
|
+
|
|
83
|
+
| Tool | Key | When the override applies |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `verify_story`, `reject_story`, `verify_all_in_epic`, `bulk_mark_complete`, `force_unclaim_story` | `LOOPCTL_ORCH_KEY` (`exact_role: :orchestrator`) | Pinned **when `LOOPCTL_ORCH_KEY` is set**. With no orchestrator key, `LOOPCTL_API_KEY` is still sent — set it to an orchestrator-role key and these work. |
|
|
86
|
+
| `place_dispatch`, `resolve_escalation` | `LOOPCTL_USER_KEY` (an unlineaged human principal) | Always pinned; `LOOPCTL_API_KEY` is never consulted. |
|
|
87
|
+
| `set_llm_config`, `llm_config` and the other secret-managing tools | `LOOPCTL_USER_KEY` | Always pinned; a secret operation must not run under a non-user global key. |
|
|
88
|
+
|
|
89
|
+
Why pin at all: the priority above means `LOOPCTL_API_KEY` beats the variable a tool names,
|
|
90
|
+
so an operator who sets `LOOPCTL_ORCH_KEY` for the custody verbs and `LOOPCTL_API_KEY` for
|
|
91
|
+
everything else had the orchestrator key discarded on exactly the calls it was set for. It
|
|
92
|
+
is **not** because the resulting 403 is confusing — `LoopctlWeb.Plugs.RequireRole` runs
|
|
93
|
+
first and answers with `code: "insufficient_role"` and `required_roles: ["orchestrator"]`,
|
|
94
|
+
which names the problem correctly.
|
|
95
|
+
|
|
96
|
+
`report_story` and `review_complete` are NOT in this table: their gates take a role RANGE
|
|
97
|
+
(`exact_role: [:agent, :orchestrator]` and `[:orchestrator, :user]`), so the
|
|
98
|
+
`LOOPCTL_API_KEY` fallback is what makes the common agent configuration work — that is
|
|
99
|
+
`LOOPCTL_API_KEY` set to an agent key. **`LOOPCTL_AGENT_KEY` alone is not that configuration
|
|
100
|
+
for these two**: the priority above never consults `LOOPCTL_AGENT_KEY` by name, only a tool's
|
|
101
|
+
own key, and `report_story` and `review_complete` both name `LOOPCTL_ORCH_KEY` as theirs — so
|
|
102
|
+
with only `LOOPCTL_AGENT_KEY` set they answer "No API key configured" before reaching their
|
|
103
|
+
gate. Most agent-facing tools do name `LOOPCTL_AGENT_KEY` as their own key and so do work
|
|
104
|
+
under it alone; these two are the exception, and `story_stage` reads it explicitly for the
|
|
105
|
+
same reason. The conditional pin in the first row is that same reasoning applied to the
|
|
106
|
+
single-role gates.
|
|
107
|
+
|
|
78
108
|
## First-time setup — provision your BYO LLM keys
|
|
79
109
|
|
|
80
110
|
**loopctl is agent-native and strictly BYO (bring-your-own-keys).** loopctl fronts
|
|
@@ -466,9 +496,9 @@ through the same authenticated + witness/STH path as every static read tool. If
|
|
|
466
496
|
`/retrieve/tools` fetch fails, listing degrades to the static tools (never errors).
|
|
467
497
|
The generated-tool count per tenant is bounded by the per-tenant entity cap.
|
|
468
498
|
|
|
469
|
-
### Runner
|
|
499
|
+
### Runner and delivery-loop tools
|
|
470
500
|
|
|
471
|
-
Enroll the dev machines that run the agent delivery loop,
|
|
501
|
+
Enroll the dev machines that run the agent delivery loop, see which are connected (issue #809), place work on them and recover a story a runner would not take. The `runner_*` tools require `LOOPCTL_USER_KEY`, and enroll and revoke additionally require a human-anchored tenant. The delivery-loop verbs below do NOT share one key — each row names the key its endpoint's gate demands, and a higher-privileged key does not substitute for an `exact_role` one.
|
|
472
502
|
|
|
473
503
|
| Tool | Description |
|
|
474
504
|
|---|---|
|
|
@@ -476,9 +506,10 @@ Enroll the dev machines that run the agent delivery loop, and see which are conn
|
|
|
476
506
|
| `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
|
|
477
507
|
| `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`. |
|
|
478
508
|
| `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. |
|
|
479
|
-
| `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. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`
|
|
509
|
+
| `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. 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`. |
|
|
480
510
|
| `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`. |
|
|
481
511
|
| `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`. |
|
|
512
|
+
| `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). |
|
|
482
513
|
|
|
483
514
|
### Dispatch & Chain of Custody (v2) Tools
|
|
484
515
|
|
package/index.js
CHANGED
|
@@ -42,12 +42,14 @@ import {
|
|
|
42
42
|
createGeneratedToolsRuntime,
|
|
43
43
|
GENERATED_TOOL_PREFIX,
|
|
44
44
|
} from "./lib/generated-tools.js";
|
|
45
|
+
import { exactKeyMissingMessage, orchestratorKeyArgs } from "./lib/custody-key.js";
|
|
45
46
|
import { createHandoff } from "./lib/handoff.js";
|
|
46
47
|
import { readPayloadFile } from "./lib/payload-path.js";
|
|
47
48
|
import { enrollRunner, listRunners, revokeRunner, runnerPool } from "./lib/runners.js";
|
|
48
49
|
import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./lib/claim-lease.js";
|
|
49
50
|
import { escalateStory as escalateStoryRequest, escalationNotice } from "./lib/escalation.js";
|
|
50
51
|
import {
|
|
52
|
+
forceUnclaimStory as forceUnclaimStoryRequest,
|
|
51
53
|
placeDispatch as placeDispatchRequest,
|
|
52
54
|
resolveEscalation as resolveEscalationRequest,
|
|
53
55
|
storyStage as storyStageRequest,
|
|
@@ -149,12 +151,38 @@ function cachedClientContextHeader() {
|
|
|
149
151
|
return clientContextHeaderCache;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
async function apiCall(
|
|
154
|
+
async function apiCall(
|
|
155
|
+
method,
|
|
156
|
+
path,
|
|
157
|
+
body,
|
|
158
|
+
keyOverride,
|
|
159
|
+
{ exactKey = false, timeoutMs, keyHint } = {},
|
|
160
|
+
) {
|
|
153
161
|
const url = `${getBaseUrl()}${path}`;
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
162
|
+
// `exactKey` sends `keyOverride` VERBATIM instead of consulting `resolveKey`
|
|
163
|
+
// (defined above), whose priority is LOOPCTL_API_KEY > keyOverride >
|
|
164
|
+
// LOOPCTL_ORCH_KEY — so without it a global LOOPCTL_API_KEY displaces the key a
|
|
165
|
+
// tool names. Two families pass it, for two different reasons:
|
|
166
|
+
//
|
|
167
|
+
// - USER-ROLE-PINNED tools pass LOOPCTL_USER_KEY with `exactKey: true`
|
|
168
|
+
// UNCONDITIONALLY. Named rather than cited by line, because a line number here
|
|
169
|
+
// drifts with every edit above it: `llmConfig` / `setLlmConfig`, the egress
|
|
170
|
+
// allowlist verbs (`clearLocalOnly`, `declareTrustedEndpoint`,
|
|
171
|
+
// `revokeTrustedEndpoint`), the WebAuthn enrollment and revocation verbs
|
|
172
|
+
// (`requestAuthenticatorChallenge`, `enrollAuthenticator`,
|
|
173
|
+
// `requestAuthenticatorRevokeChallenge`, `revokeAuthenticator`), and the shared
|
|
174
|
+
// `runnerDeps()` / `deliveryDeps()` factories — the latter covering
|
|
175
|
+
// `place_dispatch` and `resolve_escalation`, which need an unlineaged human
|
|
176
|
+
// principal. `grep -n 'exactKey: true' index.js` is the current list. None of
|
|
177
|
+
// them may silently run under a global key of another role (review #12).
|
|
178
|
+
// - The `exact_role: :orchestrator` CUSTODY verbs pass it CONDITIONALLY, and
|
|
179
|
+
// nothing here decides that: `orchestratorKeyArgs` (`lib/custody-key.js`)
|
|
180
|
+
// returns `exactKey` only when LOOPCTL_ORCH_KEY is set, and returns no options
|
|
181
|
+
// at all when only LOOPCTL_API_KEY is — an orchestrator-role global key was a
|
|
182
|
+
// working, documented configuration before 2.97.0 and still reaches the gate.
|
|
183
|
+
// The reason for pinning is the operator's EXPRESSED CHOICE of variable, never
|
|
184
|
+
// the shape of the server's 403; `lib/custody-key.js` reads the plug and shows
|
|
185
|
+
// why that 403 was never ambiguous.
|
|
158
186
|
const key = exactKey ? keyOverride : resolveKey(keyOverride);
|
|
159
187
|
|
|
160
188
|
if (!key) {
|
|
@@ -162,7 +190,12 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false, time
|
|
|
162
190
|
error: true,
|
|
163
191
|
status: 0,
|
|
164
192
|
body: exactKey
|
|
165
|
-
?
|
|
193
|
+
? // `keyHint` names the env var THIS site is pinned to. Without it the message
|
|
194
|
+
// below talks about LLM configuration, which is right for set_llm_config and
|
|
195
|
+
// actively misleading for a custody verb that is missing its orchestrator key.
|
|
196
|
+
keyHint
|
|
197
|
+
? exactKeyMissingMessage(keyHint)
|
|
198
|
+
: "No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to manage LLM configuration."
|
|
166
199
|
: "No API key configured. Set LOOPCTL_API_KEY, LOOPCTL_ORCH_KEY, or LOOPCTL_AGENT_KEY.",
|
|
167
200
|
};
|
|
168
201
|
}
|
|
@@ -1246,21 +1279,34 @@ async function verifyStory({ story_id, summary, review_type, claim }) {
|
|
|
1246
1279
|
if (review_type) body.review_type = review_type;
|
|
1247
1280
|
if (claim) body.claim = claim;
|
|
1248
1281
|
|
|
1282
|
+
// `exact_role: :orchestrator` (`story_verification_controller.ex:29-30`). When
|
|
1283
|
+
// LOOPCTL_ORCH_KEY is set it is sent EXACTLY, because `resolveKey` would otherwise
|
|
1284
|
+
// discard it in favour of a global LOOPCTL_API_KEY on the very calls it was set for.
|
|
1285
|
+
// When it is NOT set, the global key still goes — see lib/custody-key.js for why the
|
|
1286
|
+
// pin is narrowed that way, and for what the 403 from that gate actually says.
|
|
1287
|
+
const orch = orchestratorKeyArgs();
|
|
1288
|
+
|
|
1249
1289
|
const result = await apiCall(
|
|
1250
1290
|
"POST",
|
|
1251
1291
|
`/api/v1/stories/${story_id}/verify`,
|
|
1252
1292
|
body,
|
|
1253
|
-
|
|
1293
|
+
orch.override,
|
|
1294
|
+
orch.options,
|
|
1254
1295
|
);
|
|
1255
1296
|
return toContent(result);
|
|
1256
1297
|
}
|
|
1257
1298
|
|
|
1258
1299
|
async function rejectStory({ story_id, reason }) {
|
|
1300
|
+
// `exact_role: :orchestrator` (`story_verification_controller.ex:29-30`) — see
|
|
1301
|
+
// lib/custody-key.js.
|
|
1302
|
+
const orch = orchestratorKeyArgs();
|
|
1303
|
+
|
|
1259
1304
|
const result = await apiCall(
|
|
1260
1305
|
"POST",
|
|
1261
1306
|
`/api/v1/stories/${story_id}/reject`,
|
|
1262
1307
|
{ reason },
|
|
1263
|
-
|
|
1308
|
+
orch.override,
|
|
1309
|
+
orch.options,
|
|
1264
1310
|
);
|
|
1265
1311
|
return toContent(result);
|
|
1266
1312
|
}
|
|
@@ -1269,21 +1315,31 @@ async function rejectStory({ story_id, reason }) {
|
|
|
1269
1315
|
|
|
1270
1316
|
async function bulkMarkComplete({ stories }) {
|
|
1271
1317
|
// stories: [{story_id, summary, review_type}]
|
|
1318
|
+
// `exact_role: :orchestrator` (`bulk_operations_controller.ex:24-25`) — see
|
|
1319
|
+
// lib/custody-key.js.
|
|
1320
|
+
const orch = orchestratorKeyArgs();
|
|
1321
|
+
|
|
1272
1322
|
const result = await apiCall(
|
|
1273
1323
|
"POST",
|
|
1274
1324
|
"/api/v1/stories/bulk/mark-complete",
|
|
1275
1325
|
{ stories },
|
|
1276
|
-
|
|
1326
|
+
orch.override,
|
|
1327
|
+
orch.options,
|
|
1277
1328
|
);
|
|
1278
1329
|
return toContent(result);
|
|
1279
1330
|
}
|
|
1280
1331
|
|
|
1281
1332
|
async function verifyAllInEpic({ epic_id, review_type, summary }) {
|
|
1333
|
+
// `exact_role: :orchestrator` (`story_verification_controller.ex:29-30`, `:verify_all`) —
|
|
1334
|
+
// see lib/custody-key.js.
|
|
1335
|
+
const orch = orchestratorKeyArgs();
|
|
1336
|
+
|
|
1282
1337
|
const result = await apiCall(
|
|
1283
1338
|
"POST",
|
|
1284
1339
|
`/api/v1/epics/${epic_id}/verify-all`,
|
|
1285
1340
|
{ review_type, summary },
|
|
1286
|
-
|
|
1341
|
+
orch.override,
|
|
1342
|
+
orch.options,
|
|
1287
1343
|
);
|
|
1288
1344
|
return toContent(result);
|
|
1289
1345
|
}
|
|
@@ -3188,6 +3244,26 @@ async function resolveEscalation(args) {
|
|
|
3188
3244
|
return toContent(await resolveEscalationRequest(args, deliveryDeps()));
|
|
3189
3245
|
}
|
|
3190
3246
|
|
|
3247
|
+
// #846: force-unclaim is `exact_role: :orchestrator`
|
|
3248
|
+
// (`story_verification_controller.ex:29-30`), so this is the one delivery-loop verb that wants
|
|
3249
|
+
// the ORCH key. Same selection as the other four custody verbs: when LOOPCTL_ORCH_KEY is set it
|
|
3250
|
+
// is sent EXACTLY, because `resolveKey` (`index.js:132-138`) would otherwise discard it for a
|
|
3251
|
+
// global LOOPCTL_API_KEY on the one call it was set for; when it is not set, a global key still
|
|
3252
|
+
// goes, because an orchestrator-role LOOPCTL_API_KEY was a documented, working configuration
|
|
3253
|
+
// before 2.97.0. `orch.resolved` is undefined only when NEITHER is set, which is the one case
|
|
3254
|
+
// `forceUnclaimStoryRequest` refuses locally. `keyHint` rides along so the missing-key message
|
|
3255
|
+
// names LOOPCTL_ORCH_KEY instead of apiCall's LLM-config default (`index.js:174-181`).
|
|
3256
|
+
async function forceUnclaimStory(args) {
|
|
3257
|
+
const orch = orchestratorKeyArgs();
|
|
3258
|
+
|
|
3259
|
+
return toContent(
|
|
3260
|
+
await forceUnclaimStoryRequest(args, {
|
|
3261
|
+
orchKey: orch.resolved,
|
|
3262
|
+
apiCall: (method, path, body) => apiCall(method, path, body, orch.override, orch.options),
|
|
3263
|
+
}),
|
|
3264
|
+
);
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3191
3267
|
// US-26: Signed Tree Head retrieval
|
|
3192
3268
|
async function getSth({ tenant_id }) {
|
|
3193
3269
|
const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
|
|
@@ -7677,9 +7753,14 @@ const TOOLS = [
|
|
|
7677
7753
|
type: "string",
|
|
7678
7754
|
enum: ["implement"],
|
|
7679
7755
|
description:
|
|
7680
|
-
"What the session is for. `implement` is the only kind
|
|
7681
|
-
"
|
|
7682
|
-
"
|
|
7756
|
+
"What the session is for. `implement` is the only kind THIS tool sends, and the " +
|
|
7757
|
+
"narrowness is deliberate rather than a gap in the contract: `triage` became " +
|
|
7758
|
+
"dispatchable in 1.10.0, but placement CLAIMS the story — that is what mints the " +
|
|
7759
|
+
"custody lineage — while a triage session only reads, and loopctl dispatches " +
|
|
7760
|
+
"triage itself from `detected` without claiming anything. Asking for `triage` " +
|
|
7761
|
+
"here would claim a story for a session that is only deciding whether it is work, " +
|
|
7762
|
+
"put it at `claimed`, and send a triage dispatch carrying no reporter words at " +
|
|
7763
|
+
"all, because nothing on this path builds the triage object.",
|
|
7683
7764
|
},
|
|
7684
7765
|
dispatch_id: {
|
|
7685
7766
|
type: "string",
|
|
@@ -7740,6 +7821,52 @@ const TOOLS = [
|
|
|
7740
7821
|
required: ["story_id", "to"],
|
|
7741
7822
|
},
|
|
7742
7823
|
},
|
|
7824
|
+
{
|
|
7825
|
+
name: "force_unclaim_story",
|
|
7826
|
+
description:
|
|
7827
|
+
"TAKE A STORY BACK from the agent holding it (POST /api/v1/stories/:id/force-unclaim). " +
|
|
7828
|
+
"Two things happen: the story resets to `agent_status: pending` with " +
|
|
7829
|
+
"`assigned_agent_id` cleared, AND — in the same transaction — its delivery stage row " +
|
|
7830
|
+
"follows the release back to `queued`.\n\n" +
|
|
7831
|
+
"IT FREES THE STAGE; IT DOES NOT MAKE THE STORY PLACEABLE. place_dispatch wants " +
|
|
7832
|
+
"`agent_status: contracted` AND stage `queued` (`Placement.claimable/2`), and the " +
|
|
7833
|
+
"release leaves the story at `pending`, whose only transition is `pending -> " +
|
|
7834
|
+
"contracted`. Run place_dispatch straight after this one and you get back the IDENTICAL " +
|
|
7835
|
+
"409 `invalid_transition`. THE REMEDY IS THREE CALLS, in this order: " +
|
|
7836
|
+
"force_unclaim_story, then contract_story, then place_dispatch.\n\n" +
|
|
7837
|
+
"WHEN TO REACH FOR IT. A story sitting at `claimed` with nobody on it is the residue of " +
|
|
7838
|
+
"a compensation that did not complete — it is NOT what a refused dispatch normally " +
|
|
7839
|
+
"leaves. Placement answers a runner's refusal INLINE by releasing the claim itself " +
|
|
7840
|
+
"(`Placement.undo_claim/5`), and if that release fails the claim lease is a further " +
|
|
7841
|
+
"backstop: `Progress.reclaim_expired_claim/3` releases over `:runner_lost` and requeues " +
|
|
7842
|
+
"the stage, unattended, once `claimed_until` has passed. Use this tool to get the story " +
|
|
7843
|
+
"back NOW rather than at lease expiry, or when both of those have left it held.\n\n" +
|
|
7844
|
+
"The release requeues from any stage a claim holds (`claimed`, `worktree`, " +
|
|
7845
|
+
"`implementing`, `reviewing`, `pr_open`, `ci`); a stage no claim holds keeps its stage " +
|
|
7846
|
+
"and is rebound to the new claim epoch; `done` and `failed` are left alone. An ESCALATED " +
|
|
7847
|
+
"story is not this tool's job — use resolve_escalation, which releases AND re-contracts " +
|
|
7848
|
+
"for you, so a story it sends to `queued` really is placeable.\n\n" +
|
|
7849
|
+
"REFUSALS. Requires an ORCHESTRATOR-ROLE key, in LOOPCTL_ORCH_KEY or LOOPCTL_API_KEY: " +
|
|
7850
|
+
"the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like " +
|
|
7851
|
+
"any other non-member and reaching for a higher-privileged key does NOT get past it. " +
|
|
7852
|
+
"When LOOPCTL_ORCH_KEY is set it is the key sent, and a global LOOPCTL_API_KEY does not " +
|
|
7853
|
+
"displace it. The orchestrator key must also be LINKED TO " +
|
|
7854
|
+
"A REGISTERED AGENT — an unlinked one is refused 400 naming that — and the tenant must be " +
|
|
7855
|
+
"human-anchored (403 `custody_tier_required` otherwise). 404 for an unknown story, 429 " +
|
|
7856
|
+
"when rate limited. A `story_id` that is not a UUID is refused here, before any call.\n\n" +
|
|
7857
|
+
"It does NOT touch `verified_status`, and it is safe to run twice: on an already-pending " +
|
|
7858
|
+
"story the stage row is written only when it needs to be — a row STRANDED behind the " +
|
|
7859
|
+
"story's claim epoch is rebound to it (the remedy for a row an older release left " +
|
|
7860
|
+
"behind) and an in-flight row is requeued, while a row already at that epoch is left " +
|
|
7861
|
+
"exactly as it is. It takes no request body.",
|
|
7862
|
+
inputSchema: {
|
|
7863
|
+
type: "object",
|
|
7864
|
+
properties: {
|
|
7865
|
+
story_id: { type: "string", description: "The story UUID to free." },
|
|
7866
|
+
},
|
|
7867
|
+
required: ["story_id"],
|
|
7868
|
+
},
|
|
7869
|
+
},
|
|
7743
7870
|
|
|
7744
7871
|
// LCP-1 §9 signed-profile tools
|
|
7745
7872
|
{
|
|
@@ -8771,6 +8898,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
8771
8898
|
case "resolve_escalation":
|
|
8772
8899
|
return await resolveEscalation(args);
|
|
8773
8900
|
|
|
8901
|
+
case "force_unclaim_story":
|
|
8902
|
+
return await forceUnclaimStory(args);
|
|
8903
|
+
|
|
8774
8904
|
case "register_custody_owner_key":
|
|
8775
8905
|
return await registerCustodyOwnerKey(args);
|
|
8776
8906
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key selection for the `exact_role: :orchestrator` custody verbs (loopctl #846 review round 2).
|
|
3
|
+
*
|
|
4
|
+
* WHICH VERBS. `verify`, `reject` and `force_unclaim` plus the aggregate `verify_all`
|
|
5
|
+
* (`lib/loopctl_web/controllers/story_verification_controller.ex:29-30`), and bulk
|
|
6
|
+
* `verify`/`reject`/`mark_complete`
|
|
7
|
+
* (`lib/loopctl_web/controllers/bulk_operations_controller.ex:24-25`). All are mounted
|
|
8
|
+
* `exact_role: :orchestrator`, where the role hierarchy does NOT apply: `RequireRole`'s
|
|
9
|
+
* exact-role clause tests `api_key.role == exact_role` (`require_role.ex:65-75`), so a `:user`
|
|
10
|
+
* or `:superadmin` key is refused there exactly as an `:agent` key is.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THE PINNING IS FOR — and what it is NOT for.
|
|
13
|
+
*
|
|
14
|
+
* NOT for the error message. An earlier draft of this comment, and every copy of it that had
|
|
15
|
+
* spread across this package and both changelogs, claimed a 403 from that gate "reads as a
|
|
16
|
+
* custody refusal about the STORY rather than a misconfigured key". That is false and the
|
|
17
|
+
* source says so: `RequireRole.forbid/3` (`require_role.ex:112-128`) halts with 403,
|
|
18
|
+
* `code: "insufficient_role"`, `required_roles: ["orchestrator"]` and the message "This
|
|
19
|
+
* endpoint requires the orchestrator role", and the plug is mounted FIRST, so the request
|
|
20
|
+
* never reaches the controller and never reaches `Progress.validate_not_self_verify/4` where
|
|
21
|
+
* the custody 409s are raised. The role error is unambiguous. Do not reintroduce that
|
|
22
|
+
* justification. It came back twice after being "removed everywhere", so it is no longer swept
|
|
23
|
+
* by hand: `test/no_false_403_claim.test.js` fails on any assertion of it anywhere in this
|
|
24
|
+
* package or in either changelog. It admits a quotation like the one above, but only where a
|
|
25
|
+
* disproof marker sits within a sentence or so of it — a whole-paragraph rule was too loose,
|
|
26
|
+
* since this refutation runs a dozen lines and its closing "do not reintroduce" would have
|
|
27
|
+
* excused an assertion at the far end of the very block that disproves the claim.
|
|
28
|
+
*
|
|
29
|
+
* What the pinning IS for is an operator's expressed configuration. `resolveKey` in
|
|
30
|
+
* `index.js` reads `LOOPCTL_API_KEY || keyOverride || LOOPCTL_ORCH_KEY` — the global
|
|
31
|
+
* override wins over the key the tool names. So an operator who set `LOOPCTL_ORCH_KEY`
|
|
32
|
+
* specifically for these verbs AND a `LOOPCTL_API_KEY` of some other role for everything else
|
|
33
|
+
* had the orchestrator key silently discarded on precisely the calls they set it for, and paid a
|
|
34
|
+
* round trip to learn it. Honouring it costs nothing and is what they asked for.
|
|
35
|
+
*
|
|
36
|
+
* WHY IT IS NARROWED TO "WHEN THE ORCH KEY IS SET". Pinning unconditionally is a BEHAVIOURAL
|
|
37
|
+
* BREAK in a minor release, and the configuration it breaks is one the shipped docs endorsed:
|
|
38
|
+
* 2.96.0's `README.md:70` describes `LOOPCTL_API_KEY` as "Global API key override (if set,
|
|
39
|
+
* always used)". `LOOPCTL_API_KEY` set to an orchestrator-role key, with no `LOOPCTL_ORCH_KEY`
|
|
40
|
+
* at all, was working, documented, and accepted by the gate — the key's ROLE is what the server
|
|
41
|
+
* tests, and it is `:orchestrator`. An unconditional pin refuses that locally, sends nothing, and
|
|
42
|
+
* tells the operator their key is the wrong role when it is not. This is the same reasoning that
|
|
43
|
+
* leaves `report_story` and `review_complete` unpinned in `test/custody_key_pinning.test.js`,
|
|
44
|
+
* applied consistently.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The `keyOverride` and options a custody verb should hand `apiCall`, plus the key that will
|
|
49
|
+
* actually be sent.
|
|
50
|
+
*
|
|
51
|
+
* @param {Record<string, string|undefined>} [env]
|
|
52
|
+
* @returns {{override: string|undefined, options: object, resolved: string|undefined}}
|
|
53
|
+
* `override` is `apiCall`'s 4th argument, `options` its 5th, and `resolved` is the key the
|
|
54
|
+
* request will carry — `undefined` when no key is configured at all, which is the only case a
|
|
55
|
+
* caller may refuse locally on.
|
|
56
|
+
*/
|
|
57
|
+
export function orchestratorKeyArgs(env = process.env) {
|
|
58
|
+
const orchKey = env.LOOPCTL_ORCH_KEY;
|
|
59
|
+
|
|
60
|
+
if (orchKey) {
|
|
61
|
+
// The operator named a key for this role. Send exactly it: `exactKey` makes `apiCall` use
|
|
62
|
+
// the override verbatim instead of `resolveKey` — the `const key = exactKey ? keyOverride :
|
|
63
|
+
// resolveKey(keyOverride)` line in `index.js`.
|
|
64
|
+
return {
|
|
65
|
+
override: orchKey,
|
|
66
|
+
options: { exactKey: true, keyHint: "LOOPCTL_ORCH_KEY" },
|
|
67
|
+
resolved: orchKey,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const globalKey = env.LOOPCTL_API_KEY;
|
|
72
|
+
|
|
73
|
+
if (globalKey) {
|
|
74
|
+
// No orchestrator key, but a global one. This is the pre-2.97.0 configuration and it is
|
|
75
|
+
// still allowed to reach the gate: if that key is orchestrator-role it passes, and if it is
|
|
76
|
+
// not, the server's own 403 names the required role. Refusing it here would break a working
|
|
77
|
+
// setup to pre-empt an error the server already reports correctly.
|
|
78
|
+
return { override: undefined, options: {}, resolved: globalKey };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Nothing configured. Pin anyway, so `apiCall`'s missing-key branch (`if (!key)`, in
|
|
82
|
+
// `index.js`) takes the `keyHint` path and names LOOPCTL_ORCH_KEY rather than falling back to
|
|
83
|
+
// the LLM-config default. Callers that refuse locally (see `forceUnclaimStory`) branch on
|
|
84
|
+
// `resolved` being undefined.
|
|
85
|
+
return {
|
|
86
|
+
override: undefined,
|
|
87
|
+
options: { exactKey: true, keyHint: "LOOPCTL_ORCH_KEY" },
|
|
88
|
+
resolved: undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What `apiCall` says when an `exactKey` site names a variable (`keyHint`) and no key is
|
|
94
|
+
* configured at all.
|
|
95
|
+
*
|
|
96
|
+
* The message it replaced said "LOOPCTL_API_KEY is deliberately not a fallback for it", which
|
|
97
|
+
* is now false guidance: this branch is reached only when NEITHER the hinted variable nor
|
|
98
|
+
* LOOPCTL_API_KEY is set, and setting LOOPCTL_API_KEY to a key of the right role IS a working
|
|
99
|
+
* configuration (see `orchestratorKeyArgs`). Telling an operator with no keys at all that one
|
|
100
|
+
* of their two options does not work sends them the long way round.
|
|
101
|
+
*
|
|
102
|
+
* What it must NOT say instead is that any key will do: the gate matches the role exactly, so
|
|
103
|
+
* a higher-privileged key is refused there like any other non-member.
|
|
104
|
+
*/
|
|
105
|
+
export function exactKeyMissingMessage(keyHint) {
|
|
106
|
+
return (
|
|
107
|
+
`No API key configured. Set ${keyHint} — or LOOPCTL_API_KEY — to a key of the exact role ` +
|
|
108
|
+
`this endpoint requires; its gate matches the role exactly, so a higher-privileged key is ` +
|
|
109
|
+
`refused there like any other. When ${keyHint} is set it is the key sent, and a global ` +
|
|
110
|
+
`LOOPCTL_API_KEY does not displace it.`
|
|
111
|
+
);
|
|
112
|
+
}
|
package/lib/delivery-loop.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The delivery loop's OPERATOR verbs (loopctl #803, #850).
|
|
2
|
+
* The delivery loop's OPERATOR verbs (loopctl #803, #850, #846).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* These tools exist because the endpoints behind them were unreachable from any session on the
|
|
5
|
+
* fleet. `POST /api/v1/runners/:runner_id/dispatches` shipped with #842 and no tool called it;
|
|
6
|
+
* `curl` at loopctl is refused by the fleet's own guardrail, deliberately; so
|
|
7
7
|
* the only way to place a dispatch was a shell on the production node. That is the same defect
|
|
8
8
|
* as `place/4` shipping with no caller, failing one layer later — the trigger exists and
|
|
9
9
|
* nothing outside the app can pull it.
|
|
10
10
|
*
|
|
11
11
|
* Mark's rule, 2026-09-15: an operator-facing endpoint is not done until an MCP tool calls it,
|
|
12
|
-
* in the same change. These are that rule applied to the
|
|
13
|
-
*
|
|
12
|
+
* in the same change. These are that rule applied to the verbs the loop needs to be RUNNABLE
|
|
13
|
+
* (place a dispatch), OBSERVABLE (read a story's stage) and RECOVERABLE (resolve an
|
|
14
|
+
* escalation, free a story a runner refused).
|
|
14
15
|
*
|
|
15
16
|
* KEY SELECTION is behaviour, not a convention, so it lives here where a test can see it:
|
|
16
17
|
*
|
|
@@ -19,6 +20,13 @@
|
|
|
19
20
|
* tree, and an agent key is refused `insufficient_role` before anything is written.
|
|
20
21
|
* - `resolve_escalation` is the HUMAN half of the escalation pair: a `:user` key no dispatch
|
|
21
22
|
* minted. The agent key that raises an escalation is 403'd on it by design.
|
|
23
|
+
* - `force_unclaim_story` is gated `exact_role: :orchestrator`
|
|
24
|
+
* (`story_verification_controller.ex:29-30`), so it needs an ORCHESTRATOR-ROLE key and a
|
|
25
|
+
* higher-privileged one does NOT substitute: `RequireRole`'s exact-role clause tests
|
|
26
|
+
* `api_key.role == exact_role` (`require_role.ex:65-75`), so a `:user` or `:superadmin`
|
|
27
|
+
* key is 403'd there exactly as an agent key is. That is a chain-of-custody gate, not an
|
|
28
|
+
* oversight. WHICH ENV VAR carries that key is a separate question, and `index.js` decides
|
|
29
|
+
* it in `lib/custody-key.js`: LOOPCTL_ORCH_KEY when set, else LOOPCTL_API_KEY.
|
|
22
30
|
* - `story_stage` is a read and takes whatever key the caller has.
|
|
23
31
|
*
|
|
24
32
|
* SINGLE SOURCE OF TRUTH: `index.js` injects `apiCall`, and the unit suite runs this code
|
|
@@ -29,14 +37,88 @@ const MISSING_USER_KEY =
|
|
|
29
37
|
"LOOPCTL_USER_KEY is required: this verb claims a story and mints a custody dispatch, " +
|
|
30
38
|
"which only an unlineaged user key may do.";
|
|
31
39
|
|
|
40
|
+
const MISSING_ORCH_KEY =
|
|
41
|
+
"No API key is configured. Set LOOPCTL_ORCH_KEY to an orchestrator-role key (or " +
|
|
42
|
+
"LOOPCTL_API_KEY to one). Force-unclaim is gated `exact_role: :orchestrator`, so a user or " +
|
|
43
|
+
"superadmin key is 403'd there like any other non-member: a higher-privileged key is not a " +
|
|
44
|
+
"way past it.";
|
|
45
|
+
|
|
32
46
|
function refuse(body) {
|
|
33
47
|
return { error: true, status: 0, body };
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`.
|
|
52
|
+
*
|
|
53
|
+
* NOT on `place_dispatch`'s `dispatch_id`, which is an id and is forwarded unchecked. That is a
|
|
54
|
+
* decision and not an omission: it travels in the request BODY, and `Placement.place/4` casts it
|
|
55
|
+
* with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:786-791`) as the second clause of its
|
|
56
|
+
* `with` — before the caller is resolved, before anything is minted and before the claim — so a
|
|
57
|
+
* malformed one answers `422 invalid_payload` with `details: ["dispatch_id: must be a UUID"]`
|
|
58
|
+
* (`lib/loopctl_web/controllers/dispatch_placement_controller.ex:321-326`). That names the
|
|
59
|
+
* parameter and the shape, costs nothing on the server, and is the opposite of the ambiguity
|
|
60
|
+
* below. There is nothing here for a client check to disambiguate. An earlier draft of this
|
|
61
|
+
* comment said the check covered "every id these verbs take"; it never did.
|
|
62
|
+
*
|
|
63
|
+
* WHY THE PATH IDS DO NEED IT — stated correctly, since an earlier draft of this comment said a
|
|
64
|
+
* malformed id made the server "500", and it does not. loopctl has a deliberate backstop:
|
|
65
|
+
* `defimpl Plug.Exception, for: Ecto.Query.CastError` maps it to 404
|
|
66
|
+
* (`lib/loopctl_web/plugs/cast_error_handler.ex:32-35` — the file also carries an
|
|
67
|
+
* `Ecto.CastError` impl at `:27-30` and an `Ecto.ChangeError` impl at `:37-42`; the query path
|
|
68
|
+
* below raises the `Ecto.Query.CastError` one), pinned by
|
|
69
|
+
* `test/loopctl_web/plugs/cast_error_handler_test.exs:9-12`. What the caller actually gets is
|
|
70
|
+
* `{"error": {"status": 404, "message": "Not found"}}` — the generic body
|
|
71
|
+
* `LoopctlWeb.ErrorJSON.render("404.json", …)` emits
|
|
72
|
+
* (`lib/loopctl_web/controllers/error_json.ex:13-15`, wired by `config/config.exs:344-347`).
|
|
73
|
+
*
|
|
74
|
+
* That 404 is the problem, not a 500. It is BYTE-IDENTICAL to the 404 for a perfectly
|
|
75
|
+
* well-formed id that names no story — `FallbackController` answers `{:error, :not_found}` with
|
|
76
|
+
* the same `%{error: %{status: 404, message: "Not found"}}`
|
|
77
|
+
* (`lib/loopctl_web/fallback_controller.ex:74-78`) — so it cannot tell an operator which
|
|
78
|
+
* of the two happened, and the two have opposite remedies: re-read the argument you passed, or
|
|
79
|
+
* go find the right story. The check here can tell them apart, and does it without a round trip.
|
|
80
|
+
*
|
|
81
|
+
* SCOPE OF THAT TRACE. The path read end to end is force-unclaim:
|
|
82
|
+
* `Progress.force_unclaim_story/3` reaches `lock_story/2` (`lib/loopctl/progress.ex:3467-3470`),
|
|
83
|
+
* which puts `story_id` straight into a `where` against a `:binary_id` column with no cast. The
|
|
84
|
+
* other verbs are NOT claimed to reach that same code. They do not need to: a shape check is
|
|
85
|
+
* worth its line on any argument that is interpolated into a URL path and must be a UUID,
|
|
86
|
+
* whatever the server would do with a value that is not one. `place_dispatch`'s `story_id` is
|
|
87
|
+
* the one exception in the other direction — it travels in the body, like `dispatch_id` — and it
|
|
88
|
+
* is checked because `story_id` is a PATH id in the three sibling verbs and `uuid()` is one
|
|
89
|
+
* shared check: giving the same parameter two different refusals depending on which verb took it
|
|
90
|
+
* is worse than checking it once too often.
|
|
91
|
+
*
|
|
92
|
+
* THE REFUSAL CARRIES `status: 0`, NOT 404, and that is deliberate. Every local refusal in this
|
|
93
|
+
* client uses it — `refuse()` below, `apiCall`'s missing-key branch and its network/timeout
|
|
94
|
+
* branches (`index.js`) — and it means ONE thing: no request was sent, so no server said
|
|
95
|
+
* anything. The whole result object is JSON-stringified into the tool output (`toContent`,
|
|
96
|
+
* `index.js`), so a reader sees that 0. Stamping 404 on it instead would make a local refusal
|
|
97
|
+
* indistinguishable from the server's answer, which is precisely the ambiguity this check
|
|
98
|
+
* exists to remove. A caller branching on the status therefore sees `0` where it previously saw
|
|
99
|
+
* `404`; that is a shape change and the 2.97.0 CHANGELOG entry says so.
|
|
100
|
+
*
|
|
101
|
+
* The refusal names the SHAPE and never echoes the value. A malformed id is frequently a
|
|
102
|
+
* token, a path or a pasted line that landed in the wrong argument, and a tool result goes
|
|
103
|
+
* into the transcript.
|
|
104
|
+
*/
|
|
105
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
106
|
+
|
|
36
107
|
function uuid(value, field) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
108
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
109
|
+
return refuse(`\`${field}\` is required.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!UUID_RE.test(value)) {
|
|
113
|
+
return refuse(
|
|
114
|
+
`\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper). ` +
|
|
115
|
+
`Got a ${value.length}-character string that is not one. The value is not repeated ` +
|
|
116
|
+
`here: a malformed id is often something pasted into the wrong argument, and a tool ` +
|
|
117
|
+
`result lands in the transcript.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
40
122
|
}
|
|
41
123
|
|
|
42
124
|
export function placementPath(runnerId) {
|
|
@@ -51,6 +133,11 @@ export function resolvePath(storyId) {
|
|
|
51
133
|
return `/api/v1/stories/${encodeURIComponent(storyId)}/stage/resolve`;
|
|
52
134
|
}
|
|
53
135
|
|
|
136
|
+
/** Note the HYPHEN: the route is `force-unclaim`, not `force_unclaim`. */
|
|
137
|
+
export function forceUnclaimPath(storyId) {
|
|
138
|
+
return `/api/v1/stories/${encodeURIComponent(storyId)}/force-unclaim`;
|
|
139
|
+
}
|
|
140
|
+
|
|
54
141
|
/**
|
|
55
142
|
* `POST /api/v1/runners/:runner_id/dispatches`: claim a queued story and push it to a runner.
|
|
56
143
|
*
|
|
@@ -142,3 +229,70 @@ export async function resolveEscalation({ story_id, to, reason } = {}, { userKey
|
|
|
142
229
|
|
|
143
230
|
return apiCall("POST", resolvePath(story_id), body);
|
|
144
231
|
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `POST /api/v1/stories/:id/force-unclaim`: take a story back off the agent holding it.
|
|
235
|
+
*
|
|
236
|
+
* TWO things happen. `force_unclaim_story/3` resets `agent_status` to `pending` and clears
|
|
237
|
+
* `assigned_agent_id` (`release_claim_changes/1`, `progress.ex:1438`); then, in the SAME
|
|
238
|
+
* transaction, `Stages.follow_release/5` makes the delivery stage row follow the release —
|
|
239
|
+
* from any stage a claim holds (`claimed`, `worktree`, `implementing`, `reviewing`, `pr_open`,
|
|
240
|
+
* `ci`) back to `queued`, rebound to the new claim epoch.
|
|
241
|
+
*
|
|
242
|
+
* ## IT FREES THE STAGE. IT DOES NOT MAKE THE STORY PLACEABLE.
|
|
243
|
+
*
|
|
244
|
+
* This comment used to say the second half was "what makes the story PLACEABLE again", and
|
|
245
|
+
* that is false. `Placement.claimable/2` (`placement.ex:482-490`) wants `agent_status ==
|
|
246
|
+
* :contracted` AND stage `queued`; the release leaves the story at `:pending`, and
|
|
247
|
+
* `@valid_transitions` (`progress.ex:3480`) has `pending: :contracted` and nothing else — so
|
|
248
|
+
* `place_dispatch` run straight afterwards answers the IDENTICAL 409 `invalid_transition`.
|
|
249
|
+
*
|
|
250
|
+
* The remedy is THREE CALLS, in this order, and the tool descriptions say so:
|
|
251
|
+
* `force_unclaim_story`, then `contract_story`, then `place_dispatch`. (An earlier draft called
|
|
252
|
+
* it "two steps" and then named three, which is worse than saying nothing: an operator counting
|
|
253
|
+
* steps runs two of the three and takes the 409 this copy exists to prevent.)
|
|
254
|
+
* (`resolve_escalation` is the one that does both for you: `Escalations.prepare_story/5`
|
|
255
|
+
* releases AND re-contracts on the `queued` route, `escalations.ex:335-342`.)
|
|
256
|
+
*
|
|
257
|
+
* ## WHEN A STORY IS ACTUALLY PARKED
|
|
258
|
+
*
|
|
259
|
+
* Not on an ordinary refusal — that path self-heals. `Placement.place/4` answers a
|
|
260
|
+
* `Runners.dispatch/3` refusal INLINE with `undo_claim/5` (`placement.ex:562`, `:706-711`),
|
|
261
|
+
* which releases the claim through this same function, unrecords the session dispatch and
|
|
262
|
+
* revokes it. If that release itself fails, the claim lease is a further backstop:
|
|
263
|
+
* `Progress.reclaim_expired_claim/3` (`progress.ex:1612`) releases over `:runner_lost` and
|
|
264
|
+
* requeues the stage, swept by `ReclaimExpiredClaimsWorker` every five minutes once
|
|
265
|
+
* `claimed_until` has passed.
|
|
266
|
+
*
|
|
267
|
+
* So a story sitting at `claimed` with nobody on it is the RESIDUE OF A FAILED COMPENSATION,
|
|
268
|
+
* not what a refusal normally leaves. Reach for this tool to get the story back now instead of
|
|
269
|
+
* at lease expiry, or when both of those have left it held.
|
|
270
|
+
*
|
|
271
|
+
* No request body — the story is named in the path.
|
|
272
|
+
*
|
|
273
|
+
* ORCHESTRATOR ROLE, and exactly that: the action is `exact_role: :orchestrator`
|
|
274
|
+
* (`story_verification_controller.ex:29-30`), so a user or superadmin key is refused —
|
|
275
|
+
* `RequireRole`'s exact-role clause tests `api_key.role == exact_role`
|
|
276
|
+
* (`require_role.ex:65-75`).
|
|
277
|
+
*
|
|
278
|
+
* WHICH ENV VAR supplies that key is `index.js`'s call, made in `lib/custody-key.js`:
|
|
279
|
+
* LOOPCTL_ORCH_KEY is sent EXACTLY when it is set, so `resolveKey` cannot discard an
|
|
280
|
+
* operator's deliberate choice in favour of a global LOOPCTL_API_KEY; when it is not set, the
|
|
281
|
+
* global key still goes, because an orchestrator-role LOOPCTL_API_KEY was a working and
|
|
282
|
+
* documented configuration before 2.97.0. The refusal below fires only when NEITHER is set.
|
|
283
|
+
*
|
|
284
|
+
* NOT because the 403 would be confusing. An earlier draft of this paragraph said a wrong-role
|
|
285
|
+
* global key "would produce a 403 that reads like the story being unclaimable rather than the
|
|
286
|
+
* key being wrong". That is false: `RequireRole` is mounted FIRST and halts with 403,
|
|
287
|
+
* `code: "insufficient_role"`, `required_roles: ["orchestrator"]` and "This endpoint requires
|
|
288
|
+
* the orchestrator role" (`require_role.ex:112-128`), so the request never reaches the
|
|
289
|
+
* controller and never reaches the custody 409s in `Progress`. The role error names the role.
|
|
290
|
+
*/
|
|
291
|
+
export async function forceUnclaimStory({ story_id } = {}, { orchKey, apiCall } = {}) {
|
|
292
|
+
if (!orchKey) return refuse(MISSING_ORCH_KEY);
|
|
293
|
+
|
|
294
|
+
const bad = uuid(story_id, "story_id");
|
|
295
|
+
if (bad) return bad;
|
|
296
|
+
|
|
297
|
+
return apiCall("POST", forceUnclaimPath(story_id), null);
|
|
298
|
+
}
|
package/lib/handoff.js
CHANGED
|
@@ -19,9 +19,17 @@
|
|
|
19
19
|
* lib/http-helpers.js). The three HTTP calls are INJECTED (`deps`), so every branch
|
|
20
20
|
* below is testable with fakes and no network.
|
|
21
21
|
*
|
|
22
|
-
* NEVER attempts `create_project`. A work project is human-anchor-gated by design
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* NEVER attempts `create_project`. A work project is human-anchor-gated by design, so on an
|
|
23
|
+
* agent-rooted tenant it cannot succeed and the attempt is a wasted round trip — that is the
|
|
24
|
+
* dead end #517 hit. NOT because the 403 is unhelpful: an earlier draft of this comment said
|
|
25
|
+
* it "reads as a wall rather than a redirect", and #505 is precisely what stopped that being
|
|
26
|
+
* true. `ProjectController` mounts `RequireHumanAnchor` on `:create` with an `alternative:`
|
|
27
|
+
* naming `create_kb_scope` (`lib/loopctl_web/controllers/project_controller.ex:46-58`), which
|
|
28
|
+
* the plug renders as `remediation.agent_native_alternative`
|
|
29
|
+
* (`lib/loopctl_web/plugs/require_human_anchor.ex:166-169`), and it mounts that tier gate
|
|
30
|
+
* BEFORE `RequireRole` (`:62`) so an agent-role key reads THAT 403 rather than
|
|
31
|
+
* `insufficient_role`. The 403 points at the kb scope. This code goes straight there because
|
|
32
|
+
* the round trip buys nothing, not because the answer would confuse anyone.
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
35
|
export const HANDOFF_KEY_PREFIX = "handoff:";
|