@atolis-hq/wake 0.3.78 → 0.3.80

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.
@@ -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,
@@ -1,17 +1,27 @@
1
1
  import { ResourceCorrelationRole } from '../resources/index.js';
2
2
  export function createCapabilityResourceTransitionEvidence(input) {
3
+ const registrationFor = (capabilities) => input.policies.find((registration) => registration.capabilities.some((capability) => capabilities.includes(capability)));
3
4
  return {
4
5
  triggers: [...new Set(input.policies.flatMap(({ policy }) => policy.triggers))],
5
6
  async resolve(evidence) {
7
+ // A work item may hold several primary correlations (e.g. its
8
+ // originating issue and an implementation PR). Only the correlation
9
+ // whose resource matches a registered policy's capabilities is
10
+ // relevant here; the rest drop out rather than blocking resolution.
6
11
  const correlations = await input.resources.correlationsForWork(evidence.workItemId);
7
12
  const primaries = correlations.filter(({ role }) => role === ResourceCorrelationRole.Primary);
8
- if (primaries.length !== 1)
13
+ const registrations = [];
14
+ for (const primary of primaries) {
15
+ const resource = await input.resources.get(primary.resourceId);
16
+ if (resource === null)
17
+ continue;
18
+ const registration = registrationFor(resource.capabilities);
19
+ if (registration !== undefined)
20
+ registrations.push(registration);
21
+ }
22
+ if (registrations.length !== 1)
9
23
  return null;
10
- const resource = await input.resources.get(primaries[0].resourceId);
11
- if (resource === null)
12
- return null;
13
- const registration = input.policies.find(({ capabilities }) => capabilities.some((capability) => resource.capabilities.includes(capability)));
14
- return registration?.policy.resolve(evidence) ?? null;
24
+ return registrations[0].policy.resolve(evidence);
15
25
  },
16
26
  };
17
27
  }
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "g8656e9f";
111
+ export const wakeVersion = "g3986c6c";
@@ -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
- import { ActivationClaimConflictError, RunStatus, WorkspaceMode } from '../../execution/index.js';
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 { findUnresolvedSucceededTerminal, findUnresolvedTerminal, isExecutionFailureTerminal, } from './execution-reconciliation.js';
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
- activationId: recovery.item.activation.activationId,
104
- runId: recovery.run.runId,
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
- const allRuns = await execution.list();
119
- if (allRuns.filter((run) => run.status === RunStatus.Started).length >= maxConcurrentRuns)
120
- return { kind: 'no-work' };
121
- const selectedCandidate = dispatchPolicy.select(await Promise.all(pending.map(async (item, requestedPosition) => ({
122
- workItemId: item.workflow.workItemId,
123
- activationId: item.activation.activationId,
124
- requestedPosition,
125
- hasActiveRun: (await execution.list(item.activation.activationId)).some((run) => run.status === RunStatus.Started) ||
126
- allRuns.some((run) => run.status === RunStatus.Started &&
127
- run.workflowInstanceId === item.workflow.workflowInstanceId &&
128
- run.workspace?.mode === WorkspaceMode.Branch),
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 an
6
- * activationId/runId that intake has no honest value for. ResidentHost
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
  */
@@ -15,7 +15,7 @@ export class TickHost {
15
15
  const result = await this.advance({ maxProgress: 1 });
16
16
  if (result.kind === 'progressed') {
17
17
  advances += 1;
18
- runs += 1;
18
+ runs += result.dispatched.length;
19
19
  continue;
20
20
  }
21
21
  return {
@@ -1,22 +1,36 @@
1
1
  import { ActivityOutcomeKind, BuiltInActivityName } from '../../activities/index.js';
2
2
  import { OrchestrationEventType, WatchGateVerdictSignal } from '../contracts/events.js';
3
- import { stageName, watchId } from '../contracts/identifiers.js';
3
+ import { signalName, stageName, watchId } from '../contracts/identifiers.js';
4
4
  import { ActivityActivationStatus, ApprovalAuthorityKind, WorkflowStatus, } from '../contracts/vocabulary.js';
5
5
  import { activation, nextOrdinal, stateDraft } from './decision-events.js';
6
+ // `then: await-human` on a `failed` route compiles its wait to this literal
7
+ // signal name (see compileTarget); a workflow parked there has exhausted
8
+ // whatever automatic retries it had and is durably equivalent to a blocked
9
+ // failed stage for operator-retry purposes.
10
+ const FailedOutcomeSignal = signalName(ActivityOutcomeKind.Failed);
6
11
  export function isOperatorRetryEligible(view) {
7
12
  const pending = view.pendingActivation;
8
- const eligibleActivation = view.status === WorkflowStatus.Blocked &&
9
- pending !== undefined &&
13
+ const eligibleActivation = pending !== undefined &&
10
14
  pending.status === ActivityActivationStatus.Completed &&
11
15
  pending.supplemental !== true &&
12
16
  pending.followOnIndex === undefined &&
13
17
  view.acceptedOutcomes.includes(pending.activationId);
14
18
  if (!eligibleActivation || pending === undefined)
15
19
  return false;
20
+ if (view.status === WorkflowStatus.Blocked)
21
+ return isRetryEligibleBlock(view, pending);
22
+ return isRetryEligibleFailedWait(view);
23
+ }
24
+ function isRetryEligibleBlock(view, pending) {
16
25
  return ((view.blockReason === 'unconfigured outcome failed' &&
17
26
  view.lastOutcome?.kind === ActivityOutcomeKind.Failed) ||
18
27
  view.executionFailure?.activationId === pending.activationId);
19
28
  }
29
+ function isRetryEligibleFailedWait(view) {
30
+ return (view.status === WorkflowStatus.Waiting &&
31
+ view.waitingFor?.signalKind === FailedOutcomeSignal &&
32
+ view.lastOutcome?.kind === ActivityOutcomeKind.Failed);
33
+ }
20
34
  export function selectOperatorRetryTarget(workflows) {
21
35
  const primary = workflows.find((workflow) => workflow.parentWorkflowInstanceId === undefined);
22
36
  if (primary === undefined)
@@ -52,7 +66,7 @@ export function requestOperatorRetry(definition, state, input) {
52
66
  if (!isOperatorRetryEligible(state))
53
67
  return {
54
68
  kind: 'ignored',
55
- reason: 'workflow is not blocked for a retryable failed stage',
69
+ reason: 'workflow is not in a retryable failed-stage state',
56
70
  };
57
71
  const stage = definition.stages[stageName(state.currentStage)];
58
72
  const events = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.78",
3
+ "version": "0.3.80",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {