@hotmeshio/hotmesh 0.25.5 → 0.25.6

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.
package/ISSUE.md CHANGED
@@ -1,53 +1,64 @@
1
- # ADDENDUMlost condition wakes are NOT restart-dependent (registration-window race)
1
+ ## EIGHTH ITEM a claimed row's post-signal settle can fail, stranding pending state that contradicts the workflow (dashboard-visible)
2
2
 
3
- Follow-up to the earlier "Durable wakes lost across process restart" report filed
4
- tonight (sleep timers + condition signals). New evidence sharpens the condition case.
3
+ Two order trails, same signature (hike-mono, 0.25.5/0.7.5):
4
+ - run A order `a0e68dba`: every station row resolved EXCEPT quality-control
5
+ yet the QC stationWorker delivered (shipping minted 5s later, order
6
+ COMPLETED). The QC row sits `pending` + claimed forever.
7
+ - run B order `89942462`: identical, at post-print-qa (claimed 07:34:44,
8
+ gluing resolved 07:34:54, order COMPLETED 07:35:11; the qa row still
9
+ pending+claimed 20+ min later).
5
10
 
6
- ## Reproduced with no restart involved
11
+ Trigger context (our side, fixed by us): two associates raced a shallow pond
12
+ and BOTH booked the same order in the same second (audit rows at 07:34:44) —
13
+ so a concurrent claim write was interleaved with the resolve.
7
14
 
8
- Same environment (hotmesh 0.25.4, Postgres, long-tail 0.7.3), process running
9
- continuously the whole time:
15
+ The library-side gap: the resolve path delivered the SIGNAL (workflow
16
+ advanced, next station minted) but the row's own transition to `resolved`
17
+ was lost against the concurrent claim — no error surfaced anywhere. The
18
+ result is dashboard-visible corruption: a live, claimable, countdown-bearing
19
+ work item asserting QA is owed on a COMPLETED order (real associates would
20
+ work it). Whatever branch handled this (atomic signal_key resolve or the
21
+ legacy handle.signal → ltResolveEscalation), signal delivery and row settle
22
+ must be atomic — or the settle must retry until the row cannot lie.
10
23
 
11
- - `13:59:36.204` pill escalation created (conditionLT atomic park, child
12
- `...-winddown-gluer-0`).
13
- - `13:59:36.237` row **resolved**, 33 ms after creation (a hot consumer was
14
- already scanning the pond and grabbed it instantly).
15
- - 15+ minutes later — the awaiting child is still `status > 0`, never woke;
16
- its parent's `Promise.all` wedged with it.
24
+ Repro shape: claim a signal_key escalation from principal A, and within the
25
+ same second re-claim/book from principal B while principal C resolves by
26
+ metadata. Expect: signal delivered AND row resolved, always.
17
27
 
18
- ## The pattern across all three observed condition cases
28
+ # RE-FILED two reports lost when this file was replaced with your 0.25.6 reply (both found the same evening, before it)
19
29
 
20
- Every lost condition wake we have seen shares one property: **the resolve landed
21
- sub-second after the conditionLT park** (33 ms, 232 ms, ~600 ms). Slow resolves
22
- (seconds or minutes after park) have never lost a wake across hundreds of
23
- station-worker resolutions tonight.
30
+ ## NINTH ITEM (re-filed seventh) a timeout wake KILLS the collator of a collated wait set; parent hangs forever
24
31
 
25
- Hypothesis: a resolve that commits **inside the awaiting workflow's
26
- wake-registration window** after the escalation row is visible to consumers
27
- but before the awaiter's subscription is fully registered delivers to nobody,
28
- and nothing ever re-checks the persisted row against sleeping awaiters. The
29
- restart in the earlier case was likely incidental; the window is the bug.
32
+ `Promise.all(21 × condition(signalId, {timeout:'1320s'}))` in one parent
33
+ (hike-mono simulationDirector, 0.25.5). Signal-path delivery through the
34
+ collation worked (seat 18 resolved cleanly at 04:55:05, its collator_waiter
35
+ slot consumed). At 05:07:25 UTC the FIRST batch of SLA expirations landed
36
+ (vm0..vm4 rows flipped 'expired' in the same second) and the collator job
37
+ (`HfSVaQSgUaYu3XGsCCjyQWy$C`) went **status -1 in that same second**
38
+ (context={}, is_live=t). Every subsequent expiry wake then hit
39
+ `Inactive job …$C` at CollatorService.assertJobActive (collator/index.js:13)
40
+ via Hook.processTimeHookEvent (hook.js:650) and was dropped. End state,
41
+ stable 10+ min: 20 live collator_waiter registry rows, all rows 'expired',
42
+ zero pending engine_streams for the collator, parent wedged in Promise.all
43
+ forever with no surfaced error.
30
44
 
31
- ## Suggested repro (tighter than the original)
45
+ Repro: `await Promise.all([condition('k1',{timeout:'30s'}), condition('k2',{timeout:'30s'})])`,
46
+ no resolver — let both expire. Expect collator -1 on first expiry, parent wedged.
32
47
 
33
- 1. Workflow A parks via `conditionLT` (the escalation-creating overload).
34
- 2. A hot loop polls for the row and resolves it the instant it appears
35
- (sub-100 ms). No process kill needed.
36
- 3. Observe whether A ever wakes. In our runs, a same-process consumer beating
37
- the registration window wedges A reliably enough to hit twice in one evening.
48
+ Our standing mitigation: each timeout-bearing wait moved into its own child
49
+ workflow (inline wait), parent collates ExecChild completions works, but
50
+ collated waits + SLA should compose.
38
51
 
39
- ## Suggested direction (unchanged, reinforced)
52
+ ## TENTH ITEM (re-filed follow-up) — second of two near-simultaneous sibling wakes lost (no crash, no restart)
40
53
 
41
- Boot-time AND periodic reconciliation of resolved-rows-with-sleeping-awaiters
42
- would heal both the restart loss and this race — the row carries the awaiting
43
- `workflow_id`; the join is cheap. Making the wake itself a stream message
44
- (liveness-redelivered since 0.25.4) also closes the window, since the message
45
- persists until reserved and acked.
46
-
47
- ## Our workaround (in production use as of tonight)
48
-
49
- Settlement guard in the watcher activity: after the row's outcome is known,
50
- poll `durable.jobs` for the awaiting child's status; if still running ~10s
51
- after its row resolved, terminate the child by id (terminate purges cleanly
52
- since 0.25.4) and treat the row's persisted resolver payload as authoritative.
53
- The parent catches the child's terminate rejection. Ugly but wedge-proof.
54
+ Clean floor, 10 orders: per order, TWO sibling printJobWorkers under one
55
+ dispatcher park signal_key waits; a machine resolves BOTH escalations
56
+ seconds apart. 3-for-3 wedged orders: LEFT worker woke and completed; RIGHT
57
+ worker (resolved moments later) never woke job row untouched since
58
+ creation, 10+ min, stored-pending-signal path did not recover it. 14/14
59
+ wakes delivered for the 7 orders whose resolves were spread out. Process up
60
+ the whole time (distinct from the reclaim case). Plausibly fixed by commit
61
+ 3's atomic wake INSERT ("no interleave window") — flagging so it gets a
62
+ sibling-burst regression test: resolve two waits of one parent within the
63
+ same tick, both must wake. Our mitigation: 0–4s jitter between sibling
64
+ resolves (also more realistic for print hardware).
@@ -0,0 +1,77 @@
1
+ # hotmesh 0.25.6 — fixes for the wake-loss and reservation reports (branch: fix/atomic-cancel-wake, three commits)
2
+
3
+ Everything below is implemented and fail-first tested (each fix was watched failing
4
+ before its change); the branch is in final full-suite verification before merge.
5
+
6
+ ## Commit 1 — cancellation wakes are durable
7
+
8
+ **Your second addendum (3 of 6 pills slept after the 400ms cancel wave).** Your
9
+ watchers settle waits via `cancel()`, and cancel's wake was a post-commit,
10
+ best-effort publish whose failure was silently discarded. Now the cancellation
11
+ wake commits inside the cancel transaction: a cancel that is durable always
12
+ resumes its `condition()` with `null`. A native six-pill burst test pins your
13
+ wave shape.
14
+
15
+ Also adds a permanent regression test for your converge topology
16
+ (`Promise.all([executeChild x4, sleep branch])`): the sleep's timehook mints and
17
+ the converge settles.
18
+
19
+ ## Commit 2 — dead reservations reclaim; resolved waits disarm their timers
20
+
21
+ **Your fifth data point (reservations never reclaimed after process death) — the
22
+ highest-leverage fix.** The fallback poller's discovery query filtered
23
+ `reserved_at IS NULL`, so a dead holder's reservation was invisible forever.
24
+ It now surfaces stale reservations (threshold tracks the configured window;
25
+ heartbeating work never matches), through dedicated index-backed branches, and
26
+ the fixed function re-deploys to existing databases on boot. A crash or deploy
27
+ now costs ≤ ~90 seconds of redelivery latency instead of a permanent wedge —
28
+ and this most plausibly explains your third data point too (wake messages
29
+ claimed by churning per-principal clients that died were never reclaimed).
30
+
31
+ **Your sixth item (armed-timeout drizzle).** When the signal wins an SLA-gated
32
+ wait, the armed timeout is now disarmed with the settled wait — root and cycled
33
+ dimensions both covered. New timers are jid-stamped so job-scoped purges
34
+ (terminate) remove them as well. Timers armed before the upgrade drain as the
35
+ one-shot benign events you observed.
36
+
37
+ Also hardens the pending-signal handoff and cancel wake into single atomic
38
+ statements (safe on pooled connections, no interleave window; an in-window
39
+ wake refreshed by a newer signal delivers the newest payload).
40
+
41
+ ## Commit 3 — resolve paths made single-statement; bulk resolve protects waiters
42
+
43
+ - `resolve()` and `resolveByMetadata()` now use the same single-statement
44
+ atomic idiom as cancel: row lock, status guard, and the wake INSERT commit
45
+ as one unit on any connection type.
46
+ - `resolveMany()` now excludes rows backing a live `condition()` waiter — bulk
47
+ resolution carries no wake, so those rows stay pending for a targeted
48
+ `resolve()`/`cancel()` instead of silently stranding the workflow.
49
+ - Secured-mode worker responses stamp `jid`, so job-scoped purges address them.
50
+
51
+ ## Status of the remaining items
52
+
53
+ - **Lost `sleep()` timer (your timer forensics):** still not reproduced — your
54
+ exact topology mints and converges cleanly under test, and every registration
55
+ path checks out atomic. The reclaim fix (commit 2) may retire this too if the
56
+ collator's driving message was claimed by a dying client. The decisive
57
+ forensic if it recurs: the wedged director's collator job
58
+ (`SELECT * FROM durable.jobs WHERE key LIKE '%$C'`) plus that collator jid's
59
+ `engine_streams` rows, no filters — it records exactly which item-cycle
60
+ stopped.
61
+ - **`createClient` construction churn (your fourth data point):** your
62
+ per-principal memoization is the right mitigation; an upstream answer
63
+ (internal per-auth memoization or an explicit `client.close()`) is under
64
+ design as its own change.
65
+ - **Crash windows on interrupt's parent notification** (found by our internal
66
+ audit, same family as your wake fixes): being engineered in a dedicated
67
+ follow-up PR — the straightforward implementation measurably slowed interrupt
68
+ dispatch, and interrupts race against live completions by design, so it is
69
+ getting the unhurried version.
70
+
71
+ ## After upgrading
72
+
73
+ - Restarting workers drains any orphaned-reservation backlog automatically.
74
+ - The settlement guard can be retired again — cancel and resolve wakes are both
75
+ transactional now, and dead reservations self-heal.
76
+ - Watch for `hook-signal-pending-redelivered` (in-window wake handled) and the
77
+ reclaim redeliveries after any crash: both are the system working as intended.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.5",
3
+ "version": "0.25.6",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -575,6 +575,9 @@ class Hook extends activity_1.Activity {
575
575
  this.context.metadata.jid = jobId;
576
576
  this.context.metadata.gid = gId;
577
577
  this.context.metadata.dad = dad;
578
+ //captured for the timeout disarm below: processEvent's state
579
+ //hydration replaces context.metadata before the disarm runs
580
+ const waiterJid = jobId;
578
581
  // Inline retry for FORBIDDEN: Leg2 arrived in the window between
579
582
  // setHookSignal (standalone) and Leg1 transaction.exec(). The 100B
580
583
  // ledger digit is not yet visible. Leg1 needs only milliseconds to
@@ -589,6 +592,25 @@ class Hook extends activity_1.Activity {
589
592
  await this.processEvent(status, code, 'hook');
590
593
  if (code === 200) {
591
594
  await taskService.deleteWebHookSignal(this.config.hook.topic, data);
595
+ //signal won the race: disarm the SLA timeout leg (the mirror
596
+ //of the timeout-won path, which deletes the signal). The
597
+ //armed timehook may not survive to fire against the settled
598
+ //wait. Addressed by the SIGNAL's jid + dimensional address —
599
+ //processEvent has since rehydrated this.context.
600
+ if (this.config.sleep) {
601
+ try {
602
+ await this.store.expireTimeHook?.(waiterJid, this.metadata.aid);
603
+ }
604
+ catch (error) {
605
+ //best-effort: a surviving timer fires once and is
606
+ //consumed as an inactive-job event
607
+ this.logger.warn('hook-timeout-disarm-error', {
608
+ jid: waiterJid,
609
+ aid: this.metadata.aid,
610
+ error,
611
+ });
612
+ }
613
+ }
592
614
  //clean up orphan pending on the sibling signal topic
593
615
  // wfs.wait delivered → remove wfs.signal pending
594
616
  // wfs.signal delivered → remove wfs.wait pending
@@ -73,9 +73,9 @@ export declare class EscalationClientService {
73
73
  private _hashConnection;
74
74
  private _deliverEscalationSignal;
75
75
  /**
76
- * Builds the wake publish as a SQL command so the store can commit it
77
- * INSIDE the resolve transaction — the wake becomes durable with the
78
- * resolved row, closing the crash window between resolve commit and
76
+ * Builds the wake as a webhook message so the store can commit it
77
+ * INSIDE the resolve/cancel transaction — the wake becomes durable
78
+ * with the status change, closing the crash window between commit and
79
79
  * post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
80
80
  * topic fallback chain; returns null when no hook rule is deployed
81
81
  * for any candidate topic (the caller then keeps post-commit
@@ -132,10 +132,10 @@ export declare class EscalationClientService {
132
132
  /**
133
133
  * Cancels a pending escalation and delivers a cancellation signal to the
134
134
  * waiting workflow so that `condition()` returns `null`. Terminal rows
135
- * return `already-terminal`. Signal delivery is best-effort post-commit
136
- * the committed cancelled row is the durable record; any missed delivery
137
- * can be detected via a sweep of rows with `status = 'cancelled'` and a
138
- * non-null `signal_key`.
135
+ * return `already-terminal`. The cancellation wake commits inside the
136
+ * cancel transaction (same durability contract as `resolve()`); when no
137
+ * hook rule is deployed for any candidate topic, delivery falls back to
138
+ * a best-effort post-commit publish.
139
139
  */
140
140
  cancel(id: string, namespace?: string): Promise<CancelEscalationResult>;
141
141
  /**
@@ -207,6 +207,12 @@ export declare class EscalationClientService {
207
207
  * (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
208
208
  * See {@link resolve}.
209
209
  */
210
+ /**
211
+ * Bulk-resolves standalone escalations (rows with `signal_key = null`).
212
+ * Rows backing a live `condition()` waiter are excluded — they stay
213
+ * pending so a targeted `resolve()`/`cancel()` can deliver their wake;
214
+ * bulk resolution carries no wake and would strand the workflow.
215
+ */
210
216
  resolveMany(params: ResolveManyParams): Promise<EscalationEntry[]>;
211
217
  /**
212
218
  * Returns dashboard-ready escalation counts. `period` controls the window
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EscalationClientService = void 0;
4
4
  const enums_1 = require("../../modules/enums");
5
5
  const utils_1 = require("../../modules/utils");
6
- const key_1 = require("../../modules/key");
7
6
  const hotmesh_1 = require("../hotmesh");
8
7
  const types_1 = require("../../types");
9
8
  const stream_1 = require("../../types/stream");
@@ -148,9 +147,9 @@ class EscalationClientService {
148
147
  return delivered;
149
148
  }
150
149
  /**
151
- * Builds the wake publish as a SQL command so the store can commit it
152
- * INSIDE the resolve transaction — the wake becomes durable with the
153
- * resolved row, closing the crash window between resolve commit and
150
+ * Builds the wake as a webhook message so the store can commit it
151
+ * INSIDE the resolve/cancel transaction — the wake becomes durable
152
+ * with the status change, closing the crash window between commit and
154
153
  * post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
155
154
  * topic fallback chain; returns null when no hook rule is deployed
156
155
  * for any candidate topic (the caller then keeps post-commit
@@ -173,13 +172,10 @@ class EscalationClientService {
173
172
  metadata: { guid: (0, utils_1.guid)(), aid, topic: candidate },
174
173
  data: { id: signalKey, data },
175
174
  };
176
- const streamKey = engine.stream.mintKey(key_1.KeyType.STREAMS, {
177
- topic: null,
178
- });
179
- const { sql, params } = engine.stream._publishMessages(streamKey, [
180
- JSON.stringify(streamData),
181
- ]);
182
- return { forSignalKey: signalKey, sql, params };
175
+ return {
176
+ forSignalKey: signalKey,
177
+ message: JSON.stringify(streamData),
178
+ };
183
179
  }
184
180
  catch {
185
181
  /* candidate not deployed — try the next topic */
@@ -286,18 +282,27 @@ class EscalationClientService {
286
282
  /**
287
283
  * Cancels a pending escalation and delivers a cancellation signal to the
288
284
  * waiting workflow so that `condition()` returns `null`. Terminal rows
289
- * return `already-terminal`. Signal delivery is best-effort post-commit
290
- * the committed cancelled row is the durable record; any missed delivery
291
- * can be detected via a sweep of rows with `status = 'cancelled'` and a
292
- * non-null `signal_key`.
285
+ * return `already-terminal`. The cancellation wake commits inside the
286
+ * cancel transaction (same durability contract as `resolve()`); when no
287
+ * hook rule is deployed for any candidate topic, delivery falls back to
288
+ * a best-effort post-commit publish.
293
289
  */
294
290
  async cancel(id, namespace) {
295
291
  const ns = namespace ?? factory_1.APP_ID;
296
292
  const hm = await this._engine(null, ns);
297
- const result = await hm.engine.store.cancelEscalation(id, namespace);
293
+ const store = hm.engine.store;
294
+ //pre-build the cancellation wake so it commits INSIDE the cancel
295
+ //transaction — condition() resumes with null even if the process
296
+ //dies the instant the cancel commits
297
+ let wakeCommand = null;
298
+ const preview = await store.getEscalation(id, namespace);
299
+ if (preview?.signal_key) {
300
+ wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signal_key, { __escalation_cancelled: true });
301
+ }
302
+ const result = await store.cancelEscalation(id, namespace, wakeCommand ?? undefined);
298
303
  if (result.ok === true) {
299
304
  this._emit('cancelled', result.entry);
300
- if (result.entry.signal_key) {
305
+ if (result.entry.signal_key && !result.wakeEnqueued) {
301
306
  await this._deliverEscalationSignal(ns, result.entry.topic, {
302
307
  id: result.entry.signal_key,
303
308
  data: { __escalation_cancelled: true },
@@ -444,6 +449,12 @@ class EscalationClientService {
444
449
  * (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
445
450
  * See {@link resolve}.
446
451
  */
452
+ /**
453
+ * Bulk-resolves standalone escalations (rows with `signal_key = null`).
454
+ * Rows backing a live `condition()` waiter are excluded — they stay
455
+ * pending so a targeted `resolve()`/`cancel()` can deliver their wake;
456
+ * bulk resolution carries no wake and would strand the workflow.
457
+ */
447
458
  async resolveMany(params) {
448
459
  const hm = await this._engine(null, params.namespace);
449
460
  const entries = await hm.engine.store.resolveManyEscalations(params);
@@ -162,6 +162,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
162
162
  * picks it up — no intermediate table, no polling, fully transactional.
163
163
  */
164
164
  registerTimeHook(jobId: string, gId: string, activityId: string, _type: WorkListTaskType, deletionTime: number, dad: string, transaction?: ProviderTransaction): Promise<void>;
165
+ /**
166
+ * Disarms a scheduled timehook (soft delete) for one activity of a
167
+ * job — the mirror of registerTimeHook, called when the SIGNAL wins
168
+ * an SLA-gated wait so the armed timeout cannot fire against the
169
+ * settled workflow. Scoped by the jid index, then narrowed to the
170
+ * activity via the message metadata. Dimensional addressing is
171
+ * deliberately NOT matched: a job's waits on one activity are
172
+ * sequential (cycle N settles before cycle N+1 arms), so at most one
173
+ * timer per (jid, aid) is armed at a time, and the signal composite's
174
+ * address can differ from the stored one at cycle offsets.
175
+ */
176
+ expireTimeHook(jobId: string, activityId: string): Promise<number>;
165
177
  getNextTask(_listKey?: string): Promise<[
166
178
  listKey: string,
167
179
  jobId: string,
@@ -274,13 +286,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
274
286
  claimEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ClaimByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ClaimByMetadataResult>;
275
287
  releaseEscalation(params: import('../../../../types/hmsh_escalations').ReleaseEscalationParams): Promise<import('../../../../types/hmsh_escalations').ReleaseEscalationResult>;
276
288
  /**
277
- * Executes a pre-built wake publish inside the currently open resolve
278
- * transaction, guarded by a SAVEPOINT so a wake failure can never roll
279
- * back the resolve itself. Returns true when the wake row committed
280
- * with the transaction; false means the caller should fall back to
281
- * post-commit delivery.
289
+ * Composes the wake as a data-modifying CTE appended to a settle
290
+ * statement (resolve/cancel). The INSERT fires iff the settle CTE
291
+ * (`fromCTE`) produced a row whose signal_key matches the wake one
292
+ * atomic statement, safe on any connection type (Client or Pool),
293
+ * with no transaction window for unrelated queries to interleave
294
+ * into. Appends the wake params to `params` in place.
282
295
  */
283
- private enqueueEscalationWake;
296
+ private composeEscalationWakeCTE;
284
297
  resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
285
298
  signalKey?: string | null;
286
299
  topic?: string | null;
@@ -302,7 +315,9 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
302
315
  topic?: string | null;
303
316
  wakeEnqueued?: boolean;
304
317
  }>;
305
- cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult>;
318
+ cancelEscalation(id: string, namespace?: string, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult & {
319
+ wakeEnqueued?: boolean;
320
+ }>;
306
321
  escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
307
322
  updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
308
323
  appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
@@ -814,43 +814,52 @@ class PostgresStoreService extends __1.StoreService {
814
814
  const wasInsert = lockResult.rows[0]?.inserted;
815
815
  const isPending = captured?.startsWith('$pending::');
816
816
  if (isPending && redelivery) {
817
- //consume the marker and commit its redelivery as ONE unit: the
818
- //pending wake becomes a durable engine stream message in the
819
- //same transaction that destroys the marker, so the wake
820
- //survives a crash at any instant. A crash before COMMIT leaves
821
- //the marker intact for the resume path to consume again.
822
- const pendingData = captured.slice('$pending::'.length);
823
- const message = JSON.stringify({
824
- type: 'webhook',
825
- status: 'success',
826
- code: 200,
827
- metadata: {
828
- guid: (0, utils_1.guid)(),
829
- aid: redelivery.aid,
830
- topic: redelivery.topic,
831
- },
832
- data: JSON.parse(pendingData),
833
- });
817
+ //consume the marker and commit its redelivery as ONE statement:
818
+ //the pending wake becomes a durable engine stream message in
819
+ //the same atomic unit that destroys the marker, so the wake
820
+ //survives a crash at any instant. Single-statement composition
821
+ //is safe on any connection type (Client or Pool) and cannot
822
+ //interleave with unrelated queries. The consume is guarded on
823
+ //the value still being a pending marker, and the redelivered
824
+ //payload is read from the row itself — a marker refreshed by a
825
+ //newer signal between capture and consume delivers the NEWER
826
+ //payload rather than a stale copy.
834
827
  const schemaName = this.kvsql().safeName(this.appId);
835
- await this.pgClient.query('BEGIN');
836
- try {
837
- await this.pgClient.query(`UPDATE ${tableName}
838
- SET value = $1, expiry = NOW() + INTERVAL '${delay} seconds'
839
- WHERE key = $2`, [jobId, storedKey]);
840
- await this.pgClient.query(`INSERT INTO ${schemaName}.engine_streams
828
+ const res = await this.pgClient.query(`WITH prev AS (
829
+ SELECT value FROM ${tableName}
830
+ WHERE key = $1 FOR UPDATE
831
+ ),
832
+ consumed AS (
833
+ UPDATE ${tableName} t
834
+ SET value = $2, expiry = NOW() + INTERVAL '${delay} seconds'
835
+ FROM prev
836
+ WHERE t.key = $1 AND prev.value LIKE '$pending::%'
837
+ RETURNING prev.value AS pending_value
838
+ )
839
+ INSERT INTO ${schemaName}.engine_streams
841
840
  (stream_name, message, priority)
842
- VALUES ($1, $2, 5)`, [this.appId, message]);
843
- await this.pgClient.query('COMMIT');
844
- }
845
- catch (redeliveryError) {
846
- await this.pgClient.query('ROLLBACK');
847
- throw redeliveryError;
841
+ SELECT $3,
842
+ json_build_object(
843
+ 'type', 'webhook',
844
+ 'status', 'success',
845
+ 'code', 200,
846
+ 'metadata', json_build_object(
847
+ 'guid', $4::text, 'aid', $5::text, 'topic', $6::text),
848
+ 'data', substr(consumed.pending_value, 11)::json
849
+ )::text,
850
+ 5
851
+ FROM consumed
852
+ RETURNING id`, [storedKey, jobId, this.appId, (0, utils_1.guid)(), redelivery.aid, redelivery.topic]);
853
+ if ((res.rowCount ?? 0) > 0) {
854
+ this.logger.warn('hook-signal-pending-redelivered', {
855
+ key: signalKey,
856
+ topic: redelivery.topic,
857
+ });
858
+ return { success: true };
848
859
  }
849
- this.logger.warn('hook-signal-pending-redelivered', {
850
- key: signalKey,
851
- topic: redelivery.topic,
852
- });
853
- return { success: true };
860
+ //the marker vanished between capture and consume (a concurrent
861
+ //duplicate Leg1 consumed it) — idempotent no-op
862
+ return { success: false };
854
863
  }
855
864
  if (!wasInsert) {
856
865
  //step 2: row existed — overwrite with hook value
@@ -1022,10 +1031,12 @@ class PostgresStoreService extends __1.StoreService {
1022
1031
  metadata: { guid: (0, utils_1.guid)(), jid: jobId, gid: gId, dad: msgDad, aid },
1023
1032
  data: { timestamp: Date.now() },
1024
1033
  });
1034
+ //jid column stamped so job-scoped expiry (interrupt purge, timeout
1035
+ //disarm) can address armed timers through the partial jid index
1025
1036
  const sql = `INSERT INTO ${schemaName}.engine_streams
1026
- (stream_name, message, priority, visible_at)
1027
- VALUES ($1, $2, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
1028
- const params = [this.appId, message];
1037
+ (stream_name, jid, message, priority, visible_at)
1038
+ VALUES ($1, $2, $3, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
1039
+ const params = [this.appId, jobId, message];
1029
1040
  if (transaction && typeof transaction.addCommand === 'function') {
1030
1041
  transaction.addCommand(sql, params);
1031
1042
  }
@@ -1033,6 +1044,26 @@ class PostgresStoreService extends __1.StoreService {
1033
1044
  await this.pgClient.query(sql, params);
1034
1045
  }
1035
1046
  }
1047
+ /**
1048
+ * Disarms a scheduled timehook (soft delete) for one activity of a
1049
+ * job — the mirror of registerTimeHook, called when the SIGNAL wins
1050
+ * an SLA-gated wait so the armed timeout cannot fire against the
1051
+ * settled workflow. Scoped by the jid index, then narrowed to the
1052
+ * activity via the message metadata. Dimensional addressing is
1053
+ * deliberately NOT matched: a job's waits on one activity are
1054
+ * sequential (cycle N settles before cycle N+1 arms), so at most one
1055
+ * timer per (jid, aid) is armed at a time, and the signal composite's
1056
+ * address can differ from the stored one at cycle offsets.
1057
+ */
1058
+ async expireTimeHook(jobId, activityId) {
1059
+ const schemaName = this.kvsql().safeName(this.appId);
1060
+ const res = await this.pgClient.query(`UPDATE ${schemaName}.engine_streams
1061
+ SET expired_at = NOW()
1062
+ WHERE jid = $1 AND expired_at IS NULL AND visible_at > NOW()
1063
+ AND (message::jsonb ->> 'type') = 'timehook'
1064
+ AND (message::jsonb -> 'metadata' ->> 'aid') = $2`, [jobId, activityId]);
1065
+ return res.rowCount ?? 0;
1066
+ }
1036
1067
  async getNextTask(_listKey) {
1037
1068
  //no-op: time hooks are now engine_streams messages with future visible_at.
1038
1069
  //the engine's normal dequeue handles them when they become visible.
@@ -1832,85 +1863,95 @@ class PostgresStoreService extends __1.StoreService {
1832
1863
  return { ok: true, entry: row.entry_json };
1833
1864
  }
1834
1865
  /**
1835
- * Executes a pre-built wake publish inside the currently open resolve
1836
- * transaction, guarded by a SAVEPOINT so a wake failure can never roll
1837
- * back the resolve itself. Returns true when the wake row committed
1838
- * with the transaction; false means the caller should fall back to
1839
- * post-commit delivery.
1866
+ * Composes the wake as a data-modifying CTE appended to a settle
1867
+ * statement (resolve/cancel). The INSERT fires iff the settle CTE
1868
+ * (`fromCTE`) produced a row whose signal_key matches the wake one
1869
+ * atomic statement, safe on any connection type (Client or Pool),
1870
+ * with no transaction window for unrelated queries to interleave
1871
+ * into. Appends the wake params to `params` in place.
1840
1872
  */
1841
- async enqueueEscalationWake(wakeCommand, signalKey, escalationId) {
1842
- if (!wakeCommand || !signalKey || wakeCommand.forSignalKey !== signalKey) {
1843
- return false;
1844
- }
1845
- await this.pgClient.query('SAVEPOINT escalation_wake');
1846
- try {
1847
- await this.pgClient.query(wakeCommand.sql, wakeCommand.params);
1848
- return true;
1849
- }
1850
- catch (error) {
1851
- await this.pgClient.query('ROLLBACK TO SAVEPOINT escalation_wake');
1852
- this.logger.warn('escalation-wake-enqueue-error', {
1853
- escalationId,
1854
- error: error.message,
1855
- });
1856
- return false;
1873
+ composeEscalationWakeCTE(wakeCommand, params, fromCTE) {
1874
+ if (!wakeCommand) {
1875
+ return { wakeCTE: '', wakeCount: '0::int AS wake_count' };
1857
1876
  }
1877
+ const schemaName = this.kvsql().safeName(this.appId);
1878
+ const base = params.length;
1879
+ params.push(this.appId, wakeCommand.message, wakeCommand.forSignalKey);
1880
+ return {
1881
+ wakeCTE: `,
1882
+ wake AS (
1883
+ INSERT INTO ${schemaName}.engine_streams (stream_name, message, priority)
1884
+ SELECT $${base + 1}, $${base + 2}, 5 FROM ${fromCTE}
1885
+ WHERE ${fromCTE}.signal_key = $${base + 3}
1886
+ RETURNING id
1887
+ )`,
1888
+ wakeCount: '(SELECT COUNT(*) FROM wake)::int AS wake_count',
1889
+ };
1858
1890
  }
1859
1891
  async resolveEscalation(params, wakeCommand) {
1860
1892
  const { id, namespace, resolverPayload, metadata } = params;
1861
1893
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1862
- // metaJson is bound at a fixed index; the CASE no-ops when null so resolution
1863
- // can merge into the GIN-indexed metadata without disturbing the param layout.
1864
1894
  const metaJson = metadata ? JSON.stringify(metadata) : null;
1865
- // Explicit transaction: FOR UPDATE locks the row; WHERE guard on UPDATE is the TOCTOU
1866
- // barrier a concurrent caller whose UPDATE matches 0 rows sees 'already-resolved'.
1867
- // On crash before COMMIT neither write lands; on crash after COMMIT both are durable.
1868
- // The resolved row's signal_key is the durable proof for recovery sweeps.
1869
- await this.pgClient.query('BEGIN');
1870
- try {
1871
- const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1872
- WHERE id = $1 ${namespace ? 'AND namespace = $2' : ''} FOR UPDATE`, namespace ? [id, namespace] : [id]);
1873
- if (!lockResult.rows[0]) {
1874
- await this.pgClient.query('ROLLBACK');
1875
- return { ok: false, reason: 'not-found' };
1876
- }
1877
- const { signal_key, topic, status } = lockResult.rows[0];
1878
- if (status === 'cancelled') {
1879
- await this.pgClient.query('ROLLBACK');
1895
+ //single statement: FOR UPDATE lock, TOCTOU-guarded UPDATE, and the
1896
+ //wake INSERT are one atomic unit. The wake commits WITH the
1897
+ //resolve a crash after the statement leaves both the resolved row
1898
+ //and its wake message durable; before, neither. The engine_streams
1899
+ //INSERT trigger emits the delivery NOTIFY on commit. Safe on any
1900
+ //connection type; no window for unrelated queries to interleave.
1901
+ const sqlParams = namespace
1902
+ ? [id, payloadJson, metaJson, namespace]
1903
+ : [id, payloadJson, metaJson];
1904
+ const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, sqlParams, 'resolved');
1905
+ const result = await this.pgClient.query(`
1906
+ WITH target AS MATERIALIZED (
1907
+ SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1908
+ WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
1909
+ LIMIT 1 FOR UPDATE
1910
+ ),
1911
+ resolved AS (
1912
+ UPDATE public.hmsh_escalations e
1913
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1914
+ metadata = CASE WHEN $3::jsonb IS NOT NULL
1915
+ THEN COALESCE(e.metadata, '{}'::jsonb) || $3::jsonb
1916
+ ELSE e.metadata END,
1917
+ updated_at = NOW()
1918
+ FROM target
1919
+ WHERE e.id = target.id AND target.status = 'pending'
1920
+ RETURNING e.*
1921
+ )${wakeCTE}
1922
+ SELECT t.id, t.status AS prior_status, t.signal_key, t.topic,
1923
+ CASE
1924
+ WHEN r.id IS NOT NULL THEN 'resolved'
1925
+ WHEN t.id IS NULL THEN 'not-found'
1926
+ ELSE 'blocked'
1927
+ END AS outcome,
1928
+ row_to_json(r.*) AS entry_json,
1929
+ ${wakeCount}
1930
+ FROM (SELECT * FROM target) t
1931
+ FULL OUTER JOIN (SELECT * FROM resolved) r ON r.id = t.id
1932
+ `, sqlParams);
1933
+ const row = result.rows[0];
1934
+ if (!row || row.outcome === 'not-found')
1935
+ return { ok: false, reason: 'not-found' };
1936
+ if (row.outcome === 'blocked') {
1937
+ if (row.prior_status === 'cancelled')
1880
1938
  return { ok: false, reason: 'already-cancelled' };
1881
- }
1882
- if (status === 'expired') {
1939
+ if (row.prior_status === 'expired') {
1883
1940
  // The wait's SLA timer fired first and the workflow resumed with
1884
1941
  // false — the resolver payload has nowhere to go. Name it, so the
1885
1942
  // operator learns the deadline passed rather than believing the
1886
1943
  // resolution landed.
1887
- await this.pgClient.query('ROLLBACK');
1888
1944
  return { ok: false, reason: 'already-expired' };
1889
1945
  }
1890
- const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1891
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1892
- metadata = CASE WHEN $3::jsonb IS NOT NULL
1893
- THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1894
- ELSE metadata END,
1895
- updated_at = NOW()
1896
- WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''} AND status = 'pending'
1897
- RETURNING *`, namespace ? [id, payloadJson, metaJson, namespace] : [id, payloadJson, metaJson]);
1898
- if (!updateResult.rows[0]) {
1899
- await this.pgClient.query('ROLLBACK');
1900
- return { ok: false, reason: 'already-resolved' };
1901
- }
1902
- //the wake commits WITH the resolve: a crash after COMMIT leaves both
1903
- //the resolved row and its wake message durable; a crash before
1904
- //leaves neither. The engine_streams INSERT trigger emits the
1905
- //delivery NOTIFY on commit.
1906
- const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
1907
- await this.pgClient.query('COMMIT');
1908
- return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
1909
- }
1910
- catch (e) {
1911
- await this.pgClient.query('ROLLBACK');
1912
- throw e;
1946
+ return { ok: false, reason: 'already-resolved' };
1913
1947
  }
1948
+ return {
1949
+ ok: true,
1950
+ entry: row.entry_json,
1951
+ signalKey: row.signal_key,
1952
+ topic: row.topic,
1953
+ wakeEnqueued: row.wake_count > 0,
1954
+ };
1914
1955
  }
1915
1956
  /**
1916
1957
  * Non-locking preview of the row `resolveEscalationByMetadata` would
@@ -1938,46 +1979,66 @@ class PostgresStoreService extends __1.StoreService {
1938
1979
  const filter = JSON.stringify({ [key]: value });
1939
1980
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1940
1981
  const metaJson = metadata ? JSON.stringify(metadata) : null;
1941
- await this.pgClient.query('BEGIN');
1942
- try {
1943
- const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1944
- WHERE ${namespace ? 'namespace = $3 AND' : ''}
1945
- metadata @> $1::jsonb
1946
- AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1947
- AND status IN ('pending', 'cancelled')
1948
- ORDER BY priority ASC, created_at ASC
1949
- LIMIT 1 FOR UPDATE`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
1950
- if (!lockResult.rows[0]) {
1951
- await this.pgClient.query('ROLLBACK');
1952
- return { ok: false, reason: 'not-found' };
1953
- }
1954
- const { id, signal_key, topic, status } = lockResult.rows[0];
1955
- if (status === 'cancelled') {
1956
- await this.pgClient.query('ROLLBACK');
1982
+ //single statement — see resolveEscalation for the atomicity contract
1983
+ const sqlParams = namespace
1984
+ ? [filter, roles ?? null, payloadJson, metaJson, namespace]
1985
+ : [filter, roles ?? null, payloadJson, metaJson];
1986
+ const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, sqlParams, 'resolved');
1987
+ const result = await this.pgClient.query(`
1988
+ WITH target AS MATERIALIZED (
1989
+ SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1990
+ WHERE ${namespace ? 'namespace = $5 AND' : ''}
1991
+ metadata @> $1::jsonb
1992
+ AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1993
+ AND status IN ('pending', 'cancelled')
1994
+ ORDER BY priority ASC, created_at ASC
1995
+ LIMIT 1 FOR UPDATE
1996
+ ),
1997
+ resolved AS (
1998
+ UPDATE public.hmsh_escalations e
1999
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $3,
2000
+ metadata = CASE WHEN $4::jsonb IS NOT NULL
2001
+ THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
2002
+ ELSE e.metadata END,
2003
+ updated_at = NOW()
2004
+ FROM target
2005
+ WHERE e.id = target.id AND target.status = 'pending'
2006
+ RETURNING e.*
2007
+ )${wakeCTE}
2008
+ SELECT t.id, t.status AS prior_status, t.signal_key, t.topic,
2009
+ CASE
2010
+ WHEN r.id IS NOT NULL THEN 'resolved'
2011
+ WHEN t.id IS NULL THEN 'not-found'
2012
+ ELSE 'blocked'
2013
+ END AS outcome,
2014
+ row_to_json(r.*) AS entry_json,
2015
+ ${wakeCount}
2016
+ FROM (SELECT * FROM target) t
2017
+ FULL OUTER JOIN (SELECT * FROM resolved) r ON r.id = t.id
2018
+ `, sqlParams);
2019
+ const row = result.rows[0];
2020
+ if (!row || row.outcome === 'not-found')
2021
+ return { ok: false, reason: 'not-found' };
2022
+ if (row.outcome === 'blocked') {
2023
+ if (row.prior_status === 'cancelled')
1957
2024
  return { ok: false, reason: 'already-cancelled' };
1958
- }
1959
- const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1960
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1961
- metadata = CASE WHEN $3::jsonb IS NOT NULL
1962
- THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1963
- ELSE metadata END,
1964
- updated_at = NOW()
1965
- WHERE id = $1 AND status = 'pending'
1966
- RETURNING *`, [id, payloadJson, metaJson]);
1967
- if (!updateResult.rows[0]) {
1968
- await this.pgClient.query('ROLLBACK');
1969
- return { ok: false, reason: 'already-resolved' };
1970
- }
1971
- const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
1972
- await this.pgClient.query('COMMIT');
1973
- return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
1974
- }
1975
- catch (e) {
1976
- await this.pgClient.query('ROLLBACK');
1977
- throw e;
2025
+ return { ok: false, reason: 'already-resolved' };
1978
2026
  }
2027
+ return {
2028
+ ok: true,
2029
+ entry: row.entry_json,
2030
+ signalKey: row.signal_key,
2031
+ topic: row.topic,
2032
+ wakeEnqueued: row.wake_count > 0,
2033
+ };
1979
2034
  }
1980
- async cancelEscalation(id, namespace) {
2035
+ async cancelEscalation(id, namespace, wakeCommand) {
2036
+ //single statement: the cancellation wake is a data-modifying CTE
2037
+ //that fires iff the cancel lands AND the row's signal_key matches
2038
+ //the wake — atomic on any connection type (Client or Pool), with
2039
+ //no BEGIN/COMMIT window for unrelated queries to interleave into
2040
+ const params = namespace ? [id, namespace] : [id];
2041
+ const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, params, 'cancelled');
1981
2042
  const result = await this.pgClient.query(`
1982
2043
  WITH target AS MATERIALIZED (
1983
2044
  SELECT id, status FROM public.hmsh_escalations
@@ -1991,23 +2052,24 @@ class PostgresStoreService extends __1.StoreService {
1991
2052
  WHERE public.hmsh_escalations.id = target.id
1992
2053
  AND target.status = 'pending'
1993
2054
  RETURNING public.hmsh_escalations.*
1994
- )
2055
+ )${wakeCTE}
1995
2056
  SELECT t.id, t.status AS prior_status,
1996
2057
  CASE
1997
2058
  WHEN c.id IS NOT NULL THEN 'cancelled'
1998
2059
  WHEN t.id IS NULL THEN 'not-found'
1999
2060
  ELSE 'already-terminal'
2000
2061
  END AS outcome,
2001
- row_to_json(c.*) AS entry_json
2062
+ row_to_json(c.*) AS entry_json,
2063
+ ${wakeCount}
2002
2064
  FROM (SELECT * FROM target) t
2003
2065
  FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
2004
- `, namespace ? [id, namespace] : [id]);
2066
+ `, params);
2005
2067
  if (!result.rows[0] || result.rows[0].outcome === 'not-found')
2006
2068
  return { ok: false, reason: 'not-found' };
2007
2069
  if (result.rows[0].outcome === 'already-terminal')
2008
2070
  return { ok: false, reason: 'already-terminal' };
2009
2071
  const entry = result.rows[0].entry_json;
2010
- return { ok: true, entry };
2072
+ return { ok: true, entry, wakeEnqueued: result.rows[0].wake_count > 0 };
2011
2073
  }
2012
2074
  async escalateEscalationToRole(params) {
2013
2075
  const { id, targetRole, namespace } = params;
@@ -2156,6 +2218,7 @@ class PostgresStoreService extends __1.StoreService {
2156
2218
  WHERE id = ANY($2::uuid[])
2157
2219
  ${namespace ? 'AND namespace = $4' : ''}
2158
2220
  AND status = 'pending'
2221
+ AND signal_key IS NULL
2159
2222
  RETURNING *`, namespace ? [payloadJson, ids, metaJson, namespace] : [payloadJson, ids, metaJson]);
2160
2223
  return result.rows;
2161
2224
  }
@@ -35,6 +35,10 @@ async function deploySchema(streamClient, appId, logger) {
35
35
  await ensureIndexes(client, schemaName);
36
36
  await ensureProcedures(client, schemaName);
37
37
  await ensureStatementLevelTriggers(client, schemaName);
38
+ // Re-deploy the fallback poller's discovery function so existing
39
+ // databases receive predicate changes (v0.25.6: stale-reservation
40
+ // reclaim — see getNotifyVisibleMessagesSQL)
41
+ await client.query(getNotifyVisibleMessagesSQL(schemaName));
38
42
  }
39
43
  finally {
40
44
  await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
@@ -177,6 +181,19 @@ async function ensureIndexes(client, schemaName) {
177
181
  CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
178
182
  ON ${workerTable} (stream_name, priority DESC, id)
179
183
  WHERE expired_at IS NULL;
184
+ `);
185
+ // v0.25.6: in-flight partial indexes — bounded to currently reserved
186
+ // rows (≈ concurrent executions) — serve the fallback poller's
187
+ // stale-reservation reclaim branch (see getNotifyVisibleMessagesSQL)
188
+ await client.query(`
189
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_inflight
190
+ ON ${engineTable} (stream_name, reserved_at)
191
+ WHERE expired_at IS NULL AND reserved_at IS NOT NULL;
192
+ `);
193
+ await client.query(`
194
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_inflight
195
+ ON ${workerTable} (stream_name, reserved_at)
196
+ WHERE expired_at IS NULL AND reserved_at IS NOT NULL;
180
197
  `);
181
198
  // v0.18.0: add jid column to engine_streams for job tracing
182
199
  await client.query(`ALTER TABLE ${engineTable} ADD COLUMN IF NOT EXISTS jid TEXT NOT NULL DEFAULT ''`);
@@ -461,7 +478,34 @@ async function createNotificationTriggers(client, schemaName) {
461
478
  EXECUTE FUNCTION ${schemaName}.notify_new_worker_stream_message();
462
479
  `);
463
480
  // ---- Visibility timeout notification function (queries both tables) ----
464
- await client.query(`
481
+ await client.query(getNotifyVisibleMessagesSQL(schemaName));
482
+ }
483
+ /**
484
+ * The fallback poller's discovery function. Surfaces streams that have
485
+ * deliverable work: unreserved visible messages AND stale reservations
486
+ * whose holder died (heartbeats refresh reserved_at every ~15s while an
487
+ * activity runs, so any reservation older than 60s has a dead holder or
488
+ * is already claimable). Stale-reservation discovery is the ONLY path
489
+ * that reclaims orphaned in-flight work on a quiet stream — without it,
490
+ * a process death wedges its reserved messages forever. A premature
491
+ * surface (reservation window configured above 60s on a provider with
492
+ * static leases) costs one empty fetch; the claim query enforces the
493
+ * real window.
494
+ *
495
+ * Deployed on fresh schemas AND re-deployed on every boot (see
496
+ * deploySchema) so existing databases receive predicate changes.
497
+ */
498
+ function getNotifyVisibleMessagesSQL(schemaName) {
499
+ const engineTable = `${schemaName}.engine_streams`;
500
+ const workerTable = `${schemaName}.worker_streams`;
501
+ //stale threshold tracks the configured base window: heartbeats refresh
502
+ //reserved_at every base/2 (15s default), so live holders never look
503
+ //stale; 2x base keeps static-lease holders (secured workers, one
504
+ //adaptive doubling) from surfacing as false positives. Baked at
505
+ //deploy time from the deploying node's env — a premature surface
506
+ //costs one empty fetch (the claim query enforces the real window).
507
+ const staleSeconds = Math.max(60, enums_1.HMSH_RESERVATION_TIMEOUT_S * 2);
508
+ return `
465
509
  CREATE OR REPLACE FUNCTION ${schemaName}.notify_visible_messages()
466
510
  RETURNS INTEGER AS $$
467
511
  DECLARE
@@ -470,13 +514,23 @@ async function createNotificationTriggers(client, schemaName) {
470
514
  payload JSON;
471
515
  notification_count INTEGER := 0;
472
516
  BEGIN
473
- -- Engine streams
517
+ -- Engine streams: visible unreserved work (active_messages
518
+ -- partial index) UNION stale reservations whose holder died
519
+ -- (inflight partial index) — each branch index-matched
474
520
  FOR msg IN
475
- SELECT DISTINCT stream_name
476
- FROM ${engineTable}
477
- WHERE visible_at <= NOW()
478
- AND reserved_at IS NULL
479
- AND expired_at IS NULL
521
+ SELECT DISTINCT u.stream_name FROM (
522
+ (SELECT stream_name FROM ${engineTable}
523
+ WHERE visible_at <= NOW()
524
+ AND reserved_at IS NULL
525
+ AND expired_at IS NULL
526
+ LIMIT 50)
527
+ UNION ALL
528
+ (SELECT stream_name FROM ${engineTable}
529
+ WHERE expired_at IS NULL
530
+ AND reserved_at IS NOT NULL
531
+ AND reserved_at < NOW() - INTERVAL '${staleSeconds} seconds'
532
+ LIMIT 50)
533
+ ) u
480
534
  LIMIT 50
481
535
  LOOP
482
536
  channel_name := 'eng_' || msg.stream_name;
@@ -493,13 +547,21 @@ async function createNotificationTriggers(client, schemaName) {
493
547
  notification_count := notification_count + 1;
494
548
  END LOOP;
495
549
 
496
- -- Worker streams
550
+ -- Worker streams: same two index-matched branches
497
551
  FOR msg IN
498
- SELECT DISTINCT stream_name
499
- FROM ${workerTable}
500
- WHERE visible_at <= NOW()
501
- AND reserved_at IS NULL
502
- AND expired_at IS NULL
552
+ SELECT DISTINCT u.stream_name FROM (
553
+ (SELECT stream_name FROM ${workerTable}
554
+ WHERE visible_at <= NOW()
555
+ AND reserved_at IS NULL
556
+ AND expired_at IS NULL
557
+ LIMIT 50)
558
+ UNION ALL
559
+ (SELECT stream_name FROM ${workerTable}
560
+ WHERE expired_at IS NULL
561
+ AND reserved_at IS NOT NULL
562
+ AND reserved_at < NOW() - INTERVAL '${staleSeconds} seconds'
563
+ LIMIT 50)
564
+ ) u
503
565
  LIMIT 50
504
566
  LOOP
505
567
  channel_name := 'wrk_' || msg.stream_name;
@@ -519,7 +581,7 @@ async function createNotificationTriggers(client, schemaName) {
519
581
  RETURN notification_count;
520
582
  END;
521
583
  $$ LANGUAGE plpgsql;
522
- `);
584
+ `;
523
585
  }
524
586
  function getNotificationChannelName(streamName, isEngine) {
525
587
  const prefix = isEngine ? 'eng_' : 'wrk_';
@@ -296,12 +296,15 @@ async function dropDeadJobMessages(client, tableName, streamName, rows, liveness
296
296
  }
297
297
  }
298
298
  catch (error) {
299
- if (error?.code === '42P01') {
300
- //jobs table is not visible from this connection; the guard cannot
301
- //run here — interrupt-time purging (expireJobMessages) still applies
299
+ if (error?.code === '42P01' || error?.code === '42501') {
300
+ //jobs table is not visible (42P01) or not readable (42501) from
301
+ //this connection; the guard cannot run here — interrupt-time
302
+ //purging (expireJobMessages) still applies. Self-disable so the
303
+ //hot path stops issuing a failing cross-table query per fetch.
302
304
  liveness.enabled = false;
303
305
  logger.info('postgres-stream-liveness-guard-disabled', {
304
306
  jobsTable: liveness.jobsTable,
307
+ code: error.code,
305
308
  });
306
309
  }
307
310
  else {
@@ -150,17 +150,19 @@ function getCreateProceduresSQL(schemaName) {
150
150
  -- Engine stream_name is the schema/appId name
151
151
  engine_stream_name := '${schemaName}';
152
152
 
153
+ -- jid stamped from the message metadata (parity with the raw
154
+ -- publish path) so job-scoped expiry can address these rows
153
155
  IF p_max_retry_attempts IS NOT NULL THEN
154
156
  INSERT INTO ${engineTable}
155
- (stream_name, message, priority, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)
157
+ (stream_name, jid, message, priority, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)
156
158
  VALUES
157
- (engine_stream_name, p_message, p_priority, p_max_retry_attempts, p_backoff_coefficient, p_maximum_interval_seconds, p_visible_at, p_retry_attempt)
159
+ (engine_stream_name, COALESCE(p_message::jsonb -> 'metadata' ->> 'jid', ''), p_message, p_priority, p_max_retry_attempts, p_backoff_coefficient, p_maximum_interval_seconds, p_visible_at, p_retry_attempt)
158
160
  RETURNING id INTO new_id;
159
161
  ELSE
160
162
  INSERT INTO ${engineTable}
161
- (stream_name, message, priority, visible_at, retry_attempt)
163
+ (stream_name, jid, message, priority, visible_at, retry_attempt)
162
164
  VALUES
163
- (engine_stream_name, p_message, p_priority, p_visible_at, p_retry_attempt)
165
+ (engine_stream_name, COALESCE(p_message::jsonb -> 'metadata' ->> 'jid', ''), p_message, p_priority, p_visible_at, p_retry_attempt)
164
166
  RETURNING id INTO new_id;
165
167
  END IF;
166
168
 
@@ -106,17 +106,16 @@ export type ResolveEscalationResult = {
106
106
  reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
107
107
  };
108
108
  /**
109
- * A pre-built wake publish, executed INSIDE the resolve transaction so the
110
- * awaiting workflow's wake commits atomically with `status='resolved'`. The
111
- * SQL is a stream INSERT produced by the stream provider; `forSignalKey`
112
- * pins the command to the row it was built for — the store executes it only
113
- * when the locked row's `signal_key` matches (a mismatched row falls back
114
- * to post-commit delivery).
109
+ * A pre-built wake message, committed INSIDE the resolve/cancel transaction
110
+ * so the awaiting workflow's wake is durable with the status change. The
111
+ * client composes the webhook message; the store owns the stream INSERT.
112
+ * `forSignalKey` pins the wake to the row it was built for — it is written
113
+ * only when the affected row's `signal_key` matches (a mismatched row falls
114
+ * back to post-commit delivery).
115
115
  */
116
116
  export interface EscalationWakeCommand {
117
117
  forSignalKey: string;
118
- sql: string;
119
- params: unknown[];
118
+ message: string;
120
119
  }
121
120
  export type ReleaseEscalationResult = {
122
121
  ok: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.5",
3
+ "version": "0.25.6",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -0,0 +1,19 @@
1
+ jid,reserved_at,retry_attempt,created_at,message
2
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3,2026-07-06 20:19:07.953447+00,0,2026-07-06 20:18:07.945876+00,"{""metadata"":{""guid"":""HxfFFVmLVwtk37194ysP1qQ"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3"",""gid"":""HAXgFtL8ZicUsOywHCKUauE"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3"",""operatorId"":""0dfa0000-0000-4000-8000-000000000004"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":6,""plan"":[],""advertId"":""0a4af7d1-2dc4-4e06-9d43-791acf3c49e5""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
3
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4,2026-07-06 20:19:10.889217+00,0,2026-07-06 20:18:10.880925+00,"{""metadata"":{""guid"":""HXZnTUGkTix4uF7q14PuP0f"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4"",""gid"":""HO9SAZEoZGiycaugLuwuUXv"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4"",""operatorId"":""0dfa0000-0000-4000-8000-000000000005"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":6,""plan"":[],""advertId"":""a1d6d90a-a2cf-4621-a74f-e0ed1013cbc6""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
4
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5,2026-07-06 20:19:16.082786+00,0,2026-07-06 20:18:16.072927+00,"{""metadata"":{""guid"":""HE7nFSVKoh2oFe6-lYtCJWV"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5"",""gid"":""H697gK8YvdS-_waP4NLiL-L"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5"",""operatorId"":""0dfa0000-0000-4000-8000-000000000006"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":4,""plan"":[],""advertId"":""df441cd6-1811-41f0-b646-3c794ebf71d4""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
5
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1,2026-07-06 20:19:05.227375+00,0,2026-07-06 20:18:20.216145+00,"{""metadata"":{""guid"":""HTFIOwV5q0HYi8z04GwX9qj"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1"",""gid"":""HSR3wD1ryGQ6f5vzzGcO8eV"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1"",""operatorId"":""0dfa0000-0000-4000-8000-000000000002"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":13,""plan"":[],""advertId"":""e8ee662b-6de6-4a57-b8d6-ee9ed76e1f99""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
6
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0,2026-07-06 20:19:09.068044+00,0,2026-07-06 20:18:39.059327+00,"{""metadata"":{""guid"":""HcFhO_I7Fqn_5ftBd4uUTuZ"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0"",""gid"":""HyVUxkj0OkvEO2r0sdgtTan"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0"",""operatorId"":""0dfa0000-0000-4000-8000-000000000001"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":12,""plan"":[],""advertId"":""53bd1b3a-8ec3-4c35-a839-da93d9e23bc0""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
7
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2,2026-07-06 20:19:18.195319+00,0,2026-07-06 20:19:18.191286+00,"{""metadata"":{""guid"":""HWpXdbtZ-Bx5iqINxvAa6wf"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2"",""gid"":""HWzNcP3ToTg27Qc5u5MljTT"",""dad"":"",0,16,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2"",""operatorId"":""0dfa0000-0000-4000-8000-000000000003"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":8,""plan"":[],""advertId"":""a0a84553-5031-456d-ae02-5d9746b606fc""}],""workflowDimension"":""$1"",""index"":6,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2-$printerBatch$1-6"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2-$printerBatch$1-6"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
8
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager,2026-07-06 20:19:14.76557+00,0,2026-07-06 20:18:29.759824+00,"{""metadata"":{""guid"":""HzkL-DZTtXyIyDV8oZNZJxI"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager"",""gid"":""HCGavoqirvD8FwdwQOWgcov"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""managerBatch"",""arguments"":[{""laneId"":""9a93b820-dde3-425b-a320-260b91c08ec2"",""reviewSeconds"":3,""moveSeconds"":3,""idleTickSeconds"":3,""maxIterations"":25,""actorEmail"":""sim.manager.marco@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager-$managerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager-$managerBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
9
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0,2026-07-06 20:19:12.094467+00,0,2026-07-06 20:18:42.089308+00,"{""metadata"":{""guid"":""H2MwGXqBeP8-8nPX-T6ndnU"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0"",""gid"":""H7nf5o8VvELz6NHWGWGNsXe"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_QUALITY_CONTROL"",""role"":""quality-inspector"",""toStatus"":""NEEDS_SHIPPING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":7,""unparkedRejects"":[],""troubledOrders"":[""931a1e99-2db6-4280-b034-57e3c42a9aba""],""actorEmail"":""sim.inspector.ines@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
10
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1,2026-07-06 20:19:12.096894+00,0,2026-07-06 20:18:42.093836+00,"{""metadata"":{""guid"":""H4pQ4lScxaW8wUEiP8BQh8E"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1"",""gid"":""HQUqSMYqzsvODyjMvRNPtvl"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_QUALITY_CONTROL"",""role"":""quality-inspector"",""toStatus"":""NEEDS_SHIPPING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":7,""unparkedRejects"":[],""troubledOrders"":[""931a1e99-2db6-4280-b034-57e3c42a9aba""],""actorEmail"":""sim.inspector.tomas@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
11
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft,2026-07-06 20:19:16.150935+00,0,2026-07-06 20:18:46.146153+00,"{""metadata"":{""guid"":""HgtJMxOb1oYv6jJb2uErF0_"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft"",""gid"":""HN6b23imRu-wgDHA-DpxzLV"",""dad"":"",0,14,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""DRAFT"",""role"":""insole-designer"",""toStatus"":""NEEDS_MANUFACTURING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":24,""unparkedRejects"":[],""troubledOrders"":[""2b294913-f8ca-4639-9928-3abd53ea9616""],""actorEmail"":""sim.designer.dana@sim.hike.local"",""worklistStation"":""design-approval""}],""workflowDimension"":""$1"",""index"":4,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft-$associateBatch$1-4"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft-$associateBatch$1-4"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
12
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1,2026-07-06 20:19:04.938838+00,0,2026-07-06 20:18:49.932504+00,"{""metadata"":{""guid"":""HQD-nuRyxBmH431uJ8noMBS"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1"",""gid"":""HWM3gVxjxz9_K-EnQpCeFjY"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_FINISHING"",""role"":""finisher"",""toStatus"":""NEEDS_QUALITY_CONTROL"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":9,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.finisher.kofi@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
13
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0,2026-07-06 20:19:14.016307+00,0,2026-07-06 20:18:59.008759+00,"{""metadata"":{""guid"":""HHBHdHpX2Wear_cVJim70Ps"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0"",""gid"":""HkZyt5-JhLoCNX26Q4cyqbq"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_FINISHING"",""role"":""finisher"",""toStatus"":""NEEDS_QUALITY_CONTROL"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":11,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.finisher.ada@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
14
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping,2026-07-06 20:19:15.654293+00,0,2026-07-06 20:19:00.646061+00,"{""metadata"":{""guid"":""HffnP49JTwmHAwk3BNRn22l"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping"",""gid"":""HuK-iSfDdYXXBJWo2WwhCDF"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_SHIPPING"",""role"":""shipping-associate"",""toStatus"":""COMPLETED"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":12,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.shipper.june@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
15
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1,2026-07-06 20:19:05.381804+00,0,2026-07-06 20:19:05.3792+00,"{""metadata"":{""guid"":""Hx82JQ7g0Y4XT7nqPFK4km9"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1"",""gid"":""HoT3fZZg04_z7BKimsTs-Ei"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_GLUING"",""role"":""gluer"",""toStatus"":""NEEDS_FINISHING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":13,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.gluer.dev@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
16
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0,2026-07-06 20:19:05.386589+00,0,2026-07-06 20:19:05.384446+00,"{""metadata"":{""guid"":""HvNFncRHNbqR_OSOOiQ0M12"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0"",""gid"":""Hk7Wy8HO98wh45Clgp5-sUo"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_GLUING"",""role"":""gluer"",""toStatus"":""NEEDS_FINISHING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":13,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.gluer.rosa@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
17
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1,2026-07-06 20:19:06.832852+00,0,2026-07-06 20:19:06.829652+00,"{""metadata"":{""guid"":""Hg1xKYket-ULnS-HdHaNuiD"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1"",""gid"":""HLjxCrhPCUyqBaWbwSE0pxp"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""POST_PRINT_QA"",""role"":""post-print-qa"",""toStatus"":""NEEDS_GLUING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":14,""rejectRate"":{""pct"":15,""targetStation"":""PRINTING"",""reasons"":[""Layer separation on the arch — reprint"",""Surface finish below spec"",""Edge delamination at the heel cup"",""Warping on the medial side""],""leftQuantity"":1,""rightQuantity"":0},""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.qa.elena@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
18
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0,2026-07-06 20:19:12.746444+00,0,2026-07-06 20:19:12.744474+00,"{""metadata"":{""guid"":""HTl4tkkOMhgso2QMlQlKYvn"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0"",""gid"":""HY9mpKnlDbC3a1o3B4R-53h"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""POST_PRINT_QA"",""role"":""post-print-qa"",""toStatus"":""NEEDS_GLUING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":16,""rejectRate"":{""pct"":15,""targetStation"":""PRINTING"",""reasons"":[""Layer separation on the arch — reprint"",""Surface finish below spec"",""Edge delamination at the heel cup"",""Warping on the medial side""],""leftQuantity"":1,""rightQuantity"":0},""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.qa.marcus@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
19
+ simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design,2026-07-06 20:19:10.594054+00,0,2026-07-06 20:18:55.585455+00,"{""metadata"":{""guid"":""HeSdgdXIms_1Lhh2nNgc4EP"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design"",""gid"":""HU01R82t5rnplNLKWryLJPz"",""dad"":"",0,40,0,0"",""aid"":""proxyer"",""topic"":""simulation-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""designTwinBatch"",""arguments"":[{""user"":""sim.taika.iris@sim.hike.local"",""designSeconds"":2,""idleTickSeconds"":3,""maxIterations"":10,""concurrency"":4,""designedOrders"":[""0f596ab9-f449-485d-b06d-e74864750ae7"",""a5cedbc2-f726-4411-9ac3-10fd113532d2"",""c1a09bd7-bd4b-4eb1-bd3e-91d6b45b479f"",""94d74e0e-e4ce-4db7-91f3-02c1c081c2ba"",""0ed99be7-83b6-4a91-9aa5-560d5c110c6b"",""33b987bb-9253-483f-baa4-37d4ffd0903c"",""2c4bc240-1335-423d-96af-e5855b6ccc0f"",""0e7f24bb-3fba-40e5-a5a8-666f5c91fd49"",""ad0c05b6-4528-4f2a-9569-d06c76589b54"",""de2a02e2-33ca-4c0c-843c-5ad422c7c517"",""c008abd0-8b3b-44f7-9f1c-7d314f72f587"",""04a28a34-20f3-4d10-a636-3cb8f504f56e"",""52295fa8-452b-48ca-862b-64cf3ff8cae6"",""cb1dd956-639d-4ff8-9f56-c12f9c5278e8"",""fca4a3f0-fc49-47cd-8285-bf7e11c60dbe"",""bcd42ec0-f047-4acc-8e70-33575ae7535a"",""3b73fb95-2366-4498-ac9a-3f2d9eb4d1c0"",""3c9d174f-7928-42e7-9edb-2f3b38ba0ddc"",""1e61d445-77ba-4013-af89-1a6de07ff407"",""0b066160-06ab-4681-af20-8c36866c9987"",""fa9f996c-2327-4f30-8fdc-e63239483f23"",""ff31ec18-c7e3-4907-85f0-5c4eda42841f"",""f17a825c-8905-4166-b687-54015ddeae6c"",""931a1e99-2db6-4280-b034-57e3c42a9aba"",""2b294913-f8ca-4639-9928-3abd53ea9616"",""04caa323-662a-4c28-8de0-3655d435f206"",""b2eac688-d074-45d9-8a60-e87719b18c81"",""a11092fe-31e5-4cfe-815c-230a09f2165e"",""176e3d03-0813-4816-83fd-a785798a83bb"",""e1d887bd-1ad4-4436-b728-d75e7df2c39a"",""c8a0ffdc-91b0-443a-a8f9-c01c2e3935c0"",""83cbe6eb-ea9b-492e-8657-4f138e3df05e"",""e8e56685-82a3-4f88-a804-b6a8c21f0a4c"",""89d231bf-600b-40ed-8161-e58dbcc7157c"",""6904b329-7a63-465f-aa6a-82a1095638fb"",""9ca2abe9-ae5f-459a-a003-9ea4ad5cafc0"",""cae10b57-439a-4e98-b0d0-1bb172a9d79f"",""f90af7cb-401b-4e68-9f22-a45d34581d64"",""86a1d1b2-7287-48ab-94b3-544a743fda41"",""76793c77-67a0-4148-b83b-fa50f900111c""]}],""workflowDimension"":""$3"",""index"":8,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design-$designTwinBatch$3-8"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design-$designTwinBatch$3-8"",""workflowTopic"":""simulation-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"