@edgehero/pi-dispatch 4.2.0 → 4.3.1

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/forges.mjs CHANGED
@@ -37,8 +37,13 @@
37
37
  * names that must be refused in `PI_FORWARD_ENV`, and those two lists being derived from one entry is
38
38
  * the point: a forge added to the mint but not to the refusal set is a long-lived host token forwarded
39
39
  * into every container, and nothing would have said so.
40
- * - `hostVar` is where a self-hosted instance URL lands in the container, or `null` for a forge that has
41
- * no instance concept in this codebase yet.
40
+ * - `hostVars` are where a self-hosted instance URL lands in the container (the configured URL verbatim,
41
+ * scheme included), or an empty list for a forge that has no instance concept in this codebase yet. A
42
+ * LIST for the reason `tokenVars` is one: the CLI's own name is the one that matters, and a second,
43
+ * forge-named copy is what scripts and skills reach for.
44
+ * - `receiverOnlyVars` are credentials of this forge that only the RECEIVER reads, and that must never reach a
45
+ * job: refused in `PI_FORWARD_ENV`, reserved from `run.secrets`, and deleted from the container env as a
46
+ * backstop (`RECEIVER_ONLY_VARS`). Empty for every forge whose author gate the job's own token can answer.
42
47
  * - `prLabelAction` is this forge's `pull_request` action meaning "a label changed", or `null` where the
43
48
  * forge has no distinguishable one. It decides whether a rule naming that action must carry a positive
44
49
  * selector, and the reason is independent of who is gating: a rule keyed on labels that names no labels
@@ -51,7 +56,8 @@ export const FORGES = {
51
56
  deliveryIdName: "X-GitHub-Delivery GUID",
52
57
  pullRequestSep: "#",
53
58
  tokenVars: ["GITHUB_TOKEN", "GH_TOKEN"],
54
- hostVar: null,
59
+ hostVars: [],
60
+ receiverOnlyVars: [],
55
61
  prLabelAction: "labeled",
56
62
  },
