openclaw - ✅(Solved) Fix Cron: jobs without sessionTarget crash with TypeError in assertSupportedJobSpec (load path bypasses normalize defaulter) [1 pull requests, 2 comments, 3 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#70360Fetched 2026-04-23 07:25:41
View on GitHub
Comments
2
Participants
3
Timeline
3
Reactions
0
Timeline (top)
commented ×2cross-referenced ×1

If a stored cron job in jobs.json is missing sessionTarget, the gateway crashes with:

TypeError: Cannot read properties of undefined (reading 'startsWith')
    at assertSupportedJobSpec (src/cron/service/jobs.ts:156)

The load path in src/cron/store.ts reads jobs verbatim without running the normalize defaulter that exists at src/cron/normalize.ts:527-536 (which would default sessionTarget to "main" for systemEvent payloads or "isolated" otherwise). Every subsequent call to applyJobPatch on that job — including internal state updates like state.runningAtMs on the next scheduled tick — hits assertSupportedJobSpec (src/cron/service/jobs.ts:156) and throws the TypeError shown above, because sessionTarget is still undefined.

Error Message

TypeError: Cannot read properties of undefined (reading 'startsWith') at assertSupportedJobSpec (src/cron/service/jobs.ts:156)

Root Cause

The load path in src/cron/store.ts reads jobs verbatim without running the normalize defaulter that exists at src/cron/normalize.ts:527-536 (which would default sessionTarget to "main" for systemEvent payloads or "isolated" otherwise). Every subsequent call to applyJobPatch on that job — including internal state updates like state.runningAtMs on the next scheduled tick — hits assertSupportedJobSpec (src/cron/service/jobs.ts:156) and throws the TypeError shown above, because sessionTarget is still undefined.

Fix Action

Fix / Workaround

The load path in src/cron/store.ts reads jobs verbatim without running the normalize defaulter that exists at src/cron/normalize.ts:527-536 (which would default sessionTarget to "main" for systemEvent payloads or "isolated" otherwise). Every subsequent call to applyJobPatch on that job — including internal state updates like state.runningAtMs on the next scheduled tick — hits assertSupportedJobSpec (src/cron/service/jobs.ts:156) and throws the TypeError shown above, because sessionTarget is still undefined.

  1. loadCronStore doesn't run normalize. Jobs stored without sessionTarget stay that way in memory.
  2. assertSupportedJobSpec calls .startsWith on sessionTarget without a null-check.
  3. applyJobPatch preserves existing job.sessionTarget (if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;), but doesn't default it when the existing value is missing.
it("recovers from a stored job missing sessionTarget", async () => {
  // write jobs.json with a job that has payload.kind="agentTurn" and no sessionTarget
  const store = await loadCronStore(tmpPath);
  const job = store.jobs[0];
  expect(job.sessionTarget).toBe("isolated"); // defaulted on load
  // and applyJobPatch should not throw
  expect(() => applyJobPatch(job, { state: { runningAtMs: 1 } })).not.toThrow();
});

PR fix notes

PR #70367: fix(cron): default missing sessionTarget on load and guard assertSupportedJobSpec

Description (problem / solution / changelog)

Summary

  • Problem: Cron jobs persisted without sessionTarget crash the gateway on the next tick with TypeError: Cannot read properties of undefined (reading 'startsWith') in assertSupportedJobSpec (src/cron/service/jobs.ts:156).
  • Why it matters: The crash turns a single hand-edited or legacy job into a permanent failure lane: consecutiveErrors climbs forever and external edits to jobs.json to add sessionTarget get overwritten by the next saveCronStore.
  • What changed: In ensureLoaded (src/cron/service/store.ts), mirror the create-time defaulter for persisted jobs missing sessionTarget so systemEvent payloads default to "main" and agentTurn payloads default to "isolated". In assertSupportedJobSpec (src/cron/service/jobs.ts), throw a clear Error instead of crashing with TypeError when sessionTarget is still missing.
  • What did NOT change (scope boundary): No other normalize defaulters run on load. wakeMode, deleteAfterRun, enabled defaulting, name inference, and current-session resolution stay gated behind applyDefaults: true (create path). No doctor/fix or store-rewrite behavior is added.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #70360
  • Related #63402 (different call sites: src/cron/delivery-plan.ts, src/gateway/server-cron.ts; complementary guards)
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: ensureLoaded in src/cron/service/store.ts calls normalizeCronJobInput(raw) without applyDefaults: true, so the sessionTarget defaulter at src/cron/normalize.ts:527-536 does not run on load. Jobs persisted without sessionTarget stay undefined in memory. assertSupportedJobSpec (src/cron/service/jobs.ts:156-160) then calls job.sessionTarget.startsWith("session:") with no null guard.
  • Missing detection / guardrail: no type-level check at the call-site and no load-time default. The existing enabled-backfill block already established this shape at src/cron/service/store.ts:65-69; sessionTarget just needed the same treatment.
  • Contributing context (if known): the defaulter is invoked through normalizeCronJobCreate, which sets applyDefaults: true. Jobs written to jobs.json by older paths (or hand-edited) can reach the load path without sessionTarget.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/cron/service/store.load-missing-session-target.test.ts
  • Scenario the test should lock in: stored job with missing sessionTarget loads with the correct default ("main" for systemEvent, "isolated" for agentTurn) and subsequent assertSupportedJobSpec does not throw. Also: assertSupportedJobSpec throws a clear error when handed a spec with sessionTarget still missing.
  • Why this is the smallest reliable guardrail: it exercises the real ensureLoaded path through createCronServiceState + setupCronServiceSuite, the same seam used by src/cron/service/store.test.ts.
  • Existing test that already covers this (if any): none.
  • If no new test is added, why not: N/A (added).

User-visible / Behavior Changes

None for well-formed jobs. Legacy / externally-edited jobs that were previously crashing every tick now run with a sensible default sessionTarget based on their payload kind.

Diagram (if applicable)

N/A

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation: N/A

Repro + Verification

Environment

  • OS: macOS 15 (Darwin 25.3)
  • Runtime/container: Node 22, pnpm
  • Model/provider: N/A (headless cron path)
  • Integration/channel (if any): cron service
  • Relevant config (redacted): a cron/jobs.json entry with payload.kind="agentTurn" and no sessionTarget field.

Steps

  1. Write a cron/jobs.json entry without sessionTarget, as described in #70360.
  2. Start the gateway.
  3. Observe the TypeError in gateway logs.

Expected

Gateway loads the job and, on tick, runs assertSupportedJobSpec without crashing. Missing sessionTarget resolves to "isolated" for agentTurn and "main" for systemEvent.

Actual (before this PR)

TypeError: Cannot read properties of undefined (reading 'startsWith') at src/cron/service/jobs.ts:156, consecutiveErrors climbs on every tick.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

New test file reproduces the pre-fix crash path via assertSupportedJobSpec with a spec missing sessionTarget (fails on main with TypeError; passes with a clear Error after the fix). Load tests assert the defaulted values.

Local checks before push:

  • pnpm vitest run -c test/vitest/vitest.cron.config.ts: 80 files, 666 tests pass.
  • pnpm tsgo:core, pnpm tsgo:core:test: clean.
  • pnpm lint:core: 0 warnings, 0 errors.
  • pnpm format:check on touched files: clean.

Human Verification (required)

  • Verified scenarios:
    • Stored job with payload.kind="systemEvent" and no sessionTarget loads with sessionTarget="main"; assertSupportedJobSpec does not throw.
    • Stored job with payload.kind="agentTurn" and no sessionTarget loads with sessionTarget="isolated"; assertSupportedJobSpec does not throw.
    • assertSupportedJobSpec handed a spec with missing sessionTarget throws a clear Error instead of TypeError.
    • Full cron test lane (666 tests across 80 files) still passes with these changes.
  • Edge cases checked: applyDefaults: true at load was explored first but was rejected because it also flips deleteAfterRun: true on existing schedule.kind === "at" jobs, breaking timer regression tests. Narrowed to a sessionTarget-only backfill that mirrors the create-time defaulter and does not alter any other field.
  • What I did not verify: whether the defaulting also needs to land in the in-file saveCronStore path so the repaired value persists back to disk. Kept out of scope; doctor --fix is the repair path for persisting the normalized shape (see existing cron: job has invalid persisted sessionTarget; run openclaw doctor --fix to repair log at src/cron/service/store.ts:51-52).

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No
  • If yes, exact upgrade steps: N/A

Risks and Mitigations

  • Risk: A job persisted without sessionTarget also has a payload kind that is neither systemEvent nor agentTurn.
    • Mitigation: the defaulter only runs for those two payload kinds; any other shape leaves sessionTarget unset, and assertSupportedJobSpec now throws a clear error instead of a TypeError, matching the existing cron: job has invalid persisted sessionTarget; run openclaw doctor --fix to repair log-and-skip pattern.

Changed files

  • src/cron/service.test-harness.ts (modified, +1/-0)
  • src/cron/service/jobs.ts (modified, +5/-0)
  • src/cron/service/state.ts (modified, +7/-0)
  • src/cron/service/store.load-missing-session-target.test.ts (added, +115/-0)
  • src/cron/service/store.ts (modified, +37/-0)

Code Example

TypeError: Cannot read properties of undefined (reading 'startsWith')
    at assertSupportedJobSpec (src/cron/service/jobs.ts:156)

---

export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "payload">) {
  if (job.sessionTarget == null) {
    // Mirror normalize.ts:527-536
    job.sessionTarget = job.payload?.kind === "systemEvent" ? "main" : "isolated";
  }
  const isIsolatedLike =
    job.sessionTarget === "isolated" ||
    job.sessionTarget === "current" ||
    job.sessionTarget.startsWith("session:");
  // ...rest unchanged
}

