Skip to content

feat(automation): accept a per-kernel scheduledWorkPolicy in the engine and both schedule triggers - #19858

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-19834-per-kernel-scheduled-work-policy
Sep 23, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-19834-per-kernel-scheduled-work-policy

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #19834

Clause-②: no

This PR follows the ruled shape on #19834 (ruling batch #216 item 1, letter ①′, on objectstack-ai/cloud#2337). A kernel can now carry its own ScheduledWorkPolicy. The deployment default and its spelling (OS_AUTOMATION_SCHEDULED_WORK_ENABLED) are unchanged, and so is objectstack serve.

What changed

Surface Change
AutomationEngineOptions (@objectstack/service-automation) New optional scheduledWorkPolicy, which takes a ScheduledWorkPolicy value or a resolver. The bind gate in activateFlowTrigger reads it through a private readScheduledWorkPolicy(). When the option is absent, that method calls the zero-argument resolveScheduledWorkPolicy() exactly as before. A resolver is called at each bind.
AutomationServicePluginOptions New optional scheduledWorkPolicy, passed to the engine unchanged. maxLogSize and runSummaryLog are forwarded the same way.
ScheduleTriggerPlugin, TimeRelativeTriggerPlugin (@objectstack/trigger-schedule) New constructor options with an optional scheduledWorkPolicy, passed to the trigger they register. new XPlugin() behaves as before.
ScheduleTrigger, TimeRelativeTrigger New trailing constructor argument options with scheduledWorkPolicy. Each trigger's own gate reads it, and falls back to the deployment resolver when it is absent.
Barrel New exported types: ScheduleTriggerPluginOptions, TimeRelativeTriggerPluginOptions, ScheduledWorkTriggerOptions, ScheduledWorkPolicySource.

The refusal is still structured. A time-triggered flow that the per-kernel policy leaves unarmed gets SCHEDULED_WORK_DISABLED_REASON on getTriggerBindingAudit() and on its getFlowRuntimeStates() row. The engine checks the policy before it looks up the trigger, as before, so the diagnostic names the policy and never says "add requires: ['triggers']".

Every bare call site, re-checked on this tree (afc3b64928)

Site Disposition
service-automation/src/engine.ts activateFlowTrigger Reads the per-kernel option when one is given, otherwise the deployment resolver.
trigger-schedule/src/schedule-trigger.ts ScheduleTrigger.start (via readScheduledWorkPolicy) Reads the per-kernel option when one is given, otherwise the deployment resolver.
trigger-schedule/src/time-relative-trigger.ts TimeRelativeTrigger.start Reads the per-kernel option when one is given, otherwise the deployment resolver. On this tree there is one resolver call. It is read once per bind, and the sweep closure reuses the result for runOwnership and describeScheduleRunOwnership. The card counted two sites at cloud's pin.
runtime/src/app-plugin.ts (the gate for defineStack({ jobs })) Still reads the deployment resolver. It is not in the ruled shape, and I did not add a surface there. See the acceptance notes.
cli/src/commands/doctor.ts Still reads the deployment resolver. doctor inspects the deployment's configuration and has no kernel options.

Tests (all at 817d4d38f2)

  • Acceptance test: packages/triggers/trigger-schedule/src/per-kernel-scheduled-work-policy.test.ts. It boots two real LiteKernels in one process, each with the real AutomationServicePlugin, ScheduleTriggerPlugin and TimeRelativeTriggerPlugin. The kernel built with enabled: false schedules nothing: no plain schedule flow and no time_relative sweep reach the job service, and its audit gives SCHEDULED_WORK_DISABLED_REASON for both flows. Its sibling built with enabled: true schedules both jobs. The test runs under the deployment switch OFF and again under it ON, which shows the per-kernel answer wins over the environment in both directions. Two pins check that the zero-argument path is unchanged: a kernel with no policy follows the deployment in each state. The file also drives both triggers directly, without an engine, with a value and with a resolver.
  • Engine source pins: packages/services/service-automation/src/per-kernel-scheduled-work-policy.test.ts. They cover two engines under both deployment states, the policy reason with no trigger registered, a resolver that is read at each bind, and the zero-argument path (no option, {}, { scheduledWorkPolicy: undefined }). They also cover the plugin forwarding, with two kernels plus a control kernel under a deployment that is ON.
  • pnpm --filter @objectstack/trigger-schedule test: 6 files, 144 tests passed.
  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2: 142 files, 1696 tests passed.
  • pnpm --filter @objectstack/trigger-schedule --filter @objectstack/service-automation typecheck: both passed. The new trigger-schedule test file appears in tsc --listFiles, and service-automation's check:test-typecheck is green.
  • Targeted eslint (eslint --no-inline-config --format json) on the 10 changed TS files: 10 files, 0 errors, 0 warnings. None of them was ignored, because an ignored file would show up as a warning. eslint.config.mjs never enables type-aware linting: there is no parserOptions.project or projectService, which --print-config confirms. So this diff cannot change the lint result of any file it does not touch.

