Skip to content

feat(trigger-schedule): a scheduled run whose due time passed while no kernel was ticking is recorded as missed, never replayed - #26485

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-qvefhq
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-qvefhq

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread
Fixes #25235

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:托管内核停摆(驱逐、重启、崩溃)期间到点的定时流程,下一个内核绑定该流程时为每个错过的到点时间在运行历史里记一条 missed(开始=到点时间,结束=发现时间),不补跑。
  • 为什么改:cloud#3149 第 5 项裁决(本卡裁决 6353729816 字母 A):应用今天无法得知每日清扫没跑;spec 成员 missed 已由 feat(spec): ExecutionStatus gains missed, a scheduled due time that passed while no kernel was ticking #25363 落地,这是生产者一半。
  • 风险与回滚:只新增一种终态行与一次绑定时的只读检测;故意拒绝的流程(定时开关关、缺组织、调度不可读)现在会注销上一个内核留下的 job,这是唯一改变既有行为的点。回滚 = revert 本 PR。
  • 先看:packages/triggers/trigger-schedule/src/schedule-trigger.ts(recordSilence 与 release)、packages/services/service-automation/src/engine.ts(recordMissedRun、策略闸门调 release)、packages/services/service-job/src/db-job-adapter.ts(readArming)。
  • 两分钟验证:pnpm --filter @objectstack/trigger-schedule exec vitest run src/missed-run-two-kernels.test.ts,预期 8 个用例全绿(停一次记一条、停三次记三条、中间夹一个关闭开关的内核记零条、跑通的内核记零条)。
  • 需要你做的:无,席位落地。

What changes

