@cadraos/sdk 0.3.8 → 0.3.10

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,
@@ -319,6 +323,8 @@ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
319
323
  orgId: ctx.orgId,
320
324
  input: ctx.input,
321
325
  idemKey: runIdemKey,
326
+ // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
327
+ allowAskUser: false,
322
328
  callbackRef: ctx.callbackUrl
323
329
  });
324
330
  return { dispatched: true, executionUuid };
@@ -332,6 +338,8 @@ async function dispatchStageHandler(ctx, cadra, webhookOpts) {
332
338
  orgId: ctx.orgId,
333
339
  input: ctx.input,
334
340
  idemKey: runIdemKey,
341
+ // Board/headless dispatch always disables ask_user (spec §6.5 / FINDING-5).
342
+ allowAskUser: false,
335
343
  callbackRef: ctx.callbackUrl
336
344
  });
337
345
  return { dispatched: true, executionUuid };
@@ -382,10 +390,25 @@ function nextId(prefix) {
382
390
  idSeq += 1;
383
391
  return `${prefix}-${idSeq}`;
384
392
  }
393
+ var DEFAULT_STALE_QUEUED_MS = 15 * 6e4;
385
394
  var InMemoryStorageAdapter = class {
386
395
  cards = /* @__PURE__ */ new Map();
387
396
  logs = /* @__PURE__ */ new Map();
388
397
  runs = /* @__PURE__ */ new Map();
398
+ /**
399
+ * A `queued` stage-run older than this (ms) is STALE and re-claimable by
400
+ * `beginStageRun` (design §10) — it models a run whose process died between
401
+ * `beginStageRun` and dispatch, so `failStageRun` never ran. A `failed` run is
402
+ * re-claimable regardless of age. Must comfortably exceed the dispatch time so
403
+ * a genuinely in-flight `queued` run is never re-claimed out from under it.
404
+ */
405
+ staleQueuedMs;
406
+ /** Injectable clock (ms) — real hosts use `Date.now`; tests pin it. */
407
+ now;
408
+ constructor(opts) {
409
+ this.staleQueuedMs = opts?.staleQueuedMs ?? DEFAULT_STALE_QUEUED_MS;
410
+ this.now = opts?.now ?? (() => Date.now());
411
+ }
389
412
  // ----- Cards -----
390
413
  async listCards(boardConfigUuid) {
391
414
  return [...this.cards.values()].filter((c) => c.boardConfigUuid === boardConfigUuid).map((c) => this.publicCard(c));
@@ -485,18 +508,48 @@ var InMemoryStorageAdapter = class {
485
508
  // ----- Stage runs -----
486
509
  async beginStageRun(cardId, r) {
487
510
  const existing = this.runs.get(r.idemKey);
488
- if (existing) return { created: false, run: { ...existing } };
511
+ if (existing) {
512
+ if (this.isReclaimable(existing)) {
513
+ const run2 = {
514
+ idemKey: r.idemKey,
515
+ cardId,
516
+ stageId: r.stageId,
517
+ handlerType: r.handlerType,
518
+ status: "queued",
519
+ attempts: (existing.attempts ?? 1) + 1,
520
+ createdAt: new Date(this.now()).toISOString()
521
+ };
522
+ this.runs.set(r.idemKey, run2);
523
+ return { created: true, run: { ...run2 } };
524
+ }
525
+ return { created: false, run: { ...existing } };
526
+ }
489
527
  const run = {
490
528
  idemKey: r.idemKey,
491
529
  cardId,
492
530
  stageId: r.stageId,
493
531
  handlerType: r.handlerType,
494
532
  status: "queued",
495
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
533
+ attempts: 1,
534
+ createdAt: new Date(this.now()).toISOString()
496
535
  };
497
536
  this.runs.set(r.idemKey, run);
498
537
  return { created: true, run: { ...run } };
499
538
  }
539
+ /**
540
+ * A stranded stage-run the next `beginStageRun` may re-claim (design §10): a
541
+ * `failed` run (dispatch threw), or a `queued` run older than `staleQueuedMs`
542
+ * (a process died before dispatch flipped it). `running`/`completed` runs are
543
+ * never re-claimed.
544
+ */
545
+ isReclaimable(run) {
546
+ if (run.status === "failed") return true;
547
+ if (run.status === "queued") {
548
+ const age = this.now() - Date.parse(run.createdAt);
549
+ return Number.isFinite(age) && age >= this.staleQueuedMs;
550
+ }
551
+ return false;
552
+ }
500
553
  async completeStageRun(idemKey, c) {
501
554
  const run = this.runs.get(idemKey);
502
555
  if (!run) throw new Error(`No stage run for idemKey ${idemKey}`);
@@ -1191,6 +1244,14 @@ function runAdapterConformance(makeAdapter, runner) {
1191
1244
  expect(still.lane).toBe("generate");
1192
1245
  });
1193
1246
  const asConductor = (adapter) => adapter.listDueCards && adapter.countsByLane && adapter.capabilities ? adapter : null;
1247
+ const supportsField = (adapter, field) => {
1248
+ const fields = adapter.capabilities().dueRuleFields;
1249
+ return fields.includes("*") || fields.includes(field);
1250
+ };
1251
+ const supportsPredicate = (adapter, ref) => {
1252
+ const preds = adapter.capabilities().dueRulePredicates;
1253
+ return preds.includes("*") || preds.includes(ref);
1254
+ };
1194
1255
  it("listDueCards {kind:'always'} returns every card in the lane", async () => {
1195
1256
  const base = await makeAdapter();
1196
1257
  const adapter = asConductor(base);
@@ -1222,6 +1283,7 @@ function runAdapterConformance(makeAdapter, runner) {
1222
1283
  const base = await makeAdapter();
1223
1284
  const adapter = asConductor(base);
1224
1285
  if (!adapter) return;
1286
+ if (!supportsField(adapter, "next_touch_at")) return;
1225
1287
  const past = new Date(Date.now() - 6e4).toISOString();
1226
1288
  const future = new Date(Date.now() + 6e4).toISOString();
1227
1289
  await seedCard(adapter, "scheduled", "due", { next_touch_at: past });
@@ -1243,6 +1305,7 @@ function runAdapterConformance(makeAdapter, runner) {
1243
1305
  const base = await makeAdapter();
1244
1306
  const adapter = asConductor(base);
1245
1307
  if (!adapter) return;
1308
+ if (!supportsPredicate(adapter, "status")) return;
1246
1309
  await seedCard(adapter, "queued", "approved", { status: "approved" });
1247
1310
  await seedCard(adapter, "queued", "draft", { status: "draft" });
1248
1311
  const rule = {
@@ -1259,6 +1322,8 @@ function runAdapterConformance(makeAdapter, runner) {
1259
1322
  const base = await makeAdapter();
1260
1323
  const adapter = asConductor(base);
1261
1324
  if (!adapter) return;
1325
+ if (!supportsField(adapter, "next_touch_at") || !supportsPredicate(adapter, "status"))
1326
+ return;
1262
1327
  const past = new Date(Date.now() - 6e4).toISOString();
1263
1328
  const future = new Date(Date.now() + 6e4).toISOString();
1264
1329
  await seedCard(adapter, "queued", "send-me", {
@@ -1521,20 +1586,28 @@ function createEngine(options) {
1521
1586
  if (!begin.created) {
1522
1587
  return { dispatched: false, executionUuid: begin.run.executionUuid };
1523
1588
  }
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
- );
1589
+ let result;
1590
+ try {
1591
+ result = await dispatchStageHandler(
1592
+ {
1593
+ cardId,
1594
+ boardConfigUuid: card.boardId,
1595
+ configVersion: cfg.version,
1596
+ stage,
1597
+ idemKey,
1598
+ input: cardContext(card, stage),
1599
+ orgId: options.orgId,
1600
+ callbackUrl
1601
+ },
1602
+ cadraClient,
1603
+ webhookOptions
1604
+ );
1605
+ } catch (err) {
1606
+ await adapter.failStageRun(idemKey, {
1607
+ error: err instanceof Error ? err.message : String(err)
1608
+ });
1609
+ throw err;
1610
+ }
1538
1611
  if (result.executionUuid && adapter.markStageRunDispatched) {
1539
1612
  await adapter.markStageRunDispatched(idemKey, {
1540
1613
  executionUuid: result.executionUuid
@@ -1603,21 +1676,30 @@ function createEngine(options) {
1603
1676
  }));
1604
1677
  batchMembers.set(batchKey, batchCards.map((c) => c.idemKey));
1605
1678
  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
- );
1679
+ let result;
1680
+ try {
1681
+ result = await dispatchStageHandler(
1682
+ {
1683
+ cardId: lead.id,
1684
+ boardConfigUuid: lead.boardId,
1685
+ configVersion: cfg.version,
1686
+ stage,
1687
+ idemKey: eligible[0].idemKey,
1688
+ input: batchContext(lead, stage, batchCards),
1689
+ orgId: options.orgId,
1690
+ callbackUrl,
1691
+ batch: { batchKey, cards: batchCards }
1692
+ },
1693
+ cadraClient,
1694
+ webhookOptions
1695
+ );
1696
+ } catch (err) {
1697
+ const msg = err instanceof Error ? err.message : String(err);
1698
+ for (const { idemKey } of eligible) {
1699
+ await adapter.failStageRun(idemKey, { error: msg });
1700
+ }
1701
+ throw err;
1702
+ }
1621
1703
  if (result.executionUuid && adapter.markStageRunDispatched) {
1622
1704
  for (const { idemKey } of eligible) {
1623
1705
  await adapter.markStageRunDispatched(idemKey, {