Skip to content

feat(sdk): duties — periodic work that exactly one process on a machine or workspace runs - #1014

Merged
alexmercerpo merged 9 commits into
feat/m10-stall-dev-eventsfrom
feat/m12-duties
Nov 3, 2026
Merged

alexmercerpo merged 9 commits into
feat/m10-stall-dev-eventsfrom
feat/m12-duties

Conversation

@frontegg-david

@frontegg-david frontegg-david commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1013

What and why

Proposal item 4.4: recurring work that exactly one process of a machine or project runs.

Several copies of a FrontMCP server often run on one machine: a stdio server per client session, several HTTP workers.
Work such as pruning a cache they share, compacting a SQLite file or re-indexing a project should run once per
interval among them, not once per copy, and keep running when the copy doing it exits. FrontMCP had no way to say so:
@Job has no schedule, and a setInterval() started in a provider's onReady() runs in every process.

What this PR changes:

  • @Duty({ name?, every, scope: 'machine' | 'workspace', leaseMs? }) marks a provider class whose run({ signal })
    the server calls every every, in the one process that holds the duty's lease. every is milliseconds or a duration
    ('500ms', '30s', '5m', '1h', '1d'), from 1 ms to 2147483647 ms. leaseMs defaults to three intervals,
    clamped to 3–60 s, and must be at least 1000 (stores keep a TTL in whole seconds). name defaults to the class name.
    The options are checked by a Zod schema when the class is decorated; a mistake throws
    InvalidDecoratorMetadataError naming the field, and a class without run() fails to compile and throws.
  • Registration. A duty is a provider: it goes in the providers of @App, @FrontMcp or a plugin, gets its
    dependencies through its constructor, and keeps its own onBootstrap(), onReady() and onShutdown(). It starts in
    the onReady() phase (M01), on every transport, and stops in the onShutdown() phase, before the providers it
    depends on. The registry recognises a @Duty instance when it builds it and loads the duty runtime with a dynamic
    import, so a server without duties (and the browser bundle) never loads it.
  • The lease. A key in a store every process opens, holding the holder's id with a TTL of leaseMs:
    • acquire with set-if-absent, renew every third of the lease with compare-and-expire, release at shutdown with
      compare-and-delete (after aborting the run in flight and waiting for it). The other processes try to acquire at the
      same pace.
    • Expiry is the store's TTL; no process compares its clock with another's. A killed or hung holder stops renewing, and
      another process takes over within leaseMs plus a third of it.
    • Before each run the holder renews the lease again, and runs only if that succeeds. A holder paused past its lease
      (SIGSTOP, a long GC) finds the key taken when it wakes and never runs again; a run in flight when the lease is lost
      sees its signal aborted.
    • A holder that could not renew for a whole leaseMs (Redis unreachable, a locked file, a hung store call) gives
      the duty up: it aborts the run in flight and contends again. It times this on a monotonic clock from the moment it
      sent its last successful renew, so it stops no later than the store could end its lease and hand it to another
      process.
    • At shutdown the holder keeps renewing the lease until the run it aborted returns, then releases it, so no other
      process starts while that run winds down. A run that ignores signal holds the shutdown up to
      lifecycle.shutdownHookTimeoutMs, and its lease then expires on its own.
    • On one machine every process reads the same system clock, so they can't disagree about a SQLite lease. A forward
      jump of that clock (an NTP step, a laptop waking from sleep) by more than leaseMs ends a lease and the last-run
      marker early, so another process may take over and run at once; a backward jump delays a takeover after a crash by
      the size of the jump. Redis keeps TTLs by its own clock.
    • A run that would overlap the one still running is skipped, not queued. A failed run is logged
      (Duty "<name>" failed: <cause>) and the lease is kept. A store error skips that check or run and is retried.
    • Each run sets a marker key whose TTL is one interval. A process that takes the lease over waits until it expires
      (at most one interval from the takeover), so the duty still runs once per interval across a handover.
    • A process that doesn't get the lease logs once that the duty already runs elsewhere, with the lease key, so a
      duty that never runs in a lone server points at whoever holds it.
  • Where leases live. Through createStorage() with fallback: 'error' (never per-process memory): a SQLite file
    every process on the machine opens, ~/.frontmcp/duties.sqlite ($FRONTMCP_HOME/duties.sqlite), using M02's atomic
    operations; or the server's top-level redis when its provider is Redis, with the hostname in the key prefix so
    'machine' still means one machine. The key is <info.name>:<duty name>, both URI-encoded so a : in either can't
    make two leases one, plus the SHA-256 of the project root (the nearest folder with a package.json at or above the
    working directory) for 'workspace'. The key is worked out before the store is opened, so a project root that can't
    be read leaves no handle open. A store that can't be opened (no @frontmcp/storage-sqlite, Redis down) is logged
    through the onReady() failure line and the duty does not run.
  • Who shares a lease. A server's identity is its info.name, as for its ~/.<info.name>/ data folder: two
    different servers with the same name on one machine share every duty of the same name. That is documented, and the
    already runs elsewhere line shows it. Two different duty classes with the same lease in one process would leave
    one never running, so the second to start fails with DutyNameConflictError; the same class built more than once (a
    top-level provider of a splitByApp server is built per app) shares its lease and runs once.
  • File safety. The lease folder is created 0700 and the file 0600 (created with an append open, so processes
    starting together never truncate it; SQLite gives its -wal and -shm files the same mode). A folder that the
    group or others can write to, or that belongs to another user, or one above it that does (unless sticky, like
    /tmp), is refused with UnsafeDutyStoreError: the walk is the Unix-socket guard's, now exported as
    findReplaceableDirectory(). So two users never share leases and nobody else can hold or delete them. With Redis,
    replicas given the same hostname share a 'machine' lease; that is documented.
  • Where duties don't run. createContainer() builds a duty but never starts it, so scripts and the workers later
    PRs boot from a container don't run duties; createContainer({ runDuties: true, info: { name } }) starts them, and
    dispose() stops them; runDuties needs info, and each duty's own onReady() runs before it starts, as in a
    server. A serverless deployment, the edge and the browser skip them with a warning. createForCli()
    and createForGraph() never run onReady(), so never start them.

