fix(loop): make recurring expiry observable - #69
Conversation
Preserve the bounded seven-day lifetime while exposing exact expiry boundaries and generation-fenced retirement signals. Reject stale or repeated retirement so explicit recreation remains the only renewal path. Closes #67
There was a problem hiding this comment.
🟡 Changes recommended
The TriggerSystem’s event-fire expiry path can leave event subscriptions/timers lingering unless the scheduler onExpired callback is wired, so it should defensively unsubscribe when expiry is actually settled.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR makes the seven-day expiry of recurring controllers observable and deterministic across scheduler, trigger, and session-recovery paths by tracking the exact expiresAt boundary, emitting a loops:expired event, and delivering a generation-fenced hidden notification.
Changes:
- Track and settle expiries at the exact
expiresAtboundary (including event-only controllers) and report retirement via an explicit callback/event path. - Expose
expiresAtinLoopListoutput and add typedLoopExpiredPayloadplumbing (LoopExpiryDisposition,LoopExpirySource) through the public API surface. - Emit recovered expiries during session startup after store mutation (outside the store lock) and add broad test coverage + docs updates.
File summaries
| File | Description |
|---|---|
| test/trigger-system.test.ts | Adds coverage ensuring expired event loops retire instead of firing. |
| test/store.test.ts | Adds coverage for rejecting post-expiry resume/continue and for bounded expiry record reporting. |
| test/session-runtime.test.ts | Updates session startup behavior tests for expiry consumption, staleness, and post-cleanup emission. |
| test/scheduler.test.ts | Adds scheduler tests for event-only expiry tracking and stale-context suppression. |
| test/notification-runtime.test.ts | Adds tests for loop-expired hidden notification delivery and generation fencing. |
| test/loop-tools.test.ts | Validates LoopList displays expiresAt and LoopUpdate rejects expired continuation. |
| test/index.test.ts | End-to-end test asserting loops:expired emission + hidden notification delivery + list reflects deletion. |
| src/types.ts | Introduces LoopExpiryDisposition and LoopExpirySource types. |
| src/trigger-system.ts | Routes all entries through the scheduler and retires event fires at/after expiry. |
| src/tools/loop-tools.ts | Rejects expired dynamic continuation; prints ISO expiresAt in LoopList. |
| src/store.ts | Adds expireEntry/expireEntries returning retirement records and refactors clearExpired accordingly. |
| src/scheduler.ts | Tracks expiry boundaries separately from fire times; emits expiry callback; supports stale-context suppression. |
| src/runtime/session-runtime.ts | Consumes/store-settles expiries at session start and emits recovered expiries after cleanup. |
| src/runtime/notification-runtime.ts | Adds queueOrDeliverLoopExpired and formats explicit expiry notifications with recreation guidance. |
| src/runtime/loop-events.ts | Defines LoopExpiredPayload and builder for loops:expired emissions. |
| src/index.ts | Wires scheduler expiry callback to emit loops:expired and queue hidden notifications; fences on generation/context. |
| src/commands/loop-command.ts | Prevents “Resume” UI action for already-expired paused controllers. |
| src/api.ts | Exposes LoopExpiredPayload and expiry-related types on the public API surface. |
| README.md | Documents observable recurring-loop expiry at a high level. |
| docs/USAGE_GUIDE.md | Documents new expiresAt visibility and loops:expired lifecycle event. |
| docs/REFERENCE.md | Updates reference contract for expiry observability and notification semantics. |
Review details
- Files reviewed: 21/21 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Only unsubscribe event triggers after LoopStore confirms expiry settlement, so stale-context rejection leaves the controller armed for a later current runtime.
There was a problem hiding this comment.
🔵 Needs a closer look
Session recovery can still delete persisted event/hybrid loops via expireEventLoops(..., reason: "resume_event_stale") without going through the new loops:expired/notification path, leaving a remaining silent-retirement path that conflicts with the stated “observable retirement” goal.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/runtime/session-runtime.ts:130
showPersistedLoopsnow emitsloops:expiredfor records returned bystore.expireEntries(sessionStartedAt), but persisted event/hybrid loops can still be removed immediately after viastore.expireEventLoops(sessionStartedAt)(which appliesLOOP_EXPIREDwith reasonresume_event_stale). Those retirements remain silent (noloops:expiredevent / hidden notification), so consumers still can’t observe that a persisted event/hybrid controller disappeared on session recovery.
If the intent is “observable retirement” for recurring controllers, consider returning structured retirement records from expireEventLoops and routing them through the same emitLoopExpired path (which would likely require extending LoopExpiredPayload.reason beyond only "expires_at", or introducing a separate event for resume_event_stale).
const expired = store.expireEntries(sessionStartedAt);
for (const record of expired) {
if (!isCurrentGeneration(generation)) return;
emitLoopExpired(record.entry, record.disposition, generation);
}
- Files reviewed: 21/21 changed files
- Comments generated: 0 new
- Review effort level: Lite
Return structured records when session recovery retires stale event or hybrid controllers, then emit the same typed event and hidden notification used for seven-day expiry.
|
Addressed the Copilot re-review note in signed commit d4f56ea. Session recovery now receives structured records from stale event/hybrid retirement and routes each through the same generation-fenced |
There was a problem hiding this comment.
🟢 Approval recommended
The changes align with the stated contract, are consistently wired end-to-end (store→scheduler/trigger→runtime→notification), and include targeted test coverage for the key regressions and edge cases (boundary timing, event-only loops, stale context, and recovery).
Review details
- Files reviewed: 21/21 changed files
- Comments generated: 0 new
- Review effort level: Lite
Summary
Closes #67.
Recurring controllers now remain registered until their exact seven-day
expiresAtboundary and retire observably instead of disappearing before the boundary or failing silently.What changed
expiresAtvalue inLoopListloops:expiredevent with controller metadata, source, reason, anddeleted/pauseddispositionReproduction
The RED regression demonstrated three independent failures on
origin/master:CronScheduler.armTimer()deleted a recurring loop immediately when its next cron occurrence was later thanexpiresAt, potentially before the documented seven-day boundary.LoopListomitted the controller's expiry boundary.Validation
npm run lint(four established optional-chain warnings only)npm run typechecknpx vitest run --maxWorkers=1(51 files / 806 tests)npm run buildnpm run test:packagenpm audit --audit-level=moderategit diff --checkDesign note
This deliberately does not add
autoRenew. Expiry remains the bounded safety contract; the wake tells the agent/user to recreate the controller explicitly if it is still authorized and needed.Like existing pending
loop:firewakes, the expiry notification is generation-fenced but memory-only rather than a durable event ledger. The store mutation remains authoritative across process death.