File size: 36,390 Bytes
0f71b56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# Async Execution β€” Design Specification

Status: **design approved; Phase 1 implementing as foundational substrate.**
Target rung: 2.5 (between projection completion and zoom-quantum).
Scope: one foundational pillar. Implementation proceeds phase-by-phase under separate directives.

## Framing (authoritative)

The pillar is **ownership of execution**, not "offline" as a feature.

Three layers, kept distinct:

- **Now Session** β€” the user-facing scope. The browser tab, the compose bar,
  the field as it reads during an interactive moment. UX lives here. In
  Phase 1 this is UNCHANGED: prompts still feel like "I asked here, it is
  working here, this thread is mine now."

- **Shadow job** β€” the execution substrate. A server-owned lifecycle record
  (`sa_job`) that tracks what the executor is actually doing. Introduced in
  Phase 1 as foundational infrastructure, with no UX surfacing. The seam
  that makes later decoupling possible without rewriting retries, state
  transitions, frontier selection, failure handling, or field updates.

- **Offline / Subconscious** β€” a future user-facing mode built on the
  shadow-job substrate. Marketing layer for background / recoverable / long-
  running work. NOT introduced by Phase 1. Introduced only when a later
  directive surfaces it.

The instruction the rest of this spec serves:

> Treat shadow jobs as foundational infrastructure introduced now, but keep
> their behavior scoped to the current Now Session until a later directive
> surfaces them as an explicit Offline/Subconscious capability.

---

## 0. Scope note

This spec describes **async prompt execution** as a first-class capability.

It does NOT cover:
- The client-state race bug-fix (a separate, independent commit β€” closes a symptom of the sync model; lands before or alongside phase 1 here).
- Implementation commits (each phase below becomes a directive; each directive becomes one or more commits).

It DOES cover:
- What a prompt job is, and how it differs from the current request.
- The canonical state machine.
- The storage model.
- The worker strategy (comparison + decision + rationale).
- The client interaction model (comparison + phased choice).
- A phased migration plan where each phase ships a working system.
- The failure / retry / recovery model.
- Explicit tradeoffs and risks.
- Measurable success criteria.

---

## 1. Motivation and Current Limitations

### Today

- `POST /sa-core/v1/prompt` is synchronous. The REST thread calls `SA_Executor::run`, which calls the adapter via `wp_remote_post`, which blocks the PHP request for up to 180 seconds (after the recent timeout raise).
- The browser tab owns the entire prompt lifecycle. The client's `fetch()` holds the connection; if the tab closes, the server continues running but the user loses all visibility into the outcome.
- Two prompts submitted in parallel are two parallel PHP workers, each doing a long cURL wait, with zero coordination, no priority, and no recovery if one of them crashes.
- The just-observed concurrent-submit race (two prompts attaching to the same parent) is a symptom: client-side `activeCtxRootId` is the only source of truth for "which node is the continuation root," so timing conflicts between user navigation and async resolution are inevitable.

### Why this is structural, not cosmetic

1. **Rung 5 (operator realism) is blocked.** Tests that simulate load, capacity planning, reliability SLAs, and meaningful metrics all require async job execution. Every one of these is meaningless against a sync model.
2. **Rung 4 (zoom-quantum engine) gets much harder on sync.** The engine wants cheap state-transition re-renders. Pushing state through long sync calls + `/mission/recent` refreshes introduces races we'd have to hunt repeatedly.
3. **Rungs 3 and 6 (self-hosting, carma-audit absorption) produce entity streams that shouldn't block HTTP.** Scanner runs, doc ingestion, code ingestion β€” none of these should tie up a web request.
4. **The tab is not a reliable process owner.** Real work β€” long code generations, multi-step agent chains, background analyses β€” must survive a closed tab.
5. **Browser-side state conflicts are fundamental with sync.** The concurrent-submit race is one; future race surfaces (multi-user live editing, real-time collaboration) are coming. Moving execution state authority to the server closes these by construction.

---

## 2. Execution Model

### What is a "prompt job"

A **prompt job** is the unit of work that executes the `SA_Executor` pipeline against a specific prompt entity. It is:

- A durable record with its own UUID identity (distinct from the prompt entity).
- A pointer to a persisted prompt entity (`prompt_id`).
- A member of the queue with a state, a priority, and a retry budget.
- Owned by the server, not the browser.
- Inspectable, retriable, cancellable, supersedable β€” as a first-class operation.

### How it differs from the current request model