Different from the plan, and why:

  • A provider, not an app entry. A duty has no MCP surface: nothing lists, calls or hooks it, so an entry kind
    (registry, flows, owner, list-changed) would be machinery for nothing. What it needs is constructor injection and a
    start/stop lifecycle in dependency order, which a GLOBAL provider with M01's hooks already has. It also lets a plugin
    ship a duty, and keeps duties out of containers by default without extra code.
  • leaseMs is three intervals clamped to 3–60 s, not a plain multiple of every. The lease measures the holder's
    liveness and is renewed on its own timer, so it doesn't need to grow with the interval: three intervals of an hourly
    duty would leave it unrun for three hours after a crash, and a 100 ms duty would get a lease shorter than a store TTL
    can express.
  • A store key, not a lock file, for both scopes: 'workspace' is a key per project root in the same machine-wide
    file, so nothing is written into the project.
  • Re-check before each run, no fencing token. The renew before each run covers a holder waking from a pause, and
    the aborted signal covers a run in flight. A fencing token would only help a duty that writes to a store which
    checks it; none of the stores here do.
  • Based on the M07 top (feat/m07-hook-ordering), not feat/m11-worker-offload: M09–M11 are in review and are
    merged in later. Nothing here depends on them.
  • createContainer() gains info, so a script that runs duties shares the server's leases.

The catalog's decorators guide said to "use @Job with a cron schedule for recurring work"; jobs have no schedule, so
that row and create-job's matching bullet now point to @Duty.

