@wopr-network/defcon 1.3.0 → 1.4.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.
@@ -75,6 +75,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
75
75
  initialState: f.initialState,
76
76
  maxConcurrent: f.maxConcurrent,
77
77
  maxConcurrentPerRepo: f.maxConcurrentPerRepo,
78
+ claimRetryAfterMs: f.claimRetryAfterMs,
78
79
  gateTimeoutMs: f.gateTimeoutMs,
79
80
  createdBy: f.createdBy,
80
81
  discipline: f.discipline,
@@ -90,6 +91,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
90
91
  promptTemplate: s.promptTemplate,
91
92
  constraints: s.constraints,
92
93
  onEnter: s.onEnter,
94
+ retryAfterMs: s.retryAfterMs,
93
95
  });
94
96
  }
95
97
  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>;
@@ -34,6 +35,7 @@ export declare const StateDefinitionSchema: z.ZodObject<{
34
35
  artifacts: z.ZodArray<z.ZodString>;
35
36
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
36
37
  }, z.core.$strip>>;
38
+ retryAfterMs: z.ZodOptional<z.ZodNumber>;
37
39
  }, z.core.$strip>;
38
40
  export declare const CommandGateSchema: z.ZodObject<{
39
41
  name: z.ZodString;
@@ -101,6 +103,7 @@ export declare const SeedFileSchema: z.ZodObject<{
101
103
  maxConcurrent: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
102
104
  maxConcurrentPerRepo: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
103
105
  affinityWindowMs: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
106
+ claimRetryAfterMs: z.ZodOptional<z.ZodNumber>;
104
107
  gateTimeoutMs: z.ZodOptional<z.ZodNumber>;
105
108
  version: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
106
109
  createdBy: z.ZodOptional<z.ZodString>;
@@ -123,6 +126,7 @@ export declare const SeedFileSchema: z.ZodObject<{
123
126
  artifacts: z.ZodArray<z.ZodString>;
124
127
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
125
128
  }, z.core.$strip>>;
129
+ retryAfterMs: z.ZodOptional<z.ZodNumber>;
126
130
  }, z.core.$strip>>;
127
131
  gates: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
128
132
  name: z.ZodString;
@@ -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(),
@@ -47,6 +48,7 @@ export const StateDefinitionSchema = z.object({
47
48
  .optional(),
48
49
  constraints: z.record(z.string(), z.unknown()).optional(),
49
50
  onEnter: OnEnterSchema.optional(),
51
+ retryAfterMs: z.number().int().min(0).optional(),
50
52
  });
51
53
  // Gate: discriminated union on `type`
52
54
  const BaseGateSchema = z.object({
@@ -54,6 +54,7 @@ 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>;
58
59
  createEntity(flowName: string, refs?: Record<string, {
59
60
  adapter: 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);
@@ -2,7 +2,32 @@ import { DEFAULT_TIMEOUT_PROMPT } from "../../engine/constants.js";
2
2
  import { errorResult, jsonResult, validateInput } from "../mcp-helpers.js";
3
3
  import { FlowClaimSchema, FlowFailSchema, FlowGetPromptSchema, FlowReportSchema } from "../tool-schemas.js";
4
4
  const RETRY_SHORT_MS = 30_000; // entities exist but all claimed
5
- const RETRY_LONG_MS = 300_000; // backlog empty
5
+ const RETRY_LONG_MS = 300_000; // backlog empty — fallback when no per-flow/state config
6
+ /**
7
+ * Resolve the check_back delay for a "no work" response.
8
+ * Priority: state.retryAfterMs > flow.claimRetryAfterMs > RETRY_LONG_MS
9
+ */
10
+ function resolveRetryMs(flows, forEmpty) {
11
+ if (!forEmpty)
12
+ return RETRY_SHORT_MS;
13
+ // Find the minimum configured retryAfterMs across all candidate flows/states.
14
+ let best = null;
15
+ for (const flow of flows) {
16
+ const flowDefault = flow.claimRetryAfterMs ?? null;
17
+ // Check each state for a state-level override
18
+ for (const state of flow.states) {
19
+ if (state.promptTemplate === null)
20
+ continue; // not a claimable state
21
+ const ms = state.retryAfterMs ?? flowDefault;
22
+ if (ms !== null && (best === null || ms < best))
23
+ best = ms;
24
+ }
25
+ // If no states had overrides but the flow has a default
26
+ if (flowDefault !== null && (best === null || flowDefault < best))
27
+ best = flowDefault;
28
+ }
29
+ return best ?? RETRY_LONG_MS;
30
+ }
6
31
  function noWorkResult(retryAfterMs, role) {
7
32
  return jsonResult({
8
33
  next_action: "check_back",
@@ -15,6 +40,10 @@ export async function handleFlowClaim(deps, args) {
15
40
  if (!v.ok)
16
41
  return v.result;
17
42
  const { worker_id, role, flow: flowName } = v.data;
43
+ // 0. Reject drained workers immediately
44
+ if (worker_id && deps.engine?.isDraining(worker_id)) {
45
+ return noWorkResult(RETRY_SHORT_MS, role);
46
+ }
18
47
  // 1. Find candidate flows filtered by discipline
19
48
  let candidateFlows = [];
20
49
  if (flowName) {
@@ -23,15 +52,18 @@ export async function handleFlowClaim(deps, args) {
23
52
  return errorResult(`Flow not found: ${flowName}`);
24
53
  // Discipline must match — null discipline flows are claimable by any role
25
54
  if (flow.discipline !== null && flow.discipline !== role)
26
- return noWorkResult(RETRY_LONG_MS, role);
55
+ return noWorkResult(flow.claimRetryAfterMs ?? RETRY_LONG_MS, role);
56
+ // Paused flows have no claimable work
57
+ if (flow.paused)
58
+ return noWorkResult(flow.claimRetryAfterMs ?? RETRY_LONG_MS, role);
27
59
  candidateFlows = [flow];
28
60
  }
29
61
  else {
30
62
  const allFlows = await deps.flows.list();
31
- candidateFlows = allFlows.filter((f) => f.discipline === null || f.discipline === role);
63
+ candidateFlows = allFlows.filter((f) => !f.paused && (f.discipline === null || f.discipline === role));
32
64
  }
33
65
  if (candidateFlows.length === 0)
34
- return noWorkResult(RETRY_LONG_MS, role);
66
+ return noWorkResult(RETRY_LONG_MS, role); // no flows configured for this role
35
67
  const allCandidates = [];
36
68
  for (const flow of candidateFlows) {
37
69
  const unclaimed = await deps.invocations.findUnclaimedByFlow(flow.id);
@@ -48,7 +80,7 @@ export async function handleFlowClaim(deps, args) {
48
80
  break;
49
81
  }
50
82
  }
51
- return noWorkResult(hasEntities ? RETRY_SHORT_MS : RETRY_LONG_MS, role);
83
+ return noWorkResult(resolveRetryMs(candidateFlows, !hasEntities), role);
52
84
  }
53
85
  // 3. Load entities for priority sorting
54
86
  const entityMap = new Map();
@@ -60,27 +92,18 @@ export async function handleFlowClaim(deps, args) {
60
92
  }));
61
93
  // 4. Build a flow lookup map (needed for affinity window below)
62
94
  const flowById = new Map(candidateFlows.map((f) => [f.id, f]));
63
- // 5. Check affinity for each entity using the flow's configured window
95
+ // 5. Check affinity for each entity using findUnclaimedWithAffinity per flow
64
96
  const affinitySet = new Set();
65
- const now = Date.now();
66
97
  if (worker_id) {
67
- await Promise.all(uniqueEntityIds.map(async (eid) => {
68
- const entity = entityMap.get(eid);
69
- const flow = entity ? flowById.get(entity.flowId) : undefined;
70
- const affinityWindowMs = flow?.affinityWindowMs ?? 300000;
71
- const invocations = await deps.invocations.findByEntity(eid);
72
- const lastCompleted = invocations
73
- .filter((inv) => inv.completedAt !== null && inv.claimedBy === worker_id)
74
- .sort((a, b) => (b.completedAt?.getTime() ?? 0) - (a.completedAt?.getTime() ?? 0));
75
- if (lastCompleted.length > 0) {
76
- const elapsed = now - (lastCompleted[0].completedAt?.getTime() ?? 0);
77
- if (elapsed < affinityWindowMs) {
78
- affinitySet.add(eid);
79
- }
98
+ await Promise.all(candidateFlows.map(async (flow) => {
99
+ const affinityInvocations = await deps.invocations.findUnclaimedWithAffinity(flow.id, role, worker_id);
100
+ for (const inv of affinityInvocations) {
101
+ affinitySet.add(inv.entityId);
80
102
  }
81
103
  }));
82
104
  }
83
105
  // 6. Sort candidates by priority algorithm
106
+ const now = Date.now();
84
107
  allCandidates.sort((a, b) => {
85
108
  const entityA = entityMap.get(a.entityId);
86
109
  const entityB = entityMap.get(b.entityId);
@@ -100,55 +123,89 @@ export async function handleFlowClaim(deps, args) {
100
123
  return timeA - timeB;
101
124
  });
102
125
  // 7. Try claiming in priority order (handle race conditions)
126
+ const claimerId = worker_id ?? `agent:${role}`;
103
127
  for (const invocation of allCandidates) {
128
+ const entity = entityMap.get(invocation.entityId);
129
+ // Finding 3: If entity is not in the entityMap, the invocation is orphaned.
130
+ // Skip it — do not claim it, as that would permanently lock it.
131
+ if (!entity) {
132
+ continue;
133
+ }
134
+ // Claim entity first to establish ownership, then claim the invocation.
135
+ // If invocation claim loses a race (returns null), release the entity so
136
+ // another worker can pick it up.
137
+ let claimedEntity = null;
138
+ try {
139
+ claimedEntity = await deps.entities.claimById(entity.id, claimerId);
140
+ }
141
+ catch (err) {
142
+ console.error(`Failed to claim entity ${entity.id}:`, err);
143
+ continue;
144
+ }
145
+ if (!claimedEntity) {
146
+ // Another worker claimed this entity first — skip.
147
+ continue;
148
+ }
149
+ // Finding 1: Verify entity state still matches invocation stage after claiming.
150
+ // A race can leave stale invocation data if the entity changed state between
151
+ // the invocation query and the entity claim.
152
+ if (claimedEntity.state !== invocation.stage) {
153
+ await deps.entities.release(entity.id, claimerId).catch(() => { });
154
+ return noWorkResult(RETRY_SHORT_MS, role);
155
+ }
104
156
  let claimed;
105
157
  try {
106
- claimed = await deps.invocations.claim(invocation.id, worker_id ?? `agent:${role}`);
158
+ claimed = await deps.invocations.claim(invocation.id, claimerId);
107
159
  }
108
160
  catch (err) {
109
161
  console.error(`Failed to claim invocation ${invocation.id}:`, err);
162
+ if (entity && claimedEntity) {
163
+ await deps.entities.release(entity.id, claimerId).catch(() => { });
164
+ }
110
165
  continue;
111
166
  }
112
- if (claimed) {
113
- const entity = entityMap.get(claimed.entityId);
114
- if (entity) {
115
- let claimedEntity;
116
- try {
117
- claimedEntity = await deps.entities.claimById(entity.id, worker_id ?? `agent:${role}`);
118
- }
119
- catch (err) {
120
- console.error(`Failed to claim entity ${entity.id}:`, err);
121
- await deps.invocations.releaseClaim(claimed.id);
122
- continue;
123
- }
124
- if (!claimedEntity) {
125
- // Race condition: another worker claimed this entity first.
126
- // Release the invocation claim so it can be picked up by another worker.
127
- await deps.invocations.releaseClaim(claimed.id);
128
- continue;
129
- }
167
+ if (!claimed) {
168
+ // Race condition: invocation already claimed by another worker.
169
+ // Release the entity so another worker can pick it up.
170
+ if (entity && claimedEntity) {
171
+ await deps.entities.release(entity.id, claimerId).catch(() => { });
130
172
  }
131
- const flow = entity ? flowById.get(entity.flowId) : undefined;
132
- // Record affinity for the claiming worker (best-effort; failure must not block the claim)
133
- if (worker_id && entity && flow) {
134
- try {
135
- const windowMs = flow.affinityWindowMs ?? 300000;
136
- await deps.entities.setAffinity(claimed.entityId, worker_id, role, new Date(Date.now() + windowMs));
137
- }
138
- catch (err) {
139
- console.error(`Failed to set affinity for entity ${claimed.entityId}:`, err);
140
- }
173
+ continue;
174
+ }
175
+ const flow = entity ? flowById.get(entity.flowId) : undefined;
176
+ // Record affinity for the claiming worker (best-effort; failure must not block the claim)
177
+ if (worker_id && entity && flow) {
178
+ try {
179
+ const windowMs = flow.affinityWindowMs ?? 300000;
180
+ await deps.entities.setAffinity(claimed.entityId, worker_id, role, new Date(Date.now() + windowMs));
141
181
  }
142
- return jsonResult({
143
- worker_id: worker_id,
144
- entity_id: claimed.entityId,
145
- invocation_id: claimed.id,
146
- flow: flow?.name ?? null,
147
- stage: claimed.stage,
148
- prompt: claimed.prompt,
149
- context: claimed.context,
182
+ catch (err) {
183
+ console.error(`Failed to set affinity for entity ${claimed.entityId}:`, err);
184
+ }
185
+ }
186
+ // Finding 2: Emit entity.claimed event for WebSocket broadcast.
187
+ if (deps.engine) {
188
+ deps.engine
189
+ .emit({
190
+ type: "entity.claimed",
191
+ entityId: entity.id,
192
+ flowId: entity.flowId,
193
+ agentId: claimerId,
194
+ emittedAt: new Date(),
195
+ })
196
+ .catch((err) => {
197
+ console.error(`Failed to emit entity.claimed for entity ${entity.id}:`, err);
150
198
  });
151
199
  }
200
+ return jsonResult({
201
+ worker_id: worker_id,
202
+ entity_id: claimed.entityId,
203
+ invocation_id: claimed.id,
204
+ flow: flow?.name ?? null,
205
+ stage: claimed.stage,
206
+ prompt: claimed.prompt,
207
+ context: claimed.context,
208
+ });
152
209
  }
153
210
  return noWorkResult(RETRY_SHORT_MS, role);
154
211
  }
@@ -7,7 +7,8 @@ import { DEFAULT_TIMEOUT_PROMPT } from "../engine/constants.js";
7
7
  import { isTerminal } from "../engine/state-machine.js";
8
8
  import { consoleLogger } from "../logger.js";
9
9
  import { AdminEntityCancelSchema, AdminEntityResetSchema, AdminFlowCreateSchema, AdminFlowPauseSchema, AdminFlowRestoreSchema, AdminFlowSnapshotSchema, AdminFlowUpdateSchema, AdminGateAttachSchema, AdminGateCreateSchema, AdminGateRerunSchema, AdminStateCreateSchema, AdminStateUpdateSchema, AdminTransitionCreateSchema, AdminTransitionUpdateSchema, AdminWorkerDrainSchema, } from "./admin-schemas.js";
10
- import { FlowClaimSchema, FlowFailSchema, FlowGetPromptSchema, FlowReportSchema, FlowSeedSchema, QueryEntitiesSchema, QueryEntitySchema, QueryFlowSchema, QueryInvocationsSchema, } from "./tool-schemas.js";
10
+ import { handleFlowClaim } from "./handlers/flow.js";
11
+ import { FlowFailSchema, FlowGetPromptSchema, FlowReportSchema, FlowSeedSchema, QueryEntitiesSchema, QueryEntitySchema, QueryFlowSchema, QueryInvocationsSchema, } from "./tool-schemas.js";
11
12
  function getSystemDefaultGateTimeoutMs() {
12
13
  const parsed = parseInt(process.env.DEFCON_DEFAULT_GATE_TIMEOUT_MS ?? "", 10);
13
14
  return !Number.isNaN(parsed) && parsed > 0 ? parsed : 300000;
@@ -490,57 +491,12 @@ function errorResult(message) {
490
491
  isError: true,
491
492
  };
492
493
  }
493
- const RETRY_SHORT_MS = 30_000; // work exists but all entities currently claimed
494
- const RETRY_LONG_MS = 300_000; // backlog empty
495
- function noWorkResult(retryAfterMs, role) {
496
- return jsonResult({
497
- next_action: "check_back",
498
- retry_after_ms: retryAfterMs,
499
- message: `No work available for role '${role}' right now. Call flow.claim again after the retry delay.`,
500
- });
501
- }
502
494
  function constantTimeEqual(a, b) {
503
495
  const hashA = createHash("sha256").update(a.trim()).digest();
504
496
  const hashB = createHash("sha256").update(b.trim()).digest();
505
497
  return timingSafeEqual(hashA, hashB);
506
498
  }
507
499
  // ─── Tool Handlers ───
508
- async function handleFlowClaim(deps, args) {
509
- const v = validateInput(FlowClaimSchema, args);
510
- if (!v.ok)
511
- return v.result;
512
- const { worker_id, role, flow: flowName } = v.data;
513
- if (!deps.engine) {
514
- return errorResult("Engine not available — MCP server started without engine dependency");
515
- }
516
- let result;
517
- try {
518
- result = await deps.engine.claimWork(role, flowName, worker_id);
519
- }
520
- catch (err) {
521
- const message = err instanceof Error ? err.message : String(err);
522
- const isNotFound = /not found|unknown flow/i.test(message);
523
- if (isNotFound) {
524
- return errorResult(message);
525
- }
526
- return jsonResult({ next_action: "check_back", retry_after_ms: RETRY_LONG_MS, message });
527
- }
528
- if (result === "all_claimed") {
529
- return noWorkResult(RETRY_SHORT_MS, role);
530
- }
531
- if (!result) {
532
- return noWorkResult(RETRY_LONG_MS, role);
533
- }
534
- return jsonResult({
535
- worker_id: worker_id,
536
- entity_id: result.entityId,
537
- invocation_id: result.invocationId,
538
- flow: result.flowName,
539
- stage: result.stage,
540
- prompt: result.prompt,
541
- context: result.context,
542
- });
543
- }
544
500
  async function handleFlowGetPrompt(deps, args) {
545
501
  const v = validateInput(FlowGetPromptSchema, args);
546
502
  if (!v.ok)
@@ -14,6 +14,7 @@ function rowToState(r) {
14
14
  promptTemplate: r.promptTemplate ?? null,
15
15
  constraints: r.constraints,
16
16
  onEnter: r.onEnter ?? null,
17
+ retryAfterMs: r.retryAfterMs ?? null,
17
18
  };
18
19
  }
19
20
  function rowToTransition(r) {
@@ -41,6 +42,7 @@ function rowToFlow(r, states, transitions) {
41
42
  maxConcurrent: r.maxConcurrent ?? 0,
42
43
  maxConcurrentPerRepo: r.maxConcurrentPerRepo ?? 0,
43
44
  affinityWindowMs: r.affinityWindowMs ?? 300000,
45
+ claimRetryAfterMs: r.claimRetryAfterMs ?? null,
44
46
  gateTimeoutMs: r.gateTimeoutMs ?? null,
45
47
  version: r.version ?? 1,
46
48
  createdBy: r.createdBy ?? null,
@@ -86,6 +88,7 @@ export class DrizzleFlowRepository {
86
88
  maxConcurrent: input.maxConcurrent ?? 0,
87
89
  maxConcurrentPerRepo: input.maxConcurrentPerRepo ?? 0,
88
90
  affinityWindowMs: input.affinityWindowMs ?? 300000,
91
+ claimRetryAfterMs: input.claimRetryAfterMs ?? null,
89
92
  gateTimeoutMs: input.gateTimeoutMs ?? null,
90
93
  version: 1,
91
94
  createdBy: input.createdBy ?? null,
@@ -138,6 +141,8 @@ export class DrizzleFlowRepository {
138
141
  updateValues.maxConcurrentPerRepo = changes.maxConcurrentPerRepo;
139
142
  if (changes.affinityWindowMs !== undefined)
140
143
  updateValues.affinityWindowMs = changes.affinityWindowMs;
144
+ if (changes.claimRetryAfterMs !== undefined)
145
+ updateValues.claimRetryAfterMs = changes.claimRetryAfterMs;
141
146
  if (changes.gateTimeoutMs !== undefined)
142
147
  updateValues.gateTimeoutMs = changes.gateTimeoutMs;
143
148
  if (changes.version !== undefined)
@@ -168,6 +173,7 @@ export class DrizzleFlowRepository {
168
173
  promptTemplate: state.promptTemplate ?? null,
169
174
  constraints: (state.constraints ?? null),
170
175
  onEnter: (state.onEnter ?? null),
176
+ retryAfterMs: state.retryAfterMs ?? null,
171
177
  };
172
178
  this.db.transaction((tx) => {
173
179
  tx.insert(stateDefinitions).values(row).run();
@@ -192,6 +198,8 @@ export class DrizzleFlowRepository {
192
198
  updateValues.constraints = changes.constraints;
193
199
  if (changes.onEnter !== undefined)
194
200
  updateValues.onEnter = changes.onEnter;
201
+ if (changes.retryAfterMs !== undefined)
202
+ updateValues.retryAfterMs = changes.retryAfterMs;
195
203
  if (Object.keys(updateValues).length > 0) {
196
204
  this.db.update(stateDefinitions).set(updateValues).where(eq(stateDefinitions.id, stateId)).run();
197
205
  }
@@ -272,6 +280,7 @@ export class DrizzleFlowRepository {
272
280
  updatedAt: flow.updatedAt,
273
281
  discipline: flow.discipline,
274
282
  defaultModelTier: flow.defaultModelTier,
283
+ claimRetryAfterMs: flow.claimRetryAfterMs,
275
284
  states: flow.states,
276
285
  transitions: flow.transitions,
277
286
  };
@@ -325,6 +334,7 @@ export class DrizzleFlowRepository {
325
334
  promptTemplate: s.promptTemplate,
326
335
  constraints: s.constraints,
327
336
  onEnter: (s.onEnter ?? null),
337
+ retryAfterMs: s.retryAfterMs ?? null,
328
338
  })
329
339
  .run();
330
340
  }
@@ -359,6 +369,7 @@ export class DrizzleFlowRepository {
359
369
  createdBy: snap.createdBy,
360
370
  discipline: snap.discipline,
361
371
  defaultModelTier: snap.defaultModelTier ?? null,
372
+ claimRetryAfterMs: snap.claimRetryAfterMs ?? null,
362
373
  timeoutPrompt: snap.timeoutPrompt,
363
374
  updatedAt: Date.now(),
364
375
  })
@@ -146,6 +146,23 @@ export declare const flowDefinitions: import("drizzle-orm/sqlite-core").SQLiteTa
146
146
  identity: undefined;
147
147
  generated: undefined;
148
148
  }, {}, {}>;
149
+ claimRetryAfterMs: import("drizzle-orm/sqlite-core").SQLiteColumn<{
150
+ name: "claim_retry_after_ms";
151
+ tableName: "flow_definitions";
152
+ dataType: "number";
153
+ columnType: "SQLiteInteger";
154
+ data: number;
155
+ driverParam: number;
156
+ notNull: false;
157
+ hasDefault: false;
158
+ isPrimaryKey: false;
159
+ isAutoincrement: false;
160
+ hasRuntimeDefault: false;
161
+ enumValues: undefined;
162
+ baseColumn: never;
163
+ identity: undefined;
164
+ generated: undefined;
165
+ }, {}, {}>;
149
166
  gateTimeoutMs: import("drizzle-orm/sqlite-core").SQLiteColumn<{
150
167
  name: "gate_timeout_ms";
151
168
  tableName: "flow_definitions";
@@ -481,6 +498,23 @@ export declare const stateDefinitions: import("drizzle-orm/sqlite-core").SQLiteT
481
498
  identity: undefined;
482
499
  generated: undefined;
483
500
  }, {}, {}>;
501
+ retryAfterMs: import("drizzle-orm/sqlite-core").SQLiteColumn<{
502
+ name: "retry_after_ms";
503
+ tableName: "state_definitions";
504
+ dataType: "number";
505
+ columnType: "SQLiteInteger";
506
+ data: number;
507
+ driverParam: number;
508
+ notNull: false;
509
+ hasDefault: false;
510
+ isPrimaryKey: false;
511
+ isAutoincrement: false;
512
+ hasRuntimeDefault: false;
513
+ enumValues: undefined;
514
+ baseColumn: never;
515
+ identity: undefined;
516
+ generated: undefined;
517
+ }, {}, {}>;
484
518
  };
485
519
  dialect: "sqlite";
486
520
  }>;
@@ -9,6 +9,7 @@ export const flowDefinitions = sqliteTable("flow_definitions", {
9
9
  maxConcurrent: integer("max_concurrent").default(0),
10
10
  maxConcurrentPerRepo: integer("max_concurrent_per_repo").default(0),
11
11
  affinityWindowMs: integer("affinity_window_ms").default(300000),
12
+ claimRetryAfterMs: integer("claim_retry_after_ms"),
12
13
  gateTimeoutMs: integer("gate_timeout_ms"),
13
14
  version: integer("version").default(1),
14
15
  createdBy: text("created_by"),
@@ -31,6 +32,7 @@ export const stateDefinitions = sqliteTable("state_definitions", {
31
32
  promptTemplate: text("prompt_template"),
32
33
  constraints: text("constraints", { mode: "json" }),
33
34
  onEnter: text("on_enter", { mode: "json" }),
35
+ retryAfterMs: integer("retry_after_ms"),
34
36
  }, (table) => ({
35
37
  flowNameUnique: uniqueIndex("state_definitions_flow_name_unique").on(table.flowId, table.name),
36
38
  }));
@@ -87,6 +87,8 @@ export interface State {
87
87
  promptTemplate: string | null;
88
88
  constraints: Record<string, unknown> | null;
89
89
  onEnter: OnEnterConfig | null;
90
+ /** Override check_back delay for workers claiming this state. Falls back to Flow.claimRetryAfterMs. */
91
+ retryAfterMs: number | null;
90
92
  }
91
93
  /** A transition rule between two states */
92
94
  export interface Transition {
@@ -124,6 +126,8 @@ export interface Flow {
124
126
  maxConcurrent: number;
125
127
  maxConcurrentPerRepo: number;
126
128
  affinityWindowMs: number;
129
+ /** Flow-level default check_back delay when no work is available. Falls back to RETRY_LONG_MS (300s) if null. */
130
+ claimRetryAfterMs: number | null;
127
131
  gateTimeoutMs: number | null;
128
132
  version: number;
129
133
  createdBy: string | null;
@@ -155,6 +159,7 @@ export interface CreateFlowInput {
155
159
  maxConcurrent?: number;
156
160
  maxConcurrentPerRepo?: number;
157
161
  affinityWindowMs?: number;
162
+ claimRetryAfterMs?: number;
158
163
  gateTimeoutMs?: number;
159
164
  createdBy?: string;
160
165
  discipline?: string;
@@ -170,6 +175,7 @@ export interface CreateStateInput {
170
175
  promptTemplate?: string;
171
176
  constraints?: Record<string, unknown>;
172
177
  onEnter?: OnEnterConfig;
178
+ retryAfterMs?: number;
173
179
  }
174
180
  /** Input for adding a transition rule */
175
181
  export interface CreateTransitionInput {
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `flow_definitions` ADD `claim_retry_after_ms` integer;--> statement-breakpoint
2
+ ALTER TABLE `state_definitions` ADD `retry_after_ms` integer;
@@ -2,7 +2,7 @@
2
2
  "version": "6",
3
3
  "dialect": "sqlite",
4
4
  "id": "0010_amusing_bastion",
5
- "prevId": "0010_amusing_bastion",
5
+ "prevId": "0009_brief_midnight",
6
6
  "tables": {
7
7
  "entities": {
8
8
  "name": "entities",
@@ -1062,4 +1062,4 @@
1062
1062
  "internal": {
1063
1063
  "indexes": {}
1064
1064
  }
1065
- }
1065
+ }