@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 +1 -1
- package/dist/bootstrap.d.ts +11 -0
- package/dist/cli/jeeves-meta/architect.md +1 -4
- package/dist/cli/jeeves-meta/index.js +226 -75
- package/dist/executor/GatewayExecutor.d.ts +20 -1
- package/dist/index.js +226 -81
- package/dist/interfaces/MetaExecutor.d.ts +0 -2
- package/dist/orchestrator/orchestratePhase.d.ts +7 -1
- package/dist/prompts/architect.md +1 -4
- package/dist/routes/metasUpdate.d.ts +1 -1
- package/dist/schema/config.d.ts +0 -9
- package/dist/schema/meta.d.ts +0 -3
- package/dist/seed/createMeta.d.ts +0 -6
- package/package.json +3 -3
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
|
|
69
|
+
| PATCH | `/metas/:path` | Update user-settable reserved properties (`_steer`, `_emphasis`, `_depth`, `_crossRefs`, `_disabled`) |
|
|
70
70
|
|
|
71
71
|
## Configuration
|
|
72
72
|
|
package/dist/bootstrap.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
1004
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
1091
|
-
if (
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
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'
|
|
1111
|
-
const text =
|
|
1112
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
1538
|
-
|
|
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
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
1625
|
-
if (
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
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'
|
|
1645
|
-
const text =
|
|
1646
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
package/dist/schema/config.d.ts
CHANGED
|
@@ -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. */
|
package/dist/schema/meta.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
113
|
+
"version": "0.16.0"
|
|
114
114
|
}
|