@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.
@@ -782,12 +782,6 @@ const autoSeedRuleSchema = z.object({
782
782
  crossRefs: z.array(z.string()).optional(),
783
783
  /** Walk up this many extra parent levels from the matched file's directory. Default 0. */
784
784
  parentDepth: z.number().int().min(0).optional(),
785
- /** Per-category timeout override for the architect phase (seconds, min 30). */
786
- architectTimeout: z.number().int().min(30).optional(),
787
- /** Per-category timeout override for the builder phase (seconds, min 30). */
788
- builderTimeout: z.number().int().min(30).optional(),
789
- /** Per-category timeout override for the critic phase (seconds, min 30). */
790
- criticTimeout: z.number().int().min(30).optional(),
791
785
  });
792
786
  /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
793
787
  const serviceConfigSchema = metaConfigSchema.extend({
@@ -925,7 +919,7 @@ class SpawnTimeoutError extends Error {
925
919
  * @module executor/GatewayExecutor
926
920
  */
927
921
  const DEFAULT_POLL_INTERVAL_MS = 5000;
928
- const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
922
+ const DEFAULT_SAFETY_VALVE_MS = 3_600_000; // 1 hour fallback
929
923
  /**
930
924
  * MetaExecutor that spawns OpenClaw sessions via the gateway's
931
925
  * `/tools/invoke` endpoint.
@@ -956,6 +950,46 @@ class GatewayExecutor {
956
950
  /* best-effort cleanup */
957
951
  }
958
952
  }
953
+ /** Read and clean up the staging output file. Returns content or undefined if absent. */
954
+ readStagingFile(outputPath) {
955
+ if (!existsSync(outputPath))
956
+ return undefined;
957
+ try {
958
+ return readFileSync(outputPath, 'utf8');
959
+ }
960
+ finally {
961
+ this.cleanupOutputFile(outputPath);
962
+ }
963
+ }
964
+ /** Extract plain text from a message content field, skipping ANNOUNCE_SKIP sentinels. */
965
+ static extractMessageText(content) {
966
+ if (!content)
967
+ return undefined;
968
+ const text = typeof content === 'string'
969
+ ? content
970
+ : Array.isArray(content)
971
+ ? content
972
+ .filter((b) => b.type === 'text' && b.text)
973
+ .map((b) => b.text)
974
+ .join('\n')
975
+ : '';
976
+ return text && text.trim() !== 'ANNOUNCE_SKIP' ? text : undefined;
977
+ }
978
+ /**
979
+ * Check history messages for timeout detection.
980
+ *
981
+ * Does NOT determine completion — session completion is authoritative
982
+ * (via sessions_list). History stop reasons can false-positive on
983
+ * sessions_yield artifacts (#200).
984
+ */
985
+ static checkHistoryCompletion(messages) {
986
+ if (messages.length === 0)
987
+ return { timedOut: false };
988
+ const last = messages[messages.length - 1];
989
+ if (last.role !== 'assistant' || !last.stopReason)
990
+ return { timedOut: false };
991
+ return { timedOut: last.stopReason === 'timeout' };
992
+ }
959
993
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
960
994
  async invoke(tool, args, sessionKey) {
961
995
  const headers = {
@@ -982,7 +1016,13 @@ class GatewayExecutor {
982
1016
  }
983
1017
  return data;
984
1018
  }
985
- /** Look up session metadata (tokens, completion status) via sessions_list. */
1019
+ /**
1020
+ * Look up session metadata (tokens, completion status) via sessions_list.
1021
+ *
1022
+ * Detects gateway-side timeout (`status: "timeout"`) and killed sessions
1023
+ * (`status: "killed"`) as completed, with a `timedOut` flag to distinguish
1024
+ * timeout from normal completion.
1025
+ */
986
1026
  async getSessionInfo(sessionKey) {
987
1027
  try {
988
1028
  const result = await this.invoke('sessions_list', {
@@ -998,13 +1038,18 @@ class GatewayExecutor {
998
1038
  // With limit=200 this is reliable; a false positive here only
999
1039
  // means we read the output file slightly early (still correct
1000
1040
  // if the file exists).
1001
- return { completed: true };
1041
+ return { completed: true, timedOut: false };
1002
1042
  }
1003
- const done = match.status === 'completed' || match.status === 'done';
1004
- return { tokens: match.totalTokens, completed: done };
1043
+ const status = match.status;
1044
+ const done = status === 'completed' ||
1045
+ status === 'done' ||
1046
+ status === 'timeout' ||
1047
+ status === 'killed';
1048
+ const timedOut = status === 'timeout';
1049
+ return { tokens: match.totalTokens, completed: done, timedOut };
1005
1050
  }
1006
1051
  catch {
1007
- return { completed: false };
1052
+ return { completed: false, timedOut: false };
1008
1053
  }
1009
1054
  }
1010
1055
  /** Whether this executor has been aborted by the operator. */
@@ -1015,12 +1060,38 @@ class GatewayExecutor {
1015
1060
  abort() {
1016
1061
  this.controller.abort();
1017
1062
  }
1063
+ /**
1064
+ * Query the gateway's configured subagent run timeout.
1065
+ *
1066
+ * Returns the value in milliseconds, or `undefined` if the query fails
1067
+ * or the value is absent/zero (no timeout configured).
1068
+ */
1069
+ async queryGatewayRunTimeout() {
1070
+ try {
1071
+ const result = await this.invoke('session_status', {});
1072
+ const details = (result.result?.details ?? result.result ?? {});
1073
+ const runTimeoutSeconds = details.runTimeoutSeconds ??
1074
+ details.timeout;
1075
+ if (typeof runTimeoutSeconds === 'number' && runTimeoutSeconds > 0) {
1076
+ return runTimeoutSeconds * 1000;
1077
+ }
1078
+ }
1079
+ catch {
1080
+ // Gateway unreachable or field not exposed — fall back to default
1081
+ }
1082
+ return undefined;
1083
+ }
1018
1084
  async spawn(task, options) {
1019
1085
  // Fresh controller for each spawn call
1020
1086
  this.controller = new AbortController();
1021
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
1022
- const timeoutMs = timeoutSeconds * 1000;
1023
- const deadline = Date.now() + timeoutMs;
1087
+ // Safety-valve deadline: gateway's runTimeoutSeconds + 60s buffer,
1088
+ // defaulting to 1 hour if the gateway value is 0/absent/query fails.
1089
+ // This is a circuit breaker, not a timeout mechanism.
1090
+ const gatewayTimeoutMs = await this.queryGatewayRunTimeout();
1091
+ const safetyValveMs = gatewayTimeoutMs
1092
+ ? gatewayTimeoutMs + 60_000
1093
+ : DEFAULT_SAFETY_VALVE_MS;
1094
+ const safetyDeadline = Date.now() + safetyValveMs;
1024
1095
  // Ensure workspace dir exists
1025
1096
  if (!existsSync(this.workspaceDir)) {
1026
1097
  mkdirSync(this.workspaceDir, { recursive: true });
@@ -1042,7 +1113,6 @@ class GatewayExecutor {
1042
1113
  const spawnResult = await this.invoke('sessions_spawn', {
1043
1114
  task: taskWithOutput,
1044
1115
  label,
1045
- runTimeoutSeconds: timeoutSeconds,
1046
1116
  ...(options?.thinking ? { thinking: options.thinking } : {}),
1047
1117
  ...(options?.model ? { model: options.model } : {}),
1048
1118
  });
@@ -1054,9 +1124,18 @@ class GatewayExecutor {
1054
1124
  throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
1055
1125
  JSON.stringify(spawnResult));
1056
1126
  }
1057
- // Step 2: Poll for completion via sessions_history
1127
+ // Step 2: Poll for completion gateway owns the subagent lifecycle.
1128
+ // Loop exits via: (a) completion detection, (b) abort signal,
1129
+ // (c) gateway-side timeout detection, or (d) safety-valve circuit breaker.
1058
1130
  await sleepAsync(3000);
1059
- while (Date.now() < deadline) {
1131
+ while (true) {
1132
+ // Safety-valve circuit breaker
1133
+ if (Date.now() >= safetyDeadline) {
1134
+ this.cleanupOutputFile(outputPath);
1135
+ throw new SpawnTimeoutError('Safety-valve deadline exceeded (' +
1136
+ safetyValveMs.toString() +
1137
+ 'ms) — gateway timeout may be misconfigured', outputPath);
1138
+ }
1060
1139
  // Check for abort before each poll iteration
1061
1140
  if (this.controller.signal.aborted) {
1062
1141
  this.cleanupOutputFile(outputPath);
@@ -1072,63 +1151,51 @@ class GatewayExecutor {
1072
1151
  historyResult.result?.messages ??
1073
1152
  [];
1074
1153
  const msgArray = messages;
1075
- // Check 1: terminal stop reason in history
1076
- let historyDone = false;
1077
- if (msgArray.length > 0) {
1078
- const lastMsg = msgArray[msgArray.length - 1];
1079
- if (lastMsg.role === 'assistant' &&
1080
- lastMsg.stopReason &&
1081
- lastMsg.stopReason !== 'toolUse' &&
1082
- lastMsg.stopReason !== 'error') {
1083
- historyDone = true;
1084
- }
1085
- }
1154
+ // Check 1: history-based timeout detection (stop reason)
1155
+ const { timedOut: historyTimedOut } = GatewayExecutor.checkHistoryCompletion(msgArray);
1086
1156
  // Check 2: session completion status via sessions_list
1157
+ // Gate on session completion only — history stop reasons can
1158
+ // false-positive on sessions_yield artifacts (#200).
1087
1159
  const sessionInfo = await this.getSessionInfo(sessionKey);
1088
- if (historyDone || sessionInfo.completed) {
1160
+ const timedOut = sessionInfo.timedOut || historyTimedOut;
1161
+ if (sessionInfo.completed) {
1089
1162
  const tokens = sessionInfo.tokens;
1090
- // Read output from file (sub-agent wrote it via Write tool)
1091
- if (existsSync(outputPath)) {
1092
- try {
1093
- const output = readFileSync(outputPath, 'utf8');
1163
+ // Gateway-side timeout detected check staging file for recovery
1164
+ if (timedOut) {
1165
+ const output = this.readStagingFile(outputPath);
1166
+ if (output !== undefined)
1094
1167
  return { output, tokens };
1095
- }
1096
- finally {
1097
- try {
1098
- unlinkSync(outputPath);
1099
- }
1100
- catch {
1101
- /* cleanup best-effort */
1102
- }
1103
- }
1168
+ // No output or partial output — throw for _state recovery (§3.16.6)
1169
+ throw new SpawnTimeoutError('Gateway-side timeout detected (session status: timeout)', outputPath);
1104
1170
  }
1171
+ // Normal completion — read output from file
1172
+ const output = this.readStagingFile(outputPath);
1173
+ if (output !== undefined)
1174
+ return { output, tokens };
1105
1175
  // Fallback: extract from message content if file wasn't written.
1106
1176
  // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
1107
1177
  // a preceding assistant message (the file write).
1108
1178
  for (let i = msgArray.length - 1; i >= 0; i--) {
1109
1179
  const msg = msgArray[i];
1110
- if (msg.role === 'assistant' && msg.content) {
1111
- const text = typeof msg.content === 'string'
1112
- ? msg.content
1113
- : Array.isArray(msg.content)
1114
- ? msg.content
1115
- .filter((b) => b.type === 'text' && b.text)
1116
- .map((b) => b.text)
1117
- .join('\n')
1118
- : '';
1119
- if (text && text.trim() !== 'ANNOUNCE_SKIP')
1180
+ if (msg.role === 'assistant') {
1181
+ const text = GatewayExecutor.extractMessageText(msg.content);
1182
+ if (text !== undefined)
1120
1183
  return { output: text, tokens };
1121
1184
  }
1122
1185
  }
1123
1186
  return { output: '', tokens };
1124
1187
  }
1125
1188
  }
1126
- catch {
1189
+ catch (err) {
1190
+ // Re-throw SpawnTimeoutError and SpawnAbortedError — only swallow transient poll failures
1191
+ if (err instanceof SpawnTimeoutError ||
1192
+ err instanceof SpawnAbortedError) {
1193
+ throw err;
1194
+ }
1127
1195
  // Transient poll failure — keep trying
1128
1196
  }
1129
1197
  await sleepAsync(this.pollIntervalMs);
1130
1198
  }
1131
- throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
1132
1199
  }
1133
1200
  }
1134
1201
 
@@ -2626,7 +2693,6 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2626
2693
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
2627
2694
  const result = await executor.spawn(architectTask, {
2628
2695
  thinking: config.thinking,
2629
- timeout: currentMeta._architectTimeout ?? config.architectTimeout,
2630
2696
  label: 'meta-architect',
2631
2697
  });
2632
2698
  const builderBrief = parseArchitectOutput(result.output);
@@ -2678,7 +2744,6 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2678
2744
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
2679
2745
  const result = await executor.spawn(builderTask, {
2680
2746
  thinking: config.thinking,
2681
- timeout: currentMeta._builderTimeout ?? config.builderTimeout,
2682
2747
  label: 'meta-builder',
2683
2748
  });
2684
2749
  const rawOutput = result.output;
@@ -2773,7 +2838,6 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2773
2838
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
2774
2839
  const result = await executor.spawn(criticTask, {
2775
2840
  thinking: config.thinking,
2776
- timeout: currentMeta._criticTimeout ?? config.criticTimeout,
2777
2841
  label: 'meta-critic',
2778
2842
  });
2779
2843
  const feedback = parseCriticOutput(result.output);
@@ -3109,36 +3173,33 @@ class ProgressReporter {
3109
3173
  }
3110
3174
 
3111
3175
  /**
3112
- * Hybrid 3-layer synthesis queue.
3176
+ * Synthesis queue.
3113
3177
  *
3114
3178
  * Layer 1: Current — the single item currently executing (at most one).
3115
- * Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
3116
- * FIFO among overrides, ahead of automatic candidates.
3117
- * Layer 3: Automatic — computed on read, not persisted. All metas with a
3118
- * pending phase, ranked by scheduler priority.
3119
- *
3120
- * Legacy: `pending` array is the union of overrides + automatic.
3179
+ * Layer 2: Pending — items enqueued via POST /synthesize (targeted) or
3180
+ * scheduler tick (automatic). FIFO, ahead of automatic candidates.
3181
+ * Layer 3: Automatic — computed on read (GET /queue), not persisted. All
3182
+ * metas with a pending phase, ranked by scheduler priority.
3121
3183
  *
3122
3184
  * @module queue
3123
3185
  */
3124
3186
  const DEPTH_WARNING_THRESHOLD = 3;
3187
+ /** Strip trailing .meta suffix for consistent path comparison. */
3188
+ function normQueuePath(p) {
3189
+ return p.endsWith('.meta') ? p.slice(0, -5).replace(/[/\\]$/, '') : p;
3190
+ }
3125
3191
  /**
3126
- * Hybrid 3-layer synthesis queue.
3192
+ * Synthesis queue.
3127
3193
  *
3128
- * Only one synthesis runs at a time. Override items (explicit triggers)
3129
- * take priority over automatic candidates.
3194
+ * Only one synthesis runs at a time. Explicitly enqueued items
3195
+ * take priority over automatic (computed-on-read) candidates.
3130
3196
  */
3131
3197
  class SynthesisQueue {
3132
- /** Legacy queue (used by processQueue for backward compat). */
3133
- queue = [];
3134
- currentItem = null;
3198
+ entries = [];
3199
+ currentPhaseItem = null;
3135
3200
  processing = false;
3136
3201
  logger;
3137
3202
  onEnqueueCallback = null;
3138
- /** Explicit override entries (3-layer model). */
3139
- overrideEntries = [];
3140
- /** Currently executing item with phase info (3-layer model). */
3141
- currentPhaseItem = null;
3142
3203
  constructor(logger) {
3143
3204
  this.logger = logger;
3144
3205
  }
@@ -3146,52 +3207,67 @@ class SynthesisQueue {
3146
3207
  onEnqueue(callback) {
3147
3208
  this.onEnqueueCallback = callback;
3148
3209
  }
3149
- // ── Override layer (3-layer model) ─────────────────────────────────
3210
+ // ── Enqueue / dequeue ──────────────────────────────────────────────
3150
3211
  /**
3151
- * Add an explicit override entry (from POST /synthesize with path).
3212
+ * Add an entry to the queue.
3152
3213
  * Deduped by path. Returns position and whether already queued.
3153
3214
  */
3154
- enqueueOverride(path) {
3215
+ enqueue(path) {
3216
+ const norm = normQueuePath(path);
3155
3217
  // Check if currently executing
3156
- if (this.currentPhaseItem?.path === path ||
3157
- this.currentItem?.path === path) {
3218
+ if (this.currentPhaseItem &&
3219
+ normQueuePath(this.currentPhaseItem.path) === norm) {
3158
3220
  return { position: 0, alreadyQueued: true };
3159
3221
  }
3160
- // Check if already in overrides
3161
- const existing = this.overrideEntries.findIndex((e) => e.path === path);
3222
+ // Check if already in queue
3223
+ const existing = this.entries.findIndex((e) => normQueuePath(e.path) === norm);
3162
3224
  if (existing !== -1) {
3163
3225
  return { position: existing, alreadyQueued: true };
3164
3226
  }
3165
- this.overrideEntries.push({
3227
+ this.entries.push({
3166
3228
  path,
3167
3229
  enqueuedAt: new Date().toISOString(),
3168
3230
  });
3169
- const position = this.overrideEntries.length - 1;
3170
- if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
3171
- this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
3231
+ const position = this.entries.length - 1;
3232
+ if (this.entries.length > DEPTH_WARNING_THRESHOLD) {
3233
+ this.logger.warn({ depth: this.entries.length }, 'Queue depth exceeds threshold');
3172
3234
  }
3173
3235
  this.onEnqueueCallback?.();
3174
3236
  return { position, alreadyQueued: false };
3175
3237
  }
3176
- /** Dequeue the next override entry, or undefined if empty. */
3177
- dequeueOverride() {
3178
- return this.overrideEntries.shift();
3238
+ /** Dequeue the next entry, or undefined if empty. */
3239
+ dequeue() {
3240
+ return this.entries.shift();
3179
3241
  }
3180
- /** Get all override entries (shallow copy). */
3181
- get overrides() {
3182
- return [...this.overrideEntries];
3242
+ /** Get all queued entries (shallow copy). */
3243
+ get items() {
3244
+ return [...this.entries];
3183
3245
  }
3184
- /** Clear all override entries. Returns count removed. */
3185
- clearOverrides() {
3186
- const count = this.overrideEntries.length;
3187
- this.overrideEntries = [];
3246
+ /** Number of items waiting in the queue (excludes current). */
3247
+ get depth() {
3248
+ return this.entries.length;
3249
+ }
3250
+ /**
3251
+ * Remove all pending items from the queue.
3252
+ * Does not affect the currently-running item.
3253
+ *
3254
+ * @returns The number of items removed.
3255
+ */
3256
+ clear() {
3257
+ const count = this.entries.length;
3258
+ this.entries = [];
3188
3259
  return count;
3189
3260
  }
3190
- /** Check if a path is in the override layer. */
3191
- hasOverride(path) {
3192
- return this.overrideEntries.some((e) => e.path === path);
3261
+ /** Check whether a path is in the queue or currently being synthesized. */
3262
+ has(path) {
3263
+ const norm = normQueuePath(path);
3264
+ if (this.currentPhaseItem &&
3265
+ normQueuePath(this.currentPhaseItem.path) === norm) {
3266
+ return true;
3267
+ }
3268
+ return this.entries.some((e) => normQueuePath(e.path) === norm);
3193
3269
  }
3194
- // ── Current-item tracking (3-layer model) ──────────────────────────
3270
+ // ── Current-item tracking ──────────────────────────────────────────
3195
3271
  /** Set the currently executing phase item. */
3196
3272
  setCurrentPhase(path, phase) {
3197
3273
  this.currentPhaseItem = {
@@ -3208,129 +3284,21 @@ class SynthesisQueue {
3208
3284
  get currentPhase() {
3209
3285
  return this.currentPhaseItem;
3210
3286
  }
3211
- // ── Legacy queue interface (preserved for backward compat) ─────────
3212
- /**
3213
- * Add a path to the synthesis queue.
3214
- *
3215
- * @param path - Meta path to synthesize.
3216
- * @param priority - If true, insert at front of queue.
3217
- * @returns Position and whether the path was already queued.
3218
- */
3219
- enqueue(path, priority = false) {
3220
- if (this.currentItem?.path === path) {
3221
- return { position: 0, alreadyQueued: true };
3222
- }
3223
- const existingIndex = this.queue.findIndex((item) => item.path === path);
3224
- if (existingIndex !== -1) {
3225
- return { position: existingIndex, alreadyQueued: true };
3226
- }
3227
- const item = {
3228
- path,
3229
- priority,
3230
- enqueuedAt: new Date().toISOString(),
3231
- };
3232
- if (priority) {
3233
- this.queue.unshift(item);
3234
- }
3235
- else {
3236
- this.queue.push(item);
3237
- }
3238
- if (this.queue.length > DEPTH_WARNING_THRESHOLD) {
3239
- this.logger.warn({ depth: this.queue.length }, 'Queue depth exceeds threshold');
3240
- }
3241
- const position = this.queue.findIndex((i) => i.path === path);
3242
- this.onEnqueueCallback?.();
3243
- return { position, alreadyQueued: false };
3244
- }
3245
- /** Remove and return the next item from the queue. */
3246
- dequeue() {
3247
- const item = this.queue.shift();
3248
- if (item) {
3249
- this.currentItem = item;
3250
- }
3251
- return item;
3252
- }
3253
- /** Mark the currently-running synthesis as complete. */
3254
- complete() {
3255
- this.currentItem = null;
3256
- }
3257
- /** Number of items waiting in the queue (excludes current). */
3258
- get depth() {
3259
- return this.queue.length;
3260
- }
3261
- /** The item currently being synthesized, or null. */
3262
- get current() {
3263
- return this.currentItem;
3264
- }
3265
- /** A shallow copy of the queued items. */
3266
- get items() {
3267
- return [...this.queue];
3268
- }
3269
- /** A shallow copy of the pending items (alias for items). */
3270
- get pending() {
3271
- return [...this.queue];
3272
- }
3273
- /**
3274
- * Remove all pending items from the queue.
3275
- * Does not affect the currently-running item.
3276
- *
3277
- * @returns The number of items removed.
3278
- */
3279
- clear() {
3280
- const count = this.queue.length;
3281
- this.queue = [];
3282
- return count;
3283
- }
3284
- /** Check whether a path is in the queue or currently being synthesized. */
3285
- has(path) {
3286
- if (this.currentItem?.path === path)
3287
- return true;
3288
- if (this.currentPhaseItem?.path === path)
3289
- return true;
3290
- return (this.queue.some((item) => item.path === path) ||
3291
- this.overrideEntries.some((e) => e.path === path));
3292
- }
3293
- /** Get the 0-indexed position of a path in the queue. */
3294
- getPosition(path) {
3295
- // Check overrides first
3296
- const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
3297
- if (overrideIdx !== -1)
3298
- return overrideIdx;
3299
- const index = this.queue.findIndex((item) => item.path === path);
3300
- return index === -1 ? null : index;
3301
- }
3302
- /** Dequeue the next item: overrides first, then legacy queue. */
3303
- nextItem() {
3304
- const override = this.dequeueOverride();
3305
- if (override)
3306
- return { path: override.path, source: 'override' };
3307
- const item = this.dequeue();
3308
- if (item)
3309
- return { path: item.path, source: 'legacy' };
3310
- return undefined;
3311
- }
3287
+ // ── Queue state ────────────────────────────────────────────────────
3312
3288
  /** Return a snapshot of queue state for the /status endpoint. */
3313
3289
  getState() {
3314
3290
  return {
3315
- depth: this.queue.length + this.overrideEntries.length,
3316
- items: [
3317
- ...this.overrideEntries.map((e) => ({
3318
- path: e.path,
3319
- priority: true,
3320
- enqueuedAt: e.enqueuedAt,
3321
- })),
3322
- ...this.queue.map((item) => ({
3323
- path: item.path,
3324
- priority: item.priority,
3325
- enqueuedAt: item.enqueuedAt,
3326
- })),
3327
- ],
3291
+ depth: this.entries.length,
3292
+ items: this.entries.map((e) => ({
3293
+ path: e.path,
3294
+ enqueuedAt: e.enqueuedAt,
3295
+ })),
3328
3296
  };
3329
3297
  }
3298
+ // ── Processing ─────────────────────────────────────────────────────
3330
3299
  /**
3331
- * Process queued items one at a time until all queues are empty.
3300
+ * Process queued items one at a time until the queue is empty.
3332
3301
  *
3333
- * Override entries are processed first (FIFO), then legacy queue items.
3334
3302
  * Re-entry is prevented: if already processing, the call returns
3335
3303
  * immediately. Errors are logged and do not block subsequent items.
3336
3304
  *
@@ -3341,7 +3309,7 @@ class SynthesisQueue {
3341
3309
  return;
3342
3310
  this.processing = true;
3343
3311
  try {
3344
- let next = this.nextItem();
3312
+ let next = this.dequeue();
3345
3313
  while (next) {
3346
3314
  try {
3347
3315
  await synthesizeFn(next.path);
@@ -3350,9 +3318,7 @@ class SynthesisQueue {
3350
3318
  this.logger.error({ path: next.path, err }, 'Synthesis failed');
3351
3319
  }
3352
3320
  this.clearCurrentPhase();
3353
- if (next.source === 'legacy')
3354
- this.complete();
3355
- next = this.nextItem();
3321
+ next = this.dequeue();
3356
3322
  }
3357
3323
  }
3358
3324
  finally {
@@ -3686,12 +3652,6 @@ async function createMeta(ownerPath, options) {
3686
3652
  metaJson._crossRefs = options.crossRefs;
3687
3653
  if (options?.steer !== undefined)
3688
3654
  metaJson._steer = options.steer;
3689
- if (options?.architectTimeout !== undefined)
3690
- metaJson._architectTimeout = options.architectTimeout;
3691
- if (options?.builderTimeout !== undefined)
3692
- metaJson._builderTimeout = options.builderTimeout;
3693
- if (options?.criticTimeout !== undefined)
3694
- metaJson._criticTimeout = options.criticTimeout;
3695
3655
  const metaJsonPath = join(metaDir, 'meta.json');
3696
3656
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
3697
3657
  return { metaDir, _id };
@@ -3767,9 +3727,6 @@ async function autoSeedPass(rules, watcher, logger) {
3767
3727
  candidates.set(dir, {
3768
3728
  steer: rule.steer,
3769
3729
  crossRefs: rule.crossRefs,
3770
- architectTimeout: rule.architectTimeout,
3771
- builderTimeout: rule.builderTimeout,
3772
- criticTimeout: rule.criticTimeout,
3773
3730
  });
3774
3731
  }
3775
3732
  }
@@ -3915,7 +3872,6 @@ class Scheduler {
3915
3872
  this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
3916
3873
  return;
3917
3874
  }
3918
- // Enqueue using the legacy queue path (backward compat with processQueue)
3919
3875
  this.queue.enqueue(candidate.path);
3920
3876
  this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
3921
3877
  // Opportunistic watcher restart detection
@@ -4339,7 +4295,7 @@ function registerMetasRoutes(app, deps) {
4339
4295
  /**
4340
4296
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
4341
4297
  *
4342
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4298
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
4343
4299
  * Set a field to null to remove it. Unknown keys are rejected.
4344
4300
  *
4345
4301
  * @module routes/metasUpdate
@@ -4351,9 +4307,6 @@ const updateBodySchema = z
4351
4307
  _depth: z.union([z.number(), z.null()]).optional(),
4352
4308
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
4353
4309
  _disabled: z.union([z.boolean(), z.null()]).optional(),
4354
- _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4355
- _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4356
- _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4357
4310
  })
4358
4311
  .strict();
4359
4312
  function registerMetasUpdateRoute(app, deps) {
@@ -4517,8 +4470,8 @@ function registerPreviewRoute(app, deps) {
4517
4470
  /**
4518
4471
  * Queue management and abort routes.
4519
4472
  *
4520
- * - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
4521
- * - POST /queue/clear — remove override entries only
4473
+ * - GET /queue — 3-layer queue model (current, pending, automatic)
4474
+ * - POST /queue/clear — remove pending entries
4522
4475
  * - POST /synthesize/abort — abort the current synthesis
4523
4476
  *
4524
4477
  * @module routes/queue
@@ -4528,24 +4481,24 @@ function registerQueueRoutes(app, deps) {
4528
4481
  const { queue } = deps;
4529
4482
  app.get(getEndpoint('queue').path, async () => {
4530
4483
  const currentPhase = queue.currentPhase;
4531
- const overrides = queue.overrides;
4532
- // Compute owedPhase for each override entry by reading meta state
4533
- const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
4484
+ const pending = queue.items;
4485
+ // Compute owedPhase for each pending entry by reading meta state
4486
+ const enrichedPending = await Promise.all(pending.map(async (entry) => {
4534
4487
  try {
4535
- const metaDir = resolveMetaDir(o.path);
4488
+ const metaDir = resolveMetaDir(entry.path);
4536
4489
  const meta = await readMetaJson(metaDir);
4537
4490
  const ps = derivePhaseState(meta);
4538
4491
  return {
4539
- path: o.path,
4492
+ path: entry.path,
4540
4493
  owedPhase: getOwedPhase(ps),
4541
- enqueuedAt: o.enqueuedAt,
4494
+ enqueuedAt: entry.enqueuedAt,
4542
4495
  };
4543
4496
  }
4544
4497
  catch {
4545
4498
  return {
4546
- path: o.path,
4499
+ path: entry.path,
4547
4500
  owedPhase: null,
4548
- enqueuedAt: o.enqueuedAt,
4501
+ enqueuedAt: entry.enqueuedAt,
4549
4502
  };
4550
4503
  }
4551
4504
  }));
@@ -4566,21 +4519,6 @@ function registerQueueRoutes(app, deps) {
4566
4519
  catch {
4567
4520
  // If listing fails, automatic stays empty
4568
4521
  }
4569
- // Legacy: pending is the union of overrides + automatic + legacy queue items
4570
- const pendingItems = [
4571
- ...enrichedOverrides.map((o) => ({
4572
- path: o.path,
4573
- owedPhase: o.owedPhase,
4574
- })),
4575
- ...automatic.map((a) => ({
4576
- path: a.path,
4577
- owedPhase: a.owedPhase,
4578
- })),
4579
- ...queue.pending.map((item) => ({
4580
- path: item.path,
4581
- owedPhase: null,
4582
- })),
4583
- ];
4584
4522
  return {
4585
4523
  current: currentPhase
4586
4524
  ? {
@@ -4588,58 +4526,45 @@ function registerQueueRoutes(app, deps) {
4588
4526
  phase: currentPhase.phase,
4589
4527
  startedAt: currentPhase.startedAt,
4590
4528
  }
4591
- : queue.current
4592
- ? {
4593
- path: queue.current.path,
4594
- phase: null,
4595
- startedAt: queue.current.enqueuedAt,
4596
- }
4597
- : null,
4598
- overrides: enrichedOverrides,
4529
+ : null,
4530
+ pending: enrichedPending,
4599
4531
  automatic,
4600
- pending: pendingItems,
4601
- // Legacy state
4602
- state: queue.getState(),
4603
4532
  };
4604
4533
  });
4605
4534
  app.post(getEndpoint('queueClear').path, () => {
4606
- const removed = queue.clearOverrides();
4535
+ const removed = queue.clear();
4607
4536
  return { cleared: removed };
4608
4537
  });
4609
4538
  app.post(getEndpoint('abort').path, async (_request, reply) => {
4610
- // Check 3-layer current first
4611
4539
  const currentPhase = queue.currentPhase;
4612
- const current = currentPhase ?? queue.current;
4613
- if (!current) {
4540
+ if (!currentPhase) {
4614
4541
  return reply.status(200).send({ status: 'idle' });
4615
4542
  }
4616
4543
  // Abort the executor
4617
4544
  deps.executor?.abort();
4618
- const metaDir = resolveMetaDir(current.path);
4619
- const phase = currentPhase?.phase ?? null;
4545
+ const metaDir = resolveMetaDir(currentPhase.path);
4546
+ const { phase } = currentPhase;
4620
4547
  // Transition running phase to failed and write _error to meta.json
4621
- if (phase) {
4622
- try {
4623
- const meta = await readMetaJson(metaDir);
4624
- let ps = derivePhaseState(meta);
4625
- ps = phaseFailed(ps, phase);
4626
- const updated = {
4627
- ...meta,
4628
- _phaseState: ps,
4629
- _error: {
4630
- step: phase,
4631
- code: 'ABORT',
4632
- message: 'Aborted by operator',
4633
- },
4634
- };
4635
- const lockPath = join(metaDir, '.lock');
4636
- const metaJsonPath = join(metaDir, 'meta.json');
4637
- await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
4638
- await copyFile(lockPath, metaJsonPath);
4639
- }
4640
- catch {
4641
- // Best-effort — meta may be unreadable
4642
- }
4548
+ try {
4549
+ const meta = await readMetaJson(metaDir);
4550
+ let ps = derivePhaseState(meta);
4551
+ ps = phaseFailed(ps, phase);
4552
+ const updated = {
4553
+ ...meta,
4554
+ _phaseState: ps,
4555
+ _error: {
4556
+ step: phase,
4557
+ code: 'ABORT',
4558
+ message: 'Aborted by operator',
4559
+ },
4560
+ };
4561
+ const lockPath = join(metaDir, '.lock');
4562
+ const metaJsonPath = join(metaDir, 'meta.json');
4563
+ await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
4564
+ await copyFile(lockPath, metaJsonPath);
4565
+ }
4566
+ catch {
4567
+ // Best-effort — meta may be unreadable
4643
4568
  }
4644
4569
  // Release the lock for the current meta path
4645
4570
  try {
@@ -4648,11 +4573,11 @@ function registerQueueRoutes(app, deps) {
4648
4573
  catch {
4649
4574
  // Lock may already be released
4650
4575
  }
4651
- deps.logger.info({ path: current.path }, 'Synthesis aborted');
4576
+ deps.logger.info({ path: currentPhase.path }, 'Synthesis aborted');
4652
4577
  return {
4653
4578
  status: 'aborted',
4654
- path: current.path,
4655
- ...(phase ? { phase } : {}),
4579
+ path: currentPhase.path,
4580
+ phase,
4656
4581
  };
4657
4582
  });
4658
4583
  }
@@ -4763,9 +4688,9 @@ async function checkWatcher(url) {
4763
4688
  function deriveServiceState(deps) {
4764
4689
  if (deps.shuttingDown)
4765
4690
  return 'stopping';
4766
- if (deps.queue.current || deps.queue.currentPhase)
4691
+ if (deps.queue.currentPhase)
4767
4692
  return 'synthesizing';
4768
- if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
4693
+ if (deps.queue.depth > 0)
4769
4694
  return 'waiting';
4770
4695
  return 'idle';
4771
4696
  }
@@ -4817,7 +4742,7 @@ function registerStatusRoute(app, deps) {
4817
4742
  }
4818
4743
  return {
4819
4744
  serviceState: deriveServiceState(deps),
4820
- currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
4745
+ currentTarget: queue.currentPhase?.path ?? null,
4821
4746
  queue: queue.getState(),
4822
4747
  stats: {
4823
4748
  totalSyntheses: stats.totalSyntheses,
@@ -4905,7 +4830,7 @@ function registerSynthesizeRoute(app, deps) {
4905
4830
  alreadyQueued: false,
4906
4831
  });
4907
4832
  }
4908
- const result = queue.enqueueOverride(targetPath);
4833
+ const result = queue.enqueue(targetPath);
4909
4834
  return reply.code(202).send({
4910
4835
  status: 'queued',
4911
4836
  path: targetPath,
@@ -4936,8 +4861,9 @@ function registerSynthesizeRoute(app, deps) {
4936
4861
  const stalest = winner.node.metaPath;
4937
4862
  const enqueueResult = queue.enqueue(stalest);
4938
4863
  return reply.code(202).send({
4939
- status: 'accepted',
4864
+ status: 'queued',
4940
4865
  path: stalest,
4866
+ owedPhase: winner.owedPhase,
4941
4867
  queuePosition: enqueueResult.position,
4942
4868
  alreadyQueued: enqueueResult.alreadyQueued,
4943
4869
  });
@@ -5086,26 +5012,14 @@ function registerShutdownHandlers(deps) {
5086
5012
  deps.logger.info('Scheduler stopped');
5087
5013
  }
5088
5014
  // 2. Release lock for in-progress synthesis
5089
- const current = deps.queue.current;
5090
- if (current) {
5091
- try {
5092
- releaseLock(current.path);
5093
- deps.logger.info({ path: current.path }, 'Released lock for in-progress synthesis');
5094
- }
5095
- catch {
5096
- deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
5097
- }
5098
- }
5099
- // Release lock for in-progress override synthesis (only when it
5100
- // differs from the legacy current item to avoid double-release)
5101
5015
  const currentPhase = deps.queue.currentPhase;
5102
- if (currentPhase && currentPhase.path !== current?.path) {
5016
+ if (currentPhase) {
5103
5017
  try {
5104
5018
  releaseLock(resolveMetaDir(currentPhase.path));
5105
- deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress override synthesis');
5019
+ deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress synthesis');
5106
5020
  }
5107
5021
  catch {
5108
- deps.logger.warn({ path: currentPhase.path }, 'Failed to release override lock during shutdown');
5022
+ deps.logger.warn({ path: currentPhase.path }, 'Failed to release lock during shutdown');
5109
5023
  }
5110
5024
  }
5111
5025
  // 3. Close server