| Concern | Today (sync) | Proposed (async) |
|---|---|---|
| Who owns the pipeline's lifecycle? | The REST thread | The worker layer |
| Where is "in-progress" state? | PHP memory + browser memory | `sa_job` DB row |
| Does closing the tab kill the work? | No (it continues) but the user loses visibility | No β€” and the user regains visibility on reload |
| Can two prompts run simultaneously? | Only as two parallel PHP workers with no coordination | Yes, first-class, priority-ordered |
| Can a failed execution be retried cleanly? | Via `retry_prompt` after the fact | Built into the state machine |
| Can the user inspect queued / running work? | No | Yes (CLI phase 2; UI later) |

### Canonical lifecycle

1. **Submit.** `POST /prompt` creates:
   - Prompt entity (as today, synchronously within the request β€” this is fast; the adapter call is what blocks).
   - `sa_job` row with `state='queued'`.
   - Returns **`202 Accepted`** with `prompt_id`, `job_id`, and initial state. Target: response within 50ms.
2. **Enqueue.** Client adds the prompt to its `entities[]` array with the job's state, renders a "queued" drop. Client begins polling (phase 2) or listening via SSE (phase 4).
3. **Claim.** A worker polls for the highest-priority queued job. Transactional update: `state='queued'` β†’ `state='running'`, `worker_claim=<uuid>`, `claimed_at=now`.
4. **Run.** The worker invokes `SA_Executor::run_claimed(job_id)`. This is a refactored executor entry-point that operates on an already-persisted prompt + job, not a fresh submit.
5. **Complete.** On adapter success, the worker writes the response entity, the `flows-to` edge, the ledger row, the structural audit row (as today), and transitions the job to `state='succeeded'` with `response_id` set.
6. **Observe.** Client sees state transitions via polling or SSE, updates the drop's visual state class accordingly.
7. **Fail (if applicable).** On adapter error, the worker classifies the error, applies retry policy, and either re-queues the job with backoff OR marks it `state='failed'`.

---

## 3. State Machine

### States

| State | Meaning | Terminal? |
|---|---|---|
| `queued` | Created; waiting for a worker | No |
| `running` | Claimed by a worker; adapter call in flight | No |
| `succeeded` | Adapter returned OK; response entity persisted | Yes |
| `failed` | Retry budget exhausted or non-retryable error | Yes |
| `cancelled` | User or operator intervention | Yes |
| `superseded` | Replaced by a newer job (e.g., user retried) | Yes |

Once a job enters a terminal state, it does not change. A retry does **not** mutate an existing job β€” it creates a **new job** that references the old one via `supersedes`.

### Transitions

```
queued      β†’ running      : worker successfully claims
queued      β†’ cancelled    : user/operator action before claim
running     β†’ succeeded    : adapter OK + response entity written
running     β†’ queued       : transient failure; attempt < max_attempts; apply backoff
running     β†’ failed       : retry budget exhausted OR non-retryable error
running     β†’ failed       : stuck (started > 15 min ago, no heartbeat) via cleanup
running     β†’ cancelled    : operator kill (response discarded even if it arrives later)
succeeded   β†’ superseded   : user-initiated retry replaces result
failed      β†’ superseded   : user-initiated retry
cancelled   β†’ superseded   : user-initiated retry after cancel
```

### Heartbeat model

While `state='running'`, the worker MUST update `running_heartbeat_at` every 30 seconds. A separate cleanup task (run via system cron every 5 minutes) reclaims jobs where `running_heartbeat_at < now() - 5min` back to `state='queued'` (if `attempt < max_attempts`) or to `state='failed'` (else).

### Triggers

- `queued` ← `SA_Executor::enqueue` (called from REST handler).
- `running` ← worker process picks up a `queued` job and wins the claim race.
- `succeeded` ← worker completes execution and commits.
- `failed` ← worker exhausts retries OR cleanup declares job stuck.
- `cancelled` ← CLI `wp sa-core cancel-job <id>` OR admin action.
- `superseded` ← a new job is created referring to this one via `supersedes`.

---

## 4. Storage Model

### Decision: new `sa_job` table

Not overloading `sa_entity`.

Rationale:
- `sa_entity` represents authored knowledge: the user's prompt, the LLM's response, the derived context. It has one durable identity per artifact.
- `sa_job` represents execution: a prompt may have multiple execution attempts (retries), each with its own claim, timing, errors, heartbeats. Conflating these with entity state confuses semantics and crowds the entity row.
- Keeping them separate means: `sa_entity` stays durable knowledge; `sa_job` stays ephemeral execution records. Queries stay clean: "show me the user's recent prompts" is a `sa_entity` query; "show me what's queued" is a `sa_job` query.

