@memberjunction/task-graph 6.1.0-edge.2 → 6.1.0-edge.3

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.
Files changed (53) hide show
  1. package/LICENSE +180 -4
  2. package/README.md +30 -1
  3. package/dist/TaskClaimStore.d.ts +376 -4
  4. package/dist/TaskClaimStore.d.ts.map +1 -1
  5. package/dist/TaskClaimStore.js +600 -20
  6. package/dist/TaskClaimStore.js.map +1 -1
  7. package/dist/TaskGraphDispatcher.d.ts +326 -25
  8. package/dist/TaskGraphDispatcher.d.ts.map +1 -1
  9. package/dist/TaskGraphDispatcher.js +1604 -286
  10. package/dist/TaskGraphDispatcher.js.map +1 -1
  11. package/dist/TaskGraphService.d.ts +255 -5
  12. package/dist/TaskGraphService.d.ts.map +1 -1
  13. package/dist/TaskGraphService.js +583 -26
  14. package/dist/TaskGraphService.js.map +1 -1
  15. package/dist/TaskGraphSubmitterImpl.d.ts.map +1 -1
  16. package/dist/TaskGraphSubmitterImpl.js +5 -0
  17. package/dist/TaskGraphSubmitterImpl.js.map +1 -1
  18. package/dist/condition-gate.d.ts +128 -0
  19. package/dist/condition-gate.d.ts.map +1 -0
  20. package/dist/condition-gate.js +257 -0
  21. package/dist/condition-gate.js.map +1 -0
  22. package/dist/debug-state.d.ts +102 -0
  23. package/dist/debug-state.d.ts.map +1 -0
  24. package/dist/debug-state.js +135 -0
  25. package/dist/debug-state.js.map +1 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/operations/TaskGraphDebugOperations.d.ts +99 -0
  31. package/dist/operations/TaskGraphDebugOperations.d.ts.map +1 -0
  32. package/dist/operations/TaskGraphDebugOperations.js +310 -0
  33. package/dist/operations/TaskGraphDebugOperations.js.map +1 -0
  34. package/dist/operations/TaskGraphOperations.d.ts +20 -2
  35. package/dist/operations/TaskGraphOperations.d.ts.map +1 -1
  36. package/dist/operations/TaskGraphOperations.js +47 -8
  37. package/dist/operations/TaskGraphOperations.js.map +1 -1
  38. package/dist/settlement-rescue.d.ts +85 -0
  39. package/dist/settlement-rescue.d.ts.map +1 -0
  40. package/dist/settlement-rescue.js +119 -0
  41. package/dist/settlement-rescue.js.map +1 -0
  42. package/dist/task-graph-kick.d.ts +3 -0
  43. package/dist/task-graph-kick.d.ts.map +1 -0
  44. package/dist/task-graph-kick.js +17 -0
  45. package/dist/task-graph-kick.js.map +1 -0
  46. package/dist/task-predicates.d.ts +77 -0
  47. package/dist/task-predicates.d.ts.map +1 -0
  48. package/dist/task-predicates.js +75 -0
  49. package/dist/task-predicates.js.map +1 -0
  50. package/dist/types.d.ts +110 -1
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +12 -11
@@ -22,6 +22,43 @@
22
22
  * @module @memberjunction/task-graph
23
23
  */
24
24
  import { LogError, LogStatus } from '@memberjunction/core';
25
+ import { TERMINAL_TASK_GRAPH_STATUSES } from '@memberjunction/ai-core-plus';
26
+ import { MachineTaskSQL } from './task-predicates.js';
27
+ /**
28
+ * Every object path that must exist for a JSON path to be writable — i.e. its proper prefixes,
29
+ * excluding the root and the leaf itself.
30
+ *
31
+ * `$.debug.edgeOverrides."abc"` → `['$.debug', '$.debug.edgeOverrides']`.
32
+ *
33
+ * Exported and pure because the rule ("JSON_MODIFY does not create intermediate objects") is the
34
+ * kind of database behaviour that is easy to assume wrongly and cheap to pin with a test.
35
+ */
36
+ export function ContainingPaths(path) {
37
+ const segments = [];
38
+ let current = '';
39
+ let quoted = false;
40
+ for (const char of path) {
41
+ if (char === '"') {
42
+ quoted = !quoted;
43
+ current += char;
44
+ continue;
45
+ }
46
+ if (char === '.' && !quoted) {
47
+ segments.push(current);
48
+ current = '';
49
+ continue;
50
+ }
51
+ current += char;
52
+ }
53
+ segments.push(current);
54
+ // Drop the root ('$') and the leaf: neither needs creating — the root is the document, and the
55
+ // leaf is what the caller is about to write.
56
+ const containers = [];
57
+ for (let i = 2; i < segments.length; i++) {
58
+ containers.push(segments.slice(0, i).join('.'));
59
+ }
60
+ return containers;
61
+ }
25
62
  /**
26
63
  * Guarded reads and writes over the `Task` claim columns.
27
64
  *
@@ -31,6 +68,19 @@ import { LogError, LogStatus } from '@memberjunction/core';
31
68
  * changed underneath it, which is precisely the race being defended against. Every method here is a
32
69
  * single statement; nothing reads-then-writes.
33
70
  */
