@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.
- package/dist/src/config/seed-loader.js +2 -0
- package/dist/src/config/zod-schemas.d.ts +4 -0
- package/dist/src/config/zod-schemas.js +2 -0
- package/dist/src/engine/engine.d.ts +1 -0
- package/dist/src/engine/engine.js +3 -0
- package/dist/src/execution/handlers/flow.js +114 -57
- package/dist/src/execution/mcp-server.js +2 -46
- package/dist/src/repositories/drizzle/flow.repo.js +11 -0
- package/dist/src/repositories/drizzle/schema.d.ts +34 -0
- package/dist/src/repositories/drizzle/schema.js +2 -0
- package/dist/src/repositories/interfaces.d.ts +6 -0
- package/drizzle/0012_certain_human_fly.sql +2 -0
- package/drizzle/meta/0010_snapshot.json +2 -2
- package/drizzle/meta/0012_snapshot.json +1086 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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(
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
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,
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 {
|
|
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 {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": "6",
|
|
3
3
|
"dialect": "sqlite",
|
|
4
4
|
"id": "0010_amusing_bastion",
|
|
5
|
-
"prevId": "
|
|
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
|
+
}
|