@memberjunction/task-graph 0.0.0 → 6.1.0-edge.2
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 +7 -0
- package/README.md +167 -27
- package/dist/DispatcherConditionEvaluator.d.ts +10 -0
- package/dist/DispatcherConditionEvaluator.d.ts.map +1 -0
- package/dist/DispatcherConditionEvaluator.js +27 -0
- package/dist/DispatcherConditionEvaluator.js.map +1 -0
- package/dist/TaskClaimStore.d.ts +125 -0
- package/dist/TaskClaimStore.d.ts.map +1 -0
- package/dist/TaskClaimStore.js +223 -0
- package/dist/TaskClaimStore.js.map +1 -0
- package/dist/TaskGraphDispatcher.d.ts +548 -0
- package/dist/TaskGraphDispatcher.d.ts.map +1 -0
- package/dist/TaskGraphDispatcher.js +2232 -0
- package/dist/TaskGraphDispatcher.js.map +1 -0
- package/dist/TaskGraphService.d.ts +247 -0
- package/dist/TaskGraphService.d.ts.map +1 -0
- package/dist/TaskGraphService.js +753 -0
- package/dist/TaskGraphService.js.map +1 -0
- package/dist/TaskGraphSubmitterImpl.d.ts +7 -0
- package/dist/TaskGraphSubmitterImpl.d.ts.map +1 -0
- package/dist/TaskGraphSubmitterImpl.js +52 -0
- package/dist/TaskGraphSubmitterImpl.js.map +1 -0
- package/dist/TaskLoopExecutor.d.ts +62 -0
- package/dist/TaskLoopExecutor.d.ts.map +1 -0
- package/dist/TaskLoopExecutor.js +248 -0
- package/dist/TaskLoopExecutor.js.map +1 -0
- package/dist/WorkflowSpecSync.d.ts +197 -0
- package/dist/WorkflowSpecSync.d.ts.map +1 -0
- package/dist/WorkflowSpecSync.js +474 -0
- package/dist/WorkflowSpecSync.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/TaskGraphOperations.d.ts +39 -0
- package/dist/operations/TaskGraphOperations.d.ts.map +1 -0
- package/dist/operations/TaskGraphOperations.js +168 -0
- package/dist/operations/TaskGraphOperations.js.map +1 -0
- package/dist/operations/WorkflowDraftOperation.d.ts +37 -0
- package/dist/operations/WorkflowDraftOperation.d.ts.map +1 -0
- package/dist/operations/WorkflowDraftOperation.js +141 -0
- package/dist/operations/WorkflowDraftOperation.js.map +1 -0
- package/dist/operations/WorkflowOperations.d.ts +22 -0
- package/dist/operations/WorkflowOperations.d.ts.map +1 -0
- package/dist/operations/WorkflowOperations.js +99 -0
- package/dist/operations/WorkflowOperations.js.map +1 -0
- package/dist/types.d.ts +328 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +36 -8
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 MemberJunction
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,45 +1,185 @@
|
|
|
1
1
|
# @memberjunction/task-graph
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Durable execution of task graphs: submission, the dispatcher, the claim protocol, and the runners
|
|
4
|
+
that turn a node into work.
|
|
4
5
|
|
|
5
|
-
**
|
|
6
|
+
> **New to workflows?** Start with the [Workflows and Task Graphs
|
|
7
|
+
> Guide](../../guides/WORKFLOW_AND_TASK_GRAPH_GUIDE.md), which covers what a workflow is, when to
|
|
8
|
+
> use one, and every rule that decides what happens next. This README is the package tour.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What this package is for
|
|
13
|
+
|
|
14
|
+
A task graph is work that has to **outlive the thing that asked for it**. Before durable execution,
|
|
15
|
+
a multi-step agent plan lived inside one agent run: a page reload lost it, a server restart orphaned
|
|
16
|
+
it, and no channel other than the one that started it could see it.
|
|
17
|
+
|
|
18
|
+
This package is the other half of that split. Something else *produces* a graph; this package makes
|
|
19
|
+
it durable and runs it.
|
|
20
|
+
|
|
21
|
+
```mermaid
|
|
22
|
+
graph TB
|
|
23
|
+
subgraph Producers["Producers — anyone"]
|
|
24
|
+
P1["Flow agent<br/><i>compiled</i>"]
|
|
25
|
+
P2["Loop agent<br/><i>emitted</i>"]
|
|
26
|
+
P3["Entity action"]
|
|
27
|
+
P4["TaskGraph.Submit<br/><i>remote op</i>"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
P1 & P2 & P3 & P4 --> SVC["<b>TaskGraphService.Submit</b><br/>validate · resolve · persist"]
|
|
31
|
+
SVC --> ROWS["Task + TaskDependency"]
|
|
32
|
+
ROWS --> DISP["<b>TaskGraphDispatcher</b><br/>poll · claim · execute · settle"]
|
|
33
|
+
|
|
34
|
+
DISP --> R1["TaskAgentRunner"]
|
|
35
|
+
DISP --> R2["TaskActionRunner"]
|
|
36
|
+
DISP --> R3["TaskLoopExecutor"]
|
|
37
|
+
DISP --> R4["a person"]
|
|
38
|
+
|
|
39
|
+
style SVC fill:#7c5295,stroke:#563a6b,color:#fff
|
|
40
|
+
style ROWS fill:#2d8659,stroke:#1a5c3a,color:#fff
|
|
41
|
+
style DISP fill:#b8762f,stroke:#8a5722,color:#fff
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Submission never waits for execution.** `Submit` returns as soon as the graph is durable. That
|
|
45
|
+
split is what makes the engine invocation-agnostic: an agent, a scheduled job, a Slack message and a
|
|
46
|
+
manual UI all call the same method, and whichever dispatcher instance is running picks the work up.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What it deliberately does not do
|
|
51
|
+
|
|
52
|
+
**It does not decide graph semantics.** Eligibility, failure propagation, parent rollup, skip
|
|
53
|
+
cascade, exclusive-group resolution and stall detection all come from pure, dependency-free
|
|
54
|
+
functions in [`@memberjunction/ai-core-plus`](../AI/CorePlus). That is not tidiness — it is what
|
|
55
|
+
stops the in-run executor and the durable executor from drifting apart. Neither owns the rules.
|
|
56
|
+
|
|
57
|
+
**It does not import MJServer.** Provider minting and agent execution arrive as injected seams
|
|
58
|
+
(`ProviderFactory`, `TaskAgentRunner`, `TaskActionRunner`), so the dependency runs
|
|
59
|
+
MJServer → task-graph and never the reverse. That is also what keeps the dispatcher unit-testable
|
|
60
|
+
without standing up the agent framework.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## The pieces
|
|
65
|
+
|
|
66
|
+
| File | Responsibility |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `TaskGraphService.ts` | Validate a spec, resolve names to IDs, write parent + children + edges. The only write path. |
|
|
69
|
+
| `TaskGraphDispatcher.ts` | Poll, claim, execute, evaluate edges, propagate, roll up, settle, credit cost. |
|
|
70
|
+
| `TaskClaimStore.ts` | The atomic claim protocol — guarded writes so two instances cannot run the same task. |
|
|
71
|
+
| `TaskLoopExecutor.ts` | `ForEach` / `While` semantics: bounds, ordering, concurrency, delay, failure. |
|
|
72
|
+
| `DispatcherConditionEvaluator.ts` | Edge conditions, over the superset context both dialects can read. |
|
|
73
|
+
| `TaskGraphSubmitterImpl.ts` | Registers the durable submitter under the `ClassFactory` seam. |
|
|
74
|
+
| `operations/` | The `TaskGraph.*` remote operations. |
|
|
8
75
|
|
|
9
|
-
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Claiming: why a task runs exactly once
|
|
79
|
+
|
|
80
|
+
Multiple dispatcher instances poll the same table. Correctness comes from a **guarded write**, not
|
|
81
|
+
from coordination: claiming is an update that only succeeds if the row still looks the way the
|
|
82
|
+
claimer expects.
|
|
83
|
+
|
|
84
|
+
```mermaid
|
|
85
|
+
sequenceDiagram
|
|
86
|
+
participant A as Instance A
|
|
87
|
+
participant B as Instance B
|
|
88
|
+
participant DB as Task row
|
|
89
|
+
|
|
90
|
+
A->>DB: claim if unclaimed / expired
|
|
91
|
+
B->>DB: claim if unclaimed / expired
|
|
92
|
+
DB-->>A: 1 row updated ✅
|
|
93
|
+
DB-->>B: 0 rows updated ❌
|
|
94
|
+
Note over B: defers — no lock, no retry storm
|
|
95
|
+
loop while running
|
|
96
|
+
A->>DB: heartbeat (extends ClaimExpiresAt)
|
|
97
|
+
end
|
|
98
|
+
A->>DB: complete IF still owned by A
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If A dies, its claim expires and reconciliation makes the task claimable again. If A finishes but
|
|
102
|
+
the row changed underneath it (cancelled, reassigned, reclaimed), the guarded completion refuses and
|
|
103
|
+
A defers — overwriting would undo a newer, deliberate decision.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Runners are seams, not implementations
|
|
108
|
+
|
|
109
|
+
| Seam | Implemented by | Absent means |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `TaskAgentRunner` | `TaskGraphAgentRunner` (MJServer) | agent nodes cannot run here |
|
|
112
|
+
| `TaskActionRunner` | `TaskGraphActionRunner` (MJServer) | action nodes stay `Pending`, **not** Failed |
|
|
113
|
+
| `ProviderFactory` | `TaskGraphProviderFactory` (MJServer) | required |
|
|
114
|
+
| `TaskContinuationDeliverer` | `TaskGraphContinuationDeliverer` (MJServer) | outcomes are recorded but not announced |
|
|
115
|
+
| `TaskGraphObserver` | the frame resolver (MJServer) | nobody is watching; behaviour identical |
|
|
116
|
+
|
|
117
|
+
**A host with no runner is limited, not broken.** "Nobody here can run this" is not the same as
|
|
118
|
+
"this ran and did not work", so those tasks stay visible and claimable by an instance that can.
|
|
119
|
+
|
|
120
|
+
> **An Agent node starts a brand-new `AIAgentRun`** — a root run, linked from `Task.AgentRunID`
|
|
121
|
+
> rather than nested under the submitting run. The relationship is expressed by the Task row.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Starting a dispatcher
|
|
10
126
|
|
|
11
|
-
|
|
12
|
-
1. Configure OIDC trusted publishing for the package name `@memberjunction/task-graph`
|
|
13
|
-
2. Enable secure, token-less publishing from CI/CD workflows
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
127
|
+
MJAPI does this for you (`StartTaskGraphDispatcher`). For a custom host:
|
|
15
128
|
|
|
16
|
-
|
|
129
|
+
```typescript
|
|
130
|
+
import { TaskGraphDispatcher, LoadTaskGraphOperations } from '@memberjunction/task-graph';
|
|
17
131
|
|
|
18
|
-
|
|
132
|
+
LoadTaskGraphOperations(); // registers the remote operations + the durable submitter
|
|
19
133
|
|
|
20
|
-
|
|
134
|
+
const dispatcher = new TaskGraphDispatcher(
|
|
135
|
+
providerFactory,
|
|
136
|
+
agentRunner,
|
|
137
|
+
contextUser,
|
|
138
|
+
{ InstanceID: `worker-${process.pid}` },
|
|
139
|
+
continuationDeliverer, // optional
|
|
140
|
+
observer, // optional
|
|
141
|
+
actionRunner, // optional
|
|
142
|
+
);
|
|
143
|
+
await dispatcher.Start();
|
|
144
|
+
```
|
|
21
145
|
|
|
22
|
-
|
|
146
|
+
Without `LoadTaskGraphOperations()` nothing registers the submitter, and
|
|
147
|
+
`GetTaskGraphSubmitter()` returns `null` — which callers must report rather than swallow.
|
|
23
148
|
|
|
24
|
-
|
|
25
|
-
2. Configure the trusted publisher (e.g., GitHub Actions)
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
149
|
+
---
|
|
28
150
|
|
|
29
|
-
##
|
|
151
|
+
## Cost rollup
|
|
30
152
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
153
|
+
When a graph settles, the dispatcher credits its spending back to the run that submitted it, writing
|
|
154
|
+
the `…Rollup` columns on `AIAgentRun`:
|
|
36
155
|
|
|
37
|
-
|
|
156
|
+
- `TotalCost` — what the submitting run itself spent. **Never rewritten here.**
|
|
157
|
+
- `TotalCostRollup` — that run plus everything it caused.
|
|
38
158
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
159
|
+
This cannot happen during the run: a submitting run *ends at submission*, so at the moment it
|
|
160
|
+
computes its own totals the graph has not spent anything yet. See the guide's [cost
|
|
161
|
+
section](../../guides/WORKFLOW_AND_TASK_GRAPH_GUIDE.md#cost-and-tokens-the-seam).
|
|
42
162
|
|
|
43
163
|
---
|
|
44
164
|
|
|
45
|
-
|
|
165
|
+
## Testing
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
cd packages/TaskGraph && pnpm test
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Unit tests cover the pure pieces — loop semantics, dispatchable kinds, configuration persistence,
|
|
172
|
+
parent metadata. The dispatcher driving **real rows against SQL Server** is covered by the `IT74`
|
|
173
|
+
bundle in `@memberjunction/integration-test-suite`, which uses a stub agent runner so it stays in
|
|
174
|
+
the deterministic tier: no model calls, no tokens, real claim protocol, real condition evaluator,
|
|
175
|
+
real rollup.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Related
|
|
180
|
+
|
|
181
|
+
- [Workflows and Task Graphs Guide](../../guides/WORKFLOW_AND_TASK_GRAPH_GUIDE.md) — start here
|
|
182
|
+
- [`@memberjunction/ai-core-plus`](../AI/CorePlus) — the spec, validator, compiler, pure algorithms,
|
|
183
|
+
payload mapping and layout
|
|
184
|
+
- [`@memberjunction/ai-agents`](../AI/Agents) — the agent framework and `FlowAgentType`
|
|
185
|
+
- [`packages/Actions/CLAUDE.md`](../Actions/CLAUDE.md) — Actions as boundaries
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IConditionEvaluator } from '@memberjunction/ai-core-plus';
|
|
2
|
+
export declare class DispatcherConditionEvaluator implements IConditionEvaluator {
|
|
3
|
+
private readonly evaluator;
|
|
4
|
+
Evaluate(expression: string, context: Record<string, unknown>): {
|
|
5
|
+
Success: boolean;
|
|
6
|
+
Value?: unknown;
|
|
7
|
+
ErrorMessage?: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=DispatcherConditionEvaluator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DispatcherConditionEvaluator.d.ts","sourceRoot":"","sources":["../src/DispatcherConditionEvaluator.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE,qBAAa,4BAA6B,YAAW,mBAAmB;IACpE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiC;IAEpD,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE;CAUtI"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Condition evaluation for durable graph edges.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the same `SafeExpressionEvaluator` the design-time flow executor uses. Sharing the evaluator
|
|
5
|
+
* is not incidental — it is what makes an edge condition mean the same thing whether it was drawn in
|
|
6
|
+
* the flow editor or emitted by an agent, which is the premise Save as Workflow (D17) rests on.
|
|
7
|
+
*
|
|
8
|
+
* @module @memberjunction/task-graph
|
|
9
|
+
*/
|
|
10
|
+
import { SafeExpressionEvaluator } from '@memberjunction/global';
|
|
11
|
+
export class DispatcherConditionEvaluator {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.evaluator = new SafeExpressionEvaluator();
|
|
14
|
+
}
|
|
15
|
+
Evaluate(expression, context) {
|
|
16
|
+
try {
|
|
17
|
+
const result = this.evaluator.evaluate(expression, context);
|
|
18
|
+
return result.success
|
|
19
|
+
? { Success: true, Value: result.value }
|
|
20
|
+
: { Success: false, ErrorMessage: String(result.error ?? 'condition evaluation failed') };
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return { Success: false, ErrorMessage: e instanceof Error ? e.message : String(e) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=DispatcherConditionEvaluator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DispatcherConditionEvaluator.js","sourceRoot":"","sources":["../src/DispatcherConditionEvaluator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAGjE,MAAM,OAAO,4BAA4B;IAAzC;QACqB,cAAS,GAAG,IAAI,uBAAuB,EAAE,CAAC;IAY/D,CAAC;IAVU,QAAQ,CAAC,UAAkB,EAAE,OAAgC;QAChE,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YAC5D,OAAO,MAAM,CAAC,OAAO;gBACjB,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE;gBACxC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,IAAI,6BAA6B,CAAC,EAAE,CAAC;QAClG,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;QACxF,CAAC;IACL,CAAC;CACJ"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The compare-and-swap claim protocol for durable task execution.
|
|
3
|
+
*
|
|
4
|
+
* This is the mechanism that lets more than one dispatcher instance work the same task table
|
|
5
|
+
* without two of them running the same task, and that lets a crashed instance's work be picked up
|
|
6
|
+
* rather than stranded. It is deliberately a small, self-contained unit: every state transition is
|
|
7
|
+
* a guarded `UPDATE ... WHERE <expected state>` whose rowcount is the answer, so correctness rests
|
|
8
|
+
* on the database's own atomicity rather than on a distributed lock manager.
|
|
9
|
+
*
|
|
10
|
+
* **Why rowcount and not read-then-write.** Reading a task, deciding it is claimable, then writing
|
|
11
|
+
* the claim is a textbook race: two instances can both read `Pending`. The single-statement form —
|
|
12
|
+
* `UPDATE Task SET ClaimedBy=@me WHERE ID=@id AND Status='Pending'` — makes the check and the write
|
|
13
|
+
* one atomic operation, so exactly one instance sees rowcount 1 and the other sees 0 and moves on.
|
|
14
|
+
*
|
|
15
|
+
* **Why every transition is guarded, not just the initial claim.** Per D20 the Task table stays
|
|
16
|
+
* user-writable: entity forms, Data Explorer, GraphQL, and any agent holding an update-record action
|
|
17
|
+
* can change `Status` or clear `ClaimedBy` underneath a running executor. A completion write that
|
|
18
|
+
* only said "set this task Complete" would happily overwrite a task someone had reassigned. Guarding
|
|
19
|
+
* on `ClaimedBy=@me` means a stale executor's write fails cleanly (rowcount 0) instead of
|
|
20
|
+
* double-completing, and the dispatcher can defer to the sweep.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/task-graph
|
|
23
|
+
*/
|
|
24
|
+
import { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
25
|
+
import { ReconciliationEvent } from './types.js';
|
|
26
|
+
/** Fields the claim protocol needs from a candidate task. */
|
|
27
|
+
export type ClaimableTask = {
|
|
28
|
+
ID: string;
|
|
29
|
+
Name: string;
|
|
30
|
+
AgentID: string | null;
|
|
31
|
+
UserID: string | null;
|
|
32
|
+
InputPayload: string | null;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Guarded reads and writes over the `Task` claim columns.
|
|
36
|
+
*
|
|
37
|
+
* Uses direct SQL rather than `BaseEntity.Save()` on purpose, and this is the one place in the
|
|
38
|
+
* program where that is correct: the entire point is a *conditional* write whose rowcount is the
|
|
39
|
+
* return value. `Save()` issues an unconditional update and reports success for a row whose state
|
|
40
|
+
* changed underneath it, which is precisely the race being defended against. Every method here is a
|
|
41
|
+
* single statement; nothing reads-then-writes.
|
|
42
|
+
*/
|
|
43
|
+
export declare class TaskClaimStore {
|
|
44
|
+
private readonly instanceID;
|
|
45
|
+
private readonly claimTTLSeconds;
|
|
46
|
+
constructor(instanceID: string, claimTTLSeconds: number);
|
|
47
|
+
private sql;
|
|
48
|
+
/** Schema-qualified `Task` table for the provider's configured core schema. */
|
|
49
|
+
private taskTable;
|
|
50
|
+
/**
|
|
51
|
+
* Attempts to claim one task.
|
|
52
|
+
*
|
|
53
|
+
* The `Status='Pending'` predicate is the whole contract: a task another instance already moved
|
|
54
|
+
* to `In Progress` fails the predicate and yields rowcount 0. `ClaimedBy IS NULL OR
|
|
55
|
+
* ClaimExpiresAt < now` additionally lets an expired claim be taken over without a separate
|
|
56
|
+
* reconciliation pass having to run first.
|
|
57
|
+
*
|
|
58
|
+
* @returns true when this instance now owns the task
|
|
59
|
+
*/
|
|
60
|
+
TryClaim(provider: IMetadataProvider, taskID: string, contextUser: UserInfo): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Extends this instance's claim on a task it is actively running.
|
|
63
|
+
*
|
|
64
|
+
* Guarded on `ClaimedBy=@me` so a heartbeat can never resurrect a claim that reconciliation
|
|
65
|
+
* already released — if the sweep took the task back, the heartbeat fails and the executor
|
|
66
|
+
* learns its work is no longer owned.
|
|
67
|
+
*
|
|
68
|
+
* @returns true when the claim was extended; false means this instance no longer owns the task
|
|
69
|
+
*/
|
|
70
|
+
Heartbeat(provider: IMetadataProvider, taskID: string, contextUser: UserInfo): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Records a terminal outcome and releases the claim in one guarded statement.
|
|
73
|
+
*
|
|
74
|
+
* Guarded on both `Status='In Progress'` and `ClaimedBy=@me`: a task that was cancelled or
|
|
75
|
+
* reassigned while running fails the predicate, so a stale executor cannot overwrite the newer
|
|
76
|
+
* decision. The caller treats rowcount 0 as "someone else owns this now" rather than an error.
|
|
77
|
+
*
|
|
78
|
+
* @returns true when this instance's outcome was recorded
|
|
79
|
+
*/
|
|
80
|
+
CompleteClaimed(provider: IMetadataProvider, taskID: string, outcome: {
|
|
81
|
+
Status: 'Complete' | 'Failed';
|
|
82
|
+
OutputPayload?: string | null;
|
|
83
|
+
ErrorMessage?: string | null;
|
|
84
|
+
AgentRunID?: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* The step's Configuration bag, when the run produced something that belongs in it.
|
|
87
|
+
*
|
|
88
|
+
* Written in the SAME guarded UPDATE as the rest of the outcome rather than a follow-up
|
|
89
|
+
* save, because a second write could land after the row was reclaimed and would then
|
|
90
|
+
* attribute one instance's runtime artefacts to another instance's execution.
|
|
91
|
+
*
|
|
92
|
+
* Omitted leaves the column untouched — a step whose run produces no artefacts must not
|
|
93
|
+
* have its authored configuration blanked as a side effect of finishing.
|
|
94
|
+
*/
|
|
95
|
+
Configuration?: string | null;
|
|
96
|
+
}, contextUser: UserInfo): Promise<boolean>;
|
|
97
|
+
/**
|
|
98
|
+
* Reclaims tasks whose claims have lapsed, returning them to `Pending` so any instance can pick
|
|
99
|
+
* them up.
|
|
100
|
+
*
|
|
101
|
+
* **Human tasks are exempt** (review round 2). A task assigned to a person (`UserID` set) never
|
|
102
|
+
* carries a claim, so `In Progress` with no claim is its *legitimate* parked shape — an approval
|
|
103
|
+
* waiting on someone. Normalizing it would reset that approval out from under the user. Their
|
|
104
|
+
* lifecycle is driven by `DueAt` notification and escalation, never by claim expiry.
|
|
105
|
+
*
|
|
106
|
+
* Only expired claims are reclaimed; a live claim is left strictly alone, which is what keeps a
|
|
107
|
+
* slow-but-healthy task from being executed twice.
|
|
108
|
+
*/
|
|
109
|
+
ReleaseExpiredClaims(provider: IMetadataProvider, contextUser: UserInfo): Promise<ReconciliationEvent[]>;
|
|
110
|
+
/**
|
|
111
|
+
* Returns *agent* tasks sitting `In Progress` with no claim at all.
|
|
112
|
+
*
|
|
113
|
+
* This is the anomalous shape D20 anticipates from a human or an agent writing `Status`
|
|
114
|
+
* directly. It is reported rather than silently corrected: the row is evidence of tampering or
|
|
115
|
+
* of a bug, and Record Changes already carries the audit trail. Human-assigned tasks are
|
|
116
|
+
* excluded because for them this shape is legitimate, not anomalous.
|
|
117
|
+
*/
|
|
118
|
+
FindOrphanedInProgress(provider: IMetadataProvider, contextUser: UserInfo): Promise<ReconciliationEvent[]>;
|
|
119
|
+
/** Runs the affected-rows statement, returning 0 on error rather than throwing into the loop. */
|
|
120
|
+
private affectedRows;
|
|
121
|
+
private literalOrNull;
|
|
122
|
+
/** Single-quote escaping. Inputs here are UUIDs and JSON the dispatcher itself produced. */
|
|
123
|
+
private escape;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=TaskClaimStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TaskClaimStore.d.ts","sourceRoot":"","sources":["../src/TaskClaimStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAE,iBAAiB,EAA6C,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAC9G,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAE9C,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF;;;;;;;;GAQG;AACH,qBAAa,cAAc;IAEnB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,eAAe;gBADf,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,MAAM;IAG5C,OAAO,CAAC,GAAG;IAIX,+EAA+E;IAC/E,OAAO,CAAC,SAAS;IAKjB;;;;;;;;;OASG;IACU,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAiB3G;;;;;;;;OAQG;IACU,SAAS,CAAC,QAAQ,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAY5G;;;;;;;;OAQG;IACU,eAAe,CACxB,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;QACL,MAAM,EAAE,UAAU,GAAG,QAAQ,CAAC;QAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC3B;;;;;;;;;WASG;QACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACjC,EACD,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,OAAO,CAAC;IA6BnB;;;;;;;;;;;OAWG;IACU,oBAAoB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IA2CrH;;;;;;;OAOG;IACU,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAqBvH,iGAAiG;YACnF,YAAY;IAe1B,OAAO,CAAC,aAAa;IAIrB,4FAA4F;IAC5F,OAAO,CAAC,MAAM;CAGjB"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The compare-and-swap claim protocol for durable task execution.
|
|
3
|
+
*
|
|
4
|
+
* This is the mechanism that lets more than one dispatcher instance work the same task table
|
|
5
|
+
* without two of them running the same task, and that lets a crashed instance's work be picked up
|
|
6
|
+
* rather than stranded. It is deliberately a small, self-contained unit: every state transition is
|
|
7
|
+
* a guarded `UPDATE ... WHERE <expected state>` whose rowcount is the answer, so correctness rests
|
|
8
|
+
* on the database's own atomicity rather than on a distributed lock manager.
|
|
9
|
+
*
|
|
10
|
+
* **Why rowcount and not read-then-write.** Reading a task, deciding it is claimable, then writing
|
|
11
|
+
* the claim is a textbook race: two instances can both read `Pending`. The single-statement form —
|
|
12
|
+
* `UPDATE Task SET ClaimedBy=@me WHERE ID=@id AND Status='Pending'` — makes the check and the write
|
|
13
|
+
* one atomic operation, so exactly one instance sees rowcount 1 and the other sees 0 and moves on.
|
|
14
|
+
*
|
|
15
|
+
* **Why every transition is guarded, not just the initial claim.** Per D20 the Task table stays
|
|
16
|
+
* user-writable: entity forms, Data Explorer, GraphQL, and any agent holding an update-record action
|
|
17
|
+
* can change `Status` or clear `ClaimedBy` underneath a running executor. A completion write that
|
|
18
|
+
* only said "set this task Complete" would happily overwrite a task someone had reassigned. Guarding
|
|
19
|
+
* on `ClaimedBy=@me` means a stale executor's write fails cleanly (rowcount 0) instead of
|
|
20
|
+
* double-completing, and the dispatcher can defer to the sweep.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/task-graph
|
|
23
|
+
*/
|
|
24
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
25
|
+
/**
|
|
26
|
+
* Guarded reads and writes over the `Task` claim columns.
|
|
27
|
+
*
|
|
28
|
+
* Uses direct SQL rather than `BaseEntity.Save()` on purpose, and this is the one place in the
|
|
29
|
+
* program where that is correct: the entire point is a *conditional* write whose rowcount is the
|
|
30
|
+
* return value. `Save()` issues an unconditional update and reports success for a row whose state
|
|
31
|
+
* changed underneath it, which is precisely the race being defended against. Every method here is a
|
|
32
|
+
* single statement; nothing reads-then-writes.
|
|
33
|
+
*/
|
|
34
|
+
export class TaskClaimStore {
|
|
35
|
+
constructor(instanceID, claimTTLSeconds) {
|
|
36
|
+
this.instanceID = instanceID;
|
|
37
|
+
this.claimTTLSeconds = claimTTLSeconds;
|
|
38
|
+
}
|
|
39
|
+
sql(provider) {
|
|
40
|
+
return provider;
|
|
41
|
+
}
|
|
42
|
+
/** Schema-qualified `Task` table for the provider's configured core schema. */
|
|
43
|
+
taskTable(provider) {
|
|
44
|
+
const db = this.sql(provider);
|
|
45
|
+
return `${db.QuoteIdentifier(db.MJCoreSchemaName)}.${db.QuoteIdentifier('Task')}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Attempts to claim one task.
|
|
49
|
+
*
|
|
50
|
+
* The `Status='Pending'` predicate is the whole contract: a task another instance already moved
|
|
51
|
+
* to `In Progress` fails the predicate and yields rowcount 0. `ClaimedBy IS NULL OR
|
|
52
|
+
* ClaimExpiresAt < now` additionally lets an expired claim be taken over without a separate
|
|
53
|
+
* reconciliation pass having to run first.
|
|
54
|
+
*
|
|
55
|
+
* @returns true when this instance now owns the task
|
|
56
|
+
*/
|
|
57
|
+
async TryClaim(provider, taskID, contextUser) {
|
|
58
|
+
const db = this.sql(provider);
|
|
59
|
+
const expires = new Date(Date.now() + this.claimTTLSeconds * 1000);
|
|
60
|
+
const sql = `
|
|
61
|
+
UPDATE ${this.taskTable(provider)}
|
|
62
|
+
SET ${db.QuoteIdentifier('Status')} = 'In Progress',
|
|
63
|
+
${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}',
|
|
64
|
+
${db.QuoteIdentifier('ClaimExpiresAt')} = '${expires.toISOString()}',
|
|
65
|
+
${db.QuoteIdentifier('StartedAt')} = '${new Date().toISOString()}'
|
|
66
|
+
WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
|
|
67
|
+
AND ${db.QuoteIdentifier('Status')} = 'Pending'
|
|
68
|
+
AND (${db.QuoteIdentifier('ClaimedBy')} IS NULL
|
|
69
|
+
OR ${db.QuoteIdentifier('ClaimExpiresAt')} IS NULL
|
|
70
|
+
OR ${db.QuoteIdentifier('ClaimExpiresAt')} < '${new Date().toISOString()}')`;
|
|
71
|
+
return (await this.affectedRows(db, sql, contextUser)) === 1;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Extends this instance's claim on a task it is actively running.
|
|
75
|
+
*
|
|
76
|
+
* Guarded on `ClaimedBy=@me` so a heartbeat can never resurrect a claim that reconciliation
|
|
77
|
+
* already released — if the sweep took the task back, the heartbeat fails and the executor
|
|
78
|
+
* learns its work is no longer owned.
|
|
79
|
+
*
|
|
80
|
+
* @returns true when the claim was extended; false means this instance no longer owns the task
|
|
81
|
+
*/
|
|
82
|
+
async Heartbeat(provider, taskID, contextUser) {
|
|
83
|
+
const db = this.sql(provider);
|
|
84
|
+
const expires = new Date(Date.now() + this.claimTTLSeconds * 1000);
|
|
85
|
+
const sql = `
|
|
86
|
+
UPDATE ${this.taskTable(provider)}
|
|
87
|
+
SET ${db.QuoteIdentifier('ClaimExpiresAt')} = '${expires.toISOString()}'
|
|
88
|
+
WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
|
|
89
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}'
|
|
90
|
+
AND ${db.QuoteIdentifier('Status')} = 'In Progress'`;
|
|
91
|
+
return (await this.affectedRows(db, sql, contextUser)) === 1;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Records a terminal outcome and releases the claim in one guarded statement.
|
|
95
|
+
*
|
|
96
|
+
* Guarded on both `Status='In Progress'` and `ClaimedBy=@me`: a task that was cancelled or
|
|
97
|
+
* reassigned while running fails the predicate, so a stale executor cannot overwrite the newer
|
|
98
|
+
* decision. The caller treats rowcount 0 as "someone else owns this now" rather than an error.
|
|
99
|
+
*
|
|
100
|
+
* @returns true when this instance's outcome was recorded
|
|
101
|
+
*/
|
|
102
|
+
async CompleteClaimed(provider, taskID, outcome, contextUser) {
|
|
103
|
+
const db = this.sql(provider);
|
|
104
|
+
const sets = [
|
|
105
|
+
`${db.QuoteIdentifier('Status')} = '${outcome.Status}'`,
|
|
106
|
+
`${db.QuoteIdentifier('CompletedAt')} = '${new Date().toISOString()}'`,
|
|
107
|
+
`${db.QuoteIdentifier('PercentComplete')} = ${outcome.Status === 'Complete' ? 100 : 0}`,
|
|
108
|
+
// Release the claim as part of the same atomic write — a separate release could be
|
|
109
|
+
// interrupted, leaving a terminal task holding a claim that the sweep would then flag.
|
|
110
|
+
`${db.QuoteIdentifier('ClaimedBy')} = NULL`,
|
|
111
|
+
`${db.QuoteIdentifier('ClaimExpiresAt')} = NULL`,
|
|
112
|
+
];
|
|
113
|
+
sets.push(`${db.QuoteIdentifier('OutputPayload')} = ${this.literalOrNull(outcome.OutputPayload)}`);
|
|
114
|
+
sets.push(`${db.QuoteIdentifier('ErrorMessage')} = ${this.literalOrNull(outcome.ErrorMessage)}`);
|
|
115
|
+
sets.push(`${db.QuoteIdentifier('AgentRunID')} = ${outcome.AgentRunID ? `'${this.escape(outcome.AgentRunID)}'` : 'NULL'}`);
|
|
116
|
+
// Only when supplied — see the note on the parameter. `undefined` means "leave it alone",
|
|
117
|
+
// which is not the same as an explicit null.
|
|
118
|
+
if (outcome.Configuration !== undefined) {
|
|
119
|
+
sets.push(`${db.QuoteIdentifier('Configuration')} = ${this.literalOrNull(outcome.Configuration)}`);
|
|
120
|
+
}
|
|
121
|
+
const sql = `
|
|
122
|
+
UPDATE ${this.taskTable(provider)}
|
|
123
|
+
SET ${sets.join(', ')}
|
|
124
|
+
WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
|
|
125
|
+
AND ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
126
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}'`;
|
|
127
|
+
return (await this.affectedRows(db, sql, contextUser)) === 1;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Reclaims tasks whose claims have lapsed, returning them to `Pending` so any instance can pick
|
|
131
|
+
* them up.
|
|
132
|
+
*
|
|
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.
|
|
137
|
+
*
|
|
138
|
+
* Only expired claims are reclaimed; a live claim is left strictly alone, which is what keeps a
|
|
139
|
+
* slow-but-healthy task from being executed twice.
|
|
140
|
+
*/
|
|
141
|
+
async ReleaseExpiredClaims(provider, contextUser) {
|
|
142
|
+
const db = this.sql(provider);
|
|
143
|
+
const now = new Date().toISOString();
|
|
144
|
+
// Capture what will be reclaimed BEFORE reclaiming, so the log names the tasks. The
|
|
145
|
+
// subsequent UPDATE re-states the same predicate, so a task whose claim was refreshed in
|
|
146
|
+
// between is correctly skipped rather than reclaimed on stale information.
|
|
147
|
+
const candidates = await db.ExecuteSQL(`SELECT ${db.QuoteIdentifier('ID')}, ${db.QuoteIdentifier('Name')}, ${db.QuoteIdentifier('ClaimedBy')}
|
|
148
|
+
FROM ${this.taskTable(provider)}
|
|
149
|
+
WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
150
|
+
AND (${db.QuoteIdentifier('AgentID')} IS NOT NULL OR ${db.QuoteIdentifier('ActionID')} IS NOT NULL)
|
|
151
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} IS NOT NULL
|
|
152
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} IS NOT NULL
|
|
153
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} < '${now}'`, undefined, undefined, contextUser);
|
|
154
|
+
if (!candidates || candidates.length === 0)
|
|
155
|
+
return [];
|
|
156
|
+
const sql = `
|
|
157
|
+
UPDATE ${this.taskTable(provider)}
|
|
158
|
+
SET ${db.QuoteIdentifier('Status')} = 'Pending',
|
|
159
|
+
${db.QuoteIdentifier('ClaimedBy')} = NULL,
|
|
160
|
+
${db.QuoteIdentifier('ClaimExpiresAt')} = NULL
|
|
161
|
+
WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
162
|
+
AND (${db.QuoteIdentifier('AgentID')} IS NOT NULL OR ${db.QuoteIdentifier('ActionID')} IS NOT NULL)
|
|
163
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} IS NOT NULL
|
|
164
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} IS NOT NULL
|
|
165
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} < '${now}'`;
|
|
166
|
+
const released = await this.affectedRows(db, sql, contextUser);
|
|
167
|
+
const events = candidates.slice(0, released).map((c) => ({
|
|
168
|
+
TaskID: c.ID,
|
|
169
|
+
Action: 'ExpiredClaimReleased',
|
|
170
|
+
Detail: `Claim held by '${c.ClaimedBy}' expired; task '${c.Name}' returned to Pending.`,
|
|
171
|
+
}));
|
|
172
|
+
for (const e of events) {
|
|
173
|
+
LogStatus(`[TaskGraph reconciliation] ${e.Action}: ${e.Detail}`);
|
|
174
|
+
}
|
|
175
|
+
return events;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Returns *agent* tasks sitting `In Progress` with no claim at all.
|
|
179
|
+
*
|
|
180
|
+
* This is the anomalous shape D20 anticipates from a human or an agent writing `Status`
|
|
181
|
+
* directly. It is reported rather than silently corrected: the row is evidence of tampering or
|
|
182
|
+
* of a bug, and Record Changes already carries the audit trail. Human-assigned tasks are
|
|
183
|
+
* excluded because for them this shape is legitimate, not anomalous.
|
|
184
|
+
*/
|
|
185
|
+
async FindOrphanedInProgress(provider, contextUser) {
|
|
186
|
+
const db = this.sql(provider);
|
|
187
|
+
const rows = await db.ExecuteSQL(`SELECT ${db.QuoteIdentifier('ID')}, ${db.QuoteIdentifier('Name')}
|
|
188
|
+
FROM ${this.taskTable(provider)}
|
|
189
|
+
WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
190
|
+
AND (${db.QuoteIdentifier('AgentID')} IS NOT NULL OR ${db.QuoteIdentifier('ActionID')} IS NOT NULL)
|
|
191
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} IS NULL`, undefined, undefined, contextUser);
|
|
192
|
+
const events = (rows ?? []).map((r) => ({
|
|
193
|
+
TaskID: r.ID,
|
|
194
|
+
Action: 'OrphanedInProgressReleased',
|
|
195
|
+
Detail: `Agent task '${r.Name}' is In Progress with no claim — no dispatcher owns it.`,
|
|
196
|
+
}));
|
|
197
|
+
for (const e of events) {
|
|
198
|
+
LogError(`[TaskGraph reconciliation] ${e.Action}: ${e.Detail}`);
|
|
199
|
+
}
|
|
200
|
+
return events;
|
|
201
|
+
}
|
|
202
|
+
/** Runs the affected-rows statement, returning 0 on error rather than throwing into the loop. */
|
|
203
|
+
async affectedRows(db, sql, contextUser) {
|
|
204
|
+
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);
|
|
208
|
+
return Number(rows?.[0]?.AffectedRows ?? 0);
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
LogError(`[TaskGraph] guarded write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
literalOrNull(value) {
|
|
216
|
+
return value == null ? 'NULL' : `'${this.escape(value)}'`;
|
|
217
|
+
}
|
|
218
|
+
/** Single-quote escaping. Inputs here are UUIDs and JSON the dispatcher itself produced. */
|
|
219
|
+
escape(value) {
|
|
220
|
+
return value.replace(/'/g, "''");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=TaskClaimStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TaskClaimStore.js","sourceRoot":"","sources":["../src/TaskClaimStore.ts"],"names":[],"mappings":"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"}
|