Skip to content

Commit a85ef70

Browse files
committed
feat(runner): report a run.flow that resolves in no loaded skill tier (#189)
The trigger's run.flow reached the container only as prompt prose, and pi never matches prose against loaded skill names, so a flow that materialised in no tier (repo, injected, overlay or staged package) ran to a clean exit 0 without the procedure it was written for and reported success for work it could not have done. The worker now forwards the flow structurally as PI_FLOW (omitted when the job carries none, never an empty string; env and not event.json, because an execution knob is not a fact about the delivery), and the runner compares it against the skill set the loader actually materialised, immediately after resource load and before any session or spend, emitting one flow_not_loaded line on a miss. Report, not refusal, deliberately: run.flow is by long doctrine a prompt hint, and a refusal shipped in an image upgrade would fail yesterday's jobs for a value the reviewed file has carried all along. The check sits at the pre-spend moment, so flipping report to refusal later is a one-line change at the same site. getSkills() moves out of the packages-only guard so the check runs for every job. isFlowLoaded is exact name equality against the LOADED names (pi names a skill frontmatter name || parent dir at the pin, now pinned by a loader test), and a disableModelInvocation skill counts as loaded: it is absent from the catalogue but invocable, so it is not the silent no-op this line exists to catch. Specs: INT-CONTAINER-JOB-INPUTS AMENDED (PI_FLOW), REQ-PER-TRIGGER-SKILLS AMENDED (the runner acceptance clause), NEW DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS (report-not-refuse and the rejected alternatives: boot-time failure, event.json carriage, a new top-level outcome). UNCHANGED, checked: INT-RUNNER-EXIT-CODE-PROTOCOL, INT-TRIGGERS-FILE-CONTRACT, INT-WEBHOOK-PAYLOAD-SUBSET, REQ-GLOBAL-PI-OVERLAY, REQ-DEPLOYMENT-BOOTSTRAP, DES-AI-TRIGGER-FLOW-GATE, DES-TRIGGERS-UNIFIED-FILE. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent d26c2f4 commit a85ef70

14 files changed

Lines changed: 243 additions & 5 deletions

image/runner/run-job.mjs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
decideExit,
1616
EXIT_INFRA,
1717
} from "./src/outcome.mjs";
18-
import { countPackageResources, findShadowedSkills, owningRoot } from "./src/packages.mjs";
18+
import { countPackageResources, findShadowedSkills, isFlowLoaded, owningRoot } from "./src/packages.mjs";
1919
import { openSessionManager } from "./src/session.mjs";
2020
import { attachTokenBudget } from "./src/token-budget.mjs";
2121
import { attachTurnBudget } from "./src/turn-budget.mjs";
@@ -107,9 +107,28 @@ async function main() {
107107
log,
108108
});
109109