---

it("recovers from a stored job missing sessionTarget", async () => {
  // write jobs.json with a job that has payload.kind="agentTurn" and no sessionTarget
  const store = await loadCronStore(tmpPath);
  const job = store.jobs[0];
  expect(job.sessionTarget).toBe("isolated"); // defaulted on load
  // and applyJobPatch should not throw
  expect(() => applyJobPatch(job, { state: { runningAtMs: 1 } })).not.toThrow();
});
RAW_BUFFERClick to expand / collapse

Cron jobs with missing sessionTarget crash with TypeError: undefined.startsWith — load path bypasses normalize

Summary

If a stored cron job in jobs.json is missing sessionTarget, the gateway crashes with:

TypeError: Cannot read properties of undefined (reading 'startsWith')
    at assertSupportedJobSpec (src/cron/service/jobs.ts:156)

The load path in src/cron/store.ts reads jobs verbatim without running the normalize defaulter that exists at src/cron/normalize.ts:527-536 (which would default sessionTarget to "main" for systemEvent payloads or "isolated" otherwise). Every subsequent call to applyJobPatch on that job — including internal state updates like state.runningAtMs on the next scheduled tick — hits assertSupportedJobSpec (src/cron/service/jobs.ts:156) and throws the TypeError shown above, because sessionTarget is still undefined.