57
63
  gitlab: {
@@ -59,7 +65,8 @@ export const FORGES = {
59
65
  deliveryIdName: "webhook-id / Idempotency-Key",
60
66
  pullRequestSep: "!",
61
67
  tokenVars: ["GITLAB_TOKEN", "GL_TOKEN"],
62
- hostVar: "GITLAB_HOST",
68
+ hostVars: ["GITLAB_HOST"],
69
+ receiverOnlyVars: [],
63
70
  // A label added to a merge request arrives as a plain `update`, indistinguishable from any other
64
71
  // edit, so there is no action for a positive-selector rule to attach to. (Separately, a GitLab label
65
72
  // is not an approval at all -- a Guest can set one at issue creation -- which is why every gitlab
@@ -76,9 +83,23 @@ export const FORGES = {
76
83
  // Forgejo numbers issues and pull requests from ONE per-repository sequence, exactly as GitHub does
77
84
  // -- an issue and a PR cannot share an index. So `#` names one thing and no discriminator is needed.
78
85
  pullRequestSep: "#",
79
- // `tea` reads GITEA_SERVER_TOKEN; the unprefixed name is what the API examples and most scripts use.
80
- tokenVars: ["FORGEJO_TOKEN", "GITEA_SERVER_TOKEN"],
81
- hostVar: "FORGEJO_HOST",
86
+ // `tea` logs in from the environment ONLY through GITEA_TOKEN plus GITEA_INSTANCE_URL. Measured on the
87
+ // pinned tea 0.15.0 in the 4.3.0 job image against a live Forgejo 14.0.5: that pair lists issues, while
88
+ // GITEA_SERVER_URL + GITEA_SERVER_TOKEN (with or without GITEA_LOGIN_VIA_ENV=1), FORGEJO_HOST +
89
+ // GITEA_SERVER_TOKEN, and GITEA_TOKEN alone inside a clone all fail "Error: no available login". So
90
+ // from be1cc42e until this row changed, every `tea` command the forgejo envelope tells the agent to run
91
+ // failed. GITEA_SERVER_TOKEN stays: it is the default of `tea login add --token` (its --help says
92
+ // `[$GITEA_SERVER_TOKEN]`), so an agent that adds a login explicitly still gets the job's token, and it
93
+ // stays reserved. FORGEJO_TOKEN is what the API examples and most scripts use.
94
+ // worker/test/forges.test.mjs pins these names to the tea version image/Dockerfile installs.
95
+ tokenVars: ["FORGEJO_TOKEN", "GITEA_TOKEN", "GITEA_SERVER_TOKEN"],
96
+ // GITEA_INSTANCE_URL is tea's, and must be the full URL with its scheme (FORGEJO_URL is). FORGEJO_HOST
97
+ // is read by no CLI; it is kept because it has been in every forgejo job since #61 and a skill may use it.
98
+ hostVars: ["GITEA_INSTANCE_URL", "FORGEJO_HOST"],
99
+ // The author gate's own lookup token (OQ-043): Forgejo answers a lookup of another user's permission only to a
100
+ // repository admin, so this is a read:repository token of an account that is one, and the job's FORGEJO_TOKEN
101
+ // can stay a write collaborator's. A job holding it would hold an admin's token, which is the whole point lost.
102
+ receiverOnlyVars: ["FORGEJO_PERMISSION_TOKEN"],
82
103
  // Forgejo names the action, so the positive-selector rule applies exactly as it does on GitHub.
83
104
  // Note this is HYGIENE, not the gate: unlike GitHub, every forgejo trigger is additionally gated on
84
105
  // the actor's resolved repository permission -- see filter-forgejo.mjs for why that is not
@@ -99,7 +120,8 @@ export const FORGES = {
99
120
  pullRequestSep: "!",
100
121
  // `az repos` reads AZURE_DEVOPS_EXT_PAT; SYSTEM_ACCESSTOKEN is what a pipeline-shaped script expects.
101
122
  tokenVars: ["AZURE_DEVOPS_EXT_PAT", "SYSTEM_ACCESSTOKEN"],
102
- hostVar: "AZURE_DEVOPS_ORG_URL",
123
+ hostVars: ["AZURE_DEVOPS_ORG_URL"],
124
+ receiverOnlyVars: [],
103
125
  // Azure attaches no labels to a pull request at all, so there is no action to attach the rule to and
104
126
  // a predicated PR rule could never match. The loader refuses one rather than letting it load dead.
105
127
  prLabelAction: null,
@@ -140,14 +162,20 @@ export const MINTED_TOKEN_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[ki
140
162
  * `MINTED_TOKEN_VARS` and derived the same way, from the table rather than by hand, so a forge added
141
163
  * later cannot be missed here either.
142
164
  *
143
- * Separate from `MINTED_TOKEN_VARS` because `hostVar` is a separate column: the token set is
165
+ * Separate from `MINTED_TOKEN_VARS` because `hostVars` is a separate column: the token set is
144
166
  * `tokenVars` only, so a name like `GITLAB_HOST` is in NEITHER set until this one exists. That gap is
145
167
  * not theoretical -- `buildContainerEnv` writes this variable after the mint, so a trigger field able
146
168
  * to name it would be silently overwritten, and the job would run without the value it asked for on a
147
- * clean exit 0. github's row is `null` and contributes nothing, which is why the filter is here and
148
- * not at the call site.
169
+ * clean exit 0. github's row is empty and contributes nothing.
149
170
  */
150
- export const FORGE_HOST_VARS = new Set(FORGE_KINDS.map((kind) => FORGES[kind].hostVar).filter((name) => typeof name === "string"));
171
+ export const FORGE_HOST_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].hostVars));
172
+
173
+ /**
174
+ * Every credential name only the receiver reads (`receiverOnlyVars`), derived from the table like the two sets above.
175
+ * The worker refuses each in `PI_FORWARD_ENV`, the triggers loader refuses each in `run.secrets`, and
176
+ * `buildContainerEnv` deletes each last, so no path puts one in a container.
177
+ */
178
+ export const RECEIVER_ONLY_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].receiverOnlyVars));
151
179
 
152
180
  /**
153
181
  * The separator between a repo label and a target number, for this forge and this target type: the
@@ -26,6 +26,7 @@
26
26
  import { Octokit } from "@octokit/rest";
27
27
  import { configError } from "./config.mjs";
28
28
  import { octokitLogOptions } from "./octokit-log.mjs";
29
+ import { PARKED_LABEL } from "./parked.mjs";
29
30
  import { InfraRetry } from "./processor.mjs";
30
31
 
31
32
  /**
@@ -134,7 +135,26 @@ export function makeGitHubHost({ log, octokitFor = (token) => new Octokit({ auth
134
135
  return { headRef: data?.head?.ref, headRepo: data?.head?.repo?.full_name };
135
136
  }
136
137
 
137
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
138
+ /**
139
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). GitHub creates a label the repository lacks
140
+ * when it is added, so there is nothing to create first. Removing a label the issue does not carry is a 404, which is
141
+ * the state asked for. The caller treats any throw as fail-open: the session store's sidecar, not this label, decides.
142
+ */
143
+ async function setParkedLabel(repo, target, on, token) {
144
+ const [owner, name] = splitRepo(repo);
145
+ const octokit = octokitFor(token);
146
+ if (on) {
147
+ await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { owner, repo: name, issue_number: target?.number, labels: [PARKED_LABEL] });
148
+ return;
149
+ }
150
+ try {
151
+ await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { owner, repo: name, issue_number: target?.number, name: PARKED_LABEL });
152
+ } catch (error) {
153
+ if (error?.status !== 404) throw error;
154
+ }
155
+ }
156
+
157
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
138
158
  }
139
159
 