110-
if (cfg.packages.length > 0) {
111-
const { skills, diagnostics } = resourceLoader.getSkills();
110+
// Read ONCE, unconditionally: the flow check below needs the loaded set whether or not packages
111+
// are staged, and the packages diagnostics block reuses the same bindings.
112+
const { skills, diagnostics } = resourceLoader.getSkills();
113+
114+
// REPORT a flow that resolved in NO tier (issue #189). The trigger's run.flow reaches the model
115+
// as prompt prose, and pi never matches prose against loaded skill names, so without this line a
116+
// flow that materialised nowhere -- repo, injected, overlay or staged package -- runs to a clean
117+
// exit 0 without the procedure it was written for and reports success for work it could not have
118+
// done. That is the exact outcome assertPackagePathsExist refuses for an unmounted package root,
119+
// and the deliberate difference is that this one only REPORTS: run.flow is by long doctrine a
120+
// prompt hint (prepare.mjs), deployments legitimately run flows as loose hints over repos with no
121+
// .pi/skills, and the runner cannot tell that steady state from breakage. Refusing would break
122+
// them on an image upgrade for a value their reviewed file has carried all along. The line sits
123+
// at the pre-spend moment anyway, so flipping report to refusal is a one-line change here plus a
124+
// spec row (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS records the choice). Doctor's host-side tier
125+
// lines are the other advisory layer; this one is exact because it reads what actually loaded.
126+
// Flow name, never task content: run.flow is operator config out of the reviewed triggers file.
127+
if (!isFlowLoaded(cfg.flow, skills)) {
128+
log("flow_not_loaded", { flow: cfg.flow, skills: skills.length });
129+
}
112130

131+
if (cfg.packages.length > 0) {
113132
// REPORT a staged package skill that TRIED to shadow a repo or operator-overlay skill.
114133
//
115134
// pi builds skillPaths as mergePaths(cliEnabledSkills, additionalSkillPaths) -- package paths

image/runner/src/config.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export function parseRunnerEnv(env) {
3333
// path under the per-job /session mount. `null` when the trigger did not arm run.resume, which is
3434
// the default and is byte-identical to every job before the feature existed.
3535
sessionFile: parseSessionFile(env, "PI_SESSION_FILE"),
36+
// INT-CONTAINER-JOB-INPUTS (issue #189): the trigger's run.flow, structurally, so run-job can
37+
// compare it against the loaded skill names. `null` when the job carries no flow (a bare
38+
// run.task cron job), which skips the check entirely.
39+
flow: parseFlowName(env, "PI_FLOW"),
3640
retry: {
3741
maxRetries: parsePositiveInt(env, "PI_RETRY_MAX", 2),
3842
baseDelayMs: parsePositiveInt(env, "PI_RETRY_BASE_MS", 2000),
@@ -132,6 +136,25 @@ function parsePackagePaths(env, name) {
132136
return paths;
133137
}
134138

139+
/**
140+
* Parse the flow name the worker forwarded (issue #189). Unset or empty is `null` -- a job whose
141+
* trigger names no flow has nothing to verify, and that is the normal state for a bare run.task
142+
* cron job, not a misconfiguration.
143+
*
144+
* Deliberately NO charset validation here, unlike every sibling parser above. The value is used for
145+
* exactly two things -- name-equality against pi's loaded skill set and one log field -- and is
146+
* never interpolated into a path or a shell word, so a strange name cannot escape anything. Refusing
147+
* a shape the worker's own validator accepted (parseTriggers pins run.flow to a non-empty string,
148+
* nothing narrower) would mean an image upgrade starts failing jobs that ran yesterday, for a value
149+
* the operator's reviewed file has carried all along. The comparison simply misses and the miss is
150+
* reported, which is this variable's whole purpose.
151+
*/
152+
function parseFlowName(env, name) {
153+
const raw = env[name];
154+
if (raw === undefined || raw === "") return null;
155+
return raw;
156+
}
157+
135158
/**
136159
* Parse the persisted-session path (INT-SESSION-STORE-CONTRACT).
137160
*

image/runner/src/packages.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,26 @@ export function countPackageResources({ packageRoots = [], extensionPaths = [],
185185
}));
186186
}
187187

188+
/**
189+
* Does the trigger's flow name any skill pi actually loaded? (Issue #189.)
190+
*
191+
* Exact name equality against the LOADED set, which makes this the one check in the system that is
192+
* not an approximation: doctor probes tier directories host-side and a dir name can lie (pi names a
193+
* skill `frontmatter.name || parentDirName` at the 0.80.7 pin, skills.js:221), but here the names
194+
* come off the skills the loader materialised for THIS job, after every tier and override.
195+
*
196+
* A `disableModelInvocation` skill still counts as loaded -- it is absent from the system-prompt
197+
* catalogue but present in the session, invocable as /skill:name, so the flow it names is not the
198+
* silent no-op this check exists to catch.
199+
*
200+
* `flow` null/empty returns true: no flow, nothing to verify, no line. Malformed skill entries
201+
* (no `name`) are skipped rather than crashing a job over a diagnostic input.
202+
*/
203+
export function isFlowLoaded(flow, skills) {
204+
if (flow === null || flow === undefined || flow === "") return true;
205+
return (skills ?? []).some((skill) => skill?.name === flow);
206+
}
207+
188208
/**
189209
* Containment by path SEGMENT, not by string prefix -- `/opt/pi-global/packages/tool` must not
190210
* claim a path under `/opt/pi-global/packages/tools`. Trailing slashes on a root are tolerated

image/runner/test/compose.test.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,24 @@ test("run-job.mjs staples `usage` onto the success-path exit line -- worker pars
9898
// The catch-path exit line legitimately omits `usage` for the same reason it omits turns: no meter
9999
// exists when a preflight throw kills the run before any session started.
100100
});
101+
102+
test("run-job.mjs verifies the flow against the LOADED skill set, unconditionally and pre-spend", () => {
103+
// Same source-guard tactic as the turns/usage pins above (main() self-runs on import, log is
104+
// unexported). Three orderings pinned, each of which failed silently before issue #189:
105+
// (1) getSkills() sits OUTSIDE the packages guard -- inside it, the flow check would only run for
106+
// jobs with staged packages, which is exactly the blindness being closed;
107+
// (2) the flow_not_loaded line exists and carries the flow -- a flow that resolves in no tier
108+
// must leave a named, greppable trace, so "a silent exit 0 for this case" is a failing test;
109+
// (3) the check sits before openSessionManager -- the pre-spend moment, so flipping report to
110+
// refusal (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS) stays a one-line change at this site.
111+
const src = readFileSync(new URL("../run-job.mjs", import.meta.url), "utf8");
112+
assert.ok(
113+
src.indexOf("resourceLoader.getSkills()") < src.indexOf("if (cfg.packages.length"),
114+
"getSkills() must be read before (outside) the packages guard",
115+
);
116+
assert.match(src, /log\("flow_not_loaded",\s*\{[^}]*flow:/, "the miss must leave a named line carrying the flow");
117+
assert.ok(
118+
src.indexOf('log("flow_not_loaded"') < src.indexOf("openSessionManager("),
119+
"the flow check must sit at the pre-spend moment",
120+
);
121+
});

image/runner/test/config.test.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,17 @@ test("PI_SESSION_FILE refuses a relative path, a .. segment, and anything under
209209
// A path merely NAMED like the workspace is fine -- the check is on the path boundary, not a prefix.
210210
assert.equal(parseRunnerEnv({ ...base, PI_SESSION_FILE: "/workspace-sessions/s.jsonl" }).sessionFile, "/workspace-sessions/s.jsonl");
211211
});
212+
213+
test("PI_FLOW is optional: unset or empty is null, so a flowless job is byte-identical to today", () => {
214+
assert.equal(parseRunnerEnv(base).flow, null);
215+
assert.equal(parseRunnerEnv({ ...base, PI_FLOW: "" }).flow, null);
216+
});
217+
218+
test("PI_FLOW parses to the exact string, with no charset opinion", () => {
219+
assert.equal(parseRunnerEnv({ ...base, PI_FLOW: "review" }).flow, "review");
220+
// Deliberately accepted: the value is only compared against loaded skill names and logged, never
221+
// interpolated into a path or a shell word, and refusing a shape the worker's own validator
222+
// accepted (parseTriggers pins non-empty string, nothing narrower) would start failing
223+
// yesterday's jobs on an image upgrade. The comparison simply misses, and the miss is the report.
224+
assert.equal(parseRunnerEnv({ ...base, PI_FLOW: "Not A Skill Name" }).flow, "Not A Skill Name");
225+
});

image/runner/test/loader.test.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,30 @@ test("a staged skill whose name collides with nothing is left completely alone",
743743
assert.equal(skill.filePath, join(pkg, "skills", "pkg-skill", "SKILL.md"));
744744
});
745745

746+
test("pi names a skill frontmatter `name` || its parent dir -- the premise the flow check compares against", { skip }, async () => {
747+
// isFlowLoaded (issue #189) does exact equality against these loaded names, and doctor's
748+
// host-side tier probes approximate them by DIR name. This pins the naming rule at the pin
749+
// (loadSkill: frontmatter.name || basename(dirname)), so a pi bump that changes it fails here
750+
// rather than silently turning the runner's check, or doctor's ✓, into a lie.
751+
const f = fixture();
752+
// No `name:` in frontmatter -> the parent DIR is the name.
753+
mkdirSync(join(f.jobPi, "skills", "dir-named"), { recursive: true });
754+
writeFileSync(join(f.jobPi, "skills", "dir-named", "SKILL.md"), "---\ndescription: named by its dir\n---\n\nSteps.\n");
755+
// Frontmatter `name:` wins over the dir -- the rename case a dir-name probe cannot see.
756+
mkdirSync(join(f.jobPi, "skills", "some-dir"), { recursive: true });
757+
writeFileSync(join(f.jobPi, "skills", "some-dir", "SKILL.md"), "---\nname: renamed\ndescription: named by frontmatter\n---\n\nSteps.\n");
758+
const loader = await loaderModule.buildLoadedResourceLoader({
759+
cwd: f.workspace,
760+
jobPiDir: f.jobPi,
761+
guardrailsPath: f.guardrailsPath,
762+
outboxProtocolPath: f.outboxProtocolPath,
763+
});
764+
const names = loader.getSkills().skills.map((s) => s.name);
765+
assert.ok(names.includes("dir-named"), `no frontmatter name -> the dir names the skill; got ${JSON.stringify(names)}`);
766+
assert.ok(names.includes("renamed"), "a frontmatter name must win over the dir name");
767+
assert.ok(!names.includes("some-dir"), "the renamed skill's dir must NOT also be a loaded name");
768+
});
769+
746770
// --- enforceProtectedSkillPrecedence, decided on injected input (no skills tree, no collisions) ---
747771

748772
/** A Skill-shaped record; only name and filePath are load-bearing for the precedence decision. */

image/runner/test/packages.test.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
countPackageResources,
77
extensionEntryName,
88
findShadowedSkills,
9+
isFlowLoaded,
910
isUnderAnyRoot,
1011
owningRoot,
1112
partitionAdminExtensions,
@@ -313,3 +314,35 @@ test("nothing admin-like means nothing dropped, and no input shape throws", () =
313314
// Tools may arrive as plain names too -- the decision must not depend on pi's container type.
314315
assert.equal(adminExtensionReason({ path: WORKSPACE_EXT, tools: ["dispatch_run"] }), "admin-tools");
315316
});
317+
318+
// --- isFlowLoaded (issue #189): the flow-resolves-somewhere check, against the LOADED set ---
319+
320+
test("a flow that names a loaded skill is loaded, and a near-miss is not", () => {
321+
const skills = [{ name: "review", filePath: "/job/pi/skills/review/SKILL.md" }, { name: "deploy" }];
322+
assert.equal(isFlowLoaded("review", skills), true);
323+
// Exact equality, no normalisation: pi's own catalogue is exact-name, so a check that fuzzed
324+
// ("revieww", case folds) would report loaded for a skill pi will never surface.
325+
assert.equal(isFlowLoaded("revieww", skills), false);
326+
assert.equal(isFlowLoaded("Review", skills), false);
327+
});
328+
329+
test("no flow means nothing to verify: null, undefined and empty all pass without a report", () => {
330+
// A bare run.task cron job carries no flow. That is the normal state, not a miss -- reporting it
331+
// would teach operators to ignore the line that matters.
332+
for (const flow of [null, undefined, ""]) {
333+
assert.equal(isFlowLoaded(flow, []), true, `flow ${JSON.stringify(flow)} must not report`);
334+
}
335+
});
336+
337+
test("a disableModelInvocation skill still counts as loaded", () => {
338+
// It is absent from the system-prompt catalogue but present in the session (invocable as
339+
// /skill:name), so the flow it names is not the silent no-op this check exists to catch.
340+
assert.equal(isFlowLoaded("quiet", [{ name: "quiet", disableModelInvocation: true }]), true);
341+
});
342+
343+
test("malformed skill entries and an absent list are skipped, never a crash", () => {
344+
// This runs inside every job; a diagnostic input must not take the job down with it.
345+
assert.equal(isFlowLoaded("review", [null, {}, { filePath: "/x" }]), false);
346+
assert.equal(isFlowLoaded("review", undefined), false);
347+
assert.equal(isFlowLoaded("review", []), false);
348+
});

0 commit comments

Comments
 (0)