### Schema

```sql
CREATE TABLE {prefix}sa_job (
    id                       CHAR(36) NOT NULL,
    prompt_id                CHAR(36) NOT NULL,

    state                    VARCHAR(16) NOT NULL DEFAULT 'queued',
        -- queued | running | succeeded | failed | cancelled | superseded

    priority                 TINYINT UNSIGNED NOT NULL DEFAULT 50,
        -- lower number = higher priority
        -- 0-10: urgent (interactive, current-tab)
        -- 20-40: normal user-initiated background
        -- 50: default
        -- 60-90: bulk / scheduled / idle-time
        -- 100: lowest (opportunistic)

    attempt                  SMALLINT UNSIGNED NOT NULL DEFAULT 1,
    max_attempts             SMALLINT UNSIGNED NOT NULL DEFAULT 3,

    execution_options        LONGTEXT NOT NULL,
        -- JSON: { model?, prefer_provider?, depth?, router_model?, ... }

    worker_claim             VARCHAR(64) NULL,
    claimed_at               DATETIME NULL,
    started_at               DATETIME NULL,
    running_heartbeat_at     DATETIME NULL,
    finished_at              DATETIME NULL,

    response_id              CHAR(36) NULL,
        -- set on succeeded; points to the response entity

    error_kind               VARCHAR(32) NULL,
        -- timeout | rate_limit | provider_5xx | provider_4xx
        -- | parse | auth | worker_crash | stuck | unknown

    error_message            TEXT NULL,

    supersedes               CHAR(36) NULL,
        -- if this job replaces a previous attempt

    org_id                   BIGINT UNSIGNED NOT NULL,
    created_by               BIGINT UNSIGNED NOT NULL,
    created_at               DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at               DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                             ON UPDATE CURRENT_TIMESTAMP,

    PRIMARY KEY (id),
    KEY idx_state_priority_created (state, priority, created_at),
    KEY idx_prompt                 (prompt_id),
    KEY idx_worker_claim           (worker_claim),
    KEY idx_org_user_state         (org_id, created_by, state),
    KEY idx_heartbeat              (state, running_heartbeat_at)
)
```

### Interaction with existing entity fields

| Field | Lives on | Set when | Changed by jobs? |
|---|---|---|---|
| `correlation_id` | `sa_entity` | At prompt creation time (unchanged) | No |
| `parent_id` | `sa_entity` | At prompt creation time (continuation semantics, unchanged) | No |
| `causation_id` | `sa_entity` | At prompt creation time (unchanged) | No |
| `source_refs` | `sa_entity` (response) | Written by worker at job completion | Indirectly (job runs the executor which sets it) |
| `truth_class` / `state_class` | `sa_entity` | Set at write time (unchanged) | No |

The `sa_job` table is orthogonal to the entity graph. Entities are durable structural records. Jobs are execution metadata.

### Retry and supersession

When a user retries a failed or succeeded prompt:
1. Create a **new** `sa_job` row with the same `prompt_id`, new UUID, `attempt=1`, `state=queued`, `supersedes=<old_job_id>`.
2. The old job stays in place (terminal state preserved).
3. If the retry path requires cleaning up a prior response entity (as the current `retry_prompt` does for failed responses), that happens in the worker that claims the new job, NOT at enqueue time.

This model means every execution attempt is recorded and queryable. "Show me the history of this prompt" becomes `SELECT * FROM sa_job WHERE prompt_id = X ORDER BY created_at`.

---

## 5. Worker Strategy

### Decision: custom worker, driven by WP-CLI + system cron

### Comparison

**Option A: WP-Cron alone** β€” not chosen.
- Pros: zero external dependencies.
- Cons: WP-Cron spawns only on page visits (unreliable for low-traffic sites); each spawn is bound by `max_execution_time`; no native priority; no concurrency management. The "real cron hitting `wp-cron.php`" workaround mitigates the first point but the others remain.

**Option B: Action Scheduler (the library)** β€” fallback / future option.
- Pros: mature, battle-tested by WooCommerce and Mailpoet, has built-in claim/retry/logs, comes with an admin UI, well-understood in the WP ecosystem.
- Cons: adds a dependency (~200KB library, or plugin requirement); its schema and patterns are WooCommerce-flavored; it wraps our job concept with its own scheduling layer that we'd then have to thread through.
- Worth reconsidering if we ever need to interoperate with a WooCommerce-running site that already uses AS.

**Option C: Custom worker** β€” chosen.
- Rationale: our `sa_job` table **is** our job system. Its state machine is specific to our semantics (prompt-centric, projection-aware, org-scoped). Adding Action Scheduler on top means we'd write adapters between our table and theirs. A custom worker is ~300 lines of code, directly aligned with our data model.
- Cons: we own the scheduler code. Edge cases (claim races, heartbeat recovery, backoff) must be solved by us. But: the surface is narrow, there's no distributed coordination to worry about (single-node plugin), and the patterns are well-known.

### Custom worker implementation

```
wp sa-core run-worker [--once] [--batch-size=N] [--priority-ceiling=N]
```

Behavior in one iteration:
1. Generate or resume a `worker_id` for this process (UUID).
2. Claim a batch of jobs:
   ```sql
   UPDATE sa_job
      SET state='running',
          worker_claim=<worker_id>,
          claimed_at=NOW(),
          started_at=NOW(),
          running_heartbeat_at=NOW()
    WHERE state='queued'
      AND priority <= <priority-ceiling>
    ORDER BY priority ASC, created_at ASC
    LIMIT <batch-size>
   ```
   The single-statement UPDATE is the claim atomic: whichever worker's UPDATE lands first, wins.
3. Re-query the claimed rows by `worker_claim=<worker_id>` to get the actual job ids.
4. For each claimed job:
   - Call `SA_Executor::run_claimed(job_id)`.
   - On success: set state='succeeded', response_id, finished_at.
   - On retryable failure: if attempt < max_attempts, set state='queued' with backoff delay (encoded via `created_at += backoff`); else state='failed'.
   - On non-retryable failure: state='failed' immediately.
5. Spawn a heartbeat thread/callback that updates `running_heartbeat_at` every 30s while the adapter is in flight.
6. Sleep 2 seconds. If `--once`, exit. Otherwise loop.

### Driving the worker

Three supported modes:

**Mode 1: Single-shot via system cron** (recommended for production)
```cron
* * * * * wp sa-core run-worker --once --batch-size=5
```
Every minute, claim up to 5 jobs, process them, exit. Reliable, bounded, manageable.

**Mode 2: Long-running service** (optional for high-volume)
```
wp sa-core run-worker
```
Starts a persistent worker. Polls continuously. Should be supervised (systemd / supervisor) for restarts on crash.

**Mode 3: WP-Cron via wp-cron.php** (default; fallback)
If a site has no system cron, register `sa-core-process-queue` as a WP-Cron event firing every minute. Relies on site traffic; acceptable for dev and low-traffic installs.

### Heartbeat and stuck-job cleanup

Separate command:
```
wp sa-core reclaim-stuck-jobs
```
System cron: every 5 minutes.

Behavior:
- Find jobs where `state='running'` AND `running_heartbeat_at < now() - 5 minutes`.
- For each:
  - If `attempt < max_attempts` AND `started_at > now() - 15 minutes`: reclaim to `state='queued'`, clear `worker_claim`.
  - Else: `state='failed'`, `error_kind='stuck'`.

### Double-execution safety

Risk: worker A claims a job, loses heartbeat, cleanup reclaims to queued, worker B claims and runs. Worker A's adapter call may still be in flight. Both workers could reach the "complete" step.

Mitigation: all state-finalization writes (`state='succeeded'`, `response_id`, etc.) are conditioned on `WHERE state='running' AND worker_claim=<my_worker_id>`. Whichever update lands second sees no matching row and aborts silently. The first worker's result wins; the other worker's result is discarded (wasted work, but no data corruption).

---

## 6. Client Interaction Model

### Decision: polling in phase 2, SSE in phase 4, WebSocket deferred

### Comparison

| Protocol | Pros | Cons | Chosen? |
|---|---|---|---|
| **Polling** | Simplest; works anywhere; zero new infra; trivial to implement | Latency up to poll interval; wastes bandwidth when idle (mitigated by only polling while in-flight jobs exist) | Phase 2 |
| **Server-Sent Events** | Efficient for state updates; built into browsers (`EventSource`); one-way but that's what we need | Requires long-lived HTTP connection; may conflict with PHP `max_execution_time` on some hosts; harder to debug | Phase 4 |
| **WebSocket** | Full-duplex; lowest latency; richest protocol | Needs separate infra (Node or Ratchet-in-PHP); significant deployment burden; overkill for our scale | Deferred |

