@wopr-network/defcon 1.3.0 → 1.5.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.
@@ -12,6 +12,7 @@ export type ClaimResponse = {
12
12
  invocation_id: string;
13
13
  flow: string | null;
14
14
  stage: string;
15
+ agent_role: string | null;
15
16
  prompt: string;
16
17
  context: Record<string, unknown> | null;
17
18
  worker_notice?: string;
@@ -63,6 +63,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
63
63
  timeoutMs: g.timeoutMs,
64
64
  failurePrompt: g.failurePrompt,
65
65
  timeoutPrompt: g.timeoutPrompt,
66
+ outcomes: g.outcomes,
66
67
  });
67
68
  gateNameToId.set(g.name, gate.id);
68
69
  }
@@ -75,6 +76,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
75
76
  initialState: f.initialState,
76
77
  maxConcurrent: f.maxConcurrent,
77
78
  maxConcurrentPerRepo: f.maxConcurrentPerRepo,
79
+ claimRetryAfterMs: f.claimRetryAfterMs,
78
80
  gateTimeoutMs: f.gateTimeoutMs,
79
81
  createdBy: f.createdBy,
80
82
  discipline: f.discipline,
@@ -85,11 +87,13 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
85
87
  for (const s of flowStates) {
86
88
  await flowRepo.addState(flow.id, {
87
89
  name: s.name,
90
+ agentRole: s.agentRole,
88
91
  modelTier: s.modelTier,
89
92
  mode: s.mode,
90
93
  promptTemplate: s.promptTemplate,
91
94
  constraints: s.constraints,
92
95
  onEnter: s.onEnter,
96
+ retryAfterMs: s.retryAfterMs,
93
97
  });
94
98
  }
95
99
  const flowTransitions = parsed.transitions.filter((t) => t.flowName === f.name);
@@ -7,6 +7,7 @@ export declare const FlowDefinitionSchema: z.ZodObject<{
7
7
  maxConcurrent: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
8
8
  maxConcurrentPerRepo: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
9
9
  affinityWindowMs: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
10
+ claimRetryAfterMs: z.ZodOptional<z.ZodNumber>;
10
11
  gateTimeoutMs: z.ZodOptional<z.ZodNumber>;
11
12
  version: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
12
13
  createdBy: z.ZodOptional<z.ZodString>;
@@ -22,6 +23,7 @@ export declare const OnEnterSchema: z.ZodObject<{
22
23
  export declare const StateDefinitionSchema: z.ZodObject<{
23
24
  name: z.ZodString;
24
25
  flowName: z.ZodString;
26
+ agentRole: z.ZodOptional<z.ZodString>;
25
27
  modelTier: z.ZodOptional<z.ZodString>;
26
28
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
27
29
  passive: "passive";
@@ -34,12 +36,17 @@ export declare const StateDefinitionSchema: z.ZodObject<{
34
36
  artifacts: z.ZodArray<z.ZodString>;
35
37
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
36
38
  }, z.core.$strip>>;
39
+ retryAfterMs: z.ZodOptional<z.ZodNumber>;
37
40
  }, z.core.$strip>;
38
41
  export declare const CommandGateSchema: z.ZodObject<{
39
42
  name: z.ZodString;
40
43
  timeoutMs: z.ZodOptional<z.ZodNumber>;
41
44
  failurePrompt: z.ZodOptional<z.ZodString>;
42
45
  timeoutPrompt: z.ZodOptional<z.ZodString>;
46
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
47
+ proceed: z.ZodOptional<z.ZodBoolean>;
48
+ toState: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$strip>>>;
43
50
  type: z.ZodLiteral<"command">;
44
51
  command: z.ZodString;
45
52
  }, z.core.$strip>;
@@ -48,6 +55,10 @@ export declare const FunctionGateSchema: z.ZodObject<{
48
55
  timeoutMs: z.ZodOptional<z.ZodNumber>;
49
56
  failurePrompt: z.ZodOptional<z.ZodString>;
50
57
  timeoutPrompt: z.ZodOptional<z.ZodString>;
58
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
59
+ proceed: z.ZodOptional<z.ZodBoolean>;
60
+ toState: z.ZodOptional<z.ZodString>;
61
+ }, z.core.$strip>>>;
51
62
  type: z.ZodLiteral<"function">;
52
63
  functionRef: z.ZodString;
53
64
  }, z.core.$strip>;
@@ -56,6 +67,10 @@ export declare const ApiGateSchema: z.ZodObject<{
56
67
  timeoutMs: z.ZodOptional<z.ZodNumber>;
57
68
  failurePrompt: z.ZodOptional<z.ZodString>;
58
69
  timeoutPrompt: z.ZodOptional<z.ZodString>;
70
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
71
+ proceed: z.ZodOptional<z.ZodBoolean>;
72
+ toState: z.ZodOptional<z.ZodString>;
73
+ }, z.core.$strip>>>;
59
74
  type: z.ZodLiteral<"api">;
60
75
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
61
76
  }, z.core.$strip>;
