Skip to content

feat(forecast): trigger-simulation endpoint + runId filter (#3734 C1+C2)#3811

Merged
koala73 merged 6 commits into
mainfrom
fix/3734-trigger-simulation
May 18, 2026
Merged

feat(forecast): trigger-simulation endpoint + runId filter (#3734 C1+C2)#3811
koala73 merged 6 commits into
mainfrom
fix/3734-trigger-simulation

Conversation

@koala73

@koala73 koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #3734 at C1+C2 scope per the v3 plan (tracked as #3798, file at docs/plans/2026-05-18-003-feat-simulation-trigger-and-runid-filter-plan.md).

  • New: POST /api/forecast/v1/trigger-simulation — PRO-gated mutation that enqueues a simulation task for the current SIMULATION_PACKAGE_LATEST_KEY pointer. Mirrors run-scenario.ts shape (Pro gate + per-IP rate-limit 10/60s + queue-depth 429 + ApiError for non-200). Returns { queued, runId, pkgFingerprint, reason }.
  • Modified: GET /api/forecast/v1/get-simulation-outcome?runId=X — the runId filter is now actually active (TODO Expand startup ecosystem coverage with VC blogs, regional news, and unicorn tracking #27 was previously docs-only). Five distinct paths: by-run hit, tombstone-aware fallback, processing=true for in-queue runIds, :latest fallback with expiry note, no-runId existing behavior.
  • Worker: writes both :latest (canonical, 60d) AND :by-run:{runId} (24h secondary index) with tombstone-on-failure + Sentry counter. Adds opaque pkgFingerprint to task payload so worker can detect cron-rotation between handler-time and drain-time.
  • .mjs seeder enqueueSimulationTask backports redis_error reason code so the auto-trigger path can surface transport failures cleanly (previously swallowed as 'duplicate').

What the audit-review process surfaced (and v3 fixed before shipping)

The v1 plan went through two rounds of six-persona document review (50+ findings across coherence, feasibility, product-lens, security, scope-guardian, adversarial). v3 incorporates the consolidated findings. Notable design corrections vs the audit's literal suggestion:

Audit suggested What v3 ships Why
Mirror deduct-situation auth pattern Mirror run-scenario.ts instead run-scenario is the actual closest precedent — Pro-gated mutation, jobId, queue-depth, ApiError. deduct-situation soft-degrades, doesn't 403.
Accept caller-supplied runId Server-derive from package pointer Caller-supplied is a footgun (lock collision, queue stuffing).
Return raw R2 pkgKey for drift detection Opaque pkgFingerprint (sha256:16) Raw path leaks bucket layout.
New per-Pro-identity rate-limit primitive Use existing per-IP ENDPOINT_RATE_POLICIES run-scenario runs same shape on per-IP without abuse; new primitive was premature abstraction. checkScopedRateLimit exists as migration path.
Cache-Control immutable on by-run hits No new cache logic (gateway tier honored) Gateway overwrites handler-set headers; immutable contradicts worker overwrite-on-rerun.
Distinct already-queued vs already-completed-this-cycle external codes Collapse to single already-handled Distinct codes leak cron timing to polling Pro callers. Server logs retain the distinction.

Files changed (16, +1860 / -50)

  • New: proto/worldmonitor/forecast/v1/trigger_simulation.proto, server/_shared/_simulation-queue-constants.mjs + .d.mts, server/_shared/simulation-queue.ts, server/worldmonitor/forecast/v1/trigger-simulation.ts, 4 test files
  • Modified: forecast handler.ts, service.proto, get_simulation_outcome.proto (+ regenerated client/server/OpenAPI), get-simulation-outcome.ts, seed-forecasts.mjs (enqueueSimulationTask + worker + writeSimulationOutcome), rate-limit.ts, premium-paths.ts, mcp-api-parity.test.mjs (+1 exclusion), server-handlers.test.mjs (note-text update)
  • TODO transitions: Optimize map rendering layers #21 pending → complete; Expand startup ecosystem coverage with VC blogs, regional news, and unicorn tracking #27 work log notes filter is now actually active

Test plan

  • 26 new tests: simulation-queue-parity (7), forecast-trigger-simulation (9), forecast-get-simulation-outcome (6), simulation-outcome-by-run-write (4) — all green
  • Full project test suite: 9222/9222 pass
  • npm run typecheck clean
  • npm run typecheck:api clean (.d.mts rename was required — TS resolves .mjs imports to .d.mts)
  • Sentry-coverage gate clean
  • Proto regeneration clean (make generate)
  • Pro-bundle / version-sync gates clean
  • Reviewer: spot-check the v3 plan's risk acknowledgements vs the implementation (especially around the pkgFingerprint tri-state predicate, tombstone semantics, and the already-handled external-code collapse)
  • Post-merge: instrument the 30-day demand experiment via the success-path console.log ('queued runId=X authKind=Y') — if fewer than 1 call/week from non-team identities, deprecate C1 + C2 together per the Scope Boundaries coupling note

Follow-ups filed

Out of scope (deferred to C3)

  • queueDepth + estimatedWaitMs in trigger response
  • Dedicated GetSimulationRunStatus RPC
  • MCP tool wrappers — explicit position: NOT MCP-exposed by default. C3 requires a separate threat + cost model (MCP is the highest-blast-radius surface — any Pro Claude Desktop user with the connector). Locked in via the mcp-api-parity.test.mjs exclusion.

Closes #3734. Implements the C1+C2 scope of v3 plan
(docs/plans/2026-05-18-003-feat-simulation-trigger-and-runid-filter-plan.md;
also tracked as #3798):

  - POST /api/forecast/v1/trigger-simulation — PRO-gated, mirrors run-scenario
    shape (isCallerPremium → queue-depth → server-derived runId → idempotency
    pre-check → enqueue). Returns { queued, runId, pkgFingerprint, reason }.
    External reason taxonomy collapses to {'', 'no_package', 'already-handled'}
    — server logs retain the distinction between already-queued vs
    already-completed-this-cycle to avoid a cron-timing oracle (Sec4).

  - GET /api/forecast/v1/get-simulation-outcome?runId=X — filter now actually
    fires. New behavior: by-run hit → return outcome; tombstone hit → fall
    through to :latest with distinct note; queued-but-not-completed → return
    processing=true (uses listQueuedSimulationTasks; prevents queued-vs-expired
    indistinguishability from contaminating the 30-day demand signal); miss →
    fall through with expiry note.

  - Worker (writeSimulationOutcome) writes BOTH :latest (canonical, 60d TTL)
    AND :by-run:{runId} (secondary index, 24h TTL). By-run failure logs +
    attempts tombstone payload so reads can distinguish transient failure
    from true expiry (D9).

  - pkgFingerprint = sha256(pkgKey).slice(0,16) opaque token. Replaces what
    v2 had as raw R2 path (raw path leaked bucket layout to Pro callers per
    Sec7). Worker verifies task.pkgFingerprint vs current pointer fingerprint
    with truthy-guard predicate to avoid spurious package_rotated logs on
    pre-upgrade in-flight tasks (Feas-F4).

  - .mjs seeder enqueueSimulationTask backports redis_error reason code so
    the auto-trigger path can surface transport failures cleanly (Adv-F4 /
    SG-2). No documented asymmetry between TS and .mjs paths.

  - Shared shim at server/_shared/_simulation-queue-constants.mjs (with .d.ts)
    is single source of truth for queue key constants + 24h TTL +
    pkgFingerprint helper + MAX_QUEUE_DEPTH. Both the seeder and the new TS
    handler module import natively from it.

  - PREMIUM_RPC_PATHS entry added so the gateway's Pro-bearer gate fires
    before per-IP rate-limit (matches run-scenario precedent).

  - ENDPOINT_RATE_POLICIES entry: { limit: 10, window: '60 s' } per-IP,
    identical to run-scenario.

  - Proto: TriggerSimulationRequest carries an optional client_version field
    (sebuf v0.11.1 emits a typecheck-broken POST client for fully-empty
    request messages; one field works around it). Server logs but never
    branches on it.

  - Plan + tracking metadata: TODO #21 transitioned from pending → complete;
    TODO #27 work log updated noting the filter is now actually active
    (the original 2026-03-24 "complete" was docs-only).

Tests (26 total, all passing):
  - tests/simulation-queue-parity.test.mts (7) — TS ↔ .mjs parity for all
    reason codes (no documented asymmetry); structural lock on the worker's
    truthy-guard predicate
  - tests/forecast-trigger-simulation.test.mts (9) — handler test for 403 /
    429 / no_package / already-handled (both states collapse to single
    external code) / happy path with opaque fingerprint / 503 transport on
    pointer read AND enqueue / regression test that internal codes never
    leak externally
  - tests/forecast-get-simulation-outcome.test.mts (6) — all 5 read paths
    + NOT_FOUND
  - tests/simulation-outcome-by-run-write.test.mjs (4) — structural locks on
    :latest canonicality, :by-run TTL + no-NX flag, tombstone-on-failure
    pattern, return shape

Follow-up: #3809 filed for the audit-reframe docs/marketing work (surface
"simulations auto-run hourly via cron" in OpenAPI / Pro marketing / future
MCP tool descriptions). Closing #3734 in code without that docs change leaves
the audit's framing defensible to future reviewers.
@vercel

vercel Bot commented May 18, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 18, 2026 2:37pm

Request Review

@mintlify

mintlify Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview May 18, 2026, 12:16 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

…un-id

Inline operator notes on the script that runs the simulation worker:
  - The `--run-id=X` format must match `/^\d{13,}-[a-z0-9-]{1,64}$/i`;
    invalid IDs are silently skipped (pre-existing footgun #3734 Adv-F5).
  - Force-reprocess via `--run-id` overwrites both :latest and :by-run
    keys with no NX, resets the 24h TTL; CDN cache may serve stale for
    up to 30min (D6 trade-off documented in v3 plan).

No behavior change — comments only. Discoverable by operators reading
the script they invoke, rather than requiring knowledge of a separate
runbook file.
The local pre-push hook ran 'npm install --prefer-offline' (its safety net
when node_modules is missing in a worktree) and that pruned five
'[email protected]' optional peer-dep entries that exist for non-macOS
platforms. CI's 'npm ci' on Linux then refused the lockfile.

Restoring the lockfile to origin/main's version — those entries belong
there for cross-platform compatibility.
@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements two tightly-coupled changes for the forecast simulation pipeline: a new PRO-gated POST /api/forecast/v1/trigger-simulation endpoint that server-derives a runId from the package pointer and enqueues via Redis SET NX, and an activated runId filter on GET /api/forecast/v1/get-simulation-outcome that now routes through five distinct paths. Supporting plumbing includes a shared .mjs/.d.mts constants shim, a new simulation-queue.ts helper module, worker extensions to dual-write :latest and :by-run:{runId} keys with tombstone-on-failure, and 26 new tests.

  • trigger-simulation.ts: Mirrors run-scenario.ts shape — Pro gate, per-IP rate-limit, queue-depth 429, ApiError throws. Idempotency collapses already-queued/already-completed-this-cycle into a single external already-handled code.
  • get-simulation-outcome.ts: Activates the previously no-op runId filter using a by-run Redis key (24h TTL) with tombstone-aware fallback to :latest. Adds processing=true for in-queue runIds.
  • seed-forecasts.mjs: Extended to dual-write :latest and :by-run:{runId} with best-effort tombstone on failure; enqueueSimulationTask gains redis_error reason and pkgFingerprint payload.

Confidence Score: 4/5

Safe to merge with one targeted fix: the tombstone SET in the worker needs an NX flag before going to production.

The tombstone write in writeSimulationOutcome lacks an NX guard. A primary :by-run write that commits server-side but times out at the network layer will be immediately overwritten by the tombstone, permanently marking a successful outcome as a transient failure for the 24h TTL. All other changes are well-designed and covered by 26 new tests.

scripts/seed-forecasts.mjs (tombstone write race); server/worldmonitor/forecast/v1/get-simulation-outcome.ts (dual key-source for SIMULATION_OUTCOME_LATEST_KEY)

Important Files Changed

Filename Overview
scripts/seed-forecasts.mjs Adds dual-write and tombstone-on-failure; tombstone SET lacks NX guard, creating a race where a timed-out-but-committed primary write gets silently overwritten.
server/worldmonitor/forecast/v1/trigger-simulation.ts New PRO-gated handler; gate sequence, idempotency, and backpressure all correct. client_version never logged despite proto promise.
server/worldmonitor/forecast/v1/get-simulation-outcome.ts Five-path runId filter correct; imports SIMULATION_OUTCOME_LATEST_KEY from cache-keys.ts while sibling module imports same constant from _simulation-queue-constants.mjs.
server/_shared/simulation-queue.ts New helper module; all exported functions correct. redisGetThrowing missing User-Agent header.
server/_shared/_simulation-queue-constants.mjs New .mjs constants shim; values match cache-keys.ts duplicates currently.
server/_shared/rate-limit.ts Adds per-IP rate-limit for trigger-simulation matching run-scenario shape.
src/shared/premium-paths.ts Adds trigger-simulation to PRO-gated paths correctly.
tests/forecast-trigger-simulation.test.mts 9 tests cover all key paths including cron-timing-oracle regression.
tests/forecast-get-simulation-outcome.test.mts 6 tests covering all five runId filter response paths.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Gateway
    participant TriggerHandler as trigger-simulation.ts
    participant GetOutcomeHandler as get-simulation-outcome.ts
    participant Redis
    participant Worker as seed-forecasts.mjs

    Client->>Gateway: POST /trigger-simulation (Pro key)
    Gateway->>TriggerHandler: isCallerPremium, rate-limit check
    TriggerHandler->>Redis: ZCARD queue depth
    TriggerHandler->>Redis: GET simulation-package:latest
    TriggerHandler->>Redis: GET simulation-outcome:latest
    TriggerHandler->>Redis: "SET simulation-task:v1:{runId} NX"
    TriggerHandler->>Redis: ZADD task-queue
    TriggerHandler-->>Client: "queued=true, runId, pkgFingerprint"

    Worker->>Redis: ZRANGE drain queue
    Worker->>Redis: SET simulation-outcome:latest EX 60d
    Worker->>Redis: "SET simulation-outcome:by-run:{runId} EX 24h"
    Note over Worker,Redis: tombstone fallback missing NX

    Client->>GetOutcomeHandler: "GET /get-simulation-outcome?runId=X"
    GetOutcomeHandler->>Redis: "GET by-run:{runId}"
    alt by-run hit
        GetOutcomeHandler-->>Client: "found=true, processing=false"
    else tombstone
        GetOutcomeHandler->>Redis: GET :latest
        GetOutcomeHandler-->>Client: "found=true, note=by-run failed"
    else miss + in-queue
        GetOutcomeHandler->>Redis: ZRANGE task queue
        GetOutcomeHandler-->>Client: "found=false, processing=true"
    else miss + not queued
        GetOutcomeHandler->>Redis: GET :latest
        GetOutcomeHandler-->>Client: "found=true, note=may have expired"
    end
Loading

Reviews (1): Last reviewed commit: "docs(simulation): operator notes for pro..." | Re-trigger Greptile

Comment on lines +16974 to +16979
try {
await redisCommand(url, token, [
'SET', byRunKey,
JSON.stringify({ runId, error: 'by_run_write_failed', tombstoneAt: Date.now() }),
'EX', String(SIMULATION_OUTCOME_BY_RUN_TTL_SECONDS),
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Tombstone SET should use NX to avoid overwriting a by-run outcome that was committed to Redis but whose response timed out. Without NX, a primary write that succeeds server-side but throws a network error will be silently replaced by the tombstone on the retry path, permanently corrupting the by-run key for the 24h TTL window. Callers querying runId=X would then see the tombstone fallback and receive the "Redis transient" note even though the data was written successfully.

Suggested change
try {
await redisCommand(url, token, [
'SET', byRunKey,
JSON.stringify({ runId, error: 'by_run_write_failed', tombstoneAt: Date.now() }),
'EX', String(SIMULATION_OUTCOME_BY_RUN_TTL_SECONDS),
]);
try {
await redisCommand(url, token, [
'SET', byRunKey,
JSON.stringify({ runId, error: 'by_run_write_failed', tombstoneAt: Date.now() }),
'EX', String(SIMULATION_OUTCOME_BY_RUN_TTL_SECONDS),
'NX',
]);

Comment on lines +9 to +12
import { SIMULATION_OUTCOME_LATEST_KEY } from '../../../_shared/cache-keys';
import {
SIMULATION_OUTCOME_BY_RUN_KEY_PREFIX,
} from '../../../_shared/_simulation-queue-constants.mjs';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 SIMULATION_OUTCOME_LATEST_KEY is now exported from _simulation-queue-constants.mjs (the designated single source of truth for queue constants) and already imported for SIMULATION_OUTCOME_BY_RUN_KEY_PREFIX just below. Keeping a separate import from cache-keys creates two definitions of the same string — a future schema-version bump that updates one without the other would silently break the :latest fallback path without any TypeScript error.

Suggested change
import { SIMULATION_OUTCOME_LATEST_KEY } from '../../../_shared/cache-keys';
import {
SIMULATION_OUTCOME_BY_RUN_KEY_PREFIX,
} from '../../../_shared/_simulation-queue-constants.mjs';
import {
SIMULATION_OUTCOME_LATEST_KEY,
SIMULATION_OUTCOME_BY_RUN_KEY_PREFIX,
} from '../../../_shared/_simulation-queue-constants.mjs';

const authKind = apiKeyHeader
? (apiKeyHeader.startsWith('wm_') ? 'user_api_key' : 'enterprise_api_key')
: (authHeader ? 'clerk_jwt' : 'unknown');
console.log(`[TriggerSimulation] queued runId=${pointer.runId} authKind=${authKind} pkgFingerprint=${pointer.pkgFingerprint}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The proto comment for client_version states "Server LOGS this with the success breadcrumb" but the handler marks _req as unused and clientVersion never appears in the success console.log. Either log it as documented or update the proto comment.

Suggested change
console.log(`[TriggerSimulation] queued runId=${pointer.runId} authKind=${authKind} pkgFingerprint=${pointer.pkgFingerprint}`);
console.log(`[TriggerSimulation] queued runId=${pointer.runId} authKind=${authKind} pkgFingerprint=${pointer.pkgFingerprint} clientVersion=${_req.clientVersion || ''}`);

Comment on lines +33 to +36
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(REDIS_READ_TIMEOUT_MS),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 AGENTS.md requires User-Agent on all server-side fetch calls. redisGetThrowing calls the Upstash REST API without one.

Suggested change
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(REDIS_READ_TIMEOUT_MS),
});
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'worldmonitor-server/1.0' },
signal: AbortSignal.timeout(REDIS_READ_TIMEOUT_MS),
});

Vercel Edge bundler hoisted `server/_shared/_simulation-queue-constants.mjs`
into Edge function bundles (transitively via cache-keys.ts → various Edge
handlers). The shim's `import { createHash } from 'node:crypto'` is not
available in Edge runtime, breaking deploy with:

  The Edge Function "api/discord/oauth/callback" is referencing
  unsupported modules:
    - __vc__ns__/5/server/_shared/_simulation-queue-constants.mjs:
      node:crypto

Switch to `globalThis.crypto.subtle.digest('SHA-256', ...)` — the only
crypto API available in BOTH Edge runtime AND Node 19+. The seeder
(scripts/seed-forecasts.mjs) keeps working without a polyfill.

Web Crypto's subtle.digest is async-only, so pkgFingerprint() is now
async. Both call sites (handler's getSimulationPackagePointer in the TS
module + worker's currentFingerprint in the .mjs seeder) already run
inside async functions; updated to await.

Output is byte-identical to the previous Node-crypto version (16-char
sha256 hex prefix), so payloads and parity tests remain stable.
P1 — Tombstone SET now uses NX (scripts/seed-forecasts.mjs).
A by-run primary SET that landed server-side but threw a network error
on the response would have been overwritten by the tombstone on the
catch path, corrupting the real outcome for the 24h TTL window. NX
preserves the real write when it actually landed; if Redis is fully
down the tombstone also fails and the read path falls back to :latest
with the standard "may have expired" note (acceptable).

P2 — Single source of truth for SIMULATION_OUTCOME_LATEST_KEY
(server/worldmonitor/forecast/v1/get-simulation-outcome.ts).
Was importing the key from both _simulation-queue-constants.mjs (the
designated shim) AND cache-keys.ts. Drift on a future schema-version
bump could silently break the :latest fallback path. Import only from
the shim now; cache-keys still has the const (unchanged, kept for
non-simulation consumers).

P2 — clientVersion now logged per the proto comment promise
(server/worldmonitor/forecast/v1/trigger-simulation.ts).
The proto field's doc says "Server LOGS this with the success
breadcrumb" but the handler was marking req as unused. Echoes the
field in the success log line; sanitized (truncated to 64 chars,
restricted charset) since it's a free-text caller-provided string.

P2 — User-Agent header on Upstash GET in simulation-queue.ts
(server/_shared/simulation-queue.ts).
AGENTS.md requires User-Agent on all server-side fetch calls.
redisGetThrowing was missing it.

Tests: 26/26 simulation tests still pass. Typecheck clean.
@koala73

koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner Author

Addressed all 4 Greptile findings in commit b17e00c:

  • P1 (tombstone NX) — added NX flag on the tombstone SET in scripts/seed-forecasts.mjs:16979. If the primary by-run SET landed server-side but the response timed out, NX preserves the real outcome instead of overwriting with a tombstone. If Redis is fully down the tombstone also fails and the read path falls back to :latest with the standard expiry note — acceptable degradation.
  • P2 (duplicate import)SIMULATION_OUTCOME_LATEST_KEY now imported only from _simulation-queue-constants.mjs in get-simulation-outcome.ts. Cache-keys still exports the const for non-simulation consumers; the simulation read path uses the shim consistently.
  • P2 (clientVersion not logged) — handler now echoes req.clientVersion in the success log line, matching the proto comment promise. Sanitized to 64 chars + restricted charset since it's free-text caller-provided.
  • P2 (User-Agent)redisGetThrowing now sets User-Agent: worldmonitor-server/1.0 (simulation-queue) per AGENTS.md.

All 26 simulation tests still pass. Typecheck clean.

Fix 1 — processing=true responses can be cached for minutes (P1).
  get-simulation-outcome.ts returned {processing: true} on the by-run-miss
  + runId-queued branch without marking no-cache. The gateway's `slow`
  cache tier would cache the transient state for up to 30 min, so polling
  clients would never see the outcome land after the worker finished.
  Added markNoCacheResponse on that branch + regression test asserting
  X-No-Cache=1 on the response.

Fix 2 — enqueueSimulationTaskForServer + .mjs enqueueSimulationTask
  could return queued:true even when ZADD failed (P1, both sides).
  Worker discovers tasks EXCLUSIVELY via ZRANGE on the queue ZSET
  (scripts/seed-forecasts.mjs listQueuedSimulationTasks at line 17052,
  then processNextSimulationTask iterates queuedRunIds). A task key
  without a corresponding ZSET member is invisible to the worker until
  the 4h task TTL — silent stuck-invisible failure mode.

  Previously labeled "best-effort"; ZADD is actually load-bearing. Now:
  - Capture the ZADD result (transport throw OR non-numeric reply both
    count as failure)
  - On failure: issue compensating DEL on the task key so a future trigger
    isn't blocked by SET NX and no stale half-state lingers for the 4h TTL
  - Return {queued: false, reason: 'redis_error'}
  - EXPIRE failure stays non-fatal (TTL hint only — queue member is
    durable; logged but doesn't roll back)

  Added parity regression test covering both .mjs and .ts: simulates SET
  NX success + ZADD non-numeric reply, asserts both implementations
  surface redis_error AND issue the compensating DEL.

All 9223 tests pass; typecheck + typecheck:api green.
@koala73

koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner Author

Addressed both blocking findings in 2bf6144.

Fix 1 — processing: true cache leak
Added markNoCacheResponse(ctx.request) on the by-run-miss + runId-queued branch in server/worldmonitor/forecast/v1/get-simulation-outcome.ts. The gateway's slow tier would otherwise cache the transient state for up to 30 min — polling clients would never see the outcome land after the worker finished. Regression test asserts X-No-Cache: 1 on the response (tests/forecast-get-simulation-outcome.test.mts Path 3).

Fix 2 — silent ZADD failure → invisible task (both .mjs and .ts)
You were right that the prior "best-effort" framing was wrong. The worker discovers tasks exclusively via ZRANGE on the queue ZSET (scripts/seed-forecasts.mjs:17052 listQueuedSimulationTasksprocessNextSimulationTask at 17079). A task key with no corresponding ZSET member is invisible until the 4h task TTL — silent stuck-invisible.

Both enqueueSimulationTaskForServer and the .mjs enqueueSimulationTask now:

  • Capture the ZADD result (transport throw OR non-numeric reply = failure)
  • On failure: issue a compensating DEL on the task key so a future trigger isn't blocked by SET NX and no stale half-state lingers for 4h
  • Return {queued: false, reason: 'redis_error'}
  • EXPIRE failure stays non-fatal (TTL hint only — the queue member is durable; logged but no rollback)

Parity regression test (tests/simulation-queue-parity.test.mts) covers both implementations: simulates SET NX success + ZADD non-numeric reply, asserts both sides surface redis_error and issue the compensating DEL.

9223/9223 tests pass; typecheck + typecheck:api green.

@koala73
koala73 merged commit a1acb02 into main May 18, 2026
13 checks passed
@koala73
koala73 deleted the fix/3734-trigger-simulation branch May 18, 2026 15:29
koala73 added a commit that referenced this pull request May 18, 2026
…orkers can resolve it (#3811 hotfix) (#3818)

* fix(simulation): move queue-constants shim into scripts/ so Railway workers can resolve it

#3811 introduced `_simulation-queue-constants.mjs` at `server/_shared/`
and had `scripts/seed-forecasts.mjs` import it via `../server/_shared/...`.
That works in dev and on Vercel (esbuild inlines), but the three Railway
services that run the seeder — `seed-forecasts`, `simulation-worker`,
`deep-forecast-worker` — use the nixpacks build with `root_dir=scripts`,
which packages only `scripts/` contents into `/app/` in the container.

The relative import resolves to `/server/_shared/_simulation-queue-
constants.mjs` at runtime — a path that does not exist in the container —
and crashes every worker on startup:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/server/_shared/_simulation-queue-constants.mjs'
  imported from /app/seed-forecasts.mjs

All three Railway services have been in a crash loop since #3811 merged.

Fix: move the shim into `scripts/` (where it ships alongside the seeder)
and update the 4 importers:

  - scripts/seed-forecasts.mjs          → './_simulation-queue-constants.mjs'
  - server/_shared/simulation-queue.ts  → '../../scripts/_simulation-queue-constants.mjs'
  - server/worldmonitor/forecast/v1/trigger-simulation.ts    → '../../../../scripts/...'
  - server/worldmonitor/forecast/v1/get-simulation-outcome.ts → '../../../../scripts/...'

esbuild bundles the shim inline at Vercel build time, so the cross-
directory `../../scripts/...` path is fine on the server side. The
`.d.mts` declaration moves alongside so TS module-resolution still
picks it up. The shim's own header now documents WHY it lives in
scripts/ to prevent the next contributor from "tidying" it back into
server/_shared/.

Regression test: tests/scripts-railway-nixpacks-no-escape-import.test.mts.
BFS-walks `scripts/seed-forecasts.mjs`, `scripts/process-simulation-tasks.mjs`,
and `scripts/process-deep-forecast-tasks.mjs` (the three Railway service
entry points), following every relative import inside `scripts/`, and
fails if any resolved path escapes `scripts/`. Verified that the test
correctly fails with the broken import restored, then passes after fix.

Verification:
  - typecheck (main + api): clean
  - simulation tests: 30/30 pass (27 existing + 3 new)
  - docker/build-handlers.mjs: completes with 0 failures
  - shim resolves at runtime from both old (.mjs entry) and esbuild path

Distinct from #3811's Vercel-Edge fix commit (Web Crypto port) — that
addressed a build-time bundler error; this addresses a runtime
container-packaging error that only fires on Railway. No tests in #3811
exercised a Railway-shipped resolution path, which is how this escaped
review. The new test closes that coverage gap for these three workers
and any future scripts/ entry added to ENTRY_POINTS.

* review(pr-3818): address 2 Greptile P2 findings

1. Shim header missing the Railway-packaging rationale (P2).
   Three importers (server/_shared/simulation-queue.ts and the two
   forecast/v1 handlers) added "see the header of
   scripts/_simulation-queue-constants.mjs for the Railway packaging
   reason" cross-references in their import comments, but the shim
   itself was renamed with similarity index 100% — its header was
   the original Edge-bundling note with no mention of why it lives
   in scripts/ now. My header edit was made locally but never staged
   before the previous commit, so the published file shipped without
   the rationale. Fix: rewrite the header to lead with "DO NOT MOVE
   THIS FILE BACK TO server/_shared/", spell out the nixpacks
   root_dir=scripts mechanism, name the three affected Railway
   services, and link the regression test + incident PRs. A future
   contributor opening the shim to "tidy" it will now find an
   in-file warning instead of needing to chase importer comments.

2. isBareOrNode dead code in the BFS loop (P2).
   collectRelativeImports already pre-filters to `./` and `../`
   specs only, so isBareOrNode(spec) in the loop body always
   returned false and the `continue` was never reached. Removed
   both the unused helper and the loop guard.

Tests still 3/3 pass; typecheck:api clean.
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.

AI simulation cannot be triggered via API — human Railway operator required, agents are permanently read-only

1 participant