### Polling protocol (phase 2)

**Submit flow:**

1. Client calls `POST /prompt` with prompt text + root_entity_id + etc.
2. Server returns `202` with `{ prompt_id, job_id, state: 'queued' }`.
3. Client:
   - Adds entity to `entities[]` with `state='queued'`, renders as queued drop.
   - Starts a poll loop if not already running.

**Poll loop:**

- Every 2 seconds, while at least one in-flight (`queued` or `running`) job exists:
  - Call `GET /sa-core/v1/mission/recent?since=<iso8601>`.
  - Server returns entities with any updates since the timestamp.
  - Client merges results by `prompt_id` (does not replace array wholesale).
  - If all known prompts are terminal (succeeded, failed, cancelled, superseded) or no in-flight jobs remain, stop polling.

**Server endpoint changes (phase 2):**

- `GET /mission/recent` accepts `since=<iso8601>`. Returns only prompts updated since that time (joins `sa_job.updated_at` into the "is this updated" test).
- Response includes an `as_of` timestamp the client uses as the next `since` value.

### Field state β†’ visual class mapping

| Job state | CSS class on drop | Existing / new | Visual |
|---|---|---|---|
| `queued` | `is-queued` | **New** | Slow breath, paler halo; "waiting" reading |
| `running` | `is-pending` | Existing | Current pending animation (dashed rotating halo) |
| `succeeded` | (none) | Existing | Default settled drop |
| `failed` | `is-orphan` | Existing | Muted salmon halo + retry chip |
| `cancelled` | `is-cancelled` | **New** | Greyed-out, no retry chip |
| `superseded` | hidden OR `.is-superseded` | **New** | Typically hidden; shown faded if a debug toggle enabled |

Transitions:
- `queued β†’ running`: swap `is-queued` for `is-pending`.
- `running β†’ succeeded`: remove `is-pending`, merge real response data, `renderField()` for any layout adjustment.
- `running β†’ failed`: swap `is-pending` for `is-orphan`.

### SSE protocol (phase 4, future)

- Client opens `EventSource('/sa-core/v1/mission/events')` instead of polling.
- Server streams events like `event: job.state_changed\ndata: {"prompt_id":"…","state":"succeeded",…}\n\n` whenever a job's state changes for this viewer.
- Client falls back to polling if the `EventSource` fails.

### WebSocket (deferred)

Adds separate infra (Node sidecar, Ratchet-in-PHP, or a managed service like Pusher). No concrete need yet. Revisit when multi-user real-time collaboration becomes a live concern.

---

## 7. Migration Plan

### Principle: each phase ships a working system

No phase leaves the codebase in a half-broken state. At every phase boundary, a user can submit a prompt and see a result.

### Phase 0 β€” Concurrent-submit race fix (prerequisite, separate scope)

- Single commit, fixes the browser-state race in the current sync model.
- Does NOT introduce async.
- Makes parallel-submit testing reliable, which phase 2 needs.

### Phase 1 β€” Schema + shadow jobs (1–2 commits, zero behavior change)

Goal: write `sa_job` rows alongside synchronous execution, so the data layer exists before the execution path changes.

Work:
- Migration: create `sa_job` table (schema in Β§4).
- `SA_Executor::run` remains synchronous. At the end, it writes a `sa_job` row with `state='succeeded'` (or `'failed'` if the adapter errored).
- No REST change. No client change. No worker yet.

Test: CLI proof submits a prompt, verifies an `sa_job` row with correct state exists and references the prompt.

Rollback: drop the table via migration down; no user impact.

### Phase 2 β€” Async execution path (3–4 commits)

Goal: move adapter invocation out of the REST handler.

Work:
- Refactor `SA_Executor` into two entry-points:
  - `SA_Executor::run` β€” retained for direct synchronous use (CLI proofs, back-compat).
  - `SA_Executor::enqueue($prompt_text, $options)` β€” writes prompt entity + queued job, returns ids. Fast.
  - `SA_Executor::run_claimed($job_id)` β€” worker's entry-point; runs the pipeline against an existing prompt + job.
