@wopr-network/defcon 1.14.0 → 1.16.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/api/wire-types.d.ts +3 -6
- package/dist/src/config/seed-loader.js +1 -0
- package/dist/src/config/zod-schemas.d.ts +2 -0
- package/dist/src/config/zod-schemas.js +2 -0
- package/dist/src/engine/engine.d.ts +2 -2
- package/dist/src/engine/engine.js +28 -11
- package/dist/src/engine/invocation-builder.js +1 -1
- package/dist/src/execution/cli.js +3 -1
- package/dist/src/execution/handlers/flow.js +13 -8
- package/dist/src/execution/provision-worktree.js +5 -0
- package/dist/src/repositories/drizzle/flow.repo.js +5 -0
- package/dist/src/repositories/drizzle/schema.d.ts +17 -0
- package/dist/src/repositories/drizzle/schema.js +2 -0
- package/dist/src/repositories/interfaces.d.ts +3 -0
- package/drizzle/0019_same_rhodey.sql +1 -0
- package/drizzle/meta/0019_snapshot.json +1358 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
|
@@ -7,15 +7,12 @@ export type ClaimResponse = {
|
|
|
7
7
|
retry_after_ms: number;
|
|
8
8
|
message: string;
|
|
9
9
|
} | {
|
|
10
|
-
worker_id?: string;
|
|
11
10
|
entity_id: string;
|
|
12
11
|
invocation_id: string;
|
|
13
12
|
flow: string | null;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
context: Record<string, unknown> | null;
|
|
18
|
-
worker_notice?: string;
|
|
13
|
+
state: string;
|
|
14
|
+
refs: Record<string, unknown> | null;
|
|
15
|
+
artifacts: Record<string, unknown> | null;
|
|
19
16
|
};
|
|
20
17
|
export type ReportResponse = {
|
|
21
18
|
next_action: "continue";
|
|
@@ -45,6 +45,7 @@ export declare const StateDefinitionSchema: z.ZodObject<{
|
|
|
45
45
|
timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
46
46
|
}, z.core.$strip>>;
|
|
47
47
|
retryAfterMs: z.ZodOptional<z.ZodNumber>;
|
|
48
|
+
meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
48
49
|
}, z.core.$strip>;
|
|
49
50
|
export declare const CommandGateSchema: z.ZodObject<{
|
|
50
51
|
name: z.ZodString;
|
|
@@ -165,6 +166,7 @@ export declare const SeedFileSchema: z.ZodObject<{
|
|
|
165
166
|
timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
166
167
|
}, z.core.$strip>>;
|
|
167
168
|
retryAfterMs: z.ZodOptional<z.ZodNumber>;
|
|
169
|
+
meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
168
170
|
}, z.core.$strip>>;
|
|
169
171
|
gates: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
170
172
|
name: z.ZodString;
|
|
@@ -60,6 +60,8 @@ export const StateDefinitionSchema = z.object({
|
|
|
60
60
|
onEnter: OnEnterSchema.optional(),
|
|
61
61
|
onExit: OnExitSchema.optional(),
|
|
62
62
|
retryAfterMs: z.number().int().min(0).optional(),
|
|
63
|
+
/** Opaque metadata passed through to consumers (e.g. radar). Defcon stores but does not interpret. */
|
|
64
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
63
65
|
});
|
|
64
66
|
// Gate: discriminated union on `type`
|
|
65
67
|
const GateOutcomeSchema = z.object({
|
|
@@ -21,8 +21,8 @@ export interface ClaimWorkResult {
|
|
|
21
21
|
invocationId: string;
|
|
22
22
|
flowName: string;
|
|
23
23
|
stage: string;
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
refs: Record<string, unknown> | null;
|
|
25
|
+
artifacts: Record<string, unknown> | null;
|
|
26
26
|
}
|
|
27
27
|
export interface EngineStatus {
|
|
28
28
|
flows: Record<string, Record<string, number>>;
|
|
@@ -8,6 +8,8 @@ import { buildInvocation } from "./invocation-builder.js";
|
|
|
8
8
|
import { executeOnEnter } from "./on-enter.js";
|
|
9
9
|
import { executeOnExit } from "./on-exit.js";
|
|
10
10
|
import { findTransition, isTerminal } from "./state-machine.js";
|
|
11
|
+
const MERGE_BLOCKED_THRESHOLD = 3;
|
|
12
|
+
const MERGE_BLOCKED_STUCK_MESSAGE = "PR blocked in merge queue 3+ times — likely branch protection or queue contention issue";
|
|
11
13
|
export class Engine {
|
|
12
14
|
entityRepo;
|
|
13
15
|
flowRepo;
|
|
@@ -93,10 +95,31 @@ export class Engine {
|
|
|
93
95
|
return { gated: true, ...routing, terminal: false };
|
|
94
96
|
}
|
|
95
97
|
// Gate passed or redirected — determine the actual destination.
|
|
96
|
-
|
|
98
|
+
let toState = routing.kind === "redirect" ? routing.toState : transition.toState;
|
|
97
99
|
const trigger = routing.kind === "redirect" ? routing.trigger : signal;
|
|
98
100
|
const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
|
|
99
101
|
const { gatesPassed } = routing;
|
|
102
|
+
// 4a. Merge-blocked loop detection: if the watcher sends "blocked" from
|
|
103
|
+
// "merging", increment a counter. After MERGE_BLOCKED_THRESHOLD cycles,
|
|
104
|
+
// override the destination to "stuck" to break the loop.
|
|
105
|
+
if (signal === "blocked" && entity.state === "merging") {
|
|
106
|
+
const prevCount = (typeof entity.artifacts?.merge_blocked_count === "number" ? entity.artifacts.merge_blocked_count : 0);
|
|
107
|
+
const newCount = prevCount + 1;
|
|
108
|
+
await this.entityRepo.updateArtifacts(entityId, { merge_blocked_count: newCount });
|
|
109
|
+
if (newCount >= MERGE_BLOCKED_THRESHOLD) {
|
|
110
|
+
const stuckState = flow.states.find((s) => s.name === "stuck");
|
|
111
|
+
if (stuckState) {
|
|
112
|
+
toState = "stuck";
|
|
113
|
+
await this.entityRepo.updateArtifacts(entityId, {
|
|
114
|
+
merge_blocked_message: MERGE_BLOCKED_STUCK_MESSAGE,
|
|
115
|
+
});
|
|
116
|
+
this.logger.warn(`[engine] Entity ${entityId} merge-blocked ${newCount} times, transitioning to stuck`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
this.logger.warn(`merge_blocked_count >= threshold but no stuck state in flow ${flow.name} — entity ${entityId} will continue looping`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
100
123
|
// 4b. Execute onExit hook on the DEPARTING state (before transition)
|
|
101
124
|
const departingStateDef = flow.states.find((s) => s.name === entity.state);
|
|
102
125
|
if (departingStateDef?.onExit) {
|
|
@@ -556,12 +579,6 @@ export class Engine {
|
|
|
556
579
|
this.logger.warn(`[engine] setAffinity failed for entity ${claimed.id} worker ${worker_id} — continuing:`, err);
|
|
557
580
|
}
|
|
558
581
|
}
|
|
559
|
-
const versionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
|
|
560
|
-
const effectiveFlow = versionedFlow ?? flow;
|
|
561
|
-
const state = effectiveFlow.states.find((s) => s.name === pending.stage);
|
|
562
|
-
const build = state
|
|
563
|
-
? await this.buildPromptForEntity(state, claimed, effectiveFlow)
|
|
564
|
-
: { prompt: pending.prompt, context: null };
|
|
565
582
|
await this.eventEmitter.emit({
|
|
566
583
|
type: "entity.claimed",
|
|
567
584
|
entityId: claimed.id,
|
|
@@ -574,8 +591,8 @@ export class Engine {
|
|
|
574
591
|
invocationId: claimedInvocation.id,
|
|
575
592
|
flowName: flow.name,
|
|
576
593
|
stage: pending.stage,
|
|
577
|
-
|
|
578
|
-
|
|
594
|
+
refs: claimed.refs ?? null,
|
|
595
|
+
artifacts: claimed.artifacts ?? null,
|
|
579
596
|
};
|
|
580
597
|
}
|
|
581
598
|
// 8. Fallback: no unclaimed invocations — claim entity directly and create invocation
|
|
@@ -653,8 +670,8 @@ export class Engine {
|
|
|
653
670
|
invocationId: claimedInvocation.id,
|
|
654
671
|
flowName: flow.name,
|
|
655
672
|
stage: state.name,
|
|
656
|
-
|
|
657
|
-
|
|
673
|
+
refs: claimed.refs ?? null,
|
|
674
|
+
artifacts: claimed.artifacts ?? null,
|
|
658
675
|
};
|
|
659
676
|
}
|
|
660
677
|
}
|
|
@@ -46,7 +46,7 @@ export async function buildInvocation(state, entity, adapters, flow, logger = co
|
|
|
46
46
|
let systemPrompt = "";
|
|
47
47
|
let userContent = "";
|
|
48
48
|
if (state.promptTemplate) {
|
|
49
|
-
const template = getHandlebars().compile(state.promptTemplate);
|
|
49
|
+
const template = getHandlebars().compile(state.promptTemplate, { noEscape: true });
|
|
50
50
|
prompt = template(context);
|
|
51
51
|
systemPrompt = `${INJECTION_WARNING}\n\n${prompt}`;
|
|
52
52
|
userContent = `<external-data>\n${JSON.stringify(entityDataForContext(entity), null, 2)}\n</external-data>`;
|
|
@@ -474,9 +474,11 @@ program
|
|
|
474
474
|
sqlite.close();
|
|
475
475
|
});
|
|
476
476
|
// ─── provision-worktree ───
|
|
477
|
+
// TODO(WOP-2014): Remove this subcommand once nuke containers are deployed and stable.
|
|
478
|
+
// Nuke workers provision their own workspace inside the container, making this unnecessary.
|
|
477
479
|
program
|
|
478
480
|
.command("provision-worktree")
|
|
479
|
-
.description("Provision a git worktree and branch for an issue")
|
|
481
|
+
.description("[DEPRECATED] Provision a git worktree and branch for an issue (superseded by nuke containers)")
|
|
480
482
|
.argument("<repo>", "GitHub repo (e.g. wopr-network/defcon)")
|
|
481
483
|
.argument("<issue-key>", "Issue key (e.g. WOP-392)")
|
|
482
484
|
.option("--base-path <path>", "Worktree base directory", join(homedir(), "worktrees"))
|
|
@@ -173,8 +173,15 @@ export async function handleFlowClaim(deps, args) {
|
|
|
173
173
|
continue;
|
|
174
174
|
}
|
|
175
175
|
const flow = entity ? flowById.get(entity.flowId) : undefined;
|
|
176
|
+
if (!flow) {
|
|
177
|
+
await deps.entities.release(entity.id, claimerId).catch(() => { });
|
|
178
|
+
await deps.invocations
|
|
179
|
+
.fail(claimed.id, `Flow not found for entity ${entity.id} (flowId: ${entity.flowId})`)
|
|
180
|
+
.catch(() => { });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
176
183
|
// Record affinity for the claiming worker (best-effort; failure must not block the claim)
|
|
177
|
-
if (worker_id && entity
|
|
184
|
+
if (worker_id && entity) {
|
|
178
185
|
try {
|
|
179
186
|
const windowMs = flow.affinityWindowMs ?? 300000;
|
|
180
187
|
await deps.entities.setAffinity(claimed.entityId, worker_id, role, new Date(Date.now() + windowMs));
|
|
@@ -198,14 +205,12 @@ export async function handleFlowClaim(deps, args) {
|
|
|
198
205
|
});
|
|
199
206
|
}
|
|
200
207
|
return jsonResult({
|
|
201
|
-
|
|
202
|
-
entity_id: claimed.entityId,
|
|
208
|
+
entity_id: entity.id,
|
|
203
209
|
invocation_id: claimed.id,
|
|
204
|
-
flow: flow
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
context: claimed.context,
|
|
210
|
+
flow: flow.name,
|
|
211
|
+
state: claimed.stage,
|
|
212
|
+
refs: claimedEntity.refs ?? null,
|
|
213
|
+
artifacts: claimedEntity.artifacts ?? null,
|
|
209
214
|
});
|
|
210
215
|
}
|
|
211
216
|
return noWorkResult(RETRY_SHORT_MS, role);
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated provision-worktree is superseded by nuke containerized workers (WOP-2014).
|
|
3
|
+
* Nuke containers provision their own workspace inside the container.
|
|
4
|
+
* TODO: remove this file once nuke is deployed and stable.
|
|
5
|
+
*/
|
|
1
6
|
import { execFileSync } from "node:child_process";
|
|
2
7
|
import { existsSync } from "node:fs";
|
|
3
8
|
import { homedir } from "node:os";
|
|
@@ -17,6 +17,7 @@ function rowToState(r) {
|
|
|
17
17
|
onEnter: r.onEnter ?? null,
|
|
18
18
|
onExit: r.onExit ?? null,
|
|
19
19
|
retryAfterMs: r.retryAfterMs ?? null,
|
|
20
|
+
meta: r.meta,
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
function rowToTransition(r) {
|
|
@@ -161,6 +162,7 @@ export class DrizzleFlowRepository {
|
|
|
161
162
|
onEnter: s.onEnter ?? null,
|
|
162
163
|
onExit: s.onExit ?? null,
|
|
163
164
|
retryAfterMs: s.retryAfterMs ?? null,
|
|
165
|
+
meta: s.meta ?? null,
|
|
164
166
|
})),
|
|
165
167
|
transitions: (snap.transitions ?? []).map((t) => ({
|
|
166
168
|
id: t.id,
|
|
@@ -238,6 +240,7 @@ export class DrizzleFlowRepository {
|
|
|
238
240
|
onEnter: (state.onEnter ?? null),
|
|
239
241
|
onExit: (state.onExit ?? null),
|
|
240
242
|
retryAfterMs: state.retryAfterMs ?? null,
|
|
243
|
+
meta: (state.meta ?? null),
|
|
241
244
|
};
|
|
242
245
|
this.db.transaction((tx) => {
|
|
243
246
|
tx.insert(stateDefinitions).values(row).run();
|
|
@@ -268,6 +271,8 @@ export class DrizzleFlowRepository {
|
|
|
268
271
|
updateValues.onExit = changes.onExit;
|
|
269
272
|
if (changes.retryAfterMs !== undefined)
|
|
270
273
|
updateValues.retryAfterMs = changes.retryAfterMs;
|
|
274
|
+
if (changes.meta !== undefined)
|
|
275
|
+
updateValues.meta = changes.meta;
|
|
271
276
|
if (Object.keys(updateValues).length > 0) {
|
|
272
277
|
this.db.update(stateDefinitions).set(updateValues).where(eq(stateDefinitions.id, stateId)).run();
|
|
273
278
|
}
|
|
@@ -532,6 +532,23 @@ export declare const stateDefinitions: import("drizzle-orm/sqlite-core").SQLiteT
|
|
|
532
532
|
identity: undefined;
|
|
533
533
|
generated: undefined;
|
|
534
534
|
}, {}, {}>;
|
|
535
|
+
meta: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
536
|
+
name: "meta";
|
|
537
|
+
tableName: "state_definitions";
|
|
538
|
+
dataType: "json";
|
|
539
|
+
columnType: "SQLiteTextJson";
|
|
540
|
+
data: unknown;
|
|
541
|
+
driverParam: string;
|
|
542
|
+
notNull: false;
|
|
543
|
+
hasDefault: false;
|
|
544
|
+
isPrimaryKey: false;
|
|
545
|
+
isAutoincrement: false;
|
|
546
|
+
hasRuntimeDefault: false;
|
|
547
|
+
enumValues: undefined;
|
|
548
|
+
baseColumn: never;
|
|
549
|
+
identity: undefined;
|
|
550
|
+
generated: undefined;
|
|
551
|
+
}, {}, {}>;
|
|
535
552
|
};
|
|
536
553
|
dialect: "sqlite";
|
|
537
554
|
}>;
|
|
@@ -34,6 +34,8 @@ export const stateDefinitions = sqliteTable("state_definitions", {
|
|
|
34
34
|
onEnter: text("on_enter", { mode: "json" }),
|
|
35
35
|
onExit: text("on_exit", { mode: "json" }),
|
|
36
36
|
retryAfterMs: integer("retry_after_ms"),
|
|
37
|
+
/** Opaque metadata passed through to consumers. Defcon stores but does not interpret. */
|
|
38
|
+
meta: text("meta", { mode: "json" }),
|
|
37
39
|
}, (table) => ({
|
|
38
40
|
flowNameUnique: uniqueIndex("state_definitions_flow_name_unique").on(table.flowId, table.name),
|
|
39
41
|
}));
|
|
@@ -99,6 +99,8 @@ export interface State {
|
|
|
99
99
|
onExit: OnExitConfig | null;
|
|
100
100
|
/** Override check_back delay for workers claiming this state. Falls back to Flow.claimRetryAfterMs. */
|
|
101
101
|
retryAfterMs: number | null;
|
|
102
|
+
/** Opaque metadata passed through to consumers (e.g. radar). Defcon stores but does not interpret. */
|
|
103
|
+
meta: Record<string, unknown> | null;
|
|
102
104
|
}
|
|
103
105
|
/** A transition rule between two states */
|
|
104
106
|
export interface Transition {
|
|
@@ -195,6 +197,7 @@ export interface CreateStateInput {
|
|
|
195
197
|
onEnter?: OnEnterConfig;
|
|
196
198
|
onExit?: OnExitConfig;
|
|
197
199
|
retryAfterMs?: number;
|
|
200
|
+
meta?: Record<string, unknown>;
|
|
198
201
|
}
|
|
199
202
|
/** Input for adding a transition rule */
|
|
200
203
|
export interface CreateTransitionInput {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `state_definitions` ADD `meta` text;
|