140
160
  /**
@@ -155,6 +155,31 @@ function prReplicaLines(replica, replicas) {
155
155
  * The safety paragraph is repeated verbatim rather than assumed inherited. It is cheap, and the whole
156
156
  * premise of a long-lived transcript is that early turns get compacted away.
157
157
  */
158
+ /**
159
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
160
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
161
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
162
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
163
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
164
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
165
+ */
166
+ function resumedActionLines(target, n) {
167
+ if (target?.type === "pull_request") {
168
+ return [
169
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
170
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
171
+ "not. Do not open a second pull request -- your push updates the existing one.",
172
+ ];
173
+ }
174
+ const branch = issueBranch(n);
175
+ return [
176
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
177
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
178
+ `branch only. Check first for an open pull request from it (\`gh pr list --head ${branch} --state open\`):`,
179
+ "if one exists your push updates it, so do not open a second; only if none exists, run `gh pr create`.",
180
+ ];
181
+ }
182
+
158
183
  function buildResumedPrompt(flow, target, comment, review, instructions) {
159
184
  const n = normalizeNumber(target?.number);
160
185
  const noun = target?.type === "pull_request" ? "pull request" : "issue";
@@ -168,9 +193,7 @@ function buildResumedPrompt(flow, target, comment, review, instructions) {
168
193
  `and \`gh pr diff ${n}\` (or \`gh issue view ${n}\`) before doing anything, then follow the`,
169
194
  `"${flow}" skill from the top.`,
170
195
  "",
171
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
172
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
173
- "not. Do not open a second pull request -- your push updates the existing one.",
196
+ ...resumedActionLines(target, n),
174
197
  "",
175
198
  // The operator block sits HERE and not after: later text reads as more specific, and the harness's
176
199
  // non-negotiables must be the last thing before the data region rather than something an operator
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { configError } from "./config.mjs";
27
+ import { PARKED_LABEL } from "./parked.mjs";
27
28
  import { InfraRetry } from "./processor.mjs";
28
29
  import { fetchFailureReason } from "./gitlab-identity.mjs";
29
30
  import { isDeterminateFetchFailure } from "./transient.mjs";
@@ -164,7 +165,29 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
164
165
  return { headRef: mr?.source_branch, headRepo: sameProject ? job?.repo : null };
165
166
  }
166
167
 
167
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
168
+ /**
169
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). `add_labels` creates a
170
+ * project label the project lacks, and `remove_labels` of one the issue does not carry changes nothing. The caller treats any throw as fail-open:
171
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
172
+ */
173
+ async function setParkedLabel(projectRef, target, on, token) {
174
+ const id = projectId(projectRef);
175
+ const path = `/projects/${id}/issues/${encodeURIComponent(target?.number)}`;
176
+ let res;
177
+ try {
178
+ res = await fetchFn(`${root}${path}`, {
179
+ method: "PUT",
180
+ headers: { "PRIVATE-TOKEN": token, "content-type": "application/json" },
181
+ body: JSON.stringify(on ? { add_labels: PARKED_LABEL } : { remove_labels: PARKED_LABEL }),
182
+ redirect: "error",
183
+ });
184
+ } catch (err) {
185
+ throw new InfraRetry(`gitlab-host: PUT ${path} failed (${fetchFailureReason(err)})`);
186
+ }
187
+ if (!res.ok) throw new InfraRetry(`gitlab-host: PUT ${path} returned ${res.status}`);
188
+ }
189
+
190
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
168
191
  }
169
192
 
170
193
  /**
@@ -76,6 +76,31 @@ function mrReplicaLines(replica, replicas) {
76
76
  * toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no
77
77
  * idea what it was asked to do. `glab`, never `gh`: this envelope's whole reason for existing.
78
78
  */