- `POST /prompt`: call `enqueue`, return `202` with `{ prompt_id, job_id }`.
- Client `submitPrompt`: handle `202`, render queued drop, start poll loop.
- Server: `GET /mission/recent` accepts `since` param; returns updated rows.
- Add worker CLI: `wp sa-core run-worker`.
- Add heartbeat cleanup: `wp sa-core reclaim-stuck-jobs`.
- New CSS classes: `is-queued`, `is-cancelled`.

Test:
- CLI proof: submit a prompt, verify `state=queued`, run worker once, verify `state=running` β†’ `state=succeeded`.
- Concurrent-submit proof: submit two prompts with different priorities (10 and 90), run worker, verify priority=10 runs first.
- Retry proof: submit, simulate transient failure (mock adapter), verify re-queue with backoff and eventual success.

Rollback: revert `POST /prompt` to call `run` synchronously. `sa_job` rows for already-queued jobs need manual cleanup (or a CLI command to replay them synchronously). Worker command stays but unused.

### Phase 3 β€” Priority queue (1–2 commits)

Goal: expose priority so active-thread prompts outrank bulk / background work.

Work:
- `SA_Executor::enqueue` accepts `priority` in options (default 50).
- Client: no UI change. Still submits at default. Future bulk operations (Scanner, Code Lens ingestion, etc.) pass higher priority values.
- Worker query already orders by priority β€” schema supports it from phase 1.

Test: submit 3 prompts with priorities {10, 50, 90}; verify worker claims in that order.

Rollback: trivial; remove priority from enqueue options, falls back to default.

### Phase 4 β€” SSE layer (2–3 commits)

Goal: eliminate polling overhead.

Work:
- Add `GET /sa-core/v1/mission/events` endpoint that streams SSE.
- Server-side: when a job's state changes, broadcast an SSE event to that viewer's stream.
- Client: detect `EventSource` support; subscribe if available; else continue polling.

Test: compare SSE event timing vs polling; verify identical state propagation; verify graceful fallback when connection drops.

Rollback: client falls back to polling automatically if SSE endpoint returns 404 or connection fails.

### Phase 5 β€” Hardening (2–4 commits)

Goal: operator visibility, intervention, metrics.

Work:
- `wp sa-core list-jobs [--state=] [--org=] [--since=]` β€” list jobs with filters.
- `wp sa-core job-details <job_id>` β€” full history for a job.
- `wp sa-core cancel-job <job_id>` β€” operator cancellation.
- `wp sa-core retry-job <job_id>` β€” explicit requeue.
- Optional: `wp sa-core queue-metrics` β€” count by state, average latency per org, error rate.

Test: CLI proofs for each command.

### Phase 6 β€” async-native features (optional, post-pillar)

Built on the same `sa_job` infrastructure, no further schema changes:

- Background bulk operations (Scanner, Code Lens, Doc Lens at scale).
- Scheduled prompts (run at 2am) via a `scheduled_for` column on `sa_job`.
- Multi-prompt chains (job B starts after job A succeeds).

Not part of the async pillar's completion criteria; listed for continuity.

### Test discipline at each phase

Every phase ships one or more CLI proofs in the `includes/cli/` pattern already established (geodesic-proof, promotion-proof, projection-proof, claim-continuation):

- `async-execution-proof`: state progression queued β†’ running β†’ succeeded against a synthetic prompt.
- `priority-queue-proof`: three synthetic prompts at different priorities; verify claim order.
- `retry-proof`: simulated transient adapter failure; verify re-queue and eventual success.
- `heartbeat-proof`: start a synthetic running job, sleep past heartbeat window, run reclaim, verify state.
- `concurrent-safety-proof`: simulate double-claim race; verify only one worker's writes take effect.

---

## 8. Failure and Retry Model

### Error classification

Every failure gets an `error_kind`:

| `error_kind` | Meaning | Retryable? | Backoff |
|---|---|---|---|
| `timeout` | cURL 28 or executor-level timeout | Yes | Exponential (5s, 25s, 125s + jitter) |
| `rate_limit` | Adapter HTTP 429 | Yes | Respect `Retry-After` header; else 60s min |
| `provider_5xx` | Adapter returned 5xx | Yes | Exponential |
| `provider_4xx` (non-429) | Adapter returned 4xx | No | N/A |
| `parse` | Response body couldn't be parsed | No | N/A |
| `auth` | Credentials invalid | No | N/A (needs operator) |
| `worker_crash` | Worker died mid-execution | Yes (via reclaim) | Immediate |
| `stuck` | Job has been running > 15 min | No | Terminal; mark failed |
| `unknown` | Catch-all for unclassified errors | Yes (conservative default) | Exponential |

