@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.
@@ -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
- stage: string;
15
- agent_role: string | null;
16
- prompt: string;
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";
@@ -95,6 +95,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
95
95
  onEnter: s.onEnter,
96
96
  onExit: s.onExit,
97
97
  retryAfterMs: s.retryAfterMs,
98
+ meta: s.meta,
98
99
  });
99
100
  }
100
101
  const flowTransitions = parsed.transitions.filter((t) => t.flowName === f.name);
@@ -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
- prompt: string;
25
- context: Record<string, unknown> | null;
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
- const toState = routing.kind === "redirect" ? routing.toState : transition.toState;
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
- prompt: build.prompt,
578
- context: build.context,
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
- prompt: build.prompt,
657
- context: build.context,
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 && flow) {
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
- worker_id: worker_id,
202
- entity_id: claimed.entityId,
208
+ entity_id: entity.id,
203
209
  invocation_id: claimed.id,
204
- flow: flow?.name ?? null,
205
- stage: claimed.stage,
206
- agent_role: claimed.agentRole || null,
207
- prompt: claimed.prompt,
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;