A scheduled flow whose due time passed while no kernel was ticking now gets one run-history entry with status missed (due time, detected time), written by the next kernel that binds the flow. Nothing is run for it.

  • Detection (trigger-schedule). On a flow's first bind in a kernel, ScheduleTrigger reads how the previous kernel left the flow's job (DbJobAdapter.readArming, adapter-local like setReplayGuard). Only a sys_job row still active counts as a silence: a kernel that stops ticking cancels nothing, while a deliberate unbind cancels. The due times of the schedule that kernel armed, strictly after its last-alive instant and up to now, are computed in a new sibling module, missed-due-times.ts.
  • Record (service-automation). AutomationEngine.recordMissedRun checks each due time against the dispatch ledger (sys_flow_dispatch). A claimed window was dispatched and is skipped. An unclaimed one is written as a missed terminal row with an id derived from (flow, due time), so two replicas write one row. With no persisted ledger, or a ledger read that throws, nothing is recorded and the trigger warns, naming the flow.
  • Bound. At most the run history's own per-flow cap (maxTerminalRunsPerFlow, read through AutomationEngine.missedRunLimit(), no second literal) newest due times per flow per detection. One warn names the flow, the count not recorded and the earliest due time dropped.
  • A refusal is not a silence. A flow refused on purpose (scheduled work off, missing acting organization, unreadable schedule) now has its job cancelled by its deterministic name even when this kernel never bound it (ScheduleTrigger.release, an optional FlowTrigger.release the engine's scheduled-work policy gate calls, since that gate returns before the trigger is reached). Without this, a later kernel would read the refusing kernel's whole life as missed due times.
  • The three run-row vocabulary sites widen together, as the refused precedent did (ADR-0078): TERMINAL_RUN_STATUSES (which the store's row gate reads), sys_automation_run.status options and the retention onlyWhen set. plugin-approvals RUN_STATUS_LIVENESS already carries missed: 'terminal' on main.

Why

Ruling 6353729816 (letter A) on cloud#3149 item 5: record only, no replay. The spec member missed landed in #25363; this is the producer.

Basis for Clause-②: yes (widening), measured on the diff (the claim line said no and asked for the measurement): the diff adds public surface to published packages: AutomationEngine.recordMissedRun and missedRunLimit, the optional FlowTrigger.release, SuspendedRunStore.maxTerminalRunsPerFlow, DbJobAdapter.readArming, ScheduleTrigger.release, and the barrel types MissedScheduledRun, MissedRunOutcome, JobArming, JobArmingReading, MissedScheduledRunInput; and the stored sys_automation_run.status column accepts one more option. Nothing accepted before is refused. Metadata-protocol: none: nothing a metadata author writes changes.

Tests

  • packages/triggers/trigger-schedule/src/missed-run-two-kernels.test.ts (in-process, the test-budget shape): a kernel stopped across one due time records one missed entry and runs nothing; an evicted kernel across three due times records three; a kernel that ticked through records none; a due time whose fire was claimed before the kernel died records none; a flow deliberately unbound before the silence records none; an OFF kernel between two ON kernels records none; a long silence records only the cap's newest and warns; no persisted ledger skips with one warning.
  • missed-due-times.test.ts (cron, interval, once; the cap and the dropped count), service-automation/src/missed-run-record.test.ts (ledger verdicts, deterministic id, the three vocabulary sites, missedRunLimit, the policy gate's release), suspended-run-store.test.ts (a missed row is stored and listed as history), service-job/src/db-job-adapter.test.ts (readArming: absent, every schedule type, fire moves last-alive, cancel disarms, eviction leaves it armed, a failed read rejects).

Acceptance notes

  • The work was resumed from claude/project-thread-rtuv45@3830dd8c71 by cherry-picking its four services commits onto main (the local spec commit and its revert skipped); they applied cleanly. This branch adds a docblock fix (the recordMissedRun docblock sat above missedRunLimit), a note on which way a late arming read can fail, and the barrel export of the two structural types.
  • Ordering of the arming read and the row rewrite: the read is issued before schedule(). If a pooled driver still answered it after the rewrite, the row would read as touched just now and no silence would be found, so the failure direction loses a missed entry and never invents one. Making schedule() wait for the read was tried and dropped: a stop() in between would be overtaken by a late re-arm.
  • Observation, not filed: with two replicas ticking at once, a replica that binds in the few milliseconds between a due time and the ticking replica's ledger claim would record that due time as missed while the other replica runs it. Single-kernel hosted environments, the case cloud#3149 describes, cannot hit it.
  • The run-history reader on the console side: ui/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx maps five statuses and falls back to the raw status string for the rest, as it already does for timed_out and refused; missed renders as the label missed with no duration (none is recorded). No ui/ edit in this PR.
  • Not ruled and flagged by the ruling for the maintainer (item 5 g): whether missed rows should be bounded tighter than the store's per-flow cap so real history survives a long outage. This PR uses the store's cap, as ruled.

Verification

Head 1bff9c7e9f (after merging origin/main at 68901b0383; base of the card work 08ed7e295f).

Red first: the new pins against main's sources

Production sources restored to 08ed7e295f (tree only), this branch's tests, service-automation rebuilt at that base: missed-run-record.test.ts + suspended-run-store.test.ts 11 failed / 57 passed; db-job-adapter.test.ts 4 failed / 20 passed; missed-run-two-kernels.test.ts 5 failed (the 12 passing are missed-due-times.test.ts, a new pure module that exists on both sides). Restored by git checkout HEAD, git diff HEAD empty.

Suites on the final head
  • @objectstack/trigger-schedule 191/191 · @objectstack/service-job 121/121 · @objectstack/service-automation 2719/2719 · @objectstack/plugin-approvals 1290/1290.
  • typecheck for the three touched packages: exit 0. eslint --no-inline-config over their src/: exit 0, no output.
  • test:repo projects for core (83), objectql (5), types (11), spec (1293): all pass.
Ablations, one per pinned behaviour, each landed on disk (anchor 1 to 0) and restored to the HEAD blob
  1. Engine ignores a claimed window: 1 red (a claimed window was dispatched).
  2. missed dropped from TERMINAL_RUN_STATUSES: 4 red (engine, two-replica, vocabulary, store row).
  3. readArming reads every row as active: 1 red (cancel() disarms; eviction leaves the row armed).
  4. ScheduleTrigger.release never cancels an unbound job: 1 red (the OFF kernel between two ON kernels).
  5. Trigger ignores the active bit: 2 red (deliberately unbound flow, OFF kernel).
  6. Detection writes 5 past the cap: 1 red (the long-silence cap case).
  7. missedRunLimit answers a number with no ledger (dist rebuilt, preflight marker present, then absent after restore): 1 red in each of the engine unit and the two-kernel suites.
  8. The policy gate no longer calls release (dist rebuilt, preflight as above): 2 red in the engine unit suite, 1 red in the two-kernel suite.
  9. The missed row's id is no longer per (flow, due time): 2 red.

Three first attempts did not count and were rerun: ablations 4 to 6 first ran against a service-automation dist left at the red-first base (rebuilt, then rerun); ablation 8's first marker was elided by the build (preflight failed), rerun with a marker that survives; ablation 6 first used a bound so large the cases timed out, rerun with cap + 5.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 1bff9c7e9f: 63 commands. All 63 ran on this head and every one exited 0; dispatch-gates --ran reconciles 63 run, 0 NOT MEASURED. A first round at 92f11484f3 had check:dual-build-cjs-loads answer PREREQUISITE NOT MET (exit 3, no full build in the worktree); on this head it ran and passed (112 entry points, 69 packages). Outside the derived list and left to CI: the workspace type-check lanes, Build Core, and the dogfood and temporal batteries that run only on main.


Generated by Claude Code

…while no kernel was ticking as missed (wip)

Claude-Session: https://claude.ai/code/session_01Q4SPCWwEtTZJXVsgRzJgcz
Co-authored-by: Claude <noreply@anthropic.com>
…e job adapter (wip)

Claude-Session: https://claude.ai/code/session_01Q4SPCWwEtTZJXVsgRzJgcz
Co-authored-by: Claude <noreply@anthropic.com>
…; a refusal disarms the job (wip)

Per ruling 6353729816 on #25235:
- per flow per detection, at most the store's maxTerminalRunsPerFlow newest
  due times are recorded; one warn names the flow, the count not recorded
  and the earliest due time dropped (AutomationEngine.missedRunLimit);
- with no persisted dispatch ledger the check is skipped with one warn
  naming the flow;
- a flow refused on purpose (scheduled work off, missing organization,
  unreadable schedule) is released: its job is cancelled by name whether
  or not this process bound it (optional FlowTrigger.release, called by
  the engine's policy gate), so a refusal never reads as a silence;
- the status description drops runtime vocabulary.

Claude-Session: https://claude.ai/code/session_01Q4SPCWwEtTZJXVsgRzJgcz
Co-authored-by: Claude <noreply@anthropic.com>
…cess suite, not a nightly-tier file

The `.e2e.test.ts` suffix put it in the nightly tier, which trigger-schedule's
vitest config does not partition (check:tier-file-adoption). It runs in
milliseconds, so it stays in the package's own suite under a plain name.

Claude-Session: https://claude.ai/code/session_01Q4SPCWwEtTZJXVsgRzJgcz
Co-authored-by: Claude <noreply@anthropic.com>
…lock sits on recordMissedRun; say which way a late arming read can fail

Claude-Session: https://claude.ai/code/session_01FEkQ5ofGbFRago9bvAetNb
Co-authored-by: Claude <noreply@anthropic.com>
…ce and ledger surfaces now name

Claude-Session: https://claude.ai/code/session_01FEkQ5ofGbFRago9bvAetNb
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/service-automation, @objectstack/service-job, @objectstack/trigger-schedule, touching 40 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts, packages/services/service-job/src/index.ts, packages/triggers/trigger-schedule/src/index.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/flows.mdx (via flowName (symbol, a field of interface MissedScheduledRun; a field of interface MissedScheduledRunInput))
  • content/docs/automation/jobs.mdx (via flowName (symbol, a field of interface MissedScheduledRun; a field of interface MissedScheduledRunInput))

⛔ 5 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))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), flowName (symbol, a field of interface MissedScheduledRun; a field of interface MissedScheduledRunInput))
  • content/docs/releases/v17/17-5.mdx (via flowName (symbol, a field of interface MissedScheduledRun; a field of interface MissedScheduledRunInput), timed_out (literal, a string literal in SysAutomationRun; a string literal in TERMINAL_RUN_STATUSES; a string literal in lifecycle))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v18/18-0.mdx (via flowName (symbol, a field of interface MissedScheduledRun; a field of interface MissedScheduledRunInput))

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/services/service-automation/src/index.ts, packages/services/service-job/src/index.ts, packages/triggers/trigger-schedule/src/index.ts) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 d0a7e748a933a7fb549c38c3105c1c49f5935dec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2054eb0a6e884a0824a559f9e2f190c92ddaa023 — the merge of head 1bff9c7e9fc201d2593133e1c14ac19cf185b704 into base d0a7e748a933a7fb549c38c3105c1c49f5935dec, 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 2054eb0a6e884a0824a559f9e2f190c92ddaa023 && git checkout 2054eb0a6e884a0824a559f9e2f190c92ddaa023
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d0a7e748a933a7fb549c38c3105c1c49f5935dec 1bff9c7e9fc201d2593133e1c14ac19cf185b704 && git checkout -B drift-repro d0a7e748a933a7fb549c38c3105c1c49f5935dec && git merge --no-ff 1bff9c7e9fc201d2593133e1c14ac19cf185b704