### Retry rules

- Default `max_attempts = 3`. Configurable per-enqueue via options.
- Backoff stored as a delay on the re-queued job's `created_at`: `created_at = now() + backoff`. Worker query only claims jobs where `created_at <= now()`, so backoff is honored naturally.
- Backoff durations:
  - Attempt 2: 5s + random(0, 5s)
  - Attempt 3: 25s + random(0, 15s)
  - (After attempt 3 fails: terminal)

### Timeout handling

- **Adapter timeout (180s)**: cURL 28. `error_kind='timeout'`. Retryable.
- **PHP `max_execution_time` (300s)**: worker process killed. Heartbeat lost. Reclaimed by `reclaim-stuck-jobs`.
- **Hard job ceiling (15 minutes)**: a running job started > 15 min ago with no recent heartbeat is declared stuck. `error_kind='stuck'`. Terminal.

### Dead-letter handling

- Jobs that exhaust `max_attempts` go to `state='failed'` with `error_kind` set.
- They remain visible in the user's field as `is-orphan` drops (same treatment as today's orphans from sync-failure).
- User can retry via the retry chip (same UX as today). Retry creates a new `sa_job` row with `supersedes=<old_id>`.
- Operator CLI: `wp sa-core list-jobs --state=failed` surfaces recent failures.
- Operator can run `wp sa-core retry-job <id>` to force a retry without user interaction.

### Stuck-job recovery

- `wp sa-core reclaim-stuck-jobs` runs via system cron every 5 minutes.
- Query: `state='running' AND running_heartbeat_at < now() - INTERVAL 5 MINUTE`.
- For each:
  - If `attempt < max_attempts` AND `started_at > now() - 15min`: reclaim to `state='queued'`, clear `worker_claim`.
  - Else: `state='failed'`, `error_kind='stuck'`.

### Double-execution safety

Mitigation already detailed in Β§5: all finalization writes are conditioned on `WHERE state='running' AND worker_claim=<my_worker_id>`. Second-arriving writer sees no matching row and exits silently. First-arriving result wins.

Additional safeguard: response entity writes are keyed on a deterministic id derived from `(prompt_id, adapter_request_id)` when available, so duplicate writes collide on primary key and the second `INSERT` fails gracefully (caught by the finalization code).

---

## 9. Risks and Tradeoffs

### Risk: cron reliability on managed hosts

WP Engine (and many managed hosts) run cron via `wp-cron.php` spawned on page requests. On low-traffic sites this is unreliable.

**Mitigation.** Document that production deployments SHOULD use a real system cron hitting `wp sa-core run-worker --once`. The command is standalone (doesn't depend on wp-cron's spawning). An on-page-load spawn continues to work for dev and low-volume sites.

### Risk: worker compute resource pressure

A long-running worker process consumes PHP process slots. On shared hosting, this may conflict with web requests.

**Mitigation.** Default worker mode is `--once` (cron-driven, not service-style). Batch size is tunable. For high-volume users, the operator can tune cron frequency + batch size to stay within their host's limits.

### Risk: `sa_job` table growth

Every prompt creates β‰₯ 1 job. Retries create additional jobs per prompt. Over time, the table grows unbounded.

**Mitigation.** Optional archival policy (phase 5 or later): jobs in a terminal state older than 30 days get archived or removed. `wp sa-core archive-old-jobs --before=<date>`. Indexes keep queries fast regardless of row count at expected volumes; archival is an optimization, not a correctness concern.

### Risk: client polling overhead

Many users Γ— many tabs Γ— many in-flight jobs = many simultaneous polls.

**Mitigation.**
- Polls happen only while in-flight jobs exist; idle tabs don't poll.
- Poll interval is 2s; tunable.
- Phase 4 (SSE) eliminates this risk entirely for modern browsers.

### Risk: stale merges on client

If the client has an in-memory edit (user typing in compose) and a poll fetches fresh entity data, we must not discard user input.

**Mitigation.** Poll merges by `prompt_id` and only updates entity-state fields (response_id, state, context_ids, etc.). Compose-input state lives in the DOM, untouched by poll handling.

### Risk: concurrent-submit out-of-order display

Two prompts submitted close together may complete in the reverse order (e.g., P1 is slow, P2 is fast). The field would render P2 before P1.

