@edgehero/pi-dispatch 4.1.0 → 4.3.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/src/processor.mjs CHANGED
@@ -7,6 +7,7 @@ import { unqualifiedScope } from "./pause-windows.mjs";
7
7
  import { PROJECT_CAP_REASON } from "./scoped-limits.mjs";
8
8
  import { DEFAULT_SECRETS_PROFILE, secretsArmed } from "./secrets.mjs";
9
9
  import { RESERVED_ENV_NAMES } from "./triggers.mjs";
10
+ import { MCP_REFUSAL_COMMENT } from "./mcp-preflight.mjs";
10
11
  import { EXIT_COMPLETED, EXIT_INFRA, EXIT_POLICY } from "./exit-code.mjs";
11
12
  import { COST_CAP_WHYS, EXIT_OOM_KILLED, RUNNER_POLICY_REASONS } from "./run-history.mjs";
12
13
  import { DEFAULT_EGRESS_PROXY } from "./egress.mjs";
@@ -129,11 +130,35 @@ export const TERMINAL_COMMENTS = {
129
130
  "model-not-allowed": "Stopped: the run tried to call an AI model this trigger does not allow, or to change an AI request in a way it does not allow, so the call was not made. Partial work may exist. Not retried.",
130
131
  "cost-cap-unenforceable": "Stopped: this run has a cost limit, and the job image could not enforce it before each AI call, so nothing was sent to the AI provider. The operator needs to update the job image. Not retried.",
131
132
  "model-policy-unenforceable": "Stopped: this run is limited to certain AI models, and the job image could not enforce that before each AI call, so nothing was sent to the AI provider. The operator needs to update the job image. Not retried.",
133
+ // The runner's refusals of the job's MCP servers, each before the prompt. Never the server's name, its host or a
134
+ // variable: the exit line carries the name and the cause in its message, which the worker never parses (it reaches
135
+ // the worker's log as the container's output). Each says what the operator can check; the issue's author can act on
136
+ // none of it.
137
+ "mcp-unavailable": "Stopped: an MCP server this trigger names could not be used (it did not start, failed its handshake or refused the connection, an authentication error among them), so the run ended before the agent started. The operator needs to check that server and the job image. Not retried.",
138
+ "mcp-config-invalid": "Stopped: the list of MCP servers the worker wrote for this job is not one the job image accepts, so the run ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
139
+ "mcp-config-mismatch": "Stopped: the MCP servers the worker wrote for this job are not the ones it named for it, so the run ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
140
+ "mcp-secret-missing": "Stopped: a credential an MCP server this trigger names needs was not set inside the job, so the run ended before the agent started. The operator needs to check the secrets this trigger passes. Not retried.",
141
+ "mcp-command-missing": "Stopped: the job image does not have the program an MCP server this trigger names runs, so the run ended before the agent started. The operator needs to install that server in the job image or the overlay. Not retried.",
142
+ "mcp-shadowed": "Stopped: another extension in this job registers a tool or command that only the job's MCP servers may, so the run ended before the agent started. The operator needs to remove that extension or this trigger's MCP servers. Not retried.",
143
+ // Issue #625. The runner's two refusals of a job armed with run.awaitInput, each before the prompt.
144
+ "await-input-shadowed": "Stopped: another extension in this job registers the tool a run uses to stop and wait for an answer, so the run ended before the agent started. The operator needs to remove that extension or this trigger's run.awaitInput. Not retried.",
145
+ "await-input-without-session": "Stopped: this run may stop to wait for an answer but had nowhere to keep its work, so it ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
132
146
  // Issue #596. Never the size or the project: both are operator configuration, and the reader may be an issue author
133
147
  // who can act on neither. The worker log and the run record carry them.
134
148
  [EXIT_OOM_KILLED]: "Stopped: the job's container ran out of memory and was stopped. Partial work may exist. Not retried, because the same size would stop the same way. The operator can raise this job's memory size.",
135
149
  };
136
150
 
151
+ // Issue #625: the one comment the COMPLETED path posts, on a run that ended awaiting-input. The agent posted its own
152
+ // question (the tool tells it to, before it ends); this says how the run continues. Fixed and path-free, the
153
+ // TERMINAL_COMMENTS rule, and it carries no model text: the question never crosses the exit line.
154
+ // On an issue any reply continues it (the receiver routes it on the marker); a pull request's reply keeps the phrase,
155
+ // since its session key needs a head branch that costs a token to read (parked.mjs).
156
+ export const PARKED_COMMENT = "This run stopped to wait for an answer to the question above. Reply here, and the next run continues from here.";
157
+ export const PARKED_COMMENT_PR = "This run stopped to wait for an answer to the question above. Reply in a comment that starts with this repository's pi-dispatch comment phrase, and the next run continues from here.";
158
+ export function parkedComment(target) {
159
+ return target?.type === "pull_request" ? PARKED_COMMENT_PR : PARKED_COMMENT;
160
+ }
161
+
137
162
  // Issue #502: the `model-unknown` refusal's comment. Names no model: the reader may be an issue author.
