@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/.env.example +6 -4
- package/package.json +4 -2
- package/src/azure-host.mjs +28 -1
- package/src/azure-prompt.mjs +26 -3
- package/src/config.mjs +2 -2
- package/src/doctor.mjs +185 -13
- package/src/egress-allowlist.mjs +81 -0
- package/src/env-allowlist.mjs +8 -1
- package/src/forgejo-host.mjs +33 -1
- package/src/forgejo-prompt.mjs +26 -3
- package/src/github-host.mjs +21 -1
- package/src/github-prompt.mjs +26 -3
- package/src/gitlab-host.mjs +24 -1
- package/src/gitlab-prompt.mjs +26 -3
- package/src/host-pi.mjs +8 -5
- package/src/image-preflight.mjs +35 -1
- package/src/import-pi.mjs +113 -8
- package/src/mcp-config.mjs +466 -0
- package/src/mcp-preflight.mjs +262 -0
- package/src/model-catalog.mjs +2 -0
- package/src/model-endpoints.mjs +8 -3
- package/src/models-json.mjs +65 -53
- package/src/outbox.mjs +7 -1
- package/src/parked.mjs +18 -0
- package/src/prepare.mjs +12 -4
- package/src/processor.mjs +81 -4
- package/src/provider-steering.mjs +1 -0
- package/src/queue.mjs +10 -2
- package/src/reserved-env.mjs +2 -0
- package/src/run-container.mjs +6 -0
- package/src/run-history.mjs +29 -2
- package/src/schedules.mjs +1 -1
- package/src/session-store.mjs +45 -2
- package/src/start.mjs +59 -1
- package/src/triggers-file.mjs +7 -0
- package/src/triggers.mjs +152 -7
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
|
|
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
|
-
|
|
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,
|
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
|
package/src/reserved-env.mjs
CHANGED
|
@@ -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.
|
package/src/run-container.mjs
CHANGED
|
@@ -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,
|
package/src/run-history.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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 } };
|
package/src/session-store.mjs
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/src/triggers-file.mjs
CHANGED
|
@@ -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 }) {
|