Ablations (committed first, mutated with scripts/ablation-replace.mjs, restored to the HEAD blob with a clean git diff HEAD)

  1. In engine.ts, readScheduledWorkPolicy was changed to ignore the option. The engine suite went from 6/6 passing to 5 failing and 1 passing. The zero-argument pin stayed green, as it should.
  2. In schedule-trigger.ts, readScheduledWorkPolicy was changed to ignore source. The trigger-schedule suite went from 6/6 passing to 3 failing and 3 passing. The failures were the two direct-trigger cases and the acceptance case under deployment OFF, where the ON kernel's triggers then refuse what its engine allowed.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 61 families, and all 61 were run. Reconciled with --ran including exit codes: 59 passed. 2 are NOT MEASURED because the prerequisite is not met (exit 3, both need a whole-workspace build that CI does): pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Neither package is in the debt ledger. The CI jobs and type-check lanes that the derivation lists separately are left to CI.

Acceptance notes

  • Plugin forwarding. The ruling names AutomationEngineOptions. Hosts build the engine through AutomationServicePlugin, so the option is useless per kernel unless the plugin forwards it. The plugin forwards it the same way it already forwards maxLogSize and runSummaryLog.
  • Pass the same policy to the engine and both trigger plugins. Each trigger keeps its own gate for hosts that drive it without the engine. If a trigger reads a different answer from its engine, the engine's audit reports the trigger's refusal as a binding failure. The option docblocks say this.
  • Invariant for hand-built policies. A hand-built value must keep requiresActingOrganization === (enabled && runOwnership === 'declared'). If a host spreads the deployment reading and overrides only enabled, the result is wrong under an isolated posture: a kernel turned ON would bind org-less flows. The option docblocks and the changeset say so. No builder is added, because that would be a surface outside the ruled shape.
  • Reason text. The reason is still SCHEDULED_WORK_DISABLED_REASON word for word, as ruled. That sentence tells the reader to set OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true, which is the wrong fix for a kernel whose host turned it off. This is noted, not changed here.
  • defineStack({ jobs }). Declarative jobs gated in runtime/src/app-plugin.ts still follow the deployment switch only. In a mixed-plan host, a kernel whose flows are turned off would still schedule its defineJobs while the deployment default is ON. The ruled shape does not cover AppPlugin, so this is left for a decision.

