Skip to content

Unattended project manager: a portfolio snapshot into a flagged cron job, a priorities plan back out through the outbox #505

Description

@edgehero

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-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

  1. 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.
  2. 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.
  3. 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 === true and the live triggers file still flags job.data.trigger.id.
    • If Valkey is unreachable, it throws InfraRetry, like any infrastructure fault before the reserve.
    • Shape (integers are micro-dollars):
    { "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 } }
    • 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.
  4. The plan channel /outbox/priorities.json. It is a separate file name, not a request-<n>.json with a type. It never uses a chain slot (PI_CHAIN_MAX_PER_JOB), and older workers cannot misread it as a chain request. The body is exactly INT-PRIORITIES-PLAN-CONTRACT from Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504.
  5. 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:
      1. Local parent and completed exit (the existing collection point). No file means plan: null in the record and nothing else happens.
      2. 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.
      3. Size of at most 16 KiB, read with stat before any read: plan-oversize.
      4. A regular file, checked with lstat: plan-not-regular-file.
      5. JSON parse with an object root: plan-parse-error.
      6. parsePlan: plan-invalid, plus a field name from a fixed list.
      7. applyPlan from 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-off through plan-busy).
  6. Idempotency and races.
    • 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.
  7. Refusal records, all PII-free.
  8. 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.
  9. 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.
  10. 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.
    • INT-CONTAINER-JOB-INPUTS: /job/portfolio.json.
    • INT-TRIGGERS-FILE-CONTRACT, INT-RUN-HISTORY-FILE-CONTRACT.
    • SECURITY.md trigger 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

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.

By hand

All of this is zero-spend.

  1. Declare a local Ollama endpoint through Local model servers: declared endpoints reached through the egress proxy, keyless providers that pass the credential gate, and fleet-wide slot leases #503 (qwen2.5:3b or larger, since smaller models often skip the file write). Give it a nonzero cost table in the overlay models.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.
  2. 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.
  3. 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.
  4. Remove "portfolio": true and run it again. Expect plan.outcome: "refused" with reason plan-not-portfolio, and no change in the panel.
  5. 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.
  6. Unset the envelope file and run it. Expect the refusal portfolio-no-envelope, with no container started and no reservation.

Acceptance

  • A flagged cron job reads /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.
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions