agents-relay 1.0.177 → 2.0.0
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/README.md +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { GraphDispatcher } from './graph-v2.js';
|
|
2
|
+
/**
|
|
3
|
+
* The single production graph execution boundary.
|
|
4
|
+
* Ingress adapters normalize input/facts, while GraphDispatcher alone evaluates,
|
|
5
|
+
* persists graph transitions, executes graph-selected actions, and emits events.
|
|
6
|
+
*/
|
|
7
|
+
export async function dispatchWorkflowGraph(runtime, options) {
|
|
8
|
+
let current = options.initialState;
|
|
9
|
+
const maxSteps = options.maxSteps ?? 1;
|
|
10
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
11
|
+
const dispatcher = new GraphDispatcher(runtime, {
|
|
12
|
+
persistTransition: async (transition) => { await options.persistTransition?.(transition.source, transition.target); },
|
|
13
|
+
executeAction: options.executeAction ? async (actionId) => await options.executeAction(actionId) : undefined,
|
|
14
|
+
publishEvent: async (event) => { await options.publishEvent?.(event.id, event.payload); },
|
|
15
|
+
});
|
|
16
|
+
const result = await dispatcher.dispatch({
|
|
17
|
+
currentState: current,
|
|
18
|
+
input: options.input,
|
|
19
|
+
durableFacts: options.durableFacts ?? {},
|
|
20
|
+
});
|
|
21
|
+
if (!result.decision.accepted)
|
|
22
|
+
return result.decision;
|
|
23
|
+
current = result.decision.target;
|
|
24
|
+
if (!options.isTransientState?.(current))
|
|
25
|
+
return result.decision;
|
|
26
|
+
}
|
|
27
|
+
return { accepted: false, source: current, reason: 'no_transition' };
|
|
28
|
+
}
|
|
29
|
+
export function dispatchWorkflowGraphSync(runtime, options) {
|
|
30
|
+
let current = options.initialState;
|
|
31
|
+
const maxSteps = options.maxSteps ?? 1;
|
|
32
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
33
|
+
const dispatcher = new GraphDispatcher(runtime, {
|
|
34
|
+
persistTransition: transition => { const result = options.persistTransition?.(transition.source, transition.target); if (result instanceof Promise)
|
|
35
|
+
throw new Error('sync workflow persistTransition returned Promise'); },
|
|
36
|
+
executeAction: options.executeAction ? actionId => { const result = options.executeAction(actionId); if (result instanceof Promise)
|
|
37
|
+
throw new Error('sync workflow executeAction returned Promise'); return result; } : undefined,
|
|
38
|
+
publishEvent: event => { const result = options.publishEvent?.(event.id, event.payload); if (result instanceof Promise)
|
|
39
|
+
throw new Error('sync workflow publishEvent returned Promise'); },
|
|
40
|
+
});
|
|
41
|
+
const result = dispatcher.dispatchSync({ currentState: current, input: options.input, durableFacts: options.durableFacts ?? {} });
|
|
42
|
+
if (!result.decision.accepted)
|
|
43
|
+
return result.decision;
|
|
44
|
+
current = result.decision.target;
|
|
45
|
+
if (!options.isTransientState?.(current))
|
|
46
|
+
return result.decision;
|
|
47
|
+
}
|
|
48
|
+
return { accepted: false, source: current, reason: 'no_transition' };
|
|
49
|
+
}
|
|
50
|
+
export async function dispatchWorkflowTargetOverride(runtime, options) {
|
|
51
|
+
// Administrative correction still derives legality from the canonical Mermaid graph.
|
|
52
|
+
// It intentionally executes no workflow actions/effects: this is a state repair, not an event replay.
|
|
53
|
+
const { assertV2StateTransition } = await import('./definitions-v2.js');
|
|
54
|
+
assertV2StateTransition(runtime.graph, options.currentState, options.targetState, options.kind);
|
|
55
|
+
await options.persistTransition(options.currentState, options.targetState);
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { dispatchJobLifecycleSync, dispatchTaskLifecycleSync } from './lifecycle.js';
|
|
2
|
+
function dependenciesSatisfied(job, dependencies) {
|
|
3
|
+
if (dependencies.length === 0)
|
|
4
|
+
return true;
|
|
5
|
+
const byId = new Map(job.tasks.map(task => [task.id, task]));
|
|
6
|
+
return dependencies.every(id => byId.get(id)?.state === 'SUCCEEDED');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Canonical creation boundary for new Task entities.
|
|
10
|
+
* Constructors provide metadata/facts only; the Task lifecycle graph derives
|
|
11
|
+
* whether a fresh Task remains DRAFT or becomes QUEUED.
|
|
12
|
+
*/
|
|
13
|
+
export function createTaskEntity(job, value, options = {}) {
|
|
14
|
+
const task = { ...value, state: 'DRAFT' };
|
|
15
|
+
if (!options.defer) {
|
|
16
|
+
dispatchTaskLifecycleSync(task, 'reconcile', {
|
|
17
|
+
dependenciesSatisfied: dependenciesSatisfied(job, task.dependencies),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return task;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Canonical creation boundary for new Job entities.
|
|
24
|
+
* New Jobs begin at the graph initial state and scheduled creation is expressed
|
|
25
|
+
* as the normal activate lifecycle event.
|
|
26
|
+
*/
|
|
27
|
+
export function createJobEntity(value, options = {}) {
|
|
28
|
+
const job = { ...value, state: 'DRAFT' };
|
|
29
|
+
if (options.activate)
|
|
30
|
+
dispatchJobLifecycleSync(job, 'activate');
|
|
31
|
+
return job;
|
|
32
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/** Strict, executable Mermaid flowchart profile for the incremental v2 graph runtime. */
|
|
2
|
+
const IDENTIFIER = '[A-Za-z_][A-Za-z0-9_.-]*';
|
|
3
|
+
const NODE = new RegExp(`^(${IDENTIFIER})\\["(state|guard|action|emit):\\s*([^|\\]"]+?)(?:\\s*\\|[^\\]"]*)?"\\]$`);
|
|
4
|
+
const EDGE = new RegExp(`^(${IDENTIFIER})\\s+-->\\s*(?:\\|\\s*([^|]+?)\\s*\\|\\s*)?(${IDENTIFIER})$`);
|
|
5
|
+
const HEADER = /^flowchart (LR|RL|TD|TB)$/;
|
|
6
|
+
const NAME = new RegExp(`^${IDENTIFIER}$`);
|
|
7
|
+
function fail(line, message) { throw new Error(`line ${line}: ${message}`); }
|
|
8
|
+
function lookup(value, id) {
|
|
9
|
+
return value instanceof Map ? value.get(id) : value?.[id];
|
|
10
|
+
}
|
|
11
|
+
function entries(value) {
|
|
12
|
+
return value instanceof Map ? value.entries() : Object.entries(value ?? {});
|
|
13
|
+
}
|
|
14
|
+
function parseLabel(raw, line) {
|
|
15
|
+
if (raw === undefined)
|
|
16
|
+
return { kind: 'unlabelled', semanticId: null };
|
|
17
|
+
const label = raw.trim();
|
|
18
|
+
if (label === 'true' || label === 'false')
|
|
19
|
+
return { kind: label, semanticId: null };
|
|
20
|
+
const match = label.match(/^(on|request|to|emit):\s*(.*)$/);
|
|
21
|
+
if (!match || !NAME.test(match[2]))
|
|
22
|
+
fail(line, `unsupported edge label '${label}'`);
|
|
23
|
+
return { kind: match[1], semanticId: match[2] };
|
|
24
|
+
}
|
|
25
|
+
export function parseGraphV2(source) {
|
|
26
|
+
const lines = source.split(/\r?\n/);
|
|
27
|
+
let direction = null;
|
|
28
|
+
let initial = null;
|
|
29
|
+
const nodes = new Map();
|
|
30
|
+
const edges = [];
|
|
31
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
32
|
+
const lineNumber = index + 1;
|
|
33
|
+
const line = lines[index].trim();
|
|
34
|
+
if (!line)
|
|
35
|
+
continue;
|
|
36
|
+
if (line.startsWith('%%')) {
|
|
37
|
+
const directive = line.match(/^%%\s*relay:\s*initial\s+(.+)$/);
|
|
38
|
+
if (directive) {
|
|
39
|
+
if (initial)
|
|
40
|
+
fail(lineNumber, 'duplicate initial declaration');
|
|
41
|
+
initial = directive[1].trim();
|
|
42
|
+
if (!NAME.test(initial))
|
|
43
|
+
fail(lineNumber, `invalid initial state '${initial}'`);
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!direction) {
|
|
48
|
+
const header = line.match(HEADER);
|
|
49
|
+
if (!header)
|
|
50
|
+
fail(lineNumber, 'expected flowchart LR, RL, TD, or TB');
|
|
51
|
+
direction = header[1];
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const node = line.match(NODE);
|
|
55
|
+
if (node) {
|
|
56
|
+
if (nodes.has(node[1]))
|
|
57
|
+
fail(lineNumber, `duplicate node '${node[1]}'`);
|
|
58
|
+
const semanticId = node[3].trim();
|
|
59
|
+
if (!NAME.test(semanticId))
|
|
60
|
+
fail(lineNumber, `invalid ${node[2]} identifier '${semanticId}'`);
|
|
61
|
+
if (node[2] === 'state' && [...nodes.values()].some(candidate => candidate.kind === 'state' && candidate.semanticId === semanticId))
|
|
62
|
+
fail(lineNumber, `duplicate semantic declaration 'state: ${semanticId}'`);
|
|
63
|
+
nodes.set(node[1], { id: node[1], kind: node[2], semanticId, line: lineNumber });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const edge = line.match(EDGE);
|
|
67
|
+
if (edge) {
|
|
68
|
+
const label = parseLabel(edge[2], lineNumber);
|
|
69
|
+
edges.push({ from: edge[1], to: edge[3], ...label, line: lineNumber });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const unsupportedShape = line.match(/[({<]/)?.[0];
|
|
73
|
+
if (unsupportedShape) {
|
|
74
|
+
const shape = unsupportedShape === '{' ? '{...}' : `${unsupportedShape}...`;
|
|
75
|
+
fail(lineNumber, `unsupported node shape '${shape}'; expected '[\"...\"]'`);
|
|
76
|
+
}
|
|
77
|
+
fail(lineNumber, `unsupported syntax '${line}'`);
|
|
78
|
+
}
|
|
79
|
+
if (!direction)
|
|
80
|
+
throw new Error('line 1: executable graph is empty; expected flowchart header');
|
|
81
|
+
if (!initial)
|
|
82
|
+
throw new Error('graph requires exactly one %% relay: initial STATE declaration');
|
|
83
|
+
if (!nodes.has(initial) || nodes.get(initial)?.kind !== 'state')
|
|
84
|
+
throw new Error(`initial state '${initial}' is not a declared state node`);
|
|
85
|
+
if (![...nodes.values()].some(node => node.kind === 'state'))
|
|
86
|
+
throw new Error('graph requires at least one state node');
|
|
87
|
+
if (!edges.length)
|
|
88
|
+
throw new Error('graph requires at least one executable edge');
|
|
89
|
+
for (const edge of edges) {
|
|
90
|
+
if (!nodes.has(edge.from))
|
|
91
|
+
fail(edge.line, `edge source '${edge.from}' is not declared`);
|
|
92
|
+
if (!nodes.has(edge.to))
|
|
93
|
+
fail(edge.line, `edge target '${edge.to}' is not declared`);
|
|
94
|
+
}
|
|
95
|
+
validateTopology(nodes, edges, initial);
|
|
96
|
+
return { source, direction, nodes, edges, initial };
|
|
97
|
+
}
|
|
98
|
+
function validateTopology(nodes, edges, initial) {
|
|
99
|
+
const outgoing = new Map();
|
|
100
|
+
for (const edge of edges)
|
|
101
|
+
outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]);
|
|
102
|
+
for (const node of nodes.values()) {
|
|
103
|
+
const exits = outgoing.get(node.id) ?? [];
|
|
104
|
+
if (node.kind === 'state') {
|
|
105
|
+
for (const edge of exits) {
|
|
106
|
+
if (edge.kind !== 'on' && edge.kind !== 'request')
|
|
107
|
+
fail(edge.line, `state '${node.semanticId}' may only have on:/request: exits`);
|
|
108
|
+
const target = nodes.get(edge.to);
|
|
109
|
+
if (target.kind !== 'state' && target.kind !== 'guard' && target.kind !== 'action')
|
|
110
|
+
fail(edge.line, `${edge.kind} edge must lead to a state, guard, or action`);
|
|
111
|
+
}
|
|
112
|
+
const groups = new Map();
|
|
113
|
+
for (const edge of exits) {
|
|
114
|
+
const key = `${edge.kind}\\0${edge.semanticId}`;
|
|
115
|
+
groups.set(key, [...(groups.get(key) ?? []), edge]);
|
|
116
|
+
}
|
|
117
|
+
for (const group of groups.values()) {
|
|
118
|
+
if (group.length < 2)
|
|
119
|
+
continue;
|
|
120
|
+
if (group.some(edge => nodes.get(edge.to).kind !== 'guard') || new Set(group.map(edge => edge.to)).size !== group.length) {
|
|
121
|
+
const edge = group[1];
|
|
122
|
+
fail(edge.line, `ambiguous input path for ${edge.kind} '${edge.semanticId}' from state '${node.semanticId}'`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (node.kind === 'guard') {
|
|
127
|
+
if (exits.length < 1 || exits.length > 2 || exits.some(edge => edge.kind !== 'true' && edge.kind !== 'false') || new Set(exits.map(edge => edge.kind)).size !== exits.length)
|
|
128
|
+
fail(node.line, `guard '${node.semanticId}' requires one or two unique true/false exits`);
|
|
129
|
+
for (const edge of exits)
|
|
130
|
+
if (!['state', 'guard', 'action'].includes(nodes.get(edge.to).kind))
|
|
131
|
+
fail(edge.line, `guard edge '${edge.kind}' must lead to a state, guard, or action`);
|
|
132
|
+
}
|
|
133
|
+
else if (node.kind === 'action') {
|
|
134
|
+
const continuations = exits.filter(edge => edge.kind === 'to' || edge.kind === 'unlabelled');
|
|
135
|
+
if (continuations.length > 1)
|
|
136
|
+
fail(node.line, `action '${node.semanticId}' has multiple continuations`);
|
|
137
|
+
for (const edge of continuations) {
|
|
138
|
+
const target = nodes.get(edge.to);
|
|
139
|
+
if (edge.kind === 'to') {
|
|
140
|
+
if (target.kind !== 'state')
|
|
141
|
+
fail(edge.line, 'to: action continuation must target a state');
|
|
142
|
+
if (edge.semanticId !== target.semanticId)
|
|
143
|
+
fail(edge.line, `to edge names state '${edge.semanticId}' but targets state '${target.semanticId}'`);
|
|
144
|
+
}
|
|
145
|
+
else if (target.kind !== 'state' && target.kind !== 'action')
|
|
146
|
+
fail(edge.line, 'unlabelled action continuation must lead to an action or state');
|
|
147
|
+
}
|
|
148
|
+
for (const edge of exits.filter(edge => edge.kind === 'emit')) {
|
|
149
|
+
const target = nodes.get(edge.to);
|
|
150
|
+
if (target.kind !== 'emit')
|
|
151
|
+
fail(edge.line, 'emit edge must lead to an emit node');
|
|
152
|
+
if (edge.semanticId !== target.semanticId)
|
|
153
|
+
fail(edge.line, `emit edge names event '${edge.semanticId}' but targets emit '${target.semanticId}'`);
|
|
154
|
+
}
|
|
155
|
+
if (!continuations.length)
|
|
156
|
+
fail(node.line, `action '${node.semanticId}' must have one continuation`);
|
|
157
|
+
}
|
|
158
|
+
else if (exits.length)
|
|
159
|
+
fail(node.line, `emit node '${node.semanticId}' must not have outgoing edges`);
|
|
160
|
+
}
|
|
161
|
+
const reachable = new Set([initial]);
|
|
162
|
+
let changed = true;
|
|
163
|
+
while (changed) {
|
|
164
|
+
changed = false;
|
|
165
|
+
for (const edge of edges)
|
|
166
|
+
if (reachable.has(edge.from) && !reachable.has(edge.to)) {
|
|
167
|
+
reachable.add(edge.to);
|
|
168
|
+
changed = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const node of nodes.values())
|
|
172
|
+
if (node.kind === 'state' && !reachable.has(node.id))
|
|
173
|
+
fail(node.line, `state '${node.semanticId}' is unreachable from initial state '${initial}'`);
|
|
174
|
+
}
|
|
175
|
+
export class GraphRegistry {
|
|
176
|
+
states;
|
|
177
|
+
guards;
|
|
178
|
+
actions;
|
|
179
|
+
events;
|
|
180
|
+
constructor(registries = {}) {
|
|
181
|
+
this.states = registries.states ?? new Set();
|
|
182
|
+
this.guards = new Map(entries(registries.guards));
|
|
183
|
+
this.actions = new Map(entries(registries.actions));
|
|
184
|
+
this.events = new Map(entries(registries.events));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export class GraphRuntime {
|
|
188
|
+
graph;
|
|
189
|
+
registry;
|
|
190
|
+
constructor(graphOrSource, registries = {}) {
|
|
191
|
+
this.graph = typeof graphOrSource === 'string' ? parseGraphV2(graphOrSource) : graphOrSource;
|
|
192
|
+
this.registry = new GraphRegistry(registries);
|
|
193
|
+
this.validateRegistries();
|
|
194
|
+
}
|
|
195
|
+
validateRegistries() {
|
|
196
|
+
for (const node of this.graph.nodes.values()) {
|
|
197
|
+
if (node.kind === 'state' && !this.registry.states.has(node.semanticId))
|
|
198
|
+
throw new Error(`unknown state '${node.semanticId}'`);
|
|
199
|
+
if (node.kind === 'guard' && !this.registry.guards.has(node.semanticId))
|
|
200
|
+
throw new Error(`unknown guard '${node.semanticId}'`);
|
|
201
|
+
if (node.kind === 'action' && !this.registry.actions.has(node.semanticId))
|
|
202
|
+
throw new Error(`unknown action '${node.semanticId}'`);
|
|
203
|
+
if (node.kind === 'emit' && !this.registry.events.has(node.semanticId))
|
|
204
|
+
throw new Error(`unknown emitted event '${node.semanticId}'`);
|
|
205
|
+
}
|
|
206
|
+
for (const edge of this.graph.edges) {
|
|
207
|
+
if (edge.kind === 'on' || edge.kind === 'emit') {
|
|
208
|
+
if (!this.registry.events.has(edge.semanticId))
|
|
209
|
+
throw new Error(`unknown event '${edge.semanticId}'`);
|
|
210
|
+
}
|
|
211
|
+
if (edge.kind === 'request' && !this.registry.actions.has(edge.semanticId))
|
|
212
|
+
throw new Error(`unknown action '${edge.semanticId}'`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
validateInput(input) {
|
|
216
|
+
const definition = input.transport === 'event' ? this.registry.events.get(input.id) : this.registry.actions.get(input.id);
|
|
217
|
+
if (!definition)
|
|
218
|
+
throw new Error(`unknown ${input.transport} '${input.id}'`);
|
|
219
|
+
if (definition.validate && !definition.validate(input.payload))
|
|
220
|
+
throw new Error(`invalid payload for ${input.transport} '${input.id}'`);
|
|
221
|
+
}
|
|
222
|
+
pathFromSync(current, facts) {
|
|
223
|
+
const actions = [];
|
|
224
|
+
const visited = new Set();
|
|
225
|
+
while (true) {
|
|
226
|
+
if (visited.has(current))
|
|
227
|
+
throw new Error(`cyclic graph path at '${current}'`);
|
|
228
|
+
visited.add(current);
|
|
229
|
+
const currentNode = this.graph.nodes.get(current);
|
|
230
|
+
if (currentNode.kind === 'state')
|
|
231
|
+
return { target: currentNode.semanticId, actions };
|
|
232
|
+
if (currentNode.kind === 'guard') {
|
|
233
|
+
const guard = this.registry.guards.get(currentNode.semanticId);
|
|
234
|
+
const result = guard(facts);
|
|
235
|
+
if (result instanceof Promise)
|
|
236
|
+
throw new Error(`guard '${currentNode.semanticId}' is asynchronous; use evaluate()`);
|
|
237
|
+
const branch = (result ? 'true' : 'false');
|
|
238
|
+
const branchEdge = this.graph.edges.find(edge => edge.from === currentNode.id && edge.kind === branch);
|
|
239
|
+
if (!branchEdge)
|
|
240
|
+
return null;
|
|
241
|
+
current = branchEdge.to;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (currentNode.kind === 'action') {
|
|
245
|
+
const emittedEvents = this.graph.edges.filter(edge => edge.from === currentNode.id && edge.kind === 'emit').map(edge => edge.semanticId);
|
|
246
|
+
actions.push({ node: currentNode, emittedEvents });
|
|
247
|
+
current = this.graph.edges.find(edge => edge.from === currentNode.id && (edge.kind === 'to' || edge.kind === 'unlabelled')).to;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`invalid executable path through emit node '${currentNode.semanticId}'`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
evaluateSync(facts) {
|
|
254
|
+
this.validateInput(facts.input);
|
|
255
|
+
const stateNode = [...this.graph.nodes.values()].find(node => node.kind === 'state' && node.semanticId === facts.currentState);
|
|
256
|
+
if (!stateNode)
|
|
257
|
+
throw new Error(`unknown state '${facts.currentState}'`);
|
|
258
|
+
const edges = this.graph.edges.filter(edge => edge.from === stateNode.id && edge.kind === (facts.input.transport === 'event' ? 'on' : 'request') && edge.semanticId === facts.input.id);
|
|
259
|
+
const results = edges.map(edge => this.pathFromSync(edge.to, facts)).filter((result) => result !== null);
|
|
260
|
+
if (results.length === 0)
|
|
261
|
+
return { accepted: false, source: facts.currentState, reason: 'no_transition' };
|
|
262
|
+
if (results.length > 1)
|
|
263
|
+
return { accepted: false, source: facts.currentState, reason: 'ambiguous' };
|
|
264
|
+
const result = results[0];
|
|
265
|
+
return { accepted: true, source: facts.currentState, target: result.target, actions: result.actions.map(step => ({ id: step.node.semanticId, input: facts.input, emittedEvents: step.emittedEvents })) };
|
|
266
|
+
}
|
|
267
|
+
async evaluate(facts) {
|
|
268
|
+
this.validateInput(facts.input);
|
|
269
|
+
const stateNode = [...this.graph.nodes.values()].find(node => node.kind === 'state' && node.semanticId === facts.currentState);
|
|
270
|
+
if (!stateNode)
|
|
271
|
+
throw new Error(`unknown state '${facts.currentState}'`);
|
|
272
|
+
const edges = this.graph.edges.filter(candidate => candidate.from === stateNode.id && candidate.kind === (facts.input.transport === 'event' ? 'on' : 'request') && candidate.semanticId === facts.input.id);
|
|
273
|
+
const evaluatePath = async (start) => {
|
|
274
|
+
let current = start;
|
|
275
|
+
const actions = [];
|
|
276
|
+
while (true) {
|
|
277
|
+
const node = this.graph.nodes.get(current);
|
|
278
|
+
if (node.kind === 'state')
|
|
279
|
+
return { target: node.semanticId, actions };
|
|
280
|
+
if (node.kind === 'guard') {
|
|
281
|
+
const result = await this.registry.guards.get(node.semanticId)(facts);
|
|
282
|
+
const branch = this.graph.edges.find(candidate => candidate.from === node.id && candidate.kind === (result ? 'true' : 'false'));
|
|
283
|
+
if (!branch)
|
|
284
|
+
return null;
|
|
285
|
+
current = branch.to;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (node.kind === 'action') {
|
|
289
|
+
const emittedEvents = this.graph.edges.filter(candidate => candidate.from === node.id && candidate.kind === 'emit').map(candidate => candidate.semanticId);
|
|
290
|
+
actions.push({ node, emittedEvents });
|
|
291
|
+
current = this.graph.edges.find(candidate => candidate.from === node.id && (candidate.kind === 'to' || candidate.kind === 'unlabelled')).to;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
throw new Error(`invalid executable path through emit node '${node.semanticId}'`);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const results = (await Promise.all(edges.map(edge => evaluatePath(edge.to)))).filter((result) => result !== null);
|
|
298
|
+
if (results.length === 0)
|
|
299
|
+
return { accepted: false, source: facts.currentState, reason: 'no_transition' };
|
|
300
|
+
if (results.length > 1)
|
|
301
|
+
return { accepted: false, source: facts.currentState, reason: 'ambiguous' };
|
|
302
|
+
const result = results[0];
|
|
303
|
+
return { accepted: true, source: facts.currentState, target: result.target, actions: result.actions.map(step => ({ id: step.node.semanticId, input: facts.input, emittedEvents: step.emittedEvents })) };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export class GraphDispatcher {
|
|
307
|
+
runtime;
|
|
308
|
+
ports;
|
|
309
|
+
constructor(runtime, ports) {
|
|
310
|
+
this.runtime = runtime;
|
|
311
|
+
this.ports = ports;
|
|
312
|
+
}
|
|
313
|
+
dispatchSync(facts) {
|
|
314
|
+
const decision = this.runtime.evaluateSync(facts);
|
|
315
|
+
if (!decision.accepted)
|
|
316
|
+
return { decision, receipts: [] };
|
|
317
|
+
const persisted = this.ports.persistTransition({ source: decision.source, target: decision.target });
|
|
318
|
+
if (persisted instanceof Promise)
|
|
319
|
+
throw new Error('GraphDispatcher.dispatchSync requires synchronous persistTransition');
|
|
320
|
+
const receipts = [];
|
|
321
|
+
for (const step of decision.actions) {
|
|
322
|
+
const action = this.runtime.registry.actions.get(step.id);
|
|
323
|
+
const context = { facts, actionId: step.id, input: step.input };
|
|
324
|
+
const pending = this.ports.executeAction ? this.ports.executeAction(step.id, context) : action.execute ? action.execute(context) : {};
|
|
325
|
+
if (pending instanceof Promise)
|
|
326
|
+
throw new Error('GraphDispatcher.dispatchSync requires synchronous executeAction');
|
|
327
|
+
const receipt = pending;
|
|
328
|
+
receipts.push(receipt);
|
|
329
|
+
if (receipt.ok === false) {
|
|
330
|
+
const definition = this.runtime.registry.events.get(receipt.failureEvent.id);
|
|
331
|
+
if (!definition)
|
|
332
|
+
throw new Error(`unknown failure event '${receipt.failureEvent.id}'`);
|
|
333
|
+
if (definition.validate && !definition.validate(receipt.failureEvent.payload))
|
|
334
|
+
throw new Error(`invalid payload for failure event '${receipt.failureEvent.id}'`);
|
|
335
|
+
const published = this.ports.publishEvent(receipt.failureEvent);
|
|
336
|
+
if (published instanceof Promise)
|
|
337
|
+
throw new Error('GraphDispatcher.dispatchSync requires synchronous publishEvent');
|
|
338
|
+
return { decision, receipts };
|
|
339
|
+
}
|
|
340
|
+
const payloads = receipt.payloads ?? {};
|
|
341
|
+
for (const eventId of step.emittedEvents) {
|
|
342
|
+
const definition = this.runtime.registry.events.get(eventId);
|
|
343
|
+
if (!(eventId in payloads))
|
|
344
|
+
throw new Error(`action '${step.id}' did not supply payload for emitted event '${eventId}'`);
|
|
345
|
+
if (definition.validate && !definition.validate(payloads[eventId]))
|
|
346
|
+
throw new Error(`invalid payload for emitted event '${eventId}'`);
|
|
347
|
+
}
|
|
348
|
+
for (const eventId of step.emittedEvents) {
|
|
349
|
+
const published = this.ports.publishEvent({ id: eventId, payload: payloads[eventId] });
|
|
350
|
+
if (published instanceof Promise)
|
|
351
|
+
throw new Error('GraphDispatcher.dispatchSync requires synchronous publishEvent');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { decision, receipts };
|
|
355
|
+
}
|
|
356
|
+
async dispatch(facts) {
|
|
357
|
+
const decision = await this.runtime.evaluate(facts);
|
|
358
|
+
if (!decision.accepted)
|
|
359
|
+
return { decision, receipts: [] };
|
|
360
|
+
await this.ports.persistTransition({ source: decision.source, target: decision.target });
|
|
361
|
+
const receipts = [];
|
|
362
|
+
for (const step of decision.actions) {
|
|
363
|
+
const action = this.runtime.registry.actions.get(step.id);
|
|
364
|
+
const context = { facts, actionId: step.id, input: step.input };
|
|
365
|
+
const receipt = this.ports.executeAction ? await this.ports.executeAction(step.id, context) : action.execute ? await action.execute(context) : {};
|
|
366
|
+
receipts.push(receipt);
|
|
367
|
+
if (receipt.ok === false) {
|
|
368
|
+
const definition = this.runtime.registry.events.get(receipt.failureEvent.id);
|
|
369
|
+
if (!definition)
|
|
370
|
+
throw new Error(`unknown failure event '${receipt.failureEvent.id}'`);
|
|
371
|
+
if (definition.validate && !definition.validate(receipt.failureEvent.payload))
|
|
372
|
+
throw new Error(`invalid payload for failure event '${receipt.failureEvent.id}'`);
|
|
373
|
+
await this.ports.publishEvent(receipt.failureEvent);
|
|
374
|
+
return { decision, receipts };
|
|
375
|
+
}
|
|
376
|
+
const payloads = receipt.payloads ?? {};
|
|
377
|
+
for (const eventId of step.emittedEvents) {
|
|
378
|
+
const definition = this.runtime.registry.events.get(eventId);
|
|
379
|
+
if (!(eventId in payloads))
|
|
380
|
+
throw new Error(`action '${step.id}' did not supply payload for emitted event '${eventId}'`);
|
|
381
|
+
if (definition.validate && !definition.validate(payloads[eventId]))
|
|
382
|
+
throw new Error(`invalid payload for emitted event '${eventId}'`);
|
|
383
|
+
}
|
|
384
|
+
for (const eventId of step.emittedEvents)
|
|
385
|
+
await this.ports.publishEvent({ id: eventId, payload: payloads[eventId] });
|
|
386
|
+
}
|
|
387
|
+
return { decision, receipts };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
export const parseExecutableGraph = parseGraphV2;
|
|
391
|
+
export const loadGraphV2 = (source, registries = {}) => new GraphRuntime(source, registries);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { decideJobLifecycle, dispatchJobLifecycle } from './lifecycle.js';
|
|
2
|
+
import { implementationComplete } from '../runtime-status.js';
|
|
3
|
+
export function decideJobReconcile(job, facts) {
|
|
4
|
+
return decideJobLifecycle(job, 'reconcile', {
|
|
5
|
+
prOpen: facts.prState === 'OPEN',
|
|
6
|
+
reviewDisabled: job.reviewEnabled === false,
|
|
7
|
+
implementationComplete: implementationComplete(job),
|
|
8
|
+
autoMerge: job.autoMerge !== false,
|
|
9
|
+
headAvailable: Boolean(facts.headSha),
|
|
10
|
+
mergeCapable: facts.mergeCapable,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function dispatchJobReconcile(job, facts, executeEffect, persist) {
|
|
14
|
+
return dispatchJobLifecycle(job, 'reconcile', {
|
|
15
|
+
prOpen: facts.prState === 'OPEN', reviewDisabled: job.reviewEnabled === false,
|
|
16
|
+
implementationComplete: implementationComplete(job), autoMerge: job.autoMerge !== false,
|
|
17
|
+
headAvailable: Boolean(facts.headSha), mergeCapable: facts.mergeCapable,
|
|
18
|
+
}, executeEffect, persist);
|
|
19
|
+
}
|
|
@@ -1,8 +1,86 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { classifyWorkflowActions, emptyWorkflowDecision } from './contracts.js';
|
|
2
|
+
import { jobLifecycleV2, taskLifecycleV2 } from './definitions-v2.js';
|
|
3
|
+
import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
4
|
+
import { applyJobGraphTarget, applyTaskGraphTarget, applyTaskMutation } from './runtime.js';
|
|
5
|
+
function v2WorkflowDecision(runtime, from, event, facts = {}) {
|
|
6
|
+
const actions = [];
|
|
7
|
+
const decision = dispatchWorkflowGraphSync(runtime, {
|
|
8
|
+
initialState: from,
|
|
9
|
+
input: { transport: 'event', id: event, payload: {} },
|
|
10
|
+
durableFacts: facts,
|
|
11
|
+
executeAction: actionId => { actions.push(actionId); return {}; },
|
|
12
|
+
});
|
|
13
|
+
if (!decision.accepted)
|
|
14
|
+
return emptyWorkflowDecision(decision.reason === 'ambiguous' ? 'ambiguous' : 'no_transition');
|
|
15
|
+
const classified = classifyWorkflowActions(actions);
|
|
16
|
+
return {
|
|
17
|
+
accepted: true,
|
|
18
|
+
target: decision.target,
|
|
19
|
+
guard: null,
|
|
20
|
+
actions,
|
|
21
|
+
...classified,
|
|
22
|
+
reason: 'accepted',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
3
25
|
export function decideTaskLifecycle(task, event, facts = {}) {
|
|
4
|
-
return
|
|
26
|
+
return v2WorkflowDecision(taskLifecycleV2, task.state, event, facts);
|
|
5
27
|
}
|
|
6
28
|
export function decideJobLifecycle(job, event, facts = {}) {
|
|
7
|
-
return
|
|
29
|
+
return v2WorkflowDecision(jobLifecycleV2, job.state, event, facts);
|
|
30
|
+
}
|
|
31
|
+
export function dispatchTaskLifecycleSync(task, event, facts = {}) {
|
|
32
|
+
const actions = [];
|
|
33
|
+
const result = dispatchWorkflowGraphSync(taskLifecycleV2, {
|
|
34
|
+
initialState: task.state,
|
|
35
|
+
input: { transport: 'event', id: event, payload: {} },
|
|
36
|
+
durableFacts: facts,
|
|
37
|
+
persistTransition: (_source, target) => applyTaskGraphTarget(task, target),
|
|
38
|
+
executeAction: actionId => { actions.push(actionId); if (actionId.startsWith('mutation.'))
|
|
39
|
+
applyTaskMutation(task, actionId); return {}; },
|
|
40
|
+
});
|
|
41
|
+
if (!result.accepted)
|
|
42
|
+
return emptyWorkflowDecision(result.reason === 'ambiguous' ? 'ambiguous' : 'no_transition');
|
|
43
|
+
return { accepted: true, target: result.target, guard: null, actions, ...classifyWorkflowActions(actions), reason: 'accepted' };
|
|
44
|
+
}
|
|
45
|
+
export async function dispatchTaskLifecycle(task, event, facts = {}, executeEffect, persist) {
|
|
46
|
+
const actions = [];
|
|
47
|
+
const result = await dispatchWorkflowGraph(taskLifecycleV2, {
|
|
48
|
+
initialState: task.state, input: { transport: 'event', id: event, payload: {} }, durableFacts: facts,
|
|
49
|
+
persistTransition: async (_source, target) => { applyTaskGraphTarget(task, target); await persist?.(); },
|
|
50
|
+
executeAction: async (actionId) => {
|
|
51
|
+
actions.push(actionId);
|
|
52
|
+
if (actionId.startsWith('mutation.'))
|
|
53
|
+
applyTaskMutation(task, actionId);
|
|
54
|
+
else
|
|
55
|
+
await executeEffect?.(actionId);
|
|
56
|
+
return {};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
if (!result.accepted)
|
|
60
|
+
return emptyWorkflowDecision(result.reason === 'ambiguous' ? 'ambiguous' : 'no_transition');
|
|
61
|
+
return { accepted: true, target: result.target, guard: null, actions, ...classifyWorkflowActions(actions), reason: 'accepted' };
|
|
62
|
+
}
|
|
63
|
+
export function dispatchJobLifecycleSync(job, event, facts = {}) {
|
|
64
|
+
const actions = [];
|
|
65
|
+
const result = dispatchWorkflowGraphSync(jobLifecycleV2, {
|
|
66
|
+
initialState: job.state,
|
|
67
|
+
input: { transport: 'event', id: event, payload: {} },
|
|
68
|
+
durableFacts: facts,
|
|
69
|
+
persistTransition: (_source, target) => applyJobGraphTarget(job, target),
|
|
70
|
+
executeAction: actionId => { actions.push(actionId); return {}; },
|
|
71
|
+
});
|
|
72
|
+
if (!result.accepted)
|
|
73
|
+
return emptyWorkflowDecision(result.reason === 'ambiguous' ? 'ambiguous' : 'no_transition');
|
|
74
|
+
return { accepted: true, target: result.target, guard: null, actions, ...classifyWorkflowActions(actions), reason: 'accepted' };
|
|
75
|
+
}
|
|
76
|
+
export async function dispatchJobLifecycle(job, event, facts = {}, executeEffect, persist) {
|
|
77
|
+
const actions = [];
|
|
78
|
+
const result = await dispatchWorkflowGraph(jobLifecycleV2, {
|
|
79
|
+
initialState: job.state, input: { transport: 'event', id: event, payload: {} }, durableFacts: facts,
|
|
80
|
+
persistTransition: async (_source, target) => { applyJobGraphTarget(job, target); await persist?.(); },
|
|
81
|
+
executeAction: async (actionId) => { actions.push(actionId); await executeEffect?.(actionId); return {}; },
|
|
82
|
+
});
|
|
83
|
+
if (!result.accepted)
|
|
84
|
+
return emptyWorkflowDecision(result.reason === 'ambiguous' ? 'ambiguous' : 'no_transition');
|
|
85
|
+
return { accepted: true, target: result.target, guard: null, actions, ...classifyWorkflowActions(actions), reason: 'accepted' };
|
|
8
86
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { GraphRuntime } from './graph-v2.js';
|
|
3
|
+
import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
4
|
+
import { isEffectivelyBlocked } from '../unblock.js';
|
|
5
|
+
const source = readFileSync(new URL('../../workflow/planner-orchestration-v2.mmd', import.meta.url), 'utf8');
|
|
6
|
+
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
7
|
+
export const plannerOrchestrationV2 = new GraphRuntime(source, {
|
|
8
|
+
states: new Set([
|
|
9
|
+
'idle', 'check_legacy_pending', 'check_recover', 'check_seed', 'check_planner',
|
|
10
|
+
'check_blocked', 'check_work', 'check_materialized', 'check_objective', 'wait', 'recovered', 'created',
|
|
11
|
+
]),
|
|
12
|
+
guards: {
|
|
13
|
+
autonomousJob: boolGuard('autonomousJob'),
|
|
14
|
+
legacyPlanningPending: boolGuard('legacyPlanningPending'),
|
|
15
|
+
recoverablePendingPlan: boolGuard('recoverablePendingPlan'),
|
|
16
|
+
hasSeedWork: boolGuard('hasSeedWork'),
|
|
17
|
+
noActivePlanner: boolGuard('noActivePlanner'),
|
|
18
|
+
noBlockedWork: boolGuard('noBlockedWork'),
|
|
19
|
+
noActiveImplementation: boolGuard('noActiveImplementation'),
|
|
20
|
+
continuationMaterialized: boolGuard('continuationMaterialized'),
|
|
21
|
+
objectiveUnsatisfied: boolGuard('objectiveUnsatisfied'),
|
|
22
|
+
},
|
|
23
|
+
actions: {
|
|
24
|
+
'planner.recoverPending': { id: 'planner.recoverPending' },
|
|
25
|
+
'planner.createRound': { id: 'planner.createRound' },
|
|
26
|
+
},
|
|
27
|
+
events: { 'planner.reconcile': { id: 'planner.reconcile' } },
|
|
28
|
+
});
|
|
29
|
+
export async function dispatchPlannerOrchestration(facts, executeAction) {
|
|
30
|
+
return dispatchWorkflowGraph(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 9, executeAction });
|
|
31
|
+
}
|
|
32
|
+
export function decidePlannerOrchestration(facts) {
|
|
33
|
+
return dispatchWorkflowGraphSync(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 9 });
|
|
34
|
+
}
|
|
35
|
+
export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
|
|
36
|
+
const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
37
|
+
const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
|
|
38
|
+
const scopedPlans = (job.plans ?? []).filter(plan => !Number.isFinite(slot) || Date.parse(plan.createdAt) >= slot)
|
|
39
|
+
.sort((a, b) => a.round - b.round || a.createdAt.localeCompare(b.createdAt));
|
|
40
|
+
const latestPlan = scopedPlans.at(-1);
|
|
41
|
+
const legacyLatest = scopedPlans.length === 0
|
|
42
|
+
? scopedTasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)
|
|
43
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1)
|
|
44
|
+
: undefined;
|
|
45
|
+
const latestId = latestPlan?.id ?? legacyLatest?.id;
|
|
46
|
+
const latestObjective = latestPlan?.objectiveStatus ?? legacyLatest?.plannerResult?.objective_status;
|
|
47
|
+
return {
|
|
48
|
+
autonomousJob: job.executionMode === 'autonomous',
|
|
49
|
+
legacyPlanningPending: job.initialPlanning?.status === 'pending',
|
|
50
|
+
recoverablePendingPlan,
|
|
51
|
+
hasSeedWork: !((job.plans?.length ?? 0) === 0 && job.tasks.filter(task => task.kind !== 'planner').length === 0),
|
|
52
|
+
noActivePlanner: !scopedPlans.some(plan => plan.status === 'PLANNING'),
|
|
53
|
+
noBlockedWork: !scopedTasks.some(task => task.kind !== 'planner' && isEffectivelyBlocked(task)),
|
|
54
|
+
noActiveImplementation: !scopedTasks.some(task => task.kind !== 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)),
|
|
55
|
+
continuationMaterialized: !(latestObjective === 'in_progress' && latestId && !job.tasks.some(task => task.kind !== 'planner' && task.planningDecisionId === latestId)),
|
|
56
|
+
objectiveUnsatisfied: latestObjective !== 'satisfied',
|
|
57
|
+
};
|
|
58
|
+
}
|