You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Unattended project manager: a portfolio snapshot into a flagged cron job, a priorities plan back out through the outbox #505
The owner wants the project manager to run unattended as a scheduled job, not only in an operator's pi session.
The manager's judgement often reads issue text. CONST-ISOLATION-CONTAINER-PER-JOB scopes the operator's session out of the container rule because it "processes no adversarial input" (specs/constitution.md:64-68), so that judgement belongs in a container.
A job container is queue-blind on purpose. DES-JOB-OUTBOX-CHAINING (specs/design.md:2675) rejects VALKEY_URL in the container and a host HTTP broker (specs/design.md:2727-2731). The runner also drops the admin extension from every job session (dropAdminExtensions, image/runner/src/loader.mjs:83).
So a job cannot see budgets and cannot write a plan today.
The one channel back is /outbox, which the worker reads in worker/src/outbox.mjs after a completed exit (worker/src/processor.mjs:967-974).
That collector reads only request-<n>.json. A {"type": "priorities"} request with no flow would be refused as chain-bad-flow-name, and a file with any other name is never read.
Refusals only increase the record's chainRefused count, and the baked persona tells the agent they are silent (guardrails/OUTBOX_PROTOCOL.md, item 4).
What to build
run.portfolio on cron triggers (worker/src/triggers.mjs, the cron normaliser near line 340).
It is strictly boolean. A non-boolean value is refused at load.
It is refused on every webhook kind: a forge job is driven by untrusted text and has no /outbox.
It is refused beside run.command. A plan is written by a flow's judgement, and a command has no committed skill to review.
worker/src/schedules.mjs carries it into the job data. Absent stays absent, so an unflagged schedule stays byte-identical.
It is reviewed-file-only, like run.secrets. buildTriggerEntry behind dispatch_trigger_add gets no portfolio field, and the panel's a key does not offer it. dispatch_trigger_edit keeps an existing flag. A test pins both facts.
It is not inherited by outbox children. The explicit property reads in worker/src/outbox.mjs:161-209 already leave it out; add a comment line with the reason, beside the secrets one.
Amend INT-TRIGGERS-FILE-CONTRACT with a run.portfolio bullet and extend the cron byte-match acceptance.
A free pre-spend gate. A portfolio job whose deployment has no envelope, or has delegation off, is refused before the mint and the clone as portfolio-no-envelope (policy, never retried). A manager that cannot apply a plan should not pay to write one.
The snapshot /job/portfolio.json. A new module, worker/src/portfolio-snapshot.mjs, with buildPortfolioSnapshot({ envelope, projects, allocation, redis, runs, now }).
prepareLocalWorkspace (worker/src/prepare-local.mjs:30) calls it through an injected option. It writes the result to jobDir/portfolio.json with mode 0o444, beside event.json, so it reaches the container through the existing read-only /job mount with no new mount.
It is built only when job.data.portfolio === trueand the live triggers file still flags job.data.trigger.id.
If Valkey is unreachable, it throws InfraRetry, like any infrastructure fault before the reserve.
The content rule: ids, digests, integers, ISO instants and enum tokens only.
Project ids and forge labels are operator configuration. A local member is labelled local:<basename>, the targetFor rule (worker/src/run-history.mjs:571-575).
No issue text, no title, no plan reason and no path.
runs7d comes from the local run history, merged with the Valkey run mirror when one is on (readMirroredRuns, worker/src/run-mirror.mjs:144). fleet.runsComplete is false when PI_WORKER_NAME is unset, because then there is no mirror (worker/src/start.mjs:976).
Money numbers come from the fleet-wide counters, so they are complete on every host.
The size is capped at 64 KiB. Over the cap, the job refuses pre-spend as portfolio-snapshot-oversize and names the project count.
How stale it may be: it is built at prepare time. The plan is collected at most JOB_TIMEOUT_MS (30 minutes, worker/src/index.mjs:20) later, and the basis check makes any change in between a refusal, not a silent overwrite.
The collector, a new makeCollectPlan in worker/src/outbox-plan.mjs.
It is injected into the processor like collectChain (worker/src/processor.mjs:249, wired in worker/src/index.mjs:813) and called on the completed branch after collectChain.
It never throws. A throw there would turn a completed paid job into a retry (CONST-RETRY-INFRA-ONLY).
Validation order, failing closed at the first miss:
Local parent and completed exit (the existing collection point). No file means plan: null in the record and nothing else happens.
Portfolio authority. job.data.portfolio === true, job.data.trigger present, no parentJobId or chainDepth, and the live triggers file entry with that id still says run.portfolio: true. Otherwise: plan-not-portfolio.
Size of at most 16 KiB, read with stat before any read: plan-oversize.
A regular file, checked with lstat: plan-not-regular-file.
JSON parse with an object root: plan-parse-error.
parsePlan: plan-invalid, plus a field name from a fixed list.
The plan id is a content hash. If a job is re-run after its plan applied (a stall after a completed container), the second collection sees the same id and records plan-duplicate as a no-op.
When two different plans race (the operator's dispatch_priorities_set and a job, or two hosts), the first to take alloc:lock wins. The second is refused as plan-stale, because its basis is no longer current, or as plan-too-soon.
Nothing merges two plans.
Refusal records, all PII-free.
The run record gains plan: { outcome: "applied"|"duplicate"|"refused", reason: <enum>|null, planId: <16 hex>|null, clamped: <bool> }. Amend INT-RUN-HISTORY-FILE-CONTRACT.
The worker log line is plan_collected with { jobId, outcome, reason } only.
The next snapshot's lastAttempt tells the next manager run what happened.
The persona. Add guardrails/PORTFOLIO_PROTOCOL.md with a sentinel, baked beside OUTBOX_PROTOCOL.md (image/Dockerfile:199).
The loader composes it only when /job/portfolio.json exists, evaluated once at loader build (CONST-PERSONA-IN-CACHED-PREFIX), in the same way OUTBOX_PROTOCOL.md is gated on the /outbox mount (image/runner/src/loader.mjs:266).
It documents the file, the schema, the basis rule, and that the host decides after exit.
Firing a cron trigger by hand: pi-dispatch run --trigger <cron id>. This is operator-typed CLI only, and no tool calls it.
It enqueues one job with that trigger's schedule data from the reviewed file. The job id is manual:<id>:<now>, and scheduledFor is null in event.json.
That is the only way to test a portfolio job without waiting for the schedule. pi-dispatch run <folder> makes a manual job, which never carries the flag.
Spec entries.
INT-OUTBOX-CONTRACT: a second file with its own ladder.
DES-JOB-OUTBOX-CHAINING: a second kind of request. Record the rejected type discriminator and why.
readMirroredRuns and mergeRuns (worker/src/run-mirror.mjs:144,199) for run counts.
The loader's gated persona composition (image/runner/src/loader.mjs:266) and the contract test that asserts OUTBOX-SENTINEL.
What not to do
Do not give the container Valkey, the envelope or an admin tool. DES-JOB-OUTBOX-CHAINING rejected both routes, and a plan file is enough.
Do not reuse request-<n>.json with a type key. It would share the chain count cap, and an older worker would refuse it under a misleading reason.
Do not trust the flag on job data alone. Re-read the live triggers file at snapshot time and at collection time, so removing the flag takes effect for jobs already queued.
Do not put issue text, titles or reasons into the snapshot. The next manager run would read agent-authored or attacker-chosen text as if it were facts.
Do not widen the outbox to forge jobs. A manager does not need it, and an untrusted issue author must never reach this channel.
Do not make a refused plan a failed job. The container completed. A refusal is a recorded outcome.
How to test it
Automated
worker/test/triggers.test.mjs: run.portfolio accepted on cron. Refused as a non-boolean, on each webhook kind, and beside run.command. The byte-match for an unflagged cron entry is unchanged.
admin/test/crud.test.mjs: dispatch_trigger_add cannot produce the flag. dispatch_trigger_edit keeps it.
worker/test/portfolio-snapshot.test.mjs: a fixture with two projects and fake counters gives the expected JSON byte for byte. It uses an injected now, always. A fixture issue title planted in a run record must not appear. Also covers the oversize refusal and runsComplete: false without a mirror.
worker/test/outbox-plan.test.mjs, with the injected fs fakes of worker/test/outbox.test.mjs: one test per ladder step and in order. A symlinked priorities.json is rejected. A manual job and a chained child are refused as plan-not-portfolio. A flag removed from the live file after scheduling is refused. A retried parent is recorded as plan-duplicate. The collector never throws, even when applyPlan rejects.
worker/test/processor.test.mjs: the plan is collected only on the completed branch, and a policy or infra exit collects nothing. A refused plan leaves outcome: "completed". portfolio-no-envelope refuses before the mint.
worker/test/run-history.test.mjs: the plan field is enum and hash only.
image/runner/test/loader.test.mjs: the portfolio persona is composed only with the file present, and the prompt is byte-identical across turns.
worker/test/cli.test.mjs: run --trigger enqueues the trigger's data, and an unknown id is refused.
Live Valkey in CI: a job's plan and a concurrent dispatch_priorities_set give one applied and one plan-stale or plan-busy.
Mutation checks, each of which must turn a test red: skip the live-file re-check, read priorities.json with stat instead of lstat, collect on the policy branch, inherit portfolio into a chain child.
Make a folder with git init, a flow pm-echo whose SKILL.md says "copy /workspace/plan.json to /outbox/priorities.json, then stop", and a committed plan.json with shop 3 and platform 1 and the current basis (null when no plan has applied yet). Add a cron trigger with "portfolio": true, "model" set to the Ollama model, and a yearly pattern.
Run pi-dispatch run --trigger pm-weekly. The flow also runs cat /job/portfolio.json, so /dispatch logs shows the snapshot. Check it holds ids and numbers only. Expect a run record with plan.outcome: "applied", and the panel showing 70/30.
Remove "portfolio": true and run it again. Expect plan.outcome: "refused" with reason plan-not-portfolio, and no change in the panel.
Put the flag back and run it at once. Expect plan-duplicate. Change a weight in plan.json without updating basis, commit, and run again: expect plan-stale. Set basis to the current plan id from /dispatch priorities, commit, and run again: expect plan-too-soon.
Unset the envelope file and run it. Expect the refusal portfolio-no-envelope, with no container started and no reservation.
The same file from an unflagged, manual or chained job is refused as plan-not-portfolio and recorded.
Every ladder refusal is recorded in the run record, the audit log and the next snapshot's lastAttempt. None is silent.
The snapshot holds no free text from any forge, and no reason or path.
run.portfolio cannot be set by any model-callable tool or panel key.
pi-dispatch run --trigger fires a cron trigger once by hand.
The spec entries above are amended with revision rows.
Depends on / unblocks
Depends on #504 (plan contract, apply, envelope, audit), #499 and #501. The by-hand test depends on #503. Unblocks #506.
Open questions
Should a portfolio job be refused when a plan is already valid and too recent to replace? Recommendation: no. The flow may still report, and plan-too-soon is recorded. The free gate covers only a missing envelope.
Should run --trigger exist, or should the docs say "set the pattern to the next minute"? Recommendation: build it. Editing a reviewed file to test is error-prone, and the command is small.
Should the runner check the plan before exit, so the agent gets feedback? Recommendation: yes, a read-only parsePlan check in the runner that prints refusals to the log. The host still decides.
Why this is needed
The owner wants the project manager to run unattended as a scheduled job, not only in an operator's pi session.
The manager's judgement often reads issue text.
CONST-ISOLATION-CONTAINER-PER-JOBscopes the operator's session out of the container rule because it "processes no adversarial input" (specs/constitution.md:64-68), so that judgement belongs in a container.A job container is queue-blind on purpose.
DES-JOB-OUTBOX-CHAINING(specs/design.md:2675) rejectsVALKEY_URLin the container and a host HTTP broker (specs/design.md:2727-2731). The runner also drops the admin extension from every job session (dropAdminExtensions,image/runner/src/loader.mjs:83).So a job cannot see budgets and cannot write a plan today.
The one channel back is
/outbox, which the worker reads inworker/src/outbox.mjsafter a completed exit (worker/src/processor.mjs:967-974).That collector reads only
request-<n>.json. A{"type": "priorities"}request with noflowwould be refused aschain-bad-flow-name, and a file with any other name is never read.Refusals only increase the record's
chainRefusedcount, and the baked persona tells the agent they are silent (guardrails/OUTBOX_PROTOCOL.md, item 4).What to build
run.portfolioon cron triggers (worker/src/triggers.mjs, the cron normaliser near line 340)./outbox.run.command. A plan is written by a flow's judgement, and a command has no committed skill to review.worker/src/schedules.mjscarries it into the job data. Absent stays absent, so an unflagged schedule stays byte-identical.run.secrets.buildTriggerEntrybehinddispatch_trigger_addgets noportfoliofield, and the panel'sakey does not offer it.dispatch_trigger_editkeeps an existing flag. A test pins both facts.worker/src/outbox.mjs:161-209already leave it out; add a comment line with the reason, beside thesecretsone.INT-TRIGGERS-FILE-CONTRACTwith arun.portfoliobullet and extend the cron byte-match acceptance.portfolio-no-envelope(policy, never retried). A manager that cannot apply a plan should not pay to write one./job/portfolio.json. A new module,worker/src/portfolio-snapshot.mjs, withbuildPortfolioSnapshot({ envelope, projects, allocation, redis, runs, now }).prepareLocalWorkspace(worker/src/prepare-local.mjs:30) calls it through an injected option. It writes the result tojobDir/portfolio.jsonwith mode0o444, besideevent.json, so it reaches the container through the existing read-only/jobmount with no new mount.job.data.portfolio === trueand the live triggers file still flagsjob.data.trigger.id.InfraRetry, like any infrastructure fault before the reserve.{ "version": 1, "generatedAt": "2026-10-05T06:00:03Z", "window": { "kind": "week", "start": "2026-10-05", "end": "2026-10-12" }, "envelope": { "digest": "c0ffee0123456789", "totalMicros": 100000000, "maxStepPct": 25, "minIntervalHours": 24, "maxPlanDays": 14, "planAllowedAfter": "2026-10-05T06:00:00Z" }, "plan": { "id": "3f9a0c1d2e4b5a67", "writer": "portfolio-job", "appliedAt": "…", "validUntil": "…", "clamped": false }, "lastAttempt": { "at": "…", "outcome": "refused", "reason": "plan-too-soon" }, "projects": [ { "id": "shop", "floorMicros": 10000000, "weight": 3, "allocationMicros": 70000000, "reservedMicros": 2000000, "spentMicros": 31000000, "members": [ { "ref": "a1b2c3d4", "label": "github:acme/web", "weight": 2, "allocationMicros": 46666667, "spentMicros": 20000000 }, { "ref": "9e8d7c6b", "label": "local:shop-tools" } ], "runs7d": { "completed": 12, "policy": 3, "failed": 1, "byReason": { "allocation-cap": 2, "scope-cap": 1 } } } ], "fleet": { "runsComplete": true } }local:<basename>, thetargetForrule (worker/src/run-history.mjs:571-575).runs7dcomes from the local run history, merged with the Valkey run mirror when one is on (readMirroredRuns,worker/src/run-mirror.mjs:144).fleet.runsCompleteisfalsewhenPI_WORKER_NAMEis unset, because then there is no mirror (worker/src/start.mjs:976).portfolio-snapshot-oversizeand names the project count.JOB_TIMEOUT_MS(30 minutes,worker/src/index.mjs:20) later, and thebasischeck makes any change in between a refusal, not a silent overwrite./outbox/priorities.json. It is a separate file name, not arequest-<n>.jsonwith atype. It never uses a chain slot (PI_CHAIN_MAX_PER_JOB), and older workers cannot misread it as a chain request. The body is exactlyINT-PRIORITIES-PLAN-CONTRACTfrom Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504.makeCollectPlaninworker/src/outbox-plan.mjs.collectChain(worker/src/processor.mjs:249, wired inworker/src/index.mjs:813) and called on the completed branch aftercollectChain.CONST-RETRY-INFRA-ONLY).plan: nullin the record and nothing else happens.job.data.portfolio === true,job.data.triggerpresent, noparentJobIdorchainDepth, and the live triggers file entry with that id still saysrun.portfolio: true. Otherwise:plan-not-portfolio.statbefore any read:plan-oversize.lstat:plan-not-regular-file.plan-parse-error.parsePlan:plan-invalid, plus a field name from a fixed list.applyPlanfrom Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504 with writer{ kind: "portfolio-job", jobId, triggerId }and its own ladder (delegation-offthroughplan-busy).plan-duplicateas a no-op.dispatch_priorities_setand a job, or two hosts), the first to takealloc:lockwins. The second is refused asplan-stale, because its basis is no longer current, or asplan-too-soon.plan: { outcome: "applied"|"duplicate"|"refused", reason: <enum>|null, planId: <16 hex>|null, clamped: <bool> }. AmendINT-RUN-HISTORY-FILE-CONTRACT.plan_collectedwith{ jobId, outcome, reason }only.lastAttempttells the next manager run what happened.guardrails/PORTFOLIO_PROTOCOL.mdwith a sentinel, baked besideOUTBOX_PROTOCOL.md(image/Dockerfile:199)./job/portfolio.jsonexists, evaluated once at loader build (CONST-PERSONA-IN-CACHED-PREFIX), in the same wayOUTBOX_PROTOCOL.mdis gated on the/outboxmount (image/runner/src/loader.mjs:266).basisrule, and that the host decides after exit.pi-dispatch run --trigger <cron id>. This is operator-typed CLI only, and no tool calls it.manual:<id>:<now>, andscheduledForisnullinevent.json.pi-dispatch run <folder>makes a manual job, which never carries the flag.INT-OUTBOX-CONTRACT: a second file with its own ladder.DES-JOB-OUTBOX-CHAINING: a second kind of request. Record the rejectedtypediscriminator and why.INT-CONTAINER-JOB-INPUTS:/job/portfolio.json.INT-TRIGGERS-FILE-CONTRACT,INT-RUN-HISTORY-FILE-CONTRACT.SECURITY.mdtrigger table row: "Portfolio plan | a completed portfolio cron job's agent | revert in the panel".REQ-AI-TRIGGERED-RUNS: UNCHANGED, checked. A plan enqueues nothing.With what
makeCollectChain(worker/src/outbox.mjs:39-221).event.jsonwriter inworker/src/prepare-local.mjs:52-64for a0o444job input.localEventContextinworker/src/prepare.mjs:167for how cron is told apart from manual and chain.parsePlan,applyPlan, envelope ref and audit log. Projects: group repos and folders into a project that is recorded per run, folded in the cost views and capped as one #499's project membership. Dollar budgets: a per-job cost cap enforced before each provider call, and dollar windows reserved before the run and settled after #501's counters.readMirroredRunsandmergeRuns(worker/src/run-mirror.mjs:144,199) for run counts.image/runner/src/loader.mjs:266) and the contract test that assertsOUTBOX-SENTINEL.What not to do
DES-JOB-OUTBOX-CHAININGrejected both routes, and a plan file is enough.request-<n>.jsonwith atypekey. It would share the chain count cap, and an older worker would refuse it under a misleading reason.How to test it
Automated
worker/test/triggers.test.mjs:run.portfolioaccepted on cron. Refused as a non-boolean, on each webhook kind, and besiderun.command. The byte-match for an unflagged cron entry is unchanged.admin/test/crud.test.mjs:dispatch_trigger_addcannot produce the flag.dispatch_trigger_editkeeps it.worker/test/portfolio-snapshot.test.mjs: a fixture with two projects and fake counters gives the expected JSON byte for byte. It uses an injectednow, always. A fixture issue title planted in a run record must not appear. Also covers the oversize refusal andrunsComplete: falsewithout a mirror.worker/test/outbox-plan.test.mjs, with the injectedfsfakes ofworker/test/outbox.test.mjs: one test per ladder step and in order. A symlinkedpriorities.jsonis rejected. A manual job and a chained child are refused asplan-not-portfolio. A flag removed from the live file after scheduling is refused. A retried parent is recorded asplan-duplicate. The collector never throws, even whenapplyPlanrejects.worker/test/processor.test.mjs: the plan is collected only on the completed branch, and a policy or infra exit collects nothing. A refused plan leavesoutcome: "completed".portfolio-no-enveloperefuses before the mint.worker/test/run-history.test.mjs: theplanfield is enum and hash only.image/runner/test/loader.test.mjs: the portfolio persona is composed only with the file present, and the prompt is byte-identical across turns.worker/test/cli.test.mjs:run --triggerenqueues the trigger's data, and an unknown id is refused.dispatch_priorities_setgive oneappliedand oneplan-staleorplan-busy.priorities.jsonwithstatinstead oflstat, collect on the policy branch, inheritportfoliointo a chain child.By hand
All of this is zero-spend.
qwen2.5:3bor larger, since smaller models often skip the file write). Give it a nonzerocosttable in the overlaymodels.json, so dollars are metered with no real spend. Set the Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504 envelope to $100 per week with floors of 10 and 10.git init, a flowpm-echowhoseSKILL.mdsays "copy/workspace/plan.jsonto/outbox/priorities.json, then stop", and a committedplan.jsonwith shop 3 and platform 1 and the currentbasis(nullwhen no plan has applied yet). Add a cron trigger with"portfolio": true,"model"set to the Ollama model, and a yearly pattern.pi-dispatch run --trigger pm-weekly. The flow also runscat /job/portfolio.json, so/dispatch logsshows the snapshot. Check it holds ids and numbers only. Expect a run record withplan.outcome: "applied", and the panel showing 70/30."portfolio": trueand run it again. Expectplan.outcome: "refused"with reasonplan-not-portfolio, and no change in the panel.plan-duplicate. Change a weight inplan.jsonwithout updatingbasis, commit, and run again: expectplan-stale. Setbasisto the current plan id from/dispatch priorities, commit, and run again: expectplan-too-soon.portfolio-no-envelope, with no container started and no reservation.Acceptance
/job/portfolio.json, writes/outbox/priorities.json, and the worker applies it under Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504's rules with no keypress.plan-not-portfolioand recorded.lastAttempt. None is silent.run.portfoliocannot be set by any model-callable tool or panel key.pi-dispatch run --triggerfires a cron trigger once by hand.Depends on / unblocks
Depends on #504 (plan contract, apply, envelope, audit), #499 and #501. The by-hand test depends on #503. Unblocks #506.
Open questions
plan-too-soonis recorded. The free gate covers only a missing envelope.run --triggerexist, or should the docs say "set the pattern to the next minute"? Recommendation: build it. Editing a reviewed file to test is error-prone, and the command is small.parsePlancheck in the runner that prints refusals to the log. The host still decides.