79
+ /**
80
+ * What a resumed run is told to do with the activity (issue #624). On a merge request the branch and the pull
81
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
82
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
83
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
84
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
85
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
86
+ */
87
+ function resumedActionLines(target, n) {
88
+ if (target?.type === "pull_request") {
89
+ return [
90
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
91
+ "`git push --force-with-lease`, and reply on the merge request saying what you did or why you could",
92
+ "not. Do not open a second merge request -- your push updates the existing one.",
93
+ ];
94
+ }
95
+ const branch = issueBranch(n);
96
+ return [
97
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
98
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
99
+ `branch only. Check first for an open merge request from it (\`glab mr list --source-branch ${branch}\`):`,
100
+ "if one exists your push updates it, so do not open a second; only if none exists, run `glab mr create`.",
101
+ ];
102
+ }
103
+
79
104
  function buildResumedPrompt(flow, target, comment, instructions) {
80
105
  const n = normalizeNumber(target?.number);
81
106
  const noun = target?.type === "pull_request" ? "merge request" : "issue";
@@ -89,9 +114,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
89
114
  `and \`glab mr diff ${n}\` (or \`glab issue view ${n}\`) before doing anything, then follow the`,
90
115
  `"${flow}" skill from the top.`,
91
116
  "",
92
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
93
- "`git push --force-with-lease`, and reply on the merge request saying what you did or why you could",
94
- "not. Do not open a second merge request -- your push updates the existing one.",
117
+ ...resumedActionLines(target, n),
95
118
  "",
96
119
  // Above the never-merge paragraph, so the harness has the last word before the data region.
97
120
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
package/src/host-pi.mjs CHANGED
@@ -72,11 +72,14 @@ export const PINNED_PI_NEEDLES = {
72
72
  "const path = `${BUILTIN_PATH_PREFIX}${name}`;",
73
73
  ],
74
74
  "dist/core/source-info.js": ['export const BUILTIN_PATH_PREFIX = "builtin:";'],
75
- "dist/core/settings-manager.d.ts": [
76
- "export type PackageSource = string | {",
77
- " autoload?: boolean;",
78
- " packages?: PackageSource[];",
79
- " extensions?: string[];",
75
+ // The settings shape parseHostSettings reads. Since pi 1.2.0 it is a TypeBox schema in settings-schema (the same
76
+ // union: a string, or an object with `source` and an optional `autoload`), and pi still loads the file without
77
+ // validating it, which is why the parse here stays lenient.
78
+ "dist/core/settings-schema.d.ts": [
79
+ "declare const PackageSourceSchema: Type.TUnion<[Type.TString, Type.TObject<{",
80
+ " autoload: Type.TOptional<Type.TBoolean>;",
81
+ " packages: Type.TOptional<Type.TArray<Type.TUnion<[Type.TString, Type.TObject<{",
82
+ " extensions: Type.TOptional<Type.TArray<Type.TString>>;",
80
83
  ],
81
84
  };
82
85
 
@@ -215,6 +215,16 @@ export const CAPABILITY_GATES = Object.freeze([
215
215
  event: "refused_image_forge_mcp_unsupported",
216
216
  comment: (image, declared) => `Refused: the job image "${image}" does not declare the forge's own MCP server (\`dev.pi-dispatch.capabilities\` ${declared}), so it does not carry the server \`run.mcp\` names as \`forge\`. Rebuild the image from a version that has this feature. Not run.`,
217
217
  }),
218
+ // Issue #625: a runner that predates run.awaitInput reads no PI_AWAIT_INPUT, so the agent is never offered the tool a
219
+ // parked run ends on: it guesses or gives up, and the run ends without the question the trigger was armed for.
220
+ Object.freeze({
221
+ token: "awaitInput",
222
+ needed: (job) => job?.awaitInput === true,
223
+ result: "awaitInputUnsupported",
224
+ reason: "job-image-await-input-unsupported",
225
+ event: "refused_image_await_input_unsupported",
226
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for stopping to wait for an answer (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.awaitInput\`. Rebuild the image from a version that has this feature. Not run.`,
227
+ }),
218
228
  ]);
219
229
 
