Repository navigation
feat(sdk): duties — periodic work that exactly one process on a machine or workspace runs - #1014
Conversation
…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.
…failed key leaves no handle open
… 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.
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 52 minutes. View limit details
No actionable comments were generated in the recent review.
|
| 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
Suggested reviewers: alexmercerpo
Merge Risk: 
Minimal · previous review 1287a
-
This change adds opt-in
@Dutyproviders 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: 
4 · 
1
-
Failed checks: 1 warning Passed checks: 4 passed-
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
|
@coderabbitai review |
Action not completed
|
…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.
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T18:45:30.129Z |
|
Merged Local runs on 1287ad3 (sdk and observability built with |
|
@coderabbitai review |
Action performed
|
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.
|
CI on 1287ad3 failed E2E chunk 5: |
… 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.
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:
@Jobhas no schedule, and asetInterval()started in a provider'sonReady()runs in every process.What this PR changes:
@Duty({ name?, every, scope: 'machine' | 'workspace', leaseMs? })marks a provider class whoserun({ signal })the server calls every
every, in the one process that holds the duty's lease.everyis milliseconds or a duration(
'500ms','30s','5m','1h','1d'), from 1 ms to 2147483647 ms.leaseMsdefaults to three intervals,clamped to 3–60 s, and must be at least 1000 (stores keep a TTL in whole seconds).
namedefaults to the class name.The options are checked by a Zod schema when the class is decorated; a mistake throws
InvalidDecoratorMetadataErrornaming the field, and a class withoutrun()fails to compile and throws.providersof@App,@FrontMcpor a plugin, gets itsdependencies through its constructor, and keeps its own
onBootstrap(),onReady()andonShutdown(). It starts inthe
onReady()phase (M01), on every transport, and stops in theonShutdown()phase, before the providers itdepends on. The registry recognises a
@Dutyinstance when it builds it and loads the duty runtime with a dynamicimport, so a server without duties (and the browser bundle) never loads it.
leaseMs:compare-and-delete (after aborting the run in flight and waiting for it). The other processes try to acquire at the
same pace.
another process takes over within
leaseMsplus a third of it.(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
signalaborted.leaseMs(Redis unreachable, a locked file, a hung store call) givesthe 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.
process starts while that run winds down. A run that ignores
signalholds the shutdown up tolifecycle.shutdownHookTimeoutMs, and its lease then expires on its own.jump of that clock (an NTP step, a laptop waking from sleep) by more than
leaseMsends a lease and the last-runmarker 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.
(
Duty "<name>" failed: <cause>) and the lease is kept. A store error skips that check or run and is retried.(at most one interval from the takeover), so the duty still runs once per interval across a handover.
already runs elsewhere, with the lease key, so aduty that never runs in a lone server points at whoever holds it.
createStorage()withfallback: 'error'(never per-process memory): a SQLite fileevery process on the machine opens,
~/.frontmcp/duties.sqlite($FRONTMCP_HOME/duties.sqlite), using M02's atomicoperations; or the server's top-level
rediswhen 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'tmake two leases one, plus the SHA-256 of the project root (the nearest folder with a
package.jsonat or above theworking directory) for
'workspace'. The key is worked out before the store is opened, so a project root that can'tbe read leaves no handle open. A store that can't be opened (no
@frontmcp/storage-sqlite, Redis down) is loggedthrough the
onReady()failure line and the duty does not run.info.name, as for its~/.<info.name>/data folder: twodifferent servers with the same name on one machine share every duty of the same name. That is documented, and the
already runs elsewhereline shows it. Two different duty classes with the same lease in one process would leaveone never running, so the second to start fails with
DutyNameConflictError; the same class built more than once (atop-level provider of a
splitByAppserver is built per app) shares its lease and runs once.0700and the file0600(created with an append open, so processesstarting together never truncate it; SQLite gives its
-waland-shmfiles the same mode). A folder that thegroup or others can write to, or that belongs to another user, or one above it that does (unless sticky, like
/tmp), is refused withUnsafeDutyStoreError: the walk is the Unix-socket guard's, now exported asfindReplaceableDirectory(). 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.createContainer()builds a duty but never starts it, so scripts and the workers laterPRs boot from a container don't run duties;
createContainer({ runDuties: true, info: { name } })starts them, anddispose()stops them;runDutiesneedsinfo, and each duty's ownonReady()runs before it starts, as in aserver. A serverless deployment, the edge and the browser skip them with a warning.
createForCli()and
createForGraph()never runonReady(), so never start them.Different from the plan, and why:
(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.
leaseMsis three intervals clamped to 3–60 s, not a plain multiple ofevery. The lease measures the holder'sliveness 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.
'workspace'is a key per project root in the same machine-widefile, so nothing is written into the project.
the aborted
signalcovers a run in flight. A fencing token would only help a duty that writes to a store whichchecks it; none of the stores here do.
feat/m07-hook-ordering), notfeat/m11-worker-offload: M09–M11 are in review and aremerged in later. Nothing here depends on them.
createContainer()gainsinfo, so a script that runs duties shares the server's leases.The catalog's decorators guide said to "use
@Jobwith a cron schedule for recurring work"; jobs have no schedule, sothat row and
create-job's matching bullet now point to@Duty.User-facing change
Dutyand typesDutyMetadata,DutyScope,DutyInterface,DutyRunContextandResolvedDutyMetadata, plusdutyMetadataSchemaanddutyMetadataOf(), exported from@frontmcp/sdk.createContainer()optionsrunDuties?: boolean(defaultfalse) andinfo?: { name: string }, requiredwith
runDuties(runDuties needs info.name: duties share their leases by server name).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 }), andUnsafeDutyStoreError(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/utilswriteFile()takes aflagoption (for example'a').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, andUnrecognized keyfor anunknown option.
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).~/.frontmcp/duties.sqlite(or under$FRONTMCP_HOME), created owner-only by the first server thatstarts a duty.
setInterval()→ a@Dutyruns in oneprocess of the machine or project, and another takes over within
leaseMsplus a third of it when that one dies,or at its next check when it shuts down.
@DutyincreateContainer(): built and not started;runDuties: truestarts it.@Dutyprovider (the duty runtime is not even loaded)./reference/sdk/provider: duties, lifecycle,createContainer({ runDuties, info })), the decorators reference (new@Duty), the errors reference (the@Dutymessages of
InvalidDecoratorMetadataError,DutyNameConflictError,UnsafeDutyStoreError), the jobs page (a job has no schedule; recurring work is a duty), andprobably a new page for duties.
frontmcp-development: newreferences/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.jsonrow;SKILL.md(description,*.duty.tspath, routing row, reading order, common pattern);references/decorators-guide.md(hierarchy, new@Dutysection, quick reference row, the recurring-work pattern,and
@Jobno longer described as scheduled);references/create-job.md(recurring work points tocreate-duty);references/create-provider.md(per-process timers versus duties,runDutiesincreateContainer()).Tests
@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 perinterval 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 runstarted, 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 rightafter its run waits (the remaining marker TTL, rounded up, used to overflow
setTimeoutand run at once); aprocess that doesn't get the lease logs once that it already runs elsewhere.
duty.lifecycle.spec.ts, with a real SQLite file under a temporaryFRONTMCP_HOME:createContainer()builds aduty without starting it (no file, no run) and still runs its own
onShutdown();runDutiesstarts it anddispose()stops it; of twocreateDirect()servers only one runs a machine duty, and the other takes over when thefirst is disposed; the duty's own
onBootstrap()andonReady()run before its first run; a workspace lease is perproject 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 storeis Redis from a parsed or raw top-level
redis, and the SQLite file otherwise;runDutiesruns the duty's ownonReady()first and needsinfo.name; a second class with another's lease fails withDutyNameConflictErrorwhile the same class may start twice;
a:b+canda+b:chold different leases; the lease folder iscreated
0700and the file,-waland-shm0600; a folder of mode0777,01777or0770is refused withUnsafeDutyStoreErrorbefore 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.apps/e2e/demo-e2e-local-runtime/e2e/duties.e2e.spec.ts(new fixturefixture/duty-server.ts, acreateDirect()server with a 500 ms machine duty and a 12 s lease; 1 pass, about 25 s): five servers start, andevery 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@Dutyexisted.@frontmcp/utils(fs.spec.ts, 74 pass):writeFile(…, { flag: 'a' })appends, and appliesmodeonlywhen it creates the file. The Unix-socket guard's spec (its directory walk now shared) passes unchanged.
demo-e2e-dts-consumer(21 pass):duties-must-compile.tstypes@Duty,DutyMetadata,DutyRunContextand
createContainer({ runDuties, info }), and rejects a class withoutrun(), an unknownscopeand a non-durationevery.demo-e2e-bundle-size(10 pass): a newdutiesguard checks noduty/module reaches the browser bundle;create()+ one tool stays within its budget (1,322,000, unchanged).demo-e2e-providers(30 pass).@frontmcp/sdksuite (595 suites, 7797 tests), the full@frontmcp/utilssuite (69suites, 2001 tests),
skills(249), and everydemo-e2e-local-runtimespec (4 suites, 5 tests) pass.nx build utils sdk; lint (no warnings in the changed files);tsc --noEmitover the changed sourcesand 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 indemo-e2e-bundle-sizechecks it). Measured against #1013's head merged withmain(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) andcreate()+ WebMcpPlugin + one tool by 1,308 (1,330,054 → 1,331,362; +491 gzipped). The two browser budgets move up frommain'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
@Dutyfor 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.createContainer(), opt in withrunDuties: trueand provide a server name.