Skip to content

AutoOps post-call reschedule and cancel are stubbed in the booking workflow

One-line summary. The EAS pilot runs through the AUTO_SERVICE vertical, whose post-call entry point runAutoOpsBookingWorkflow already classifies calls as Booking / Rescheduling / Cancellation, but the Rescheduling and Cancellation branches are explicit placeholders that fall back to "manual follow-up via email" because no jobId-from-transcript extractor existed. PR #9877 introduced that extractor + the reschedule leaf function. PR #10012 introduced the cancel leaf. Neither PR wired its leaf into the booking workflow's stubbed branches. The fix is small: replace two else if branches in run-booking-autoops.ts with calls to the new leaves.

Diagnosis revised 2026-05-08

An earlier draft of this doc claimed the fix was at run-triage-home-service.ts (Home Services vertical, CRM dispatch). That was wrong on two counts: (1) the EAS pilot is the AUTO_SERVICE vertical, not HOME_SERVICES, so calls never enter that file; (2) the AUTO_SERVICE triager already routes by CRM (only AUTO_OPS is supported there), and the action dispatch (book/reschedule/cancel) lives one layer below in runAutoOpsBookingWorkflow — where the placeholder branches already exist. Recording the wrong-then-right path explicitly because it's a useful lesson about reading the dispatch chain top-to-bottom before drafting the fix.

Problem statement