Reproduction

  1. Create a cron/jobs.json with a job that has payload.kind: "agentTurn" but no sessionTarget field.
  2. Start the gateway.
  3. Wait for the job to tick (or trigger any state update on the job).
  4. Observe the TypeError in the gateway log. consecutiveErrors will climb forever.

In my case this bit three jobs that had been hand-edited by a downstream consumer to set sessionTarget. The gateway persisted them via saveCronStore from an in-memory representation that never had the field, silently overwriting the hand-edit. Result: the field was un-settable from outside the daemon, and the jobs were permanently broken.

Environment

  • Package: [email protected] (installed via npm global on macOS)
  • Crash site: src/cron/service/jobs.ts:156 (upstream main as of 2026-04-22)
  • Load path: src/cron/store.ts:loadCronStore — no normalize call observed
  • Writer: src/cron/store.ts:stripRuntimeOnlyCronFields — passthrough via spread, correctly preserves unknown keys. Not the cause.

Root cause (three layers)

  1. loadCronStore doesn't run normalize. Jobs stored without sessionTarget stay that way in memory.
  2. assertSupportedJobSpec calls .startsWith on sessionTarget without a null-check.
  3. applyJobPatch preserves existing job.sessionTarget (if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;), but doesn't default it when the existing value is missing.