220
230
  /**
package/src/index.mjs CHANGED
@@ -22,6 +22,7 @@ import { BUDGET_RECHECK_MS, HOST_BUDGET_TICK_MS, NEVER_FITS_RECHECK_MS, makeHost
22
22
  import { governedDollars } from "./allocation.mjs";
23
23
  import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, afterMs, unreadableConditions, waitArmed, waitBackoffMs, waitLabel, waitProfileNames } from "./wait-for.mjs";
24
24
  import { makeWaitState } from "./wait-state.mjs";
25
+ import { releaseSemanticWindow } from "./queue.mjs";
25
26
 
26
27
 
27
28
  export const QUEUE = "pi-jobs";
@@ -1344,6 +1345,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1344
1345
  // mirroring the name/signal injection above. Omitted when unwired so a bare processor falls back
1345
1346
  // to runJob's no-op default (a chain fault can never flip a completed outcome either way).
1346
1347
  ...(deps.collectChain ? { collectChain: (ctx) => deps.collectChain({ ...ctx, job }) } : {}),
1348
+ // DES-AWAIT-INPUT-ENDS-THE-JOB: a parked run's semantic window is closed on the REAL BullMQ job, which holds the
1349
+ // dedup id the add stored and the compare-and-delete that frees only a key it still owns (queue.mjs). Bound
1350
+ // unconditionally, AFTER the `...deps` spread like the injections around it: whether the key may outlive a park
1351
+ // is not a choice a wiring gets to make by omission.
1352
+ releaseParkedWindow: () => releaseSemanticWindow(job, deps?.log),
1347
1353
  // The plan collector (issue #505) the same way and for the same reason: the writer it records is the REAL job's
1348
1354
  // `.id`, and the authority it checks is `.data` as queued. The pickup's `portfolio` decision rides in `ctx`.
1349
1355
  ...(deps.collectPlan ? { collectPlan: (ctx) => deps.collectPlan({ ...ctx, job }) } : {}),
@@ -125,6 +125,8 @@ export function overlayProviderProblem(config, providerId) {
125
125
  if (!api) return "no-api";
126
126
  const baseUrl = def.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
127
127
  if (!baseUrl) return "no-baseUrl";
128
+ // pi 1.2.0's schema refuses both first (a token count must be above zero), so a file reaching here holds none; kept
129
+ // because provider-composer.js still checks them, and the mirror follows that file, not the schema in front of it.
128
130
  if (def.contextWindow !== undefined && def.contextWindow <= 0) return "contextWindow";
129
131
  if (def.maxTokens !== undefined && def.maxTokens <= 0) return "maxTokens";
130
132
  const composed = { id: def.id, api, baseUrl };
@@ -121,8 +121,13 @@ const T = {
121
121
  const opt = (check) => [check, true];
122
122
  const req = (check) => [check, false];
123
123
 
124
- // The schema, transcribed from model-config.js at the pin (1.0.3; worker/test/models-json.test.mjs holds the file's
125
- // content hash), in its order.
124
+ // The schema, transcribed at the pin (1.2.0; worker/test/models-json.test.mjs holds each file's content hash). Since
125
+ // 1.2.0 model-config.js builds it from two pi-ai modules, `providers/model-schema.js` (cost, prompt cache, input
126
+ // limits, thinking map, modalities) and `providers/compat-schema.js` (compat), so all three are pinned.
127
+ //
128
+ // The change that matters in 1.2.0: `compat` was a UNION of three object schemas that each admit extra keys, so a value
129
+ // one api's schema refused passed through another (`supportsStore: "x"` loaded). It is now ONE object, the superset of
130
+ // five apis' properties, so every known compat key is type-checked wherever `compat` appears.
126
131
  const PercentileCutoffs = T.obj({ p50: opt(T.num()), p75: opt(T.num()), p90: opt(T.num()), p99: opt(T.num()) });
127
132
  const numOrStr = T.union(T.num(), T.str());
128
133
  const OpenRouterRouting = T.obj({
@@ -141,70 +146,76 @@ const OpenRouterRouting = T.obj({
141
146
  preferred_max_latency: opt(T.union(T.num(), PercentileCutoffs)),
142
147
  });
143
148
  const VercelGatewayRouting = T.obj({ only: opt(T.arr(T.str())), order: opt(T.arr(T.str())) });
149
+ // model-schema.js: `Partial(Record(Enum(levels), String | Null))`, which checks the seven level keys and admits others.
144
150
  const ThinkingValue = T.union(T.str(), T.nul());
145
151
  const ThinkingLevelMap = T.obj({ off: opt(ThinkingValue), minimal: opt(ThinkingValue), low: opt(ThinkingValue), medium: opt(ThinkingValue), high: opt(ThinkingValue), xhigh: opt(ThinkingValue), max: opt(ThinkingValue) });
146
152
  // pi 1.0.2 (issue #587): a model's sampling parameters, and the same per thinking level, merged per call over them.
147
153
  const SamplingParams = T.rec(T.unknown());
148
154
  const SamplingParamsByThinkingLevel = T.obj({ off: opt(SamplingParams), minimal: opt(SamplingParams), low: opt(SamplingParams), medium: opt(SamplingParams), high: opt(SamplingParams), xhigh: opt(SamplingParams), max: opt(SamplingParams) });
149
- const KwargScalar = T.union(T.str(), T.num(), T.bool(), T.nul());
150
- const KwargVariable = T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"))), omitWhenOff: opt(T.bool()) });
151
- const Kwarg = T.union(KwargScalar, KwargVariable);
155
+ const KwargValue = T.union(T.str(), T.num(), T.bool(), T.nul(), T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"), T.lit("thinking.budget"))), omitWhenOff: opt(T.bool()) }));
152
156
  const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
153
- const OpenAICompletionsCompat = T.obj({
154
- supportsStore: opt(T.bool()),
155
- supportsDeveloperRole: opt(T.bool()),
156
- supportsReasoningEffort: opt(T.bool()),
157
- supportsUsageInStreaming: opt(T.bool()),
158
- supportsFinishReason: opt(T.bool()),
159
- maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
160
- requiresToolResultName: opt(T.bool()),
161
- requiresAssistantAfterToolResult: opt(T.bool()),
162
- requiresThinkingAsText: opt(T.bool()),
163
- requiresReasoningContentOnAssistantMessages: opt(T.bool()),
164
- thinkingFormat: opt(T.union(...["openai", "openrouter", "together", "baseten", "deepseek", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
165
- chatTemplateKwargs: opt(T.rec(Kwarg)),
166
- chatTemplateArgs: opt(T.rec(Kwarg)),
167
- cacheControlFormat: opt(T.lit("anthropic")),
168
- openRouterRouting: opt(OpenRouterRouting),
169
- vercelGatewayRouting: opt(VercelGatewayRouting),
170
- supportsOpenAIGrammarTools: opt(T.bool()),
171
- supportsStrictMode: opt(T.bool()),
172
- sendSessionAffinityHeaders: opt(T.bool()),
173
- sessionAffinityFormat: opt(affinity),
174
- supportsLongCacheRetention: opt(T.bool()),
175
- vllmPriority: opt(T.num()),
176
- });
177
- const OpenAIResponsesCompat = T.obj({
178
- supportsDeveloperRole: opt(T.bool()),
179
- sessionAffinityFormat: opt(affinity),
180
- supportsLongCacheRetention: opt(T.bool()),
181
- supportsStrictMode: opt(T.bool()),
182
- supportsOpenAIGrammarTools: opt(T.bool()),
183
- supportsMaxOutputTokens: opt(T.bool()),
184
- });
185
157
  const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
186
158
  const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
187
159
  const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
160
+ // `Partial(Record(Enum(["short", "long"]), Number > 0))`, like the thinking map.
188
161
  const ModelPromptCache = T.obj({ short: opt(T.num({ exclusiveMinimum: 0 })), long: opt(T.num({ exclusiveMinimum: 0 })) });
189
162
  const posInt = T.int({ minimum: 1 });
190
163
  const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBytes: opt(posInt), jpegQuality: opt(T.int({ minimum: 1, maximum: 100 })) });
191
164
  const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
192
- const AnthropicMessagesCompat = T.obj({
193
- supportsEagerToolInputStreaming: opt(T.bool()),
194
- supportsLongCacheRetention: opt(T.bool()),
195
- sendSessionAffinityHeaders: opt(T.bool()),
196
- supportsCacheControlOnTools: opt(T.bool()),
197
- supportsTemperature: opt(T.bool()),
198
- forceAdaptiveThinking: opt(T.bool()),
199
- allowEmptySignature: opt(T.bool()),
200
- supportsStrictTools: opt(T.bool()),
201
- supportsMidConvoEffort: opt(T.bool()),
165
+ // compat-schema.js's ProviderCompatSchema: the merged properties of the OpenAI completions, OpenAI responses, Anthropic
166
+ // messages, Bedrock and Mistral schemas. A key two of them share takes pi's generic override, which for every shared
167
+ // key here is the same type as each api's own (a boolean, or the three-way affinity format), so the superset is exact.
168
+ const bool = opt(T.bool());
169
+ const ProviderCompat = T.obj({
170
+ // OpenAI completions
171
+ supportsStore: bool,
172
+ supportsDeveloperRole: bool,
173
+ supportsReasoningEffort: bool,
174
+ supportsUsageInStreaming: bool,
175
+ supportsFinishReason: bool,
176
+ maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
177
+ requiresToolResultName: bool,
178
+ requiresAssistantAfterToolResult: bool,
179
+ requiresThinkingAsText: bool,
180
+ requiresReasoningContentOnAssistantMessages: bool,
181
+ thinkingFormat: opt(T.union(...["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
182
+ chatTemplateKwargs: opt(T.rec(KwargValue)),
183
+ chatTemplateArgs: opt(T.rec(KwargValue)),
184
+ openRouterRouting: opt(OpenRouterRouting),
185
+ vercelGatewayRouting: opt(VercelGatewayRouting),
186
+ zaiToolStream: bool,
187
+ thinkingTokenBudgetField: opt(T.union(T.lit("thinking_token_budget"), T.lit("thinking_budget"), T.lit("thinking_budget_tokens"))),
188
+ supportsThinkingTokenBudget: bool,
189
+ supportsOpenAIGrammarTools: bool,
190
+ supportsMidConvoSystemMessages: bool,
191
+ supportsMidConvoToolAdditions: bool,
192
+ supportsStrictMode: bool,
193
+ cacheControlFormat: opt(T.lit("anthropic")),
194
+ sendSessionAffinityHeaders: bool,
195
+ sessionAffinityFormat: opt(affinity),
196
+ supportsLongCacheRetention: bool,
197
+ vllmPriority: opt(T.num()),
198
+ // OpenAI responses
199
+ supportsAdditionalTools: bool,
200
+ supportsToolSearch: bool,
201
+ supportsExplicitPromptCacheMode: bool,
202
+ supportsMaxOutputTokens: bool,
203
+ // Anthropic messages
204
+ supportsEagerToolInputStreaming: bool,
205
+ supportsCacheControlOnTools: bool,
206
+ supportsTemperature: bool,
207
+ forceAdaptiveThinking: bool,
208
+ allowEmptySignature: bool,
209
+ supportsStrictTools: bool,
210
+ supportsMidConvoEffort: bool,
211
+ supportsMidConvoToolChanges: bool,
202
212
  allowedFallbackModels: opt(T.arr(T.obj({ provider: req(T.str({ minLength: 1 })), model: req(T.str({ minLength: 1 })), cost: req(ModelCost) }), { maxItems: 3 })),
203
213
  });
204
- const ProviderCompat = T.union(OpenAICompletionsCompat, OpenAIResponsesCompat, AnthropicMessagesCompat);
205
214
  const nonEmpty = T.str({ minLength: 1 });
206
215
  const inputKinds = T.arr(T.union(T.lit("text"), T.lit("image")));
207
216
  const headers = T.rec(T.str());
217
+ // 1.2.0: a token count must be above zero, where any number passed before.
218
+ const tokenCount = T.num({ exclusiveMinimum: 0 });
208
219
  const ModelDefinition = T.obj({
209
220
  id: req(nonEmpty),
210
221
  name: opt(nonEmpty),
@@ -216,8 +227,8 @@ const ModelDefinition = T.obj({
216
227
  inputLimits: opt(ModelInputLimits),
217
228
  cost: opt(ModelCost),
218
229
  promptCache: opt(ModelPromptCache),
219
- contextWindow: opt(T.num()),
220
- maxTokens: opt(T.num()),
230
+ contextWindow: opt(tokenCount),
231
+ maxTokens: opt(tokenCount),
221
232
  samplingParams: opt(SamplingParams),
222
233
  samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
223
234
  headers: opt(headers),
@@ -231,8 +242,8 @@ const ModelOverride = T.obj({
231
242
  inputLimits: opt(ModelInputLimits),
232
243
  cost: opt(T.obj({ input: opt(T.num()), output: opt(T.num()), cacheRead: opt(T.num()), cacheWrite: opt(T.num()), tiers: opt(T.arr(ModelCostTier)) })),
233
244
  promptCache: opt(ModelPromptCache),
234
- contextWindow: opt(T.num()),
235
- maxTokens: opt(T.num()),
245
+ contextWindow: opt(tokenCount),
246
+ maxTokens: opt(tokenCount),
236
247
  samplingParams: opt(SamplingParams),
237
248
  samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
238
249
  headers: opt(headers),
@@ -250,7 +261,8 @@ const ProviderConfig = T.obj({
250
261
  models: opt(T.arr(ModelDefinition)),
251
262
  modelOverrides: opt(T.rec(ModelOverride)),
252
263
  });
253
- const ModelsConfig = T.obj({ providers: req(T.rec(ProviderConfig)) });
264
+ // 1.2.0: a `$schema` key (the published https://pi.dev/schemas/models.schema.json) is declared, as a string.
265
+ const ModelsConfig = T.obj({ $schema: opt(T.str()), providers: req(T.rec(ProviderConfig)) });
254
266
 
255
267
  /**
256
268
  * Parse overlay `models.json` TEXT as pi does. `{ value }` when pi would load it, else `{ error }` with a fixed
package/src/parked.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * REQ-AWAIT-INPUT, the reply half (issue #625): the forge marker on an issue whose last run is waiting for an answer.
3
+ *
4
+ * Imported by both services, like capabilities.mjs, because the receiver routes on it and the worker writes it: two
5
+ * spellings of one label would be two places for one to drift while every test stayed green.
6
+ *
7
+ * THE LABEL IS A HINT, THE SIDECAR IS THE TRUTH. The receiver cannot read the session store (on a fleet it may not
8
+ * even run beside one), so a label the forge already sends in every comment payload is what lets it route a reply
9
+ * that carries no trigger phrase. Anyone who may label an issue can set it, so it decides nothing on its own: the
10
+ * worker refuses such a reply before any spend unless the key's `parked` sidecar exists (`not-parked`). And it
11
+ * widens WHICH comments start a job, never WHO may: the comment trigger's author gate still runs first.
12
+ */
13
+ export const PARKED_LABEL = "pi-awaiting-input";
14
+
15
+ /** Whether a subset's `[{ name }]` label list (or Azure's tag list, the same shape) carries the marker. */
16
+ export function hasParkedLabel(labels) {
17
+ return Array.isArray(labels) && labels.some((label) => label?.name === PARKED_LABEL);
18
+ }
package/src/processor.mjs CHANGED
@@ -140,11 +140,25 @@ export const TERMINAL_COMMENTS = {
140
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
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
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.",
143
146
  // Issue #596. Never the size or the project: both are operator configuration, and the reader may be an issue author
144
147
  // who can act on neither. The worker log and the run record carry them.
145
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.",
146
149
  };