@@ -64,6 +79,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
64
79
  timeoutMs: z.ZodOptional<z.ZodNumber>;
65
80
  failurePrompt: z.ZodOptional<z.ZodString>;
66
81
  timeoutPrompt: z.ZodOptional<z.ZodString>;
82
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
83
+ proceed: z.ZodOptional<z.ZodBoolean>;
84
+ toState: z.ZodOptional<z.ZodString>;
85
+ }, z.core.$strip>>>;
67
86
  type: z.ZodLiteral<"command">;
68
87
  command: z.ZodString;
69
88
  }, z.core.$strip>, z.ZodObject<{
@@ -71,6 +90,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
71
90
  timeoutMs: z.ZodOptional<z.ZodNumber>;
72
91
  failurePrompt: z.ZodOptional<z.ZodString>;
73
92
  timeoutPrompt: z.ZodOptional<z.ZodString>;
93
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
94
+ proceed: z.ZodOptional<z.ZodBoolean>;
95
+ toState: z.ZodOptional<z.ZodString>;
96
+ }, z.core.$strip>>>;
74
97
  type: z.ZodLiteral<"function">;
75
98
  functionRef: z.ZodString;
76
99
  }, z.core.$strip>, z.ZodObject<{
@@ -78,6 +101,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
78
101
  timeoutMs: z.ZodOptional<z.ZodNumber>;
79
102
  failurePrompt: z.ZodOptional<z.ZodString>;
80
103
  timeoutPrompt: z.ZodOptional<z.ZodString>;
104
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
105
+ proceed: z.ZodOptional<z.ZodBoolean>;
106
+ toState: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strip>>>;
81
108
  type: z.ZodLiteral<"api">;
82
109
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
83
110
  }, z.core.$strip>], "type">;
