Skip to content

spec: optional "ran, but the work did not happen" report on JobHandler (#6617) - #7050

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6617-jobhandler-degraded-outcome
Aug 9, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-6617-jobhandler-degraded-outcome

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6617

The spec half of the maintainer's B-minimal ruling on #5548, split contract-first: packages/spec is the spec seat's lane, and the services half (#5548 — the DbJobAdapter mapping, the sys_job_run.status enum, bumpJob) is not implemented here.

The problem

JobHandler had exactly two states — it threw, or it did not — so a run that completed without accomplishing anything was recorded as success, indistinguishable from one that did the work. The motivating case is #5529's wait-wake handler: when its store is unavailable it fires the shot at nothing, returns normally, and sys_job_run says the wake succeeded.

The change

export interface JobRunOutcome {
  outcome: 'completed' | 'degraded';
  reason?: string;
}

export type JobHandler =
  (context: { jobId: string; data?: unknown }) => Promise< void | JobRunOutcome >;

Three states, of which the third is optional: throw means failed (and retries), resolve nothing means success exactly as today, resolve a degraded outcome means the run finished but its work did not happen.

⚠️ degraded is not a failure and does not trigger a retry. Failure and retry remain driven exclusively by a rejected promise, so a resolved outcome never re-runs the job and never surfaces as an error. That separation is the point of the ruling — option A (make these handlers throw) was rejected precisely because it would change failure semantics that third-party IJobService implementations already build retry behaviour on.

Why the return-value shape

The API shape was delegated to the spec seat and ruled as a return-value union rather than a ctx.reportOutcome callback: the handler context is an inline anonymous object that each IJobService implementation constructs itself, so a context callback would force every third-party implementation to grow a new member — violating the ruling's additivity clause on the implementer side. A return-value union is additive on both sides.

The ruling, verbatim and untranslated:

B-minimal 采纳:给 handler 一个可选、可加的「跑完但没干成」回报方式;sys_job_run 得以表达第三态。

硬约束(裁决的可加性条款):existing Promise handler 逐字节不动、语义不变;不回报 = 现状(没抛错 ⇒ success)。

The issue's falsification clause required grepping for a contrary outcome-reporting precedent on a sibling handler contract, and stopping if one existed. None exists — report(Outcome|Progress|Result|Status|Degraded) has zero hits across packages/. The two nearest precedents both point the same way as the ruling:

  • packages/spec/src/automation/node-executor.zod.ts:346 — the sibling automation handler contract reports its out-of-band result by returning it: "an executor suspends by RETURNING suspend: true from execute()".
  • packages/spec/src/contracts/metadata-service.ts:292 — degraded reporting in the same contracts/ directory, also by return value: Promise< { data; degraded: boolean; errors } >.

Naming precedent for the vocabulary is integration/connector-descriptor.ts:104 (degradedReason); the ruling's own { outcome, reason } field names are kept, since the object is entirely about the outcome and self-describes.

Additivity — the acceptance criterion, pinned in both directions

Pin Evidence
Existing Promise< void > handlers unchanged The pre-change type is declared standalone in the test and asserted assignable to the new JobHandler
A degraded-reporting handler is type-legal Compile-time pin; the one that must go red under a narrowed union
Existing implementations unchanged An adapter that ignores the resolved value is pinned to behave identically
Public surface api-surface delta is exactly + JobRunOutcome (interface) — 0 breaking, 1 added
Downstream consumers pnpm --workspace-concurrency=2 --filter '...@objectstack/spec' typecheck — prefix direction, i.e. spec plus its dependents (74 of 78 projects), all green. Zero-change compilation of every existing consumer is the empirical additivity proof (#6218).

sys_job_run's status enum is untouched; extending it is #5548's half.

One consumer did need widening — reported rather than papered over

The consumption-radius sweep turned up a genuine exception to the "no consumer changes" story, and it is worth stating plainly: runWithPolicy in @objectstack/service-job typed its run as () => Promise< void >, which rejects a handler that may resolve an outcome. TypeScript's return-type void special case does not reach through Promise< void >, so the cron and interval adapters failed to build:

src/interval-job-adapter.ts(157,46): error TS2322:
  Type 'Promise< void | JobRunOutcome >' is not assignable to type 'Promise< void >'.
src/cron-job-adapter.ts(158,46): error TS2322: (same)

Fixed at the wrapper rather than the call sites: runWithPolicy / withTimeout are now generic with T = void, so every existing caller still infers void and behaviour is unchanged — what changed is that the retry wrapper no longer erases what the run resolved to. Erasing it at the call sites instead would have put a value-discarding shim at exactly the seam #5548 needs to read, which is the consumer-side accommodation Prime Directive #12 rules out. Retry semantics are untouched, and there is a test pinning that a degraded report survives the wrapper and does not retry.

Reverse verification

Predicted before running: narrowing the union back to Promise< void > should turn the degraded pins red while every legacy pin stays green — the asymmetry is what distinguishes additive widening from replacement.

The first attempt aimed at the wrong program and is worth recording: tsc --noEmit reads packages/spec/tsconfig.json, which excludes **/*.test.ts, so it reported no errors under the narrowed union — the pins live in the test layer, which is checked by check:test-typecheck against the sibling tsconfig.test.json. Re-aimed at that gate, the prediction held exactly:

--- narrowed to Promise< void > ---
• src/contracts/job-service.test.ts: 9 type error(s) in a file the ledger does not cover.
--- restored ---
check:test-typecheck: OK

job-service.test.ts is not in test-typecheck-debt.json, so it must carry zero type errors — which is what makes these pins live rather than phantom.

Verification

67 gates enumerated one by one from .github/workflows/lint.yml (both jobs), plus check:adr-0087-registration --base origin/main (exit 0 — an additive minor needs no disposition). All green. check:generated reports every artifact current; the only regenerated artifact is the api-surface shard above.


Generated by Claude Code

claude added 3 commits August 9, 2026 09:24
JobHandler had two states — threw or did not — so a run that completed
without accomplishing its work was recorded as success. Widen the return
type to Promise<void | JobRunOutcome> so a handler can OPTIONALLY report
"ran to completion, work did not happen".

Additive on both sides: existing Promise<void> handlers are unchanged
byte for byte, and existing IJobService implementations are unchanged
because this widens a return type rather than adding a context member.
degraded is not a failure and does not retry — retry stays throw-driven.

Spec half of #5548's B-minimal ruling; the DbJobAdapter/sys_job_run
mapping is #5548's half and is deliberately not wired here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
The widened JobHandler may resolve a JobRunOutcome, and a wrapper typed
`() => Promise<void>` rejects that — TypeScript's return-type void
special case does not reach through Promise<void>, so both the cron and
interval adapters failed to build.

runWithPolicy/withTimeout are now generic with T = void: every existing
caller still infers void and behaviour is unchanged, but the wrapper no
longer erases what the run resolved to. Retry stays throw-driven, so a
resolved degraded report returns on the first attempt.

Also commits the regenerated api-surface shard (+ JobRunOutcome, the
only delta: 0 breaking, 1 added).

The sys_job_run mapping that CONSUMES the outcome remains #5548's half.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
)

Prime Directive #4 — @objectstack/spec subpaths, matching the sibling
adapters; JobHandler/JobRunOutcome are not on the package root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 11:50am

Request Review

@github-actions github-actions Bot added the size/m label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-job, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-job, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/service-job, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

⛔ 7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/service-job, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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/m tests tooling

Projects

None yet

2 participants