Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Add the dispatch job: idempotent task generation, backfill-capable - #43

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job
Sep 1, 2026
Merged

Add the dispatch job: idempotent task generation, backfill-capable#43
os-warren merged 3 commits into
mainfrom
claude/issue-2-dispatcher-job

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#2

The spine. Active recurring duties become tasks, once per period, forever, without a lock.

Gates green at fe7941a (the merged head, origin/main merged in for #36/#38): pnpm validate ✓ · pnpm typecheck ✓ · pnpm test243 passed (7 files) · pnpm build ✓.

Metadata-first: what is declarative, and what could not be

Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (duly_task_dispatch_identity, already on the object).

Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:

src/jobs/dispatch.plan.tsPure. Every scheduling decision. No I/O, no clock, no platform import. Rows in, rows out.
src/jobs/dispatch.job.tsThe defineJob metadata, plus ~40 lines that talk to the engine.

A scheduled flow was the alternative and was measured, not assumed. It is expressible (get_recordscriptloopcreate_record) and it was rejected on three findings:

  1. Its error handling cannot tell a duplicate from a disaster.try_catch and fault edges swallow every failure identically, because the create_record executor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.
  2. Backfill cannot reach a scheduled flow.ScheduleTrigger hands the flow { event, params: { jobId, flowName, schedule } } — no input channel. IJobService.trigger(name, data) forwards data to a job handler, which is how { from, to } gets in.
  3. No retry policy or timeout.ScheduleTrigger calls jobService.schedule(name, schedule, handler) with no options; defineJob threads both.

The product docs settle the shape independently (data-model.md: "the dispatcher can be a plain idempotent job"; duty.object.ts names this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.

Idempotency: attempt the insert, then ask the data

The happy path is a bare insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.

The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as code: 'SQLITE_CONSTRAINT_UNIQUE' here and would be 23505 on Postgres; the platform's dialect-independent predicate isUniqueViolationError lives in @objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.

So on failure it asks the data, which every driver answers the same way: is the row there now?

  • Present → the obligation exists exactly once, whoever won the race. Counted as existing. A run that inserts nothing because everything already exists is a successful run.
  • Absent → the insert failed for a reason that is not "already dispatched". Re-thrown, so the run fails. That is the half a blanket swallow gets wrong.

The gap this could not close: a job handler has no data reach

Measured: a defineJob handler is invoked with exactly { jobId, data, bundle } — no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.

Not worked around silently. runDispatch(engine, …) takes its engine as an argument; bindDispatchEngine is the one named seam; and until a host calls it dulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.

Semantics worth reviewing

  • One UTC pass, every zone resolved locally. The question asked per duty is "is this period's task due to exist yet", never "is it midnight here". Pinned: at 2026-08-31T20:00Z an Asia/Shanghai duty gets 2026-09 and an America/Los_Angeles duty gets 2026-08, on the same run.
  • Which periods a scheduled run emits. The current period unconditionallyvisible_from governs when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded at today + lead_days, which is exact rather than generous.
  • Two effective-window tests. Duty-level on today (the scheduled run's selection rule, per the card); period-level on due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.
  • The effective window is deliberately not in the SQL filter.effective_from/effective_to are calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.
  • last_dispatched_period advances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.
  • The stagnation clock is untouched. The dispatcher writes duly_duty and never duly_task. Asserted directly: a spy engine records every update target across two runs at two clocks and duly_task must not appear.
  • Backfill input is validated, not guessed.to on its own is refused — it could only mean "since the duty began", and effective_from is nullable, so that window has no floor.
  • Paged reads. An unbounded sweep fails by dispatching a prefix of the duties, with no error anywhere.

Tests — and why they run on sqlite

test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.

The engine is sqlite, not memory, and that is the load-bearing choice. Measured: InMemoryDriver.create is a table.push() that stores no constraints, so two identical duly_task inserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.

Two ablations, each committed first, each confirmed on disk, each restored by a trap:

ablationon-disk confirmationresult
sqlite → memory driverremoved-literal 1 → 0, injected-literal count 1, git diff --stat non-empty4 red, including "the index is real on this engine" — the sqlite choice is load-bearing
dispatcher updates an existing duly_task rowanchor 1 → 0, injected engine.update('duly_task' count 11 red — exactly the stagnation-clock guard

Both restored clean (git status empty after each). Independent evidence that the suite bites: it caught a real bug during the first run — with lead_days: 0 the look-ahead passed the run's instant as from and midnight-of-today as to, so periodsBetween returned [] and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.

Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap · last_dispatched_period advances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.

Files, and one note on scope

src/jobs/dispatch.job.ts, src/jobs/dispatch.plan.ts, src/jobs/index.ts, test/dispatch.test.ts — all inside the declared surface.

One addition outside it, named here rather than buried:src/functions/index.ts, to register the handler in dulyFunctions. A job needs two registrations — the schedule in dulyJobs and the handler in the functions map — and AppPlugin resolves the second as collectBundleFunctions(bundle)[job.handler], logging job handler not found in bundle.functions — skipping on a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.

objectstack.config.ts was not touched; the barrels were sufficient.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:06
@os-warren
os-warren merged commit c905993 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

Dispatcher job — idempotent task generation, backfill-capable

1 participant

@os-warren