@@ -101,6 +128,7 @@ export declare const SeedFileSchema: z.ZodObject<{
101
128
  maxConcurrent: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
102
129
  maxConcurrentPerRepo: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
103
130
  affinityWindowMs: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
131
+ claimRetryAfterMs: z.ZodOptional<z.ZodNumber>;
104
132
  gateTimeoutMs: z.ZodOptional<z.ZodNumber>;
105
133
  version: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
106
134
  createdBy: z.ZodOptional<z.ZodString>;
@@ -111,6 +139,7 @@ export declare const SeedFileSchema: z.ZodObject<{
111
139
  states: z.ZodArray<z.ZodObject<{
112
140
  name: z.ZodString;
113
141
  flowName: z.ZodString;
142
+ agentRole: z.ZodOptional<z.ZodString>;
114
143
  modelTier: z.ZodOptional<z.ZodString>;
115
144
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
116
145
  passive: "passive";
@@ -123,12 +152,17 @@ export declare const SeedFileSchema: z.ZodObject<{
123
152
  artifacts: z.ZodArray<z.ZodString>;
124
153
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
125
154
  }, z.core.$strip>>;
155
+ retryAfterMs: z.ZodOptional<z.ZodNumber>;
126
156
  }, z.core.$strip>>;
127
157
  gates: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
128
158
  name: z.ZodString;
129
159
  timeoutMs: z.ZodOptional<z.ZodNumber>;
130
160
  failurePrompt: z.ZodOptional<z.ZodString>;
131
161
  timeoutPrompt: z.ZodOptional<z.ZodString>;
162
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
163
+ proceed: z.ZodOptional<z.ZodBoolean>;
164
+ toState: z.ZodOptional<z.ZodString>;
165
+ }, z.core.$strip>>>;
132
166
  type: z.ZodLiteral<"command">;
133
167
  command: z.ZodString;
134
168
  }, z.core.$strip>, z.ZodObject<{
@@ -136,6 +170,10 @@ export declare const SeedFileSchema: z.ZodObject<{
136
170
  timeoutMs: z.ZodOptional<z.ZodNumber>;
137
171
  failurePrompt: z.ZodOptional<z.ZodString>;
138
172
  timeoutPrompt: z.ZodOptional<z.ZodString>;
173
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
174
+ proceed: z.ZodOptional<z.ZodBoolean>;
175
+ toState: z.ZodOptional<z.ZodString>;
176
+ }, z.core.$strip>>>;
139
177
  type: z.ZodLiteral<"function">;
140
178
  functionRef: z.ZodString;
141
179
  }, z.core.$strip>, z.ZodObject<{
@@ -143,6 +181,10 @@ export declare const SeedFileSchema: z.ZodObject<{
143
181
  timeoutMs: z.ZodOptional<z.ZodNumber>;
144
182
  failurePrompt: z.ZodOptional<z.ZodString>;
145
183
  timeoutPrompt: z.ZodOptional<z.ZodString>;
184
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
185
+ proceed: z.ZodOptional<z.ZodBoolean>;
186
+ toState: z.ZodOptional<z.ZodString>;
187
+ }, z.core.$strip>>>;
146
188
  type: z.ZodLiteral<"api">;
147
189
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
148
190
  }, z.core.$strip>], "type">>>>;
