@karmaniverous/jeeves-meta 0.15.12 → 0.16.1

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/index.js CHANGED
@@ -1271,12 +1271,6 @@ const autoSeedRuleSchema = z.object({
1271
1271
  crossRefs: z.array(z.string()).optional(),
1272
1272
  /** Walk up this many extra parent levels from the matched file's directory. Default 0. */
1273
1273
  parentDepth: z.number().int().min(0).optional(),
1274
- /** Per-category timeout override for the architect phase (seconds, min 30). */
1275
- architectTimeout: z.number().int().min(30).optional(),
1276
- /** Per-category timeout override for the builder phase (seconds, min 30). */
1277
- builderTimeout: z.number().int().min(30).optional(),
1278
- /** Per-category timeout override for the critic phase (seconds, min 30). */
1279
- criticTimeout: z.number().int().min(30).optional(),
1280
1274
  });
1281
1275
  /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
1282
1276
  const serviceConfigSchema = metaConfigSchema.extend({
@@ -1459,7 +1453,7 @@ class SpawnTimeoutError extends Error {
1459
1453
  * @module executor/GatewayExecutor
1460
1454
  */
1461
1455
  const DEFAULT_POLL_INTERVAL_MS = 5000;
1462
- const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
1456
+ const DEFAULT_SAFETY_VALVE_MS = 3_600_000; // 1 hour fallback
1463
1457
  /**
1464
1458
  * MetaExecutor that spawns OpenClaw sessions via the gateway's
1465
1459
  * `/tools/invoke` endpoint.
@@ -1490,6 +1484,46 @@ class GatewayExecutor {
1490
1484
  /* best-effort cleanup */
1491
1485
  }
1492
1486
  }
1487
+ /** Read and clean up the staging output file. Returns content or undefined if absent. */
1488
+ readStagingFile(outputPath) {
1489
+ if (!existsSync(outputPath))
1490
+ return undefined;
1491
+ try {
1492
+ return readFileSync(outputPath, 'utf8');
1493
+ }
1494
+ finally {
1495
+ this.cleanupOutputFile(outputPath);
1496
+ }
1497
+ }
1498
+ /** Extract plain text from a message content field, skipping ANNOUNCE_SKIP sentinels. */
1499
+ static extractMessageText(content) {
1500
+ if (!content)
1501
+ return undefined;
1502
+ const text = typeof content === 'string'
1503
+ ? content
1504
+ : Array.isArray(content)
1505
+ ? content
1506
+ .filter((b) => b.type === 'text' && b.text)
1507
+ .map((b) => b.text)
1508
+ .join('\n')
1509
+ : '';
1510
+ return text && text.trim() !== 'ANNOUNCE_SKIP' ? text : undefined;
1511
+ }
1512
+ /**
1513
+ * Check history messages for timeout detection.
1514
+ *
1515
+ * Does NOT determine completion — session completion is authoritative
1516
+ * (via sessions_list). History stop reasons can false-positive on
1517
+ * sessions_yield artifacts (#200).
1518
+ */
1519
+ static checkHistoryCompletion(messages) {
1520
+ if (messages.length === 0)
1521
+ return { timedOut: false };
1522
+ const last = messages[messages.length - 1];
1523
+ if (last.role !== 'assistant' || !last.stopReason)
1524
+ return { timedOut: false };
1525
+ return { timedOut: last.stopReason === 'timeout' };
1526
+ }
1493
1527
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
1494
1528
  async invoke(tool, args, sessionKey) {
1495
1529
  const headers = {
@@ -1516,7 +1550,13 @@ class GatewayExecutor {
1516
1550
  }
1517
1551
  return data;
1518
1552
  }
1519
- /** Look up session metadata (tokens, completion status) via sessions_list. */
1553
+ /**
1554
+ * Look up session metadata (tokens, completion status) via sessions_list.
1555
+ *
1556
+ * Detects gateway-side timeout (`status: "timeout"`) and killed sessions
1557
+ * (`status: "killed"`) as completed, with a `timedOut` flag to distinguish
1558
+ * timeout from normal completion.
1559
+ */
1520
1560
  async getSessionInfo(sessionKey) {
1521
1561
  try {
1522
1562
  const result = await this.invoke('sessions_list', {
@@ -1532,13 +1572,18 @@ class GatewayExecutor {
1532
1572
  // With limit=200 this is reliable; a false positive here only
1533
1573
  // means we read the output file slightly early (still correct
1534
1574
  // if the file exists).
1535
- return { completed: true };
1575
+ return { completed: true, timedOut: false };
1536
1576
  }
1537
- const done = match.status === 'completed' || match.status === 'done';
1538
- return { tokens: match.totalTokens, completed: done };
1577
+ const status = match.status;
1578
+ const done = status === 'completed' ||
1579
+ status === 'done' ||
1580
+ status === 'timeout' ||
1581
+ status === 'killed';
1582
+ const timedOut = status === 'timeout';
1583
+ return { tokens: match.totalTokens, completed: done, timedOut };
1539
1584
  }
1540
1585
  catch {
1541
- return { completed: false };
1586
+ return { completed: false, timedOut: false };
1542
1587
  }
1543
1588
  }
1544
1589
  /** Whether this executor has been aborted by the operator. */
@@ -1549,12 +1594,38 @@ class GatewayExecutor {
1549
1594
  abort() {
1550
1595
  this.controller.abort();
1551
1596
  }
1597
+ /**
1598
+ * Query the gateway's configured subagent run timeout.
1599
+ *
1600
+ * Returns the value in milliseconds, or `undefined` if the query fails
1601
+ * or the value is absent/zero (no timeout configured).
1602
+ */
1603
+ async queryGatewayRunTimeout() {
1604
+ try {
1605
+ const result = await this.invoke('session_status', {});
1606
+ const details = (result.result?.details ?? result.result ?? {});
1607
+ const runTimeoutSeconds = details.runTimeoutSeconds ??
1608
+ details.timeout;
1609
+ if (typeof runTimeoutSeconds === 'number' && runTimeoutSeconds > 0) {
1610
+ return runTimeoutSeconds * 1000;
1611
+ }
1612
+ }
1613
+ catch {
1614
+ // Gateway unreachable or field not exposed — fall back to default
1615
+ }
1616
+ return undefined;
1617
+ }
1552
1618
  async spawn(task, options) {
1553
1619
  // Fresh controller for each spawn call
1554
1620
  this.controller = new AbortController();
1555
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
1556
- const timeoutMs = timeoutSeconds * 1000;
1557
- const deadline = Date.now() + timeoutMs;
1621
+ // Safety-valve deadline: gateway's runTimeoutSeconds + 60s buffer,
1622
+ // defaulting to 1 hour if the gateway value is 0/absent/query fails.
1623
+ // This is a circuit breaker, not a timeout mechanism.
1624
+ const gatewayTimeoutMs = await this.queryGatewayRunTimeout();
1625
+ const safetyValveMs = gatewayTimeoutMs
1626
+ ? gatewayTimeoutMs + 60_000
1627
+ : DEFAULT_SAFETY_VALVE_MS;
1628
+ const safetyDeadline = Date.now() + safetyValveMs;
1558
1629
  // Ensure workspace dir exists
1559
1630
  if (!existsSync(this.workspaceDir)) {
1560
1631
  mkdirSync(this.workspaceDir, { recursive: true });
@@ -1576,7 +1647,6 @@ class GatewayExecutor {
1576
1647
  const spawnResult = await this.invoke('sessions_spawn', {
1577
1648
  task: taskWithOutput,
1578
1649
  label,
1579
- runTimeoutSeconds: timeoutSeconds,
1580
1650
  ...(options?.thinking ? { thinking: options.thinking } : {}),
1581
1651
  ...(options?.model ? { model: options.model } : {}),
1582
1652
  });
@@ -1588,9 +1658,18 @@ class GatewayExecutor {
1588
1658
  throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
1589
1659
  JSON.stringify(spawnResult));
1590
1660
  }
1591
- // Step 2: Poll for completion via sessions_history
1661
+ // Step 2: Poll for completion gateway owns the subagent lifecycle.
1662
+ // Loop exits via: (a) completion detection, (b) abort signal,
1663
+ // (c) gateway-side timeout detection, or (d) safety-valve circuit breaker.
1592
1664
  await sleepAsync(3000);
1593
- while (Date.now() < deadline) {
1665
+ while (true) {
1666
+ // Safety-valve circuit breaker
1667
+ if (Date.now() >= safetyDeadline) {
1668
+ this.cleanupOutputFile(outputPath);
1669
+ throw new SpawnTimeoutError('Safety-valve deadline exceeded (' +
1670
+ safetyValveMs.toString() +
1671
+ 'ms) — gateway timeout may be misconfigured', outputPath);
1672
+ }
1594
1673
  // Check for abort before each poll iteration
1595
1674
  if (this.controller.signal.aborted) {
1596
1675
  this.cleanupOutputFile(outputPath);
@@ -1606,63 +1685,51 @@ class GatewayExecutor {
1606
1685
  historyResult.result?.messages ??
1607
1686
  [];
1608
1687
  const msgArray = messages;
1609
- // Check 1: terminal stop reason in history
1610
- let historyDone = false;
1611
- if (msgArray.length > 0) {
1612
- const lastMsg = msgArray[msgArray.length - 1];
1613
- if (lastMsg.role === 'assistant' &&
1614
- lastMsg.stopReason &&
1615
- lastMsg.stopReason !== 'toolUse' &&
1616
- lastMsg.stopReason !== 'error') {
1617
- historyDone = true;
1618
- }
1619
- }
1688
+ // Check 1: history-based timeout detection (stop reason)
1689
+ const { timedOut: historyTimedOut } = GatewayExecutor.checkHistoryCompletion(msgArray);
1620
1690
  // Check 2: session completion status via sessions_list
1691
+ // Gate on session completion only — history stop reasons can
1692
+ // false-positive on sessions_yield artifacts (#200).
1621
1693
  const sessionInfo = await this.getSessionInfo(sessionKey);
1622
- if (historyDone || sessionInfo.completed) {
1694
+ const timedOut = sessionInfo.timedOut || historyTimedOut;
1695
+ if (sessionInfo.completed) {
1623
1696
  const tokens = sessionInfo.tokens;
1624
- // Read output from file (sub-agent wrote it via Write tool)
1625
- if (existsSync(outputPath)) {
1626
- try {
1627
- const output = readFileSync(outputPath, 'utf8');
1697
+ // Gateway-side timeout detected check staging file for recovery
1698
+ if (timedOut) {
1699
+ const output = this.readStagingFile(outputPath);
1700
+ if (output !== undefined)
1628
1701
  return { output, tokens };
1629
- }
1630
- finally {
1631
- try {
1632
- unlinkSync(outputPath);
1633
- }
1634
- catch {
1635
- /* cleanup best-effort */
1636
- }
1637
- }
1702
+ // No output or partial output — throw for _state recovery (§3.16.6)
1703
+ throw new SpawnTimeoutError('Gateway-side timeout detected (session status: timeout)', outputPath);
1638
1704
  }
1705
+ // Normal completion — read output from file
1706
+ const output = this.readStagingFile(outputPath);
1707
+ if (output !== undefined)
1708
+ return { output, tokens };
1639
1709
  // Fallback: extract from message content if file wasn't written.
1640
1710
  // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
1641
1711
  // a preceding assistant message (the file write).
1642
1712
  for (let i = msgArray.length - 1; i >= 0; i--) {
1643
1713
  const msg = msgArray[i];
1644
- if (msg.role === 'assistant' && msg.content) {
1645
- const text = typeof msg.content === 'string'
1646
- ? msg.content
1647
- : Array.isArray(msg.content)
1648
- ? msg.content
1649
- .filter((b) => b.type === 'text' && b.text)
1650
- .map((b) => b.text)
1651
- .join('\n')
1652
- : '';
1653
- if (text && text.trim() !== 'ANNOUNCE_SKIP')
1714
+ if (msg.role === 'assistant') {
1715
+ const text = GatewayExecutor.extractMessageText(msg.content);
1716
+ if (text !== undefined)
1654
1717
  return { output: text, tokens };
1655
1718
  }
1656
1719
  }
1657
1720
  return { output: '', tokens };
1658
1721
  }
1659
1722
  }
1660
- catch {
1723
+ catch (err) {
1724
+ // Re-throw SpawnTimeoutError and SpawnAbortedError — only swallow transient poll failures
1725
+ if (err instanceof SpawnTimeoutError ||
1726
+ err instanceof SpawnAbortedError) {
1727
+ throw err;
1728
+ }
1661
1729
  // Transient poll failure — keep trying
1662
1730
  }
1663
1731
  await sleepAsync(this.pollIntervalMs);
1664
1732
  }
1665
- throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
1666
1733
  }
1667
1734
  }
1668
1735
 
@@ -2884,7 +2951,6 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2884
2951
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
2885
2952
  const result = await executor.spawn(architectTask, {
2886
2953
  thinking: config.thinking,
2887
- timeout: currentMeta._architectTimeout ?? config.architectTimeout,
2888
2954
  label: 'meta-architect',
2889
2955
  });
2890
2956
  const builderBrief = parseArchitectOutput(result.output);
@@ -2936,7 +3002,6 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2936
3002
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
2937
3003
  const result = await executor.spawn(builderTask, {
2938
3004
  thinking: config.thinking,
2939
- timeout: currentMeta._builderTimeout ?? config.builderTimeout,
2940
3005
  label: 'meta-builder',
2941
3006
  });
2942
3007
  const rawOutput = result.output;
@@ -3031,7 +3096,6 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
3031
3096
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
3032
3097
  const result = await executor.spawn(criticTask, {
3033
3098
  thinking: config.thinking,
3034
- timeout: currentMeta._criticTimeout ?? config.criticTimeout,
3035
3099
  label: 'meta-critic',
3036
3100
  });
3037
3101
  const feedback = parseCriticOutput(result.output);
@@ -3367,36 +3431,33 @@ class ProgressReporter {
3367
3431
  }
3368
3432
 
3369
3433
  /**
3370
- * Hybrid 3-layer synthesis queue.
3434
+ * Synthesis queue.
3371
3435
  *
3372
3436
  * Layer 1: Current — the single item currently executing (at most one).
3373
- * Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
3374
- * FIFO among overrides, ahead of automatic candidates.
3375
- * Layer 3: Automatic — computed on read, not persisted. All metas with a
3376
- * pending phase, ranked by scheduler priority.
3377
- *
3378
- * Legacy: `pending` array is the union of overrides + automatic.
3437
+ * Layer 2: Pending — items enqueued via POST /synthesize (targeted) or
3438
+ * scheduler tick (automatic). FIFO, ahead of automatic candidates.
3439
+ * Layer 3: Automatic — computed on read (GET /queue), not persisted. All
3440
+ * metas with a pending phase, ranked by scheduler priority.
3379
3441
  *
3380
3442
  * @module queue
3381
3443
  */
3382
3444
  const DEPTH_WARNING_THRESHOLD = 3;
3445
+ /** Strip trailing .meta suffix for consistent path comparison. */
3446
+ function normQueuePath(p) {
3447
+ return p.endsWith('.meta') ? p.slice(0, -5).replace(/[/\\]$/, '') : p;
3448
+ }
3383
3449
  /**
3384
- * Hybrid 3-layer synthesis queue.
3450
+ * Synthesis queue.
3385
3451
  *
3386
- * Only one synthesis runs at a time. Override items (explicit triggers)
3387
- * take priority over automatic candidates.
3452
+ * Only one synthesis runs at a time. Explicitly enqueued items
3453
+ * take priority over automatic (computed-on-read) candidates.
3388
3454
  */
3389
3455
  class SynthesisQueue {
3390
- /** Legacy queue (used by processQueue for backward compat). */
3391
- queue = [];
3392
- currentItem = null;
3456
+ entries = [];
3457
+ currentPhaseItem = null;
3393
3458
  processing = false;
3394
3459
  logger;
3395
3460
  onEnqueueCallback = null;
3396
- /** Explicit override entries (3-layer model). */
3397
- overrideEntries = [];
3398
- /** Currently executing item with phase info (3-layer model). */
3399
- currentPhaseItem = null;
3400
3461
  constructor(logger) {
3401
3462
  this.logger = logger;
3402
3463
  }
@@ -3404,52 +3465,67 @@ class SynthesisQueue {
3404
3465
  onEnqueue(callback) {
3405
3466
  this.onEnqueueCallback = callback;
3406
3467
  }
3407
- // ── Override layer (3-layer model) ─────────────────────────────────
3468
+ // ── Enqueue / dequeue ──────────────────────────────────────────────
3408
3469
  /**
3409
- * Add an explicit override entry (from POST /synthesize with path).
3470
+ * Add an entry to the queue.
3410
3471
  * Deduped by path. Returns position and whether already queued.
3411
3472
  */
3412
- enqueueOverride(path) {
3473
+ enqueue(path) {
3474
+ const norm = normQueuePath(path);
3413
3475
  // Check if currently executing
3414
- if (this.currentPhaseItem?.path === path ||
3415
- this.currentItem?.path === path) {
3476
+ if (this.currentPhaseItem &&
3477
+ normQueuePath(this.currentPhaseItem.path) === norm) {
3416
3478
  return { position: 0, alreadyQueued: true };
3417
3479
  }
3418
- // Check if already in overrides
3419
- const existing = this.overrideEntries.findIndex((e) => e.path === path);
3480
+ // Check if already in queue
3481
+ const existing = this.entries.findIndex((e) => normQueuePath(e.path) === norm);
3420
3482
  if (existing !== -1) {
3421
3483
  return { position: existing, alreadyQueued: true };
3422
3484
  }
3423
- this.overrideEntries.push({
3485
+ this.entries.push({
3424
3486
  path,
3425
3487
  enqueuedAt: new Date().toISOString(),
3426
3488
  });
3427
- const position = this.overrideEntries.length - 1;
3428
- if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
3429
- this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
3489
+ const position = this.entries.length - 1;
3490
+ if (this.entries.length > DEPTH_WARNING_THRESHOLD) {
3491
+ this.logger.warn({ depth: this.entries.length }, 'Queue depth exceeds threshold');
3430
3492
  }
3431
3493
  this.onEnqueueCallback?.();
3432
3494
  return { position, alreadyQueued: false };
3433
3495
  }
3434
- /** Dequeue the next override entry, or undefined if empty. */
3435
- dequeueOverride() {
3436
- return this.overrideEntries.shift();
3496
+ /** Dequeue the next entry, or undefined if empty. */
3497
+ dequeue() {
3498
+ return this.entries.shift();
3437
3499
  }
3438
- /** Get all override entries (shallow copy). */
3439
- get overrides() {
3440
- return [...this.overrideEntries];
3500
+ /** Get all queued entries (shallow copy). */
3501
+ get items() {
3502
+ return [...this.entries];
3441
3503
  }
3442
- /** Clear all override entries. Returns count removed. */
3443
- clearOverrides() {
3444
- const count = this.overrideEntries.length;
3445
- this.overrideEntries = [];
3504
+ /** Number of items waiting in the queue (excludes current). */
3505
+ get depth() {
3506
+ return this.entries.length;
3507
+ }
3508
+ /**
3509
+ * Remove all pending items from the queue.
3510
+ * Does not affect the currently-running item.
3511
+ *
3512
+ * @returns The number of items removed.
3513
+ */
3514
+ clear() {
3515
+ const count = this.entries.length;
3516
+ this.entries = [];
3446
3517
  return count;
3447
3518
  }
3448
- /** Check if a path is in the override layer. */
3449
- hasOverride(path) {
3450
- return this.overrideEntries.some((e) => e.path === path);
3519
+ /** Check whether a path is in the queue or currently being synthesized. */
3520
+ has(path) {
3521
+ const norm = normQueuePath(path);
3522
+ if (this.currentPhaseItem &&
3523
+ normQueuePath(this.currentPhaseItem.path) === norm) {
3524
+ return true;
3525
+ }
3526
+ return this.entries.some((e) => normQueuePath(e.path) === norm);
3451
3527
  }
3452
- // ── Current-item tracking (3-layer model) ──────────────────────────
3528
+ // ── Current-item tracking ──────────────────────────────────────────
3453
3529
  /** Set the currently executing phase item. */
3454
3530
  setCurrentPhase(path, phase) {
3455
3531
  this.currentPhaseItem = {
@@ -3466,129 +3542,21 @@ class SynthesisQueue {
3466
3542
  get currentPhase() {
3467
3543
  return this.currentPhaseItem;
3468
3544
  }
3469
- // ── Legacy queue interface (preserved for backward compat) ─────────
3470
- /**
3471
- * Add a path to the synthesis queue.
3472
- *
3473
- * @param path - Meta path to synthesize.
3474
- * @param priority - If true, insert at front of queue.
3475
- * @returns Position and whether the path was already queued.
3476
- */
3477
- enqueue(path, priority = false) {
3478
- if (this.currentItem?.path === path) {
3479
- return { position: 0, alreadyQueued: true };
3480
- }
3481
- const existingIndex = this.queue.findIndex((item) => item.path === path);
3482
- if (existingIndex !== -1) {
3483
- return { position: existingIndex, alreadyQueued: true };
3484
- }
3485
- const item = {
3486
- path,
3487
- priority,
3488
- enqueuedAt: new Date().toISOString(),
3489
- };
3490
- if (priority) {
3491
- this.queue.unshift(item);
3492
- }
3493
- else {
3494
- this.queue.push(item);
3495
- }
3496
- if (this.queue.length > DEPTH_WARNING_THRESHOLD) {
3497
- this.logger.warn({ depth: this.queue.length }, 'Queue depth exceeds threshold');
3498
- }
3499
- const position = this.queue.findIndex((i) => i.path === path);
3500
- this.onEnqueueCallback?.();
3501
- return { position, alreadyQueued: false };
3502
- }
3503
- /** Remove and return the next item from the queue. */
3504
- dequeue() {
3505
- const item = this.queue.shift();
3506
- if (item) {
3507
- this.currentItem = item;
3508
- }
3509
- return item;
3510
- }
3511
- /** Mark the currently-running synthesis as complete. */
3512
- complete() {
3513
- this.currentItem = null;
3514
- }
3515
- /** Number of items waiting in the queue (excludes current). */
3516
- get depth() {
3517
- return this.queue.length;
3518
- }
3519
- /** The item currently being synthesized, or null. */
3520
- get current() {
3521
- return this.currentItem;
3522
- }
3523
- /** A shallow copy of the queued items. */
3524
- get items() {
3525
- return [...this.queue];
3526
- }
3527
- /** A shallow copy of the pending items (alias for items). */
3528
- get pending() {
3529
- return [...this.queue];
3530
- }
3531
- /**
3532
- * Remove all pending items from the queue.
3533
- * Does not affect the currently-running item.
3534
- *
3535
- * @returns The number of items removed.
3536
- */
3537
- clear() {
3538
- const count = this.queue.length;
3539
- this.queue = [];
3540
- return count;
3541
- }
3542
- /** Check whether a path is in the queue or currently being synthesized. */
3543
- has(path) {
3544
- if (this.currentItem?.path === path)
3545
- return true;
3546
- if (this.currentPhaseItem?.path === path)
3547
- return true;
3548
- return (this.queue.some((item) => item.path === path) ||
3549
- this.overrideEntries.some((e) => e.path === path));
3550
- }
3551
- /** Get the 0-indexed position of a path in the queue. */
3552
- getPosition(path) {
3553
- // Check overrides first
3554
- const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
3555
- if (overrideIdx !== -1)
3556
- return overrideIdx;
3557
- const index = this.queue.findIndex((item) => item.path === path);
3558
- return index === -1 ? null : index;
3559
- }
3560
- /** Dequeue the next item: overrides first, then legacy queue. */
3561
- nextItem() {
3562
- const override = this.dequeueOverride();
3563
- if (override)
3564
- return { path: override.path, source: 'override' };
3565
- const item = this.dequeue();
3566
- if (item)
3567
- return { path: item.path, source: 'legacy' };
3568
- return undefined;
3569
- }
3545
+ // ── Queue state ────────────────────────────────────────────────────
3570
3546
  /** Return a snapshot of queue state for the /status endpoint. */
3571
3547
  getState() {
3572
3548
  return {
3573
- depth: this.queue.length + this.overrideEntries.length,
3574
- items: [
3575
- ...this.overrideEntries.map((e) => ({
3576
- path: e.path,
3577
- priority: true,
3578
- enqueuedAt: e.enqueuedAt,
3579
- })),
3580
- ...this.queue.map((item) => ({
3581
- path: item.path,
3582
- priority: item.priority,
3583
- enqueuedAt: item.enqueuedAt,
3584
- })),
3585
- ],
3549
+ depth: this.entries.length,
3550
+ items: this.entries.map((e) => ({
3551
+ path: e.path,
3552
+ enqueuedAt: e.enqueuedAt,
3553
+ })),
3586
3554
  };
3587
3555
  }
3556
+ // ── Processing ─────────────────────────────────────────────────────
3588
3557
  /**
3589
- * Process queued items one at a time until all queues are empty.
3558
+ * Process queued items one at a time until the queue is empty.
3590
3559
  *
3591
- * Override entries are processed first (FIFO), then legacy queue items.
3592
3560
  * Re-entry is prevented: if already processing, the call returns
3593
3561
  * immediately. Errors are logged and do not block subsequent items.
3594
3562
  *
@@ -3599,7 +3567,7 @@ class SynthesisQueue {
3599
3567
  return;
3600
3568
  this.processing = true;
3601
3569
  try {
3602
- let next = this.nextItem();
3570
+ let next = this.dequeue();
3603
3571
  while (next) {
3604
3572
  try {
3605
3573
  await synthesizeFn(next.path);
@@ -3608,9 +3576,7 @@ class SynthesisQueue {
3608
3576
  this.logger.error({ path: next.path, err }, 'Synthesis failed');
3609
3577
  }
3610
3578
  this.clearCurrentPhase();
3611
- if (next.source === 'legacy')
3612
- this.complete();
3613
- next = this.nextItem();
3579
+ next = this.dequeue();
3614
3580
  }
3615
3581
  }
3616
3582
  finally {
@@ -3944,12 +3910,6 @@ async function createMeta(ownerPath, options) {
3944
3910
  metaJson._crossRefs = options.crossRefs;
3945
3911
  if (options?.steer !== undefined)
3946
3912
  metaJson._steer = options.steer;
3947
- if (options?.architectTimeout !== undefined)
3948
- metaJson._architectTimeout = options.architectTimeout;
3949
- if (options?.builderTimeout !== undefined)
3950
- metaJson._builderTimeout = options.builderTimeout;
3951
- if (options?.criticTimeout !== undefined)
3952
- metaJson._criticTimeout = options.criticTimeout;
3953
3913
  const metaJsonPath = join(metaDir, 'meta.json');
3954
3914
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
3955
3915
  return { metaDir, _id };
@@ -4025,9 +3985,6 @@ async function autoSeedPass(rules, watcher, logger) {
4025
3985
  candidates.set(dir, {
4026
3986
  steer: rule.steer,
4027
3987
  crossRefs: rule.crossRefs,
4028
- architectTimeout: rule.architectTimeout,
4029
- builderTimeout: rule.builderTimeout,
4030
- criticTimeout: rule.criticTimeout,
4031
3988
  });
4032
3989
  }
4033
3990
  }
@@ -4173,7 +4130,6 @@ class Scheduler {
4173
4130
  this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
4174
4131
  return;
4175
4132
  }
4176
- // Enqueue using the legacy queue path (backward compat with processQueue)
4177
4133
  this.queue.enqueue(candidate.path);
4178
4134
  this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
4179
4135
  // Opportunistic watcher restart detection
@@ -4597,7 +4553,7 @@ function registerMetasRoutes(app, deps) {
4597
4553
  /**
4598
4554
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
4599
4555
  *
4600
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4556
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
4601
4557
  * Set a field to null to remove it. Unknown keys are rejected.
4602
4558
  *
4603
4559
  * @module routes/metasUpdate
@@ -4609,9 +4565,6 @@ const updateBodySchema = z
4609
4565
  _depth: z.union([z.number(), z.null()]).optional(),
4610
4566
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
4611
4567
  _disabled: z.union([z.boolean(), z.null()]).optional(),
4612
- _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4613
- _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4614
- _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4615
4568
  })
4616
4569
  .strict();
4617
4570
  function registerMetasUpdateRoute(app, deps) {
@@ -4775,8 +4728,8 @@ function registerPreviewRoute(app, deps) {
4775
4728
  /**
4776
4729
  * Queue management and abort routes.
4777
4730
  *
4778
- * - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
4779
- * - POST /queue/clear — remove override entries only
4731
+ * - GET /queue — 3-layer queue model (current, pending, automatic)
4732
+ * - POST /queue/clear — remove pending entries
4780
4733
  * - POST /synthesize/abort — abort the current synthesis
4781
4734
  *
4782
4735
  * @module routes/queue
@@ -4786,24 +4739,24 @@ function registerQueueRoutes(app, deps) {
4786
4739
  const { queue } = deps;
4787
4740
  app.get(getEndpoint('queue').path, async () => {
4788
4741
  const currentPhase = queue.currentPhase;
4789
- const overrides = queue.overrides;
4790
- // Compute owedPhase for each override entry by reading meta state
4791
- const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
4742
+ const pending = queue.items;
4743
+ // Compute owedPhase for each pending entry by reading meta state
4744
+ const enrichedPending = await Promise.all(pending.map(async (entry) => {
4792
4745
  try {
4793
- const metaDir = resolveMetaDir(o.path);
4746
+ const metaDir = resolveMetaDir(entry.path);
4794
4747
  const meta = await readMetaJson(metaDir);
4795
4748
  const ps = derivePhaseState(meta);
4796
4749
  return {
4797
- path: o.path,
4750
+ path: entry.path,
4798
4751
  owedPhase: getOwedPhase(ps),
4799
- enqueuedAt: o.enqueuedAt,
4752
+ enqueuedAt: entry.enqueuedAt,
4800
4753
  };
4801
4754
  }
4802
4755
  catch {
4803
4756
  return {
4804
- path: o.path,
4757
+ path: entry.path,
4805
4758
  owedPhase: null,
4806
- enqueuedAt: o.enqueuedAt,
4759
+ enqueuedAt: entry.enqueuedAt,
4807
4760
  };
4808
4761
  }
4809
4762
  }));
@@ -4824,21 +4777,6 @@ function registerQueueRoutes(app, deps) {
4824
4777
  catch {
4825
4778
  // If listing fails, automatic stays empty
4826
4779
  }
4827
- // Legacy: pending is the union of overrides + automatic + legacy queue items
4828
- const pendingItems = [
4829
- ...enrichedOverrides.map((o) => ({
4830
- path: o.path,
4831
- owedPhase: o.owedPhase,
4832
- })),
4833
- ...automatic.map((a) => ({
4834
- path: a.path,
4835
- owedPhase: a.owedPhase,
4836
- })),
4837
- ...queue.pending.map((item) => ({
4838
- path: item.path,
4839
- owedPhase: null,
4840
- })),
4841
- ];
4842
4780
  return {
4843
4781
  current: currentPhase
4844
4782
  ? {
@@ -4846,58 +4784,45 @@ function registerQueueRoutes(app, deps) {
4846
4784
  phase: currentPhase.phase,
4847
4785
  startedAt: currentPhase.startedAt,
4848
4786
  }
4849
- : queue.current
4850
- ? {
4851
- path: queue.current.path,
4852
- phase: null,
4853
- startedAt: queue.current.enqueuedAt,
4854
- }
4855
- : null,
4856
- overrides: enrichedOverrides,
4787
+ : null,
4788
+ pending: enrichedPending,
4857
4789
  automatic,
4858
- pending: pendingItems,
4859
- // Legacy state
4860
- state: queue.getState(),
4861
4790
  };
4862
4791
  });
4863
4792
  app.post(getEndpoint('queueClear').path, () => {
4864
- const removed = queue.clearOverrides();
4793
+ const removed = queue.clear();
4865
4794
  return { cleared: removed };
4866
4795
  });
4867
4796
  app.post(getEndpoint('abort').path, async (_request, reply) => {
4868
- // Check 3-layer current first
4869
4797
  const currentPhase = queue.currentPhase;
4870
- const current = currentPhase ?? queue.current;
4871
- if (!current) {
4798
+ if (!currentPhase) {
4872
4799
  return reply.status(200).send({ status: 'idle' });
4873
4800
  }
4874
4801
  // Abort the executor
4875
4802
  deps.executor?.abort();
4876
- const metaDir = resolveMetaDir(current.path);
4877
- const phase = currentPhase?.phase ?? null;
4803
+ const metaDir = resolveMetaDir(currentPhase.path);
4804
+ const { phase } = currentPhase;
4878
4805
  // Transition running phase to failed and write _error to meta.json
4879
- if (phase) {
4880
- try {
4881
- const meta = await readMetaJson(metaDir);
4882
- let ps = derivePhaseState(meta);
4883
- ps = phaseFailed(ps, phase);
4884
- const updated = {
4885
- ...meta,
4886
- _phaseState: ps,
4887
- _error: {
4888
- step: phase,
4889
- code: 'ABORT',
4890
- message: 'Aborted by operator',
4891
- },
4892
- };
4893
- const lockPath = join(metaDir, '.lock');
4894
- const metaJsonPath = join(metaDir, 'meta.json');
4895
- await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
4896
- await copyFile(lockPath, metaJsonPath);
4897
- }
4898
- catch {
4899
- // Best-effort — meta may be unreadable
4900
- }
4806
+ try {
4807
+ const meta = await readMetaJson(metaDir);
4808
+ let ps = derivePhaseState(meta);
4809
+ ps = phaseFailed(ps, phase);
4810
+ const updated = {
4811
+ ...meta,
4812
+ _phaseState: ps,
4813
+ _error: {
4814
+ step: phase,
4815
+ code: 'ABORT',
4816
+ message: 'Aborted by operator',
4817
+ },
4818
+ };
4819
+ const lockPath = join(metaDir, '.lock');
4820
+ const metaJsonPath = join(metaDir, 'meta.json');
4821
+ await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
4822
+ await copyFile(lockPath, metaJsonPath);
4823
+ }
4824
+ catch {
4825
+ // Best-effort — meta may be unreadable
4901
4826
  }
4902
4827
  // Release the lock for the current meta path
4903
4828
  try {
@@ -4906,11 +4831,11 @@ function registerQueueRoutes(app, deps) {
4906
4831
  catch {
4907
4832
  // Lock may already be released
4908
4833
  }
4909
- deps.logger.info({ path: current.path }, 'Synthesis aborted');
4834
+ deps.logger.info({ path: currentPhase.path }, 'Synthesis aborted');
4910
4835
  return {
4911
4836
  status: 'aborted',
4912
- path: current.path,
4913
- ...(phase ? { phase } : {}),
4837
+ path: currentPhase.path,
4838
+ phase,
4914
4839
  };
4915
4840
  });
4916
4841
  }
@@ -4993,9 +4918,9 @@ async function checkWatcher(url) {
4993
4918
  function deriveServiceState(deps) {
4994
4919
  if (deps.shuttingDown)
4995
4920
  return 'stopping';
4996
- if (deps.queue.current || deps.queue.currentPhase)
4921
+ if (deps.queue.currentPhase)
4997
4922
  return 'synthesizing';
4998
- if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
4923
+ if (deps.queue.depth > 0)
4999
4924
  return 'waiting';
5000
4925
  return 'idle';
5001
4926
  }
@@ -5047,7 +4972,7 @@ function registerStatusRoute(app, deps) {
5047
4972
  }
5048
4973
  return {
5049
4974
  serviceState: deriveServiceState(deps),
5050
- currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
4975
+ currentTarget: queue.currentPhase?.path ?? null,
5051
4976
  queue: queue.getState(),
5052
4977
  stats: {
5053
4978
  totalSyntheses: stats.totalSyntheses,
@@ -5135,7 +5060,7 @@ function registerSynthesizeRoute(app, deps) {
5135
5060
  alreadyQueued: false,
5136
5061
  });
5137
5062
  }
5138
- const result = queue.enqueueOverride(targetPath);
5063
+ const result = queue.enqueue(targetPath);
5139
5064
  return reply.code(202).send({
5140
5065
  status: 'queued',
5141
5066
  path: targetPath,
@@ -5166,8 +5091,9 @@ function registerSynthesizeRoute(app, deps) {
5166
5091
  const stalest = winner.node.metaPath;
5167
5092
  const enqueueResult = queue.enqueue(stalest);
5168
5093
  return reply.code(202).send({
5169
- status: 'accepted',
5094
+ status: 'queued',
5170
5095
  path: stalest,
5096
+ owedPhase: winner.owedPhase,
5171
5097
  queuePosition: enqueueResult.position,
5172
5098
  alreadyQueued: enqueueResult.alreadyQueued,
5173
5099
  });
@@ -5316,26 +5242,14 @@ function registerShutdownHandlers(deps) {
5316
5242
  deps.logger.info('Scheduler stopped');
5317
5243
  }
5318
5244
  // 2. Release lock for in-progress synthesis
5319
- const current = deps.queue.current;
5320
- if (current) {
5321
- try {
5322
- releaseLock(current.path);
5323
- deps.logger.info({ path: current.path }, 'Released lock for in-progress synthesis');
5324
- }
5325
- catch {
5326
- deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
5327
- }
5328
- }
5329
- // Release lock for in-progress override synthesis (only when it
5330
- // differs from the legacy current item to avoid double-release)
5331
5245
  const currentPhase = deps.queue.currentPhase;
5332
- if (currentPhase && currentPhase.path !== current?.path) {
5246
+ if (currentPhase) {
5333
5247
  try {
5334
5248
  releaseLock(resolveMetaDir(currentPhase.path));
5335
- deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress override synthesis');
5249
+ deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress synthesis');
5336
5250
  }
5337
5251
  catch {
5338
- deps.logger.warn({ path: currentPhase.path }, 'Failed to release override lock during shutdown');
5252
+ deps.logger.warn({ path: currentPhase.path }, 'Failed to release lock during shutdown');
5339
5253
  }
5340
5254
  }
5341
5255
  // 3. Close server
@@ -5817,12 +5731,6 @@ const metaJsonSchema = z
5817
5731
  _error: metaErrorSchema.optional(),
5818
5732
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
5819
5733
  _disabled: z.boolean().optional(),
5820
- /** Per-entity timeout override for the architect phase (seconds, min 30). */
5821
- _architectTimeout: z.number().int().min(30).optional(),
5822
- /** Per-entity timeout override for the builder phase (seconds, min 30). */
5823
- _builderTimeout: z.number().int().min(30).optional(),
5824
- /** Per-entity timeout override for the critic phase (seconds, min 30). */
5825
- _criticTimeout: z.number().int().min(30).optional(),
5826
5734
  /**
5827
5735
  * SHA-256 hash of ancestor _builder text at last synthesis.
5828
5736
  * Observability only — no invalidation cascade.