@karmaniverous/jeeves-meta 0.15.12 → 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
 
@@ -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
 
@@ -2626,7 +2687,6 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2626
2687
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
2627
2688
  const result = await executor.spawn(architectTask, {
2628
2689
  thinking: config.thinking,
2629
- timeout: currentMeta._architectTimeout ?? config.architectTimeout,
2630
2690
  label: 'meta-architect',
2631
2691
  });
2632
2692
  const builderBrief = parseArchitectOutput(result.output);
@@ -2678,7 +2738,6 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2678
2738
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
2679
2739
  const result = await executor.spawn(builderTask, {
2680
2740
  thinking: config.thinking,
2681
- timeout: currentMeta._builderTimeout ?? config.builderTimeout,
2682
2741
  label: 'meta-builder',
2683
2742
  });
2684
2743
  const rawOutput = result.output;
@@ -2773,7 +2832,6 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2773
2832
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
2774
2833
  const result = await executor.spawn(criticTask, {
2775
2834
  thinking: config.thinking,
2776
- timeout: currentMeta._criticTimeout ?? config.criticTimeout,
2777
2835
  label: 'meta-critic',
2778
2836
  });
2779
2837
  const feedback = parseCriticOutput(result.output);
@@ -3686,12 +3744,6 @@ async function createMeta(ownerPath, options) {
3686
3744
  metaJson._crossRefs = options.crossRefs;
3687
3745
  if (options?.steer !== undefined)
3688
3746
  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
3747
  const metaJsonPath = join(metaDir, 'meta.json');
3696
3748
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
3697
3749
  return { metaDir, _id };
@@ -3767,9 +3819,6 @@ async function autoSeedPass(rules, watcher, logger) {
3767
3819
  candidates.set(dir, {
3768
3820
  steer: rule.steer,
3769
3821
  crossRefs: rule.crossRefs,
3770
- architectTimeout: rule.architectTimeout,
3771
- builderTimeout: rule.builderTimeout,
3772
- criticTimeout: rule.criticTimeout,
3773
3822
  });
3774
3823
  }
3775
3824
  }
@@ -4339,7 +4388,7 @@ function registerMetasRoutes(app, deps) {
4339
4388
  /**
4340
4389
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
4341
4390
  *
4342
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4391
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
4343
4392
  * Set a field to null to remove it. Unknown keys are rejected.
4344
4393
  *
4345
4394
  * @module routes/metasUpdate
@@ -4351,9 +4400,6 @@ const updateBodySchema = z
4351
4400
  _depth: z.union([z.number(), z.null()]).optional(),
4352
4401
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
4353
4402
  _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
4403
  })
4358
4404
  .strict();
4359
4405
  function registerMetasUpdateRoute(app, deps) {
@@ -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
 
@@ -2884,7 +2945,6 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2884
2945
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
2885
2946
  const result = await executor.spawn(architectTask, {
2886
2947
  thinking: config.thinking,
2887
- timeout: currentMeta._architectTimeout ?? config.architectTimeout,
2888
2948
  label: 'meta-architect',
2889
2949
  });
2890
2950
  const builderBrief = parseArchitectOutput(result.output);
@@ -2936,7 +2996,6 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2936
2996
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
2937
2997
  const result = await executor.spawn(builderTask, {
2938
2998
  thinking: config.thinking,
2939
- timeout: currentMeta._builderTimeout ?? config.builderTimeout,
2940
2999
  label: 'meta-builder',
2941
3000
  });
2942
3001
  const rawOutput = result.output;
@@ -3031,7 +3090,6 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
3031
3090
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
3032
3091
  const result = await executor.spawn(criticTask, {
3033
3092
  thinking: config.thinking,
3034
- timeout: currentMeta._criticTimeout ?? config.criticTimeout,
3035
3093
  label: 'meta-critic',
3036
3094
  });
3037
3095
  const feedback = parseCriticOutput(result.output);
@@ -3944,12 +4002,6 @@ async function createMeta(ownerPath, options) {
3944
4002
  metaJson._crossRefs = options.crossRefs;
3945
4003
  if (options?.steer !== undefined)
3946
4004
  metaJson._steer = options.steer;
3947
- if (options?.architectTimeout !== undefined)
3948
- metaJson._architectTimeout = options.architectTimeout;
3949
- if (options?.builderTimeout !== undefined)
3950
- metaJson._builderTimeout = options.builderTimeout;
3951
- if (options?.criticTimeout !== undefined)
3952
- metaJson._criticTimeout = options.criticTimeout;
3953
4005
  const metaJsonPath = join(metaDir, 'meta.json');
3954
4006
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
3955
4007
  return { metaDir, _id };
@@ -4025,9 +4077,6 @@ async function autoSeedPass(rules, watcher, logger) {
4025
4077
  candidates.set(dir, {
4026
4078
  steer: rule.steer,
4027
4079
  crossRefs: rule.crossRefs,
4028
- architectTimeout: rule.architectTimeout,
4029
- builderTimeout: rule.builderTimeout,
4030
- criticTimeout: rule.criticTimeout,
4031
4080
  });
4032
4081
  }
4033
4082
  }
@@ -4597,7 +4646,7 @@ function registerMetasRoutes(app, deps) {
4597
4646
  /**
4598
4647
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
4599
4648
  *
4600
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
4649
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
4601
4650
  * Set a field to null to remove it. Unknown keys are rejected.
4602
4651
  *
4603
4652
  * @module routes/metasUpdate
@@ -4609,9 +4658,6 @@ const updateBodySchema = z
4609
4658
  _depth: z.union([z.number(), z.null()]).optional(),
4610
4659
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
4611
4660
  _disabled: z.union([z.boolean(), z.null()]).optional(),
4612
- _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4613
- _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4614
- _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
4615
4661
  })
4616
4662
  .strict();
4617
4663
  function registerMetasUpdateRoute(app, deps) {
@@ -5817,12 +5863,6 @@ const metaJsonSchema = z
5817
5863
  _error: metaErrorSchema.optional(),
5818
5864
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
5819
5865
  _disabled: z.boolean().optional(),
5820
- /** Per-entity timeout override for the architect phase (seconds, min 30). */
5821
- _architectTimeout: z.number().int().min(30).optional(),
5822
- /** Per-entity timeout override for the builder phase (seconds, min 30). */
5823
- _builderTimeout: z.number().int().min(30).optional(),
5824
- /** Per-entity timeout override for the critic phase (seconds, min 30). */
5825
- _criticTimeout: z.number().int().min(30).optional(),
5826
5866
  /**
5827
5867
  * SHA-256 hash of ancestor _builder text at last synthesis.
5828
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"). */
@@ -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.12"
113
+ "version": "0.16.0"
114
114
  }