@@ -10,6 +10,7 @@ export const FlowDefinitionSchema = z.object({
10
10
  maxConcurrent: z.number().int().min(0).optional().default(0),
11
11
  maxConcurrentPerRepo: z.number().int().min(0).optional().default(0),
12
12
  affinityWindowMs: z.number().int().min(0).optional().default(300000),
13
+ claimRetryAfterMs: z.number().int().min(0).optional(),
13
14
  gateTimeoutMs: z.number().int().min(1).optional(),
14
15
  version: z.number().int().min(1).optional().default(1),
15
16
  createdBy: z.string().optional(),
@@ -36,6 +37,7 @@ export const OnEnterSchema = z.object({
36
37
  export const StateDefinitionSchema = z.object({
37
38
  name: z.string().min(1),
38
39
  flowName: z.string().min(1),
40
+ agentRole: z.string().optional(),
39
41
  modelTier: z.string().optional(),
40
42
  mode: z.enum(["passive", "active"]).optional().default("passive"),
41
43
  promptTemplate: z
@@ -47,13 +49,19 @@ export const StateDefinitionSchema = z.object({
47
49
  .optional(),
48
50
  constraints: z.record(z.string(), z.unknown()).optional(),
49
51
  onEnter: OnEnterSchema.optional(),
52
+ retryAfterMs: z.number().int().min(0).optional(),
50
53
  });
51
54
  // Gate: discriminated union on `type`
55
+ const GateOutcomeSchema = z.object({
56
+ proceed: z.boolean().optional(),
57
+ toState: z.string().min(1).optional(),
58
+ });
52
59
  const BaseGateSchema = z.object({
53
60
  name: z.string().min(1),
54
61
  timeoutMs: z.number().int().min(1).optional(),
55
62
  failurePrompt: z.string().optional(),
56
63
  timeoutPrompt: z.string().min(1).optional(),
64
+ outcomes: z.record(z.string(), GateOutcomeSchema).optional(),
57
65
  });
58
66
  export const CommandGateSchema = BaseGateSchema.extend({
59
67
  type: z.literal("command"),
@@ -54,7 +54,15 @@ export declare class Engine {
54
54
  undrainWorker(workerId: string): void;
55
55
  isDraining(workerId: string): boolean;
56
56
  listDrainingWorkers(): string[];
57
+ emit(event: import("./event-types.js").EngineEvent): Promise<void>;
57
58
  processSignal(entityId: string, signal: string, artifacts?: Artifacts, triggeringInvocationId?: string): Promise<ProcessSignalResult>;
59
+ /**
60
+ * Evaluate a gate and return a routing decision:
61
+ * - `proceed` — gate passed, continue to transition.toState
62
+ * - `redirect` — gate outcome maps to a different toState
63
+ * - `block` — gate failed or timed out; caller should return this as the signal result
64
+ */
65
+ private resolveGate;
58
66
  createEntity(flowName: string, refs?: Record<string, {
59
67
  adapter: string;
60
68
  id: string;
@@ -39,6 +39,9 @@ export class Engine {
39
39
  listDrainingWorkers() {
40
40
  return Array.from(this.drainingWorkers);
41
41
  }
42
+ async emit(event) {
43
+ await this.eventEmitter.emit(event);
44
+ }
42
45
  async processSignal(entityId, signal, artifacts, triggeringInvocationId) {
43
46
  // 1. Load entity
44
47
  const entity = await this.entityRepo.get(entityId);
@@ -52,83 +55,20 @@ export class Engine {
52
55
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
53
56
  if (!transition)
54
57
  throw new ValidationError(`No transition from "${entity.state}" on signal "${signal}" in flow "${flow.name}"`);
55
- // 4. Evaluate gate if present
56
- const gatesPassed = [];
57
- if (transition.gateId) {
58
- const gate = await this.gateRepo.get(transition.gateId);
59
- if (!gate)
60
- throw new NotFoundError(`Gate "${transition.gateId}" not found`);
61
- const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
62
- if (!gateResult.passed) {
63
- // Persist gate failure into entity artifacts for retry context
64
- const priorFailures = Array.isArray(entity.artifacts?.gate_failures)
65
- ? entity.artifacts.gate_failures
66
- : [];
67
- await this.entityRepo.updateArtifacts(entityId, {
68
- gate_failures: [
69
- ...priorFailures,
70
- {
71
- gateId: gate.id,
72
- gateName: gate.name,
73
- output: gateResult.output,
74
- failedAt: new Date().toISOString(),
75
- },
76
- ],
77
- });
78
- if (gateResult.timedOut) {
79
- await this.eventEmitter.emit({
80
- type: "gate.timedOut",
81
- entityId,
82
- gateId: gate.id,
83
- emittedAt: new Date(),
84
- });
85
- }
86
- else {
87
- await this.eventEmitter.emit({
88
- type: "gate.failed",
89
- entityId,
90
- gateId: gate.id,
91
- emittedAt: new Date(),
92
- });
93
- }
94
- let resolvedTimeoutPrompt;
95
- if (gateResult.timedOut) {
96
- const rawTemplate = gate.timeoutPrompt ?? flow.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
97
- try {
98
- const hbs = getHandlebars();
99
- const template = hbs.compile(rawTemplate);
100
- resolvedTimeoutPrompt = template({
101
- entity,
102
- flow,
103
- gate: { name: gate.name, output: gateResult.output },
104
- });
105
- }
106
- catch (err) {
107
- this.logger.error("[engine] Failed to render timeoutPrompt template:", err);
108
- resolvedTimeoutPrompt = DEFAULT_TIMEOUT_PROMPT;
109
- }
110
- }
111
- return {
112
- gated: true,
113
- gateTimedOut: gateResult.timedOut,
114
- gateOutput: gateResult.output,
115
- gateName: gate.name,
116
- failurePrompt: gate.failurePrompt ?? undefined,
117
- timeoutPrompt: resolvedTimeoutPrompt,
118
- gatesPassed,
119
- terminal: false,
120
- };
121
- }
122
- gatesPassed.push(gate.name);
123
- await this.eventEmitter.emit({
124
- type: "gate.passed",
125
- entityId,
126
- gateId: gate.id,
127
- emittedAt: new Date(),
128
- });
58
+ // 4. Evaluate gate returns a routing decision or a block result to return immediately.
59
+ const routing = transition.gateId
60
+ ? await this.resolveGate(transition.gateId, entity, flow)
61
+ : { kind: "proceed", gatesPassed: [] };
62
+ if (routing.kind === "block") {
63
+ return { gated: true, ...routing, terminal: false };
129
64
  }
65
+ // Gate passed or redirected — determine the actual destination.
66
+ const toState = routing.kind === "redirect" ? routing.toState : transition.toState;
67
+ const trigger = routing.kind === "redirect" ? routing.trigger : signal;
68
+ const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
69
+ const { gatesPassed } = routing;
130
70
  // 5. Transition entity
131
- let updated = await this.entityRepo.transition(entityId, transition.toState, signal, artifacts);
71
+ let updated = await this.entityRepo.transition(entityId, toState, trigger, artifacts);
132
72
  // Clear gate_failures on successful transition so stale failures don't bleed into future agent prompts
133
73
  await this.entityRepo.updateArtifacts(entityId, { gate_failures: [] });
134
74
  // Keep the in-memory entity in sync so buildInvocation sees the cleared failures
@@ -139,25 +79,25 @@ export class Engine {
139
79
  entityId,
140
80
  flowId: flow.id,
141
81
  fromState: entity.state,
142
- toState: transition.toState,
143
- trigger: signal,
82
+ toState,
83
+ trigger,
144
84
  emittedAt: new Date(),
145
85
  });
146
86
  const result = {
147
- newState: transition.toState,
87
+ newState: toState,
148
88
  gatesPassed,
149
89
  gated: false,
150
90
  terminal: false,
151
91
  };
152
92
  // 6b. Execute onEnter hook if defined on the new state
153
- const newStateDef = flow.states.find((s) => s.name === transition.toState);
93
+ const newStateDef = flow.states.find((s) => s.name === toState);
154
94
  if (newStateDef?.onEnter) {
155
95
  const onEnterResult = await executeOnEnter(newStateDef.onEnter, updated, this.entityRepo);
156
96
  if (onEnterResult.skipped) {
157
97
  await this.eventEmitter.emit({
158
98
  type: "onEnter.skipped",
159
99
  entityId,
160
- state: transition.toState,
100
+ state: toState,
161
101
  emittedAt: new Date(),
162
102
  });
163
103
  }
@@ -165,20 +105,20 @@ export class Engine {
165
105
  await this.eventEmitter.emit({
166
106
  type: "onEnter.failed",
167
107
  entityId,
168
- state: transition.toState,
108
+ state: toState,
169
109
  error: onEnterResult.error,
170
110
  emittedAt: new Date(),
171
111
  });
172
112
  await this.transitionLogRepo.record({
173
113
  entityId,
174
114
  fromState: entity.state,
175
- toState: transition.toState,
176
- trigger: signal,
115
+ toState,
116
+ trigger,
177
117
  invocationId: triggeringInvocationId ?? null,
178
118
  timestamp: new Date(),
179
119
  });
180
120
  return {
181
- newState: transition.toState,
121
+ newState: toState,
182
122
  gatesPassed,
183
123
  gated: false,
184
124
  onEnterFailed: true,
@@ -190,7 +130,7 @@ export class Engine {
190
130
  await this.eventEmitter.emit({
191
131
  type: "onEnter.completed",
192
132
  entityId,
193
- state: transition.toState,
133
+ state: toState,
194
134
  artifacts: onEnterResult.artifacts ?? {},
195
135
  emittedAt: new Date(),
196
136
  });
@@ -211,15 +151,15 @@ export class Engine {
211
151
  ]);
212
152
  const enriched = { ...updated, invocations, gateResults };
213
153
  const build = await buildInvocation(newStateDef, enriched, this.adapters, flow, this.logger);
214
- const invocation = await this.invocationRepo.create(entityId, transition.toState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
154
+ const invocation = await this.invocationRepo.create(entityId, toState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
215
155
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
216
- : undefined);
156
+ : undefined, newStateDef.agentRole ?? null);
217
157
  result.invocationId = invocation.id;
218
158
  await this.eventEmitter.emit({
219
159
  type: "invocation.created",
220
160
  entityId,
221
161
  invocationId: invocation.id,
222
- stage: transition.toState,
162
+ stage: toState,
223
163
  emittedAt: new Date(),
224
164
  });
225
165
  }
@@ -229,13 +169,13 @@ export class Engine {
229
169
  await this.transitionLogRepo.record({
230
170
  entityId,
231
171
  fromState: entity.state,
232
- toState: transition.toState,
233
- trigger: signal,
172
+ toState,
173
+ trigger,
234
174
  invocationId: triggeringInvocationId ?? null,
235
175
  timestamp: new Date(),
236
176
  });
237
177
  // 9. Spawn child flows
238
- const spawned = await executeSpawn(transition, updated, this.flowRepo, this.entityRepo, this.logger);
178
+ const spawned = await executeSpawn({ spawnFlow }, updated, this.flowRepo, this.entityRepo, this.logger);
239
179
  if (spawned) {
240
180
  result.spawned = [spawned.id];
241
181
  await this.eventEmitter.emit({
@@ -246,14 +186,93 @@ export class Engine {
246
186
  emittedAt: new Date(),
247
187
  });
248
188
  }
249
- // 10. Mark terminal — no invocation is created for terminal states (handled above),
250
- // but we surface terminality in the result for callers.
251
- if (isTerminal(flow, transition.toState)) {
189
+ // 10. Mark terminal
190
+ if (isTerminal(flow, toState)) {
252
191
  result.terminal = true;
253
192
  result.spawned = result.spawned ?? [];
254
193
  }
255
194
  return result;
256
195
  }
196
+ /**
197
+ * Evaluate a gate and return a routing decision:
198
+ * - `proceed` — gate passed, continue to transition.toState
199
+ * - `redirect` — gate outcome maps to a different toState
200
+ * - `block` — gate failed or timed out; caller should return this as the signal result
201
+ */
202
+ async resolveGate(gateId, entity, flow) {
203
+ const gate = await this.gateRepo.get(gateId);
204
+ if (!gate)
205
+ throw new NotFoundError(`Gate "${gateId}" not found`);
206
+ const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
207
+ const namedOutcome = gateResult.outcome && gate.outcomes ? gate.outcomes[gateResult.outcome] : undefined;
208
+ if (namedOutcome?.toState) {
209
+ const outcomeLabel = gateResult.outcome ?? gate.name;
210
+ await this.eventEmitter.emit({
211
+ type: "gate.redirected",
212
+ entityId: entity.id,
213
+ gateId: gate.id,
214
+ outcome: outcomeLabel,
215
+ toState: namedOutcome.toState,
216
+ emittedAt: new Date(),
217
+ });
218
+ return {
219
+ kind: "redirect",
220
+ toState: namedOutcome.toState,
221
+ trigger: `gate:${gate.name}:${outcomeLabel}`,
222
+ gatesPassed: [gate.name],
223
+ };
224
+ }
225
+ if (namedOutcome?.proceed || (!namedOutcome && gateResult.passed)) {
226
+ await this.eventEmitter.emit({
227
+ type: "gate.passed",
228
+ entityId: entity.id,
229
+ gateId: gate.id,
230
+ emittedAt: new Date(),
231
+ });
232
+ return { kind: "proceed", gatesPassed: [gate.name] };
233
+ }
234
+ // Gate failed — persist failure context and emit event
235
+ const priorFailures = Array.isArray(entity.artifacts?.gate_failures)
236
+ ? entity.artifacts.gate_failures
237
+ : [];
238
+ await this.entityRepo.updateArtifacts(entity.id, {
239
+ gate_failures: [
240
+ ...priorFailures,
241
+ { gateId: gate.id, gateName: gate.name, output: gateResult.output, failedAt: new Date().toISOString() },
242
+ ],
243
+ });
244
+ await this.eventEmitter.emit({
245
+ type: gateResult.timedOut ? "gate.timedOut" : "gate.failed",
246
+ entityId: entity.id,
247
+ gateId: gate.id,
248
+ emittedAt: new Date(),
249
+ });
250
+ let timeoutPrompt;
251
+ if (gateResult.timedOut) {
252
+ const rawTemplate = gate.timeoutPrompt ?? flow.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
253
+ try {
254
+ const hbs = getHandlebars();
255
+ timeoutPrompt = hbs.compile(rawTemplate)({
256
+ entity,
257
+ flow,
258
+ gate: { name: gate.name, output: gateResult.output },
259
+ });
260
+ }
261
+ catch (err) {
262
+ this.logger.error("[engine] Failed to render timeoutPrompt template:", err);
263
+ timeoutPrompt = DEFAULT_TIMEOUT_PROMPT;
264
+ }
265
+ }
266
+ return {
267
+ kind: "block",
268
+ gateTimedOut: gateResult.timedOut,
269
+ gateOutput: gateResult.output,
270
+ gateName: gate.name,
271
+ failurePrompt: gate.failurePrompt ?? undefined,
272
+ timeoutPrompt,
273
+ gatesPassed: [],
274
+ };
275
+ }
257
276
  async createEntity(flowName, refs, payload) {
258
277
  const flow = await this.flowRepo.getByName(flowName);
259
278
  if (!flow)
@@ -312,7 +331,7 @@ export class Engine {
312
331
  const build = await buildInvocation(initialState, enriched, this.adapters, flow, this.logger);
313
332
  await this.invocationRepo.create(entity.id, flow.initialState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
314
333
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
315
- : undefined);
334
+ : undefined, initialState.agentRole ?? null);
316
335
  }
317
336
  return entity;
318
337
  }
@@ -484,7 +503,7 @@ export class Engine {
484
503
  const build = await this.buildPromptForEntity(state, claimed, flow);
485
504
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
486
505
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
487
- : undefined);
506
+ : undefined, state.agentRole ?? null);
488
507
  const entityClaimToken = worker_id ?? `agent:${role}`;
489
508
  let claimedInvocation;
490
509
  try {
@@ -68,6 +68,13 @@ export type EngineEvent = {
68
68
  entityId: string;
69
69
  gateId: string;
70
70
  emittedAt: Date;
71
+ } | {
72
+ type: "gate.redirected";
73
+ entityId: string;
74
+ gateId: string;
75
+ outcome: string;
76
+ toState: string;
77
+ emittedAt: Date;
71
78
  } | {
72
79
  type: "flow.spawned";
73
80
  entityId: string;
@@ -1,8 +1,10 @@
1
1
  import type { Logger } from "../logger.js";
2
- import type { Entity, IEntityRepository, IFlowRepository, Transition } from "../repositories/interfaces.js";
2
+ import type { Entity, IEntityRepository, IFlowRepository } from "../repositories/interfaces.js";
3
3
  /**
4
4
  * If the transition has a spawnFlow, look up that flow and create a new entity in it.
5
5
  * The spawned entity inherits the parent entity's refs.
6
6
  * Returns the spawned entity, or null if no spawn is configured.
7
7
  */
8
- export declare function executeSpawn(transition: Transition, parentEntity: Entity, flowRepo: IFlowRepository, entityRepo: IEntityRepository, logger?: Logger): Promise<Entity | null>;
8
+ export declare function executeSpawn(transition: {
9
+ spawnFlow: string | null | undefined;
10
+ }, parentEntity: Entity, flowRepo: IFlowRepository, entityRepo: IEntityRepository, logger?: Logger): Promise<Entity | null>;
@@ -3,6 +3,10 @@ export interface GateEvalResult {
3
3
  passed: boolean;
4
4
  timedOut: boolean;
5
5
  output: string;
6
+ /** Named outcome from structured JSON output, if the gate emitted one. */
7
+ outcome?: string;
8
+ /** Human-readable message from structured JSON output. */
9
+ message?: string;
6
10
  }
7
11
  export declare function resolveGateTimeout(gateTimeoutMs: number | null | undefined, flowGateTimeoutMs: number | null | undefined): number;
8
12
  /**
@@ -59,6 +59,33 @@ export async function evaluateGate(gate, entity, gateRepo, flowGateTimeoutMs) {
59
59
  passed = result.exitCode === 0;
60
60
  output = result.output;
61
61
  timedOut = result.timedOut;
62
+ // Parse structured JSON outcome from the last non-empty output line.
63
+ // Gate scripts can emit { outcome: string, message?: string } as their final line
64
+ // to enable named outcome routing (e.g. "blocked" → toState: "fixing").
65
+ const lastLine = result.output
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .filter(Boolean)
69
+ .at(-1);
70
+ if (lastLine?.startsWith("{")) {
71
+ try {
72
+ const parsed = JSON.parse(lastLine);
73
+ if (typeof parsed.outcome === "string") {
74
+ const outcomeResult = {
75
+ passed,
76
+ timedOut,
77
+ output: result.output,
78
+ outcome: parsed.outcome,
79
+ message: typeof parsed.message === "string" ? parsed.message : undefined,
80
+ };
81
+ await gateRepo.record(entity.id, gate.id, passed, result.output);
82
+ return outcomeResult;
83
+ }
84
+ }
85
+ catch {
86
+ // Not JSON — treat output as plain text, fall through to normal path
87
+ }
88
+ }
62
89
  }
63
90
  else if (gate.type === "function") {
64
91
  try {
@@ -104,7 +104,7 @@ export class ActiveRunner {
104
104
  await this.entityRepo.updateArtifacts(invocation.entityId, { stuck: true, stuckAt: new Date().toISOString() });
105
105
  return;
106
106
  }
107
- await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, { ...(invocation.context ?? {}), retryCount: retryCount + 1 });
107
+ await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, { ...(invocation.context ?? {}), retryCount: retryCount + 1 }, invocation.agentRole);
108
108
  return;
109
109
  }
110
110
  // Gate timed out — re-queue after backoff instead of failing.
@@ -113,7 +113,7 @@ export class ActiveRunner {
113
113
  if (result.gated && result.gateTimedOut) {
114
114
  const retryAfterMs = 30000;
115
115
  this.logger.info(`[active-runner] gate timed out for entity ${invocation.entityId}, re-queuing after ${retryAfterMs}ms`);
116
- await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, invocation.context ?? undefined);
116
+ await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, invocation.context ?? undefined, invocation.agentRole);
117
117
  await sleep(retryAfterMs, signal);
118
118
  return;
119
119
  }