71
+ /**
72
+ * Statuses a graph parent has stopped moving from — the single source of truth.
73
+ *
74
+ * `Blocked` is INCLUDED: `ComputeParentRollup` returns it as settled, so a
75
+ * failure-blocked graph is as settled as a completed one. Leaving it out left a Blocked settlement
76
+ * unprotected from overwrite AND invisible to the rescue sweep — a stranded run with extra steps.
77
+ *
78
+ * Exported because the dispatcher's sweep filters on the same set. Two lists that must agree is how
79
+ * a graph becomes invisible to the machinery meant to rescue it.
80
+ */
81
+ export const TERMINAL_PARENT_STATUSES = TERMINAL_TASK_GRAPH_STATUSES;
82
+ /** The same set as a SQL literal list, so the guards and the sweep cannot drift. */
83
+ export const TERMINAL_PARENT_STATUS_SQL = TERMINAL_PARENT_STATUSES.map((s) => `'${s}'`).join(',');
34
84
  export class TaskClaimStore {
35
85
  constructor(instanceID, claimTTLSeconds) {
36
86
  this.instanceID = instanceID;
@@ -44,6 +94,52 @@ export class TaskClaimStore {
44
94
  const db = this.sql(provider);
45
95
  return `${db.QuoteIdentifier(db.MJCoreSchemaName)}.${db.QuoteIdentifier('Task')}`;
46
96
  }
97
+ agentRunTable(provider) {
98
+ const db = this.sql(provider);
99
+ return `${db.QuoteIdentifier(db.MJCoreSchemaName)}.${db.QuoteIdentifier('AIAgentRun')}`;
100
+ }
101
+ /**
102
+ * Writes a graph's cost rollup onto the submitting run, those four columns and no others.
103
+ *
104
+ * **The full-row `Save()` this replaces could revert a peer's settle** (C4). Two instances
105
+ * entering the settled branch for one graph is by design, so instance B's rollup — loaded before
106
+ * A settled the run — would write back `Paused` over A's `Completed`, along with every other
107
+ * column it had read. And a crash between this write and the same pass's lifecycle write left
108
+ * the run `Paused` under a claimed marker, which no sweep re-enters.
109
+ */
110
+ async TrySetRunCostRollup(provider, runID, totals, contextUser) {
111
+ const db = this.sql(provider);
112
+ const num = (v) => (v == null ? 'NULL' : String(v));
113
+ const sql = `
114
+ UPDATE ${this.agentRunTable(provider)}
115
+ SET ${db.QuoteIdentifier('TotalCostRollup')} = ${num(totals.Cost)},
116
+ ${db.QuoteIdentifier('TotalTokensUsedRollup')} = ${num(totals.Tokens)},
117
+ ${db.QuoteIdentifier('TotalPromptTokensUsedRollup')} = ${num(totals.PromptTokens)},
118
+ ${db.QuoteIdentifier('TotalCompletionTokensUsedRollup')} = ${num(totals.CompletionTokens)}
119
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(runID)}'`;
120
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
121
+ }
122
+ /**
123
+ * Settles a parked agent run, guarded on it still being parked.
124
+ *
125
+ * Same reasoning as the rollup above and as every parent write since Round 1: a full-row save
126
+ * carries a whole stale snapshot, and the `Paused` predicate makes the transition once-only
127
+ * across instances rather than last-write-wins.
128
+ */
129
+ async TrySettleRun(provider, runID, succeeded, errorMessage, contextUser) {
130
+ const db = this.sql(provider);
131
+ const errorClause = errorMessage == null
132
+ ? ''
133
+ : `, ${db.QuoteIdentifier('ErrorMessage')} = CONCAT(COALESCE(${db.QuoteIdentifier('ErrorMessage')} + CHAR(10) + CHAR(10), ''), '${this.escape(errorMessage)}')`;
134
+ const sql = `
135
+ UPDATE ${this.agentRunTable(provider)}
136
+ SET ${db.QuoteIdentifier('Status')} = '${succeeded ? 'Completed' : 'Failed'}',
137
+ ${db.QuoteIdentifier('Success')} = ${succeeded ? 1 : 0},
138
+ ${db.QuoteIdentifier('CompletedAt')} = SYSUTCDATETIME()${errorClause}
139
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(runID)}'
140
+ AND ${db.QuoteIdentifier('Status')} = 'Paused'`;
141
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
142
+ }
47
143
  /**
48
144
  * Attempts to claim one task.
49
145
  *
@@ -56,18 +152,22 @@ export class TaskClaimStore {
56
152
  */
57
153
  async TryClaim(provider, taskID, contextUser) {
58
154
  const db = this.sql(provider);
59
- const expires = new Date(Date.now() + this.claimTTLSeconds * 1000);
155
+ // The lease is written AND compared on the database's clock (SYSUTCDATETIME), never this
156
+ // process's. The claim protocol is multi-instance: a lease written from one host's clock and
157
+ // judged expired against another's turns ordinary NTP skew into premature reclamation — the
158
+ // task runs twice — or into a lease that outlives its worker. One clock, the only shared one.
159
+ const ttlSeconds = Math.max(0, Math.round(this.claimTTLSeconds));
60
160
  const sql = `
61
161
  UPDATE ${this.taskTable(provider)}
62
162
  SET ${db.QuoteIdentifier('Status')} = 'In Progress',
63
163
  ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}',
64
- ${db.QuoteIdentifier('ClaimExpiresAt')} = '${expires.toISOString()}',
65
- ${db.QuoteIdentifier('StartedAt')} = '${new Date().toISOString()}'
164
+ ${db.QuoteIdentifier('ClaimExpiresAt')} = DATEADD(SECOND, ${ttlSeconds}, SYSUTCDATETIME()),
165
+ ${db.QuoteIdentifier('StartedAt')} = SYSUTCDATETIME()
66
166
  WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
67
167
  AND ${db.QuoteIdentifier('Status')} = 'Pending'
68
168
  AND (${db.QuoteIdentifier('ClaimedBy')} IS NULL
69
169
  OR ${db.QuoteIdentifier('ClaimExpiresAt')} IS NULL
70
- OR ${db.QuoteIdentifier('ClaimExpiresAt')} < '${new Date().toISOString()}')`;
170
+ OR ${db.QuoteIdentifier('ClaimExpiresAt')} < SYSUTCDATETIME())`;
71
171
  return (await this.affectedRows(db, sql, contextUser)) === 1;
72
172
  }
73
173
  /**
@@ -81,10 +181,11 @@ export class TaskClaimStore {
81
181
  */
82
182
  async Heartbeat(provider, taskID, contextUser) {
83
183
  const db = this.sql(provider);
84
- const expires = new Date(Date.now() + this.claimTTLSeconds * 1000);
184
+ // Same single-clock rule as TryClaim: the renewal is computed on the database's clock.
185
+ const ttlSeconds = Math.max(0, Math.round(this.claimTTLSeconds));
85
186
  const sql = `
86
187
  UPDATE ${this.taskTable(provider)}
87
- SET ${db.QuoteIdentifier('ClaimExpiresAt')} = '${expires.toISOString()}'
188
+ SET ${db.QuoteIdentifier('ClaimExpiresAt')} = DATEADD(SECOND, ${ttlSeconds}, SYSUTCDATETIME())
88
189
  WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
89
190
  AND ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}'
90
191
  AND ${db.QuoteIdentifier('Status')} = 'In Progress'`;
@@ -103,7 +204,7 @@ export class TaskClaimStore {
103
204
  const db = this.sql(provider);
104
205
  const sets = [
105
206
  `${db.QuoteIdentifier('Status')} = '${outcome.Status}'`,
106
- `${db.QuoteIdentifier('CompletedAt')} = '${new Date().toISOString()}'`,
207
+ `${db.QuoteIdentifier('CompletedAt')} = SYSUTCDATETIME()`,
107
208
  `${db.QuoteIdentifier('PercentComplete')} = ${outcome.Status === 'Complete' ? 100 : 0}`,
108
209
  // Release the claim as part of the same atomic write — a separate release could be
109
210
  // interrupted, leaving a terminal task holding a claim that the sweep would then flag.
@@ -130,27 +231,31 @@ export class TaskClaimStore {
130
231
  * Reclaims tasks whose claims have lapsed, returning them to `Pending` so any instance can pick
131
232
  * them up.
132
233
  *
133
- * **Human tasks are exempt** (review round 2). A task assigned to a person (`UserID` set) never
134
- * carries a claim, so `In Progress` with no claim is its *legitimate* parked shape — an approval
135
- * waiting on someone. Normalizing it would reset that approval out from under the user. Their
136
- * lifecycle is driven by `DueAt` notification and escalation, never by claim expiry.
234
+ * **Scoped to tasks a dispatcher executes**, via the one shared predicate — see `task-predicates`.
235
+ * Expressed that way rather than as a list of the runner columns that happened to exist when this
236
+ * was written: the earlier form named `AgentID` and `ActionID` only, and the day `PromptID`
237
+ * arrived, a crashed prompt task became unrecoverable and undiagnosable in the same stroke.
238
+ *
239
+ * **Tasks a person completes are exempt.** One never carries a claim, so `In Progress` with no
240
+ * claim is its *legitimate* parked shape — an approval waiting on someone. Normalizing it would
241
+ * reset that approval out from under the user. Their lifecycle is driven by `DueAt` notification
242
+ * and escalation, never by claim expiry.
137
243
  *
138
244
  * Only expired claims are reclaimed; a live claim is left strictly alone, which is what keeps a
139
245
  * slow-but-healthy task from being executed twice.
140
246
  */
141
247
  async ReleaseExpiredClaims(provider, contextUser) {
142
248
  const db = this.sql(provider);
143
- const now = new Date().toISOString();
144
249
  // Capture what will be reclaimed BEFORE reclaiming, so the log names the tasks. The
145
250
  // subsequent UPDATE re-states the same predicate, so a task whose claim was refreshed in
146
251
  // between is correctly skipped rather than reclaimed on stale information.
147
252
  const candidates = await db.ExecuteSQL(`SELECT ${db.QuoteIdentifier('ID')}, ${db.QuoteIdentifier('Name')}, ${db.QuoteIdentifier('ClaimedBy')}
148
253
  FROM ${this.taskTable(provider)}
149
254
  WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
150
- AND (${db.QuoteIdentifier('AgentID')} IS NOT NULL OR ${db.QuoteIdentifier('ActionID')} IS NOT NULL)
255
+ AND ${MachineTaskSQL(db.QuoteIdentifier.bind(db))}
151
256
  AND ${db.QuoteIdentifier('ClaimedBy')} IS NOT NULL
152
257
  AND ${db.QuoteIdentifier('ClaimExpiresAt')} IS NOT NULL
153
- AND ${db.QuoteIdentifier('ClaimExpiresAt')} < '${now}'`, undefined, undefined, contextUser);
258
+ AND ${db.QuoteIdentifier('ClaimExpiresAt')} < SYSUTCDATETIME()`, undefined, undefined, contextUser);
154
259
  if (!candidates || candidates.length === 0)
