@karmaniverous/jeeves-meta 0.15.11 → 0.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/README.md CHANGED
@@ -66,7 +66,7 @@ jeeves-meta service install --config /path/to/jeeves-meta/config.json
66
66
  | POST | `/config/apply` | Apply a config patch (merge or replace) |
67
67
  | GET | `/queue` | Queue state: current (with phase), overrides, automatic, pending |
68
68
  | POST | `/queue/clear` | Remove all override queue entries |
69
- | PATCH | `/metas/:path` | Update user-settable reserved properties (`_steer`, `_emphasis`, `_depth`, `_crossRefs`, `_disabled`, `_architectTimeout`, `_builderTimeout`, `_criticTimeout`) |
69
+ | PATCH | `/metas/:path` | Update user-settable reserved properties (`_steer`, `_emphasis`, `_depth`, `_crossRefs`, `_disabled`) |
70
70
 
71
71
  ## Configuration
72
72
 
@@ -4,6 +4,17 @@
4
4
  * @module bootstrap
5
5
  */
6
6
  import { type ServiceConfig } from './schema/config.js';
7
+ /**
8
+ * Compute per-cycle token total from a completed meta.
9
+ *
10
+ * Exported for testing.
11
+ *
12
+ * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
13
+ * a value of 1 means architect ran this cycle (was 0 pre-increment),
14
+ * so all three phase token fields are summed. A value \> 1 means architect
15
+ * was skipped (cached brief reused), so only builder + critic are summed.
16
+ */
17
+ export declare function computeCycleTokens(meta: Record<string, unknown>): number;
7
18
  /**
8
19
  * Bootstrap the service: create logger, build server, start listening,
9
20
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -128,8 +128,6 @@ The Builder should:
128
128
  4. Merge new findings with previous `_content` (carried in context)
129
129
 
130
130
  If the scope is small enough to process in one pass, omit chunking instructions.
131
- The Builder has a timeout of \{{config.builderTimeout}} seconds.
132
-
133
131
  ### 8. Output Structure
134
132
 
135
133
  Define non-underscore fields for structured data and the _content narrative
@@ -148,7 +146,6 @@ Quote the specific issue and state what to do differently.
148
146
  Your task brief will be compiled as a Handlebars template before the Builder
149
147
  receives it. You can use these variables to write adaptive instructions:
150
148
 
151
- - `\{{config.builderTimeout}}` — Builder timeout in seconds
152
149
  - `\{{config.maxLines}}` — Maximum _content lines
153
150
  - `\{{config.architectEvery}}` — Cycles between architect refreshes
154
151
  - `\{{config.maxArchive}}` — Archive snapshots retained
@@ -159,7 +156,7 @@ receives it. You can use these variables to write adaptive instructions:
159
156
  - `\{{meta._depth}}` — Scheduling depth
160
157
  - `\{{meta._emphasis}}` — Scheduling emphasis
161
158
 
162
- Example: "Process files in chunks of 50. You have \{{config.builderTimeout}} seconds."
159
+ Example: "Process files in chunks of 50. Limit output to \{{config.maxLines}} lines."
163
160
 
164
161
  ## Constraints
165
162
 
@@ -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,42 @@ 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
+ /** Check history messages for terminal completion. */
979
+ static checkHistoryCompletion(messages) {
980
+ if (messages.length === 0)
981
+ return { done: false, timedOut: false };
982
+ const last = messages[messages.length - 1];
983
+ if (last.role !== 'assistant' || !last.stopReason)
984
+ return { done: false, timedOut: false };
985
+ if (last.stopReason === 'toolUse' || last.stopReason === 'error')
986
+ return { done: false, timedOut: false };
987
+ return { done: true, timedOut: last.stopReason === 'timeout' };
988
+ }
959
989
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
960
990
  async invoke(tool, args, sessionKey) {
961
991
  const headers = {
@@ -982,7 +1012,13 @@ class GatewayExecutor {
982
1012
  }
983
1013
  return data;
984
1014
  }
985
- /** Look up session metadata (tokens, completion status) via sessions_list. */
1015
+ /**
1016
+ * Look up session metadata (tokens, completion status) via sessions_list.
1017
+ *
1018
+ * Detects gateway-side timeout (`status: "timeout"`) and killed sessions
1019
+ * (`status: "killed"`) as completed, with a `timedOut` flag to distinguish
1020
+ * timeout from normal completion.
1021
+ */
986
1022
  async getSessionInfo(sessionKey) {
987
1023
  try {
988
1024
  const result = await this.invoke('sessions_list', {
@@ -998,13 +1034,18 @@ class GatewayExecutor {
998
1034
  // With limit=200 this is reliable; a false positive here only
999
1035
  // means we read the output file slightly early (still correct
1000
1036
  // if the file exists).
1001
- return { completed: true };
1037
+ return { completed: true, timedOut: false };
1002
1038
  }
1003
- const done = match.status === 'completed' || match.status === 'done';
1004
- return { tokens: match.totalTokens, completed: done };
1039
+ const status = match.status;
1040
+ const done = status === 'completed' ||
1041
+ status === 'done' ||
1042
+ status === 'timeout' ||
1043
+ status === 'killed';
1044
+ const timedOut = status === 'timeout';
1045
+ return { tokens: match.totalTokens, completed: done, timedOut };
1005
1046
  }
1006
1047
  catch {
1007
- return { completed: false };
1048
+ return { completed: false, timedOut: false };
1008
1049
  }
1009
1050
  }
1010
1051
  /** Whether this executor has been aborted by the operator. */
@@ -1015,12 +1056,38 @@ class GatewayExecutor {
1015
1056
  abort() {
1016
1057
  this.controller.abort();
1017
1058
  }
1059
+ /**
1060
+ * Query the gateway's configured subagent run timeout.
1061
+ *
1062
+ * Returns the value in milliseconds, or `undefined` if the query fails
1063
+ * or the value is absent/zero (no timeout configured).
1064
+ */
1065
+ async queryGatewayRunTimeout() {
1066
+ try {
1067
+ const result = await this.invoke('session_status', {});
1068
+ const details = (result.result?.details ?? result.result ?? {});
1069
+ const runTimeoutSeconds = details.runTimeoutSeconds ??
1070
+ details.timeout;
1071
+ if (typeof runTimeoutSeconds === 'number' && runTimeoutSeconds > 0) {
1072
+ return runTimeoutSeconds * 1000;
1073
+ }
1074
+ }
1075
+ catch {
1076
+ // Gateway unreachable or field not exposed — fall back to default
1077
+ }
1078
+ return undefined;
1079
+ }
1018
1080
  async spawn(task, options) {
1019
1081
  // Fresh controller for each spawn call
1020
1082
  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;
1083
+ // Safety-valve deadline: gateway's runTimeoutSeconds + 60s buffer,
1084
+ // defaulting to 1 hour if the gateway value is 0/absent/query fails.
1085
+ // This is a circuit breaker, not a timeout mechanism.
1086
+ const gatewayTimeoutMs = await this.queryGatewayRunTimeout();
1087
+ const safetyValveMs = gatewayTimeoutMs
1088
+ ? gatewayTimeoutMs + 60_000
1089
+ : DEFAULT_SAFETY_VALVE_MS;
1090
+ const safetyDeadline = Date.now() + safetyValveMs;
1024
1091
  // Ensure workspace dir exists
1025
1092
  if (!existsSync(this.workspaceDir)) {
1026
1093
  mkdirSync(this.workspaceDir, { recursive: true });
@@ -1042,7 +1109,6 @@ class GatewayExecutor {
1042
1109
  const spawnResult = await this.invoke('sessions_spawn', {
1043
1110
  task: taskWithOutput,
1044
1111
  label,
1045
- runTimeoutSeconds: timeoutSeconds,
1046
1112
  ...(options?.thinking ? { thinking: options.thinking } : {}),
1047
1113
  ...(options?.model ? { model: options.model } : {}),
1048
1114
  });
@@ -1054,9 +1120,18 @@ class GatewayExecutor {
1054
1120
  throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
1055
1121
  JSON.stringify(spawnResult));
1056
1122
  }
1057
- // Step 2: Poll for completion via sessions_history
1123
+ // Step 2: Poll for completion gateway owns the subagent lifecycle.
1124
+ // Loop exits via: (a) completion detection, (b) abort signal,
1125
+ // (c) gateway-side timeout detection, or (d) safety-valve circuit breaker.
1058
1126
  await sleepAsync(3000);
1059
- while (Date.now() < deadline) {
1127
+ while (true) {
1128
+ // Safety-valve circuit breaker
1129
+ if (Date.now() >= safetyDeadline) {
1130
+ this.cleanupOutputFile(outputPath);
1131
+ throw new SpawnTimeoutError('Safety-valve deadline exceeded (' +
1132
+ safetyValveMs.toString() +
1133
+ 'ms) — gateway timeout may be misconfigured', outputPath);
1134
+ }
1060
1135
  // Check for abort before each poll iteration
1061
1136
  if (this.controller.signal.aborted) {
1062
1137
  this.cleanupOutputFile(outputPath);
@@ -1073,62 +1148,48 @@ class GatewayExecutor {
1073
1148
  [];
1074
1149
  const msgArray = messages;
1075
1150
  // 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
- }
1151
+ const { done: historyDone, timedOut: historyTimedOut } = GatewayExecutor.checkHistoryCompletion(msgArray);
1086
1152
  // Check 2: session completion status via sessions_list
1087
1153
  const sessionInfo = await this.getSessionInfo(sessionKey);
1154
+ const timedOut = sessionInfo.timedOut || historyTimedOut;
1088
1155
  if (historyDone || sessionInfo.completed) {
1089
1156
  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');
1157
+ // Gateway-side timeout detected check staging file for recovery
1158
+ if (timedOut) {
1159
+ const output = this.readStagingFile(outputPath);
1160
+ if (output !== undefined)
1094
1161
  return { output, tokens };
1095
- }
1096
- finally {
1097
- try {
1098
- unlinkSync(outputPath);
1099
- }
1100
- catch {
1101
- /* cleanup best-effort */
1102
- }
1103
- }
1162
+ // No output or partial output — throw for _state recovery (§3.16.6)
1163
+ throw new SpawnTimeoutError('Gateway-side timeout detected (session status: timeout)', outputPath);
1104
1164
  }
1165
+ // Normal completion — read output from file
1166
+ const output = this.readStagingFile(outputPath);
1167
+ if (output !== undefined)
1168
+ return { output, tokens };
1105
1169
  // Fallback: extract from message content if file wasn't written.
1106
1170
  // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
1107
1171
  // a preceding assistant message (the file write).
1108
1172
  for (let i = msgArray.length - 1; i >= 0; i--) {
1109
1173
  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')
1174
+ if (msg.role === 'assistant') {
1175
+ const text = GatewayExecutor.extractMessageText(msg.content);
1176
+ if (text !== undefined)
1120
1177
  return { output: text, tokens };
1121
1178
  }
1122
1179
  }
1123
1180
  return { output: '', tokens };
1124
1181
  }
1125
1182
  }
1126
- catch {
1183
+ catch (err) {
1184
+ // Re-throw SpawnTimeoutError and SpawnAbortedError — only swallow transient poll failures
1185
+ if (err instanceof SpawnTimeoutError ||
1186
+ err instanceof SpawnAbortedError) {
1187
+ throw err;
1188
+ }
1127
1189
  // Transient poll failure — keep trying
1128
1190
  }
1129
1191
  await sleepAsync(this.pollIntervalMs);
1130
1192
  }
1131
- throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
1132
1193
  }
1133
1194
  }
1134
1195
 
@@ -2337,7 +2398,7 @@ function buildPhaseCandidates(entries, architectEvery) {
2337
2398
  if (entry.meta._phaseState) {
2338
2399
  const needsArchitect = !entry.meta._builder ||
2339
2400
  (entry.meta._synthesisCount ?? 0) >= architectEvery;
2340
- if (needsArchitect && ps.architect === 'fresh') {
2401
+ if (needsArchitect && isFullyFresh(ps)) {
2341
2402
  ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
2342
2403
  }
2343
2404
  }
@@ -2573,6 +2634,9 @@ async function persistPhaseState(base, phaseState, updates) {
2573
2634
  _phaseState: phaseState,
2574
2635
  _structureHash: base.structureHash,
2575
2636
  };
2637
+ if (!merged._id) {
2638
+ merged._id = randomUUID();
2639
+ }
2576
2640
  // Clean undefined
2577
2641
  if (merged._error === undefined)
2578
2642
  delete merged._error;
@@ -2623,7 +2687,6 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2623
2687
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
2624
2688
  const result = await executor.spawn(architectTask, {
2625
2689
  thinking: config.thinking,
2626
- timeout: currentMeta._architectTimeout ?? config.architectTimeout,
2627
2690
  label: 'meta-architect',
2628
2691
  });
2629
2692
  const builderBrief = parseArchitectOutput(result.output);
@@ -2675,10 +2738,35 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2675
2738
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
2676
2739
  const result = await executor.spawn(builderTask, {
2677
2740
  thinking: config.thinking,
2678
- timeout: currentMeta._builderTimeout ?? config.builderTimeout,
2679
2741
  label: 'meta-builder',
2680
2742
  });
2681
- const builderOutput = parseBuilderOutput(result.output);
2743
+ const rawOutput = result.output;
2744
+ // Exact match only — ANNOUNCE_SKIP as the entire output means "no update."
2745
+ // A trailing sentinel on valid output (e.g. JSON + ANNOUNCE_SKIP) is handled
2746
+ // by stripSentinel() inside parseBuilderOutput and is NOT a skip.
2747
+ const isSkip = rawOutput.trim() === 'ANNOUNCE_SKIP';
2748
+ if (isSkip) {
2749
+ // ANNOUNCE_SKIP: preserve existing _content, bump _generatedAt only
2750
+ ps = builderSuccess(ps);
2751
+ const skipUpdates = {
2752
+ _builderTokens: result.tokens,
2753
+ _generatedAt: new Date().toISOString(),
2754
+ _error: undefined,
2755
+ };
2756
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
2757
+ if (ancestorHash)
2758
+ skipUpdates._ancestorBuilderHash = ancestorHash;
2759
+ const updatedMeta = await persistPhaseState(base, ps, skipUpdates);
2760
+ await onProgress?.({
2761
+ type: 'phase_complete',
2762
+ path: node.ownerPath,
2763
+ phase: 'builder',
2764
+ tokens: result.tokens,
2765
+ durationMs: Date.now() - builderStart,
2766
+ });
2767
+ return { executed: true, phaseState: ps, updatedMeta };
2768
+ }
2769
+ const builderOutput = parseBuilderOutput(rawOutput);
2682
2770
  const builderTokens = result.tokens;
2683
2771
  // Builder success: builder → fresh, critic → pending
2684
2772
  ps = builderSuccess(ps);
@@ -2744,7 +2832,6 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2744
2832
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
2745
2833
  const result = await executor.spawn(criticTask, {
2746
2834
  thinking: config.thinking,
2747
- timeout: currentMeta._criticTimeout ?? config.criticTimeout,
2748
2835
  label: 'meta-critic',
2749
2836
  });
2750
2837
  const feedback = parseCriticOutput(result.output);
@@ -2798,6 +2885,28 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2798
2885
  *
2799
2886
  * @module orchestrator/orchestratePhase
2800
2887
  */
2888
+ /**
2889
+ * Check whether a meta has an empty scope — no source files, no children,
2890
+ * no cross-refs, and no prior content. Matches §3.9 empty-scope criteria.
2891
+ */
2892
+ function isEmptyScope(scopeFiles, node, meta) {
2893
+ return (scopeFiles.length === 0 &&
2894
+ node.children.length === 0 &&
2895
+ (!meta._crossRefs || meta._crossRefs.length === 0) &&
2896
+ !meta._content);
2897
+ }
2898
+ /**
2899
+ * Handle an empty-scope meta: set all phases fresh, bump _generatedAt.
2900
+ * Prevents perpetual staleness without wasting tokens on synthesis.
2901
+ */
2902
+ async function handleEmptyScope(node, currentMeta, config, structureHash, logger) {
2903
+ await persistPhaseState({
2904
+ metaPath: node.metaPath,
2905
+ current: currentMeta,
2906
+ structureHash,
2907
+ }, freshPhaseState(), { _generatedAt: new Date().toISOString() });
2908
+ logger?.info({ path: node.ownerPath }, 'Empty scope — set all phases fresh, bumped _generatedAt');
2909
+ }
2801
2910
  /** Phase runner dispatch map — avoids repeating the same switch/case. */
2802
2911
  const phaseRunners = {
2803
2912
  architect: runArchitect,
@@ -2853,6 +2962,11 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
2853
2962
  // Compute structure hash for the phase
2854
2963
  const { scopeFiles } = await getScopeFiles(winner.node, watcher);
2855
2964
  const structureHash = computeStructureHash(scopeFiles);
2965
+ // Empty-scope guard (§3.9, #177): skip synthesis when nothing to synthesize
2966
+ if (isEmptyScope(scopeFiles, winner.node, currentMeta)) {
2967
+ await handleEmptyScope(winner.node, currentMeta, config, structureHash, logger);
2968
+ return { executed: false };
2969
+ }
2856
2970
  // skipUnchanged: bump _generatedAt without altering _phaseState
2857
2971
  if (config.skipUnchanged && currentMeta._generatedAt) {
2858
2972
  const verifiedStale = await isStale(getScopePrefix(winner.node), currentMeta, watcher);
@@ -2894,6 +3008,11 @@ async function orchestrateTargeted(config, executor, watcher, targetPath, onProg
2894
3008
  // Compute structure hash
2895
3009
  const { scopeFiles } = await getScopeFiles(node, watcher);
2896
3010
  const structureHash = computeStructureHash(scopeFiles);
3011
+ // Empty-scope guard (§3.9, #177): skip synthesis when nothing to synthesize
3012
+ if (isEmptyScope(scopeFiles, node, currentMeta)) {
3013
+ await handleEmptyScope(node, currentMeta, config, structureHash, logger);
3014
+ return { executed: false, metaPath: normalizedTarget };
3015
+ }
2897
3016
  return await executePhase(node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
2898
3017
  }
2899
3018
  finally {
@@ -3625,12 +3744,6 @@ async function createMeta(ownerPath, options) {
3625
3744
  metaJson._crossRefs = options.crossRefs;
3626
3745
  if (options?.steer !== undefined)
3627
3746
  metaJson._steer = options.steer;
3628
- if (options?.architectTimeout !== undefined)
3629
- metaJson._architectTimeout = options.architectTimeout;
3630
- if (options?.builderTimeout !== undefined)
3631
- metaJson._builderTimeout = options.builderTimeout;
3632
- if (options?.criticTimeout !== undefined)
3633
- metaJson._criticTimeout = options.criticTimeout;
3634
3747
  const metaJsonPath = join(metaDir, 'meta.json');
3635
3748
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
3636
3749
  return { metaDir, _id };
@@ -3706,9 +3819,6 @@ async function autoSeedPass(rules, watcher, logger) {
3706
3819
  candidates.set(dir, {
3707
3820
  steer: rule.steer,
3708
3821
  crossRefs: rule.crossRefs,
3709
- architectTimeout: rule.architectTimeout,
3710
- builderTimeout: rule.builderTimeout,
3711
- criticTimeout: rule.criticTimeout,
3712
3822
  });
3713
3823
  }
3714
3824
  }
@@ -4056,6 +4166,12 @@ function registerConfigApplyRoute(app, configPath) {
4056
4166
  .status(500)
4057
4167
  .send({ error: `Failed to write config: ${message}` });
4058
4168
  }
4169
+ // Compute whether any restart-required fields actually changed
4170
+ // (compares validated output against previous config, catching both
4171
+ // explicit patch keys and implicit changes via replace + schema defaults)
4172
+ const validatedRecord = validatedConfig;
4173
+ const restartRequired = RESTART_REQUIRED_FIELDS.some((field) => JSON.stringify(existing[field]) !==
4174
+ JSON.stringify(validatedRecord[field]));
4059
4175
  // Apply hot-reload callback
4060
4176
  try {
4061
4177
  applyHotReloadedConfig(validatedConfig);
@@ -4065,11 +4181,12 @@ function registerConfigApplyRoute(app, configPath) {
4065
4181
  return reply.status(200).send({
4066
4182
  applied: true,
4067
4183
  warning: `Config written but callback failed: ${message}`,
4068
- restartRequired: RESTART_REQUIRED_FIELDS,
4184
+ restartRequired,
4069
4185
  });
4070
4186
  }
4071
4187
  return reply.status(200).send({
4072
4188
  applied: true,
4189
+ restartRequired,
4073
4190
  });
4074
4191
  });
4075
4192
  }
@@ -4271,7 +4388,7 @@ function registerMetasRoutes(app, deps) {
4271
4388
  /**
4272
4389
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
4273
4390
  *
4274
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4391
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
4275
4392
  * Set a field to null to remove it. Unknown keys are rejected.
4276
4393
  *
4277
4394
  * @module routes/metasUpdate
@@ -4283,9 +4400,6 @@ const updateBodySchema = z
4283
4400
  _depth: z.union([z.number(), z.null()]).optional(),
4284
4401
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
4285
4402
  _disabled: z.union([z.boolean(), z.null()]).optional(),
4286
- _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4287
- _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4288
- _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4289
4403
  })
4290
4404
  .strict();
4291
4405
  function registerMetasUpdateRoute(app, deps) {
@@ -4330,6 +4444,7 @@ function registerMetasUpdateRoute(app, deps) {
4330
4444
  }
4331
4445
  Object.assign(updated, toSet);
4332
4446
  await writeFile(metaJsonPath, JSON.stringify(updated, null, 2) + '\n');
4447
+ deps.cache.invalidate();
4333
4448
  // Project the response — exclude the same large fields as the detail route.
4334
4449
  const projected = {};
4335
4450
  for (const [k, v] of Object.entries(updated)) {
@@ -4367,10 +4482,10 @@ function registerPreviewRoute(app, deps) {
4367
4482
  const normalized = normalizePath(query.path);
4368
4483
  targetNode = findNode(result.tree, normalized);
4369
4484
  if (!targetNode) {
4370
- return {
4485
+ return reply.status(404).send({
4371
4486
  error: 'NOT_FOUND',
4372
4487
  message: 'Meta path not found: ' + query.path,
4373
- };
4488
+ });
4374
4489
  }
4375
4490
  }
4376
4491
  else {
@@ -4940,7 +5055,7 @@ function registerRoutes(app, deps) {
4940
5055
  });
4941
5056
  registerStatusRoute(app, deps);
4942
5057
  registerMetasRoutes(app, deps);
4943
- registerMetasUpdateRoute(app);
5058
+ registerMetasUpdateRoute(app, deps);
4944
5059
  registerSynthesizeRoute(app, deps);
4945
5060
  registerPreviewRoute(app, deps);
4946
5061
  registerSeedRoute(app, deps);
@@ -5027,6 +5142,18 @@ function registerShutdownHandlers(deps) {
5027
5142
  deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
5028
5143
  }
5029
5144
  }
5145
+ // Release lock for in-progress override synthesis (only when it
5146
+ // differs from the legacy current item to avoid double-release)
5147
+ const currentPhase = deps.queue.currentPhase;
5148
+ if (currentPhase && currentPhase.path !== current?.path) {
5149
+ try {
5150
+ releaseLock(resolveMetaDir(currentPhase.path));
5151
+ deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress override synthesis');
5152
+ }
5153
+ catch {
5154
+ deps.logger.warn({ path: currentPhase.path }, 'Failed to release override lock during shutdown');
5155
+ }
5156
+ }
5030
5157
  // 3. Close server
5031
5158
  try {
5032
5159
  await deps.server.close();
@@ -5124,6 +5251,25 @@ class HttpWatcherClient {
5124
5251
  *
5125
5252
  * @module bootstrap
5126
5253
  */
5254
+ /**
5255
+ * Compute per-cycle token total from a completed meta.
5256
+ *
5257
+ * Exported for testing.
5258
+ *
5259
+ * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5260
+ * a value of 1 means architect ran this cycle (was 0 pre-increment),
5261
+ * so all three phase token fields are summed. A value \> 1 means architect
5262
+ * was skipped (cached brief reused), so only builder + critic are summed.
5263
+ */
5264
+ function computeCycleTokens(meta) {
5265
+ const builderTokens = meta._builderTokens ?? 0;
5266
+ const criticTokens = meta._criticTokens ?? 0;
5267
+ const architectRan = (meta._synthesisCount ?? 1) === 1;
5268
+ const architectTokens = architectRan
5269
+ ? (meta._architectTokens ?? 0)
5270
+ : 0;
5271
+ return architectTokens + builderTokens + criticTokens;
5272
+ }
5127
5273
  /**
5128
5274
  * Bootstrap the service: create logger, build server, start listening,
5129
5275
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5250,10 +5396,15 @@ async function startService(config, configPath) {
5250
5396
  }
5251
5397
  // Emit synthesis_complete only on full-cycle completion
5252
5398
  if (result.cycleComplete) {
5399
+ const updatedMeta = result.phaseResult?.updatedMeta;
5400
+ const tokens = updatedMeta
5401
+ ? computeCycleTokens(updatedMeta)
5402
+ : undefined;
5253
5403
  await progress.report({
5254
5404
  type: 'synthesis_complete',
5255
5405
  path: ownerPath,
5256
5406
  durationMs,
5407
+ tokens,
5257
5408
  });
5258
5409
  }
5259
5410
  }
@@ -36,13 +36,32 @@ export declare class GatewayExecutor implements MetaExecutor {
36
36
  constructor(options?: GatewayExecutorOptions);
37
37
  /** Remove a temp output file if it exists. */
38
38
  private cleanupOutputFile;
39
+ /** Read and clean up the staging output file. Returns content or undefined if absent. */
40
+ private readStagingFile;
41
+ /** Extract plain text from a message content field, skipping ANNOUNCE_SKIP sentinels. */
42
+ private static extractMessageText;
43
+ /** Check history messages for terminal completion. */
44
+ private static checkHistoryCompletion;
39
45
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
40
46
  private invoke;
41
- /** Look up session metadata (tokens, completion status) via sessions_list. */
47
+ /**
48
+ * Look up session metadata (tokens, completion status) via sessions_list.
49
+ *
50
+ * Detects gateway-side timeout (`status: "timeout"`) and killed sessions
51
+ * (`status: "killed"`) as completed, with a `timedOut` flag to distinguish
52
+ * timeout from normal completion.
53
+ */
42
54
  private getSessionInfo;
43
55
  /** Whether this executor has been aborted by the operator. */
44
56
  get aborted(): boolean;
45
57
  /** Abort the currently running spawn, if any. */
46
58
  abort(): void;
59
+ /**
60
+ * Query the gateway's configured subagent run timeout.
61
+ *
62
+ * Returns the value in milliseconds, or `undefined` if the query fails
63
+ * or the value is absent/zero (no timeout configured).
64
+ */
65
+ private queryGatewayRunTimeout;
47
66
  spawn(task: string, options?: MetaSpawnOptions): Promise<MetaSpawnResult>;
48
67
  }
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,42 @@ 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
+ /** Check history messages for terminal completion. */
1513
+ static checkHistoryCompletion(messages) {
1514
+ if (messages.length === 0)
1515
+ return { done: false, timedOut: false };
1516
+ const last = messages[messages.length - 1];
1517
+ if (last.role !== 'assistant' || !last.stopReason)
1518
+ return { done: false, timedOut: false };
1519
+ if (last.stopReason === 'toolUse' || last.stopReason === 'error')
1520
+ return { done: false, timedOut: false };
1521
+ return { done: true, timedOut: last.stopReason === 'timeout' };
1522
+ }
1493
1523
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
1494
1524
  async invoke(tool, args, sessionKey) {
1495
1525
  const headers = {
@@ -1516,7 +1546,13 @@ class GatewayExecutor {
1516
1546
  }
1517
1547
  return data;
1518
1548
  }
1519
- /** Look up session metadata (tokens, completion status) via sessions_list. */
1549
+ /**
1550
+ * Look up session metadata (tokens, completion status) via sessions_list.
1551
+ *
1552
+ * Detects gateway-side timeout (`status: "timeout"`) and killed sessions
1553
+ * (`status: "killed"`) as completed, with a `timedOut` flag to distinguish
1554
+ * timeout from normal completion.
1555
+ */
1520
1556
  async getSessionInfo(sessionKey) {
1521
1557
  try {
1522
1558
  const result = await this.invoke('sessions_list', {
@@ -1532,13 +1568,18 @@ class GatewayExecutor {
1532
1568
  // With limit=200 this is reliable; a false positive here only
1533
1569
  // means we read the output file slightly early (still correct
1534
1570
  // if the file exists).
1535
- return { completed: true };
1571
+ return { completed: true, timedOut: false };
1536
1572
  }
1537
- const done = match.status === 'completed' || match.status === 'done';
1538
- return { tokens: match.totalTokens, completed: done };
1573
+ const status = match.status;
1574
+ const done = status === 'completed' ||
1575
+ status === 'done' ||
1576
+ status === 'timeout' ||
1577
+ status === 'killed';
1578
+ const timedOut = status === 'timeout';
1579
+ return { tokens: match.totalTokens, completed: done, timedOut };
1539
1580
  }
1540
1581
  catch {
1541
- return { completed: false };
1582
+ return { completed: false, timedOut: false };
1542
1583
  }
1543
1584
  }
1544
1585
  /** Whether this executor has been aborted by the operator. */
@@ -1549,12 +1590,38 @@ class GatewayExecutor {
1549
1590
  abort() {
1550
1591
  this.controller.abort();
1551
1592
  }
1593
+ /**
1594
+ * Query the gateway's configured subagent run timeout.
1595
+ *
1596
+ * Returns the value in milliseconds, or `undefined` if the query fails
1597
+ * or the value is absent/zero (no timeout configured).
1598
+ */
1599
+ async queryGatewayRunTimeout() {
1600
+ try {
1601
+ const result = await this.invoke('session_status', {});
1602
+ const details = (result.result?.details ?? result.result ?? {});
1603
+ const runTimeoutSeconds = details.runTimeoutSeconds ??
1604
+ details.timeout;
1605
+ if (typeof runTimeoutSeconds === 'number' && runTimeoutSeconds > 0) {
1606
+ return runTimeoutSeconds * 1000;
1607
+ }
1608
+ }
1609
+ catch {
1610
+ // Gateway unreachable or field not exposed — fall back to default
1611
+ }
1612
+ return undefined;
1613
+ }
1552
1614
  async spawn(task, options) {
1553
1615
  // Fresh controller for each spawn call
1554
1616
  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;
1617
+ // Safety-valve deadline: gateway's runTimeoutSeconds + 60s buffer,
1618
+ // defaulting to 1 hour if the gateway value is 0/absent/query fails.
1619
+ // This is a circuit breaker, not a timeout mechanism.
1620
+ const gatewayTimeoutMs = await this.queryGatewayRunTimeout();
1621
+ const safetyValveMs = gatewayTimeoutMs
1622
+ ? gatewayTimeoutMs + 60_000
1623
+ : DEFAULT_SAFETY_VALVE_MS;
1624
+ const safetyDeadline = Date.now() + safetyValveMs;
1558
1625
  // Ensure workspace dir exists
1559
1626
  if (!existsSync(this.workspaceDir)) {
1560
1627
  mkdirSync(this.workspaceDir, { recursive: true });
@@ -1576,7 +1643,6 @@ class GatewayExecutor {
1576
1643
  const spawnResult = await this.invoke('sessions_spawn', {
1577
1644
  task: taskWithOutput,
1578
1645
  label,
1579
- runTimeoutSeconds: timeoutSeconds,
1580
1646
  ...(options?.thinking ? { thinking: options.thinking } : {}),
1581
1647
  ...(options?.model ? { model: options.model } : {}),
1582
1648
  });
@@ -1588,9 +1654,18 @@ class GatewayExecutor {
1588
1654
  throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
1589
1655
  JSON.stringify(spawnResult));
1590
1656
  }
1591
- // Step 2: Poll for completion via sessions_history
1657
+ // Step 2: Poll for completion gateway owns the subagent lifecycle.
1658
+ // Loop exits via: (a) completion detection, (b) abort signal,
1659
+ // (c) gateway-side timeout detection, or (d) safety-valve circuit breaker.
1592
1660
  await sleepAsync(3000);
1593
- while (Date.now() < deadline) {
1661
+ while (true) {
1662
+ // Safety-valve circuit breaker
1663
+ if (Date.now() >= safetyDeadline) {
1664
+ this.cleanupOutputFile(outputPath);
1665
+ throw new SpawnTimeoutError('Safety-valve deadline exceeded (' +
1666
+ safetyValveMs.toString() +
1667
+ 'ms) — gateway timeout may be misconfigured', outputPath);
1668
+ }
1594
1669
  // Check for abort before each poll iteration
1595
1670
  if (this.controller.signal.aborted) {
1596
1671
  this.cleanupOutputFile(outputPath);
@@ -1607,62 +1682,48 @@ class GatewayExecutor {
1607
1682
  [];
1608
1683
  const msgArray = messages;
1609
1684
  // 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
- }
1685
+ const { done: historyDone, timedOut: historyTimedOut } = GatewayExecutor.checkHistoryCompletion(msgArray);
1620
1686
  // Check 2: session completion status via sessions_list
1621
1687
  const sessionInfo = await this.getSessionInfo(sessionKey);
1688
+ const timedOut = sessionInfo.timedOut || historyTimedOut;
1622
1689
  if (historyDone || sessionInfo.completed) {
1623
1690
  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');
1691
+ // Gateway-side timeout detected check staging file for recovery
1692
+ if (timedOut) {
1693
+ const output = this.readStagingFile(outputPath);
1694
+ if (output !== undefined)
1628
1695
  return { output, tokens };
1629
- }
1630
- finally {
1631
- try {
1632
- unlinkSync(outputPath);
1633
- }
1634
- catch {
1635
- /* cleanup best-effort */
1636
- }
1637
- }
1696
+ // No output or partial output — throw for _state recovery (§3.16.6)
1697
+ throw new SpawnTimeoutError('Gateway-side timeout detected (session status: timeout)', outputPath);
1638
1698
  }
1699
+ // Normal completion — read output from file
1700
+ const output = this.readStagingFile(outputPath);
1701
+ if (output !== undefined)
1702
+ return { output, tokens };
1639
1703
  // Fallback: extract from message content if file wasn't written.
1640
1704
  // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
1641
1705
  // a preceding assistant message (the file write).
1642
1706
  for (let i = msgArray.length - 1; i >= 0; i--) {
1643
1707
  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')
1708
+ if (msg.role === 'assistant') {
1709
+ const text = GatewayExecutor.extractMessageText(msg.content);
1710
+ if (text !== undefined)
1654
1711
  return { output: text, tokens };
1655
1712
  }
1656
1713
  }
1657
1714
  return { output: '', tokens };
1658
1715
  }
1659
1716
  }
1660
- catch {
1717
+ catch (err) {
1718
+ // Re-throw SpawnTimeoutError and SpawnAbortedError — only swallow transient poll failures
1719
+ if (err instanceof SpawnTimeoutError ||
1720
+ err instanceof SpawnAbortedError) {
1721
+ throw err;
1722
+ }
1661
1723
  // Transient poll failure — keep trying
1662
1724
  }
1663
1725
  await sleepAsync(this.pollIntervalMs);
1664
1726
  }
1665
- throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
1666
1727
  }
1667
1728
  }
1668
1729
 
@@ -2595,7 +2656,7 @@ function buildPhaseCandidates(entries, architectEvery) {
2595
2656
  if (entry.meta._phaseState) {
2596
2657
  const needsArchitect = !entry.meta._builder ||
2597
2658
  (entry.meta._synthesisCount ?? 0) >= architectEvery;
2598
- if (needsArchitect && ps.architect === 'fresh') {
2659
+ if (needsArchitect && isFullyFresh(ps)) {
2599
2660
  ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
2600
2661
  }
2601
2662
  }
@@ -2831,6 +2892,9 @@ async function persistPhaseState(base, phaseState, updates) {
2831
2892
  _phaseState: phaseState,
2832
2893
  _structureHash: base.structureHash,
2833
2894
  };
2895
+ if (!merged._id) {
2896
+ merged._id = randomUUID();
2897
+ }
2834
2898
  // Clean undefined
2835
2899
  if (merged._error === undefined)
2836
2900
  delete merged._error;
@@ -2881,7 +2945,6 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2881
2945
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
2882
2946
  const result = await executor.spawn(architectTask, {
2883
2947
  thinking: config.thinking,
2884
- timeout: currentMeta._architectTimeout ?? config.architectTimeout,
2885
2948
  label: 'meta-architect',
2886
2949
  });
2887
2950
  const builderBrief = parseArchitectOutput(result.output);
@@ -2933,10 +2996,35 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2933
2996
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
2934
2997
  const result = await executor.spawn(builderTask, {
2935
2998
  thinking: config.thinking,
2936
- timeout: currentMeta._builderTimeout ?? config.builderTimeout,
2937
2999
  label: 'meta-builder',
2938
3000
  });
2939
- const builderOutput = parseBuilderOutput(result.output);
3001
+ const rawOutput = result.output;
3002
+ // Exact match only — ANNOUNCE_SKIP as the entire output means "no update."
3003
+ // A trailing sentinel on valid output (e.g. JSON + ANNOUNCE_SKIP) is handled
3004
+ // by stripSentinel() inside parseBuilderOutput and is NOT a skip.
3005
+ const isSkip = rawOutput.trim() === 'ANNOUNCE_SKIP';
3006
+ if (isSkip) {
3007
+ // ANNOUNCE_SKIP: preserve existing _content, bump _generatedAt only
3008
+ ps = builderSuccess(ps);
3009
+ const skipUpdates = {
3010
+ _builderTokens: result.tokens,
3011
+ _generatedAt: new Date().toISOString(),
3012
+ _error: undefined,
3013
+ };
3014
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
3015
+ if (ancestorHash)
3016
+ skipUpdates._ancestorBuilderHash = ancestorHash;
3017
+ const updatedMeta = await persistPhaseState(base, ps, skipUpdates);
3018
+ await onProgress?.({
3019
+ type: 'phase_complete',
3020
+ path: node.ownerPath,
3021
+ phase: 'builder',
3022
+ tokens: result.tokens,
3023
+ durationMs: Date.now() - builderStart,
3024
+ });
3025
+ return { executed: true, phaseState: ps, updatedMeta };
3026
+ }
3027
+ const builderOutput = parseBuilderOutput(rawOutput);
2940
3028
  const builderTokens = result.tokens;
2941
3029
  // Builder success: builder → fresh, critic → pending
2942
3030
  ps = builderSuccess(ps);
@@ -3002,7 +3090,6 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
3002
3090
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
3003
3091
  const result = await executor.spawn(criticTask, {
3004
3092
  thinking: config.thinking,
3005
- timeout: currentMeta._criticTimeout ?? config.criticTimeout,
3006
3093
  label: 'meta-critic',
3007
3094
  });
3008
3095
  const feedback = parseCriticOutput(result.output);
@@ -3056,6 +3143,28 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
3056
3143
  *
3057
3144
  * @module orchestrator/orchestratePhase
3058
3145
  */
3146
+ /**
3147
+ * Check whether a meta has an empty scope — no source files, no children,
3148
+ * no cross-refs, and no prior content. Matches §3.9 empty-scope criteria.
3149
+ */
3150
+ function isEmptyScope(scopeFiles, node, meta) {
3151
+ return (scopeFiles.length === 0 &&
3152
+ node.children.length === 0 &&
3153
+ (!meta._crossRefs || meta._crossRefs.length === 0) &&
3154
+ !meta._content);
3155
+ }
3156
+ /**
3157
+ * Handle an empty-scope meta: set all phases fresh, bump _generatedAt.
3158
+ * Prevents perpetual staleness without wasting tokens on synthesis.
3159
+ */
3160
+ async function handleEmptyScope(node, currentMeta, config, structureHash, logger) {
3161
+ await persistPhaseState({
3162
+ metaPath: node.metaPath,
3163
+ current: currentMeta,
3164
+ structureHash,
3165
+ }, freshPhaseState(), { _generatedAt: new Date().toISOString() });
3166
+ logger?.info({ path: node.ownerPath }, 'Empty scope — set all phases fresh, bumped _generatedAt');
3167
+ }
3059
3168
  /** Phase runner dispatch map — avoids repeating the same switch/case. */
3060
3169
  const phaseRunners = {
3061
3170
  architect: runArchitect,
@@ -3111,6 +3220,11 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
3111
3220
  // Compute structure hash for the phase
3112
3221
  const { scopeFiles } = await getScopeFiles(winner.node, watcher);
3113
3222
  const structureHash = computeStructureHash(scopeFiles);
3223
+ // Empty-scope guard (§3.9, #177): skip synthesis when nothing to synthesize
3224
+ if (isEmptyScope(scopeFiles, winner.node, currentMeta)) {
3225
+ await handleEmptyScope(winner.node, currentMeta, config, structureHash, logger);
3226
+ return { executed: false };
3227
+ }
3114
3228
  // skipUnchanged: bump _generatedAt without altering _phaseState
3115
3229
  if (config.skipUnchanged && currentMeta._generatedAt) {
3116
3230
  const verifiedStale = await isStale(getScopePrefix(winner.node), currentMeta, watcher);
@@ -3152,6 +3266,11 @@ async function orchestrateTargeted(config, executor, watcher, targetPath, onProg
3152
3266
  // Compute structure hash
3153
3267
  const { scopeFiles } = await getScopeFiles(node, watcher);
3154
3268
  const structureHash = computeStructureHash(scopeFiles);
3269
+ // Empty-scope guard (§3.9, #177): skip synthesis when nothing to synthesize
3270
+ if (isEmptyScope(scopeFiles, node, currentMeta)) {
3271
+ await handleEmptyScope(node, currentMeta, config, structureHash, logger);
3272
+ return { executed: false, metaPath: normalizedTarget };
3273
+ }
3155
3274
  return await executePhase(node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
3156
3275
  }
3157
3276
  finally {
@@ -3883,12 +4002,6 @@ async function createMeta(ownerPath, options) {
3883
4002
  metaJson._crossRefs = options.crossRefs;
3884
4003
  if (options?.steer !== undefined)
3885
4004
  metaJson._steer = options.steer;
3886
- if (options?.architectTimeout !== undefined)
3887
- metaJson._architectTimeout = options.architectTimeout;
3888
- if (options?.builderTimeout !== undefined)
3889
- metaJson._builderTimeout = options.builderTimeout;
3890
- if (options?.criticTimeout !== undefined)
3891
- metaJson._criticTimeout = options.criticTimeout;
3892
4005
  const metaJsonPath = join(metaDir, 'meta.json');
3893
4006
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
3894
4007
  return { metaDir, _id };
@@ -3964,9 +4077,6 @@ async function autoSeedPass(rules, watcher, logger) {
3964
4077
  candidates.set(dir, {
3965
4078
  steer: rule.steer,
3966
4079
  crossRefs: rule.crossRefs,
3967
- architectTimeout: rule.architectTimeout,
3968
- builderTimeout: rule.builderTimeout,
3969
- criticTimeout: rule.criticTimeout,
3970
4080
  });
3971
4081
  }
3972
4082
  }
@@ -4314,6 +4424,12 @@ function registerConfigApplyRoute(app, configPath) {
4314
4424
  .status(500)
4315
4425
  .send({ error: `Failed to write config: ${message}` });
4316
4426
  }
4427
+ // Compute whether any restart-required fields actually changed
4428
+ // (compares validated output against previous config, catching both
4429
+ // explicit patch keys and implicit changes via replace + schema defaults)
4430
+ const validatedRecord = validatedConfig;
4431
+ const restartRequired = RESTART_REQUIRED_FIELDS.some((field) => JSON.stringify(existing[field]) !==
4432
+ JSON.stringify(validatedRecord[field]));
4317
4433
  // Apply hot-reload callback
4318
4434
  try {
4319
4435
  applyHotReloadedConfig(validatedConfig);
@@ -4323,11 +4439,12 @@ function registerConfigApplyRoute(app, configPath) {
4323
4439
  return reply.status(200).send({
4324
4440
  applied: true,
4325
4441
  warning: `Config written but callback failed: ${message}`,
4326
- restartRequired: RESTART_REQUIRED_FIELDS,
4442
+ restartRequired,
4327
4443
  });
4328
4444
  }
4329
4445
  return reply.status(200).send({
4330
4446
  applied: true,
4447
+ restartRequired,
4331
4448
  });
4332
4449
  });
4333
4450
  }
@@ -4529,7 +4646,7 @@ function registerMetasRoutes(app, deps) {
4529
4646
  /**
4530
4647
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
4531
4648
  *
4532
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4649
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
4533
4650
  * Set a field to null to remove it. Unknown keys are rejected.
4534
4651
  *
4535
4652
  * @module routes/metasUpdate
@@ -4541,9 +4658,6 @@ const updateBodySchema = z
4541
4658
  _depth: z.union([z.number(), z.null()]).optional(),
4542
4659
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
4543
4660
  _disabled: z.union([z.boolean(), z.null()]).optional(),
4544
- _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4545
- _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4546
- _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4547
4661
  })
4548
4662
  .strict();
4549
4663
  function registerMetasUpdateRoute(app, deps) {
@@ -4588,6 +4702,7 @@ function registerMetasUpdateRoute(app, deps) {
4588
4702
  }
4589
4703
  Object.assign(updated, toSet);
4590
4704
  await writeFile(metaJsonPath, JSON.stringify(updated, null, 2) + '\n');
4705
+ deps.cache.invalidate();
4591
4706
  // Project the response — exclude the same large fields as the detail route.
4592
4707
  const projected = {};
4593
4708
  for (const [k, v] of Object.entries(updated)) {
@@ -4625,10 +4740,10 @@ function registerPreviewRoute(app, deps) {
4625
4740
  const normalized = normalizePath(query.path);
4626
4741
  targetNode = findNode(result.tree, normalized);
4627
4742
  if (!targetNode) {
4628
- return {
4743
+ return reply.status(404).send({
4629
4744
  error: 'NOT_FOUND',
4630
4745
  message: 'Meta path not found: ' + query.path,
4631
- };
4746
+ });
4632
4747
  }
4633
4748
  }
4634
4749
  else {
@@ -5170,7 +5285,7 @@ function registerRoutes(app, deps) {
5170
5285
  });
5171
5286
  registerStatusRoute(app, deps);
5172
5287
  registerMetasRoutes(app, deps);
5173
- registerMetasUpdateRoute(app);
5288
+ registerMetasUpdateRoute(app, deps);
5174
5289
  registerSynthesizeRoute(app, deps);
5175
5290
  registerPreviewRoute(app, deps);
5176
5291
  registerSeedRoute(app, deps);
@@ -5257,6 +5372,18 @@ function registerShutdownHandlers(deps) {
5257
5372
  deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
5258
5373
  }
5259
5374
  }
5375
+ // Release lock for in-progress override synthesis (only when it
5376
+ // differs from the legacy current item to avoid double-release)
5377
+ const currentPhase = deps.queue.currentPhase;
5378
+ if (currentPhase && currentPhase.path !== current?.path) {
5379
+ try {
5380
+ releaseLock(resolveMetaDir(currentPhase.path));
5381
+ deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress override synthesis');
5382
+ }
5383
+ catch {
5384
+ deps.logger.warn({ path: currentPhase.path }, 'Failed to release override lock during shutdown');
5385
+ }
5386
+ }
5260
5387
  // 3. Close server
5261
5388
  try {
5262
5389
  await deps.server.close();
@@ -5354,6 +5481,25 @@ class HttpWatcherClient {
5354
5481
  *
5355
5482
  * @module bootstrap
5356
5483
  */
5484
+ /**
5485
+ * Compute per-cycle token total from a completed meta.
5486
+ *
5487
+ * Exported for testing.
5488
+ *
5489
+ * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5490
+ * a value of 1 means architect ran this cycle (was 0 pre-increment),
5491
+ * so all three phase token fields are summed. A value \> 1 means architect
5492
+ * was skipped (cached brief reused), so only builder + critic are summed.
5493
+ */
5494
+ function computeCycleTokens(meta) {
5495
+ const builderTokens = meta._builderTokens ?? 0;
5496
+ const criticTokens = meta._criticTokens ?? 0;
5497
+ const architectRan = (meta._synthesisCount ?? 1) === 1;
5498
+ const architectTokens = architectRan
5499
+ ? (meta._architectTokens ?? 0)
5500
+ : 0;
5501
+ return architectTokens + builderTokens + criticTokens;
5502
+ }
5357
5503
  /**
5358
5504
  * Bootstrap the service: create logger, build server, start listening,
5359
5505
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5480,10 +5626,15 @@ async function startService(config, configPath) {
5480
5626
  }
5481
5627
  // Emit synthesis_complete only on full-cycle completion
5482
5628
  if (result.cycleComplete) {
5629
+ const updatedMeta = result.phaseResult?.updatedMeta;
5630
+ const tokens = updatedMeta
5631
+ ? computeCycleTokens(updatedMeta)
5632
+ : undefined;
5483
5633
  await progress.report({
5484
5634
  type: 'synthesis_complete',
5485
5635
  path: ownerPath,
5486
5636
  durationMs,
5637
+ tokens,
5487
5638
  });
5488
5639
  }
5489
5640
  }
@@ -5712,12 +5863,6 @@ const metaJsonSchema = z
5712
5863
  _error: metaErrorSchema.optional(),
5713
5864
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
5714
5865
  _disabled: z.boolean().optional(),
5715
- /** Per-entity timeout override for the architect phase (seconds, min 30). */
5716
- _architectTimeout: z.number().int().min(30).optional(),
5717
- /** Per-entity timeout override for the builder phase (seconds, min 30). */
5718
- _builderTimeout: z.number().int().min(30).optional(),
5719
- /** Per-entity timeout override for the critic phase (seconds, min 30). */
5720
- _criticTimeout: z.number().int().min(30).optional(),
5721
5866
  /**
5722
5867
  * SHA-256 hash of ancestor _builder text at last synthesis.
5723
5868
  * Observability only — no invalidation cascade.
@@ -7,8 +7,6 @@
7
7
  export interface MetaSpawnOptions {
8
8
  /** Model override for this subprocess. */
9
9
  model?: string;
10
- /** Timeout in seconds. */
11
- timeout?: number;
12
10
  /** Label for the spawned session. */
13
11
  label?: string;
14
12
  /** Thinking level (e.g. "low", "medium", "high"). */
@@ -8,11 +8,17 @@
8
8
  *
9
9
  * @module orchestrator/orchestratePhase
10
10
  */
11
+ import type { MetaNode } from '../discovery/types.js';
11
12
  import type { MetaExecutor, WatcherClient } from '../interfaces/index.js';
12
13
  import type { MinimalLogger } from '../logger/index.js';
13
14
  import type { ProgressEvent } from '../progress/index.js';
14
- import type { MetaConfig, PhaseName } from '../schema/index.js';
15
+ import type { MetaConfig, MetaJson, PhaseName } from '../schema/index.js';
15
16
  import { type PhaseResult } from './runPhase.js';
17
+ /**
18
+ * Check whether a meta has an empty scope — no source files, no children,
19
+ * no cross-refs, and no prior content. Matches §3.9 empty-scope criteria.
20
+ */
21
+ export declare function isEmptyScope(scopeFiles: string[], node: MetaNode, meta: MetaJson): boolean;
16
22
  /** Callback for synthesis progress events. */
17
23
  export type PhaseProgressCallback = (event: ProgressEvent) => void | Promise<void>;
18
24
  /** Result of a single phase-aware orchestration tick. */
@@ -128,8 +128,6 @@ The Builder should:
128
128
  4. Merge new findings with previous `_content` (carried in context)
129
129
 
130
130
  If the scope is small enough to process in one pass, omit chunking instructions.
131
- The Builder has a timeout of \{{config.builderTimeout}} seconds.
132
-
133
131
  ### 8. Output Structure
134
132
 
135
133
  Define non-underscore fields for structured data and the _content narrative
@@ -148,7 +146,6 @@ Quote the specific issue and state what to do differently.
148
146
  Your task brief will be compiled as a Handlebars template before the Builder
149
147
  receives it. You can use these variables to write adaptive instructions:
150
148
 
151
- - `\{{config.builderTimeout}}` — Builder timeout in seconds
152
149
  - `\{{config.maxLines}}` — Maximum _content lines
153
150
  - `\{{config.architectEvery}}` — Cycles between architect refreshes
154
151
  - `\{{config.maxArchive}}` — Archive snapshots retained
@@ -159,7 +156,7 @@ receives it. You can use these variables to write adaptive instructions:
159
156
  - `\{{meta._depth}}` — Scheduling depth
160
157
  - `\{{meta._emphasis}}` — Scheduling emphasis
161
158
 
162
- Example: "Process files in chunks of 50. You have \{{config.builderTimeout}} seconds."
159
+ Example: "Process files in chunks of 50. Limit output to \{{config.maxLines}} lines."
163
160
 
164
161
  ## Constraints
165
162
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
3
3
  *
4
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
5
5
  * Set a field to null to remove it. Unknown keys are rejected.
6
6
  *
7
7
  * @module routes/metasUpdate
@@ -14,9 +14,6 @@ declare const autoSeedRuleSchema: z.ZodObject<{
14
14
  steer: z.ZodOptional<z.ZodString>;
15
15
  crossRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
16
16
  parentDepth: z.ZodOptional<z.ZodNumber>;
17
- architectTimeout: z.ZodOptional<z.ZodNumber>;
18
- builderTimeout: z.ZodOptional<z.ZodNumber>;
19
- criticTimeout: z.ZodOptional<z.ZodNumber>;
20
17
  }, z.core.$strip>;
21
18
  /** Inferred type for an auto-seed rule. */
22
19
  export type AutoSeedRule = z.infer<typeof autoSeedRuleSchema>;
@@ -29,9 +26,6 @@ export declare const serviceConfigSchema: z.ZodObject<{
29
26
  depthWeight: z.ZodDefault<z.ZodNumber>;
30
27
  maxArchive: z.ZodDefault<z.ZodNumber>;
31
28
  maxLines: z.ZodDefault<z.ZodNumber>;
32
- architectTimeout: z.ZodDefault<z.ZodNumber>;
33
- builderTimeout: z.ZodDefault<z.ZodNumber>;
34
- criticTimeout: z.ZodDefault<z.ZodNumber>;
35
29
  thinking: z.ZodDefault<z.ZodString>;
36
30
  defaultArchitect: z.ZodOptional<z.ZodString>;
37
31
  defaultCritic: z.ZodOptional<z.ZodString>;
@@ -54,9 +48,6 @@ export declare const serviceConfigSchema: z.ZodObject<{
54
48
  steer: z.ZodOptional<z.ZodString>;
55
49
  crossRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
56
50
  parentDepth: z.ZodOptional<z.ZodNumber>;
57
- architectTimeout: z.ZodOptional<z.ZodNumber>;
58
- builderTimeout: z.ZodOptional<z.ZodNumber>;
59
- criticTimeout: z.ZodOptional<z.ZodNumber>;
60
51
  }, z.core.$strip>>>>;
61
52
  }, z.core.$strip>;
62
53
  /** Inferred type for service configuration. */
@@ -43,9 +43,6 @@ export declare const metaJsonSchema: z.ZodObject<{
43
43
  message: z.ZodString;
44
44
  }, z.core.$strip>>;
45
45
  _disabled: z.ZodOptional<z.ZodBoolean>;
46
- _architectTimeout: z.ZodOptional<z.ZodNumber>;
47
- _builderTimeout: z.ZodOptional<z.ZodNumber>;
48
- _criticTimeout: z.ZodOptional<z.ZodNumber>;
49
46
  _ancestorBuilderHash: z.ZodOptional<z.ZodString>;
50
47
  _phaseState: z.ZodOptional<z.ZodObject<{
51
48
  architect: z.ZodEnum<{
@@ -11,12 +11,6 @@ export interface CreateMetaOptions {
11
11
  crossRefs?: string[];
12
12
  /** Steering prompt for the meta. */
13
13
  steer?: string;
14
- /** Per-entity timeout override for the architect phase (seconds). */
15
- architectTimeout?: number;
16
- /** Per-entity timeout override for the builder phase (seconds). */
17
- builderTimeout?: number;
18
- /** Per-entity timeout override for the critic phase (seconds). */
19
- criticTimeout?: number;
20
14
  }
21
15
  /** Result of creating a new meta. */
22
16
  export interface CreateMetaResult {
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@karmaniverous/jeeves": "^0.5.12",
11
- "@karmaniverous/jeeves-meta-core": "^0.1.3",
11
+ "@karmaniverous/jeeves-meta-core": "^0.2.0",
12
12
  "commander": "^14",
13
13
  "croner": "^10",
14
14
  "fastify": "^5.8",
@@ -98,7 +98,7 @@
98
98
  "url": "git+https://github.com/karmaniverous/jeeves-meta.git"
99
99
  },
100
100
  "scripts": {
101
- "build": "rimraf dist && cross-env NO_COLOR=1 rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
101
+ "build": "rimraf dist && cross-env NO_COLOR=1 rollup --config rollup.config.ts --configPlugin \"@rollup/plugin-typescript={outputToFilesystem:true}\"",
102
102
  "changelog": "git-cliff -o CHANGELOG.md",
103
103
  "knip": "knip",
104
104
  "lint": "eslint .",
@@ -110,5 +110,5 @@
110
110
  },
111
111
  "type": "module",
112
112
  "types": "dist/index.d.ts",
113
- "version": "0.15.11"
113
+ "version": "0.16.0"
114
114
  }