**Mitigation.** Field display order is by `prompt.created_at`, not job completion order. Stable regardless of async resolution order. The visual `is-queued` vs `is-running` vs settled state makes the running state transparent to the user.

### Tradeoff: consistency window vs latency

Async means there is a window where client shows `state='queued'` while server has already transitioned to `state='running'`. Polling latency is up to 2s. SSE tightens this to sub-second.

**Accepted.** Up to 2s of client-side staleness is not a meaningful UX regression; phase 4 closes it.

### Tradeoff: simplicity vs ecosystem reuse

Custom worker vs Action Scheduler. Custom is simpler for our narrow scope but means we own the scheduler. AS is richer but adds a dependency and adapter layer.

**Accepted.** Custom for phase 2. If we ever need to coexist with an AS-using site, the `sa_job` table's facade can be driven by either our worker or an AS callback without changing job semantics.

### Risk: migrating users mid-transition

Between phase 1 (shadow jobs written) and phase 2 (async path switched on), a user's `sa_job` rows represent historical sync executions. When phase 2 lands, any prompt submitted mid-transition could have incomplete metadata.

**Mitigation.** Phase 2 rollout during a low-traffic window. On deploy, any `sa_job` rows with `state='queued'` from pre-phase-2 (there shouldn't be any, but defensively) are either processed by the first worker run or marked `state='failed'` with `error_kind='migration'` for manual review.

---

## 10. Success Criteria

The async pillar is complete (through phase 2) when:

1. `POST /prompt` returns within 100ms, 99th percentile, regardless of adapter latency.
2. Closing the browser tab during a prompt's execution does NOT cancel the job; reopening the site shows the completed result.
3. Two prompts submitted to different continuation parents from different tabs are processed correctly, with no race-induced data corruption.
4. A cURL timeout on the adapter causes an automatic retry with exponential backoff; a permanently-failing prompt surfaces as a retriable orphan after `max_attempts` exhaustion.
5. A crashed worker's job is reclaimed within 5 minutes and either completes on the next attempt or fails cleanly.
6. The CLI proofs described in Β§7's test discipline section all pass.
7. Existing `/mission` UI works throughout β€” no regressions on selection, trace, thread view, promote, verdict, continuation, retry, or content-collapse.
8. The `wp sa-core run-worker` command can be configured to run under standard system cron without supervisor-style infra.

The pillar is **complete through phase 4** when additionally:

9. SSE delivers state updates within 200ms of server-side transition.
10. Polling mode still works identically as a fallback when SSE is unavailable.

---

## 11. Out of Scope (deliberately deferred)

- **Multi-node distributed coordination.** Single-node assumption in claim locking. Revisit if horizontal scaling ever becomes a concern.
- **Priority inversion prevention.** A long-running low-priority job blocking urgent jobs is theoretically possible but rare in our expected volume. Observe first; fix if it ever occurs.
- **User-facing queue inspection UI.** CLI only through phase 5. A UI can be added later; `sa_job` data is already viewer-scoped via `org_id + created_by` indexing.
- **Scheduled prompts** (`scheduled_for`): phase 6 (optional).
- **Multi-prompt chains** (dependent jobs): phase 6 (optional).
- **Per-org quotas** (max N concurrent running jobs): defer until multi-tenant scaling matters.
- **Cross-process worker coordination** (multiple machines): defer until single-machine capacity is exhausted.
- **Real-time collaboration** (two users editing the same field simultaneously): defer to the ACL / perspective-projection pillar.

---

## 12. Appendix: Summary of Design Decisions

| Concern | Decision | Rationale |
|---|---|---|
| Storage | New `sa_job` table | Separate execution from knowledge; supports multiple attempts cleanly |
| Worker | Custom WP-CLI runner | Narrow scope; direct control; ~300 lines; no new dependency |
| Scheduling driver | System cron β†’ `wp sa-core run-worker --once` | Reliable across hosts; standalone from wp-cron |
| State machine | 6 states + heartbeat + supersession | Covers all observed outcomes; no ambiguity |
| Client protocol | Polling (phase 2) β†’ SSE (phase 4) | Ships working async in phase 2; optimizes in phase 4 |
| Retry | Exponential backoff, max_attempts=3 | Handles transient failures; bounded pessimism |
| Rollback | Phase-by-phase; each has a defined revert | Safe to implement incrementally |
| Migration | Shadow jobs first (phase 1) β†’ switch path (phase 2) | Data layer proven before execution switch |

This spec is the contract. Implementation directives reference it by phase.