147
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
+
148
162
  // Issue #502: the `model-unknown` refusal's comment. Names no model: the reader may be an issue author.
149
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.";
150
164
 
@@ -282,6 +296,16 @@ export async function runJob(job, deps) {
282
296
  // the store, on a COMPLETED exit only. Never throws. The default is a no-op so a wiring that omits
283
297
  // it behaves exactly as before -- no store, no promotion, no session in the record.
284
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 () => {},
305
+ // () => Promise<unknown> (DES-AWAIT-INPUT-ENDS-THE-JOB): close this job's semantic dedup window once it has parked,
306
+ // so the answer is not swallowed against it. Takes no job: the key belongs to the REAL BullMQ job, which only the
307
+ // wiring holds (index.mjs binds queue.mjs's `releaseSemanticWindow` to it). Never throws. The default does nothing.
308
+ releaseParkedWindow = async () => {},
285
309
  // The session store's root, or null when the feature is unavailable (REQ-RESUMABLE-SESSION). Read
286
310
  // ONLY to answer the fail-closed gate below -- nothing here opens it, and it never reaches the
287
311
  // container env (docker-run.mjs mounts a per-job COPY, never the store).
@@ -345,7 +369,8 @@ export async function runJob(job, deps) {
345
369
  // runContainer({ job, token, prepared, secrets, name, signal, user, home, modelEndpoints?, size?, hostCpus?, unenforced? }) => { code, aborted, abortReason, turns, tokens, session, usage, context, exitReason }.
346
370
  // `size` (issue #596) is `jobSize` below; `hostCpus` the runtime's CPU count off the job-user gate's own facts read.
347
371
  // `unenforced` (issue #596) is the size flags that same read says the runtime drops, passed only when there are some.
348
- // `exitReason` (issue #437) is parseExitReason's closed-set label, read only inside the exit-2 branch.
372
+ // `exitReason` (issue #437) is parseExitReason's closed-set label, read only inside the exit-2 branch, and in the
373
+ // exit-0 branch for `awaiting-input` alone (issue #625).
349
374
  // `resources` and `exitOomKilled` (issue #596) are the exit line's cgroup block and the supervisor's OOM report.
350
375
  // `user`/`home` are the job-user gate's answer (issue #341), null for the image's own USER.
351
376
  // `secrets` is the resolved map from the gate above: values, already fetched, host-side. It MUST honour
@@ -960,6 +985,16 @@ export async function runJob(job, deps) {
960
985
  // purpose: only forge jobs can arm the flag today (triggers.mjs refuses it on cron, and a CLI or
961
986
  // chained job has no trigger entry that could set it), but a gate written as an enumeration of kinds
962
987
  // is a gate the next kind skips silently.
988
+ // A reply that reached this job WITHOUT the trigger phrase (issue #625) was routed only because the forge said the
989
+ // issue carries the parked marker, and anyone who may label an issue can set that. The truth is the key's `parked`
990
+ // mark beside its transcript, and it is free to read, so it is asked here, before the mint and before any spend.
991
+ // Without it the reply is ordinary conversation on an issue: refused, silently (no comment, since the person was
992
+ // talking to people), and the stale marker is cleared so the next comment is not routed here again.
993
+ if (job.parkedReply === true && !isParked(job)) {
994
+ log("refused_not_parked", { kind: job.kind ?? null });
995
+ await parkedLabel(job, false);
996
+ 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
997
+ }
963
998
  if (job.resume === true && !sessionsDir) {
964
999
  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.");
965
1000
  // The variable NAME, never a value: there is no path to print here (its absence IS the refusal),
@@ -1482,9 +1517,27 @@ export async function runJob(job, deps) {
1482
1517
  // (CONST-RETRY-INFRA-ONLY). Same completed-only rule INT-OUTBOX-CONTRACT already uses, and
1483
1518
  // it sits beside the chain collection for the same reason: both must happen before the
1484
1519
  // `finally` deletes jobDir. Never throws.
1485
- const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context }) : null;
1520
+ // A run that asked a person a question and ended (issue #625, REQ-AWAIT-INPUT) is still a completed run: it is
1521
+ // promoted like any other, with the key marked parked, and the one fixed comment says how it continues.
1522
+ // exitReason is RUNNER_COMPLETED_REASONS-filtered and read only here, on code 0, so it cannot move a job
1523
+ // between retry classes. Only a job armed with run.awaitInput may park: a line that says so on any other
1524
+ // job is ignored, since that runner was never offered the tool.
1525
+ const parked = exitReason === "awaiting-input" && job.awaitInput === true;
1526
+ const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context, parked }) : null;
1527
+ // The window closes AFTER the promotion (an answer that gets in must find the `parked` mark and the transcript)
1528
+ // and BEFORE the comment and the marker: the marker is what routes a phrase-free reply on an issue, and the
1529
+ // comment is what tells a pull request's author to reply, so no answer they invite can meet the old key. Every
1530
+ // park takes this path, a resumed run that parks again included. Guarded although the seam never throws: a
1531
+ // fault here must not cost the person the comment that says how to continue.
1532
+ if (parked) await Promise.resolve().then(releaseParkedWindow).catch(() => {});
1533
+ if (parked) await comment(job, parkedComment(job.target));
1534
+ // The forge marker the receiver routes a phrase-free reply on: set on a park, cleared when a run on a key that
1535
+ // could park ends without parking. Never throws, and decides nothing: the sidecar promotion just wrote does.
1536
+ if (parked) await parkedLabel(job, true);
1537
+ else if (job.awaitInput === true || job.parkedReply === true) await parkedLabel(job, false);
1486
1538
  return {
1487
1539
  outcome: "completed",
1540
+ ...(parked ? { reason: "awaiting-input" } : {}),
1488
1541
  exitCode: code,
1489
1542
  turns,
1490
1543
  tokens,