155
260
  return [];
156
261
  const sql = `
@@ -159,10 +264,10 @@ export class TaskClaimStore {
159
264
  ${db.QuoteIdentifier('ClaimedBy')} = NULL,
160
265
  ${db.QuoteIdentifier('ClaimExpiresAt')} = NULL
161
266
  WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
162
- AND (${db.QuoteIdentifier('AgentID')} IS NOT NULL OR ${db.QuoteIdentifier('ActionID')} IS NOT NULL)
267
+ AND ${MachineTaskSQL(db.QuoteIdentifier.bind(db))}
163
268
  AND ${db.QuoteIdentifier('ClaimedBy')} IS NOT NULL
164
269
  AND ${db.QuoteIdentifier('ClaimExpiresAt')} IS NOT NULL
165
- AND ${db.QuoteIdentifier('ClaimExpiresAt')} < '${now}'`;
270
+ AND ${db.QuoteIdentifier('ClaimExpiresAt')} < SYSUTCDATETIME()`;
166
271
  const released = await this.affectedRows(db, sql, contextUser);
167
272
  const events = candidates.slice(0, released).map((c) => ({
168
273
  TaskID: c.ID,
@@ -187,7 +292,7 @@ export class TaskClaimStore {
187
292
  const rows = await db.ExecuteSQL(`SELECT ${db.QuoteIdentifier('ID')}, ${db.QuoteIdentifier('Name')}
188
293
  FROM ${this.taskTable(provider)}
189
294
  WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