User-facing change

  • What changed:
    • New decorator Duty and types DutyMetadata, DutyScope, DutyInterface, DutyRunContext and
      ResolvedDutyMetadata, plus dutyMetadataSchema and dutyMetadataOf(), exported from @frontmcp/sdk.
    • New createContainer() options runDuties?: boolean (default false) and info?: { name: string }, required
      with runDuties (runDuties needs info.name: duties share their leases by server name).
    • New errors DutyNameConflictError (DUTY_NAME_CONFLICT): Duty "<name>" of server "<server>" is declared by two classes in this process (<A> and <B>): give one of them its own name with @Duty({ name }), and
      UnsafeDutyStoreError (UNSAFE_DUTY_STORE): Refusing to keep duty leases under <folder> (mode <mode>): <reason>. Set FRONTMCP_HOME to a folder only you can write to., both exported from @frontmcp/sdk.
    • @frontmcp/utils writeFile() takes a flag option (for example 'a').
    • New errors at decoration (InvalidDecoratorMetadataError, INVALID_DECORATOR_METADATA), for example
      @Duty invalid metadata for "every": every must be milliseconds or a duration such as "30s", "5m", "1h",
      ... "every": every must be greater than 0, ... "every": every must be a whole number of milliseconds,
      ... "every": every must be at most 2147483647 ms (about 24.8 days),
      ... "scope": scope must be 'machine' or 'workspace',
      ... "leaseMs": leaseMs must be at least 1000 (stores keep a TTL in whole seconds),
      ... "name": name must not be empty, ... "run": <Class> has no run() method, and Unrecognized key for an
      unknown option.
    • New log lines: Duty "<name>" runs in this process every <ms> ms (info), Duty "<name>" already runs elsewhere (lease "<key>"); this one takes it over if that one stops (info), Duty "<name>" lost its lease; another process runs it now (warn), Duty "<name>" could not renew its lease within <leaseMs> ms; it stops here until it gets the lease again (warn), Duty "<name>": the lease store failed: <cause> (warn), Duty "<name>" failed: <cause> (error), Duty "<name>" is still running; skipping this run (verbose), Duty "<name>" does not run in a serverless or edge deployment: it needs a long-lived process (warn).
    • New file ~/.frontmcp/duties.sqlite (or under $FRONTMCP_HOME), created owner-only by the first server that
      starts a duty.
  • Before → after:
    • Recurring work in several copies of a server: every copy ran its own setInterval() → a @Duty runs in one
      process of the machine or project, and another takes over within leaseMs plus a third of it when that one dies,
      or at its next check when it shuts down.
    • A provider class decorated with @Duty in createContainer(): built and not started; runDuties: true starts it.
    • Unchanged: servers and containers without a @Duty provider (the duty runtime is not even loaded).
  • frontmcp.dev pages affected (if known): the provider reference (/reference/sdk/provider: duties, lifecycle,
    createContainer({ runDuties, info })), the decorators reference (new @Duty), the errors reference (the @Duty
    messages of InvalidDecoratorMetadataError, DutyNameConflictError, UnsafeDutyStoreError), the jobs page (a job has no schedule; recurring work is a duty), and
    probably a new page for duties.
  • Skills catalog entries updated: frontmcp-development: new references/create-duty.md (when to use, steps,
    options, name conflicts, how the lease works including giving it up and clocks, where leases live, file safety,
    log lines, patterns, checklist, troubleshooting) with its
    skills-manifest.json row; SKILL.md (description, *.duty.ts path, routing row, reading order, common pattern);
    references/decorators-guide.md (hierarchy, new @Duty section, quick reference row, the recurring-work pattern,
    and @Job no longer described as scheduled); references/create-job.md (recurring work points to create-duty);
    references/create-provider.md (per-process timers versus duties, runDuties in createContainer()).