138
163
  export const MODEL_UNKNOWN_COMMENT = "Refused: this job names an AI model that this deployment does not know (it is in neither pi's model catalog nor the overlay models.json), so no container was started and nothing was spent. Ask the operator to check the trigger's model settings. Not run.";
139
164
 
@@ -271,6 +296,12 @@ export async function runJob(job, deps) {
271
296
  // the store, on a COMPLETED exit only. Never throws. The default is a no-op so a wiring that omits
272
297
  // it behaves exactly as before -- no store, no promotion, no session in the record.
273
298
  promoteSession = () => null,
299
+ // (job) => boolean (issue #625): whether the job's session key holds a `parked` mark. Free and never throws. The
300
+ // default answers no, so a wiring without a store refuses every phrase-free reply rather than running one.
301
+ isParked = () => false,
302
+ // (job, on) => Promise<void> (issue #625): add or remove the forge's parked marker. Never throws; the marker is a
303
+ // hint for the receiver, and the sidecar decides. The default does nothing.
304
+ parkedLabel = async () => {},
274
305
  // The session store's root, or null when the feature is unavailable (REQ-RESUMABLE-SESSION). Read
275
306
  // ONLY to answer the fail-closed gate below -- nothing here opens it, and it never reaches the
276
307
  // container env (docker-run.mjs mounts a per-job COPY, never the store).
@@ -307,6 +338,10 @@ export async function runJob(job, deps) {
307
338
  // would leave an INJECTED resolver running on every job, which is how a probe nobody wanted starts
308
339
  // spawning a subprocess per delivery to learn nothing.
309
340
  resolveSecrets = async (job) => ({ profileUnknown: job.secretsProfile ?? DEFAULT_SECRETS_PROFILE }),
341
+ // (job) => { ok, document } | { refused, name, why } | { unavailable: code } (REQ-MCP-OPERATOR-SERVERS,
342
+ // mcp-preflight.mjs). The default passes a job that names no server and refuses one that does, so a wiring that
343
+ // forgot the check can never run a job without the servers its trigger names.
344
+ checkMcp = (job) => (job?.mcp !== undefined ? { refused: "mcp-config-invalid", name: null, why: "this worker reads no MCP servers" } : { ok: true, document: null }),
310
345
  // #227. Which backends this deployment BLESSED (PI_BACKENDS). Defaults to the one name every
311
346
  // deployment already runs rather than to admit-everything: a wiring that says nothing blesses
312
347
  // `local` only, so a job naming anything else is refused instead of running somewhere the operator
@@ -330,7 +365,8 @@ export async function runJob(job, deps) {
330
365
  // runContainer({ job, token, prepared, secrets, name, signal, user, home, modelEndpoints?, size?, hostCpus?, unenforced? }) => { code, aborted, abortReason, turns, tokens, session, usage, context, exitReason }.
331
366
  // `size` (issue #596) is `jobSize` below; `hostCpus` the runtime's CPU count off the job-user gate's own facts read.
332
367
  // `unenforced` (issue #596) is the size flags that same read says the runtime drops, passed only when there are some.
333
- // `exitReason` (issue #437) is parseExitReason's closed-set label, read only inside the exit-2 branch.
368
+ // `exitReason` (issue #437) is parseExitReason's closed-set label, read only inside the exit-2 branch, and in the
369
+ // exit-0 branch for `awaiting-input` alone (issue #625).
334
370
  // `resources` and `exitOomKilled` (issue #596) are the exit line's cgroup block and the supervisor's OOM report.
335
371
  // `user`/`home` are the job-user gate's answer (issue #341), null for the image's own USER.
336
372
  // `secrets` is the resolved map from the gate above: values, already fetched, host-side. It MUST honour
@@ -491,7 +527,7 @@ export async function runJob(job, deps) {
491
527
  // Three causes, the likeliest first (PR #536's review, round 3, made the check strict): the trigger gained the
492
528
  // field after this job was queued, a service is below the version that carries it, or one still reads an
493
529
  // older copy of the triggers file. The first is fixed by re-running the job, so the comment says so.
494
- await comment(job, `Refused: this trigger sets \`run.${skew.field}\`, but the job reached the worker without it, so it would have run without that limit. Either the trigger changed after this job was queued (re-run it), or a service in this deployment is stale: below the version that carries the field, or still reading an older copy of the triggers file and in need of a restart. Not run.`);
530
+ await comment(job, `Refused: this trigger sets \`run.${skew.field}\`, but the job reached the worker without it, so it would have run as though the trigger did not set it. Either the trigger changed after this job was queued (re-run it), or a service in this deployment is stale: below the version that carries the field, or still reading an older copy of the triggers file and in need of a restart. Not run.`);
495
531
  log("refused_trigger_skew", { triggerIndex: job.trigger?.matched?.index ?? null, field: skew.field, causes: "trigger-changed-after-queue-or-stale-service" });
496
532
  return { outcome: "policy", reason: "trigger-skew", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false };
497
533
  }
@@ -945,6 +981,16 @@ export async function runJob(job, deps) {
945
981
  // purpose: only forge jobs can arm the flag today (triggers.mjs refuses it on cron, and a CLI or
946
982
  // chained job has no trigger entry that could set it), but a gate written as an enumeration of kinds
947
983
  // is a gate the next kind skips silently.
984
+ // A reply that reached this job WITHOUT the trigger phrase (issue #625) was routed only because the forge said the
985
+ // issue carries the parked marker, and anyone who may label an issue can set that. The truth is the key's `parked`
986
+ // mark beside its transcript, and it is free to read, so it is asked here, before the mint and before any spend.
987
+ // Without it the reply is ordinary conversation on an issue: refused, silently (no comment, since the person was
988
+ // talking to people), and the stale marker is cleared so the next comment is not routed here again.
989
+ if (job.parkedReply === true && !isParked(job)) {
990
+ log("refused_not_parked", { kind: job.kind ?? null });
991
+ await parkedLabel(job, false);
992
+ return { outcome: "policy", reason: "not-parked", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
993
+ }
948
994
  if (job.resume === true && !sessionsDir) {
949
995
  await comment(job, "Refused: this trigger set `run.resume` but PI_SESSIONS_DIR is unset, so there is nowhere to persist the transcript -- the job would run with no session and still report success. Set PI_SESSIONS_DIR to a private directory outside every repo, or drop `run.resume` from this trigger. Not run.");
950
996
  // The variable NAME, never a value: there is no path to print here (its absence IS the refusal),
@@ -974,6 +1020,25 @@ export async function runJob(job, deps) {
974
1020
  return { outcome: "policy", reason: "skills-dir-missing", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
975
1021
  }
976
1022
 
1023
+ // REQ-MCP-OPERATOR-SERVERS. A trigger that named MCP servers its job cannot use would run its flow without them,
1024
+ // or hand a server the job's own credentials, or open a connection the egress proxy refuses at its first call:
1025
+ // each a plausible report and a clean exit. One read of the overlay's mcp.json, no credential and no network, so
1026
+ // it sits with the free refusals before the secrets resolver (the one gate that spawns) and before anything that
1027
+ // spends (CONST-BUDGET-BEFORE-TOKENS). Its answer carries the per-job document prepare writes, so the servers the
1028
+ // job connects are exactly the ones this passed.
1029
+ const mcpChecked = checkMcp(job);
1030
+ if (mcpChecked.unavailable) {
1031
+ throw new InfraRetry(`an overlay file the MCP check reads (mcp.json, models.json or egress-allowlist.conf) could not be read just now (${mcpChecked.unavailable})`, { reason: "container-never-started", provider: job.provider ?? null, model: job.model ?? null });
1032
+ }
1033
+ if (mcpChecked.refused) {
1034
+ // The server NAME is the operator's own configuration, the same class as the image ref on the image refusal.
1035
+ // `why` can name an internal host or a variable (never a value, a url's path or its query), so it goes to the
1036
+ // worker's log and the public comment says only which rule refused.
1037
+ await comment(job, `Refused: this trigger names MCP server${mcpChecked.name ? ` "${mcpChecked.name}"` : "s"} in \`run.mcp\`, and ${MCP_REFUSAL_COMMENT[mcpChecked.refused] ?? "it cannot be used"}. The job would have run without the servers its flow was written against; the worker's log says why (\`refused_mcp\`). Not run.`);
1038
+ log("refused_mcp", { kind: job.kind ?? null, reason: mcpChecked.refused, server: mcpChecked.name ?? null, why: mcpChecked.why });
1039
+ return { outcome: "policy", reason: mcpChecked.refused, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
1040
+ }
1041
+
977
1042
  // REQ-TRIGGER-SECRETS. A trigger that named secret references the worker cannot resolve would run its
978
1043
  // flow with those variables UNSET, get a 401 from whatever it was meant to reach, write a plausible
979
1044
  // report about why the integration is down, and exit 0.
@@ -1107,7 +1172,7 @@ export async function runJob(job, deps) {
1107
1172
  // `podmanStore` (issue #429) only where the venue's job user carried one: the podman store the container ran in.
1108
1173
  // `portfolio` (issue #505) only for a job the gate above confirmed: prepare then writes /job/portfolio.json, after
1109
1174
  // asking the live file once more. Absent otherwise, so every other job's prepare call is unchanged.
1110
- prepared = await prepareWorkspace(job, token, { piVersion, jobUser: { user: jobUser?.user ?? null, home: jobUser?.home ?? null }, ...(typeof jobUser?.store === "string" ? { podmanStore: jobUser.store } : {}), ...(portfolio ? { portfolio: true } : {}), ...(jobSize ? { size: jobSize } : {}) }); // resolves SHA, clones, materialises .pi/, writes prompt
1175
+ prepared = await prepareWorkspace(job, token, { piVersion, jobUser: { user: jobUser?.user ?? null, home: jobUser?.home ?? null }, ...(typeof jobUser?.store === "string" ? { podmanStore: jobUser.store } : {}), ...(portfolio ? { portfolio: true } : {}), ...(jobSize ? { size: jobSize } : {}), ...(mcpChecked.document ? { mcp: mcpChecked.document } : {}) }); // resolves SHA, clones, materialises .pi/, writes prompt
1111
1176
 
1112
1177
  // A determinate prepare refusal -- sha-gone (the default branch advanced past the resolved tip),
1113
1178
  // or a `pi-*` materialiser cap breach (the repo's .pi/ is too large to place in /job, issue #60)
@@ -1448,9 +1513,21 @@ export async function runJob(job, deps) {
1448
1513
  // (CONST-RETRY-INFRA-ONLY). Same completed-only rule INT-OUTBOX-CONTRACT already uses, and
1449
1514
  // it sits beside the chain collection for the same reason: both must happen before the
1450
1515
  // `finally` deletes jobDir. Never throws.
1451
- const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context }) : null;
1516
+ // A run that asked a person a question and ended (issue #625, REQ-AWAIT-INPUT) is still a completed run: it is
1517
+ // promoted like any other, with the key marked parked, and the one fixed comment says how it continues.
1518
+ // exitReason is RUNNER_COMPLETED_REASONS-filtered and read only here, on code 0, so it cannot move a job
1519
+ // between retry classes. Only a job armed with run.awaitInput may park: a line that says so on any other
1520
+ // job is ignored, since that runner was never offered the tool.
1521
+ const parked = exitReason === "awaiting-input" && job.awaitInput === true;
1522
+ const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context, parked }) : null;
1523
+ if (parked) await comment(job, parkedComment(job.target));
1524
+ // The forge marker the receiver routes a phrase-free reply on: set on a park, cleared when a run on a key that
1525
+ // could park ends without parking. Never throws, and decides nothing: the sidecar promotion just wrote does.
1526
+ if (parked) await parkedLabel(job, true);
1527
+ else if (job.awaitInput === true || job.parkedReply === true) await parkedLabel(job, false);
1452
1528
  return {
1453
1529
  outcome: "completed",
1530
+ ...(parked ? { reason: "awaiting-input" } : {}),
1454
1531
  exitCode: code,
1455
1532
  turns,
1456
1533
  tokens,
@@ -162,6 +162,7 @@ const UNREACHABLE_BY_SCAN = ["AWS_ENDPOINT_URL_BEDROCK_RUNTIME", "ALL_PROXY", "h
162
162
  */
163
163
  const PI_OWN_READS = [
164
164
  "PI_CLEAR_ON_SHRINK",
165
+ "PI_CLIPBOARD_OSC52",
165
166
  "PI_CODING_AGENT_DIR",
166
167
  "PI_CODING_AGENT_SESSION_DIR",
167
168
  "PI_EXPERIMENTAL",
package/src/queue.mjs CHANGED
@@ -176,7 +176,7 @@ export function swallowedRunSentence(jobId, existing) {
176
176
  return `an identical run was queued${hhmm} as ${jobId} (${existing?.state ?? "already queued or done"}); nothing new was queued.`;
177
177
  }
178
178
 
179
- async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, maxCostUsd, image, backend, excludeTools, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, enqueueNonce, now = new Date() }) {
179
+ async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, maxCostUsd, image, backend, excludeTools, mcp, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, enqueueNonce, now = new Date() }) {
180
180
  const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
181
181
  // A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
182
182
  // minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
@@ -217,6 +217,7 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
217
217
  // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
218
218
  // worker's business, never agent-visible input to reason about.
219
219
  ...(excludeTools !== undefined && { excludeTools }),
220
+ ...(mcp !== undefined && { mcp }),
220
221
  // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
221
222
  // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
222
223
  // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
@@ -310,7 +311,7 @@ export async function enqueueGitLabJob(queue, fields) {
310
311
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
311
312
  * has always been.
312
313
  */
313
- export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
314
+ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, awaitInput, parkedReply, secrets, secretsProfile, waitFor, replica, replicas }) {
314
315
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
315
316
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
316
317
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
@@ -351,6 +352,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
351
352
  // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
352
353
  // worker's business, never agent-visible input to reason about.
353
354
  ...(excludeTools !== undefined && { excludeTools }),
355
+ ...(mcp !== undefined && { mcp }),
354
356
  // #501. The trigger's per-job dollar cap, as written. Conditional like `excludeTools`, at JOB level for its
355
357
  // reason, and a narrowing like it: the worker runs the job under the smaller of this and the deployment cap.
356
358
  ...(maxCostUsd !== undefined && { maxCostUsd }),
@@ -364,6 +366,12 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
364
366
  // delivery, and `trigger` is what /job/event.json is built from.
365
367
  ...(instructions !== undefined && { instructions }),
366
368
  ...(resume !== undefined && { resume }),
369
+ // REQ-AWAIT-INPUT (issue #625): only when armed, so an unflagged job's data is byte-identical, and at JOB level
370
+ // for resume's reason: `trigger` is copied verbatim into /job/event.json.
371
+ ...(awaitInput === true && { awaitInput: true }),
372
+ // A phrase-free reply to a parked issue (issue #625), gated by the worker on the key's `parked` mark. Never part
373
+ // of an id: it says how the delivery was routed, not what it is.
374
+ ...(parkedReply === true && { parkedReply: true }),
367
375
  // REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
368
376
  // them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
369
377
  // credential in Redis would outlive the container it was scoped to. The worker resolves them at job
@@ -43,6 +43,8 @@ export const CONTAINER_ENV_NAMES = new Set([
43
43
  "PI_FLOW",
44
44
  "PI_COMMAND",
45
45
  "PI_EXCLUDE_TOOLS",
46
+ "PI_AWAIT_INPUT",
47
+ "PI_MCP_SERVERS",
46
48
  // Issues #501, #502: the per-job dollar cap (written by the worker since #501, from the trigger's and the
47
49
  // deployment's caps) and the allowed-model list (reserved until #502 writes it). Neither can be bound through
48
50
  // `run.secrets`: a trigger setting its own cap, or its own list, would be the policy choosing itself.
@@ -146,6 +146,12 @@ export function makeRunContainer({
146
146
  // non-empty validated array; the guard is the same defensive shape `flow` above wears, so a
147
147
  // hand-built job with junk in the field emits no variable rather than an empty one.
148
148
  excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
149
+ // REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
150
+ mcpServers: Array.isArray(job.mcp) && job.mcp.length > 0 ? job.mcp : undefined,
151
+ // Issue #625: whether the runner offers the tool a parked run ends on. Only beside a staged transcript: the
152
+ // runner refuses the flag without one (await-input-without-session), so a job whose resume degraded to no
153
+ // mount is not armed into that refusal.
154
+ awaitInput: job.awaitInput === true && prepared.session ? true : undefined,
149
155
  // Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
150
156
  // the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
151
157
  allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
@@ -164,12 +164,38 @@ export function parseExitTurns(text) {
164
164
  * it could not enforce before a call. The two refusals are live from the first image that knows the variables.
165
165
  * `cost-cap` is live from the image that declares `costCap`, whose runner carries the cost guard, and
166
166
  * `model-not-allowed` from the image that declares `modelPolicy`, whose runner carries the model guard.
167
+ *
168
+ * Then the runner's exit-2 refusals of a job's MCP servers (`MCP_RUNNER_REASONS`), each before the prompt. They read as
169
+ * `runner-policy` until they were named here, which hid an operator's fix (a server that answers 401, a command the
170
+ * image lacks) behind budget wording. `mcp-config-invalid` is also the pickup's FREE refusal of an overlay mcp.json it
171
+ * cannot read whole; that one returns `budgetReserved: false` and pages nobody, as the free `model-not-allowed` does.
172
+ * Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
173
+ */
174
+ export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-invalid", "mcp-config-mismatch", "mcp-secret-missing", "mcp-command-missing", "mcp-shadowed"]);
175
+ /**
176
+ * The runner's exit-2 refusals of a job armed with `run.awaitInput` (issue #625), each before the prompt: another
177
+ * extension registers the tool a parked run ends on (`await-input-shadowed`), or the job has no session file to park
178
+ * a transcript in (`await-input-without-session`, a worker regression, since the worker arms the flag only beside
179
+ * `run.resume` and refuses that with no store). Named for MCP_RUNNER_REASONS' reason: the operator's fix is not a budget.
180
+ */
181
+ export const AWAIT_INPUT_RUNNER_REASONS = Object.freeze(["await-input-shadowed", "await-input-without-session"]);
182
+ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS, ...AWAIT_INPUT_RUNNER_REASONS]);
183
+
184
+ /**
185
+ * The runner's reasons that ride a COMPLETED exit (`code: 0`) and that the worker acts on, closed (issue #625). One so
186
+ * far: `awaiting-input`, a run that asked a person a question and ended so the next run resumes with the answer. Every
187
+ * other exit-0 reason (`stop`, `length`, `command-completed`, `tool-terminated`) is still dropped, as before.
188
+ *
189
+ * Read for the same bounded reason RUNNER_POLICY_REASONS is: it cannot change the retry class. The processor consults it
190
+ * only inside its exit-0 branch, so the worst a forged line can do on a real exit 0 is mark a completed run parked: the
191
+ * worker then posts one fixed sentence and writes a marker beside a transcript the job already promoted.
167
192
  */
168
- export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable"]);
193
+ export const RUNNER_COMPLETED_REASONS = new Set(["awaiting-input"]);
169
194
 
170
195
  /**
171
196
  * The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
172
- * and only when that same line says `code: 2`. Read off the same line as `parseExitTurns`, and NEVER throws.
197
+ * and only when that same line says `code: 2`, or a member of `RUNNER_COMPLETED_REASONS` and only when it says
198
+ * `code: 0` (issue #625). Read off the same line as `parseExitTurns`, and NEVER throws.
173
199
  *
174
200
  * Why this may reach the outcome when its five siblings may not: it cannot change the retry class. The
175
201
  * processor consults it only inside its container-exit-2 branch, so a container that exits 1 while
@@ -182,6 +208,7 @@ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap
182
208
  export function parseExitReason(text) {
183
209
  const parsed = decisiveExitLine(text);
184
210
  if (parsed === null) return null;
211
+ if (parsed?.code === 0 && RUNNER_COMPLETED_REASONS.has(parsed?.reason)) return parsed.reason;
185
212
  return parsed?.code === 2 && RUNNER_POLICY_REASONS.has(parsed?.reason) ? parsed.reason : null;
186
213
  }
187
214
 
package/src/schedules.mjs CHANGED
@@ -182,7 +182,7 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
182
182
  // `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
183
183
  // level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
184
184
  // budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
185
- const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
185
+ const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.mcp !== undefined && { mcp: run.mcp }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
186
186
  // Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
187
187
  // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
188
188
  const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
@@ -90,6 +90,15 @@ const CONTEXT_FILE = "context";
90
90
  * stamp existed ran on `local`, and the default is a setting that can move under it.
91
91
  */
92
92
  const VENUE_FILE = "venue";
93
+ /**
94
+ * The run that wrote the transcript beside it ended `awaiting-input` (REQ-AWAIT-INPUT, issue #625): it asked a person
95
+ * a question and ended so that the next run on this key resumes with the answer. One line, the promotion's
96
+ * `now()` in milliseconds. Keyed state written where the key already is, like `resume-chain`, so it is not the
97
+ * index `DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED` refuses: it answers one question about this key and nothing
98
+ * looks it up by anything else. Written and REMOVED by promotion alone, under the promotion lock: a parked run
99
+ * writes it, any other completed run on the key removes it, so it never outlives the transcript it describes.
100
+ */
101
+ export const PARKED_FILE = "parked";
93
102
  /**
94
103
  * What the venue stamp reads while a promotion is swapping the transcript under it. It matches no venue:
95
104
  * parentheses are outside the charset every backend name is validated against (a test pins every name this
@@ -540,7 +549,7 @@ export function makeSessionStore({
540
549
  return st.isDirectory() ? `${st.dev}:${st.ino}` : null;
541
550
  }
542
551
 
543
- function promoteSession(session, { piVersion = null, context = null } = {}) {
552
+ function promoteSession(session, { piVersion = null, context = null, parked = false } = {}) {
544
553
  // The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
545
554
  // above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
546
555
  // rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
@@ -711,6 +720,7 @@ export function makeSessionStore({
711
720
  // it is reporting.
712
721
  writeSidecar(dir, RESUME_CHAIN_FILE, session.key, chainValue(session));
713
722
  writeContextSidecar(dir, session, context);
723
+ writeParkedSidecar(dir, session, parked);
714
724
  } finally {
715
725
  fs.closeSync(fd);
716
726
  try {
@@ -840,6 +850,20 @@ export function makeSessionStore({
840
850
  writeSidecar(dir, CONTEXT_FILE, session.key, `${context.tokens} ${context.window}${stamp}`);
841
851
  }
842
852
 
853
+ // Issue #625: `parked` is set by a run that ended awaiting-input, and cleared by every other promotion, so the
854
+ // marker always describes the transcript beside it. Caught like its siblings: the promotion already happened.
855
+ function writeParkedSidecar(dir, session, parked) {
856
+ if (parked === true) {
857
+ writeSidecar(dir, PARKED_FILE, session.key, String(now()));
858
+ return;
859
+ }
860
+ try {
861
+ fs.unlinkSync(join(dir, PARKED_FILE));
862
+ } catch {
863
+ // Absent is the desired state, so failing to remove what is not there is success.
864
+ }
865
+ }
866
+
843
867
  function keyDir(key) {
844
868
  return join(sessionsDir, key);
845
869
  }
@@ -1289,5 +1313,24 @@ export function makeSessionStore({
1289
1313
  }
1290
1314
  }
1291
1315
 
1292
- return { resolveSession, promoteSession, reapSessions };
1316
+ /**
1317
+ * Whether the key a job resolves to holds a `parked` mark (issue #625): the TRUTH the worker gates a phrase-free reply
1318
+ * on, where the forge label was only the receiver's hint. Free, read-only and never throws, so it can answer before
1319
+ * the mint. An issue's key is a pure function of the job (`pi/issue-<n>`); a pull request's needs its head branch,
1320
+ * which costs a token to read, which is why a phrase-free reply is routed on issues only and this resolves no PR.
1321
+ * The key's own name must `lstat` as a real directory, resolveSession's first gate, so a planted link answers no.
1322
+ */
1323
+ function isParked(job) {
1324
+ if (!sessionsDir || job?.target?.type === "pull_request") return false;
1325
+ const key = sessionKeyFor(job);
1326
+ if (!key) return false;
1327
+ try {
1328
+ if (!fs.lstatSync(keyDir(key)).isDirectory()) return false;
1329
+ } catch {
1330
+ return false;
1331
+ }
1332
+ return readSidecar(key, PARKED_FILE) !== null;
1333
+ }
1334
+
1335
+ return { resolveSession, promoteSession, reapSessions, isParked };
1293
1336
  }
package/src/start.mjs CHANGED
@@ -62,7 +62,10 @@ import { PODMAN_BOOT_REFUSING_CAUSES, PODMAN_INFO_TIMEOUT_MS, cachedPodmanInfo,
62
62
  import { PODMAN_RESTART_HOLD_EXPIRED, makePodmanServiceReader, onceFs, makeRootfulMemory, observeHost, observeRootfulConf, readRootfulService, rootfulConfRefusal, rootfulConfRetries, rootfulUnreadList, runtimeObservationKey } from "./runtime-observations.mjs";
63
63
 
64
64
  import { makeRunContainer } from "./run-container.mjs";
65
- import { resolveProviderCredential } from "./env-allowlist.mjs";
65
+ import { piProviders, providerKeyCandidates, resolveProviderCredential } from "./env-allowlist.mjs";
66
+ import { parseAllowlist } from "./egress-allowlist.mjs";
67
+ import { MCP_TRANSIENT_READ_CODES, jobProviders, makeMcpCheck, providerKeyNames } from "./mcp-preflight.mjs";
68
+ import { RESERVED_ENV_NAMES } from "./triggers.mjs";
66
69
  import { makeSecretsResolver } from "./secrets.mjs";
67
70
  import { buildRecord, EXIT_OOM_KILLED, makeFindPreviousRun, makeLogReaper, makeLogSink, makeReadRecord, makeRecordWriter, makeSettledRecord, newerRecord, RUNNER_POLICY_REASONS, sanitizeJobId, UNREADABLE_RECORD } from "./run-history.mjs";
68
71
  import { makeRunMirror, readMirroredRecord } from "./run-mirror.mjs";
@@ -1999,6 +2002,23 @@ export async function startWorker(
1999
2002
  log("comment", { jobId: job?.id, text });
2000
2003
  };
2001
2004
 
2005
+ // Issue #625: the forge's parked marker, set on a park and cleared when it no longer applies. comment()'s shape and its
2006
+ // rule: it NEVER throws, because the marker decides nothing (the session store's sidecar does) and a forge that refuses
2007
+ // the label must not flip an outcome. A local job has no forge and no marker. Issues only: a pull request's reply is
2008
+ // routed on the phrase (parked.mjs).
2009
+ const parkedLabel = async (job, on) => {
2010
+ const forge = forgeFor(job);
2011
+ if (!forge?.host?.setParkedLabel || job?.target?.type !== "issue") return;
2012
+ try {
2013
+ const auth = forge.auth ?? (await ensureAuth(job.kind));
2014
+ if (!auth) return;
2015
+ const token = await auth.mintToken(job);
2016
+ await forge.host.setParkedLabel(job, job.target, on, token);
2017
+ } catch (err) {
2018
+ log("parked_label_failed", { jobId: job?.id, on, reason: err?.message });
2019
+ }
2020
+ };
2021
+
2002
2022
  // The operator's failure hook (issue #288, INT-ON-FAILURE-HOOK-CONTRACT). Constructed ONLY when the
2003
2023
  // knob is set: an unset PI_ON_FAILURE builds nothing, spawns nothing, and logs nothing -- the
2004
2024
  // byte-identical guarantee. Fired from the two terminal listeners below, never from the processor:
@@ -2238,6 +2258,8 @@ export async function startWorker(
2238
2258
  // Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
2239
2259
  // retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
2240
2260
  promoteSession: sessionStore.promoteSession,
2261
+ isParked: sessionStore.isParked,
2262
+ parkedLabel,
2241
2263
  // The same value the store above was built from, passed explicitly so the processor's fail-closed
2242
2264
  // `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
2243
2265
  // real path; the difference shows under an injected env, where the store would be built from the
@@ -2256,6 +2278,42 @@ export async function startWorker(
2256
2278
  // injected env they were not, which is the divergence this file already calls out by name for
2257
2279
  // sessionsDir. One deployment value, one place, same rule as the profiles below it.
2258
2280
  resolveSecrets: makeSecretsResolverFn({ envProfiles: config.secretProfiles, roots: config.secretResolverRoots, timeoutMs: config.secretResolveTimeoutMs, forwardEnv: config.forwardEnv, hostEnv: env, log }),
2281
+ // REQ-MCP-OPERATOR-SERVERS: the overlay's mcp.json read once per job that names a server, never at boot (an
2282
+ // operator who re-runs import-pi must not have to restart the worker). The allowlist is the one the proxy
2283
+ // mounts, this folder's egress-allowlist.conf on both venues (up.mjs, podman-stack.mjs), and the endpoints
2284
+ // are the pickup's snapshot, so the check and the proxy read the same declarations.
2285
+ checkMcp: makeMcpCheck({
2286
+ globalPiDir: config.globalPiDir,
2287
+ forwardEnv: config.forwardEnv,
2288
+ hostEnv: env,
2289
+ egress: config.egress,
2290
+ // DES-FORGE-MCP: the GitLab a gitlab job's forge server calls, the same URL its clone and its glab use.
2291
+ gitlabUrl: config.gitlab?.apiUrl ?? null,
2292
+ // The credentials the job is set up to spend with (providerKeyNames). A read of the overlay models.json that
2293
+ // may pass on a retry is thrown to the check, which retries the job; any other failure adds no overlay
2294
+ // names, and the model gates, which ran first, refuse every job such a file breaks.
2295
+ reserved: (job) => {
2296
+ let overlayModels = null;
2297
+ try {
2298
+ overlayModels = readOverlayModels(config.globalPiDir);
2299
+ } catch (error) {
2300
+ if (MCP_TRANSIENT_READ_CODES.has(error?.code)) throw error;
2301
+ }
2302
+ const providers = jobProviders(job, { defaultProvider: config.provider, allowedModels: config.allowedModels, everyProvider: piProviders() });
2303
+ return new Set([...RESERVED_ENV_NAMES, ...providerKeyNames({ providers, candidatesOf: providerKeyCandidates, overlayModels })]);
2304
+ },
2305
+ allowlist: () => {
2306
+ try {
2307
+ return parseAllowlist(readFileSync(join(process.cwd(), "egress-allowlist.conf"), "utf8"));
2308
+ } catch (error) {
2309
+ // No allowlist here: every server outside a declared endpoint is unreachable, which it is. Any other
2310
+ // failure is the check's to classify (a transient errno is retried).
2311
+ if (error?.code === "ENOENT") return [];
2312
+ throw error;
2313
+ }
2314
+ },
2315
+ endpoints: () => modelEndpoints.current,
2316
+ }),
2259
2317
  // #227. What PI_BACKENDS blessed, so a trigger naming an unblessed venue refuses pre-spend. The
2260
2318
  // panel's picker is bounded by the same list, and this is the half that binds: the overlay is
2261
2319
  // not the reviewed artifact (DES-PER-TRIGGER-SECRET-PROFILE).
@@ -475,6 +475,13 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
475
475
  // unknown), the job would run under the deployment's wider cap, or with none, on a clean record. Any present
476
476
  // value counts as authored; whether it is a valid amount is the loader's question, not this one.
477
477
  Object.freeze({ key: "maxCostUsd", authored: (v) => v !== undefined && v !== null }),
478
+ // REQ-MCP-OPERATOR-SERVERS: the MCP servers a trigger's jobs connect. Not a widening when dropped but the same
479
+ // silent outcome: the job runs its flow without the servers it was written against, on a clean record.
480
+ Object.freeze({ key: "mcp", authored: (v) => Array.isArray(v) && v.length > 0 }),
481
+ // REQ-AWAIT-INPUT: the tool a run ends on to wait for a person. Dropped by a receiver from before #625 (which tolerates
482
+ // the key as unknown), the job runs without it: the agent cannot ask, and the record reads like a clean run. Only
483
+ // `true` arms it, and the job data carries it only then, so `false` is nothing authored.
484
+ Object.freeze({ key: "awaitInput", authored: (v) => v === true }),
478
485
  ]);
479
486
 
480
487
  export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {