@edgehero/pi-dispatch 4.2.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 +4 -3
- package/package.json +3 -2
- package/src/azure-host.mjs +28 -1
- package/src/azure-prompt.mjs +26 -3
- package/src/config.mjs +2 -2
- package/src/env-allowlist.mjs +4 -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 +10 -0
- package/src/model-catalog.mjs +2 -0
- package/src/models-json.mjs +65 -53
- package/src/parked.mjs +18 -0
- package/src/processor.mjs +45 -2
- package/src/provider-steering.mjs +1 -0
- package/src/queue.mjs +7 -1
- package/src/reserved-env.mjs +1 -0
- package/src/run-container.mjs +4 -0
- package/src/run-history.mjs +22 -2
- package/src/session-store.mjs +45 -2
- package/src/start.mjs +19 -0
- package/src/triggers-file.mjs +4 -0
- package/src/triggers.mjs +33 -4
package/.env.example
CHANGED
|
@@ -33,15 +33,16 @@ ANTHROPIC_API_KEY=
|
|
|
33
33
|
|
|
34
34
|
# --- Which provider/model to run by default (override per job with --provider / --model) ---
|
|
35
35
|
PI_PROVIDER=anthropic
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# Name an exact model version (claude-sonnet-5-5, or a dated id such as claude-sonnet-4-5-20250929); an alias
|
|
37
|
+
# that moves to a newer model can change cost.
|
|
38
|
+
PI_MODEL=claude-sonnet-5-5
|
|
38
39
|
# optional: the only models a job may call, comma-separated provider/model (at most 16). A trigger's own
|
|
39
40
|
# run.models replaces this list for its jobs. The default model must be on it, or every job that uses the
|
|
40
41
|
# default is refused before it spends. Env only: the settings overlay cannot set or widen it.
|
|
41
42
|
# Unset = any model. Needs a job image that declares the modelPolicy capability.
|
|
42
43
|
# No spaces, not even after a comma: a shell that sources this file cuts the value at the first space
|
|
43
44
|
# and leaves it unset, so jobs would run with no list (the worker refuses a spaced value at boot).
|
|
44
|
-
# PI_ALLOWED_MODELS=anthropic/claude-sonnet-
|
|
45
|
+
# PI_ALLOWED_MODELS=anthropic/claude-sonnet-5-5,anthropic/claude-haiku-5-5
|
|
45
46
|
|
|
46
47
|
# --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
|
|
47
48
|
# per-job turn cap -- pi has none of its own, so the harness imposes one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
|
|
6
6
|
"keywords": [
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"./git-dirty": "./src/git-dirty.mjs",
|
|
56
56
|
"./queue": "./src/queue.mjs",
|
|
57
57
|
"./capabilities": "./src/capabilities.mjs",
|
|
58
|
+
"./parked": "./src/parked.mjs",
|
|
58
59
|
"./connection": "./src/connection.mjs",
|
|
59
60
|
"./valkey-auth": "./src/valkey-auth.mjs",
|
|
60
61
|
"./job-id": "./src/job-id.mjs",
|
|
@@ -114,7 +115,7 @@
|
|
|
114
115
|
"start": "node src/cli.mjs worker"
|
|
115
116
|
},
|
|
116
117
|
"dependencies": {
|
|
117
|
-
"@earendil-works/pi-ai": "1.
|
|
118
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
118
119
|
"@octokit/auth-app": "8.2.0",
|
|
119
120
|
"@octokit/rest": "22.0.1",
|
|
120
121
|
"bullmq": "5.80.4",
|
package/src/azure-host.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { configError } from "./config.mjs";
|
|
25
|
+
import { PARKED_LABEL } from "./parked.mjs";
|
|
25
26
|
import { InfraRetry } from "./processor.mjs";
|
|
26
27
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
27
28
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
@@ -201,7 +202,33 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
|
|
|
201
202
|
return { headRef: stripRefsHeads(pr?.sourceRefName), headRepo: sameRepo ? job?.repo : null };
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Add or remove the parked marker on a work item, as a TAG (issue #625, parked.mjs): Azure work items have
|
|
207
|
+
* tags, not labels, and the receiver reads `System.Tags` into the same `[{ name }]` shape. A tags field is written
|
|
208
|
+
* whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
|
|
209
|
+
* the session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
210
|
+
*/
|
|
211
|
+
async function setParkedLabel(ref, target, on, token) {
|
|
212
|
+
const { project } = scopeOf(ref);
|
|
213
|
+
const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
|
|
214
|
+
const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
|
|
215
|
+
const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== PARKED_LABEL);
|
|
216
|
+
const value = (on ? [...tags, PARKED_LABEL] : tags).join("; ");
|
|
217
|
+
let res;
|
|
218
|
+
try {
|
|
219
|
+
res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
|
|
220
|
+
method: "PATCH",
|
|
221
|
+
headers: { Authorization: authHeader(token), "content-type": "application/json-patch+json" },
|
|
222
|
+
body: JSON.stringify([{ op: "add", path: "/fields/System.Tags", value }]),
|
|
223
|
+
redirect: "error",
|
|
224
|
+
});
|
|
225
|
+
} catch (err) {
|
|
226
|
+
throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
227
|
+
}
|
|
228
|
+
if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
/**
|
package/src/azure-prompt.mjs
CHANGED
|
@@ -68,6 +68,31 @@ function prReplicaLines(replica, replicas) {
|
|
|
68
68
|
];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
|
|
73
|
+
* request exist, so the words are the ones this envelope always had. On a work item neither is certain: the
|
|
74
|
+
* previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
|
|
75
|
+
* "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
|
|
76
|
+
* human who asked waits on the work item for a reply. So the issue form names the issue branch, checks for its pull
|
|
77
|
+
* request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
|
|
78
|
+
*/
|
|
79
|
+
function resumedActionLines(target, n) {
|
|
80
|
+
if (target?.type === "pull_request") {
|
|
81
|
+
return [
|
|
82
|
+
"Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
|
|
83
|
+
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
84
|
+
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
const branch = issueBranch(n);
|
|
88
|
+
return [
|
|
89
|
+
"Address the activity quoted below, then reply on the work item saying what you did or why you could not.",
|
|
90
|
+
`If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
|
|
91
|
+
`branch only. Check first for an active pull request from it (\`az repos pr list --source-branch ${branch} --status active\`):`,
|
|
92
|
+
`if one exists your push updates it, so do not create another; only if none exists, run \`az repos pr create --source-branch ${branch}\`.`,
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
function buildResumedPrompt(flow, target, comment, instructions) {
|
|
72
97
|
const n = normalizeNumber(target?.number);
|
|
73
98
|
const noun = target?.type === "pull_request" ? "pull request" : "work item";
|
|
@@ -81,9 +106,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
81
106
|
`\`az repos pr show --id ${n}\` (or \`az boards work-item show --id ${n}\`) before doing anything,`,
|
|
82
107
|
`then follow the "${flow}" skill from the top.`,
|
|
83
108
|
"",
|
|
84
|
-
|
|
85
|
-
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
86
|
-
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
109
|
+
...resumedActionLines(target, n),
|
|
87
110
|
"",
|
|
88
111
|
// Above the never-merge paragraph, so the harness has the last word before the data region.
|
|
89
112
|
...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
|
package/src/config.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsd
|
|
|
31
31
|
*/
|
|
32
32
|
export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
|
|
33
33
|
/** The model a job runs on when nothing names one (`PI_MODEL` unset): one copy, for `loadConfig` and doctor. */
|
|
34
|
-
export const DEFAULT_MODEL = "claude-sonnet-
|
|
34
|
+
export const DEFAULT_MODEL = "claude-sonnet-5-5";
|
|
35
35
|
/** The provider a job runs on when nothing names one (`PI_PROVIDER` unset). */
|
|
36
36
|
export const DEFAULT_PROVIDER = "anthropic";
|
|
37
37
|
|
|
@@ -394,7 +394,7 @@ export function globalExtensionsEnabled(env) {
|
|
|
394
394
|
* spend caps above, the per-job token scoping, or the admin-extension recursion block.
|
|
395
395
|
*/
|
|
396
396
|
export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
|
|
397
|
-
const model = env.PI_MODEL ?? DEFAULT_MODEL; //
|
|
397
|
+
const model = env.PI_MODEL ?? DEFAULT_MODEL; // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED
|
|
398
398
|
// #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
|
|
399
399
|
// a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
|
|
400
400
|
// called twice a few properties down for the same reason -- so this is about the index, not the throw.)
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
|
|
|
367
367
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
368
368
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
369
369
|
*/
|
|
370
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
370
|
+
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
371
371
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
372
372
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
373
373
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
@@ -433,6 +433,9 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
433
433
|
// runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
|
|
434
434
|
// admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
|
|
435
435
|
PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
|
|
436
|
+
// The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
|
|
437
|
+
// armed, never "0" or an empty string, for PI_PACKAGES' reason.
|
|
438
|
+
PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
|
|
436
439
|
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
437
440
|
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
438
441
|
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
package/src/forgejo-host.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { configError } from "./config.mjs";
|
|
24
|
+
import { PARKED_LABEL } from "./parked.mjs";
|
|
24
25
|
import { InfraRetry } from "./processor.mjs";
|
|
25
26
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
26
27
|
import { matchesBranch } from "./gitlab-host.mjs";
|
|
@@ -153,7 +154,38 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
153
154
|
return { headRef: pr?.head?.ref, headRepo: pr?.head?.repo?.full_name };
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Add or remove the parked marker on an issue (issue #625, parked.mjs). Forgejo adds and removes a
|
|
159
|
+
* label by NAME (its IssueLabelsOption and the `{identifier}` path take a name or an id), but only one the repository
|
|
160
|
+
* already has, so adding first creates the repository label; a label that already exists answers 409 or 422 there. The caller treats any throw as fail-open:
|
|
161
|
+
* the session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
162
|
+
*/
|
|
163
|
+
async function setParkedLabel(ref, target, on, token) {
|
|
164
|
+
const [owner, name] = splitRepo(ref);
|
|
165
|
+
const send = async (method, path, body, accept = []) => {
|
|
166
|
+
let res;
|
|
167
|
+
try {
|
|
168
|
+
res = await fetchFn(`${root}${path}`, {
|
|
169
|
+
method,
|
|
170
|
+
headers: { Authorization: `token ${token}`, "content-type": "application/json" },
|
|
171
|
+
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
172
|
+
redirect: "error",
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new InfraRetry(`forgejo-host: ${method} ${path} failed (${fetchFailureReason(err)})`);
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok && !accept.includes(res.status)) throw new InfraRetry(`forgejo-host: ${method} ${path} returned ${res.status}`);
|
|
178
|
+
};
|
|
179
|
+
const issue = `/repos/${owner}/${name}/issues/${encodeURIComponent(target?.number)}/labels`;
|
|
180
|
+
if (on) {
|
|
181
|
+
await send("POST", `/repos/${owner}/${name}/labels`, { name: PARKED_LABEL, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
|
|
182
|
+
await send("POST", issue, { labels: [PARKED_LABEL] });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await send("DELETE", `${issue}/${encodeURIComponent(PARKED_LABEL)}`, undefined, [404]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
157
189
|
}
|
|
158
190
|
|
|
159
191
|
/**
|
package/src/forgejo-prompt.mjs
CHANGED
|
@@ -74,6 +74,31 @@ function prReplicaLines(replica, replicas) {
|
|
|
74
74
|
* toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no idea
|
|
75
75
|
* what it was asked to do. `tea`, never `gh`: this envelope's whole reason for existing.
|
|
76
76
|
*/
|
|
77
|
+
/**
|
|
78
|
+
* What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
|
|
79
|
+
* request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
|
|
80
|
+
* previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
|
|
81
|
+
* "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
|
|
82
|
+
* human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
|
|
83
|
+
* request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
|
|
84
|
+
*/
|
|
85
|
+
function resumedActionLines(target, n) {
|
|
86
|
+
if (target?.type === "pull_request") {
|
|
87
|
+
return [
|
|
88
|
+
"Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
|
|
89
|
+
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
90
|
+
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const branch = issueBranch(n);
|
|
94
|
+
return [
|
|
95
|
+
"Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
|
|
96
|
+
`If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
|
|
97
|
+
`branch only. Check first for an open pull request from it (\`tea pr list --state open\`, look for \`${branch}\`):`,
|
|
98
|
+
`if one exists your push updates it, so do not open a second; only if none exists, run \`tea pr create --head ${branch}\`.`,
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
function buildResumedPrompt(flow, target, comment, instructions) {
|
|
78
103
|
const n = normalizeNumber(target?.number);
|
|
79
104
|
const noun = target?.type === "pull_request" ? "pull request" : "issue";
|
|
@@ -86,9 +111,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
86
111
|
`If you do not recognise this ${noun}, treat this as a fresh start: read it with \`tea pr ${n}\``,
|
|
87
112
|
`(or \`tea issue ${n}\`) before doing anything, then follow the "${flow}" skill from the top.`,
|
|
88
113
|
"",
|
|
89
|
-
|
|
90
|
-
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
91
|
-
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
114
|
+
...resumedActionLines(target, n),
|
|
92
115
|
"",
|
|
93
116
|
// Above the never-merge paragraph, so the harness has the last word before the data region.
|
|
94
117
|
...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
|
package/src/github-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/github-prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/gitlab-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/gitlab-prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
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
|
|
package/src/image-preflight.mjs
CHANGED
|
@@ -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/model-catalog.mjs
CHANGED
|
@@ -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 };
|
package/src/models-json.mjs
CHANGED
|
@@ -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
|
|
125
|
-
//
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
220
|
-
maxTokens: opt(
|
|
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(
|
|
235
|
-
maxTokens: opt(
|
|
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
|
-
|
|
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,12 @@ 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 () => {},
|
|
285
305
|
// The session store's root, or null when the feature is unavailable (REQ-RESUMABLE-SESSION). Read
|
|
286
306
|
// ONLY to answer the fail-closed gate below -- nothing here opens it, and it never reaches the
|
|
287
307
|
// container env (docker-run.mjs mounts a per-job COPY, never the store).
|
|
@@ -345,7 +365,8 @@ export async function runJob(job, deps) {
|
|
|
345
365
|
// runContainer({ job, token, prepared, secrets, name, signal, user, home, modelEndpoints?, size?, hostCpus?, unenforced? }) => { code, aborted, abortReason, turns, tokens, session, usage, context, exitReason }.
|
|
346
366
|
// `size` (issue #596) is `jobSize` below; `hostCpus` the runtime's CPU count off the job-user gate's own facts read.
|
|
347
367
|
// `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
|
|
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).
|
|
349
370
|
// `resources` and `exitOomKilled` (issue #596) are the exit line's cgroup block and the supervisor's OOM report.
|
|
350
371
|
// `user`/`home` are the job-user gate's answer (issue #341), null for the image's own USER.
|
|
351
372
|
// `secrets` is the resolved map from the gate above: values, already fetched, host-side. It MUST honour
|
|
@@ -960,6 +981,16 @@ export async function runJob(job, deps) {
|
|
|
960
981
|
// purpose: only forge jobs can arm the flag today (triggers.mjs refuses it on cron, and a CLI or
|
|
961
982
|
// chained job has no trigger entry that could set it), but a gate written as an enumeration of kinds
|
|
962
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
|
+
}
|
|
963
994
|
if (job.resume === true && !sessionsDir) {
|
|
964
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.");
|
|
965
996
|
// The variable NAME, never a value: there is no path to print here (its absence IS the refusal),
|
|
@@ -1482,9 +1513,21 @@ export async function runJob(job, deps) {
|
|
|
1482
1513
|
// (CONST-RETRY-INFRA-ONLY). Same completed-only rule INT-OUTBOX-CONTRACT already uses, and
|
|
1483
1514
|
// it sits beside the chain collection for the same reason: both must happen before the
|
|
1484
1515
|
// `finally` deletes jobDir. Never throws.
|
|
1485
|
-
|
|
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);
|
|
1486
1528
|
return {
|
|
1487
1529
|
outcome: "completed",
|
|
1530
|
+
...(parked ? { reason: "awaiting-input" } : {}),
|
|
1488
1531
|
exitCode: code,
|
|
1489
1532
|
turns,
|
|
1490
1533
|
tokens,
|
package/src/queue.mjs
CHANGED
|
@@ -311,7 +311,7 @@ export async function enqueueGitLabJob(queue, fields) {
|
|
|
311
311
|
* window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
|
|
312
312
|
* has always been.
|
|
313
313
|
*/
|
|
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, 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 }) {
|
|
315
315
|
const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
|
|
316
316
|
// `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
|
|
317
317
|
// come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
|
|
@@ -366,6 +366,12 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
366
366
|
// delivery, and `trigger` is what /job/event.json is built from.
|
|
367
367
|
...(instructions !== undefined && { instructions }),
|
|
368
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 }),
|
|
369
375
|
// REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
|
|
370
376
|
// them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
|
|
371
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,7 @@ export const CONTAINER_ENV_NAMES = new Set([
|
|
|
43
43
|
"PI_FLOW",
|
|
44
44
|
"PI_COMMAND",
|
|
45
45
|
"PI_EXCLUDE_TOOLS",
|
|
46
|
+
"PI_AWAIT_INPUT",
|
|
46
47
|
"PI_MCP_SERVERS",
|
|
47
48
|
// Issues #501, #502: the per-job dollar cap (written by the worker since #501, from the trigger's and the
|
|
48
49
|
// deployment's caps) and the allowed-model list (reserved until #502 writes it). Neither can be bound through
|
package/src/run-container.mjs
CHANGED
|
@@ -148,6 +148,10 @@ export function makeRunContainer({
|
|
|
148
148
|
excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
|
|
149
149
|
// REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
|
|
150
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,
|
|
151
155
|
// Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
|
|
152
156
|
// the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
|
|
153
157
|
allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
|
package/src/run-history.mjs
CHANGED
|
@@ -172,11 +172,30 @@ export function parseExitTurns(text) {
|
|
|
172
172
|
* Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
|
|
173
173
|
*/
|
|
174
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
|
-
|
|
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.
|
|
192
|
+
*/
|
|
193
|
+
export const RUNNER_COMPLETED_REASONS = new Set(["awaiting-input"]);
|
|
176
194
|
|
|
177
195
|
/**
|
|
178
196
|
* The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
|
|
179
|
-
* 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.
|
|
180
199
|
*
|
|
181
200
|
* Why this may reach the outcome when its five siblings may not: it cannot change the retry class. The
|
|
182
201
|
* processor consults it only inside its container-exit-2 branch, so a container that exits 1 while
|
|
@@ -189,6 +208,7 @@ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap
|
|
|
189
208
|
export function parseExitReason(text) {
|
|
190
209
|
const parsed = decisiveExitLine(text);
|
|
191
210
|
if (parsed === null) return null;
|
|
211
|
+
if (parsed?.code === 0 && RUNNER_COMPLETED_REASONS.has(parsed?.reason)) return parsed.reason;
|
|
192
212
|
return parsed?.code === 2 && RUNNER_POLICY_REASONS.has(parsed?.reason) ? parsed.reason : null;
|
|
193
213
|
}
|
|
194
214
|
|
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
|
@@ -2002,6 +2002,23 @@ export async function startWorker(
|
|
|
2002
2002
|
log("comment", { jobId: job?.id, text });
|
|
2003
2003
|
};
|
|
2004
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
|
+
|
|
2005
2022
|
// The operator's failure hook (issue #288, INT-ON-FAILURE-HOOK-CONTRACT). Constructed ONLY when the
|
|
2006
2023
|
// knob is set: an unset PI_ON_FAILURE builds nothing, spawns nothing, and logs nothing -- the
|
|
2007
2024
|
// byte-identical guarantee. Fired from the two terminal listeners below, never from the processor:
|
|
@@ -2241,6 +2258,8 @@ export async function startWorker(
|
|
|
2241
2258
|
// Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
|
|
2242
2259
|
// retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
|
|
2243
2260
|
promoteSession: sessionStore.promoteSession,
|
|
2261
|
+
isParked: sessionStore.isParked,
|
|
2262
|
+
parkedLabel,
|
|
2244
2263
|
// The same value the store above was built from, passed explicitly so the processor's fail-closed
|
|
2245
2264
|
// `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
|
|
2246
2265
|
// real path; the difference shows under an injected env, where the store would be built from the
|
package/src/triggers-file.mjs
CHANGED
|
@@ -478,6 +478,10 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
|
|
|
478
478
|
// REQ-MCP-OPERATOR-SERVERS: the MCP servers a trigger's jobs connect. Not a widening when dropped but the same
|
|
479
479
|
// silent outcome: the job runs its flow without the servers it was written against, on a clean record.
|
|
480
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 }),
|
|
481
485
|
]);
|
|
482
486
|
|
|
483
487
|
export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
package/src/triggers.mjs
CHANGED
|
@@ -425,6 +425,9 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
425
425
|
// `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
|
|
426
426
|
// today's default must not get a `data` that disagrees with the file they reviewed.
|
|
427
427
|
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
428
|
+
// Called for its refusal only (issue #625): `true` needs run.resume, which cron refuses, so the returned `run`
|
|
429
|
+
// grows no `awaitInput` key, exactly as it grows no `replicas` one.
|
|
430
|
+
validateAwaitInputFlag(run, `cron trigger "${id}"`, path);
|
|
428
431
|
// Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
|
|
429
432
|
// returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
|
|
430
433
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
@@ -557,6 +560,28 @@ function validateResumeFlag(run, at, path) {
|
|
|
557
560
|
return run.resume;
|
|
558
561
|
}
|
|
559
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Validate the per-trigger `run.awaitInput` flag (REQ-AWAIT-INPUT, issue #625): whether the job is offered the tool
|
|
565
|
+
* that ends a run to wait for a person's answer. Strictly boolean, validateResumeFlag's rule and for its reason: a
|
|
566
|
+
* truthy `"false"` would arm what the operator meant to leave off.
|
|
567
|
+
*
|
|
568
|
+
* `true` needs `run.resume: true` beside it, and that one rule carries every other refusal: a parked run is only
|
|
569
|
+
* worth anything if the next run resumes its transcript, and `run.resume` is already refused on cron and beside
|
|
570
|
+
* `run.command`, `run.replicas` and `run.secrets`, each for a reason that holds here too. So this adds no list of
|
|
571
|
+
* its own, which is one place fewer for the two to disagree.
|
|
572
|
+
*
|
|
573
|
+
* Returns the flag, undefined when absent, so an unflagged trigger normalizes byte-identically to today's.
|
|
574
|
+
*/
|
|
575
|
+
function validateAwaitInputFlag(run, at, path) {
|
|
576
|
+
if (run.awaitInput !== undefined && typeof run.awaitInput !== "boolean") {
|
|
577
|
+
throw configError(`${at}: run.awaitInput must be true or false when present: ${path}`);
|
|
578
|
+
}
|
|
579
|
+
if (run.awaitInput === true && run.resume !== true) {
|
|
580
|
+
throw configError(`${at}: run.awaitInput needs run.resume: true beside it -- a run that stops to wait for an answer is continued by the next run on the same issue or pull request, and only run.resume keeps the transcript it would continue from: ${path}`);
|
|
581
|
+
}
|
|
582
|
+
return run.awaitInput;
|
|
583
|
+
}
|
|
584
|
+
|
|
560
585
|
/**
|
|
561
586
|
* Validate the per-trigger `run.image` reference, shared by all four normalizers. It selects the Docker image
|
|
562
587
|
* this trigger's job containers run in, overriding the deployment-wide `PI_JOB_IMAGE` for this trigger only;
|
|
@@ -1649,6 +1674,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
1649
1674
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1650
1675
|
const instructions = validateInstructions(run, at, path);
|
|
1651
1676
|
const resume = validateResumeFlag(run, at, path);
|
|
1677
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1652
1678
|
const repository = validateRepository(run, "label", at, path);
|
|
1653
1679
|
const replicas = validateReplicas(run, at, path);
|
|
1654
1680
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1660,7 +1686,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
1660
1686
|
const ref = validateRunModel(on, run, at, path);
|
|
1661
1687
|
return {
|
|
1662
1688
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
1663
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1689
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1664
1690
|
};
|
|
1665
1691
|
}
|
|
1666
1692
|
|
|
@@ -1692,6 +1718,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
1692
1718
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1693
1719
|
const instructions = validateInstructions(run, at, path);
|
|
1694
1720
|
const resume = validateResumeFlag(run, at, path);
|
|
1721
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1695
1722
|
const repository = validateRepository(run, "comment", at, path);
|
|
1696
1723
|
const replicas = validateReplicas(run, at, path);
|
|
1697
1724
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1703,7 +1730,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
1703
1730
|
const ref = validateRunModel(on, run, at, path);
|
|
1704
1731
|
return {
|
|
1705
1732
|
on: { type: "comment", phrase: on.phrase },
|
|
1706
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1733
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1707
1734
|
};
|
|
1708
1735
|
}
|
|
1709
1736
|
|
|
@@ -1762,6 +1789,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1762
1789
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1763
1790
|
const instructions = validateInstructions(run, at, path);
|
|
1764
1791
|
const resume = validateResumeFlag(run, at, path);
|
|
1792
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1765
1793
|
validateRepository(run, "issue", at, path);
|
|
1766
1794
|
const replicas = validateReplicas(run, at, path);
|
|
1767
1795
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1780,7 +1808,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1780
1808
|
...(number !== undefined && { number }),
|
|
1781
1809
|
...(once !== undefined && { once }),
|
|
1782
1810
|
},
|
|
1783
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1811
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1784
1812
|
};
|
|
1785
1813
|
}
|
|
1786
1814
|
|
|
@@ -1850,6 +1878,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1850
1878
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1851
1879
|
const instructions = validateInstructions(run, at, path);
|
|
1852
1880
|
const resume = validateResumeFlag(run, at, path);
|
|
1881
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1853
1882
|
validateRepository(run, "pull_request", at, path);
|
|
1854
1883
|
const replicas = validateReplicas(run, at, path);
|
|
1855
1884
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1873,7 +1902,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1873
1902
|
...(number !== undefined && { number }),
|
|
1874
1903
|
...(once !== undefined && { once }),
|
|
1875
1904
|
},
|
|
1876
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1905
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1877
1906
|
};
|
|
1878
1907
|
}
|
|
1879
1908
|
|