Tests

  • Unit, @frontmcp/sdk (libs/sdk/src/duty/__tests__/, 56 pass; duty code and errors 98.8% statements, 99.1%
    lines, 90.9% branches):
    • duty.runner.spec.ts, with fake timers and a memory store shared by several runners: one holder runs once per
      interval while it renews; an overlapping run is skipped, not stacked; a failed run is logged and keeps the lease; a
      release on stop lets the next holder take over and wait out the interval the last run started; a holder whose store
      hangs (dead) is taken over once the lease runs out, within the lease plus a third; a holder paused past its lease
      that wakes after the takeover never runs again and logs that it gave the lease up; a run in flight is aborted when the
      lease is lost and on stop, and the lease is released only once the run ended; a store failure skips that run and the
      lease is kept; a lease got after stop() was called is released; when the store can't say when the last run
      started, the first run waits a whole interval; a holder cut off from its store for a whole lease aborts its run
      exactly at the lease's end, no later than the process that takes it over first runs; a run winding down after
      stop() keeps the lease, so the next holder runs only once it returned; a takeover of a 2147483647 ms duty right
      after its run waits (the remaining marker TTL, rounded up, used to overflow setTimeout and run at once); a
      process that doesn't get the lease logs once that it already runs elsewhere.
    • duty.lifecycle.spec.ts, with a real SQLite file under a temporary FRONTMCP_HOME: createContainer() builds a
      duty without starting it (no file, no run) and still runs its own onShutdown(); runDuties starts it and
      dispose() stops it; of two createDirect() servers only one runs a machine duty, and the other takes over when the
      first is disposed; the duty's own onBootstrap() and onReady() run before its first run; a workspace lease is per
      project root (a subfolder shares it) and every lease per server name; a project root that can't be read
      (EACCES) fails the start without opening the store; nothing starts in a serverless deployment; the lease store
      is Redis from a parsed or raw top-level redis, and the SQLite file otherwise; runDuties runs the duty's own
      onReady() first and needs info.name; a second class with another's lease fails with DutyNameConflictError
      while the same class may start twice; a:b + c and a + b:c hold different leases; the lease folder is
      created 0700 and the file, -wal and -shm 0600; a folder of mode 0777, 01777 or 0770 is refused with
      UnsafeDutyStoreError before any file is created, and the error names another user that owns a folder.
    • duty.decorator.spec.ts: durations, defaults, the clamped lease, and every validation message.
  • E2E, apps/e2e/demo-e2e-local-runtime/e2e/duties.e2e.spec.ts (new fixture fixture/duty-server.ts, a
    createDirect() server with a 500 ms machine duty and a 12 s lease; 1 pass, about 25 s): five servers start, and
    every run comes from one process at least half an interval apart (measured 500–506 ms); SIGKILL on the holder → a
    second process takes over (measured 13.5 s after the kill, asserted under 24 s) and runs alone; SIGTERM on that one →
    it exits cleanly and a third takes over at once (measured 2.6 s, asserted under 8 s, which a lease left to expire
    can't meet), no sooner than half an interval after the last run; the lease file the five processes created together
    is 0600. Timing bounds are generous for a loaded machine. Checked red first: the fixture failed to load before
    @Duty existed.
  • Unit, @frontmcp/utils (fs.spec.ts, 74 pass): writeFile(…, { flag: 'a' }) appends, and applies mode only
    when it creates the file. The Unix-socket guard's spec (its directory walk now shared) passes unchanged.
  • E2E, demo-e2e-dts-consumer (21 pass): duties-must-compile.ts types @Duty, DutyMetadata, DutyRunContext
    and createContainer({ runDuties, info }), and rejects a class without run(), an unknown scope and a non-duration
    every.
  • E2E, demo-e2e-bundle-size (10 pass): a new duties guard checks no duty/ module reaches the browser bundle;
    create() + one tool stays within its budget (1,322,000, unchanged).
  • E2E, demo-e2e-providers (30 pass).
  • Regression: the full @frontmcp/sdk suite (595 suites, 7797 tests), the full @frontmcp/utils suite (69
    suites, 2001 tests), skills (249), and every demo-e2e-local-runtime spec (4 suites, 5 tests) pass.
  • Checks: nx build utils sdk; lint (no warnings in the changed files); tsc --noEmit over the changed sources
    and specs and the e2e app; prettier.

Bundle size

@Duty's decorator and option schema reach the browser bundle; the runner and lease code stay out (a new guard in demo-e2e-bundle-size checks it). Measured against #1013's head merged with main (91531eb, whose budgets are 1,323,819 and 1,330,696): create() + one tool grows by 1,309 bytes minified (1,322,865 → 1,324,174; +478 gzipped) and create() + WebMcpPlugin + one tool by 1,308 (1,330,054 → 1,331,362; +491 gzipped). The two browser budgets move up from main's by exactly that, to 1,325,128 and 1,332,004 (1287ad3). The Vite initial chunk (943,675 of 960,000) stays within its budget.

Summary by CodeRabbit

  • New Features
    • Added @Duty for recurring background tasks, with configurable intervals and machine- or workspace-level coordination. If the process holding a duty’s lease stops, another process can take over.
    • Duties start with the server. When using createContainer(), opt in with runDuties: true and provide a server name.
  • Documentation
    • Added guidance for defining, configuring, and troubleshooting duties, and clarified how duties differ from jobs.

…ase, with takeover when the holder dies

A @duty({ every, scope: 'machine' | 'workspace', leaseMs }) provider has run() called every interval in the one
process that holds its lease. The lease is a store key with a TTL: set-if-absent to acquire, compare-and-expire to
renew every third of the lease and again before each run, compare-and-delete to release at shutdown. Expiry follows
the store's TTL, so another process takes over once a dead or paused holder stops renewing, and a holder that wakes
after a takeover never runs again. Leases live in a machine-wide SQLite file, or the server's Redis when configured.
Duties start on onReady and stop on shutdown; createContainer() builds them but runs them only with runDuties.
… lease, and keep it while a stopped run winds down

A holder whose store calls kept failing (Redis unreachable, a locked SQLite file, a hung call) kept holding the duty
and its run in flight without a confirmed lease, so once the store ended the lease another process could run it at
the same time. The holder now arms a monotonic deadline from the moment it sent each successful acquire or renew;
past it, it aborts the run in flight, logs that it could not renew within leaseMs and contends again. Acquire
anchors that deadline with compare-and-expire instead of reading the key back.

stop() stopped renewing before the aborted run returned, so a run that took longer than the rest of the lease to
wind down overlapped the next holder's. It now keeps renewing until the run returns, then releases the lease.

A takeover waited the last-run marker's TTL rounded up to whole seconds, which for an interval near the timer limit
overflowed setTimeout and ran at once; it now waits at most one interval. A process that doesn't get the lease logs
once that the duty already runs elsewhere, with the lease key.
…duty's onReady() before it starts

- Two duty classes with the same lease in one process (the same name in one server, or two plugins' classes of the
  same name) left one never running; the second to start now fails with DutyNameConflictError. The same class built
  more than once (per scope of a splitByApp server) still shares one lease.
- Lease keys URI-encode the server and duty names, so a ':' in either can't make two leases one or hit the
  last-run marker.
- createContainer({ runDuties }) needs info.name: without it every unnamed container on the machine shared the
  'frontmcp' leases. It now runs each duty's own onReady() before starting it, as a server does.
- The lease folder is created 0700 and duties.sqlite 0600 (with an append open, so processes starting together never
  truncate it); a folder another user owns, or the group or others can write to, or one above it that is not sticky,
  is refused with UnsafeDutyStoreError. The walk is the Unix-socket guard's, exported as findReplaceableDirectory().
  @frontmcp/utils writeFile() takes a flag option for the append open.
- The create-duty and create-provider references describe the conflict, the required info, file safety, giving a
  lease up, clocks, Redis hostnames, splitByApp and the new log lines.
…feat/m12-duties

# Conflicts:
#	apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
@duty's decorator and option schema reach the browser bundle (the runner itself stays out), so create() + one tool grows by 1,309 bytes minified and create() + WebMcpPlugin + one tool by 1,306. The two budgets move up by exactly that, keeping the headroom they had.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 17:44
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Next included review available in 52 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 54 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 1287ad3 and 6da5847.

Files selected for processing: 1
  • libs/sdk/src/duty/duty.runner.ts

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 4667cce and 1287ad3.

Files selected for processing: 32
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
  • apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/duties-must-compile.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
  • apps/e2e/demo-e2e-local-runtime/e2e/duties.e2e.spec.ts
  • apps/e2e/demo-e2e-local-runtime/e2e/helpers/workers.ts
  • apps/e2e/demo-e2e-local-runtime/fixture/duty-server.ts
  • libs/sdk/src/common/decorators/duty.decorator.ts
  • libs/sdk/src/common/decorators/index.ts
  • libs/sdk/src/common/interfaces/duty.interface.ts
  • libs/sdk/src/common/interfaces/index.ts
  • libs/sdk/src/common/metadata/duty.metadata.ts
  • libs/sdk/src/common/metadata/index.ts
  • libs/sdk/src/common/types/options/sqlite/resolve.ts
  • libs/sdk/src/duty/__tests__/duty.decorator.spec.ts
  • libs/sdk/src/duty/__tests__/duty.lifecycle.spec.ts
  • libs/sdk/src/duty/__tests__/duty.runner.spec.ts
  • libs/sdk/src/duty/duty.lifecycle.ts
  • libs/sdk/src/duty/duty.runner.ts
  • libs/sdk/src/errors/duty.errors.ts
  • libs/sdk/src/errors/index.ts
  • libs/sdk/src/provider/create-container.ts
  • libs/sdk/src/provider/provider.registry.ts
  • libs/sdk/src/server/security/unix-socket-guard.ts
  • libs/skills/catalog/frontmcp-development/SKILL.md
  • libs/skills/catalog/frontmcp-development/references/create-duty.md
  • libs/skills/catalog/frontmcp-development/references/create-job.md
  • libs/skills/catalog/frontmcp-development/references/create-provider.md
  • libs/skills/catalog/frontmcp-development/references/decorators-guide.md
  • libs/skills/catalog/skills-manifest.json
  • libs/utils/src/fs/fs.spec.ts
  • libs/utils/src/fs/fs.ts

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Walkthrough

The SDK adds @Duty declarations and lease-based recurring execution with configurable startup, storage, and shutdown behavior. The change also adds duty tests, E2E and declaration-consumer coverage, development references, and an optional filesystem write flag.

Changes: Duty Runtime

Layer / File(s) Summary
Duty metadata and decorator contract
libs/sdk/src/common/interfaces/*, libs/sdk/src/common/metadata/*, libs/sdk/src/common/decorators/*, libs/sdk/src/errors/duty.errors.ts, libs/sdk/src/duty/__tests__/duty.decorator.spec.ts, apps/e2e/demo-e2e-dts-consumer/fixture/duties-must-compile.ts
Adds duty interfaces and validated metadata, the @Duty decorator, duty-specific errors, and tests for decorator behavior and declaration usage.
Lease-based duty execution
libs/sdk/src/duty/duty.runner.ts, libs/sdk/src/duty/__tests__/duty.runner.spec.ts
Adds lease acquisition and renewal, interval scheduling, overlap prevention, abort handling, and runner tests.
Duty lifecycle and startup wiring
libs/sdk/src/duty/duty.lifecycle.ts, libs/sdk/src/provider/*, libs/sdk/src/server/security/unix-socket-guard.ts, libs/sdk/src/common/types/options/sqlite/resolve.ts, libs/sdk/src/duty/__tests__/duty.lifecycle.spec.ts, apps/e2e/demo-e2e-local-runtime/e2e/*, apps/e2e/demo-e2e-local-runtime/fixture/duty-server.ts, apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts, apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
Integrates duty startup and shutdown with provider and container lifecycles. Adds Redis or SQLite lease-store selection, SQLite directory checks, and lifecycle and multi-process tests.
Duty documentation and integration checks
libs/skills/catalog/frontmcp-development/*, libs/skills/catalog/skills-manifest.json, apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
Adds duty setup and behavior references, updates job and provider guidance, and adjusts bundle-size limits and duty-module leak checks.

Filesystem Write Flag

Layer / File(s) Summary
Optional write flag
libs/utils/src/fs/fs.ts, libs/utils/src/fs/fs.spec.ts
Adds an optional flag parameter to writeFile and tests append behavior and file-mode preservation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DutyLifecycle
  participant DutyRunner
  participant LeaseStorage
  participant Duty
  DutyLifecycle->>DutyRunner: start runner
  DutyRunner->>LeaseStorage: acquire or renew lease
  LeaseStorage-->>DutyRunner: lease result
  DutyRunner->>Duty: run with abort signal
  Duty-->>DutyRunner: run completes
  DutyRunner->>LeaseStorage: record run time
Loading

Suggested reviewers: alexmercerpo

Merge Risk: Minimal · previous review 1287a

This change adds opt-in @Duty providers that run recurring work under a lease held by a single process. Duties are not started by default, and the supplied evidence shows no concrete defect. The change is reasonable to merge after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 52.17% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description check Passed The description includes the required What and why, User-facing change, Before → after, affected documentation, skills updates, and Tests sections. It gives detailed implementation scope and validatio…
Title check Passed The title clearly and concisely identifies the main change: leased periodic work that runs in exactly one process per machine or workspace.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description includes the required What and why, User-facing change, Before → after, affected documentation, skills updates, and Tests sections. It gives detailed implementation scope and validation results.

Full details: Docstring Coverage

Explanation

Docstring coverage is 52.17% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 23 functions across 25 files. (7 skipped: 7 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@frontegg-david
frontegg-david added this pull request to stack #997 November 2, 2026 17:45
@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…feat/m12-duties

# Conflicts:
#	apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
On main's budgets (1,323,819 and 1,330,696), duties grow create() + one tool
by 1,309 bytes and create() + WebMcpPlugin + one tool by 1,308, measured
against #1013's merged head; the budgets move up by exactly that.
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

Project Tests Passed Warnings Failed Leaks
✅ demo-e2e-agents 4 4 0 0 0
✅ demo-e2e-cache 11 11 0 0 0
✅ demo-e2e-codecall 4 4 0 0 0
✅ demo-e2e-config 4 4 0 0 0
✅ demo-e2e-direct 3 3 0 0 0
✅ demo-e2e-elicitation 1 1 0 0 0
✅ demo-e2e-errors 4 4 0 0 0
✅ demo-e2e-hooks 3 3 0 0 0
✅ demo-e2e-multiapp 4 4 0 0 0
✅ demo-e2e-notifications 3 3 0 0 0
✅ demo-e2e-openapi 2 2 0 0 0
✅ demo-e2e-providers 4 4 0 0 0
✅ demo-e2e-public 4 4 0 0 0
✅ demo-e2e-redis 15 15 0 0 0
✅ demo-e2e-remember 4 4 0 0 0
✅ demo-e2e-remote 5 5 0 0 0
✅ demo-e2e-serverless 2 2 0 0 0
✅ demo-e2e-skills 15 15 0 0 0
✅ demo-e2e-standalone 2 2 0 0 0
✅ demo-e2e-transport-recreation 3 3 0 0 0
✅ demo-e2e-ui 4 4 0 0 0

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-02T18:45:30.129Z
Commit: cba46050

@frontegg-david

Copy link
Copy Markdown
Contributor Author

Merged feat/m10-stall-dev-events (4667cce, with main at 91531eb) into this branch in ce28f17. The one conflict was the browser bundle budgets; 1287ad3 sets them to main's plus this PR's measured growth (+1,309 and +1,308), as the Bundle size section of the body says, and keeps the duties guard.

Local runs on 1287ad3 (sdk and observability built with --skip-nx-cache): sdk 8542, utils 2026, storage-sqlite 239, skills 249; e2e stdio-transport 30, local-runtime 8, unix-socket (pass), dts-consumer 24, providers 30, bundle-size 12.

@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor
Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The cloudflare isolate-safety check (demo-e2e-cloudflare) refuses a
randomUUID() it reads as module-eval: the class field initializer of
DutyRunner.holder. The id is now set in the constructor, which runs at the
same time.
@frontegg-david

Copy link
Copy Markdown
Contributor Author

CI on 1287ad3 failed E2E chunk 5: demo-e2e-cloudflare's worker isolate-safety check refused randomUUID() at libs/sdk/src/duty/duty.runner.ts:32, a class field initializer it reads as module-eval. Fixed in 6da5847: the runner's holder id is now set in the constructor, which runs at the same time. nx test demo-e2e-cloudflare passes (16 passed, 5 skipped); the duty suites pass (56).

@alexmercerpo
alexmercerpo merged commit cc2274d into main Nov 3, 2026
46 checks passed
@alexmercerpo
alexmercerpo deleted the feat/m12-duties branch November 3, 2026 07:27
alexmercerpo pushed a commit that referenced this pull request Nov 3, 2026
… own changes, from any process (#1015)

* feat(sdk,utils,storage-sqlite,testing): add materialized resources served from a store, with a cross-process change feed

@resource({ uri, from: store.key(name) }) serves a stored value with a revision
and update time. Writes through set() and update() are atomic in every process
that shares the store (a new setIfEquals compare-and-set on memory, Redis and
SQLite), and resources/updated reaches subscribed sessions and 2026-07-28
subscriptions/listen streams whenever the revision changes, from any process.

The change feed (createChangeFeed) picks Redis or memory pub/sub, or a SQLite
PRAGMA data_version poll (SqliteStorageAdapter.dataVersion), and coalesces
bursts per topic; the store exposes it as a topic API for later SSE routes.

resources/subscribe now checks a resource's authorities as resources/read does,
and a listen stream runs each requested URI, and each sub-resource update,
through the resources:subscribe flow. The test client's resources.subscribe()
and unsubscribe() send their requests instead of logging a placeholder.

* fix(sdk,utils): refuse resource store backends without compare-and-set, keep poll feeds on other files from moving each other's counters, close stores on a failed start

A resource store now takes only memory, redis or sqlite storage (a type is
required), in the type and the schema, since every write uses setIfEquals; any
other backend failed on the first write. A poll feed that hears a publish from
another feed in the process no longer adopts its counter, so a feed on another
file with the same names cannot hide a later write. When one store fails to
open at boot, the stores already opened are closed.

* fix(utils): tell a change feed's topics after a lost Redis connection comes back, and drop the error of a poll a close cut short

Messages published while the Redis subscriber was down were missed, so a key listener stayed stale until the next
write. The Redis adapter now calls onPubSubReconnect() listeners when its subscriber connection is ready again, and a
pub/sub feed tells every followed topic then. A poll interrupted by closing the feed and its storage no longer
reaches onError.

* fix(sdk): store a resource key's initial value as JSON, return the stored snapshot from a write, and stop a key listener at once

Before the first write, get() returned the initial object itself (a caller could change it) and kept a Date that a
later read returns as a string; set() and update() returned the value given rather than the one stored. The initial
value is now kept as the JSON record a write starts from, and one JSON cannot hold fails where the key is declared
with InvalidResourceStoreError. A key listener is no longer called after its stop function when a read had already
started, and isResourceKey() also requires the key's store, which the server opens at boot.

* test(e2e): raise the WebMcp browser bundle budget by exactly the 295 bytes the resource store fixes measured

* test(e2e): fit the browser bundle budgets to materialized resources

The resource store, its change feed and the per-scope watcher reach the browser bundle, so create() + one tool and create() + WebMcpPlugin + one tool each grow by 3,992 bytes minified over #1014's head. The two budgets move up by exactly that, keeping the headroom they had.

* fix(sdk): bound a listen request's resource subscriptions and read nothing after unsubscribing

A subscriptions/listen request ran the resources:subscribe checks for every
URI it listed, all at once, with no bound and duplicates repeated. It now
takes at most 1000 resourceSubscriptions (more is refused with -32602 before
any check runs) and checks each distinct URI once.

A resource store key's subscription ran a change it had queued before
unsubscribing, and that read reopened a store that had been closed. A queued
check now returns at once once the subscription is gone.

The docs say a notification is a signal to read the latest value, not one
event per revision.
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.

2 participants