@atolis-hq/wake 0.3.78 → 0.3.79
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/dist/src/bootstrap/composition-root.js +1 -0
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/control-plane/application/advance-once-dispatch.js +109 -0
- package/dist/src/control-plane/application/advance-once.js +19 -74
- package/dist/src/control-plane/infrastructure/intake-host.js +2 -2
- package/dist/src/control-plane/infrastructure/tick-host.js +1 -1
- package/package.json +1 -1
|
@@ -113,6 +113,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
|
|
|
113
113
|
ids,
|
|
114
114
|
dispatchPolicy: new DispatchPolicy({ maxDispatches: config.controlPlane.maxDispatches }),
|
|
115
115
|
maxConcurrentRuns: config.controlPlane.maxConcurrentRuns,
|
|
116
|
+
maxDispatches: config.controlPlane.maxDispatches,
|
|
116
117
|
isDispatchPaused: isRuntimePaused,
|
|
117
118
|
workspaceRecovery: workspaces,
|
|
118
119
|
work,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ActivationClaimConflictError, RunStatus, WorkspaceMode } from '../../execution/index.js';
|
|
2
|
+
import { WorkflowStatus } from '../../orchestration/index.js';
|
|
3
|
+
import { isExecutionFailureTerminal } from './execution-reconciliation.js';
|
|
4
|
+
/**
|
|
5
|
+
* Fills open capacity within one Advancement call: dispatches ready
|
|
6
|
+
* activations one at a time, rechecking `maxConcurrentRuns` and reselecting
|
|
7
|
+
* fresh candidates after every dispatch (per #346's capacity-recheck
|
|
8
|
+
* principle), until capacity, the per-call `maxDispatches` burst cap, or
|
|
9
|
+
* eligible candidates are exhausted. Candidates dispatched earlier in the
|
|
10
|
+
* same call are excluded from later selection via `dispatchedIds`, since
|
|
11
|
+
* their `RunStarted` event is not guaranteed visible through
|
|
12
|
+
* `execution.list()` yet.
|
|
13
|
+
*/
|
|
14
|
+
export async function runDispatchLoop(pending, ctx) {
|
|
15
|
+
const dispatched = [];
|
|
16
|
+
const dispatchedIds = new Set();
|
|
17
|
+
let stopReason;
|
|
18
|
+
while (dispatched.length < ctx.maxDispatches) {
|
|
19
|
+
const allRuns = await ctx.execution.list();
|
|
20
|
+
if (allRuns.filter((run) => run.status === RunStatus.Started).length >= ctx.maxConcurrentRuns) {
|
|
21
|
+
stopReason = { kind: 'no-work' };
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
const selectedCandidate = ctx.dispatchPolicy.select(await Promise.all(pending.map(async (item, requestedPosition) => ({
|
|
25
|
+
workItemId: item.workflow.workItemId,
|
|
26
|
+
activationId: item.activation.activationId,
|
|
27
|
+
requestedPosition,
|
|
28
|
+
hasActiveRun: dispatchedIds.has(item.activation.activationId) ||
|
|
29
|
+
(await ctx.execution.list(item.activation.activationId)).some((run) => run.status === RunStatus.Started) ||
|
|
30
|
+
allRuns.some((run) => run.status === RunStatus.Started &&
|
|
31
|
+
run.workflowInstanceId === item.workflow.workflowInstanceId &&
|
|
32
|
+
run.workspace?.mode === WorkspaceMode.Branch),
|
|
33
|
+
cancelled: false,
|
|
34
|
+
}))))[0];
|
|
35
|
+
const selected = selectedCandidate === undefined
|
|
36
|
+
? undefined
|
|
37
|
+
: pending.find((item) => item.activation.activationId === selectedCandidate.activationId);
|
|
38
|
+
if (selected === undefined) {
|
|
39
|
+
const waiting = (await ctx.orchestration.listWaiting()).find((view) => view !== null);
|
|
40
|
+
stopReason =
|
|
41
|
+
waiting === undefined
|
|
42
|
+
? { kind: 'no-work' }
|
|
43
|
+
: { kind: WorkflowStatus.Waiting, workflowInstanceId: waiting.workflowInstanceId };
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
// Recheck at the dispatch boundary so maintenance cannot race a selected activation.
|
|
47
|
+
if (await ctx.isDispatchPaused()) {
|
|
48
|
+
stopReason = { kind: 'paused' };
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
if ((await ctx.orchestration.validateActivationDispatch?.(selected.workflow.workflowInstanceId, ctx.commandContext(selected.activation.activationId))) === false) {
|
|
52
|
+
stopReason = { kind: 'no-work' };
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
await ctx.orchestration.markActivationStarted(selected.workflow.workflowInstanceId, selected.activation.activationId, ctx.commandContext(selected.activation.activationId));
|
|
56
|
+
const correlated = await ctx.resources.correlationsForWork(selected.workflow.workItemId);
|
|
57
|
+
const resourceViews = (await Promise.all(correlated.map((entry) => ctx.resources.get(entry.resourceId)))).filter((resource) => resource !== null);
|
|
58
|
+
const ineligible = await ctx.runnerIneligibility();
|
|
59
|
+
let run;
|
|
60
|
+
try {
|
|
61
|
+
run = await ctx.execution.attempt(selected.activation, {
|
|
62
|
+
workItemId: selected.workflow.workItemId,
|
|
63
|
+
workflowInstanceId: selected.workflow.workflowInstanceId,
|
|
64
|
+
orchestrationGroupId: selected.workflow.orchestrationGroupId,
|
|
65
|
+
resources: resourceViews,
|
|
66
|
+
sessionPolicy: selected.workflow.parentWorkflowInstanceId === undefined ? 'resume-stage' : 'fresh',
|
|
67
|
+
awaitImmediateCompletion: true,
|
|
68
|
+
...(ineligible.size === 0 ? {} : { ineligibleRunners: ineligible }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof ActivationClaimConflictError) {
|
|
73
|
+
stopReason = { kind: 'no-work' };
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (run.status === RunStatus.Succeeded && run.outcome !== undefined) {
|
|
79
|
+
if (await ctx.isDispatchPaused()) {
|
|
80
|
+
stopReason = { kind: 'paused' };
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
await ctx.orchestration.acceptOutcome({
|
|
84
|
+
workflowInstanceId: selected.workflow.workflowInstanceId,
|
|
85
|
+
activationId: selected.activation.activationId,
|
|
86
|
+
outcome: run.outcome,
|
|
87
|
+
}, ctx.commandContext(run.runId));
|
|
88
|
+
}
|
|
89
|
+
if (isExecutionFailureTerminal(run.status))
|
|
90
|
+
await ctx.orchestration.resolveExecutionFailure?.(selected.workflow.workflowInstanceId, {
|
|
91
|
+
activationId: selected.activation.activationId,
|
|
92
|
+
runId: run.runId,
|
|
93
|
+
reason: run.failure?.message ?? 'execution failed',
|
|
94
|
+
}, ctx.commandContext(run.runId));
|
|
95
|
+
if (run.status !== RunStatus.Succeeded && run.status !== RunStatus.Started) {
|
|
96
|
+
stopReason = {
|
|
97
|
+
kind: WorkflowStatus.Blocked,
|
|
98
|
+
workflowInstanceId: selected.workflow.workflowInstanceId,
|
|
99
|
+
reason: run.failure?.message ?? 'execution failed',
|
|
100
|
+
};
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
dispatched.push({ activationId: selected.activation.activationId, runId: run.runId });
|
|
104
|
+
dispatchedIds.add(selected.activation.activationId);
|
|
105
|
+
}
|
|
106
|
+
return dispatched.length > 0
|
|
107
|
+
? { kind: 'progressed', dispatched }
|
|
108
|
+
: (stopReason ?? { kind: 'no-work' });
|
|
109
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable complexity, max-lines-per-function */
|
|
2
|
+
import { RunStatus } from '../../execution/index.js';
|
|
2
3
|
import { correlationId, EventActorKind } from '../../kernel/index.js';
|
|
3
4
|
import { isAmbiguityResolutionBlock, WorkflowStatus } from '../../orchestration/index.js';
|
|
4
5
|
import { WorkStatus } from '../../work/index.js';
|
|
5
6
|
import { ControlStreamKind } from '../contracts/streams.js';
|
|
6
7
|
import { DispatchPolicy } from '../domain/dispatch-policy.js';
|
|
7
|
-
import {
|
|
8
|
+
import { runDispatchLoop } from './advance-once-dispatch.js';
|
|
9
|
+
import { findUnresolvedSucceededTerminal, findUnresolvedTerminal, } from './execution-reconciliation.js';
|
|
8
10
|
export function createAdvanceOnce(orchestration, execution, resources, clock, dependencies) {
|
|
9
11
|
const runnerIneligibility = dependencies.runnerIneligibility ?? (async () => new Set());
|
|
10
12
|
const isDispatchPaused = dependencies.isDispatchPaused ?? (async () => false);
|
|
@@ -12,6 +14,7 @@ export function createAdvanceOnce(orchestration, execution, resources, clock, de
|
|
|
12
14
|
const transcriptRetention = dependencies.transcriptRetention;
|
|
13
15
|
const dispatchPolicy = dependencies.dispatchPolicy ?? new DispatchPolicy({ maxDispatches: 1 });
|
|
14
16
|
const maxConcurrentRuns = dependencies.maxConcurrentRuns ?? 1;
|
|
17
|
+
const maxDispatches = dependencies.maxDispatches ?? 1;
|
|
15
18
|
const context = (cause) => ({
|
|
16
19
|
commandId: dependencies.ids.next('command'),
|
|
17
20
|
correlationId: correlationId(cause),
|
|
@@ -100,8 +103,9 @@ export function createAdvanceOnce(orchestration, execution, resources, clock, de
|
|
|
100
103
|
}, context(recovery.run.runId));
|
|
101
104
|
return {
|
|
102
105
|
kind: 'progressed',
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
dispatched: [
|
|
107
|
+
{ activationId: recovery.item.activation.activationId, runId: recovery.run.runId },
|
|
108
|
+
],
|
|
105
109
|
};
|
|
106
110
|
}
|
|
107
111
|
await orchestration.resolveExecutionFailure?.(recovery.item.workflow.workflowInstanceId, {
|
|
@@ -115,76 +119,17 @@ export function createAdvanceOnce(orchestration, execution, resources, clock, de
|
|
|
115
119
|
reason: recovery.run.failure?.message ?? 'execution failed',
|
|
116
120
|
};
|
|
117
121
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
cancelled: false,
|
|
130
|
-
}))))[0];
|
|
131
|
-
const selected = selectedCandidate === undefined
|
|
132
|
-
? undefined
|
|
133
|
-
: pending.find((item) => item.activation.activationId === selectedCandidate.activationId);
|
|
134
|
-
if (selected === undefined) {
|
|
135
|
-
const waiting = (await orchestration.listWaiting()).find((view) => view !== null);
|
|
136
|
-
return waiting === undefined
|
|
137
|
-
? { kind: 'no-work' }
|
|
138
|
-
: { kind: WorkflowStatus.Waiting, workflowInstanceId: waiting.workflowInstanceId };
|
|
139
|
-
}
|
|
140
|
-
// Recheck at the dispatch boundary so maintenance cannot race a selected activation.
|
|
141
|
-
if (await isDispatchPaused())
|
|
142
|
-
return { kind: 'paused' };
|
|
143
|
-
if ((await orchestration.validateActivationDispatch?.(selected.workflow.workflowInstanceId, context(selected.activation.activationId))) === false)
|
|
144
|
-
return { kind: 'no-work' };
|
|
145
|
-
await orchestration.markActivationStarted(selected.workflow.workflowInstanceId, selected.activation.activationId, context(selected.activation.activationId));
|
|
146
|
-
const correlated = await resources.correlationsForWork(selected.workflow.workItemId);
|
|
147
|
-
const resourceViews = (await Promise.all(correlated.map((entry) => resources.get(entry.resourceId)))).filter((resource) => resource !== null);
|
|
148
|
-
const ineligible = await runnerIneligibility();
|
|
149
|
-
let run;
|
|
150
|
-
try {
|
|
151
|
-
run = await execution.attempt(selected.activation, {
|
|
152
|
-
workItemId: selected.workflow.workItemId,
|
|
153
|
-
workflowInstanceId: selected.workflow.workflowInstanceId,
|
|
154
|
-
orchestrationGroupId: selected.workflow.orchestrationGroupId,
|
|
155
|
-
resources: resourceViews,
|
|
156
|
-
sessionPolicy: selected.workflow.parentWorkflowInstanceId === undefined ? 'resume-stage' : 'fresh',
|
|
157
|
-
awaitImmediateCompletion: true,
|
|
158
|
-
...(ineligible.size === 0 ? {} : { ineligibleRunners: ineligible }),
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
if (error instanceof ActivationClaimConflictError)
|
|
163
|
-
return { kind: 'no-work' };
|
|
164
|
-
throw error;
|
|
165
|
-
}
|
|
166
|
-
if (run.status === RunStatus.Succeeded && run.outcome !== undefined) {
|
|
167
|
-
if (await isDispatchPaused())
|
|
168
|
-
return { kind: 'paused' };
|
|
169
|
-
await orchestration.acceptOutcome({
|
|
170
|
-
workflowInstanceId: selected.workflow.workflowInstanceId,
|
|
171
|
-
activationId: selected.activation.activationId,
|
|
172
|
-
outcome: run.outcome,
|
|
173
|
-
}, context(run.runId));
|
|
174
|
-
}
|
|
175
|
-
if (isExecutionFailureTerminal(run.status))
|
|
176
|
-
await orchestration.resolveExecutionFailure?.(selected.workflow.workflowInstanceId, {
|
|
177
|
-
activationId: selected.activation.activationId,
|
|
178
|
-
runId: run.runId,
|
|
179
|
-
reason: run.failure?.message ?? 'execution failed',
|
|
180
|
-
}, context(run.runId));
|
|
181
|
-
return run.status === RunStatus.Succeeded || run.status === RunStatus.Started
|
|
182
|
-
? { kind: 'progressed', activationId: selected.activation.activationId, runId: run.runId }
|
|
183
|
-
: {
|
|
184
|
-
kind: WorkflowStatus.Blocked,
|
|
185
|
-
workflowInstanceId: selected.workflow.workflowInstanceId,
|
|
186
|
-
reason: run.failure?.message ?? 'execution failed',
|
|
187
|
-
};
|
|
122
|
+
return runDispatchLoop(pending, {
|
|
123
|
+
orchestration,
|
|
124
|
+
execution,
|
|
125
|
+
resources,
|
|
126
|
+
dispatchPolicy,
|
|
127
|
+
maxConcurrentRuns,
|
|
128
|
+
maxDispatches,
|
|
129
|
+
runnerIneligibility,
|
|
130
|
+
isDispatchPaused,
|
|
131
|
+
commandContext: context,
|
|
132
|
+
});
|
|
188
133
|
};
|
|
189
134
|
// Tick, resident, and API callers share this advancement instance. Serialize the
|
|
190
135
|
// capacity check through Run creation so concurrent callers cannot over-dispatch.
|
|
@@ -2,8 +2,8 @@ import { HostStopReason } from '../contracts/commands.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Bounded host for IntakePipeline. Unlike TickHost, one cycle is always
|
|
4
4
|
* exactly one poll-and-translate pass — there's no Advancement to loop
|
|
5
|
-
* within a budget, and AdvanceResult's `progressed` variant requires
|
|
6
|
-
*
|
|
5
|
+
* within a budget, and AdvanceResult's `progressed` variant requires a
|
|
6
|
+
* dispatched batch that intake has no honest value for. ResidentHost
|
|
7
7
|
* only needs `advances > 0` to decide whether its next sleep resets to the
|
|
8
8
|
* fast end of backoff, which `processed` maps onto directly.
|
|
9
9
|
*/
|