Skip to content

Commit 54c630e

Browse files
committed
feat(runner): dispatch a run.command extension command headlessly (#189)
The runner half of issue #189 Gap 2 (the trigger field itself follows as the producer half). A trigger will be able to name a registered pi extension command instead of a flow; this PR ships the dispatch protocol the pinned artifact already supports. The prompt for a command job is rebuilt from PI_COMMAND as /<command>, never read from prompt.md: pi's dispatch grammar fires only when the ENTIRE text starts with a slash, parses the name to the first space and hands everything after it to the handler verbatim, so there is no such thing as a command line at the top of a larger prompt, and one in-container authority means a worker bug cannot make the classification misread a flow job. prompt.md stays the byte-identical human record. Before prompting, the runner verifies the name via session.extensionRunner.getCommand() and refuses an unregistered one as command-unregistered (exit 2, pre-spend): an unregistered /name is not an error to pi, it falls through template expansion into a paid model call, or into a same-named prompt template if one is staged. A handler throw is SWALLOWED by pi (prompt() resolves cleanly) and surfaces only on the public extensionRunner.onError channel; the runner subscribes and classifies it command-error, exit 1 retryable by explicit choice: pi hands us a message string, transient-vs-deterministic is undecidable, and the accepted cost (a deterministic extension bug retries until attempts run out) is recorded on the new DES entry. A clean headless return is command-completed, exit 0; before that reason existed the same shape was the retryable no-terminal-message, and the queue re-billed a success. A handler that drove the model keeps its terminal's ordinary verdict, and budget aborts keep first position. The image's capabilities label gains commands, with a verify-image.sh case asserting the claim against the baked runner source; the worker-side preflight gate lands with the producer half. A keyless real-session contract test pins all four dispatch facts against the pinned artifact. Specs: INT-RUNNER-EXIT-CODE-PROTOCOL AMENDED (three reasons under existing codes, never a new outcome), INT-CONTAINER-JOB-INPUTS AMENDED (PI_COMMAND), NEW DES-COMMAND-ENTRY-POINT. UNCHANGED, checked: INT-TRIGGERS-FILE-CONTRACT, DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS, DES-PER-TRIGGER-JOB-IMAGE, DES-TRIGGER-INSTRUCTION-IN-THE-ENVELOPE. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 2f17913 commit 54c630e

11 files changed

Lines changed: 387 additions & 9 deletions

File tree

image/Dockerfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,14 @@ LABEL dev.pi-dispatch.forges="github,gitlab,forgejo"
224224
# replica job. Both follow one rule -- an image that declares nothing gets no benefit of the doubt about what
225225
# it contains -- and both leave an UNFLAGGED job reading no label at all. `verify-image.sh` asserts this list
226226
# against the baked guardrails, so it cannot lie any more than `forges` can.
227-
LABEL dev.pi-dispatch.capabilities="replicas"
227+
# `commands` (issue #189) tells the worker this runner understands PI_COMMAND: it dispatches the
228+
# registered extension command, refuses an unregistered one pre-spend, and classifies a headless
229+
# command run as command-completed. An older runner handed PI_COMMAND would ignore the variable, read
230+
# prompt.md, and either feed "/name args" to the model as prose or exit 1 no-terminal-message and be
231+
# retried as infra -- paid retries of a job that can never classify. The label is how the worker
232+
# refuses that pairing pre-spend (image-preflight.mjs), which is what makes shipping the runner ahead
233+
# of the trigger field safe in both directions.
234+
LABEL dev.pi-dispatch.capabilities="replicas,commands"
228235

229236
USER pi
230237
WORKDIR /workspace

image/runner/run-job.mjs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ModelRegistry,
77
SettingsManager,
88
} from "@earendil-works/pi-coding-agent";
9-
import { assertPackagePathsExist, assertSessionMountReady, enforceOfflineMode, parseRunnerEnv } from "./src/config.mjs";
9+
import { assertPackagePathsExist, assertSessionMountReady, commandName, enforceOfflineMode, parseRunnerEnv } from "./src/config.mjs";
1010
import { buildLoadedResourceLoader, GLOBAL_PI_DIR, JOB_PI_DIR, TRIGGER_SKILLS_DIR, WORKSPACE } from "./src/loader.mjs";
1111
import {
1212
captureTerminal,
@@ -65,7 +65,14 @@ async function main() {
6565
// can influence. Idempotent and only ever tightening. INT-SDK-SESSION-OPTIONS.
6666
enforceOfflineMode(process.env);
6767

68-
const prompt = readPrompt(PROMPT_PATH);
68+
// A command job's prompt is rebuilt from PI_COMMAND rather than read from disk: one in-container
69+
// authority, so a worker bug that wrote a prompt.md disagreeing with the env var cannot make the
70+
// classification below (command-completed on a promptless return) misread a flow job. pi's
71+
// dispatch grammar demands it anyway -- a command dispatches only when the ENTIRE prompt starts
72+
// with "/", and everything after the first space becomes the handler's args, so there is no such
73+
// thing as a command line "at the top of" a larger prompt. prompt.md still carries the same bytes
74+
// as the human record of what ran (INT-CONTAINER-JOB-INPUTS).
75+
const prompt = cfg.command ? `/${cfg.command}` : readPrompt(PROMPT_PATH);
6976

7077
const agentDir = getAgentDir();
7178

@@ -226,6 +233,36 @@ async function main() {
226233
// leaving the first call of an extension-provided model unmetered.
227234
usageMeter.arm();
228235

236+
// A command job dispatches BEFORE any spend, so verify the command is actually registered first
237+
// (issue #189). pi's fallthrough is the hazard being closed: an unregistered "/name" is not an
238+
// error to session.prompt() -- it falls through to prompt-template expansion and then to the
239+
// MODEL as literal text, a full paid turn for a config typo, or (with a same-named template
240+
// staged) whatever that template does. Extensions have registered by createAgentSession time, so
241+
// getCommand() is authoritative here. commandName() is pi's own first-space parse, imported so
242+
// the verification reads the string exactly as dispatch will.
243+
if (cfg.command) {
244+
const name = commandName(cfg.command);
245+
if (!session.extensionRunner.getCommand(name)) {
246+
throw configError(
247+
`run.command names "${name}" but no loaded extension registers it -- stage the package that ships it, or fix the trigger`,
248+
"command-unregistered",
249+
);
250+
}
251+
log("command_dispatch", { command: name });
252+
}
253+
254+
// A throwing command handler is SWALLOWED by pi -- emitError, handled=true, prompt() resolves
255+
// cleanly -- and the extension runner's error channel is the only place it surfaces at the pin
256+
// (never the session event bus). Subscribe before prompt so decideExit can tell a failed command
257+
// from a completed one; scoped to command jobs because that is the only path whose success would
258+
// otherwise be decided by an event that cannot arrive.
259+
let commandFailed = false;
260+
const unsubscribeCommandErrors = cfg.command
261+
? session.extensionRunner.onError((extensionError) => {
262+
if (extensionError?.event === "command") commandFailed = true;
263+
})
264+
: null;
265+
229266
// prompt() returns Promise<void>, so this subscription is the ONLY channel through which the
230267
// outcome arrives. captureTerminal handles both event shapes (agent_end carries messages[],
231268
// turn_end carries message).
@@ -258,6 +295,7 @@ async function main() {
258295
// the session it was metering is exactly the kind of thing that becomes one.
259296
usageMeter.uninstall();
260297
unsubscribeTerminal();
298+
unsubscribeCommandErrors?.();
261299
// Runs the cleanup callbacks providers register via registerSessionResourceCleanup. Every
262300
// official SDK example disposes; skipping it can leak a provider transport and hang the
263301
// container until the 30-minute timeout -- a completed job turned into a timeout failure.
@@ -271,6 +309,8 @@ async function main() {
271309
// same reason:"token_budget" / exit 2.
272310
tokenAborted: usageMeter.ok ? meter.state.breached : tokenBudget.state.aborted,
273311
terminal,
312+
// Null for every prompt job, so their decision tree is byte-identical to before run.command.
313+
command: cfg.command ? { failed: commandFailed } : null,
274314
});
275315
// `metered: true` on the process-wide snapshot is what tells the daily token counter that this
276316
// total includes every in-process session, not just the root's turns.

image/runner/src/config.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export function parseRunnerEnv(env) {
3737
// compare it against the loaded skill names. `null` when the job carries no flow (a bare
3838
// run.task cron job), which skips the check entirely.
3939
flow: parseFlowName(env, "PI_FLOW"),
40+
// INT-CONTAINER-JOB-INPUTS (issue #189): the trigger's run.command -- the registered extension
41+
// command this job dispatches instead of a prompt. `null` (the overwhelmingly common state)
42+
// means a prompt job, byte-identical to every job before the feature.
43+
command: parseCommand(env, "PI_COMMAND"),
4044
retry: {
4145
maxRetries: parsePositiveInt(env, "PI_RETRY_MAX", 2),
4246
baseDelayMs: parsePositiveInt(env, "PI_RETRY_BASE_MS", 2000),
@@ -155,6 +159,47 @@ function parseFlowName(env, name) {
155159
return raw;
156160
}
157161

162+
/**
163+
* Parse the command a run.command trigger dispatches (issue #189). Unset or empty is `null` -- a
164+
* prompt job, the default. Unlike PI_FLOW this one IS validated, strictly, because the value is not
165+
* merely compared: run-job rebuilds the prompt as `/<value>` and hands it to session.prompt(), whose
166+
* dispatch grammar at the pin reads the command NAME up to the first space and passes EVERYTHING
167+
* after it -- including a newline and whatever follows -- as args. So:
168+
* - a leading "/" is refused: the runner adds the slash, and accepting one here would make
169+
* "//name" -- a prompt, silently, since no command named "/name" can register;
170+
* - surrounding whitespace is refused rather than trimmed: a trailing space changes the args a
171+
* handler receives, and normalizing here would make the container disagree with the reviewed
172+
* file about what runs;
173+
* - control characters are refused outright: a newline would smuggle a second line into what the
174+
* operator reviewed as one command line.
175+
* All deterministic misconfigurations: configError, exit 2, never retried.
176+
*/
177+
function parseCommand(env, name) {
178+
const raw = env[name];
179+
if (raw === undefined || raw === "") return null;
180+
if (raw.startsWith("/")) {
181+
throw configError(`invalid ${name}: ${JSON.stringify(raw)} (no leading "/" -- the runner adds it)`);
182+
}
183+
if (raw !== raw.trim()) {
184+
throw configError(`invalid ${name}: ${JSON.stringify(raw)} (no surrounding whitespace)`);
185+
}
186+
// Every C0 control plus DEL, written as escapes so the source itself carries no control byte.
187+
if (/[\u0000-\u001f\u007f]/.test(raw)) {
188+
throw configError(`invalid ${name}: contains a control character (a newline or tab would change what dispatches)`);
189+
}
190+
return raw;
191+
}
192+
193+
/**
194+
* The command NAME inside a run.command string -- pi's own parse, verbatim (text to the first space,
195+
* dist/core/agent-session.js _tryExecuteExtensionCommand). Exported so run-job's pre-prompt
196+
* getCommand() verification and the tests read the string the same way pi will.
197+
*/
198+
export function commandName(command) {
199+
const spaceIndex = command.indexOf(" ");
200+
return spaceIndex === -1 ? command : command.slice(0, spaceIndex);
201+
}
202+
158203
/**
159204
* Parse the persisted-session path (INT-SESSION-STORE-CONTRACT).
160205
*

image/runner/src/outcome.mjs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ export const STOP_REASONS = ["stop", "length", "toolUse", "error", "aborted"];
1717
* retryable. Tagging the error with its exit code is more robust than pattern-matching the
1818
* message: classifyThrow honours the tag before it ever consults pi's error vocabulary.
1919
*/
20-
export function configError(message) {
20+
export function configError(message, reason = "config") {
2121
const error = new Error(message);
2222
error.piDispatchExit = EXIT_POLICY;
23-
error.piDispatchReason = "config";
23+
// The optional reason (issue #189) rides the exit log line through classifyThrow, so a distinct
24+
// deterministic refusal -- an unregistered run.command -- stays greppable without a new exit code.
25+
// The default keeps every pre-existing caller byte-identical.
26+
error.piDispatchReason = reason;
2427
return error;
2528
}
2629

@@ -82,13 +85,31 @@ export function captureTerminal(previous, event) {
8285
* and names WHICH cap fired. The turn budget is checked before the token budget only for a stable
8386
* order; in practice one abort ends the run, so at most one flag is set.
8487
*/
85-
export function decideExit({ budgetAborted, budgetTurns, tokenAborted, terminal }) {
88+
export function decideExit({ budgetAborted, budgetTurns, tokenAborted, terminal, command = null }) {
8689
if (budgetAborted) {
8790
return { code: EXIT_POLICY, reason: "turn_budget", turns: budgetTurns };
8891
}
8992
if (tokenAborted) {
9093
return { code: EXIT_POLICY, reason: "token_budget" };
9194
}
95+
// A command job (issue #189, run.command): session.prompt("/name args") dispatches a registered
96+
// extension command and returns with NO assistant message, so the no-terminal branch below would
97+
// classify a clean headless run as infra and pay to retry a success. Three rules, in order:
98+
// - a handler that THREW wins over everything it produced: pi swallows the throw (the runner
99+
// observes it via extensionRunner.onError, the only channel pi offers at the pin) and a
100+
// stop-reason that claimed success would be the swallow reaching the exit code. `command-error`
101+
// is EXIT_INFRA -- retryable, by explicit choice: pi hands us only a message string, so
102+
// transient-vs-deterministic is undecidable, and the accepted cost is that a deterministic
103+
// extension bug retries until the queue's attempts run out (DES-COMMAND-ENTRY-POINT).
104+
// - a handler that drove the model (sendUserMessage/waitForIdle) produced a terminal message,
105+
// and its verdict is real -- a provider 429 inside a handler-driven turn must stay retryable.
106+
// - otherwise the command ran headlessly to completion: exit 0, named `command-completed`.
107+
// Budget aborts stay FIRST, above: a handler-driven fanout is bounded by both budgets.
108+
if (command) {
109+
if (command.failed) return { code: EXIT_INFRA, reason: "command-error" };
110+
if (terminal) return classifyStopReason(terminal);
111+
return { code: EXIT_COMPLETED, reason: "command-completed" };
112+
}
92113
return classifyStopReason(terminal);
93114
}
94115

image/runner/test/compose.test.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,27 @@ test("run-job.mjs verifies the flow against the LOADED skill set, unconditionall
119119
"the flow check must sit at the pre-spend moment",
120120
);
121121
});
122+
123+
test("run-job.mjs wires the command path: env-authoritative prompt, pre-spend verification, observed throws", () => {
124+
// Same source-guard tactic as the pins above. Five facts, each of which fails silently if unwired:
125+
// (1) the prompt is rebuilt from PI_COMMAND, never read from prompt.md, for a command job -- one
126+
// in-container authority, and pi's grammar demands the whole text start with "/";
127+
// (2) the command is verified against extensionRunner.getCommand BEFORE session.prompt -- an
128+
// unregistered "/name" is not an error to pi, it falls through toward a paid model call;
129+
// (3) the verification failure is the tagged command-unregistered refusal (exit 2, pre-spend);
130+
// (4) a swallowed handler throw is observed via extensionRunner.onError before the prompt;
131+
// (5) decideExit receives the command outcome, null for every prompt job.
132+
const src = readFileSync(new URL("../run-job.mjs", import.meta.url), "utf8");
133+
assert.match(src, /cfg\.command \? `\/\$\{cfg\.command\}` : readPrompt\(/, "the prompt must be env-authoritative for command jobs");
134+
assert.ok(
135+
src.indexOf("extensionRunner.getCommand") < src.indexOf("await session.prompt("),
136+
"getCommand verification must run before the prompt is sent",
137+
);
138+
assert.match(src, /"command-unregistered"/, "the refusal must carry its own greppable reason");
139+
assert.ok(
140+
src.indexOf("extensionRunner.onError") < src.indexOf("await session.prompt("),
141+
"the error-channel subscription must exist before the prompt, or a fast throw is missed",
142+
);
143+
assert.match(src, /command: cfg\.command \? \{ failed: commandFailed \} : null/, "decideExit must receive the command outcome");
144+
assert.match(src, /log\("command_dispatch", \{ command: name \}\)/, "the dispatch line carries the NAME only, never args");
145+
});

image/runner/test/config.test.mjs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { test } from "node:test";
3-
import { assertPackagePathsExist, enforceOfflineMode, parseRunnerEnv } from "../src/config.mjs";
3+
import { assertPackagePathsExist, commandName, enforceOfflineMode, parseRunnerEnv } from "../src/config.mjs";
44
import { EXIT_POLICY } from "../src/outcome.mjs";
55

66
const base = { PI_PROVIDER: "anthropic", PI_MODEL: "claude-x", PI_MAX_TURNS: "20" };
@@ -223,3 +223,31 @@ test("PI_FLOW parses to the exact string, with no charset opinion", () => {
223223
// yesterday's jobs on an image upgrade. The comparison simply misses, and the miss is the report.
224224
assert.equal(parseRunnerEnv({ ...base, PI_FLOW: "Not A Skill Name" }).flow, "Not A Skill Name");
225225
});
226+
227+
test("PI_COMMAND is optional: unset or empty is null, so a prompt job is byte-identical to today", () => {
228+
assert.equal(parseRunnerEnv(base).command, null);
229+
assert.equal(parseRunnerEnv({ ...base, PI_COMMAND: "" }).command, null);
230+
});
231+
232+
test("PI_COMMAND parses a bare name and a name with args, verbatim", () => {
233+
assert.equal(parseRunnerEnv({ ...base, PI_COMMAND: "wf" }).command, "wf");
234+
assert.equal(parseRunnerEnv({ ...base, PI_COMMAND: "wf run nightly" }).command, "wf run nightly");
235+
});
236+
237+
test("PI_COMMAND refuses a leading slash, surrounding whitespace, and control characters -- exit 2", () => {
238+
// Dispatch grammar at the pin: the runner prepends "/", the name runs to the first space, and
239+
// EVERYTHING after -- a newline included -- becomes handler args. Each refusal below is a value
240+
// that would silently change what dispatches rather than fail.
241+
for (const bad of ["/wf", " wf", "wf ", "wf run\nnightly", "wf\trun", "wf \u001b[1m"]) {
242+
assert.throws(
243+
() => parseRunnerEnv({ ...base, PI_COMMAND: bad }),
244+
(e) => e.piDispatchExit === EXIT_POLICY,
245+
`PI_COMMAND=${JSON.stringify(bad)} must refuse pre-spend, not silently reshape the dispatch`,
246+
);
247+
}
248+
});
249+
250+
test("commandName reads the string exactly as pi's dispatch will: text to the first space", () => {
251+
assert.equal(commandName("wf"), "wf");
252+
assert.equal(commandName("wf run nightly"), "wf");
253+
});

0 commit comments

Comments
 (0)