Skip to content

fix(loop): make recurring expiry observable - #69

Merged
trvon merged 3 commits into
masterfrom
fix/observable-loop-expiry
Aug 30, 2026
Merged

fix(loop): make recurring expiry observable#69
trvon merged 3 commits into
masterfrom
fix/observable-loop-expiry

Conversation

@trvon

@trvon trvon commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #67.

Recurring controllers now remain registered until their exact seven-day expiresAt boundary and retire observably instead of disappearing before the boundary or failing silently.

What changed

  • preserve the bounded seven-day lifetime; no implicit indefinite renewal
  • track the expiry boundary even when the next cron fire lies after it, and for event-only controllers
  • expose an ISO expiresAt value in LoopList
  • emit a typed loops:expired event with controller metadata, source, reason, and deleted/paused disposition
  • send the same expiry through the generation-fenced hidden-notification path with explicit recreation guidance
  • report expiries found during session recovery after the store mutation and outside the store lock
  • suppress repeat recovery notifications for controllers already paused by expiry
  • reject expiry mutation under a stale extension context

Reproduction

The RED regression demonstrated three independent failures on origin/master:

  1. CronScheduler.armTimer() deleted a recurring loop immediately when its next cron occurrence was later than expiresAt, potentially before the documented seven-day boundary.
  2. No expiry callback, event, or hidden notification existed.
  3. LoopList omitted the controller's expiry boundary.

Validation

  • npm run lint (four established optional-chain warnings only)
  • npm run typecheck
  • npx vitest run --maxWorkers=1 (51 files / 806 tests)
  • targeted parallel reruns for changed expiry, scheduler, trigger, session, store, notification, and index suites
  • npm run build
  • npm run test:package
  • npm audit --audit-level=moderate
  • git diff --check

Design 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:fire wakes, the expiry notification is generation-fenced but memory-only rather than a durable event ledger. The store mutation remains authoritative across process death.

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
Copilot AI lite review requested due to automatic review settings August 30, 2026 20:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 expiresAt boundary (including event-only controllers) and report retirement via an explicit callback/event path.
  • Expose expiresAt in LoopList output and add typed LoopExpiredPayload plumbing (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.

Comment thread src/trigger-system.ts
Only unsubscribe event triggers after LoopStore confirms expiry
settlement, so stale-context rejection leaves the controller armed for a
later current runtime.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

  • showPersistedLoops now emits loops:expired for records returned by store.expireEntries(sessionStartedAt), but persisted event/hybrid loops can still be removed immediately after via store.expireEventLoops(sessionStartedAt) (which applies LOOP_EXPIRED with reason resume_event_stale). Those retirements remain silent (no loops:expired event / 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.
@trvon

trvon commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

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 loops:expired event and hidden notification path. LoopExpiryReason distinguishes resume_event_stale from expires_at, and the recovery notification explains the non-resumable subscription lifecycle. Added RED→GREEN store, session-runtime, and notification-runtime coverage.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

@trvon
trvon merged commit 65dd735 into master Aug 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recurring loops expire silently after 7 days — no recreation hook, no alert

2 participants