@memberjunction/task-graph 6.1.0-edge.2 → 6.1.0-edge.4
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/LICENSE +180 -4
- package/README.md +30 -1
- package/dist/TaskClaimStore.d.ts +376 -4
- package/dist/TaskClaimStore.d.ts.map +1 -1
- package/dist/TaskClaimStore.js +600 -20
- package/dist/TaskClaimStore.js.map +1 -1
- package/dist/TaskGraphDispatcher.d.ts +326 -25
- package/dist/TaskGraphDispatcher.d.ts.map +1 -1
- package/dist/TaskGraphDispatcher.js +1604 -286
- package/dist/TaskGraphDispatcher.js.map +1 -1
- package/dist/TaskGraphService.d.ts +255 -5
- package/dist/TaskGraphService.d.ts.map +1 -1
- package/dist/TaskGraphService.js +583 -26
- package/dist/TaskGraphService.js.map +1 -1
- package/dist/TaskGraphSubmitterImpl.d.ts.map +1 -1
- package/dist/TaskGraphSubmitterImpl.js +5 -0
- package/dist/TaskGraphSubmitterImpl.js.map +1 -1
- package/dist/condition-gate.d.ts +128 -0
- package/dist/condition-gate.d.ts.map +1 -0
- package/dist/condition-gate.js +257 -0
- package/dist/condition-gate.js.map +1 -0
- package/dist/debug-state.d.ts +102 -0
- package/dist/debug-state.d.ts.map +1 -0
- package/dist/debug-state.js +135 -0
- package/dist/debug-state.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/operations/TaskGraphDebugOperations.d.ts +99 -0
- package/dist/operations/TaskGraphDebugOperations.d.ts.map +1 -0
- package/dist/operations/TaskGraphDebugOperations.js +310 -0
- package/dist/operations/TaskGraphDebugOperations.js.map +1 -0
- package/dist/operations/TaskGraphOperations.d.ts +20 -2
- package/dist/operations/TaskGraphOperations.d.ts.map +1 -1
- package/dist/operations/TaskGraphOperations.js +47 -8
- package/dist/operations/TaskGraphOperations.js.map +1 -1
- package/dist/settlement-rescue.d.ts +85 -0
- package/dist/settlement-rescue.d.ts.map +1 -0
- package/dist/settlement-rescue.js +119 -0
- package/dist/settlement-rescue.js.map +1 -0
- package/dist/task-graph-kick.d.ts +3 -0
- package/dist/task-graph-kick.d.ts.map +1 -0
- package/dist/task-graph-kick.js +17 -0
- package/dist/task-graph-kick.js.map +1 -0
- package/dist/task-predicates.d.ts +77 -0
- package/dist/task-predicates.d.ts.map +1 -0
- package/dist/task-predicates.js +75 -0
- package/dist/task-predicates.js.map +1 -0
- package/dist/types.d.ts +110 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +12 -11
package/dist/TaskClaimStore.js
CHANGED
|
@@ -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
|
-
|
|
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')} =
|
|
65
|
-
${db.QuoteIdentifier('StartedAt')} =
|
|
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')} <
|
|
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
|
-
|
|
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')} =
|
|
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')} =
|
|
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
|
-
* **
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
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')} <
|
|
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
|
|
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')} <
|
|
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
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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) {
|