Proposed fix (two parts)

Primary — route the load path through normalize

In loadCronStore (or a small helper it calls), apply the same defaulter that normalize.ts:527-536 uses to incoming job input, so legacy stored jobs missing sessionTarget get defaulted to "main"/"isolated" based on payload.kind before they ever reach assertSupportedJobSpec.

Defensive guard — null-check in assertSupportedJobSpec

Independent of the primary fix, swap the implicit undefined.startsWith crash for a diagnosable error (or apply the same default inline):

export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "payload">) {
  if (job.sessionTarget == null) {
    // Mirror normalize.ts:527-536
    job.sessionTarget = job.payload?.kind === "systemEvent" ? "main" : "isolated";
  }
  const isIsolatedLike =
    job.sessionTarget === "isolated" ||
    job.sessionTarget === "current" ||
    job.sessionTarget.startsWith("session:");
  // ...rest unchanged
}

This keeps the function from being the failure point on any future regression where a caller bypasses normalize.

Regression test

it("recovers from a stored job missing sessionTarget", async () => {
  // write jobs.json with a job that has payload.kind="agentTurn" and no sessionTarget
  const store = await loadCronStore(tmpPath);
  const job = store.jobs[0];
  expect(job.sessionTarget).toBe("isolated"); // defaulted on load
  // and applyJobPatch should not throw
  expect(() => applyJobPatch(job, { state: { runningAtMs: 1 } })).not.toThrow();
});

Local workaround applied downstream

For anyone hitting this before the fix lands, the workaround I used in a downstream deployment:

  1. Stop the gateway (launchctl unload … on macOS, equivalent elsewhere — KeepAlive=true launchd configs will otherwise restart it before your edit takes effect).
  2. Edit cron/jobs.json to add a valid sessionTarget on every affected job. Valid values per src/cron/types.ts:17 are "main" | "isolated" | "current" | \session:${string}`; session-scoped values like session:my-sessionare the right shape foragentTurn` payloads.
  3. Restart the gateway. The correct sessionTarget now survives the load → applyJobPatch → write cycle because the in-memory representation finally has the field.

Durable only as long as no other code path drops the field — which is why the primary fix in the load path is the right fix.

Context

Found via issue Dougiefrsh/Openclaw-MacDaddy#2, which has the full investigation trace.

Happy to send a PR if a maintainer can confirm the preferred shape of the fix (normalize in loadCronStore vs. a defaulter inside assertSupportedJobSpec vs. both).

extent analysis

TL;DR

Apply a defaulter to sessionTarget in the loadCronStore function to ensure jobs missing this field are properly defaulted before being processed.

Guidance

  • Modify the loadCronStore function to apply the defaulter from normalize.ts:527-536 to incoming job input, defaulting sessionTarget to "main" or "isolated" based on payload.kind.
  • Add a null-check in assertSupportedJobSpec to prevent crashes when sessionTarget is missing, and consider applying the same default inline.
  • Verify the fix by running the regression test provided, which checks that a stored job missing sessionTarget is properly defaulted on load and does not throw an error when applyJobPatch is called.
  • As a temporary workaround, manually edit cron/jobs.json to add a valid sessionTarget to affected jobs and restart the gateway.

Example

// In loadCronStore
const job = // load job from json
if (!job.sessionTarget) {
  job.sessionTarget = job.payload.kind === "systemEvent" ? "main" : "isolated";
}

Notes

The proposed fix has two parts: applying a defaulter in loadCronStore and adding a null-check in assertSupportedJobSpec. Both parts are necessary to ensure that jobs missing sessionTarget are properly handled.

Recommendation

Apply the primary fix by routing the load path through normalize to default sessionTarget for legacy stored jobs, as this addresses the root cause of the issue and prevents future regressions.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix Cron: jobs without sessionTarget crash with TypeError in assertSupportedJobSpec (load path bypasses normalize defaulter) [1 pull requests, 2 comments, 3 participants]