Phase 5 — Async Job System (Inngest)
Goal
The agent can hand off long-running work and learn when it’s done via push, without the MCP server blocking. After this phase, the system supports the full async loop: create_job → Inngest worker → notifications/job_status pushed to the originating MCP session.
Scope / Deliverables
- Inngest keys (
INNGEST_EVENT_KEY+INNGEST_SIGNING_KEY) in env, validated byconfig.ts -
POST /api/inngestmounted with the SDK’sserve()helper - Migration:
jobstable (full schema from data-and-contracts.md) -
jobs/events.ts— Zod schemas forjob.requested,job.completed,job.failed,job.cancelled -
jobs/inngest.ts— typed Inngest client (event sender) - First worker function:
image.generate— concurrency key =userId, singlestep.runplaceholder that returns a stub URL (real image-gen provider deferred) - Tool:
create_job— writes thejobsrow (status='queued'), capturesmcp_session_id, emitsjob.requested - Tool:
get_job_status— reads fromjobsrow - Tool:
cancel_job— setsstatus='cancelled'in DB; worker checks status between steps and exits early - Worker function on success → emits
job.completedand updatesjobsrow tosucceededwithresult - Worker function on terminal failure → emits
job.failedand updatesjobsrow tofailedwitherror - System function
notify.job_statustriggered byjob.completed|failed|cancelled→ looks upmcp_session_id→ pushes JSON-RPCnotifications/job_statusto that session (drops silently if not connected) -
mcp/sessions.ts— connect/disconnect handlers maintain the in-process session map
See data-and-contracts.md for the MCP session & push model and the full Inngest event schemas.
Automated Unit Testing
-
events.schema.test.ts— for each event (job.requested,job.completed,job.failed,job.cancelled):- valid payload parses cleanly
- missing required field → Zod error with field path
- extra unknown fields handled per the chosen Zod stance (passthrough vs strict — document)
job.failed.errorrequires bothcodeandmessage
-
tools/create_job.test.ts—- writes a
jobsrow withstatus='queued', generated UUID, capturedmcp_session_id, and the typed payload - emits exactly one
job.requestedevent via the (mocked) Inngest client, with matchingjobId,userId,mcpSessionId,type,payload - unknown
type→INVALID_INPUT(or accepted as opaque string — document the policy) - Zod rejects payloads not matching the per-type schema (when registered)
- writes a
-
tools/get_job_status.test.ts—- reads the row and shapes it as
{jobId, type, status, result?, error?, createdAt, updatedAt} - unknown
jobId→NOT_FOUND
- reads the row and shapes it as
-
tools/cancel_job.test.ts—- while
status='queued'→ flips tocancelled, returns{jobId, status: 'cancelled'} - while
status='running'→ flips tocancelled - while
status='succeeded'|'failed'|'cancelled'→ rejects withINVALID_INPUT(or stable error), no DB write
- while
-
sessions.test.ts—register(sessionId, pushFn)populates the mapunregister(sessionId)clears itpushTo(sessionId, msg)invokes thepushFnfor an active session and is a silent no-op for an absent one
-
notify.job_status.test.ts—- given a
job.completedevent, resolves the row’smcp_session_idand invokes the rightpushFnwith{ method: 'notifications/job_status', params: { jobId, status: 'succeeded', result } } - given a
job.failedevent, push includes{ status: 'failed', error: { code, message } } - if the session is absent from the map, no error is thrown (drop silently)
- given a
-
jobs/imageGenerate.test.ts(Inngest function, run viainngest.testharness or pure unit on the wrapped business logic):- happy path →
job.completedemitted,jobsrow updated tosucceededwithresult - mid-run DB shows
status='cancelled'→ function exits early at the nextstep.runcheckpoint without emittingjob.completed - thrown
NonRetriableErrorfrom a step →job.failedemitted,jobs.errorpopulated
- happy path →
Integration scope (deferred): full Inngest local dev round-trip (npx inngest-cli dev + a real event) is exercised in the integration suite.
Definition of Done
- Agent calls
create_job(type='image.generate', payload={prompt})→ getsjobIdimmediately. - Inngest dashboard shows the function ran with the right concurrency key.
- Agent receives a
notifications/job_statuspush on completion, within ~1s of the function finishing. - Disconnecting and reconnecting the agent:
get_job_status(jobId)reflects the final state. - Two
create_jobcalls for the sameuserIdrun sequentially, not in parallel.