@cadraos/sdk 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -315,6 +315,13 @@ interface KanbanCard {
315
315
  executionId?: string;
316
316
  /** Summary of the card's current/last run, if any. */
317
317
  run?: KanbanRunSummary;
318
+ /**
319
+ * When this card next becomes DUE on a scheduled lane (ISO string) — maps to
320
+ * `agentic_card.next_due_at` (spec §3a / STORY-003/004). Drives the board's
321
+ * ⧗ "due" chip: `next_due_at ≤ now` → due (the next sweep will pick it up).
322
+ * OPTIONAL — absent → no due chip (today's render).
323
+ */
324
+ nextDueAt?: string;
318
325
  /** Who created the card. */
319
326
  createdBy?: string;
320
327
  /** When the card was created (ISO string). */
@@ -411,6 +418,13 @@ type StageRunStatus = "queued" | "running" | "completed" | "failed";
411
418
  * dispatch or a duplicate completion can't double-fire / double-advance
412
419
  * (design §5/§11 R2), and a `key` rename never collides with a prior run.
413
420
  * Backed by a host `stage_run` table with a UNIQUE constraint on `idemKey`.
421
+ *
422
+ * Stranded-run recovery (design §10): a run left `failed` (a dispatch threw) or
423
+ * stale `queued` (a process died before dispatch) is RE-CLAIMED by the next
424
+ * `beginStageRun` — reset to a fresh `queued` intent (with `attempts` bumped) —
425
+ * so a transient dispatch failure self-heals on the following sweep. v1 is
426
+ * acyclic-only: the idemKey pins `(cardId, stageId, version)`, so a re-claim
427
+ * only ever re-runs a FAILED attempt of the current stage, never a completed one.
414
428
  */
415
429
  interface StageRun {
416
430
  /** Idempotency key (`cardId:stageId:configVersion`; stageId = immutable id). UNIQUE. */
@@ -427,6 +441,19 @@ interface StageRun {
427
441
  executionUuid?: string;
428
442
  /** Error message (when failed). */
429
443
  error?: string;
444
+ /**
445
+ * Dispatch attempt count (design §10 — stranded-run recovery). `1` on first
446
+ * claim; incremented each time `beginStageRun` RE-CLAIMS a stranded
447
+ * (failed/stale-queued) run. Optional/back-compat — a host that doesn't track
448
+ * re-claims leaves it undefined; surfaced for the sweep digest + observability.
449
+ *
450
+ * v1 RECORDS attempts but does NOT cap them: self-heal is bounded by the
451
+ * CADENCE (one re-dispatch per sweep tick), not a max-attempts ceiling, so a
452
+ * transient outage recovers while a genuinely poison card simply re-fails once
453
+ * per tick (visible via this counter). A retry ceiling / backoff is a
454
+ * deliberate follow-on, not v1.
455
+ */
456
+ attempts?: number;
430
457
  /** When the run intent was created (ISO string). */
431
458
  createdAt: string;
432
459
  }
@@ -480,7 +507,12 @@ interface ApplyTransitionInput {
480
507
  *
481
508
  * 2. `beginStageRun` is a CAS insert keyed on `idemKey` (UNIQUE). It returns
482
509
  * `{ created: true, run }` on first insert and `{ created: false, run }`
483
- * when the key already exists — so a retried dispatch is a no-op.
510
+ * when a NON-reclaimable run already exists — so a retried dispatch is a
511
+ * no-op. A `failed` or STALE `queued` run is RE-CLAIMED (reset to `queued`
512
+ * → `{ created: true }`) so a transient dispatch failure self-heals on the
513
+ * next sweep (design §10 — stranded-run recovery); a `running`/`completed`
514
+ * run is never re-claimed. v1 is acyclic-only: the idemKey pins
515
+ * `(cardId, stageId, version)`, so a re-claim only re-runs a FAILED attempt.
484
516
  *
485
517
  * 3. `completeStageRun` runs in ONE transaction: mark the stage run completed
486
518
  * AND `applyTransition` to `toLane` together; it returns `{ applied: false }`
@@ -594,11 +626,42 @@ interface KanbanStorageAdapter {
594
626
  */
595
627
  capabilities?(): AdapterCapabilities;
596
628
  /**
597
- * CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE).
629
+ * CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE), with
630
+ * FAILED/STALE-QUEUED RE-CLAIM (design §10 — stranded-run recovery).
631
+ *
632
+ * @returns `{ created: true, run }` when this call OWNS the dispatch — either
633
+ * a first insert, OR a RE-CLAIM of an existing run left `failed` (a prior
634
+ * dispatch threw → `failStageRun`) or stale `queued` (a process died between
635
+ * begin and dispatch): the adapter resets that run to a fresh `queued` intent
636
+ * (clearing the stale error/executionUuid, bumping `attempts`) and the engine
637
+ * dispatches again. `{ created: false, run }` when a NON-reclaimable run
638
+ * already exists (`running` — mid-flight; or `completed`) — the engine treats
639
+ * this as "already dispatched"/terminal and does NOT re-invoke the handler.
640
+ *
641
+ * A host implements the re-claim as a single conditional write (e.g. an
642
+ * `UPDATE stage_run SET status='queued', attempts=attempts+1, created_at=now(),
643
+ * error=NULL, execution_uuid=NULL WHERE idem_key=$1 AND (status='failed' OR
644
+ * (status='queued' AND created_at < $stale)) RETURNING …`, alongside the plain
645
+ * CAS insert).
598
646
  *
599
- * @returns `{ created: true, run }` on first insert; `{ created: false, run }`
600
- * when the key already exists (the engine treats this as "already
601
- * dispatched" and does NOT re-invoke the handler).
647
+ * ⚠ CRITICAL for the "two overlapping ticks never both win" guarantee: the SET
648
+ * MUST move the row OUT of its own WHERE range — reset the `created_at`
649
+ * discriminator (as shown). Under READ COMMITTED, a concurrent tick's UPDATE
650
+ * blocks on the row lock, then RE-CHECKS its WHERE against the just-committed
651
+ * row: with `created_at=now()` the stale-queued predicate no longer matches, so
652
+ * it updates 0 rows (RETURNING empty → `created:false`). A naive
653
+ * `SET status='queued'` that leaves `created_at` UNCHANGED is the trap — both
654
+ * ticks still match the same stale `queued` row and BOTH re-dispatch. (The
655
+ * in-memory reference adapter resets `createdAt` for exactly this reason.)
656
+ * Equivalently, claim via a `SELECT … FOR UPDATE SKIP LOCKED` subselect. The
657
+ * staleness horizon MUST comfortably exceed the host's dispatch timeout so a
658
+ * genuinely in-flight `queued` row (mid dispatch call) is never re-claimed out
659
+ * from under it.
660
+ *
661
+ * v1 scope is ACYCLIC flows only: because the idemKey embeds
662
+ * `(cardId, stageId, version)`, a card can never legitimately re-enter the same
663
+ * stage at the same version, so a re-claim only ever re-runs a FAILED attempt
664
+ * of the current stage — it never re-executes a completed one.
602
665
  */
603
666
  beginStageRun(cardId: string, r: {
604
667
  stageId: string;
@@ -625,7 +688,12 @@ interface KanbanStorageAdapter {
625
688
  applied: boolean;
626
689
  }>;
627
690
  /**
628
- * Mark a stage run failed (surfaced + retryable). Does NOT advance the card.
691
+ * Mark a stage run failed (surfaced). Does NOT advance the card — it stays in
692
+ * its lane. The engine calls this when a stage DISPATCH throws (design §10 —
693
+ * stranded-run recovery), so the run is left `failed` (NOT a stranded `queued`
694
+ * row the CAS would skip forever) and is RE-CLAIMED by the next
695
+ * `beginStageRun` on the following sweep. A `completed` run is never clobbered
696
+ * (a late failure after a success is a no-op — idempotent).
629
697
  */
630
698
  failStageRun(idemKey: string, f: {
631
699
  error: string;
@@ -805,6 +873,13 @@ interface WireHandler {
805
873
  type?: string;
806
874
  agent_uuid?: string;
807
875
  team_uuid?: string;
876
+ /**
877
+ * remote_agent → the `remote_agents.uuid` for a `remote_agent` lane (§5.3).
878
+ * Snake-cased on the wire (published JSONB); mapped to `remoteAgentUuid`.
879
+ * WITHOUT this the binding is silently dropped at parse and every published
880
+ * remote_agent lane fails dispatch with "missing remoteAgentUuid" (FINDING-6).
881
+ */
882
+ remote_agent_uuid?: string;
808
883
  /** Legacy p28 slug ref. */
809
884
  agent_ref?: string;
810
885
  webhook?: {
@@ -994,6 +1069,14 @@ interface RunAgentInput {
994
1069
  input: Record<string, unknown>;
995
1070
  /** Idempotency key (= cardId:stageId:version). */
996
1071
  idemKey: string;
1072
+ /**
1073
+ * Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5).
1074
+ * The engine only ever dispatches cadra_agent stages from board surfaces,
1075
+ * which have NO ask_user UI — an ask attempt must FAIL the execution (→ failed
1076
+ * callback → completion bridge → §10 reclaim), never pause it. Pinned `false`
1077
+ * at the type boundary, matching RunRemoteAgentInput.
1078
+ */
1079
+ allowAskUser: false;
997
1080
  /** A host callback reference the run can advance through (optional). */
998
1081
  callbackRef?: string;
999
1082
  }
@@ -1003,6 +1086,8 @@ interface RunTeamInput {
1003
1086
  orgId?: number;
1004
1087
  input: Record<string, unknown>;
1005
1088
  idemKey: string;
1089
+ /** Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5). */
1090
+ allowAskUser: false;
1006
1091
  callbackRef?: string;
1007
1092
  }
1008
1093
  interface RunRemoteAgentInput {
@@ -1196,6 +1281,20 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
1196
1281
  private cards;
1197
1282
  private logs;
1198
1283
  private runs;
1284
+ /**
1285
+ * A `queued` stage-run older than this (ms) is STALE and re-claimable by
1286
+ * `beginStageRun` (design §10) — it models a run whose process died between
1287
+ * `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
1288
+ * re-claimable regardless of age. Must comfortably exceed the dispatch time so
1289
+ * a genuinely in-flight `queued` run is never re-claimed out from under it.
1290
+ */
1291
+ private readonly staleQueuedMs;
1292
+ /** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
1293
+ private readonly now;
1294
+ constructor(opts?: {
1295
+ staleQueuedMs?: number;
1296
+ now?: () => number;
1297
+ });
1199
1298
  listCards(boardConfigUuid: string): Promise<KanbanCard[]>;
1200
1299
  getCard(cardId: string): Promise<KanbanCard | null>;
1201
1300
  createCard(input: CreateCardServerInput): Promise<KanbanCard>;
@@ -1219,6 +1318,13 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
1219
1318
  created: boolean;
1220
1319
  run: StageRun;
1221
1320
  }>;
1321
+ /**
1322
+ * A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
1323
+ * `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
1324
+ * (a process died before dispatch flipped it). `running`/`completed` runs are
1325
+ * never re-claimed.
1326
+ */
1327
+ private isReclaimable;
1222
1328
  completeStageRun(idemKey: string, c: {
1223
1329
  executionUuid?: string;
1224
1330
  toLane?: string;
@@ -315,6 +315,13 @@ interface KanbanCard {
315
315
  executionId?: string;
316
316
  /** Summary of the card's current/last run, if any. */
317
317
  run?: KanbanRunSummary;
318
+ /**
319
+ * When this card next becomes DUE on a scheduled lane (ISO string) — maps to
320
+ * `agentic_card.next_due_at` (spec §3a / STORY-003/004). Drives the board's
321
+ * ⧗ "due" chip: `next_due_at ≤ now` → due (the next sweep will pick it up).
322
+ * OPTIONAL — absent → no due chip (today's render).
323
+ */
324
+ nextDueAt?: string;
318
325
  /** Who created the card. */
319
326
  createdBy?: string;
320
327
  /** When the card was created (ISO string). */
@@ -411,6 +418,13 @@ type StageRunStatus = "queued" | "running" | "completed" | "failed";
411
418
  * dispatch or a duplicate completion can't double-fire / double-advance
412
419
  * (design §5/§11 R2), and a `key` rename never collides with a prior run.
413
420
  * Backed by a host `stage_run` table with a UNIQUE constraint on `idemKey`.
421
+ *
422
+ * Stranded-run recovery (design §10): a run left `failed` (a dispatch threw) or
423
+ * stale `queued` (a process died before dispatch) is RE-CLAIMED by the next
424
+ * `beginStageRun` — reset to a fresh `queued` intent (with `attempts` bumped) —
425
+ * so a transient dispatch failure self-heals on the following sweep. v1 is
426
+ * acyclic-only: the idemKey pins `(cardId, stageId, version)`, so a re-claim
427
+ * only ever re-runs a FAILED attempt of the current stage, never a completed one.
414
428
  */
415
429
  interface StageRun {
416
430
  /** Idempotency key (`cardId:stageId:configVersion`; stageId = immutable id). UNIQUE. */
@@ -427,6 +441,19 @@ interface StageRun {
427
441
  executionUuid?: string;
428
442
  /** Error message (when failed). */
429
443
  error?: string;
444
+ /**
445
+ * Dispatch attempt count (design §10 — stranded-run recovery). `1` on first
446
+ * claim; incremented each time `beginStageRun` RE-CLAIMS a stranded
447
+ * (failed/stale-queued) run. Optional/back-compat — a host that doesn't track
448
+ * re-claims leaves it undefined; surfaced for the sweep digest + observability.
449
+ *
450
+ * v1 RECORDS attempts but does NOT cap them: self-heal is bounded by the
451
+ * CADENCE (one re-dispatch per sweep tick), not a max-attempts ceiling, so a
452
+ * transient outage recovers while a genuinely poison card simply re-fails once
453
+ * per tick (visible via this counter). A retry ceiling / backoff is a
454
+ * deliberate follow-on, not v1.
455
+ */
456
+ attempts?: number;
430
457
  /** When the run intent was created (ISO string). */
431
458
  createdAt: string;
432
459
  }
@@ -480,7 +507,12 @@ interface ApplyTransitionInput {
480
507
  *
481
508
  * 2. `beginStageRun` is a CAS insert keyed on `idemKey` (UNIQUE). It returns
482
509
  * `{ created: true, run }` on first insert and `{ created: false, run }`
483
- * when the key already exists — so a retried dispatch is a no-op.
510
+ * when a NON-reclaimable run already exists — so a retried dispatch is a
511
+ * no-op. A `failed` or STALE `queued` run is RE-CLAIMED (reset to `queued`
512
+ * → `{ created: true }`) so a transient dispatch failure self-heals on the
513
+ * next sweep (design §10 — stranded-run recovery); a `running`/`completed`
514
+ * run is never re-claimed. v1 is acyclic-only: the idemKey pins
515
+ * `(cardId, stageId, version)`, so a re-claim only re-runs a FAILED attempt.
484
516
  *
485
517
  * 3. `completeStageRun` runs in ONE transaction: mark the stage run completed
486
518
  * AND `applyTransition` to `toLane` together; it returns `{ applied: false }`
@@ -594,11 +626,42 @@ interface KanbanStorageAdapter {
594
626
  */
595
627
  capabilities?(): AdapterCapabilities;
596
628
  /**
597
- * CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE).
629
+ * CAS-insert a stage-run intent keyed on `idemKey` (UNIQUE), with
630
+ * FAILED/STALE-QUEUED RE-CLAIM (design §10 — stranded-run recovery).
631
+ *
632
+ * @returns `{ created: true, run }` when this call OWNS the dispatch — either
633
+ * a first insert, OR a RE-CLAIM of an existing run left `failed` (a prior
634
+ * dispatch threw → `failStageRun`) or stale `queued` (a process died between
635
+ * begin and dispatch): the adapter resets that run to a fresh `queued` intent
636
+ * (clearing the stale error/executionUuid, bumping `attempts`) and the engine
637
+ * dispatches again. `{ created: false, run }` when a NON-reclaimable run
638
+ * already exists (`running` — mid-flight; or `completed`) — the engine treats
639
+ * this as "already dispatched"/terminal and does NOT re-invoke the handler.
640
+ *
641
+ * A host implements the re-claim as a single conditional write (e.g. an
642
+ * `UPDATE stage_run SET status='queued', attempts=attempts+1, created_at=now(),
643
+ * error=NULL, execution_uuid=NULL WHERE idem_key=$1 AND (status='failed' OR
644
+ * (status='queued' AND created_at < $stale)) RETURNING …`, alongside the plain
645
+ * CAS insert).
598
646
  *
599
- * @returns `{ created: true, run }` on first insert; `{ created: false, run }`
600
- * when the key already exists (the engine treats this as "already
601
- * dispatched" and does NOT re-invoke the handler).
647
+ * ⚠ CRITICAL for the "two overlapping ticks never both win" guarantee: the SET
648
+ * MUST move the row OUT of its own WHERE range — reset the `created_at`
649
+ * discriminator (as shown). Under READ COMMITTED, a concurrent tick's UPDATE
650
+ * blocks on the row lock, then RE-CHECKS its WHERE against the just-committed
651
+ * row: with `created_at=now()` the stale-queued predicate no longer matches, so
652
+ * it updates 0 rows (RETURNING empty → `created:false`). A naive
653
+ * `SET status='queued'` that leaves `created_at` UNCHANGED is the trap — both
654
+ * ticks still match the same stale `queued` row and BOTH re-dispatch. (The
655
+ * in-memory reference adapter resets `createdAt` for exactly this reason.)
656
+ * Equivalently, claim via a `SELECT … FOR UPDATE SKIP LOCKED` subselect. The
657
+ * staleness horizon MUST comfortably exceed the host's dispatch timeout so a
658
+ * genuinely in-flight `queued` row (mid dispatch call) is never re-claimed out
659
+ * from under it.
660
+ *
661
+ * v1 scope is ACYCLIC flows only: because the idemKey embeds
662
+ * `(cardId, stageId, version)`, a card can never legitimately re-enter the same
663
+ * stage at the same version, so a re-claim only ever re-runs a FAILED attempt
664
+ * of the current stage — it never re-executes a completed one.
602
665
  */
603
666
  beginStageRun(cardId: string, r: {
604
667
  stageId: string;
@@ -625,7 +688,12 @@ interface KanbanStorageAdapter {
625
688
  applied: boolean;
626
689
  }>;
627
690
  /**
628
- * Mark a stage run failed (surfaced + retryable). Does NOT advance the card.
691
+ * Mark a stage run failed (surfaced). Does NOT advance the card — it stays in
692
+ * its lane. The engine calls this when a stage DISPATCH throws (design §10 —
693
+ * stranded-run recovery), so the run is left `failed` (NOT a stranded `queued`
694
+ * row the CAS would skip forever) and is RE-CLAIMED by the next
695
+ * `beginStageRun` on the following sweep. A `completed` run is never clobbered
696
+ * (a late failure after a success is a no-op — idempotent).
629
697
  */
630
698
  failStageRun(idemKey: string, f: {
631
699
  error: string;
@@ -805,6 +873,13 @@ interface WireHandler {
805
873
  type?: string;
806
874
  agent_uuid?: string;
807
875
  team_uuid?: string;
876
+ /**
877
+ * remote_agent → the `remote_agents.uuid` for a `remote_agent` lane (§5.3).
878
+ * Snake-cased on the wire (published JSONB); mapped to `remoteAgentUuid`.
879
+ * WITHOUT this the binding is silently dropped at parse and every published
880
+ * remote_agent lane fails dispatch with "missing remoteAgentUuid" (FINDING-6).
881
+ */
882
+ remote_agent_uuid?: string;
808
883
  /** Legacy p28 slug ref. */
809
884
  agent_ref?: string;
810
885
  webhook?: {
@@ -994,6 +1069,14 @@ interface RunAgentInput {
994
1069
  input: Record<string, unknown>;
995
1070
  /** Idempotency key (= cardId:stageId:version). */
996
1071
  idemKey: string;
1072
+ /**
1073
+ * Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5).
1074
+ * The engine only ever dispatches cadra_agent stages from board surfaces,
1075
+ * which have NO ask_user UI — an ask attempt must FAIL the execution (→ failed
1076
+ * callback → completion bridge → §10 reclaim), never pause it. Pinned `false`
1077
+ * at the type boundary, matching RunRemoteAgentInput.
1078
+ */
1079
+ allowAskUser: false;
997
1080
  /** A host callback reference the run can advance through (optional). */
998
1081
  callbackRef?: string;
999
1082
  }
@@ -1003,6 +1086,8 @@ interface RunTeamInput {
1003
1086
  orgId?: number;
1004
1087
  input: Record<string, unknown>;
1005
1088
  idemKey: string;
1089
+ /** Board/headless dispatch forces allowAskUser=false (spec §6.5 / FINDING-5). */
1090
+ allowAskUser: false;
1006
1091
  callbackRef?: string;
1007
1092
  }
1008
1093
  interface RunRemoteAgentInput {
@@ -1196,6 +1281,20 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
1196
1281
  private cards;
1197
1282
  private logs;
1198
1283
  private runs;
1284
+ /**
1285
+ * A `queued` stage-run older than this (ms) is STALE and re-claimable by
1286
+ * `beginStageRun` (design §10) — it models a run whose process died between
1287
+ * `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
1288
+ * re-claimable regardless of age. Must comfortably exceed the dispatch time so
1289
+ * a genuinely in-flight `queued` run is never re-claimed out from under it.
1290
+ */
1291
+ private readonly staleQueuedMs;
1292
+ /** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
1293
+ private readonly now;
1294
+ constructor(opts?: {
1295
+ staleQueuedMs?: number;
1296
+ now?: () => number;
1297
+ });
1199
1298
  listCards(boardConfigUuid: string): Promise<KanbanCard[]>;
1200
1299
  getCard(cardId: string): Promise<KanbanCard | null>;
1201
1300
  createCard(input: CreateCardServerInput): Promise<KanbanCard>;
@@ -1219,6 +1318,13 @@ declare class InMemoryStorageAdapter implements KanbanStorageAdapter {
1219
1318
  created: boolean;
1220
1319
  run: StageRun;
1221
1320
  }>;
1321
+ /**
1322
+ * A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
1323
+ * `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
1324
+ * (a process died before dispatch flipped it). `running`/`completed` runs are
1325
+ * never re-claimed.
1326
+ */
1327
+ private isReclaimable;
1222
1328
  completeStageRun(idemKey: string, c: {
1223
1329
  executionUuid?: string;
1224
1330
  toLane?: string;
@@ -10,6 +10,10 @@ function mapHandler(h) {
10
10
  type: h.type,
11
11
  agentUuid: h.agent_uuid,
12
12
  teamUuid: h.team_uuid,
13
+ // FINDING-6: carry the remote_agent binding through the wire parse. The
14
+ // engine's remote_agent dispatch reads `remoteAgentUuid`; dropping it here
15
+ // made every published remote_agent lane dispatch-dead.
16
+ remoteAgentUuid: h.remote_agent_uuid,
13
17
  agentRef: h.agent_ref,
14
18
  webhook: h.webhook ? {
15
19
  url: h.webhook.url,
@@ -217,6 +221,8 @@ var WebhookDeliveryError = class extends Error {
217
221
  this.retryable = retryable;
218
222
  this.name = "WebhookDeliveryError";
219
223
  }
224
+ status;
225
+ retryable;
220
226
  };
221
227
  async function hmacSign(secret, payload) {
222
228
  const enc = new TextEncoder();
@@ -319,6 +325,8 @@ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
319
325
  orgId: ctx.orgId,
320
326
  input: ctx.input,
321
327
  idemKey: runIdemKey,
328
+ // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
329
+ allowAskUser: false,
322
330
  callbackRef: ctx.callbackUrl
323
331
  });
324
332
  return { dispatched: true, executionUuid };
@@ -332,6 +340,8 @@ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
332
340
  orgId: ctx.orgId,
333
341
  input: ctx.input,
334
342
  idemKey: runIdemKey,
343
+ // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
344
+ allowAskUser: false,
335
345
  callbackRef: ctx.callbackUrl
336
346
  });
337
347
  return { dispatched: true, executionUuid };
@@ -382,10 +392,25 @@ function nextId(prefix) {
382
392
  idSeq += 1;
383
393
  return `${prefix}-${idSeq}`;
384
394
  }
395
+ var DEFAULT_STALE_QUEUED_MS = 15 * 6e4;
385
396
  var InMemoryStorageAdapter = class {
386
397
  cards = /* @__PURE__ */ new Map();
387
398
  logs = /* @__PURE__ */ new Map();
388
399
  runs = /* @__PURE__ */ new Map();
400
+ /**
401
+ * A `queued` stage-run older than this (ms) is STALE and re-claimable by
402
+ * `beginStageRun` (design §10) — it models a run whose process died between
403
+ * `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
404
+ * re-claimable regardless of age. Must comfortably exceed the dispatch time so
405
+ * a genuinely in-flight `queued` run is never re-claimed out from under it.
406
+ */
407
+ staleQueuedMs;
408
+ /** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
409
+ now;
410
+ constructor(opts) {
411
+ this.staleQueuedMs = opts?.staleQueuedMs ?? DEFAULT_STALE_QUEUED_MS;
412
+ this.now = opts?.now ?? (() => Date.now());
413
+ }
389
414
  // ----- Cards -----
390
415
  async listCards(boardConfigUuid) {
391
416
  return [...this.cards.values()].filter((c) => c.boardConfigUuid === boardConfigUuid).map((c) => this.publicCard(c));
@@ -485,18 +510,48 @@ var InMemoryStorageAdapter = class {
485
510
  // ----- Stage runs -----
486
511
  async beginStageRun(cardId, r) {
487
512
  const existing = this.runs.get(r.idemKey);
488
- if (existing) return { created: false, run: { ...existing } };
513
+ if (existing) {
514
+ if (this.isReclaimable(existing)) {
515
+ const run2 = {
516
+ idemKey: r.idemKey,
517
+ cardId,
518
+ stageId: r.stageId,
519
+ handlerType: r.handlerType,
520
+ status: "queued",
521
+ attempts: (existing.attempts ?? 1) + 1,
522
+ createdAt: new Date(this.now()).toISOString()
523
+ };
524
+ this.runs.set(r.idemKey, run2);
525
+ return { created: true, run: { ...run2 } };
526
+ }
527
+ return { created: false, run: { ...existing } };
528
+ }
489
529
  const run = {
490
530
  idemKey: r.idemKey,
491
531
  cardId,
492
532
  stageId: r.stageId,
493
533
  handlerType: r.handlerType,
494
534
  status: "queued",
495
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
535
+ attempts: 1,
536
+ createdAt: new Date(this.now()).toISOString()
496
537
  };
497
538
  this.runs.set(r.idemKey, run);
498
539
  return { created: true, run: { ...run } };
499
540
  }
541
+ /**
542
+ * A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
543
+ * `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
544
+ * (a process died before dispatch flipped it). `running`/`completed` runs are
545
+ * never re-claimed.
546
+ */
547
+ isReclaimable(run) {
548
+ if (run.status === "failed") return true;
549
+ if (run.status === "queued") {
550
+ const age = this.now() - Date.parse(run.createdAt);
551
+ return Number.isFinite(age) && age >= this.staleQueuedMs;
552
+ }
553
+ return false;
554
+ }
500
555
  async completeStageRun(idemKey, c) {
501
556
  const run = this.runs.get(idemKey);
502
557
  if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
@@ -640,6 +695,10 @@ var HttpAdapterError = class extends Error {
640
695
  this.body = body;
641
696
  this.name = "HttpAdapterError";
642
697
  }
698
+ status;
699
+ method;
700
+ path;
701
+ body;
643
702
  code = "http_adapter_error";
644
703
  };
645
704
  var CapabilitiesNotLoadedError = class extends Error {
@@ -1191,6 +1250,14 @@ function runAdapterConformance(makeAdapter, runner) {
1191
1250
  expect(still.lane).toBe("generate");
1192
1251
  });
1193
1252
  const asConductor = (adapter) => adapter.listDueCards && adapter.countsByLane && adapter.capabilities ? adapter : null;
1253
+ const supportsField = (adapter, field) => {
1254
+ const fields = adapter.capabilities().dueRuleFields;
1255
+ return fields.includes("*") || fields.includes(field);
1256
+ };
1257
+ const supportsPredicate = (adapter, ref) => {
1258
+ const preds = adapter.capabilities().dueRulePredicates;
1259
+ return preds.includes("*") || preds.includes(ref);
1260
+ };
1194
1261
  it("listDueCards {kind:'always'} returns every card in the lane", async () => {
1195
1262
  const base = await makeAdapter();
1196
1263
  const adapter = asConductor(base);
@@ -1222,6 +1289,7 @@ function runAdapterConformance(makeAdapter, runner) {
1222
1289
  const base = await makeAdapter();
1223
1290
  const adapter = asConductor(base);
1224
1291
  if (!adapter) return;
1292
+ if (!supportsField(adapter, "next_touch_at")) return;
1225
1293
  const past = new Date(Date.now() - 6e4).toISOString();
1226
1294
  const future = new Date(Date.now() + 6e4).toISOString();
1227
1295
  await seedCard(adapter, "scheduled", "due", { next_touch_at: past });
@@ -1243,6 +1311,7 @@ function runAdapterConformance(makeAdapter, runner) {
1243
1311
  const base = await makeAdapter();
1244
1312
  const adapter = asConductor(base);
1245
1313
  if (!adapter) return;
1314
+ if (!supportsPredicate(adapter, "status")) return;
1246
1315
  await seedCard(adapter, "queued", "approved", { status: "approved" });
1247
1316
  await seedCard(adapter, "queued", "draft", { status: "draft" });
1248
1317
  const rule = {
@@ -1259,6 +1328,8 @@ function runAdapterConformance(makeAdapter, runner) {
1259
1328
  const base = await makeAdapter();
1260
1329
  const adapter = asConductor(base);
1261
1330
  if (!adapter) return;
1331
+ if (!supportsField(adapter, "next_touch_at") || !supportsPredicate(adapter, "status"))
1332
+ return;
1262
1333
  const past = new Date(Date.now() - 6e4).toISOString();
1263
1334
  const future = new Date(Date.now() + 6e4).toISOString();
1264
1335
  await seedCard(adapter, "queued", "send-me", {
@@ -1521,20 +1592,28 @@ function createEngine(options) {
1521
1592
  if (!begin.created) {
1522
1593
  return { dispatched: false, executionUuid: begin.run.executionUuid };
1523
1594
  }
1524
- const result = await dispatchStageHandler(
1525
- {
1526
- cardId,
1527
- boardConfigUuid: card.boardId,
1528
- configVersion: cfg.version,
1529
- stage,
1530
- idemKey,
1531
- input: cardContext(card, stage),
1532
- orgId: options.orgId,
1533
- callbackUrl
1534
- },
1535
- cadraClient,
1536
- webhookOptions
1537
- );
1595
+ let result;
1596
+ try {
1597
+ result = await dispatchStageHandler(
1598
+ {
1599
+ cardId,
1600
+ boardConfigUuid: card.boardId,
1601
+ configVersion: cfg.version,
1602
+ stage,
1603
+ idemKey,
1604
+ input: cardContext(card, stage),
1605
+ orgId: options.orgId,
1606
+ callbackUrl
1607
+ },
1608
+ cadraClient,
1609
+ webhookOptions
1610
+ );
1611
+ } catch (err) {
1612
+ await adapter.failStageRun(idemKey, {
1613
+ error: err instanceof Error ? err.message : String(err)
1614
+ });
1615
+ throw err;
1616
+ }
1538
1617
  if (result.executionUuid && adapter.markStageRunDispatched) {
1539
1618
  await adapter.markStageRunDispatched(idemKey, {
1540
1619
  executionUuid: result.executionUuid
@@ -1603,21 +1682,30 @@ function createEngine(options) {
1603
1682
  }));
1604
1683
  batchMembers.set(batchKey, batchCards.map((c) => c.idemKey));
1605
1684
  const lead = eligible[0].card;
1606
- const result = await dispatchStageHandler(
1607
- {
1608
- cardId: lead.id,
1609
- boardConfigUuid: lead.boardId,
1610
- configVersion: cfg.version,
1611
- stage,
1612
- idemKey: eligible[0].idemKey,
1613
- input: batchContext(lead, stage, batchCards),
1614
- orgId: options.orgId,
1615
- callbackUrl,
1616
- batch: { batchKey, cards: batchCards }
1617
- },
1618
- cadraClient,
1619
- webhookOptions
1620
- );
1685
+ let result;
1686
+ try {
1687
+ result = await dispatchStageHandler(
1688
+ {
1689
+ cardId: lead.id,
1690
+ boardConfigUuid: lead.boardId,
1691
+ configVersion: cfg.version,
1692
+ stage,
1693
+ idemKey: eligible[0].idemKey,
1694
+ input: batchContext(lead, stage, batchCards),
1695
+ orgId: options.orgId,
1696
+ callbackUrl,
1697
+ batch: { batchKey, cards: batchCards }
1698
+ },
1699
+ cadraClient,
1700
+ webhookOptions
1701
+ );
1702
+ } catch (err) {
1703
+ const msg = err instanceof Error ? err.message : String(err);
1704
+ for (const { idemKey } of eligible) {
1705
+ await adapter.failStageRun(idemKey, { error: msg });
1706
+ }
1707
+ throw err;
1708
+ }
1621
1709
  if (result.executionUuid && adapter.markStageRunDispatched) {
1622
1710
  for (const { idemKey } of eligible) {
1623
1711
  await adapter.markStageRunDispatched(idemKey, {