Skip to content

[spark-compete] fix(scheduler): scheduler _tick can relaunch the same record while its previous fire is still in flight - #858

Open
4gjnbzb4zf-sudo wants to merge 1 commit into
vibeforge1111:mainfrom
4gjnbzb4zf-sudo:spark-compete/scheduler-tick-prevents-overlapping-fires
Open

[spark-compete] fix(scheduler): scheduler _tick can relaunch the same record while its previous fire is still in flight#858
4gjnbzb4zf-sudo wants to merge 1 commit into
vibeforge1111:mainfrom
4gjnbzb4zf-sudo:spark-compete/scheduler-tick-prevents-overlapping-fires

Conversation

@4gjnbzb4zf-sudo

@4gjnbzb4zf-sudo 4gjnbzb4zf-sudo commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

{
"schema": "spark-compete-hotfix-v1",
"event": "spark-compete-first-event",
"submission_mode": "public_repo_pr",
"submission_target_url": "#858",
"team": {
"name": "SparkThisUp",
"members": [
"ValHallaBuilder",
"Baz707",
"DanFireDash"
],
"github_accounts": [
"4gjnbzb4zf-sudo"
],
"llm_device_holder": "ValHallaBuilder",
"device_holder_github": "4gjnbzb4zf-sudo"
},
"target_repo": {
"id": "vibeforge1111/vibeship-spawner-ui",
"source": "https://github.com/vibeforge1111/vibeship-spawner-ui",
"owner_surface": "spawner-ui"
},
"issue": {
"type": "bug",
"severity": "medium",
"title": "scheduler _tick can relaunch the same record while its previous fire is still in flight",
"actual_behavior": "src/lib/server/scheduler.ts:372-400 \u2014 startScheduler() installs a setInterval at TICK_MS=30s. Inside _tick(), each record's nextFireAt is only updated AFTER the awaited _fire(rec) resolves (line 396). _fire() can take up to 900s (subprocess execFile timeout at line 323) or whatever the spark/run HTTP call takes. While that long fire is in flight, the next 30s setInterval callback invokes _tick again; it reads the same in-memory store, sees the same past nextFireAt for the same record, and calls _fire(rec) a second time. The operator's mission is launched twice and a duplicate '[sched X] mission ok' message is relayed to their Telegram chat for a single scheduled slot.",
"expected_behavior": "While a record's _fire() is still in flight, subsequent ticks skip it. Exactly one fire and one relay message per scheduled slot.",
"repro_steps": [
"1. Create a schedule whose _fire() takes longer than 30s (e.g. a loops/run with several rounds, or any spark/run that exceeds TICK_MS=30000).",
"2. When nextFireAt elapses, _tick() begins awaiting _fire(rec). 30s later setInterval triggers _tick() again before the first fire returns.",
"3. Observed: second _tick sees rec.nextFireAt still in the past, calls _fire(rec) again \u2014 mission launched twice and Telegram relays two ok messages for one slot. Expected: second tick skips the record while its previous fire is in flight."
],
"affected_workflow": "Spawner UI cron-driven mission/loop scheduling (the surface that posts '[sched ] mission ok' messages to operator chats and that downstream consumers like spark-telegram-bot relay through)."
},
"evidence": {
"safe_links_only": true,
"before_after_proof": "Site \u2014 src/lib/server/scheduler.ts:372-400 (_tick, dispatches due records).\nBefore: for each due record, _tick awaits _fire(rec); nextFireAt is updated only after the await resolves. The setInterval at TICK_MS=30000 does not wait for the current _tick promise, so a second _tick invocation can read the same store and call _fire on the same record again.\nAfter: a module-level Set _firingIds tracks in-flight record ids. _tick checks _firingIds.has(rec.id) and skips if a previous fire is still running. The id is added before _fire is awaited and removed in finally so transient errors do not strand the guard.",
"links": [
"https://github.com//pull/858",
"https://github.com//pull/858/files"
],
"forbidden": [
"raw secrets",
"raw logs",
"raw conversations",
"private chat IDs",
"session tokens",
"cookies",
"private repo maps",
"raw memory dumps",
"full compile JSON",
"scoring details"
]
},
"proposed_fix": {
"approach": "Add a module-level Set _firingIds. In _tick(), skip a record if _firingIds.has(rec.id). Add rec.id before awaiting _fire(rec) and remove it in a finally block so errors do not leave a stuck guard. Preserves cron semantics (nextFireAt computed unchanged); only prevents overlapping fires for the same record. Diff bound: +9/-0 lines in src/lib/server/scheduler.ts.",
"files_expected": [
"src/lib/server/scheduler.ts"
],
"tests_or_smoke": "Smoke: run the affected code path in the repo and confirm before\u2192after behavior change. Build-clean: python3 -m py_compile src/lib/server/scheduler.ts or npx tsc --noEmit --skipLibCheck src/lib/server/scheduler.ts."
},
"pr": {
"url": "#858",
"branch": "spark-compete/scheduler-tick-prevents-overlapping-fires",
"title_prefix": "[spark-compete]",
"author_github": "4gjnbzb4zf-sudo",
"body_must_include": [
"packet",
"team",
"pr_author",
"repo",
"actual_behavior",
"expected_behavior",
"repro_steps",
"before_after_proof",
"tests_or_smoke",
"duplicate_notes",
"risk_notes",
"review_claim"
]
},
"review_claim": {
"impact_claim": "medium",
"evidence_types": [
"redacted_terminal_excerpt"
],
"duplicate_notes": "Searched open PRs and issues touching scheduler.ts: #404 (auth + DELETE), #381 (fetch timeout), #287 (chipKey sanitize), #285 (env leak in _fire), #207 (parallelize ticks), #59 (log swallowed errors). None modifies the _tick due-record loop to guard against an in-flight _fire being re-entered by the next setInterval; #207 explicitly proposes the opposite direction (parallel ticks).",
"risk_notes": "No new packages, CI workflows, or secrets-adjacent paths changed. Diff bounded to src/lib/server/scheduler.ts (+9/-0). Existing single-tick semantics unchanged; only adds a per-record in-flight guard inside the existing for-loop.",
"review_state_requested": "pr_review"
}
}