For the EAS pilot team (AutoOps, team 2678), a real call requesting a reschedule:

  • ✅ Updates the calls table (rescheduling: true is written by general post-call processing; data_store may carry the booking workflow's processResult).
  • ✅ Sends a "manual follow-up" email to the shop. (This is the intentional fallback.)
  • ❌ Never calls AutoOps's POST /jobs/<id>/reschedule. The job's currentStartAt does not change.

Direct curl to AutoOps's reschedule endpoint works (PR #9877's smoke confirmed). The leaf function rescheduleAutoOpsAppointment works (PR #9877's unit tests confirmed). The break is between runAutoOpsBookingWorkflow and the leaf — there is no caller.

The same gap exists for cancellations: runAutoOpsBookingWorkflow's cancellation branch is a placeholder. PR #10012 introduced cancelAutoOpsAppointment but did not wire it.

How the dispatch actually flows (corrected)

Avoca's post-call dispatch architecture is vertical-first, then CRM, then action:

EndOfCallReportInngestFunction (Inngest entry)
  └─ runWorkflow → WorkflowRunTriagerFactory.create
       └─ switches on typedConfig.vertical:
            ├─ AUTO_SERVICE   → AutoServiceWorkflowRunTriager   ← EAS pilot
            ├─ WINDOW         → WindowWorkflowRunTriager
            ├─ FLOORING       → FlooringWorkflowRunTriager
            ├─ JUNK_REMOVAL   → JunkRemovalWorkflowRunTriager
            ├─ ROOFING        → RoofingWorkflowRunTriager
            └─ default        → HomeServicesWorkflowRunTriager

Each triager has its own executeOutcome(row). AutoServiceWorkflowRunTriager.executeOutcome switches on typedConfig.crm:

if (typedConfig.crm === 'AUTO_OPS') {
  result = await runAutoOpsBookingWorkflow(callCtx, this.context);
}

AUTO_OPS is the only CRM currently supported under AUTO_SERVICE. So EAS calls always end up in runAutoOpsBookingWorkflow.

runAutoOpsBookingWorkflow then:

  1. Loads AutoOps config + builds an AutoOps client.
  2. Runs LLM extraction (runAutoServiceExtraction) which classifies callReason as Booking | Rescheduling | Cancellation | … plus other fields.
  3. Branches on callReason:
    • Booking + appointmentBookedrunBooking(...) → calls AutoOps POST /book → success ✓
    • Reschedulingstubbed, sets autoOpsAction='reschedule', logs "manual follow-up", does NOT call AutoOps
    • Cancellationstubbed, sets autoOpsAction='cancel', logs "manual follow-up", does NOT call AutoOps
  4. Updates the call record + sends an email (with errorMessages collected from above).

The Rescheduling/Cancellation branches were explicitly stubbed (with a code comment: "Reschedule and cancel both require an existing AutoOps job ID. We can't reliably extract that from a phone call transcript, so for the pilot we surface this as an explicit unsupported-action and let the human follow-up email do the work."). PR #9877 added the missing extractor (getExistingAutoOpsJobIdFromTranscript); PR #10012 reuses it. The precondition for actually performing reschedule/cancel is now met. Nobody has updated the branches.

Current behavior — failing case

Source citations:

Proposed behavior — corrected case

Replace the two stub branches with actual calls to the leaves from #9877 and #10012. Reuse the existing AutoOps client. Reuse getExistingAutoOpsJobIdFromTranscript (already imported and used by rescheduleAutoOpsAppointment internally — same import in cancel).

Note on AutoOps cancel's known constraint

AutoOps's cancel endpoint currently returns 400 "Client SMS does not support cancels" for shops where the client-SMS feature is gated off. cancelAutoOpsAppointment (PR #10012) handles this as a graceful failure result; the email fallback then communicates the situation to the shop. The wiring fix doesn't change that contract.

Why this fix shape

OptionWhereProsCons
A. Fill in the existing stub branches in run-booking-autoops.tsSame file, replace lines 130-143 with real calls to the leaves. ~30 lines diff.Smallest possible diff. Stays in the file owned by AutoOps. Reuses the existing client + extraction. The branches are already there — code comment even acknowledges the deferral.The runner is named runAutoOpsBookingWorkflow but handles all three actions. Misleading name accumulates.
B. Extract per-action runnersNew run-rescheduling-autoops.ts and run-cancellation-autoops.ts files; runAutoOpsBookingWorkflow becomes a dispatcher.Cleaner separation. Mirrors the per-action shape used in HOME_SERVICES.Larger diff. New files to maintain. Doesn't match the AUTO_SERVICE vertical's "one runner, action-branched" convention.
C. Rename + restructureRename runAutoOpsBookingWorkflowrunAutoOpsWorkflow, file → run-autoops.ts. Refactor as part of the fix.Honest naming.Scope creep. Touches more files than necessary for the bug fix.

Pick A. This is a fill-in-the-stub fix, not an architectural change. The comment in the existing code ("for the pilot we surface this as an explicit unsupported-action") even tells us where the placeholder is and what it's waiting for. Renaming and restructuring (B/C) are sensible follow-ups for a separate PR if/when AUTO_SERVICE adds a non-AutoOps CRM or wants per-action reuse.

Implementation scope

Files modified

  • apps/web/lib/workflow/stages/booking-autoops/run-booking-autoops.ts — replace lines 130-143's two stub branches with actual calls to rescheduleAutoOpsAppointment (from lib/workflow/stages/rescheduling/rescheduling-autoops) and cancelAutoOpsAppointment (from lib/workflow/stages/cancellations/cancellation-autoops). Set autoOpsJobId from the result. Treat graceful failures (e.g., jobid-not-found, client-SMS-gating) as already-handled errorMessages push, mirroring how the existing booking branch handles its failures.

Files added

None. The leaves are imported from already-existing modules introduced by #9877 / #10012.

Tests

  • Unit: existing rescheduling-autoops.test.ts and (cancel equivalent) cover the leaves. No change.
  • In-runner: add run-booking-autoops.test.ts cases for callReason === 'Rescheduling' and 'Cancellation' paths — mock the AutoOps client, assert the leaf is invoked with the right args, assert autoOpsResult.success reflects the leaf's outcome.
  • Synthetic boundary smoke (the new discipline): apps/web/scripts/smoke-autoops-postcall-reschedule.ts and smoke-autoops-postcall-cancel.ts. Synthesize an end-of-call event payload (transcript + callId + teamId), inngest.send() it, poll for run completion, assert AutoOps currentStartAt (or job state for cancel) actually changed via direct API GET. This is the test that would have caught the original gap. Lives alongside the PR diff.

Diff size estimate

~50 lines net add: in-runner replacement + 2 in-runner unit-test cases + 2 synthetic smoke scripts.

Rollout

Stack on feat/autoops-reschedule-postcall (#9877) since it provides the reschedule leaf. Cancel half can be added when #10012 merges, OR included if #10012 merges first. Either way, the wiring PR is small and atomic.

Verification (the right way, going forward)

Use the synthetic boundary smoke instead of dialing the EAS pilot phone. From the toolkit (or avoca-next directly):

bash
# Capture pre-state:
./scripts/observe/autoops-job.sh <jobId>

# Run the smoke (script lives in avoca-next, runs against dev or prod Inngest):
pnpm --filter web smoke:autoops-postcall-reschedule \
  --jobId <existing job> \
  --scheduledAt 2026-07-27T19:00:00.000Z

# Capture post-state and confirm currentStartAt changed:
./scripts/observe/autoops-job.sh <jobId>

If the smoke fails (no currentStartAt change after the synthetic event), the wiring is broken regardless of what unit tests pass. This is the assertion that would have caught the original gap.

The real-call validation against the EAS pilot phone is then a separate step — recommended at least once before considering the feature shipped, but no longer the gating test for "did we wire correctly."

Broader pattern worth surfacing to Avoca

Three observations to raise in the PR description (and worth a Slack thread to Kareem):

  1. The AUTO_SERVICE vertical's runner is named runAutoOpsBookingWorkflow but handles all three actions (book / reschedule / cancel). Misleading name. After this fix, the file should probably be renamed run-autoops.ts with the function renamed runAutoOpsWorkflow. Out of scope here; flag as a follow-up.

  2. The reschedule/cancel stubs were a deliberate, self-aware deferral — the code comment cites the missing jobId extractor as the reason. PR #9877 quietly satisfied that precondition without closing the loop in the same PR. The follow-up wiring PR closes the loop; the lesson for future per-action features is to either land the wiring atomically with the leaf, OR at minimum add a TODO comment in the leaf pointing at the stub site so the next person doesn't miss it.

  3. The per-action smoke pattern. Introducing smoke-autoops-postcall-{reschedule,cancel}.ts establishes "synthetic event → real workflow → assert system-of-truth state delta" as the canonical end-to-end smoke for this surface. Worth standardizing across other per-CRM workflows so the same class of dispatch bugs don't recur silently.

Open questions

  • Synthetic event payload shape. Confirm the exact event name + payload that EndOfCallReportInngestFunction consumes. Likely the constant from lib/inngest/events/EndOfCallReportInngestEvent.ts.
  • Does the existing booking smoke (smoke-cancel.ts was removed earlier this branch) leave behind a pattern we should mirror for the smokes?
  • JobId source for the smoke — for tests, do we hard-code a known jobId from the EAS prod tenant? Sandy already has one (job_a1f4c3e8929c423a846c1627017513e3 from prior diagnostic curls). Encode it as a SMOKE_AUTOOPS_TEST_JOB_ID env var.
  • Cancel idempotency-key prefix for parity with reschedule's ${callId}-reschedule-${jobId} shape — likely ${callId}-cancel-${jobId}. Confirm against #10012.

Diagnosis history (kept on the page so the lesson stays visible)

  • 2026-05-08, draft 1: Claimed the fix was in run-rescheduling.ts / run-cancellation.ts (HOME_SERVICES vertical), with a CRM dispatch gap mirroring Four Seasons's pattern. Wrong: EAS doesn't go through that file.
  • 2026-05-08, draft 2 (this revision): Read the dispatcher chain top-to-bottom (WorkflowRunTriagerFactoryAutoServiceWorkflowRunTriagerrunAutoOpsBookingWorkflow) and located the actual stub branches. Fix is in-runner, ~30 lines.
  • The lesson: before drafting a solution, walk the dispatcher chain from the entry point (Inngest function definition) all the way down to the leaf. Yesterday's draft started one layer below the actual entry point and missed the vertical-first split. Catching this earlier would have saved a doc rewrite. Captured for the lessons section as the canonical example of the synthetic boundary smoke discipline being mandatory at the top of the dispatcher chain, not just at the leaf.