Changeset: minor for @objectstack/service-automation and @objectstack/trigger-schedule, with Clause-②: no in its body. No edits under packages/spec or content/docs/releases/**.


Generated by Claude Code

…ne and both schedule triggers

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
…uled-work policy

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/trigger-schedule, touching 17 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/triggers/trigger-schedule/README.md, packages/triggers/trigger-schedule/src/index.ts, packages/triggers/trigger-schedule/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/connectors.mdx (via AutomationServicePluginOptions (symbol, a top-level interface))
  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class), TimeRelativeTriggerPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/triggers/trigger-schedule/README.md, packages/triggers/trigger-schedule/src/index.ts, packages/triggers/trigger-schedule/vitest.config.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d4890403e9d6b80bdc34038774091ff55b83c289 — the merge of head 817d4d38f2a17ab58d65413fa35e90914677f9e5 into base 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d4890403e9d6b80bdc34038774091ff55b83c289 && git checkout d4890403e9d6b80bdc34038774091ff55b83c289
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 817d4d38f2a17ab58d65413fa35e90914677f9e5 && git checkout -B drift-repro 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 && git merge --no-ff 817d4d38f2a17ab58d65413fa35e90914677f9e5

node scripts/docs-audit/affected-docs.mjs --json 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@huangyiirene
huangyiirene marked this pull request as ready for review September 23, 2026 12:18
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 863a775 Sep 23, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-19834-per-kernel-scheduled-work-policy branch September 23, 2026 12:39
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review (retroactive — PR landed as 863a775 before this record was written)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 817d4d38f2a17ab58d65413fa35e90914677f9e5

⚠️ Why this record is late: the domain:services seat enqueued this PR without the served-tier contract review that .claude/skills/pm-dispatch/references/contract-review.md:8 owes to .changeset prose and :25 requires on file before landing. That lapse is the seat's own. The review below was run afterwards by an isolated reviewer at the served tier. It saw only card #19834, its ruling, and the landed diff 863a775872^1..863a775872. The seat adopts the verdict.

① Derived judgments

The changeset prose was checked claim by claim at 863a775872. All 14 claims are CORRECT. Evidence:

  • engine.ts:879 (the option), :2532-2536 / :3561 (read per bind), :3606 (the engine gates first), :3622 + :4306-4316 (a differing trigger surfaces as a binding failure).
  • plugin.ts:136, :610-614 (forwarding); trigger-schedule plugin.ts:59/103, time-relative-plugin.ts:53/103.
  • schedule-trigger.ts:296-299, :663-668; time-relative-trigger.ts:294-300, :318; index.ts:4,22-23,27 (exported types); env.ts:237-241, :330-349, :374-377 (the invariant).

New public surface reachable through the exports maps:

  • @objectstack/trigger-schedule: four exported types, and new optional constructor params on four exported classes.
  • @objectstack/service-automation: scheduledWorkPolicy on AutomationEngineOptions and on AutomationServicePluginOptions.

⚠️ Clause-② classification — escalated, not charged to the PR. The PR declared Clause-②: no, which is exactly what ruling 216 item 1 on the card handed it: 「widens a runtime options surface, not packages/spec」. The written criterion reads differently. references/execution-duties.md:65 asks 「本卡放宽接受集或扩大公开面吗」, and references/contract-review.md:10-11 measures the public surface by each package's exports map, not by packages/spec. On that criterion this diff is a widening, so yes. Semver is unaffected: it is minor either way. The ruling and the criterion need reconciling by the maintainer.

② Semver level

minor on both packages is consistent (AGENTS.md:1081-1085; ruling item 4). No CHANGELOG edits. Check Changeset passed at the head.

③ Boundary flags

Zero packages/spec, zero content/docs/**. The scope sits inside the ruled shape. AutomationServicePluginOptions forwarding is outside the ruling's literal list, but it is inside item 1's intent ("every consumer reads THAT"). Item 3's acceptance test can only construct the engine through that plugin, and the forwarding was disclosed.

The dev's three open questions are escalated as follow-ups: the defineStack({ jobs }) gate, the env-var remedy text, and a policy builder.

Implemented-by: claude/issue-19834-per-kernel-scheduled-work-policy
Reviewed-by: session_01AhQASwqJr2Z7XfGWUdvnbF

VERDICT: PASS

Non-blocking defects:

  1. The Clause-② mismatch above.
  2. The invariant in the docblocks at engine.ts:875 and schedule-trigger.ts:316 is written without parentheses: requiresActingOrganization === enabled && runOwnership === 'declared'. Under JS precedence that is a different, false statement. The changeset's own parenthesised form is correct.
  3. README.md:90-91 says "is truthy", but the parser accepts only 1|true|on|yes (env.ts:239-241).
  4. The trigger-schedule acceptance suite reads service-automation from dist, not source. CI order builds dist first, so this is a hygiene note only.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants