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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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, and fireDue(app, appId, now) is the real-runtime entry point. fireDue wraps fireApp(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 callTool from apps/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: startFeatureSchedule is started in src/http/server.ts next to the traffic and GitHub sweeps, runs every 60 s, is unref'd, and is stopped on close. It runs apps one after another, and a slow app never overlaps itself.
  • Test harness: the timer returns undefined when NODE_ENV=test, which bun test sets. No sweep can outlive a test. A test that wants a tick calls fireApp or fireAll directly.
  • 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. lastFires reads it, but no page, /system line or tool does.
  • Scheduled calls send empty args.
  • A tool the app has since stopped declaring is not called.
  • The check against main was 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

Closes #9926

commented

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, and fireDue(app, appId, now) is the real-runtime entry point. fireDue wraps fireApp(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 callTool from apps/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: startFeatureSchedule is started in src/http/server.ts next to the traffic and GitHub sweeps, runs every 60 s, is unref'd, and is stopped on close. It runs apps one after another, and a slow app never overlaps itself.
  • Test harness: the timer returns undefined when NODE_ENV=test, which bun test sets. No sweep can outlive a test. A test that wants a tick calls fireApp or fireAll directly.
  • 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. lastFires reads it, but no page, /system line or tool does.
  • Scheduled calls send empty args.
  • A tool the app has since stopped declaring is not called.
  • The check against main was 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

commented

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.ts adds the pure due(schedule, lastFired, now) and fireDue(app, appId, now).
  • fireDue calls each due tool with the same callTool loopback POST the features tool 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 running is 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.ts adds one table, feature_fire, per (app, tool): time, ok, the error sentence, and duration.
  • The 60 s tick is started in src/http/server.ts beside the other sweeps, is unref'd, and stops on close. It is off when NODE_ENV=test, so no timer can outlive a test.
  • 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 uses an injected clock and never waits on wall time.

Left out

  • Nothing shows the last-fire record yet: lastFires reads it, but no page, /system line 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

Changes requested — before the latest push

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:

  1. 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 a src/tui/ screen, gated like the page) or delete lastFires and assert on the table in the test. Dead exports are a miss.
  2. Add docs/features/feature-schedule.md (how to declare feature.schedule, what the forge records, how to read it). Do not put it in README.
  3. fireDue, the real-runtime entry the issue names, is not exercised by any test (only fireApp is). Add a test that drives fireDue with an injected clock, or fold the seam so the tested function is the shipped one. The issue signature is fireDue(app, now); yours is fireDue(app, appId, now): say why or match it.
  4. fireApp calls entry.tool even if the app no longer declares it in feature.tools; scout noted this as not called but the code does not check. Skip schedule entries whose tool is not declared, with a test.
  5. feature_fire rows are never removed when an app is un-absorbed or deleted; clear them in that path (or document the retention) with a test.
  6. Confirm the new test fails on main (scout did not).

First fix: item 1 (make the record reachable).

commented

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

  1. The record is readable. lastFires now lives in src/core/feature_fires.ts, with recordFire and clearFires. FeatureView.fires carries 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.

  1. Docs. docs/features/feature-schedule.md covers declaring feature.schedule, when it fires, what is recorded and how to read it. The "not done yet" lines in absorb.md now link to it.
  2. fireDue is the tested entry, and the signature now matches the issue. fireDue(app, now) sweeps every app, and the old fireAll is gone. An optional third argument, reachOf, is the seam a test replaces. The new test drives fireDue with an injected clock and checks the sweep fires once per window and survives an app whose lookup throws.
  3. Undeclared tools are skipped. fireApp filters 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.
  4. Fires are cleared. unabsorb and deleteApp both clear an app's feature_fire rows, 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.

commented

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.

Approved

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.

commented

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

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs