@hotmeshio/hotmesh 0.25.4 → 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 ADDED
@@ -0,0 +1,64 @@
1
+ ## EIGHTH ITEM — a claimed row's post-signal settle can fail, stranding pending state that contradicts the workflow (dashboard-visible)
2
+
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).
10
+
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.
14
+
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.
23
+
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.
27
+
28
+ # RE-FILED — two reports lost when this file was replaced with your 0.25.6 reply (both found the same evening, before it)
29
+
30
+ ## NINTH ITEM (re-filed seventh) — a timeout wake KILLS the collator of a collated wait set; parent hangs forever
31
+
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.
44
+
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.
47
+
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.
51
+
52
+ ## TENTH ITEM (re-filed follow-up) — second of two near-simultaneous sibling wakes lost (no crash, no restart)
53
+
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.4",
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
@@ -72,6 +72,16 @@ export declare class EscalationClientService {
72
72
  private _makeEngineFactory;
73
73
  private _hashConnection;
74
74
  private _deliverEscalationSignal;
75
+ /**
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
+ * post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
80
+ * topic fallback chain; returns null when no hook rule is deployed
81
+ * for any candidate topic (the caller then keeps post-commit
82
+ * delivery as the only path, preserving prior behavior).
83
+ */
84
+ private _buildWakeCommand;
75
85
  /**
76
86
  * Returns all escalation rows matching the given filters. Each row includes
77
87
  * a computed `available` field (true = claimable). Supports `sortBy`,
@@ -122,10 +132,10 @@ export declare class EscalationClientService {
122
132
  /**
123
133
  * Cancels a pending escalation and delivers a cancellation signal to the
124
134
  * waiting workflow so that `condition()` returns `null`. Terminal rows
125
- * return `already-terminal`. Signal delivery is best-effort post-commit
126
- * the committed cancelled row is the durable record; any missed delivery
127
- * can be detected via a sweep of rows with `status = 'cancelled'` and a
128
- * 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.
129
139
  */
130
140
  cancel(id: string, namespace?: string): Promise<CancelEscalationResult>;
131
141
  /**
@@ -197,6 +207,12 @@ export declare class EscalationClientService {
197
207
  * (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
198
208
  * See {@link resolve}.
199
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
+ */
200
216
  resolveMany(params: ResolveManyParams): Promise<EscalationEntry[]>;
201
217
  /**
202
218
  * Returns dashboard-ready escalation counts. `period` controls the window
@@ -5,6 +5,7 @@ const enums_1 = require("../../modules/enums");
5
5
  const utils_1 = require("../../modules/utils");
6
6
  const hotmesh_1 = require("../hotmesh");
7
7
  const types_1 = require("../../types");
8
+ const stream_1 = require("../../types/stream");
8
9
  const factory_1 = require("../durable/schemas/factory");
9
10
  /**
10
11
  * Standalone client for the `public.hmsh_escalations` signal-pause surface.
@@ -145,6 +146,43 @@ class EscalationClientService {
145
146
  catch { }
146
147
  return delivered;
147
148
  }
149
+ /**
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
153
+ * post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
154
+ * topic fallback chain; returns null when no hook rule is deployed
155
+ * for any candidate topic (the caller then keeps post-commit
156
+ * delivery as the only path, preserving prior behavior).
157
+ */
158
+ async _buildWakeCommand(ns, topic, signalKey, data) {
159
+ const hm = await this._engine(null, ns);
160
+ const engine = hm.engine;
161
+ const candidates = [topic, `${ns}.wfs.signal`, `${ns}.wfs.wait`].filter(Boolean);
162
+ for (const candidate of candidates) {
163
+ try {
164
+ const hookRule = await engine.taskService.getHookRule(candidate);
165
+ if (!hookRule)
166
+ continue;
167
+ const [aid] = await engine.getSchema(`.${hookRule.to}`);
168
+ const streamData = {
169
+ type: stream_1.StreamDataType.WEBHOOK,
170
+ status: types_1.StreamStatus.SUCCESS,
171
+ code: 200,
172
+ metadata: { guid: (0, utils_1.guid)(), aid, topic: candidate },
173
+ data: { id: signalKey, data },
174
+ };
175
+ return {
176
+ forSignalKey: signalKey,
177
+ message: JSON.stringify(streamData),
178
+ };
179
+ }
180
+ catch {
181
+ /* candidate not deployed — try the next topic */
182
+ }
183
+ }
184
+ return null;
185
+ }
148
186
  // ─── Public API ─────────────────────────────────────────────────────────────
149
187
  /**
150
188
  * Returns all escalation rows matching the given filters. Each row includes
@@ -244,18 +282,27 @@ class EscalationClientService {
244
282
  /**
245
283
  * Cancels a pending escalation and delivers a cancellation signal to the
246
284
  * waiting workflow so that `condition()` returns `null`. Terminal rows
247
- * return `already-terminal`. Signal delivery is best-effort post-commit
248
- * the committed cancelled row is the durable record; any missed delivery
249
- * can be detected via a sweep of rows with `status = 'cancelled'` and a
250
- * 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.
251
289
  */
252
290
  async cancel(id, namespace) {
253
291
  const ns = namespace ?? factory_1.APP_ID;
254
292
  const hm = await this._engine(null, ns);
255
- 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);
256
303
  if (result.ok === true) {
257
304
  this._emit('cancelled', result.entry);
258
- if (result.entry.signal_key) {
305
+ if (result.entry.signal_key && !result.wakeEnqueued) {
259
306
  await this._deliverEscalationSignal(ns, result.entry.topic, {
260
307
  id: result.entry.signal_key,
261
308
  data: { __escalation_cancelled: true },
@@ -292,10 +339,19 @@ class EscalationClientService {
292
339
  const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
293
340
  const hm = await this._engine(null, ns);
294
341
  const store = hm.engine.store;
295
- const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload, metadata: params.metadata });
342
+ //pre-build the wake so it commits INSIDE the resolve transaction; the
343
+ //row's signal routing (signal_key, topic) is immutable after creation
344
+ let wakeCommand = null;
345
+ const preview = await store.getEscalation(params.id, params.namespace);
346
+ if (preview?.signal_key) {
347
+ wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signal_key, params.resolverPayload ?? {});
348
+ }
349
+ const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload, metadata: params.metadata }, wakeCommand ?? undefined);
296
350
  if (!dbResult.ok)
297
351
  return dbResult;
298
- if (dbResult.signalKey) {
352
+ if (dbResult.signalKey && !dbResult.wakeEnqueued) {
353
+ //the wake was not part of the commit (no hook rule found, or the
354
+ //enqueue was rolled back to its savepoint) — deliver post-commit
299
355
  await this._deliverEscalationSignal(ns, dbResult.topic, {
300
356
  id: dbResult.signalKey,
301
357
  data: params.resolverPayload ?? {},
@@ -316,10 +372,22 @@ class EscalationClientService {
316
372
  const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
317
373
  const hm = await this._engine(null, ns);
318
374
  const store = hm.engine.store;
319
- const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles, metadata: params.metadata });
375
+ //peek the row the resolve is expected to lock and pre-build its wake;
376
+ //the forSignalKey guard covers the race where a different row wins
377
+ let wakeCommand = null;
378
+ const preview = await store.peekEscalationByMetadata({
379
+ key: params.key,
380
+ value: params.value,
381
+ roles: params.roles,
382
+ namespace: params.namespace,
383
+ });
384
+ if (preview?.signalKey) {
385
+ wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signalKey, params.resolverPayload ?? {});
386
+ }
387
+ const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles, metadata: params.metadata }, wakeCommand ?? undefined);
320
388
  if (!dbResult.ok)
321
389
  return dbResult;
322
- if (dbResult.signalKey) {
390
+ if (dbResult.signalKey && !dbResult.wakeEnqueued) {
323
391
  await this._deliverEscalationSignal(ns, dbResult.topic, {
324
392
  id: dbResult.signalKey,
325
393
  data: params.resolverPayload ?? {},
@@ -381,6 +449,12 @@ class EscalationClientService {
381
449
  * (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
382
450
  * See {@link resolve}.
383
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
+ */
384
458
  async resolveMany(params) {
385
459
  const hm = await this._engine(null, params.namespace);
386
460
  const entries = await hm.engine.store.resolveManyEscalations(params);
@@ -68,8 +68,17 @@ declare abstract class StoreService<Provider extends ProviderClient, Transaction
68
68
  * Leg1: Attempts to set the hook signal. If a pending signal occupies
69
69
  * the key (race condition), overwrites it and returns the pending data.
70
70
  * When called with a transaction, queues the setnxex (no pending detection).
71
+ *
72
+ * When `redelivery` is provided (the webhook routing for this hook),
73
+ * a consumed pending signal is republished as an engine stream message
74
+ * in the SAME transaction that overwrites the marker — the wake
75
+ * survives a crash at any instant. `pendingData` is then not returned,
76
+ * since the store already owns the redelivery.
71
77
  */
72
- abstract setHookSignal(hook: HookSignal, transaction?: TransactionProvider): Promise<{
78
+ abstract setHookSignal(hook: HookSignal, transaction?: TransactionProvider, redelivery?: {
79
+ aid: string;
80
+ topic: string;
81
+ }): Promise<{
73
82
  success: boolean;
74
83
  pendingData?: string;
75
84
  }>;
@@ -128,7 +128,10 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
128
128
  *
129
129
  * In a transaction: queues the setnxex; pending detection deferred.
130
130
  */
131
- setHookSignal(hook: HookSignal, transaction?: ProviderTransaction): Promise<{
131
+ setHookSignal(hook: HookSignal, transaction?: ProviderTransaction, redelivery?: {
132
+ aid: string;
133
+ topic: string;
134
+ }): Promise<{
132
135
  success: boolean;
133
136
  pendingData?: string;
134
137
  }>;
@@ -159,6 +162,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
159
162
  * picks it up — no intermediate table, no polling, fully transactional.
160
163
  */
161
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>;
162
177
  getNextTask(_listKey?: string): Promise<[
163
178
  listKey: string,
164
179
  jobId: string,
@@ -270,15 +285,39 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
270
285
  claimEscalation(params: import('../../../../types/hmsh_escalations').ClaimEscalationParams): Promise<import('../../../../types/hmsh_escalations').ClaimEscalationResult>;
271
286
  claimEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ClaimByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ClaimByMetadataResult>;
272
287
  releaseEscalation(params: import('../../../../types/hmsh_escalations').ReleaseEscalationParams): Promise<import('../../../../types/hmsh_escalations').ReleaseEscalationResult>;
273
- resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
288
+ /**
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.
295
+ */
296
+ private composeEscalationWakeCTE;
297
+ resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
274
298
  signalKey?: string | null;
275
299
  topic?: string | null;
300
+ wakeEnqueued?: boolean;
276
301
  }>;
277
- resolveEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
302
+ /**
303
+ * Non-locking preview of the row `resolveEscalationByMetadata` would
304
+ * select — used to pre-build the wake command before the resolve
305
+ * transaction opens. The `forSignalKey` guard on the wake command
306
+ * handles the race where a different row wins the lock.
307
+ */
308
+ peekEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams): Promise<{
309
+ id: string;
310
+ signalKey: string | null;
311
+ topic: string | null;
312
+ } | null>;
313
+ resolveEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
278
314
  signalKey?: string | null;
279
315
  topic?: string | null;
316
+ wakeEnqueued?: boolean;
317
+ }>;
318
+ cancelEscalation(id: string, namespace?: string, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult & {
319
+ wakeEnqueued?: boolean;
280
320
  }>;
281
- cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult>;
282
321
  escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
283
322
  updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
284
323
  appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
@@ -777,7 +777,7 @@ class PostgresStoreService extends __1.StoreService {
777
777
  *
778
778
  * In a transaction: queues the setnxex; pending detection deferred.
779
779
  */
780
- async setHookSignal(hook, transaction) {
780
+ async setHookSignal(hook, transaction, redelivery) {
781
781
  const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
782
782
  const { topic, resolved, jobId } = hook;
783
783
  const signalKey = `${topic}:${resolved}`;
@@ -813,6 +813,54 @@ class PostgresStoreService extends __1.StoreService {
813
813
  const captured = lockResult.rows[0]?.value;
814
814
  const wasInsert = lockResult.rows[0]?.inserted;
815
815
  const isPending = captured?.startsWith('$pending::');
816
+ if (isPending && redelivery) {
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.
827
+ const schemaName = this.kvsql().safeName(this.appId);
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
840
+ (stream_name, message, priority)
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 };
859
+ }
860
+ //the marker vanished between capture and consume (a concurrent
861
+ //duplicate Leg1 consumed it) — idempotent no-op
862
+ return { success: false };
863
+ }
816
864
  if (!wasInsert) {
817
865
  //step 2: row existed — overwrite with hook value
818
866
  await this.pgClient.query(`UPDATE ${tableName}
@@ -983,10 +1031,12 @@ class PostgresStoreService extends __1.StoreService {
983
1031
  metadata: { guid: (0, utils_1.guid)(), jid: jobId, gid: gId, dad: msgDad, aid },
984
1032
  data: { timestamp: Date.now() },
985
1033
  });
1034
+ //jid column stamped so job-scoped expiry (interrupt purge, timeout
1035
+ //disarm) can address armed timers through the partial jid index
986
1036
  const sql = `INSERT INTO ${schemaName}.engine_streams
987
- (stream_name, message, priority, visible_at)
988
- VALUES ($1, $2, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
989
- 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];
990
1040
  if (transaction && typeof transaction.addCommand === 'function') {
991
1041
  transaction.addCommand(sql, params);
992
1042
  }
@@ -994,6 +1044,26 @@ class PostgresStoreService extends __1.StoreService {
994
1044
  await this.pgClient.query(sql, params);
995
1045
  }
996
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
+ }
997
1067
  async getNextTask(_listKey) {
998
1068
  //no-op: time hooks are now engine_streams messages with future visible_at.
999
1069
  //the engine's normal dequeue handles them when they become visible.
@@ -1792,101 +1862,183 @@ class PostgresStoreService extends __1.StoreService {
1792
1862
  return { ok: false, reason: 'wrong-assignee' };
1793
1863
  return { ok: true, entry: row.entry_json };
1794
1864
  }
1795
- async resolveEscalation(params) {
1865
+ /**
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.
1872
+ */
1873
+ composeEscalationWakeCTE(wakeCommand, params, fromCTE) {
1874
+ if (!wakeCommand) {
1875
+ return { wakeCTE: '', wakeCount: '0::int AS wake_count' };
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
+ };
1890
+ }
1891
+ async resolveEscalation(params, wakeCommand) {
1796
1892
  const { id, namespace, resolverPayload, metadata } = params;
1797
1893
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1798
- // metaJson is bound at a fixed index; the CASE no-ops when null so resolution
1799
- // can merge into the GIN-indexed metadata without disturbing the param layout.
1800
1894
  const metaJson = metadata ? JSON.stringify(metadata) : null;
1801
- // Explicit transaction: FOR UPDATE locks the row; WHERE guard on UPDATE is the TOCTOU
1802
- // barrier a concurrent caller whose UPDATE matches 0 rows sees 'already-resolved'.
1803
- // On crash before COMMIT neither write lands; on crash after COMMIT both are durable.
1804
- // The resolved row's signal_key is the durable proof for recovery sweeps.
1805
- await this.pgClient.query('BEGIN');
1806
- try {
1807
- const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1808
- WHERE id = $1 ${namespace ? 'AND namespace = $2' : ''} FOR UPDATE`, namespace ? [id, namespace] : [id]);
1809
- if (!lockResult.rows[0]) {
1810
- await this.pgClient.query('ROLLBACK');
1811
- return { ok: false, reason: 'not-found' };
1812
- }
1813
- const { signal_key, topic, status } = lockResult.rows[0];
1814
- if (status === 'cancelled') {
1815
- 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')
1816
1938
  return { ok: false, reason: 'already-cancelled' };
1817
- }
1818
- if (status === 'expired') {
1939
+ if (row.prior_status === 'expired') {
1819
1940
  // The wait's SLA timer fired first and the workflow resumed with
1820
1941
  // false — the resolver payload has nowhere to go. Name it, so the
1821
1942
  // operator learns the deadline passed rather than believing the
1822
1943
  // resolution landed.
1823
- await this.pgClient.query('ROLLBACK');
1824
1944
  return { ok: false, reason: 'already-expired' };
1825
1945
  }
1826
- const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1827
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1828
- metadata = CASE WHEN $3::jsonb IS NOT NULL
1829
- THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1830
- ELSE metadata END,
1831
- updated_at = NOW()
1832
- WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''} AND status = 'pending'
1833
- RETURNING *`, namespace ? [id, payloadJson, metaJson, namespace] : [id, payloadJson, metaJson]);
1834
- if (!updateResult.rows[0]) {
1835
- await this.pgClient.query('ROLLBACK');
1836
- return { ok: false, reason: 'already-resolved' };
1837
- }
1838
- await this.pgClient.query('COMMIT');
1839
- return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
1840
- }
1841
- catch (e) {
1842
- await this.pgClient.query('ROLLBACK');
1843
- throw e;
1946
+ return { ok: false, reason: 'already-resolved' };
1844
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
+ };
1955
+ }
1956
+ /**
1957
+ * Non-locking preview of the row `resolveEscalationByMetadata` would
1958
+ * select — used to pre-build the wake command before the resolve
1959
+ * transaction opens. The `forSignalKey` guard on the wake command
1960
+ * handles the race where a different row wins the lock.
1961
+ */
1962
+ async peekEscalationByMetadata(params) {
1963
+ const { key, value, namespace, roles } = params;
1964
+ const filter = JSON.stringify({ [key]: value });
1965
+ const result = await this.pgClient.query(`SELECT id, signal_key, topic FROM public.hmsh_escalations
1966
+ WHERE ${namespace ? 'namespace = $3 AND' : ''}
1967
+ metadata @> $1::jsonb
1968
+ AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1969
+ AND status IN ('pending', 'cancelled')
1970
+ ORDER BY priority ASC, created_at ASC
1971
+ LIMIT 1`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
1972
+ const row = result.rows[0];
1973
+ if (!row)
1974
+ return null;
1975
+ return { id: row.id, signalKey: row.signal_key, topic: row.topic };
1845
1976
  }
1846
- async resolveEscalationByMetadata(params) {
1977
+ async resolveEscalationByMetadata(params, wakeCommand) {
1847
1978
  const { key, value, namespace, resolverPayload, roles, metadata } = params;
1848
1979
  const filter = JSON.stringify({ [key]: value });
1849
1980
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1850
1981
  const metaJson = metadata ? JSON.stringify(metadata) : null;
1851
- await this.pgClient.query('BEGIN');
1852
- try {
1853
- const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1854
- WHERE ${namespace ? 'namespace = $3 AND' : ''}
1855
- metadata @> $1::jsonb
1856
- AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1857
- AND status IN ('pending', 'cancelled')
1858
- ORDER BY priority ASC, created_at ASC
1859
- LIMIT 1 FOR UPDATE`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
1860
- if (!lockResult.rows[0]) {
1861
- await this.pgClient.query('ROLLBACK');
1862
- return { ok: false, reason: 'not-found' };
1863
- }
1864
- const { id, signal_key, topic, status } = lockResult.rows[0];
1865
- if (status === 'cancelled') {
1866
- 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')
1867
2024
  return { ok: false, reason: 'already-cancelled' };
1868
- }
1869
- const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1870
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1871
- metadata = CASE WHEN $3::jsonb IS NOT NULL
1872
- THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1873
- ELSE metadata END,
1874
- updated_at = NOW()
1875
- WHERE id = $1 AND status = 'pending'
1876
- RETURNING *`, [id, payloadJson, metaJson]);
1877
- if (!updateResult.rows[0]) {
1878
- await this.pgClient.query('ROLLBACK');
1879
- return { ok: false, reason: 'already-resolved' };
1880
- }
1881
- await this.pgClient.query('COMMIT');
1882
- return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
1883
- }
1884
- catch (e) {
1885
- await this.pgClient.query('ROLLBACK');
1886
- throw e;
2025
+ return { ok: false, reason: 'already-resolved' };
1887
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
+ };
1888
2034
  }
1889
- 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');
1890
2042
  const result = await this.pgClient.query(`
1891
2043
  WITH target AS MATERIALIZED (
1892
2044
  SELECT id, status FROM public.hmsh_escalations
@@ -1900,23 +2052,24 @@ class PostgresStoreService extends __1.StoreService {
1900
2052
  WHERE public.hmsh_escalations.id = target.id
1901
2053
  AND target.status = 'pending'
1902
2054
  RETURNING public.hmsh_escalations.*
1903
- )
2055
+ )${wakeCTE}
1904
2056
  SELECT t.id, t.status AS prior_status,
1905
2057
  CASE
1906
2058
  WHEN c.id IS NOT NULL THEN 'cancelled'
1907
2059
  WHEN t.id IS NULL THEN 'not-found'
1908
2060
  ELSE 'already-terminal'
1909
2061
  END AS outcome,
1910
- row_to_json(c.*) AS entry_json
2062
+ row_to_json(c.*) AS entry_json,
2063
+ ${wakeCount}
1911
2064
  FROM (SELECT * FROM target) t
1912
2065
  FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
1913
- `, namespace ? [id, namespace] : [id]);
2066
+ `, params);
1914
2067
  if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1915
2068
  return { ok: false, reason: 'not-found' };
1916
2069
  if (result.rows[0].outcome === 'already-terminal')
1917
2070
  return { ok: false, reason: 'already-terminal' };
1918
2071
  const entry = result.rows[0].entry_json;
1919
- return { ok: true, entry };
2072
+ return { ok: true, entry, wakeEnqueued: result.rows[0].wake_count > 0 };
1920
2073
  }
1921
2074
  async escalateEscalationToRole(params) {
1922
2075
  const { id, targetRole, namespace } = params;
@@ -2065,6 +2218,7 @@ class PostgresStoreService extends __1.StoreService {
2065
2218
  WHERE id = ANY($2::uuid[])
2066
2219
  ${namespace ? 'AND namespace = $4' : ''}
2067
2220
  AND status = 'pending'
2221
+ AND signal_key IS NULL
2068
2222
  RETURNING *`, namespace ? [payloadJson, ids, metaJson, namespace] : [payloadJson, ids, metaJson]);
2069
2223
  return result.rows;
2070
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
 
@@ -73,8 +73,13 @@ class TaskService {
73
73
  expire,
74
74
  };
75
75
  //called standalone (no transaction) so the single CTE query can
76
- //atomically detect and return pending signal data on collision
77
- const result = await this.store.setHookSignal(hook);
76
+ //atomically detect and return pending signal data on collision.
77
+ //the redelivery routing lets the store republish a consumed
78
+ //pending signal durably, in the same transaction that consumes it
79
+ const result = await this.store.setHookSignal(hook, undefined, {
80
+ aid: hookRule.to,
81
+ topic,
82
+ });
78
83
  if (result.pendingData) {
79
84
  this.logger.warn('task-signal-race-pending-consumed', {
80
85
  topic,
@@ -105,6 +105,18 @@ export type ResolveEscalationResult = {
105
105
  ok: false;
106
106
  reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
107
107
  };
108
+ /**
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
+ */
116
+ export interface EscalationWakeCommand {
117
+ forSignalKey: string;
118
+ message: string;
119
+ }
108
120
  export type ReleaseEscalationResult = {
109
121
  ok: true;
110
122
  entry: EscalationEntry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.4",
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}}"