node scripts/docs-audit/affected-docs.mjs --json d0a7e748a933a7fb549c38c3105c1c49f5935dec

⚠️ 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 d0a7e748a933a7fb549c38c3105c1c49f5935dec → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 1bff9c7e9fc201d2593133e1c14ac19cf185b704
Local-runs: none

Inputs read: card #25235 (body and all 11 comments: claim 6353512245, ruling 6353729816 letter A, hold 6354378101, spec answer 6355021401, unlock 6392088171, claim 6433183891, os-dev-report 6434107183); PR #26485 body, its 15-file list, and the net diff origin/main...1bff9c7e (15 files, +1381/−13; merge base 68901b0383); the head's check-runs at 2026-10-27T05:05Z. Face: record-recognisers --paths over the file list decides no face (no published schema, no governed path), so the changeset's Clause-②: yes (widening) line owes the declaration face, TIER_DEFAULT — the tier this record is served at.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged against the ruling (6353729816, items 2–9) and the cited code at the head:

  1. Stored vocabulary widens by one member. sys_automation_run.status Field.select options gain missed (7 → 8 members), the retention onlyWhen set gains missed, and TERMINAL_RUN_STATUSES gains missed (engine.ts:1671) — the three sites the engine's own docblock says widen together, widened in the same change as the one producer. Right. The value is already the spec's: ExecutionStatus carries 'missed' appended last at packages/spec/src/automation/execution.zod.ts:53 (PR feat(spec): ExecutionStatus gains missed, a scheduled due time that passed while no kernel was ticking #25363, card spec: ExecutionStatus gains missed (a scheduled flow's due time that passed while no kernel was ticking, recorded once, never replayed) — child of #25235, ruling 6353729816 #25325), so the stored column aligns to the protocol rather than widening past it (Prime Directive 12 holds; ADR-0078 condition met). isTerminalRunStatus (engine.ts:1677) reads the list, and the store's row gate (suspended-run-store.ts:145, :841, :860) reads that predicate, so a missed row is written and listed as history with no fourth copy. plugin-approvals RUN_STATUS_LIVENESS already carries missed: 'terminal' on main (approval-service.ts:572) and is typed satisfies Record of ExecutionStatus to RunLiveness — untouched, right. No exact-list pin outside the diff enumerates the old five (grepped the head's tests).
  2. Wire surface widens by construction, not by edit. listRuns(name, { status }) and GET …/runs?status= read the spec enum (automation-api.zod.ts:692, runtime/src/domains/automation.ts:3829 parseEnumParam('status', …, ExecutionStatus.options)); the diff holds no packages/runtime/** or packages/spec/** path. Right (ruling item 9).
  3. @objectstack/service-automation public surface, all additive. AutomationEngine.recordMissedRun(missed) answering a MissedRunOutcome promise and AutomationEngine.missedRunLimit() answering number | null (new public methods); FlowTrigger.release?(flowName) (optional member — an existing implementer compiles unchanged); SuspendedRunStore.maxTerminalRunsPerFlow?: number (optional readonly; the two store classes turn a private readonly field public, same name and value); barrel types MissedScheduledRun, MissedRunOutcome; TerminalRunStatus widens. missedRunId() is exported from engine.ts but the barrel exports by name (index.ts:4, :85–92) and does not re-export it — consistent with the PR body's surface list, which omits it. Right, widening only; nothing accepted before is refused.
  4. @objectstack/service-job: DbJobAdapter.readArming(name) answering a JobArming | null promise (public method, adapter-local like setReplayGuard, ⛔ not on IJobService — as claimed and ruled); barrel type JobArming. A read that throws rejects, never answers "inactive" (pinned). Right.
  5. @objectstack/trigger-schedule: ScheduleTrigger.release(flowName) public; JobServiceSurface.readArming?, ScheduleDispatchLedger.recordMissedRun?, ScheduleDispatchLedger.missedRunLimit? optional structural members; barrel types JobArmingReading, MissedScheduledRunInput; new module missed-due-times.ts (dueTimesInSilence, DROPPED_DUE_TIME_COUNT_CEILING, not barrel-exported). Right, widening only.
  6. The one existing-behaviour change: a refusal on purpose disarms by name. start()'s three refusals (policy off, no schedule descriptor, missing organization) call release() instead of stop(), and the engine's scheduled-work policy gate (engine.ts:3692–3706) calls trigger.release?.() on every pass since that gate returns before start() is reached. release() cancels the job named flow-schedule: plus the flow name through the job service whether or not this process bound it; DbJobAdapter.cancel → setActive(name, false) (db-job-adapter.ts:321, :702). Right — this is exactly ruling item 6's fix, inside the claimed surface, with no sys_job column, platform-objects or spec change; pinned both ways (OFF kernel between two ON kernels: zero rows; evicted ON kernel: one row per due time).
  7. Gap bounds, as ruled (item 5c). Start = firstInstant(updated_at, last_run_at, created_at); at the head both upsertJobRow (:663) and bumpJob (:743) write updated_at: now, so updated_at is the newest instant a kernel touched the job, ≥ last_run_at, = bind time before any fire — the ruling's bound or tighter. End = this kernel's first BIND for the flow (ruling text says first tick; hold 6354378101 records why the due-time set is identical for cron and interval, and dueTimesInSilence is pinned to answer only instants that are the start of their own tick window, the ledger key). Read once per flow per kernel (armingRead), issued synchronously BEFORE schedule() rewrites the row; a late read can only lose a missed entry, never invent one. Right.
  8. Ledger verdict and the no-ledger skip (items 5c–d). A due time is recorded only when the row is active AND store.read(dispatchKey) answers no claim; the key is scheduleDispatchKey(flow, computeTickWindow(armedSchedule, due)), the same function the handler claims with (schedule-trigger.ts:828). missedRunLimit() answers null when flowDispatchStore is null or has no read — the same condition under which dispatch-claims.ts:82 says "IN-PROCESS ONLY" — and the trigger then warns once naming the flow and records nothing; a ledger read that throws answers unverifiable, nothing recorded. ⛔ Never inferred from an absent claim. Right.
  9. One row per (flow, due time) (item 5a). missedRunId = run_missed_ + flow name + _ + due-time epoch ms; the store prefixes run_ again (HISTORY_PREFIX + runId), so the durable id reads run_run_missed_… — deterministic and unique per (flow, due time), the two-replica pin holds one row. Cosmetic double prefix, not a contract point.
  10. Row shape (items 2, 5f). startedAt = due time, completedAt = detection, steps: [], trigger.type = 'schedule', organizationId from the flow's declared organization; durationMs is never set, recordLog (run-history.ts) passes entry.durationMs through, and the store writes duration_ms: record.durationMs ?? null — absent, as the spec TSDoc says; no error, no refusalMessage. Right.
  11. The bound (item 5e). Per flow per detection, the newest maxTerminalRunsPerFlow due times as the store was configured (default 100), read through missedRunLimit(), no second literal for the record bound; oldest dropped first; one warn naming the flow, the count and the earliest dropped due time. Two readings beside the ruling's text, both judged right: (a) when the store declares no cap or 0 (cap disabled), the engine's existing maxLogSize (default 1000, engine.ts:834) is the bound rather than "unbounded" — an existing bound reused, not a new one; (b) DROPPED_DUE_TIME_COUNT_CEILING = 1_000 is a new literal, but it bounds the WALK that counts dropped due times for the warn line (the line then says "at least 1000"), never what is recorded; the earliest dropped instant stays exact. Neither widens or narrows the ruled record bound.
  12. No replay (item 5b). Nothing is dispatched for a missed due time; d.ran stays at its prior count in every two-kernel case; the next due time runs normally. cloud#1288 untouched. Right.
  13. Nothing else in the diff touches packages/spec/**, packages/platform-objects/**, packages/runtime/**, packages/client, a gate or a script (item 9) — verified on the 15-path file list.

② Semver level

Changeset .changeset/25235-missed-scheduled-run.md: @objectstack/trigger-schedule, @objectstack/service-automation, @objectstack/service-job, each patch — one per touched published package, the three packages the diff moves. Clause-②: yes (widening) and Metadata-protocol: none, line-initial, in both the changeset body and the PR body, identical. Matches what the diff publishes: every change above is additive (new methods, optional interface members, barrel types, one more stored option); nothing an author writes changes (no spec key, no accepted value, no metadata type — Metadata-protocol: none is right), and nothing accepted before is refused (no (narrowing) arm, no !, no BREAKING banner, right). Level per AGENTS.md Post-Task Checklist 3: "none takes patch; a non-protocol change, widening or breaking, ships patch" — patch ×3 is the prescribed level (ruling item 4 says the same). Title feat(trigger-schedule): …, no !. Check Changeset on the head: success.

Clause-②: line judged right: the claim 6433183891 declared no and asked for the measurement on the diff; the dev measured yes (widening) and recorded the deviation in the os-dev-report. The diff adds public surface on three published packages and one stored status option a reader can receive and filter on, so yes (widening) is the true reading (the first claim 6353512245 and the ruling read it the same way). That line alone is what owes this declaration-face review.

③ Boundary flags

open_questions: [] — none to answer.

Dev flags (out_of_scope_findings in 6434107183, and the PR's acceptance notes), each answered or escalated:

  1. Two replicas ticking at once — a replica that binds in the gap between a due time and the ticking replica's ledger claim records that due time missed while the other runs it. Read in the code: real, sub-second, bounded by the ledger check that is the ruling's own verdict source; single-kernel hosted environments (cloud#3149's case) cannot hit it. Same shape at one kernel: a due time exactly at the bind second is recorded by recordSilence and may also be fired by the new kernel — the missed row and a completed row for one due time. Answered: not a contract breach, carried in the PR's acceptance notes; nothing to escalate under this ruling. A reader who wants it closed gets a card of its own.
  2. Console reader (ruling item 8). The dev read ui/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx at the head (exists in this repo; STATUS_META maps five statuses, statusMeta() falls back to the raw string with a clock icon), so missed renders as the label missed with no duration — pass-through, as timed_out and refused already are. Answered for ui/. ⛔ Item 8's own instruction is a grep of ../objectui at the pinned .objectui-sha by domain:server seat 1 at ACCEPT; that read is the seat's, not this record's, and a closed map there is one domain:ui card with Blocked-by: objectstack-ai/objectstack#25235. Escalated to the seat at ACCEPT (as the dev's carrier line says).
  3. Ruling item 5(g), maintainer question — whether missed rows take a bound tighter than the store's per-flow cap so real history survives a long outage. This PR uses the store's cap, as ruled; one word sets a smaller bound and missedRunLimit() is the one place to read it. Escalated to the maintainer, not blocking, exactly as the ruling flagged it.

Reviewer's own flags, none blocking:

  1. Release on every policy-OFF pass. With scheduled work off (the default state of every deployment) and a job service present, each arm pass of a scheduled flow now issues one sys_job find (+ an update when a row is active) per flow. setActive returns on no row and the cron adapter's cancel of an unknown job is a no-op, so there is no warn noise; cost is one read per flow per bind pass. Inside the ruled fix; noted.
  2. Heterogeneous policies on a shared sys_job table — a policy-OFF replica now flips a row an ON replica armed to inactive. Nothing in the fire path reads active (only readArming does, at the head), so the ON replica keeps ticking; the next kernel then reads "not armed" and loses a missed entry — the loss direction, never an invention. Noted.
  3. Check-runs on the head at review time (2026-10-27T05:05Z): 28 completed/success (Check Changeset, Governed Surface Queue Guard, both single-writer and same-issue claim guards, "The card this PR closes must claim this branch", Lint · ESLint, Type Check · source/consumer/workspace/debt ledger, Build Core, Lint & Repo Gates, TypeScript Type Check, Test Core 8/8, Dogfood Regression Gate, Plan Test Core shards, filter, Auto Label, Check PR Size, Flag docs affected, Part-of guard, the scoped lint lanes); 11 completed/skipped (UI, docs, dogfood shards, temporal, opt-in harnesses — none claims a path of this diff); 7 still running: Test Core (1/8) through (7/8). No check-run has failed. The gate verdicts that have concluded are PASS; the seven shards still in progress are recorded as such and are the merge queue's to finish — this record does not claim them.
  4. PR is a draft. Ready-for-review is the seat's stroke after the shards land; not a contract point.

Implemented-by: claude/project-thread-qvefhq
Reviewed-by: fable subagent of session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T05:15Z

Head 1bff9c7e9f, report 6434107183 (on #25235), claim 6433183891. Contract review 6434279139 on this head: TIER_DEFAULT, owed by the Clause-②: yes (widening) declaration, PASS.

item reading
Shape Draft, base main, Fixes #25235, three patch changesets (trigger-schedule, service-automation, service-job). 15 files, +1381/−13. Not governed (0 of 15 paths), under the human-merge size threshold.
Fix Ruling 6353729816 letter A, as ordered: each scheduled due time that passed while no kernel was ticking gets one missed run-history entry (due time, detection time). Nothing is replayed. The id comes from (flow, due time). The bound is the store's own maxTerminalRunsPerFlow, read through missedRunLimit() with no second literal. Detection is skipped with a warn when there is no persisted dispatch ledger. Item 6's fix is in: a refusal on purpose cancels the job by its name through release(), so a policy-OFF kernel leaves no false gap.
Resume Commits 432f4dc8be, ddd0a34e33, 819e61a1f0 and 3830dd8c71 were cherry-picked from rtuv45 onto fresh main; the spec commit and its revert were skipped. 0 paths under packages/spec, packages/platform-objects or packages/runtime. The spec member is on main (execution.zod.ts:53, #25363).
Vocabulary The three stored-vocabulary sites widen together with the producer, the refused precedent (#15788): TERMINAL_RUN_STATUSES, the sys_automation_run.status options, and the retention onlyWhen set. plugin-approvals RUN_STATUS_LIVENESS already carries missed on main.
Declarations The dev measured Clause-②: yes (widening) on the diff, against the order's guess of no: new public methods, optional interface members and barrel types on three published packages. This matches the ruling's own item 4, and the contract review agrees. Metadata-protocol: none, so patch.
Evidence CI on head 1bff9c7e9f is green: 36 checks succeeded, 11 were expected skips, and none failed or are pending; mergeable clean. Red first: 20 new pins fail with main's sources. 9 ablations each turned their pins red. Suites on the final head: trigger-schedule 191, service-job 121, service-automation 2719, plugin-approvals 1290. Derived gates: 63 of 63 exit 0, with 0 NOT-MEASURED.
Item 8 (the reader) objectui is now ui/ in this repo, and .objectui-sha no longer exists, so the seat read ui/ on origin/main. FlowRunsPanel.tsx statusMeta() falls back to the raw status string for any member its map lacks, as timed_out and refused already do, so missed shows as its label. FlowRunStatus in the studio and Problems panel is the flow's arming state, not a run status. The map is not closed, so no domain:ui card is owed: this is the acceptance-notes line.
Carried Ruling item 5(g), whether missed rows take a tighter bound than the store's cap, stays flagged for the maintainer and does not block. If one is set, missedRunLimit() is the one place it is read. Review notes, non-blocking: the durable id reads run_run_missed_… (cosmetic), and two replicas binding inside the sub-second claim window can record a due time that the other replica runs.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 05:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 27, 2026 05:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 2282aa8 Oct 27, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qvefhq branch October 27, 2026 05:35
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/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scheduled flows: record a run whose time passed while the hosted kernel was not ticking as missed, visible in the run history (no replay)

2 participants