[spark-compete] fix(scheduler): scheduler _tick can relaunch the same record while its previous fire is still in flight - #858
Conversation
… record while its previous fire is still in flight
TL;DRA scheduled mission whose What I noticedWas reading the spawner scheduler trying to figure out why the same The bugfile:
The fixAdd a module-level Reproduction
VerificationReviewer can confirm in <60s: The behavioral check: read Sister precedentPR #201 in this repo ( |
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>
{
"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.tsornpx 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"
}
}