190
- AND (${db.QuoteIdentifier('AgentID')} IS NOT NULL OR ${db.QuoteIdentifier('ActionID')} IS NOT NULL)
295
+ AND ${MachineTaskSQL(db.QuoteIdentifier.bind(db))}
191
296
  AND ${db.QuoteIdentifier('ClaimedBy')} IS NULL`, undefined, undefined, contextUser);
192
297
  const events = (rows ?? []).map((r) => ({
193
298
  TaskID: r.ID,
@@ -199,12 +304,487 @@ export class TaskClaimStore {
199
304
  }
200
305
  return events;
201
306
  }
307
+ /**
308
+ * Writes a graph parent's terminal status, and only if it is not already terminal.
309
+ *
310
+ * **Why this is not `parent.Save()`.** `GenerateSaveSQL` sends every updateable column on every
311
+ * save, not just the dirty ones — so a full-row save carries the whole in-memory snapshot,
312
+ * including `InputPayload`. Two instances polling the same settling graph both compute the
313
+ * terminal rollup; if one claims the continuation marker (written into that JSON bag) and the
314
+ * other then saves its pre-marker snapshot, **the marker is erased** and the settlement is
315
+ * delivered a second time. For `reinvoke` that is a second billed agent turn for one settlement
316
+ * — precisely the failure P4 exists to prevent, reintroduced through a column nobody thought
317
+ * they were writing.
318
+ *
319
+ * Column-scoped and guarded, per the doctrine every task transition already follows: touch
320
+ * `Status`/`PercentComplete`/`CompletedAt` and nothing else, and only from a non-terminal state.
321
+ * The second instance's write becomes a no-op instead of a rewind.
322
+ *
323
+ * @returns true when this call moved the parent to terminal; false when it was already terminal
324
+ * (someone else settled it) or the write failed
325
+ */
326
+ async TrySettleParent(provider, parentTaskID, status, percentComplete, contextUser) {
327
+ const db = this.sql(provider);
328
+ const sql = `
329
+ UPDATE ${this.taskTable(provider)}
330
+ SET ${db.QuoteIdentifier('Status')} = '${this.escape(status)}',
331
+ ${db.QuoteIdentifier('PercentComplete')} = ${Number.isFinite(percentComplete) ? Math.round(percentComplete) : 0},
332
+ ${db.QuoteIdentifier('CompletedAt')} = SYSUTCDATETIME()
333
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
334
+ AND ${db.QuoteIdentifier('Status')} NOT IN (${TERMINAL_PARENT_STATUS_SQL})`;
335
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
336
+ }
337
+ /**
338
+ * Updates a graph parent's in-flight progress — column-scoped, and refused once it is terminal.
339
+ *
340
+ * **The race this closes needs no exotic timing.** Instance A loads the graph while a child is
341
+ * still In Progress and computes a non-terminal rollup. Instance B loads after that child
342
+ * finishes, settles the parent and claims the continuation. A's full-row progress `Save()` then
343
+ * lands: `Status` reverts to non-terminal *and* A's pre-marker `InputPayload` snapshot erases
344
+ * the marker. The next pass finds a non-terminal parent with a terminal rollup and an absent
345
+ * marker — so it settles again and delivers again. That is the duplicate `reinvoke` P4 exists to
346
+ * prevent, arriving through the last unguarded window.
347
+ *
348
+ * "These writes happen before settlement" is true per instance and false across instances, which
349
+ * is exactly the kind of timing argument a guard replaces with a structural one.
350
+ */
351
+ async TryUpdateParentProgress(provider, parentTaskID, status, percentComplete, contextUser) {
352
+ const db = this.sql(provider);
353
+ const sql = `
354
+ UPDATE ${this.taskTable(provider)}
355
+ SET ${db.QuoteIdentifier('Status')} = '${this.escape(status)}',
356
+ ${db.QuoteIdentifier('PercentComplete')} = ${Number.isFinite(percentComplete) ? Math.round(percentComplete) : 0}
357
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
358
+ AND ${db.QuoteIdentifier('Status')} NOT IN (${TERMINAL_PARENT_STATUS_SQL})`;
359
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
360
+ }
361
+ /**
362
+ * Stamps a graph parent's start time, once, without touching anything else.
363
+ *
364
+ * Same reason as {@link TrySettleParent}: a full-row `Save()` here would carry the whole
365
+ * in-memory snapshot including `InputPayload`, so stamping a start time could erase a
366
+ * continuation marker another instance had just claimed. Guarded on `StartedAt IS NULL` so it is
367
+ * naturally once-only and safe to call on every pass.
368
+ */
369
+ async TryStampParentStart(provider, parentTaskID, startedAt, contextUser) {
370
+ const db = this.sql(provider);
371
+ const sql = `
372
+ UPDATE ${this.taskTable(provider)}
373
+ SET ${db.QuoteIdentifier('StartedAt')} = '${startedAt.toISOString()}'
374
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
375
+ AND ${db.QuoteIdentifier('StartedAt')} IS NULL`;
376
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
377
+ }
378
+ /**
379
+ * Claims the right to deliver a graph's continuation — exactly once, across every instance.
380
+ *
381
+ * **What this replaces.** `claimContinuation` was Load → check the marker → `BaseEntity.Save()`:
382
+ * an unconditional last-write-wins UPDATE. Two dispatchers polling the same settled graph inside
383
+ * one interval both read "no marker", both saved, and both delivered. The comments called it a
384
+ * compare-and-swap; it was read-check-write. Every *task* transition in this store is a guarded
385
+ * single statement for exactly this reason — the continuation marker was the one transition that
386
+ * was not.
387
+ *
388
+ * The marker lives inside the parent's `InputPayload` JSON bag rather than a column, so the
389
+ * guard is a JSON predicate. That keeps one representation for writer and reader: this statement
390
+ * writes it, `ParseTaskGraphParentMetadata` reads it, and a graph settled before this existed is
391
+ * decided by the same parser as one settled after — which a new column plus a backfill could not
392
+ * promise.
393
+ *
394
+ * Timestamps are ISO 8601 UTC because the TS reader parses them; `JSON_MODIFY` on a row whose
395
+ * payload is absent or unparseable writes nothing and the rowcount says so, which is the honest
396
+ * outcome — a graph we cannot read metadata for is one we must not deliver for.
397
+ *
398
+ * `workflowTaskTypeID` is REQUIRED rather than optional because this statement injects keys into
399
+ * a row's `InputPayload`. `MJ: Tasks` holds conversation tasks and users' own to-dos as well as
400
+ * workflow graphs; a mis-targeted claim would silently edit somebody's payload. Passing the
401
+ * discriminator is not a filter the caller may forget — it is the caller stating which family of
402
+ * task it believes it is writing to, and the statement refusing if it is wrong.
403
+ *
404
+ * @param deliveredAs how the settlement is being delivered, recorded alongside the marker so an
405
+ * expired settlement is distinguishable from a delivered one after the fact
406
+ * @returns true when this instance won the right to deliver
407
+ */
408
+ async TryClaimContinuation(provider, parentTaskID, deliveredAs, workflowTaskTypeID, contextUser) {
409
+ const db = this.sql(provider);
410
+ const nowIso = new Date().toISOString();
411
+ const payload = db.QuoteIdentifier('InputPayload');
412
+ const sql = `
413
+ UPDATE ${this.taskTable(provider)}
414
+ SET ${payload} = JSON_MODIFY(
415
+ JSON_MODIFY(${payload}, '$.continuationDeliveredAt', '${this.escape(nowIso)}'),
416
+ '$.continuationDeliveredAs', '${this.escape(deliveredAs)}')
417
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
418
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
419
+ AND ISJSON(${payload}) = 1
420
+ AND JSON_VALUE(${payload}, '$.continuationDeliveredAt') IS NULL`;
421
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
422
+ }
423
+ /**
424
+ * Skips one task, refusing if anything has taken it since the caller looked.
425
+ *
426
+ * **Why this cannot be a `Save()`** — and R3-1 is the proof that the earlier reasoning was wrong.
427
+ * The early-finish path skipped siblings with a full-row `BaseEntity.Save()` against a snapshot
428
+ * taken before the loop began, justified by "the siblings are Pending and unclaimed until the
429
+ * skip lands". They are not: `executeClaimed` is not awaited, so this instance's own next poll
430
+ * tick runs concurrently with the loop, and a sibling can be claimed and STARTED between the
431
+ * snapshot and its own write. The full-row save then overwrote `In Progress` back to `Skipped`
432
+ * and cleared `ClaimedBy` mid-execution — the agent's real side effects had already fired, its
433
+ * completion was refused by the claim guard, and its output was discarded. The graph settled
434
+ * `Complete` with no record anywhere that the step ran.
435
+ *
436
+ * **The status predicate is `Status='Pending'` alone, deliberately.** `TryClaim` moves a task
437
+ * to `In Progress` in the same statement that stamps `ClaimedBy`, so a task an executor holds is
438
+ * never `Pending` — the status IS the claim test. Adding `ClaimedBy IS NULL` would look like
439
+ * defence in depth and would instead break a real case: a notified human task carries a marker
440
+ * in `ClaimedBy` while still `Pending`, and those must stay skippable.
441
+ *
442
+ * **Type-scoped, like every other write in this store that a caller-supplied ID can reach.**
443
+ * `MJ: Tasks` also holds conversation tasks and users' personal to-dos; without the
444
+ * discriminator an operator verb pointed at a mis-derived (or hostile) ID could write `Skipped`
445
+ * onto somebody's to-do. The engine-internal caller (`endGraphEarly`) derives its IDs from a
446
+ * workflow parent's own children, but it pays the same predicate — one statement, one contract.
447
+ *
448
+ * @returns true when this call is the one that skipped it; false means something else got there
449
+ */
450
+ async TrySkipPending(provider, taskID, workflowTaskTypeID, contextUser) {
451
+ const db = this.sql(provider);
452
+ const sql = `
453
+ UPDATE ${this.taskTable(provider)}
454
+ SET ${db.QuoteIdentifier('Status')} = 'Skipped'
455
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
456
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
457
+ AND ${db.QuoteIdentifier('Status')} = 'Pending'`;
458
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
459
+ }
460
+ /**
461
+ * Stamps the human-notified marker, once, without touching anything else.
462
+ *
463
+ * The marker lives in `ClaimedBy` because a human task has no executor claim, and it exists to
464
+ * stop the notify path re-raising on every poll. It was written with a full-row `Save()` against
465
+ * a snapshot — so it could revert a status the row had reached since, and two instances could
466
+ * both write it after both having seen it absent. Guarded on the marker being unset, it is
467
+ * naturally once-only and the rowcount says which instance did it.
468
+ */
469
+ async TryMarkHumanNotified(provider, taskID, marker, contextUser) {
470
+ const db = this.sql(provider);
471
+ const sql = `
472
+ UPDATE ${this.taskTable(provider)}
473
+ SET ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(marker)}'
474
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
475
+ AND ${db.QuoteIdentifier('Status')} = 'Pending'
476
+ AND ${db.QuoteIdentifier('ClaimedBy')} IS NULL`;
477
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
478
+ }
479
+ /**
480
+ * Cancels one task, refusing if it settled while the caller was looking elsewhere.
481
+ *
482
+ * **The terminal check has to be IN the statement.** `Cancel` loaded every child, tested the
483
+ * terminal set against that in-memory snapshot, and wrote `Status='Cancelled'` with a full-row
484
+ * `BaseEntity.Save()` — an unconditional UPDATE sending every updateable column against a
485
+ * PK-only predicate. A child whose executor's guarded `CompleteClaimed` landed between the load
486
+ * and its save had its entire outcome overwritten: `Complete` back to `Cancelled`,
487
+ * `OutputPayload` to NULL (the null-clear companions make those explicit clears),
488
+ * `AgentRunID`/`CompletedAt`/runtime `Configuration` reverted, and stale claim columns
489
+ * re-instated on a terminal row.
490
+ *
491
+ * The moment users cancel is exactly the moment tasks are running, so this is not a narrow
492
+ * window. The reverse ordering was always safe — `CompleteClaimed`'s own predicate refuses a
493
+ * cancelled row — so the hazard lived entirely in this write.
494
+ *
495
+ * @returns true when this call cancelled it; false means it had already settled
496
+ */
497
+ async TryCancelTask(provider, taskID, contextUser) {
498
+ const db = this.sql(provider);
499
+ const sql = `
500
+ UPDATE ${this.taskTable(provider)}
501
+ SET ${db.QuoteIdentifier('Status')} = 'Cancelled'
502
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
503
+ AND ${db.QuoteIdentifier('Status')} NOT IN (${TERMINAL_PARENT_STATUS_SQL})`;
504
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
505
+ }
506
+ /**
507
+ * Records, durably and once, that a graph is finishing early.
508
+ *
509
+ * **The declaration has to outlive the deciding instance's memory.** An early finish is decided
510
+ * by one task's result (`result.ChatMessage`) and nothing else in the system knows: skip seeds
511
+ * are derived from durable condition and exclusive-group state, so no claim filter on any
512
+ * instance — including the deciding one, whose poll loop runs concurrently — can tell that the
513
+ * remaining steps are about to be skipped. Writing it here first is what lets
514
+ * `loadGraphState` fold those steps into the claim filter, closing the window for everyone
515
+ * rather than narrowing it for one.
516
+ *
517
+ * Guarded and once-only for the same reason the continuation marker is: two tasks can end the
518
+ * same flow, and the first declaration is the one that counts. Type-scoped like every other
519
+ * statement here that writes into a payload column.
520
+ *
521
+ * @returns true when this call is the one that declared it
522
+ */
523
+ async TryDeclareEarlyFinish(provider, parentTaskID, workflowTaskTypeID, contextUser) {
524
+ const db = this.sql(provider);
525
+ const payload = db.QuoteIdentifier('InputPayload');
526
+ const nowIso = new Date().toISOString();
527
+ const sql = `
528
+ UPDATE ${this.taskTable(provider)}
529
+ SET ${payload} = JSON_MODIFY(${payload}, '$.earlyFinishedAt', '${this.escape(nowIso)}')
530
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
531
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
532
+ AND ISJSON(${payload}) = 1
533
+ AND JSON_VALUE(${payload}, '$.earlyFinishedAt') IS NULL`;
534
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
535
+ }
536
+ /**
537
+ * Records why a graph ended early, writing that column and no other.
538
+ *
539
+ * The hazard is the one {@link TrySettleParent} exists for, reached by a different route. A task
540
+ * that ends the flow early skips its siblings, which makes the graph fully terminal — so another
541
+ * instance's very next poll can settle it and claim the continuation marker. The old code had
542
+ * already loaded the parent by then and finished with a full-row `Save()`, which would write back
543
+ * the pre-settle snapshot: status reverted to `In Progress`, marker gone, graph delivered twice.
544
+ *
545
+ * No status predicate here, unlike the other writes: the early-finish message is the truthful
546
+ * summary whether or not the graph has settled since, and two tasks ending the same flow both
547
+ * describe it correctly. The bug was never the value — it was the other columns riding along.
548
+ *
549
+ * Type-scoped for the same reason the claim is: every statement in this store that writes into a
550
+ * payload column states which family of task it means, so a mis-derived parent ID cannot edit a
551
+ * conversation task or somebody's to-do.
552
+ */
553
+ async TrySetParentOutput(provider, parentTaskID, outputPayload, workflowTaskTypeID, contextUser) {
554
+ const db = this.sql(provider);
555
+ const sql = `
556
+ UPDATE ${this.taskTable(provider)}
557
+ SET ${db.QuoteIdentifier('OutputPayload')} = '${this.escape(outputPayload)}'
558
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
559
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'`;
560
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
561
+ }
562
+ /**
563
+ * Clears a graph's debug state entirely — the "stop debugging this run" write.
564
+ *
565
+ * Whole-bag, and safe to be: deleting `$.debug` is the one operation that genuinely owns every
566
+ * field in it. Every PARTIAL change goes through {@link TryWriteDebugFields}, because a
567
+ * read-merge-write of the whole bag puts back whatever the fields a verb does not own held at
568
+ * read time — most sharply resurrecting a step allowance the dispatcher consumed in between.
569
+ */
570
+ async TryClearDebugState(provider, parentTaskID, workflowTaskTypeID, contextUser) {
571
+ const db = this.sql(provider);
572
+ const payload = db.QuoteIdentifier('InputPayload');
573
+ const sql = `
574
+ UPDATE ${this.taskTable(provider)}
575
+ SET ${payload} = JSON_MODIFY(${payload}, '$.debug', NULL)
576
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
577
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
578
+ AND ISJSON(${payload}) = 1`;
579
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
580
+ }
581
+ /**
582
+ * One field of the debug bag, as a value the statement can write.
583
+ *
584
+ * Typed rather than a raw SQL fragment so a caller cannot inject one: the shape decides how the
585
+ * value is rendered, and every string goes through {@link escape}.
586
+ */
587
+ static DebugField(path, value) {
588
+ return { Path: path, Value: value };
589
+ }
590
+ /**
591
+ * Writes named fields of a graph's debug bag, leaving every other field alone.
592
+ *
593
+ * **Why field-scoped rather than rewriting `$.debug`.** A read-merge-write of the whole bag is
594
+ * the same stale-snapshot hazard as a full-row save, one level down: a verb that reads the bag,
595
+ * merges its own change, and writes the result puts back whatever the fields it does NOT own
596
+ * held at read time. The sharp case is the step allowance — if the dispatcher consumes it
597
+ * between a `SetBreakpoints` read and its write, the rewrite *resurrects* the consumed
598
+ * allowance and one press of Step releases two waves, straight through the CAS that exists to
599
+ * prevent exactly that. Writing only the paths a verb owns removes the class rather than
600
+ * narrowing the window.
601
+ *
602
+ * Paths are nested `JSON_MODIFY` calls, so the whole set lands in one statement.
603
+ */
604
+ async TryWriteDebugFields(provider, parentTaskID, fields, workflowTaskTypeID, contextUser) {
605
+ if (fields.length === 0)
606
+ return true;
607
+ const db = this.sql(provider);
608
+ const payload = db.QuoteIdentifier('InputPayload');
609
+ // Innermost first, so the outermost JSON_MODIFY sees every prior change — and every write
610
+ // starts from a payload whose containing objects are known to exist (see ensureObjects).
611
+ const expression = fields.reduce((inner, field) => `JSON_MODIFY(${inner}, '${this.escape(field.Path)}', ${this.renderDebugValue(field.Value)})`, this.ensureObjects(payload, fields.map((f) => f.Path)));
612
+ const sql = `
613
+ UPDATE ${this.taskTable(provider)}
614
+ SET ${payload} = ${expression}
615
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
616
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
617
+ AND ISJSON(${payload}) = 1`;
618
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
619
+ }
620
+ /**
621
+ * Wraps a payload expression so every object CONTAINING one of these paths exists.
622
+ *
623
+ * `JSON_MODIFY` does not create intermediate objects: writing `$.debug.paused` into a payload
624
+ * with no `debug` key, or `$.debug.edgeOverrides."<id>"` with no override map yet, silently
625
+ * changes nothing — which for a control verb means the write reports success (rowcount 1, the
626
+ * row WAS updated, just not the way anyone meant) and the workflow never pauses. A graph only
627
+ * acquires a `debug` key the first time somebody debugs it, so this is the NORMAL first call,
628
+ * not an edge case.
629
+ *
630
+ * This hazard arrived WITH field-scoped writes and is the price of them: the whole-bag write
631
+ * they replaced targeted `$.debug`, one level down from a root that always exists, so it
632
+ * created the containing object as a side effect of every verb. Field-scoping is still the
633
+ * right trade — it removes the step-resurrection class outright — but it moves the
634
+ * container's existence from implicit to something this method has to guarantee.
635
+ *
636
+ * Each containing object is created only when absent, shallowest first, so an existing bag is
637
+ * never replaced.
638
+ */
639
+ ensureObjects(payload, paths) {
640
+ const parents = new Set();
641
+ for (const path of paths) {
642
+ for (const prefix of ContainingPaths(path))
643
+ parents.add(prefix);
644
+ }
645
+ // Shallowest first: `$.debug` must exist before `$.debug.edgeOverrides` can be added to it.
646
+ const ordered = [...parents].sort((a, b) => a.length - b.length);
647
+ return ordered.reduce((inner, parent) => `CASE WHEN JSON_QUERY(${inner}, '${this.escape(parent)}') IS NULL` +
648
+ ` THEN JSON_MODIFY(${inner}, '${this.escape(parent)}', JSON_QUERY('{}'))` +
649
+ ` ELSE ${inner} END`, payload);
650
+ }
651
+ /** Renders one debug value as a SQL literal `JSON_MODIFY` will store with the right JSON type. */
652
+ renderDebugValue(value) {
653
+ switch (value.Kind) {
654
+ // `NULL` in lax mode DELETES the key, which is what "this verb cleared it" should mean.
655
+ case 'null': return 'NULL';
656
+ case 'bool': return `CAST(${value.Value ? 1 : 0} AS BIT)`;
657
+ case 'string': return `'${this.escape(value.Value)}'`;
658
+ // JSON_QUERY keeps objects and arrays as JSON rather than storing them as a string.
659
+ case 'json': return `JSON_QUERY('${this.escape(value.Value)}')`;
660
+ }
661
+ }
662
+ /**
663
+ * Consumes a paused graph's one-shot step allowance — exactly once, across every instance.
664
+ *
665
+ * The predicate `$.debug.step IS NOT NULL` is the whole contract: two dispatchers polling the
666
+ * same paused graph inside one interval both see the allowance, but only one statement clears it
667
+ * and sees rowcount 1. The loser claims nothing and waits for the next allowance, so "step" can
668
+ * never release two waves.
669
+ */
670
+ async TryConsumeStepMarker(provider, parentTaskID, workflowTaskTypeID, contextUser) {
671
+ const db = this.sql(provider);
672
+ const payload = db.QuoteIdentifier('InputPayload');
673
+ const sql = `
674
+ UPDATE ${this.taskTable(provider)}
675
+ SET ${payload} = JSON_MODIFY(${payload}, '$.debug.step', NULL)
676
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
677
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
678
+ AND ISJSON(${payload}) = 1
679
+ AND JSON_VALUE(${payload}, '$.debug.step') IS NOT NULL`;
680
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
681
+ }
682
+ /**
683
+ * Pauses a graph because an eligible task hit a breakpoint — once, whichever instance sees it
684
+ * first.
685
+ *
686
+ * Guarded on "not already paused" so two instances arriving at the same breakpoint in the same
687
+ * interval produce one `BreakpointHit` announcement, not two. The graph's existing breakpoint
688
+ * list and edge overrides are untouched — only the pause fields are written.
689
+ *
690
+ * The `$.debug` object is created when absent, for the same reason the field-scoped writes need
691
+ * it: `JSON_MODIFY` will not create a missing container, so without this the pause would report
692
+ * success and the workflow would run straight through its breakpoint. Reachable here only since
693
+ * the writes became field-scoped — the whole-bag write this replaced created `$.debug` on the
694
+ * way past, so a breakpoint could not exist without its container already being there.
695
+ */
696
+ async TryPauseAtBreakpoint(provider, parentTaskID, breakpointTaskID, workflowTaskTypeID, contextUser) {
697
+ const db = this.sql(provider);
698
+ const payload = db.QuoteIdentifier('InputPayload');
699
+ const base = this.ensureObjects(payload, ['$.debug.paused']);
700
+ const sql = `
701
+ UPDATE ${this.taskTable(provider)}
702
+ SET ${payload} = JSON_MODIFY(JSON_MODIFY(JSON_MODIFY(${base},
703
+ '$.debug.paused', CAST(1 AS BIT)),
704
+ '$.debug.pausedReason', 'breakpoint'),
705
+ '$.debug.pausedAtTaskID', '${this.escape(breakpointTaskID)}')
706
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(parentTaskID)}'
707
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
708
+ AND ISJSON(${payload}) = 1
709
+ AND (JSON_VALUE(${payload}, '$.debug.paused') IS NULL
710
+ OR JSON_VALUE(${payload}, '$.debug.paused') = 'false')`;
711
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
712
+ }
713
+ /**
714
+ * Replaces a task's input, guarded on the status the caller believes it is in.
715
+ *
716
+ * **Why this is a guarded statement and not `task.Save()`.** The obvious shape — load, check
717
+ * `Status === 'Pending'` in memory, save — is an unconditional full-row UPDATE carrying the
718
+ * whole loaded snapshot. A task claimed between the load and the save has its `Status`,
719
+ * `ClaimedBy` and `ClaimExpiresAt` reverted to that snapshot *while its body executes*, after
720
+ * which a second instance claims it again and the step runs twice. That is the stale-snapshot
721
+ * class this file's header exists to prevent, and it does not become safe because the window is
722
+ * small — the dispatcher polls every few seconds.
723
+ *
724
+ * `expectedStatus` is a parameter because two verbs need it: editing the brief of a step that
725
+ * has not started (`Pending`) and correcting the brief of one that failed, on the way into a
726
+ * retry (`Failed`).
727
+ */
728
+ async TryUpdateInputPayload(provider, taskID, inputPayload, expectedStatus, workflowTaskTypeID, contextUser) {
729
+ const db = this.sql(provider);
730
+ const value = inputPayload == null ? 'NULL' : `'${this.escape(inputPayload)}'`;
731
+ const sql = `
732
+ UPDATE ${this.taskTable(provider)}
733
+ SET ${db.QuoteIdentifier('InputPayload')} = ${value}
734
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
735
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
736
+ AND ${db.QuoteIdentifier('Status')} = '${this.escape(expectedStatus)}'`;
737
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
738
+ }
739
+ /**
740
+ * Marks a task Complete with an operator-supplied output — the escape hatch for a wedged or
741
+ * externally-resolved step.
742
+ *
743
+ * The guard is deliberately narrow: `Pending`, `Failed`, `Blocked`, or `In Progress` **with a
744
+ * lapsed claim**. A live claim means an executor is genuinely working, and force-completing
745
+ * underneath it would hand dependents an output the still-running body is about to contradict —
746
+ * that case must go through Cancel or wait for the claim to lapse. Downstream edges evaluate
747
+ * against the supplied output exactly as they would a runner's.
748
+ *
749
+ * **The lapsed-claim test uses the DATABASE clock, not this process's.** With app/DB skew — or
750
+ * skew between two app servers — a claim that is live on the clock that wrote it can read as
751
+ * expired on the clock that judges it, and this verb would then complete a task underneath a
752
+ * running executor. That interleaving is the entire reason the gate is narrow, so the gate must
753
+ * not be the thing that gets it wrong. The database is the one reference every instance shares.
754
+ * (This verb once carried a residual asymmetry — it *judged* on the database clock while
755
+ * `TryClaim` still *wrote* the lease from the claiming process's clock, trading app-vs-app skew
756
+ * for app-vs-DB skew. The claim protocol has since moved its write to `SYSUTCDATETIME()` as
757
+ * well, so both ends of the comparison now come from the one shared clock and the window is
758
+ * closed rather than relocated.)
759
+ */
760
+ async TryForceComplete(provider, taskID, outputPayload, workflowTaskTypeID, contextUser) {
761
+ const db = this.sql(provider);
762
+ const output = outputPayload == null ? 'NULL' : `'${this.escape(outputPayload)}'`;
763
+ const sql = `
764
+ UPDATE ${this.taskTable(provider)}
765
+ SET ${db.QuoteIdentifier('Status')} = 'Complete',
766
+ ${db.QuoteIdentifier('OutputPayload')} = ${output},
767
+ ${db.QuoteIdentifier('ErrorMessage')} = NULL,
768
+ ${db.QuoteIdentifier('CompletedAt')} = SYSUTCDATETIME(),
769
+ ${db.QuoteIdentifier('PercentComplete')} = 100,
770
+ ${db.QuoteIdentifier('ClaimedBy')} = NULL,
771
+ ${db.QuoteIdentifier('ClaimExpiresAt')} = NULL
772
+ WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
773
+ AND ${db.QuoteIdentifier('TypeID')} = '${this.escape(workflowTaskTypeID)}'
774
+ AND (${db.QuoteIdentifier('Status')} IN ('Pending','Failed','Blocked')
775
+ OR (${db.QuoteIdentifier('Status')} = 'In Progress'
776
+ AND (${db.QuoteIdentifier('ClaimExpiresAt')} IS NULL
777
+ OR ${db.QuoteIdentifier('ClaimExpiresAt')} < SYSUTCDATETIME())))`;
778
+ return (await this.affectedRows(db, sql, contextUser)) === 1;
779
+ }
202
780
  /** Runs the affected-rows statement, returning 0 on error rather than throwing into the loop. */
203
781
  async affectedRows(db, sql, contextUser) {
204
782
  try {
205
- // Trailing SELECT is how the row count comes back as data across both dialects, rather
206
- // than depending on a driver-specific rowsAffected field.
207
- const rows = await db.ExecuteSQL(`${sql};\nSELECT @@ROWCOUNT AS ${db.QuoteIdentifier('AffectedRows')}`, undefined, undefined, contextUser);
783
+ // The count comes back as data rather than through a driver-specific rowsAffected
784
+ // field. The wrapper is dialect-owned because `@@ROWCOUNT` is T-SQL only: emitted on
785
+ // PostgreSQL it left a bare `ROWCOUNT` identifier, which folds to lowercase, so every
786
+ // guarded write failed with `column "rowcount" does not exist`.
787
+ const rows = await db.ExecuteSQL(db.Dialect.AffectedRowCountSQL(sql, 'AffectedRows'), undefined, undefined, contextUser);
208
788
  return Number(rows?.[0]?.AffectedRows ?? 0);
209
789
  }
210
790
  catch (e) {