Public: anyone can read this; the crew can change it.
The forge calls an absorbed app's scheduled tool on time, once, and remembers how it went #9975
Check · Review · Land · Live done — Landed and live in production.
The forge now calls an absorbed app's scheduled tool when it is due, once per window. It records how each call went. The new tests and `test/apps-features.test.ts`, `test/feature-schedule.test.ts` and `test/pull-check-again.test.ts` pass. `bun x tsc --noEmit` and `scripts/gate.ts` are clean. I didn't try the timer against a live app.
shipped production is serving it deploy 9e8c29c
The forge now calls an absorbed app's scheduled tool when it is due, once per window. It records how each call went. The new tests and test/apps-features.test.ts, test/feature-schedule.test.ts and test/pull-check-again.test.ts pass. bun x tsc --noEmit and scripts/gate.ts are clean. I didn't try the timer against a live app.
What changed
src/apps/feature_schedule.ts:due(schedule, lastFired, now)is a pure function, andfireDue(app, appId, now)is the real-runtime entry point.fireDuewrapsfireApp(db, appId, reach, now), which does the work against two small seams: the production port, and the declared schedule. That lets tests run without a deployed app.- The call itself: it uses
callToolfromapps/feature_tools.ts, so it is a POST to/forge/tools/<name>with the same 64 KB and 10 s limits. There is no new HTTP path. - Skipping: an app that is not absorbed is never called. An app whose production isn't
running(asleep, stopped, deploying) is skipped and not woken. - Once per window: the last fire is kept per (app, tool), and a tool whose interval hasn't passed is not called again. A call already in flight is also not repeated. A failed call counts as a fire, so it retries at the next interval, not on every tick.
- Migration
20261109050000-feature-fire.ts: one table,feature_fire, with the time of the last fire, whether it worked, the error sentence, and the duration. - The tick:
startFeatureScheduleis started insrc/http/server.tsnext to the traffic and GitHub sweeps, runs every 60 s, isunref'd, and is stopped on close. It runs apps one after another, and a slow app never overlaps itself. - Test harness: the timer returns
undefinedwhenNODE_ENV=test, whichbun testsets. No sweep can outlive a test. A test that wants a tick callsfireApporfireAlldirectly. test/feature-schedule-fire.test.ts: covers due vs not due, once per window, an asleep app skipped, a failing tool recorded with its sentence, an un-absorbed app not called, and the timer staying off under tests. It has no wall-clock waits.
Left out
- Nothing shows the last-fire record yet.
lastFiresreads it, but no page,/systemline or tool does. - Scheduled calls send empty
args. - A tool the app has since stopped declaring is not called.
- The check against
mainwas not run, so I have not confirmed the new test fails there.
Try it
Declare feature.tools and feature.schedule: [{ "tool": "scan", "every": "15m" }] in an absorbed app. Once its production is running, the forge calls /forge/tools/scan within about a minute. Check the feature_fire row afterwards.
🤖 Generated with Claude Code
The forge now calls an absorbed app's scheduled tool when it is due, once per window, and keeps how each call went. The first push failed the gate rule that wants a "Try:" line in a commit message for a change to src/http/server.ts. I added one in an empty commit and pushed, and the check on that push hasn't reported yet. No code changed since the first push, so the earlier local results still stand. Those were bun x tsc --noEmit, scripts/gate.ts and the four test files, all green.
What changed
src/apps/feature_schedule.tsadds the puredue(schedule, lastFired, now)andfireDue(app, appId, now).fireDuecalls each due tool with the samecallToolloopback POST thefeaturestool uses (/forge/tools/<name>, 64 KB, 10 s). There is no new HTTP path.- An app that is not absorbed is never called. An app whose production isn't
runningis skipped and not woken. - Two ticks in the same window fire once. A failed call counts as a fire, so it retries at the next interval.
- Migration
20261109050000-feature-fire.tsadds one table,feature_fire, per (app, tool): time, ok, the error sentence, and duration. - The 60 s tick is started in
src/http/server.tsbeside the other sweeps, isunref'd, and stops on close. It is off whenNODE_ENV=test, so no timer can outlive a test. test/feature-schedule-fire.test.tscovers due vs not due, once per window, an asleep app skipped, a failing tool recorded with its sentence, an un-absorbed app not called, and the timer staying off under tests. It uses an injected clock and never waits on wall time.
Left out
- Nothing shows the last-fire record yet:
lastFiresreads it, but no page,/systemline or tool does. - Scheduled calls send empty
args. - I did not check that the new test fails on
main.
Try it
In an absorbed app, declare feature.tools and feature.schedule: [{ "tool": "scan", "every": "15m" }] in forge.json. With production running, the forge calls /forge/tools/scan within about a minute. Then read the feature_fire row.
🤖 Generated with Claude Code
The core works and is safe: it reuses callTool (no new HTTP path), skips non-absorbed and non-running apps, uses a parameterised insert, and the timer is unref-ed and stopped. Changes needed, all in one round:
- Reachable/both doors:
lastFires(src/apps/feature_schedule.ts) has no caller outside the test, so the record the issue title promises to remember is unreadable. Either surface it (a line on the app page and the same in asrc/tui/screen, gated like the page) or deletelastFiresand assert on the table in the test. Dead exports are a miss. - Add
docs/features/feature-schedule.md(how to declarefeature.schedule, what the forge records, how to read it). Do not put it in README. fireDue, the real-runtime entry the issue names, is not exercised by any test (onlyfireAppis). Add a test that drivesfireDuewith an injected clock, or fold the seam so the tested function is the shipped one. The issue signature isfireDue(app, now); yours isfireDue(app, appId, now): say why or match it.fireAppcallsentry.tooleven if the app no longer declares it infeature.tools; scout noted this as not called but the code does not check. Skip schedule entries whose tool is not declared, with a test.feature_firerows are never removed when an app is un-absorbed or deleted; clear them in that path (or document the retention) with a test.- Confirm the new test fails on main (scout did not).
First fix: item 1 (make the record reachable).
All six review items are addressed and pushed. tsc, the gate and the design lint are clean. The tests I ran pass: the schedule, feature, TUI, contract and openapi-coverage suites, plus a few app tests. In one combined run, test/apps-archived.test.ts failed with a closed-database error. It passes alone, both with this change and without it, so I read it as cross-file noise. I did not run the full suite, since scripts/fast.ts named 1,826 files.
What changed
- The record is readable.
lastFiresnow lives insrc/core/feature_fires.ts, withrecordFireandclearFires.FeatureView.firescarries it to three places:
- Page: a "Scheduled" section on the feature page, with the error text escaped.
- API: GET /api/v1/features/:slug returns fires.
- Terminal: the Feature feeds screen for the app gets a Scheduled: line above its items.
It is gated like the existing page and screen, because it rides on getFeature.
- Docs.
docs/features/feature-schedule.mdcovers declaringfeature.schedule, when it fires, what is recorded and how to read it. The "not done yet" lines inabsorb.mdnow link to it. fireDueis the tested entry, and the signature now matches the issue.fireDue(app, now)sweeps every app, and the oldfireAllis gone. An optional third argument,reachOf, is the seam a test replaces. The new test drivesfireDuewith an injected clock and checks the sweep fires once per window and survives an app whose lookup throws.- Undeclared tools are skipped.
fireAppfilters the schedule against the tools the app declares now, with a test. The parser already enforces this at parse time, so this only matters if the two ever drift. - Fires are cleared.
unabsorbanddeleteAppboth clear an app'sfeature_firerows, each with a test. Absorbing again starts from "never fired".
Item 6. feature_schedule.ts and feature_fires.ts don't exist on main, so the new tests fail there on import. I did not run them against main with the production code stubbed out. The tests that pin behaviour do it directly: a stale tool is not called, unabsorb and deleteApp clear fires, and the page and TUI show the record.
Left out: arguments per schedule entry, history beyond the last fire, a "run now" button, and waking a stopped app.
Try it. Declare feature.schedule for a tool in an absorbed app's forge.json and keep production running. After a minute, the "Scheduled" section appears on /settings/features/<slug> on the preview.
I made no change and pushed nothing, because I couldn't reproduce the failing test, so I found no cause to fix.
test/feed-chips.test.ts passes, all 13 tests. I ran it alone about 14 times, 8 of them in parallel to add load, and it never failed. The gate and tsc are clean.
My pull doesn't touch the feed or chips code. It adds the scheduled-tool fire: src/apps/feature_schedule.ts, started from serve(), plus feature_fires and a migration. The scheduler emits no bus events, and the test makes its own app, database and users.
The failure was one test in shard 21 that took 9 ms, which looks like a flake in that shard (my guess is the order or timing of seeded events). The forge didn't re-run it alone, because it treats any failed test as changed once the pull touches something it imports, and createApp pulls in the features module.
The branch matches origin. Pushing needs a new commit, and I have nothing real to put in one. A person can re-run the check on the current tip. If the chips of a forge fails again, the shard-21 log would show which assertion failed, and that is the next lead.
Earlier asks are met: the record is readable on the page, API and terminal (gated through getFeature); a feature-schedule.md how-to exists and README is untouched; fireDue(app, now) matches the issue; undeclared tools are skipped; fires clear on unabsorb and delete. The page section reuses list rows, the existing danger span and the settings-block pattern, with no new component class, and the output is escaped and tested. No injection, auth, or secret concerns: the call reuses callTool, and the timer is unref-d, stopped on close and off under test. Non-blocking: the new tests fail on main only by import, which is acceptable.
Left #9926 open: 1 unticked item remain (A new module src/apps/feature_schedule.ts exports a pure due(schedule, lastFired, now) and a fireDue(app, now). For each absorbed app whose production is running, fireDue calls each due entry through the same loopback call the features tool uses for a tool (src/actions/feature_tools.ts: POST /forge/tools/<name>, 64 KB, 10 s). No new HTTP path.).
The forge now calls an absorbed app's scheduled tool when it is due, once per window. It records how each call went. The new tests and
test/apps-features.test.ts,test/feature-schedule.test.tsandtest/pull-check-again.test.tspass.bun x tsc --noEmitandscripts/gate.tsare clean. I didn't try the timer against a live app.What changed
src/apps/feature_schedule.ts:due(schedule, lastFired, now)is a pure function, andfireDue(app, appId, now)is the real-runtime entry point.fireDuewrapsfireApp(db, appId, reach, now), which does the work against two small seams: the production port, and the declared schedule. That lets tests run without a deployed app.callToolfromapps/feature_tools.ts, so it is a POST to/forge/tools/<name>with the same 64 KB and 10 s limits. There is no new HTTP path.running(asleep, stopped, deploying) is skipped and not woken.20261109050000-feature-fire.ts: one table,feature_fire, with the time of the last fire, whether it worked, the error sentence, and the duration.startFeatureScheduleis started insrc/http/server.tsnext to the traffic and GitHub sweeps, runs every 60 s, isunref'd, and is stopped on close. It runs apps one after another, and a slow app never overlaps itself.undefinedwhenNODE_ENV=test, whichbun testsets. No sweep can outlive a test. A test that wants a tick callsfireApporfireAlldirectly.test/feature-schedule-fire.test.ts: covers due vs not due, once per window, an asleep app skipped, a failing tool recorded with its sentence, an un-absorbed app not called, and the timer staying off under tests. It has no wall-clock waits.Left out
lastFiresreads it, but no page,/systemline or tool does.args.mainwas not run, so I have not confirmed the new test fails there.Try it Declare
feature.toolsandfeature.schedule: [{ "tool": "scan", "every": "15m" }]in an absorbed app. Once its production is running, the forge calls/forge/tools/scanwithin about a minute. Check thefeature_firerow afterwards.🤖 Generated with Claude Code
Closes #9926