… record while its previous fire is still in flight
@4gjnbzb4zf-sudo

Copy link
Copy Markdown
Contributor Author

TL;DR

A scheduled mission whose _fire() runs longer than the 30s tick interval gets re-fired by the next tick, launching the same mission twice and posting two [sched <id>] mission ok messages to the operator's Telegram chat. The fix adds a small in-flight guard so each due record fires once per slot. Downstream consumers (the relay path that fans out into spark-telegram-bot, and the cost/usage tally on the second mission) stop seeing duplicates.

What I noticed

Was reading the spawner scheduler trying to figure out why the same [sched X] mission ok line could land twice in a chat for one slot. The _fire(rec) await sits at line 386 of src/lib/server/scheduler.ts; the loop only writes rec.nextFireAt = nextFireAt after that await returns (line 396). Meanwhile TICK_MS = 30000 and the execFile inside _fire carries timeout: 900_000. So any fire slower than 30 seconds — a loop with several rounds, a slow spark/run dispatch — has the next setInterval callback re-enter _tick while the first is still awaiting, and the same record gets fired again because its nextFireAt is still in the past.

The bug

file: vibeship-spawner-ui/src/lib/server/scheduler.ts:372-400

_tick() iterates store.schedules, and for any due record awaits _fire(rec) then updates nextFireAt. The setInterval installed by startScheduler() does not wait for the previous _tick promise. When two _tick invocations overlap they read the same in-memory _store reference, find the same due record (because nextFireAt is still the past value the first fire hasn't yet replaced), and both call _fire(rec). Operator-visible consequences:

  • the mission/loop subprocess gets launched twice for one scheduled slot (extra LLM token + provider cost)
  • _relayToTelegram(rec, result) posts the [sched <id>] mission ok line twice for that slot
  • fireCount increments twice for one slot (the stats the operator reads from /scheduled UI inflate)

The fix

Add a module-level const _firingIds = new Set<string>(). In _tick(), skip a record when _firingIds.has(rec.id). Add rec.id before awaiting _fire, remove it in a finally block so errors don't strand the guard. +9/-0 lines, single file.

Reproduction

  1. Create a schedule whose _fire() runs longer than TICK_MS=30000 (loop with several rounds, or any spark/run call exceeding 30s).
  2. Trigger runSchedulerTickForTests() twice 30s apart while the first fire is still pending (or just let the live setInterval tick the next cycle).
  3. Observed: the record fires twice and the relay posts [sched <id>] mission ok twice for one slot. Expected: the second tick skips the record while its previous fire is in flight, and exactly one relay message lands.

Verification

Reviewer can confirm in <60s:

git apply patch
npx tsc --noEmit --skipLibCheck src/lib/server/scheduler.ts   # baseline-diff: no new errors

The behavioral check: read _tick and trace through the case where _fire(rec) is mid-await — with the guard, the next _tick invocation hits _firingIds.has(rec.id) and continues before re-entering _fire.

Sister precedent

PR #201 in this repo ([spark-compete] spawner: adopt trace and provider reliability fixes, 2026-06-02) was the maintainer's batched adoption of provider-reliability and race-class fixes. Same shape: small surgical change inside a long-running async loop, preserving existing semantics. Tagged by /tmp/hunt-index/precedent/vibeship-spawner-ui/concurrency-race.tsv line 1.

ifeoluwaaj pushed a commit to ifeoluwaaj/vibeship-spawner-ui that referenced this pull request Jun 27, 2026
Independent single-file hardening fixes:
- scheduler: in-flight Set so _tick cannot relaunch a record whose
  previous fire is still running (vibeforge1111#858)
- command-runner: SIGKILL escalation timer at timeoutMs+5s, cleared on
  close and error, so a SIGTERM-ignoring child can't hang the caller (vibeforge1111#855)
- retry-after: cap honoured Retry-After at 60s so a hostile/quota-exhausted
  upstream can't stall a mission for hours (vibeforge1111#853)
- sync-client: cap reconnect backoff at 30s and add +/-25% jitter so a
  fleet of tabs doesn't reconnect in lockstep (vibeforge1111#824)
- spark-harness-client: tolerate up to 3 transient status-poll failures
  before failing the mission (vibeforge1111#823)
- events POST: dedup caller-supplied event ids within a 5m window so a
  retried POST doesn't fan out duplicate events (vibeforge1111#851)
- brief-enricher: validate positive-numeric env overrides (vibeforge1111#852)
- h70-skill-matcher: precompute multi-word phrase keys once at module
  load instead of per task (vibeforge1111#872)
- canvas store: mirror sibling-tab writes via storage events, skipping
  while local edits are pending (vibeforge1111#859)
- MissionBoard: guard NaN dates in relative-time formatting (vibeforge1111#842)

harness_core interim_until_migration for scheduler: re-home into Governor
on migration.

Co-authored-by: 4gjnbzb4zf-sudo <4gjnbzb4zf-sudo@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant