Conversation
…the run The first program written on #8716's authoring layer rather than for it. A dep-keyed Sub ticks it, a tick spawns the job, the job's spawned answer carries the prompt, and a routed result lands on the tile as `last run 07:00 · ok`. The history is bounded at ten runs. `:cron run` is the on-demand path; it answers a spawn rather than an emit because a command may not reach a process out-port. The job arrives as an arg typed by its ports alone, so this module names no session and imports no session package. Unlike the pr-review example the shape is declared over the real ai-agent payloads — a prompt carrying {text, key, timestamp} and a result carrying {text, items, ok} — and the tests check those against a live claude row's own accepts predicates rather than a copy. Two seams are pinned rather than papered over. `shapeOf` reads authoring port decls and a compiled row publishes predicates, so no live session can ever fit a non-empty shape; `sessionAsJob` re-declares the two ports the arg asked for and the test fails the day that changes. And a spawn on a shaped arg still resolves the arg's service key through the registry (#8762), so every wake logs one UnknownProgram and the status stays idle, which is the truth. Registered behind a `cron` flag, default on, and planned as a graph node — boot's box config case now expects nine programs and four processes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`spawn` on a program-valued arg now resolves the arg's service key back to the program the config filled it with (#8762), and a row states that fill on `AuthoredProgram.fill`. `cron` never did, so every tick asked the registry for `tuval/arg/cron/job` and got `UnknownProgram`. The `job` a config hands `cron` goes onto the row's `fill`, and a tick spawns the session. `sessionAsJob` stays. `shapeOf` reads a compiled row now (#8887), but only one `defineProgram` built — `compilePort` is what publishes a port's schema beside its predicate, and an AI-agent row's ports are hand-written predicates. Both headers say so, and the test that pins the blocker says which change retires it. Verified live: one 20s tick spawned `claude-session` under `cron`, the prompt went out, and the turn came back `ok`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 8b43d754c7aed289da768ff0511ce9fafeb23ebf)
…nnot outlive its turn An AI-agent session stays up after its turn — it holds a transcript. Cron cleared `child` only on `stopped`, so that `stopped` never came: every later tick was dropped, and the orphan was restored on the next boot from the manifest. `result` now records the run, answers `stop(child)` and clears `child`, so the next tick spawns. `stopped` keeps the other case — the job dying before it answers — as a failed run, and ignores the one that answers cron's own `stop`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…estart cannot wedge every tick A Ctrl-C mid-run left `child` set in cron's checkpoint. On the next boot cron came back waiting for a `result`/`stopped` that nothing was going to send, so every tick was dropped for ever and the tile read "running since" against a run that ended with the process. `resume` (`authoring/resume.ts`) now sends a `restored` event when the loaded state holds a child: the half-finished run is written down as failed with "interrupted by restart", `child`/`startedAt` are cleared, and the next tick spawns. Cron reconciles the same way whether or not the child came back, and that is the honest reading rather than a shortcut. `durability/restore.ts` respawns a checkpointed process through `Processes.spawn` directly — no `on` record, which is where the spawner's routing lives, and an `unwired` `ProcessPorts` — so a restored session's `result` fails `PortNotWired` at its own emit and reaches no cron. And cron may not reap the orphan: `stop`/`send`/`ask` all fail `ProcessNotFound` on a process that is not live, that failure propagates out of the resume dispatch, and nothing catches it — so a blind `stop` would fail boot for every child the manifest did not bring back. The `any` in the fill test's handler type is narrowed to the two services that spawn handler actually reads, so `tsc` stops refusing the `runPromise` beneath it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s to its own port
Two things main made possible, landing together because they are one file's worth of
diff and neither reads right without the other.
**The shape wrapper is gone.** `sessionAsJob` existed because an AI-agent row
published hand-written port predicates and nothing else, so `shapeOf` read a live
session as `{in: {}, out: {}}` and no non-empty shape could fit one; a config had to
hand cron the row's id beside a re-declaration of the two ports it was being asked
for. #8887 taught `shapeOf` to read a compiled row and #8959 gave those rows their
payload schemas, so `claudeSession({…})` fits `jobShape` on its own and the config
hands over the row itself.
Restating the payloads was not equivalent, and that is why the wrapper could not
simply be deleted: cron's copy typed `items` as `Schema.Array(Schema.Unknown)` where
the real `TurnResultSchema` carries `Schema.Array(TranscriptItemSchema)`, so the two
do not canonicalise the same and the fit refuses on `result`. `jobShape` is now
declared over `PromptPayloadSchema` and `TurnResultSchema` out of
`ai-agent/ports/index.ts` — the interface module, not any agent's implementation,
the same import `authoring/example/pr-review.ts` makes, and what R15.1 asks for
rather than what it forbids.
**`:cron run` is a send, not a spawn.** The command used to answer a `spawn`, because
when it was written a command could `spawn` and had no way to reach its own program.
#8898 closed both halves: a command may only `send`, and a bare `send("run")`
resolves to the declaring program's own live process. So cron gains a `run` in-port
carrying `RunRequest`, an empty struct that is also the command's own `args` schema —
the command forwards exactly what it was called with and nothing is invented between
the two. Its cell is `tick` minus the counter, and both go through one `startIfIdle`,
so there is one reading of what waking means rather than two that can drift. A wake
landing mid-run records nothing, because `status` already says "running since", which
is the honest answer to "what happened when I asked". `ticks` stays the timer's count.
The spell does not reach the cron the desk boots, and the module header says so
rather than working around it. Resolution is fine — `own-process.ts` reads
`ProcessTable` and finds the planned process — but delivery goes through
`SpawnedProcesses.send`, whose `live` map holds only processes it spawned itself, and
cron is graph-launched. Verified against a running desk over the transport:
{"ok":false,"error":{"tag":"tuval/commands/UnknownProcess",
"message":"no live process \"cron\" was spawned through the process spells",
"path":["cron","run"]}}
That is #8944, not a defect of this program,
and the day it lands the spell starts working with no change to this file.
The timer path was proven on the same boot: eleven ticks at a temporary 20s cadence,
seven finished runs, every one `ok`, each summary a real `gh` readout off a live
Claude session.
`cron.unit.test.ts` used to pin the fit's *failure* and now pins the fit, so the day
a row stops publishing its payload schemas it fails there rather than in a config
that will not boot. `boot.unit.test.ts` pins the new port on the booted row, which is
the end-to-end proof that the command is addressable at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Preview deployed
|
|
review-code: FAIL @ 0e6b575 content:265d2661134e — repair round review-code — FAILGraded against #9225's seven acceptance criteria, read at head Per-criterion
Standing checks
Fan-out
Deviations
deviation-disclosure: FAIL — two findings this gate could see are not in the section. CI at head
VerdictFAIL on rows 2 and 6. Repair round: deliver (or honestly scope and disclose) the child-exit path, register the Verdict-written: 2026-09-15T10:36:18Z |
`.tuval/tuval.config.ts` stated `features.cron` and nothing else knew the key: `DeclaredFeatures` derives its keys from `featuresDefault`, so an undeclared key is dropped by the decode and reaches the row (ADR 0375 §1) and neither the `Features` service nor the generated browser module. Declared on `TuvalFeatures`/`featuresDefault` the way `prReviewExample` is — off there, this project's layer flips it on — and the config and dev-server records that pin the flag set move with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vers `stopped` is only ever the answer to a program's own `stop` effect (`stopHandler`); nothing dispatches an unsolicited child exit into a spawner's inbox, so the cell's "died before answering" branch is unreachable today. That is #9227, filed against the kernel. The cell and its test stay — the branch is written for the day #9227 delivers the event — and the module header, the docblock and the test name now say so, cited the way #8944 is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
review-code: PASS @ 268e017 content:531c21a57266 — merge-ready review-code — PASS (repair round 2)Graded against #9225's eight acceptance criteria (seven original, #8 appended at round 1), read at head Per-criterion
Standing checks
Fan-out
Deviations
deviation-disclosure: PASS — nothing undisclosed that this gate could see. CI at head
VerdictPASS. Both round-1 FAIL rows (2, 6a) are repaired on this head; no new finding routes to an acceptance criterion. Verdict-written: 2026-09-15T10:55:59Z |
|
routed-elsewhere: review-ui @ 268e017 — cron program row + flag default; no rendered surface, component, or style changes LAW-SOURCE: manifest-prose (ui law exit 13 — untyped registry). Seven files change, all under apps/tuval; the
No composition, page, screen, state or style a user sees is added or changed; the existing tile paints one more row of the shape it already paints. Tuval is a local app (no |
Tuval had no scheduler.
cronis a program ondefineProgramthat wakes on a timer, starts a jobprogram it knows only by its ports, and says on its board tile how the run went. It is the first
consumer written on the authoring layer from outside the kernel, and it is the program that
surfaced #8762, #8887, #8898/#8858 and #9220 on the way.
cron({everyMs, prompt, job})returns the row.jobisProgram.shapeover the AI-agent payloadsout of
ai-agent/ports/index.ts— the interface module, the same import thepr-reviewexamplemakes — so the config hands it
claudeSession({…})itself and cron imports no session. A tickspawns the job with
on: {result}, sends a well-formedPromptPayload, records the run when theanswer lands and stops the child; a tick while a job runs spawns nothing and the tile says
running since. Thestoppedcell handles the answer to cron's ownstop; its other branch — ajob that died before answering — is written for the day #9227 delivers that event and is
unreachable under today's kernel, stated in the module header and the cell and under Deviations. A restored cron whose
checkpointed
childis no longer live records that run as interrupted, so a restart cannot wedgeevery later tick. History is bounded at ten.
:cron runis acommandscell answeringsend("run"), the shape ADR 0372 takes since #9221.The
cronflag is declared onTuvalFeatures/featuresDefault(src/features.ts) the wayprReviewExampleis — off there, flipped on by this repo's own layer, so the key reaches theFeaturesservice and the generated browser module and not the row alone. That registration wasmissing on the first head and is the second repair in this round; round-2 criterion #8 on #9225.
The first job, in
.tuval/tuval.config.tsbehindfeatures.cron, is a read-only phoenix briefthrough
gh: merged PRs, new issues, anythingready-for:human, five lines, every ten minutes.Gates at this head:
pnpm typecheck0 errors;npx biome check src/cron src/features.ts .tuval src/boot.unit.test.ts src/config.unit.test.ts src/page/dev-server.unit.test.tsclean;pnpm vitest run --project unit321 passed / 1 failed, the one pre-existing failure named below. Boot with theflag on:
9 program(s),process cron program=cron … state=running@0, noHandlerFailed.One earlier run was verified by hand on the first head (20s cadence, ~4 min):
.tuval/processes/ cron.jsonended withticks: 11and seven runs, allok: true, each a realghreadout off alive Claude session. That is a manual proof, not an input this gate reads — no checked-in artifact
or test carries it, so read the "one real tick lands
ok: true" half of criterion 6 as unverifiedby the gate rather than as evidence.
Reviewer's first stop: the
resultandresumecells inapps/tuval/src/cron/cron.ts.Fixes #9225
Deviations
:cron runfires a tick on demand. Did: declared thecommand and the
runcell; live, the spell refuses withUnknownProcessfor the cron the bootgraph launched. Why: resolution through
own-process.tsfinds the planned cron offProcessTable, but delivery goes throughSpawnedProcesses.send, whoselivemap holds onlyprocesses it spawned — process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944, filed before this PR and now with a second caller. Working around
it here would mean cron spawning itself ad hoc, which is not what a planned process is.
Disposition: documented in the module docblock citing process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944; the spell starts working the
day that lands, with no change to this file.
src/page/attached-desk.unit.test.tsx › "closes on Escape from inside the overlay"failing.Why: it fails identically on clean
origin/main(bc890986), reproduced 2/2, and touchesnothing cron does. Disposition: not this PR's to fix; named here so the gate reads it as
known rather than introduced.
answering is recorded as a failed run. Did: the
stoppedcell has that branch, and live itis unreachable —
stoppedis only ever the answer to a program's ownstopeffect(
stopHandlerinauthoring/define-program.ts); nothing dispatches an unsolicited child exitinto a spawner's inbox, so a job that crashes before
resultleaveschildset and later ticksdropped until a restart, which
resumereconciles. Why: delivering it is a kernel change toonrouting at the child's end, which is a program's to consume and not a program's to make —filed as An authored spawner never hears an unsolicited child exit: stopped is only ever the answer to its own stop effect #9227. Disposition: the cell and its
testProgramcase stay, renamed to say theyprove the cell and not live behaviour; the module header and the cell's docblock cite An authored spawner never hears an unsolicited child exit: stopped is only ever the answer to its own stop effect #9227 the
way process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944 is cited; the branch needs no change to this file the day An authored spawner never hears an unsolicited child exit: stopped is only ever the answer to its own stop effect #9227 lands.
result.Did: it does, and the stopped children still accumulate in
.tuval/manifest.jsonand arerestored on the next boot. Why: the manifest has no forget path; that is A child ended by the authored stop effect is still restored on the next boot: the manifest never forgets a stopped process #9220, whose
stop-versus-forget question A Tuval process cannot be killed from the desk, so a stray process is restored on every boot #8332 reserves for the founder. Disposition: cron's
resumereconciliation keeps a restart from wedging; the restore of dead children is A child ended by the authored stop effect is still restored on the next boot: the manifest never forgets a stopped process #9220's.
Base is
main.🤖 Generated with Claude Code