@karmaniverous/jeeves-meta 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/cli/jeeves-meta/index.js +1091 -621
- package/dist/index.d.ts +177 -52
- package/dist/index.js +1176 -163
- package/package.json +1 -1
|
@@ -8917,22 +8917,6 @@ async function isStale(scopePrefix, meta, watcher) {
|
|
|
8917
8917
|
}
|
|
8918
8918
|
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
8919
8919
|
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
8920
|
-
/**
|
|
8921
|
-
* Compute actual staleness in seconds (now minus _generatedAt).
|
|
8922
|
-
*
|
|
8923
|
-
* Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
|
|
8924
|
-
* (1 year) so that depth weighting can differentiate them. Without
|
|
8925
|
-
* bounding, `Infinity * depthFactor` = `Infinity` for all depths.
|
|
8926
|
-
*
|
|
8927
|
-
* @param meta - Current meta.json content.
|
|
8928
|
-
* @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
|
|
8929
|
-
*/
|
|
8930
|
-
function actualStaleness(meta) {
|
|
8931
|
-
if (!meta._generatedAt)
|
|
8932
|
-
return MAX_STALENESS_SECONDS;
|
|
8933
|
-
const generatedMs = new Date(meta._generatedAt).getTime();
|
|
8934
|
-
return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
|
|
8935
|
-
}
|
|
8936
8920
|
/**
|
|
8937
8921
|
* Check whether the architect step should be triggered.
|
|
8938
8922
|
*
|
|
@@ -9307,12 +9291,12 @@ class GatewayExecutor {
|
|
|
9307
9291
|
return data;
|
|
9308
9292
|
}
|
|
9309
9293
|
/** Look up totalTokens for a session via sessions_list. */
|
|
9310
|
-
async getSessionTokens(sessionKey
|
|
9294
|
+
async getSessionTokens(sessionKey) {
|
|
9311
9295
|
try {
|
|
9312
9296
|
const result = await this.invoke('sessions_list', {
|
|
9313
9297
|
limit: 20,
|
|
9314
9298
|
messageLimit: 0,
|
|
9315
|
-
}
|
|
9299
|
+
});
|
|
9316
9300
|
const sessions = (result.result?.details?.sessions ??
|
|
9317
9301
|
result.result?.sessions ??
|
|
9318
9302
|
[]);
|
|
@@ -9323,6 +9307,10 @@ class GatewayExecutor {
|
|
|
9323
9307
|
return undefined;
|
|
9324
9308
|
}
|
|
9325
9309
|
}
|
|
9310
|
+
/** Whether this executor has been aborted by the operator. */
|
|
9311
|
+
get aborted() {
|
|
9312
|
+
return this.controller.signal.aborted;
|
|
9313
|
+
}
|
|
9326
9314
|
/** Abort the currently running spawn, if any. */
|
|
9327
9315
|
abort() {
|
|
9328
9316
|
this.controller.abort();
|
|
@@ -9340,7 +9328,6 @@ class GatewayExecutor {
|
|
|
9340
9328
|
// Generate unique output path for file-based output
|
|
9341
9329
|
const outputId = randomUUID();
|
|
9342
9330
|
const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
|
|
9343
|
-
const invokeSessionKey = 'agent:main:meta-invoke:' + outputId;
|
|
9344
9331
|
// Append file output instruction to the task
|
|
9345
9332
|
const taskWithOutput = task +
|
|
9346
9333
|
'\n\n## OUTPUT DELIVERY\n\n' +
|
|
@@ -9358,7 +9345,7 @@ class GatewayExecutor {
|
|
|
9358
9345
|
runTimeoutSeconds: timeoutSeconds,
|
|
9359
9346
|
...(options?.thinking ? { thinking: options.thinking } : {}),
|
|
9360
9347
|
...(options?.model ? { model: options.model } : {}),
|
|
9361
|
-
}
|
|
9348
|
+
});
|
|
9362
9349
|
const details = (spawnResult.result?.details ?? spawnResult.result);
|
|
9363
9350
|
const sessionKey = details?.childSessionKey ?? details?.sessionKey;
|
|
9364
9351
|
if (typeof sessionKey !== 'string' || !sessionKey) {
|
|
@@ -9378,7 +9365,7 @@ class GatewayExecutor {
|
|
|
9378
9365
|
sessionKey,
|
|
9379
9366
|
limit: 5,
|
|
9380
9367
|
includeTools: false,
|
|
9381
|
-
}
|
|
9368
|
+
});
|
|
9382
9369
|
const messages = historyResult.result?.details?.messages ??
|
|
9383
9370
|
historyResult.result?.messages ??
|
|
9384
9371
|
[];
|
|
@@ -9391,7 +9378,7 @@ class GatewayExecutor {
|
|
|
9391
9378
|
lastMsg.stopReason !== 'toolUse' &&
|
|
9392
9379
|
lastMsg.stopReason !== 'error') {
|
|
9393
9380
|
// Fetch token usage from session metadata
|
|
9394
|
-
const tokens = await this.getSessionTokens(sessionKey
|
|
9381
|
+
const tokens = await this.getSessionTokens(sessionKey);
|
|
9395
9382
|
// Read output from file (sub-agent wrote it via Write tool)
|
|
9396
9383
|
if (existsSync(outputPath)) {
|
|
9397
9384
|
try {
|
|
@@ -9941,26 +9928,6 @@ function buildCriticTask(ctx, meta, config) {
|
|
|
9941
9928
|
return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
|
|
9942
9929
|
}
|
|
9943
9930
|
|
|
9944
|
-
/**
|
|
9945
|
-
* Exponential moving average helper for token tracking.
|
|
9946
|
-
*
|
|
9947
|
-
* @module ema
|
|
9948
|
-
*/
|
|
9949
|
-
const DEFAULT_DECAY = 0.3;
|
|
9950
|
-
/**
|
|
9951
|
-
* Compute exponential moving average.
|
|
9952
|
-
*
|
|
9953
|
-
* @param current - New observation.
|
|
9954
|
-
* @param previous - Previous EMA value, or undefined for first observation.
|
|
9955
|
-
* @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
|
|
9956
|
-
* @returns Updated EMA.
|
|
9957
|
-
*/
|
|
9958
|
-
function computeEma(current, previous, decay = DEFAULT_DECAY) {
|
|
9959
|
-
if (previous === undefined)
|
|
9960
|
-
return current;
|
|
9961
|
-
return decay * current + (1 - decay) * previous;
|
|
9962
|
-
}
|
|
9963
|
-
|
|
9964
9931
|
/**
|
|
9965
9932
|
* Structured error from a synthesis step failure.
|
|
9966
9933
|
*
|
|
@@ -9984,8 +9951,24 @@ const metaErrorSchema = z.object({
|
|
|
9984
9951
|
*
|
|
9985
9952
|
* @module schema/meta
|
|
9986
9953
|
*/
|
|
9954
|
+
/** Valid states for a synthesis phase. */
|
|
9955
|
+
const phaseStatuses = [
|
|
9956
|
+
'fresh',
|
|
9957
|
+
'stale',
|
|
9958
|
+
'pending',
|
|
9959
|
+
'running',
|
|
9960
|
+
'failed',
|
|
9961
|
+
];
|
|
9962
|
+
/** Zod schema for a per-phase status value. */
|
|
9963
|
+
const phaseStatusSchema = z.enum(phaseStatuses);
|
|
9964
|
+
/** Zod schema for the per-meta phase state record. */
|
|
9965
|
+
const phaseStateSchema = z.object({
|
|
9966
|
+
architect: phaseStatusSchema,
|
|
9967
|
+
builder: phaseStatusSchema,
|
|
9968
|
+
critic: phaseStatusSchema,
|
|
9969
|
+
});
|
|
9987
9970
|
/** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
|
|
9988
|
-
|
|
9971
|
+
z
|
|
9989
9972
|
.object({
|
|
9990
9973
|
/** Stable identity. Auto-generated on first synthesis if not provided. */
|
|
9991
9974
|
_id: z.uuid().optional(),
|
|
@@ -10064,105 +10047,15 @@ const metaJsonSchema = z
|
|
|
10064
10047
|
_error: metaErrorSchema.optional(),
|
|
10065
10048
|
/** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
|
|
10066
10049
|
_disabled: z.boolean().optional(),
|
|
10050
|
+
/**
|
|
10051
|
+
* Per-phase state machine record. Engine-managed.
|
|
10052
|
+
* Keyed by phase name (architect, builder, critic) with status values.
|
|
10053
|
+
* Persisted to survive ticks; derived on first load for back-compat.
|
|
10054
|
+
*/
|
|
10055
|
+
_phaseState: phaseStateSchema.optional(),
|
|
10067
10056
|
})
|
|
10068
10057
|
.loose();
|
|
10069
10058
|
|
|
10070
|
-
/**
|
|
10071
|
-
* Merge synthesis results into meta.json.
|
|
10072
|
-
*
|
|
10073
|
-
* Preserves human-set fields (_id, _steer, _depth).
|
|
10074
|
-
* Writes engine fields (_generatedAt, _structureHash, etc.).
|
|
10075
|
-
* Validates against schema before writing.
|
|
10076
|
-
*
|
|
10077
|
-
* @module orchestrator/merge
|
|
10078
|
-
*/
|
|
10079
|
-
/**
|
|
10080
|
-
* Merge results into meta.json and write atomically.
|
|
10081
|
-
*
|
|
10082
|
-
* @param options - Merge options.
|
|
10083
|
-
* @returns The updated MetaJson.
|
|
10084
|
-
* @throws If validation fails (malformed output).
|
|
10085
|
-
*/
|
|
10086
|
-
async function mergeAndWrite(options) {
|
|
10087
|
-
const merged = {
|
|
10088
|
-
// Preserve human-set fields (auto-generate _id on first synthesis)
|
|
10089
|
-
_id: options.current._id ?? randomUUID(),
|
|
10090
|
-
_steer: options.current._steer,
|
|
10091
|
-
_depth: options.current._depth,
|
|
10092
|
-
_emphasis: options.current._emphasis,
|
|
10093
|
-
// Engine fields
|
|
10094
|
-
_architect: options.architect,
|
|
10095
|
-
_builder: options.builder,
|
|
10096
|
-
_critic: options.critic,
|
|
10097
|
-
_generatedAt: options.stateOnly
|
|
10098
|
-
? options.current._generatedAt
|
|
10099
|
-
: new Date().toISOString(),
|
|
10100
|
-
_structureHash: options.structureHash,
|
|
10101
|
-
_synthesisCount: options.synthesisCount,
|
|
10102
|
-
// Token tracking
|
|
10103
|
-
_architectTokens: options.architectTokens,
|
|
10104
|
-
_builderTokens: options.builderTokens,
|
|
10105
|
-
_criticTokens: options.criticTokens,
|
|
10106
|
-
_architectTokensAvg: options.architectTokens !== undefined
|
|
10107
|
-
? computeEma(options.architectTokens, options.current._architectTokensAvg)
|
|
10108
|
-
: options.current._architectTokensAvg,
|
|
10109
|
-
_builderTokensAvg: options.builderTokens !== undefined
|
|
10110
|
-
? computeEma(options.builderTokens, options.current._builderTokensAvg)
|
|
10111
|
-
: options.current._builderTokensAvg,
|
|
10112
|
-
_criticTokensAvg: options.criticTokens !== undefined
|
|
10113
|
-
? computeEma(options.criticTokens, options.current._criticTokensAvg)
|
|
10114
|
-
: options.current._criticTokensAvg,
|
|
10115
|
-
// Content from builder (stateOnly preserves previous content)
|
|
10116
|
-
_content: options.stateOnly
|
|
10117
|
-
? options.current._content
|
|
10118
|
-
: (options.builderOutput?.content ?? options.current._content),
|
|
10119
|
-
// Feedback from critic
|
|
10120
|
-
_feedback: options.feedback ?? options.current._feedback,
|
|
10121
|
-
// Progressive state
|
|
10122
|
-
_state: options.state,
|
|
10123
|
-
// Error handling
|
|
10124
|
-
_error: options.error ?? undefined,
|
|
10125
|
-
// Spread structured fields from builder
|
|
10126
|
-
...options.builderOutput?.fields,
|
|
10127
|
-
};
|
|
10128
|
-
// Clean up undefined optional fields
|
|
10129
|
-
if (merged._steer === undefined)
|
|
10130
|
-
delete merged._steer;
|
|
10131
|
-
if (merged._depth === undefined)
|
|
10132
|
-
delete merged._depth;
|
|
10133
|
-
if (merged._emphasis === undefined)
|
|
10134
|
-
delete merged._emphasis;
|
|
10135
|
-
if (merged._architectTokens === undefined)
|
|
10136
|
-
delete merged._architectTokens;
|
|
10137
|
-
if (merged._builderTokens === undefined)
|
|
10138
|
-
delete merged._builderTokens;
|
|
10139
|
-
if (merged._criticTokens === undefined)
|
|
10140
|
-
delete merged._criticTokens;
|
|
10141
|
-
if (merged._architectTokensAvg === undefined)
|
|
10142
|
-
delete merged._architectTokensAvg;
|
|
10143
|
-
if (merged._builderTokensAvg === undefined)
|
|
10144
|
-
delete merged._builderTokensAvg;
|
|
10145
|
-
if (merged._criticTokensAvg === undefined)
|
|
10146
|
-
delete merged._criticTokensAvg;
|
|
10147
|
-
if (merged._state === undefined)
|
|
10148
|
-
delete merged._state;
|
|
10149
|
-
if (merged._error === undefined)
|
|
10150
|
-
delete merged._error;
|
|
10151
|
-
if (merged._content === undefined)
|
|
10152
|
-
delete merged._content;
|
|
10153
|
-
if (merged._feedback === undefined)
|
|
10154
|
-
delete merged._feedback;
|
|
10155
|
-
// Validate
|
|
10156
|
-
const result = metaJsonSchema.safeParse(merged);
|
|
10157
|
-
if (!result.success) {
|
|
10158
|
-
throw new Error(`Meta validation failed: ${result.error.message}`);
|
|
10159
|
-
}
|
|
10160
|
-
// Write to specified path (lock staging) or default meta.json
|
|
10161
|
-
const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
|
|
10162
|
-
await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
|
|
10163
|
-
return result.data;
|
|
10164
|
-
}
|
|
10165
|
-
|
|
10166
10059
|
/**
|
|
10167
10060
|
* Build a minimal MetaNode from a known meta path using watcher walk.
|
|
10168
10061
|
*
|
|
@@ -10343,43 +10236,6 @@ function computeStructureHash(filePaths) {
|
|
|
10343
10236
|
return createHash('sha256').update(content).digest('hex');
|
|
10344
10237
|
}
|
|
10345
10238
|
|
|
10346
|
-
/**
|
|
10347
|
-
* Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
|
|
10348
|
-
*
|
|
10349
|
-
* @module orchestrator/finalizeCycle
|
|
10350
|
-
*/
|
|
10351
|
-
/** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
|
|
10352
|
-
async function finalizeCycle(opts) {
|
|
10353
|
-
const lockPath = join(opts.metaPath, '.lock');
|
|
10354
|
-
const metaJsonPath = join(opts.metaPath, 'meta.json');
|
|
10355
|
-
// Stage: write merged result to .lock (sequential — ordering matters)
|
|
10356
|
-
const updated = await mergeAndWrite({
|
|
10357
|
-
metaPath: opts.metaPath,
|
|
10358
|
-
current: opts.current,
|
|
10359
|
-
architect: opts.architect,
|
|
10360
|
-
builder: opts.builder,
|
|
10361
|
-
critic: opts.critic,
|
|
10362
|
-
builderOutput: opts.builderOutput,
|
|
10363
|
-
feedback: opts.feedback,
|
|
10364
|
-
structureHash: opts.structureHash,
|
|
10365
|
-
synthesisCount: opts.synthesisCount,
|
|
10366
|
-
error: opts.error,
|
|
10367
|
-
architectTokens: opts.architectTokens,
|
|
10368
|
-
builderTokens: opts.builderTokens,
|
|
10369
|
-
criticTokens: opts.criticTokens,
|
|
10370
|
-
outputPath: lockPath,
|
|
10371
|
-
state: opts.state,
|
|
10372
|
-
stateOnly: opts.stateOnly,
|
|
10373
|
-
});
|
|
10374
|
-
// Commit: copy .lock → meta.json
|
|
10375
|
-
await copyFile(lockPath, metaJsonPath);
|
|
10376
|
-
// Archive + prune from the committed meta.json (sequential)
|
|
10377
|
-
await createSnapshot(opts.metaPath, updated);
|
|
10378
|
-
await pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
10379
|
-
// .lock is cleaned up by the finally block (releaseLock)
|
|
10380
|
-
return updated;
|
|
10381
|
-
}
|
|
10382
|
-
|
|
10383
10239
|
/**
|
|
10384
10240
|
* Parse subprocess outputs for each synthesis step.
|
|
10385
10241
|
*
|
|
@@ -10478,162 +10334,441 @@ function parseCriticOutput(output) {
|
|
|
10478
10334
|
}
|
|
10479
10335
|
|
|
10480
10336
|
/**
|
|
10481
|
-
*
|
|
10337
|
+
* Pure phase-state transition functions.
|
|
10338
|
+
*
|
|
10339
|
+
* Implements every row of the §8 "Transitions and invalidation cascade" table.
|
|
10340
|
+
* No I/O — pure functions over PhaseState and documented inputs.
|
|
10482
10341
|
*
|
|
10483
|
-
* @module
|
|
10342
|
+
* @module phaseState/phaseTransitions
|
|
10343
|
+
*/
|
|
10344
|
+
/**
|
|
10345
|
+
* Create a fresh (fully-complete) phase state.
|
|
10346
|
+
*/
|
|
10347
|
+
function freshPhaseState() {
|
|
10348
|
+
return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
|
|
10349
|
+
}
|
|
10350
|
+
/**
|
|
10351
|
+
* Create a phase state for a never-synthesized meta (all pending from architect).
|
|
10484
10352
|
*/
|
|
10353
|
+
function initialPhaseState() {
|
|
10354
|
+
return { architect: 'pending', builder: 'stale', critic: 'stale' };
|
|
10355
|
+
}
|
|
10485
10356
|
/**
|
|
10486
|
-
*
|
|
10357
|
+
* Enforce the per-meta invariant: at most one phase is pending or running,
|
|
10358
|
+
* and it is the first non-fresh phase in pipeline order.
|
|
10487
10359
|
*
|
|
10488
|
-
*
|
|
10489
|
-
* if the caller should fall through to a hard failure.
|
|
10360
|
+
* Stale phases that become the first non-fresh phase are promoted to pending.
|
|
10490
10361
|
*/
|
|
10491
|
-
|
|
10492
|
-
const
|
|
10493
|
-
let
|
|
10494
|
-
|
|
10495
|
-
const
|
|
10496
|
-
|
|
10362
|
+
function enforceInvariant(state) {
|
|
10363
|
+
const result = { ...state };
|
|
10364
|
+
let foundNonFresh = false;
|
|
10365
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
10366
|
+
const s = result[phase];
|
|
10367
|
+
if (s === 'fresh')
|
|
10368
|
+
continue;
|
|
10369
|
+
if (!foundNonFresh) {
|
|
10370
|
+
foundNonFresh = true;
|
|
10371
|
+
// First non-fresh: if stale, promote to pending
|
|
10372
|
+
if (s === 'stale') {
|
|
10373
|
+
result[phase] = 'pending';
|
|
10374
|
+
}
|
|
10375
|
+
// pending, running, failed stay as-is
|
|
10376
|
+
}
|
|
10377
|
+
else {
|
|
10378
|
+
// Subsequent non-fresh: must not be pending or running
|
|
10379
|
+
if (s === 'pending') {
|
|
10380
|
+
result[phase] = 'stale';
|
|
10381
|
+
}
|
|
10382
|
+
// running in non-first position would be a bug, but don't mask it
|
|
10383
|
+
}
|
|
10497
10384
|
}
|
|
10498
|
-
|
|
10499
|
-
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10385
|
+
return result;
|
|
10386
|
+
}
|
|
10387
|
+
// ── Phase success transitions ──────────────────────────────────────────
|
|
10388
|
+
/**
|
|
10389
|
+
* Architect completes successfully.
|
|
10390
|
+
* architect → fresh; builder → pending; critic → stale.
|
|
10391
|
+
*/
|
|
10392
|
+
function architectSuccess(state) {
|
|
10393
|
+
return enforceInvariant({
|
|
10394
|
+
architect: 'fresh',
|
|
10395
|
+
builder: state.builder === 'failed' ? 'failed' : 'pending',
|
|
10396
|
+
critic: state.critic === 'fresh' ? 'stale' : state.critic,
|
|
10397
|
+
});
|
|
10398
|
+
}
|
|
10399
|
+
/**
|
|
10400
|
+
* Builder completes successfully.
|
|
10401
|
+
* builder → fresh; critic → pending.
|
|
10402
|
+
*/
|
|
10403
|
+
function builderSuccess(state) {
|
|
10404
|
+
return enforceInvariant({
|
|
10405
|
+
...state,
|
|
10406
|
+
builder: 'fresh',
|
|
10407
|
+
critic: state.critic === 'failed' ? 'failed' : 'pending',
|
|
10408
|
+
});
|
|
10409
|
+
}
|
|
10410
|
+
/**
|
|
10411
|
+
* Critic completes successfully.
|
|
10412
|
+
* critic → fresh. Meta becomes fully fresh.
|
|
10413
|
+
*/
|
|
10414
|
+
function criticSuccess(state) {
|
|
10415
|
+
return enforceInvariant({
|
|
10416
|
+
...state,
|
|
10417
|
+
critic: 'fresh',
|
|
10418
|
+
});
|
|
10419
|
+
}
|
|
10420
|
+
// ── Failure transition ─────────────────────────────────────────────────
|
|
10421
|
+
/**
|
|
10422
|
+
* A phase fails (error, timeout, or abort).
|
|
10423
|
+
* Target phase → failed; upstream and downstream unchanged.
|
|
10424
|
+
*/
|
|
10425
|
+
function phaseFailed(state, phase) {
|
|
10426
|
+
return enforceInvariant({
|
|
10427
|
+
...state,
|
|
10428
|
+
[phase]: 'failed',
|
|
10429
|
+
});
|
|
10430
|
+
}
|
|
10431
|
+
// ── Surgical retry ─────────────────────────────────────────────────────
|
|
10432
|
+
/**
|
|
10433
|
+
* Retry a failed phase: failed → pending.
|
|
10434
|
+
* Only valid when the phase is currently failed.
|
|
10435
|
+
*/
|
|
10436
|
+
function retryPhase(state, phase) {
|
|
10437
|
+
if (state[phase] !== 'failed')
|
|
10438
|
+
return state;
|
|
10439
|
+
return enforceInvariant({
|
|
10440
|
+
...state,
|
|
10441
|
+
[phase]: 'pending',
|
|
10442
|
+
});
|
|
10443
|
+
}
|
|
10444
|
+
/**
|
|
10445
|
+
* Retry all failed phases: each failed phase → pending.
|
|
10446
|
+
* Used by scheduler ticks and queue reads to auto-promote failed phases.
|
|
10447
|
+
*/
|
|
10448
|
+
function retryAllFailed(state) {
|
|
10449
|
+
let result = state;
|
|
10450
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
10451
|
+
if (result[phase] === 'failed') {
|
|
10452
|
+
result = retryPhase(result, phase);
|
|
10530
10453
|
}
|
|
10531
10454
|
}
|
|
10455
|
+
return result;
|
|
10456
|
+
}
|
|
10457
|
+
// ── Running transition ─────────────────────────────────────────────────
|
|
10458
|
+
/**
|
|
10459
|
+
* Mark a phase as running (scheduler picks it).
|
|
10460
|
+
*/
|
|
10461
|
+
function phaseRunning(state, phase) {
|
|
10462
|
+
return {
|
|
10463
|
+
...state,
|
|
10464
|
+
[phase]: 'running',
|
|
10465
|
+
};
|
|
10466
|
+
}
|
|
10467
|
+
// ── Query helpers ──────────────────────────────────────────────────────
|
|
10468
|
+
/**
|
|
10469
|
+
* Get the owed phase: first non-fresh phase in pipeline order, or null.
|
|
10470
|
+
*/
|
|
10471
|
+
function getOwedPhase(state) {
|
|
10472
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
10473
|
+
if (state[phase] !== 'fresh')
|
|
10474
|
+
return phase;
|
|
10475
|
+
}
|
|
10532
10476
|
return null;
|
|
10533
10477
|
}
|
|
10478
|
+
/**
|
|
10479
|
+
* Check if a meta is fully fresh (all phases fresh).
|
|
10480
|
+
*/
|
|
10481
|
+
function isFullyFresh(state) {
|
|
10482
|
+
return (state.architect === 'fresh' &&
|
|
10483
|
+
state.builder === 'fresh' &&
|
|
10484
|
+
state.critic === 'fresh');
|
|
10485
|
+
}
|
|
10486
|
+
/**
|
|
10487
|
+
* Get the scheduler priority band for a meta's owed phase.
|
|
10488
|
+
* 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
|
|
10489
|
+
*/
|
|
10490
|
+
function getPriorityBand(state) {
|
|
10491
|
+
const owed = getOwedPhase(state);
|
|
10492
|
+
if (!owed)
|
|
10493
|
+
return null;
|
|
10494
|
+
if (owed === 'critic')
|
|
10495
|
+
return 1;
|
|
10496
|
+
if (owed === 'builder')
|
|
10497
|
+
return 2;
|
|
10498
|
+
return 3;
|
|
10499
|
+
}
|
|
10534
10500
|
|
|
10535
10501
|
/**
|
|
10536
|
-
*
|
|
10502
|
+
* Backward-compatible derivation of _phaseState from existing meta fields.
|
|
10537
10503
|
*
|
|
10538
|
-
*
|
|
10504
|
+
* When a meta is loaded from disk without _phaseState, this reconstructs
|
|
10505
|
+
* the phase state from _content, _builder, _state, _error.step, and
|
|
10506
|
+
* the architect-invalidating inputs.
|
|
10507
|
+
*
|
|
10508
|
+
* @module phaseState/derivePhaseState
|
|
10539
10509
|
*/
|
|
10540
|
-
/**
|
|
10541
|
-
|
|
10542
|
-
|
|
10543
|
-
|
|
10544
|
-
|
|
10545
|
-
|
|
10546
|
-
|
|
10547
|
-
|
|
10548
|
-
|
|
10549
|
-
|
|
10550
|
-
|
|
10551
|
-
|
|
10552
|
-
|
|
10553
|
-
|
|
10554
|
-
|
|
10555
|
-
|
|
10556
|
-
|
|
10557
|
-
|
|
10558
|
-
|
|
10559
|
-
|
|
10560
|
-
|
|
10561
|
-
|
|
10562
|
-
|
|
10563
|
-
|
|
10564
|
-
|
|
10565
|
-
|
|
10566
|
-
|
|
10567
|
-
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10571
|
-
let builderBrief = currentMeta._builder ?? '';
|
|
10572
|
-
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
10573
|
-
let stepError = null;
|
|
10574
|
-
let architectTokens;
|
|
10575
|
-
let builderTokens;
|
|
10576
|
-
let criticTokens;
|
|
10577
|
-
// Shared base options for all finalizeCycle calls.
|
|
10578
|
-
// Note: synthesisCount is excluded because it mutates during the pipeline.
|
|
10579
|
-
const baseFinalizeOptions = {
|
|
10580
|
-
metaPath: node.metaPath,
|
|
10581
|
-
current: currentMeta,
|
|
10582
|
-
config,
|
|
10583
|
-
architect: currentMeta._architect ?? '',
|
|
10584
|
-
critic: currentMeta._critic ?? '',
|
|
10585
|
-
structureHash: newStructureHash,
|
|
10586
|
-
};
|
|
10587
|
-
if (architectTriggered) {
|
|
10588
|
-
try {
|
|
10589
|
-
await onProgress?.({
|
|
10590
|
-
type: 'phase_start',
|
|
10591
|
-
path: node.ownerPath,
|
|
10592
|
-
phase: 'architect',
|
|
10593
|
-
});
|
|
10594
|
-
const phaseStart = Date.now();
|
|
10595
|
-
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
10596
|
-
const architectResult = await executor.spawn(architectTask, {
|
|
10597
|
-
thinking: config.thinking,
|
|
10598
|
-
timeout: config.architectTimeout,
|
|
10599
|
-
label: 'meta-architect',
|
|
10600
|
-
});
|
|
10601
|
-
builderBrief = parseArchitectOutput(architectResult.output);
|
|
10602
|
-
architectTokens = architectResult.tokens;
|
|
10603
|
-
synthesisCount = 0;
|
|
10604
|
-
await onProgress?.({
|
|
10605
|
-
type: 'phase_complete',
|
|
10606
|
-
path: node.ownerPath,
|
|
10607
|
-
phase: 'architect',
|
|
10608
|
-
tokens: architectTokens,
|
|
10609
|
-
durationMs: Date.now() - phaseStart,
|
|
10610
|
-
});
|
|
10611
|
-
}
|
|
10612
|
-
catch (err) {
|
|
10613
|
-
stepError = toMetaError('architect', err);
|
|
10614
|
-
if (!currentMeta._builder) {
|
|
10615
|
-
// No cached builder — cycle fails
|
|
10616
|
-
await finalizeCycle({
|
|
10617
|
-
...baseFinalizeOptions,
|
|
10618
|
-
builder: '',
|
|
10619
|
-
builderOutput: null,
|
|
10620
|
-
feedback: null,
|
|
10621
|
-
synthesisCount,
|
|
10622
|
-
error: stepError,
|
|
10623
|
-
architectTokens,
|
|
10624
|
-
});
|
|
10625
|
-
return {
|
|
10626
|
-
synthesized: true,
|
|
10627
|
-
metaPath: node.metaPath,
|
|
10628
|
-
error: stepError,
|
|
10629
|
-
};
|
|
10510
|
+
/**
|
|
10511
|
+
* Derive _phaseState from existing meta fields.
|
|
10512
|
+
*
|
|
10513
|
+
* If the meta already has _phaseState, returns it as-is.
|
|
10514
|
+
*
|
|
10515
|
+
* Otherwise, reconstructs from available fields:
|
|
10516
|
+
* - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
|
|
10517
|
+
* - Errored meta: the failed phase is mapped from _error.step.
|
|
10518
|
+
* - Mid-cycle meta with cached _builder but no _content: builder pending.
|
|
10519
|
+
* - Fully-fresh meta: all phases fresh.
|
|
10520
|
+
* - Meta with stale architect inputs: architect pending, downstream stale.
|
|
10521
|
+
*
|
|
10522
|
+
* @param meta - The meta.json content.
|
|
10523
|
+
* @param inputs - Optional derivation inputs. If not provided, a simpler
|
|
10524
|
+
* heuristic is used (no architect invalidation check).
|
|
10525
|
+
* @returns The derived PhaseState.
|
|
10526
|
+
*/
|
|
10527
|
+
function derivePhaseState(meta, inputs) {
|
|
10528
|
+
// Already has _phaseState — use it
|
|
10529
|
+
if (meta._phaseState)
|
|
10530
|
+
return meta._phaseState;
|
|
10531
|
+
// Check for errors first — _error.step maps directly to failed phase
|
|
10532
|
+
if (meta._error) {
|
|
10533
|
+
const failedPhase = meta._error.step;
|
|
10534
|
+
const state = freshPhaseState();
|
|
10535
|
+
state[failedPhase] = 'failed';
|
|
10536
|
+
// If architect failed and no _builder, downstream is stale
|
|
10537
|
+
if (failedPhase === 'architect') {
|
|
10538
|
+
if (!meta._builder) {
|
|
10539
|
+
state.builder = 'stale';
|
|
10540
|
+
state.critic = 'stale';
|
|
10630
10541
|
}
|
|
10631
|
-
// Has cached builder — continue with existing
|
|
10632
10542
|
}
|
|
10543
|
+
// If builder failed, critic is stale
|
|
10544
|
+
if (failedPhase === 'builder') {
|
|
10545
|
+
state.critic = 'stale';
|
|
10546
|
+
}
|
|
10547
|
+
return state;
|
|
10548
|
+
}
|
|
10549
|
+
// Never synthesized: no _content AND no _builder (and no error)
|
|
10550
|
+
if (!meta._content && !meta._builder) {
|
|
10551
|
+
return initialPhaseState();
|
|
10552
|
+
}
|
|
10553
|
+
// Check architect invalidation (when inputs are provided)
|
|
10554
|
+
if (inputs) {
|
|
10555
|
+
const architectInvalidated = inputs.structureChanged ||
|
|
10556
|
+
inputs.steerChanged ||
|
|
10557
|
+
inputs.architectChanged ||
|
|
10558
|
+
inputs.crossRefsChanged ||
|
|
10559
|
+
(meta._synthesisCount ?? 0) >= inputs.architectEvery;
|
|
10560
|
+
if (architectInvalidated) {
|
|
10561
|
+
return {
|
|
10562
|
+
architect: 'pending',
|
|
10563
|
+
builder: 'stale',
|
|
10564
|
+
critic: 'stale',
|
|
10565
|
+
};
|
|
10566
|
+
}
|
|
10567
|
+
}
|
|
10568
|
+
// Has _builder but no _content: builder is pending
|
|
10569
|
+
if (meta._builder && !meta._content) {
|
|
10570
|
+
return {
|
|
10571
|
+
architect: 'fresh',
|
|
10572
|
+
builder: 'pending',
|
|
10573
|
+
critic: 'stale',
|
|
10574
|
+
};
|
|
10633
10575
|
}
|
|
10634
|
-
//
|
|
10635
|
-
|
|
10636
|
-
|
|
10576
|
+
// Has _content but no _feedback: critic is pending
|
|
10577
|
+
if (meta._content && !meta._feedback) {
|
|
10578
|
+
return {
|
|
10579
|
+
architect: 'fresh',
|
|
10580
|
+
builder: 'fresh',
|
|
10581
|
+
critic: 'pending',
|
|
10582
|
+
};
|
|
10583
|
+
}
|
|
10584
|
+
// Default: fully fresh
|
|
10585
|
+
return freshPhaseState();
|
|
10586
|
+
}
|
|
10587
|
+
|
|
10588
|
+
/**
|
|
10589
|
+
* Corpus-wide phase scheduler.
|
|
10590
|
+
*
|
|
10591
|
+
* Selects the highest-priority ready phase across all metas.
|
|
10592
|
+
* Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
|
|
10593
|
+
* Tiebreak within band: weighted staleness (§3.9).
|
|
10594
|
+
*
|
|
10595
|
+
* @module phaseState/phaseScheduler
|
|
10596
|
+
*/
|
|
10597
|
+
/**
|
|
10598
|
+
* Build phase candidates from listMetas entries.
|
|
10599
|
+
*
|
|
10600
|
+
* Derives phase state and auto-retries failed phases for each entry.
|
|
10601
|
+
* Used by orchestratePhase, queue route, and status route.
|
|
10602
|
+
*/
|
|
10603
|
+
function buildPhaseCandidates(entries) {
|
|
10604
|
+
return entries.map((entry) => ({
|
|
10605
|
+
node: entry.node,
|
|
10606
|
+
meta: entry.meta,
|
|
10607
|
+
phaseState: retryAllFailed(derivePhaseState(entry.meta)),
|
|
10608
|
+
actualStaleness: entry.stalenessSeconds,
|
|
10609
|
+
locked: entry.locked,
|
|
10610
|
+
disabled: entry.disabled,
|
|
10611
|
+
}));
|
|
10612
|
+
}
|
|
10613
|
+
/**
|
|
10614
|
+
* Rank all eligible phase candidates by priority.
|
|
10615
|
+
*
|
|
10616
|
+
* Filters to pending phases, computes effective staleness, and sorts by
|
|
10617
|
+
* band (ascending: critic first) then effective staleness (descending).
|
|
10618
|
+
*
|
|
10619
|
+
* Used by selectPhaseCandidate (returns first) and the queue route (returns all).
|
|
10620
|
+
*/
|
|
10621
|
+
function rankPhaseCandidates(metas, depthWeight) {
|
|
10622
|
+
// Filter to metas with a pending (scheduler-eligible) phase
|
|
10623
|
+
const eligible = metas.filter((m) => {
|
|
10624
|
+
if (m.locked)
|
|
10625
|
+
return false;
|
|
10626
|
+
if (m.disabled && !m.isOverride)
|
|
10627
|
+
return false;
|
|
10628
|
+
const owed = getOwedPhase(m.phaseState);
|
|
10629
|
+
if (!owed)
|
|
10630
|
+
return false;
|
|
10631
|
+
return m.phaseState[owed] === 'pending';
|
|
10632
|
+
});
|
|
10633
|
+
if (eligible.length === 0)
|
|
10634
|
+
return [];
|
|
10635
|
+
// Compute effective staleness for tiebreaking
|
|
10636
|
+
const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
|
|
10637
|
+
node: m.node,
|
|
10638
|
+
meta: m.meta,
|
|
10639
|
+
actualStaleness: m.actualStaleness,
|
|
10640
|
+
})), depthWeight);
|
|
10641
|
+
// Build candidates with band info
|
|
10642
|
+
const candidates = withStaleness.map((ws, i) => {
|
|
10643
|
+
const m = eligible[i];
|
|
10644
|
+
const owedPhase = getOwedPhase(m.phaseState);
|
|
10645
|
+
return {
|
|
10646
|
+
node: ws.node,
|
|
10647
|
+
meta: ws.meta,
|
|
10648
|
+
phaseState: m.phaseState,
|
|
10649
|
+
owedPhase,
|
|
10650
|
+
band: getPriorityBand(m.phaseState),
|
|
10651
|
+
actualStaleness: ws.actualStaleness,
|
|
10652
|
+
effectiveStaleness: ws.effectiveStaleness,
|
|
10653
|
+
};
|
|
10654
|
+
});
|
|
10655
|
+
// Sort by band (ascending = critic first) then effective staleness (descending)
|
|
10656
|
+
candidates.sort((a, b) => {
|
|
10657
|
+
if (a.band !== b.band)
|
|
10658
|
+
return a.band - b.band;
|
|
10659
|
+
return b.effectiveStaleness - a.effectiveStaleness;
|
|
10660
|
+
});
|
|
10661
|
+
return candidates;
|
|
10662
|
+
}
|
|
10663
|
+
/**
|
|
10664
|
+
* Select the best phase candidate across the corpus.
|
|
10665
|
+
*
|
|
10666
|
+
* @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
|
|
10667
|
+
* @param depthWeight - Config depthWeight for staleness tiebreak.
|
|
10668
|
+
* @returns The winning candidate, or null if no phase is ready.
|
|
10669
|
+
*/
|
|
10670
|
+
function selectPhaseCandidate(metas, depthWeight) {
|
|
10671
|
+
return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
|
|
10672
|
+
}
|
|
10673
|
+
|
|
10674
|
+
/**
|
|
10675
|
+
* Per-phase executors for the phase-state machine.
|
|
10676
|
+
*
|
|
10677
|
+
* Each function runs exactly one phase on one meta, updates _phaseState
|
|
10678
|
+
* via pure transitions, and persists via the lock-staged write.
|
|
10679
|
+
*
|
|
10680
|
+
* @module orchestrator/runPhase
|
|
10681
|
+
*/
|
|
10682
|
+
/** Write updated meta with phase state via lock staging. */
|
|
10683
|
+
async function persistPhaseState(base, phaseState, updates) {
|
|
10684
|
+
const lockPath = join(base.metaPath, '.lock');
|
|
10685
|
+
const metaJsonPath = join(base.metaPath, 'meta.json');
|
|
10686
|
+
const merged = {
|
|
10687
|
+
...base.current,
|
|
10688
|
+
...updates,
|
|
10689
|
+
_phaseState: phaseState,
|
|
10690
|
+
_structureHash: base.structureHash,
|
|
10691
|
+
};
|
|
10692
|
+
// Clean undefined
|
|
10693
|
+
if (merged._error === undefined)
|
|
10694
|
+
delete merged._error;
|
|
10695
|
+
await writeFile(lockPath, JSON.stringify(merged, null, 2) + '\n');
|
|
10696
|
+
await copyFile(lockPath, metaJsonPath);
|
|
10697
|
+
return merged;
|
|
10698
|
+
}
|
|
10699
|
+
/**
|
|
10700
|
+
* Handle phase failure (abort or error).
|
|
10701
|
+
*
|
|
10702
|
+
* Shared error path for all three phase executors. When the executor was
|
|
10703
|
+
* aborted, returns immediately without persisting (abort route handles it).
|
|
10704
|
+
* Otherwise, transitions the phase to failed and persists the error.
|
|
10705
|
+
*/
|
|
10706
|
+
async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpdates) {
|
|
10707
|
+
if (executor.aborted) {
|
|
10708
|
+
return {
|
|
10709
|
+
executed: true,
|
|
10710
|
+
phaseState: phaseFailed(ps, phase),
|
|
10711
|
+
error: { step: phase, code: 'ABORT', message: 'Aborted by operator' },
|
|
10712
|
+
};
|
|
10713
|
+
}
|
|
10714
|
+
const error = toMetaError(phase, err);
|
|
10715
|
+
const failedPs = phaseFailed(ps, phase);
|
|
10716
|
+
await persistPhaseState(base, failedPs, {
|
|
10717
|
+
_error: error,
|
|
10718
|
+
...additionalUpdates,
|
|
10719
|
+
});
|
|
10720
|
+
return { executed: true, phaseState: failedPs, error };
|
|
10721
|
+
}
|
|
10722
|
+
// ── Architect executor ─────────────────────────────────────────────────
|
|
10723
|
+
async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
|
|
10724
|
+
let ps = phaseRunning(phaseState, 'architect');
|
|
10725
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
10726
|
+
try {
|
|
10727
|
+
await onProgress?.({
|
|
10728
|
+
type: 'phase_start',
|
|
10729
|
+
path: node.ownerPath,
|
|
10730
|
+
phase: 'architect',
|
|
10731
|
+
});
|
|
10732
|
+
const phaseStart = Date.now();
|
|
10733
|
+
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
10734
|
+
const result = await executor.spawn(architectTask, {
|
|
10735
|
+
thinking: config.thinking,
|
|
10736
|
+
timeout: config.architectTimeout,
|
|
10737
|
+
label: 'meta-architect',
|
|
10738
|
+
});
|
|
10739
|
+
const builderBrief = parseArchitectOutput(result.output);
|
|
10740
|
+
const architectTokens = result.tokens;
|
|
10741
|
+
// Architect success: architect → fresh, _synthesisCount → 0
|
|
10742
|
+
ps = architectSuccess(ps);
|
|
10743
|
+
const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
|
|
10744
|
+
_builder: builderBrief,
|
|
10745
|
+
_architect: currentMeta._architect ?? config.defaultArchitect ?? '',
|
|
10746
|
+
_synthesisCount: 0,
|
|
10747
|
+
_architectTokens: architectTokens,
|
|
10748
|
+
_generatedAt: new Date().toISOString(),
|
|
10749
|
+
_error: undefined,
|
|
10750
|
+
});
|
|
10751
|
+
await onProgress?.({
|
|
10752
|
+
type: 'phase_complete',
|
|
10753
|
+
path: node.ownerPath,
|
|
10754
|
+
phase: 'architect',
|
|
10755
|
+
tokens: architectTokens,
|
|
10756
|
+
durationMs: Date.now() - phaseStart,
|
|
10757
|
+
});
|
|
10758
|
+
return { executed: true, phaseState: ps, updatedMeta };
|
|
10759
|
+
}
|
|
10760
|
+
catch (err) {
|
|
10761
|
+
return handlePhaseFailure('architect', err, executor, ps, {
|
|
10762
|
+
metaPath: node.metaPath,
|
|
10763
|
+
current: currentMeta,
|
|
10764
|
+
structureHash,
|
|
10765
|
+
});
|
|
10766
|
+
}
|
|
10767
|
+
}
|
|
10768
|
+
// ── Builder executor ───────────────────────────────────────────────────
|
|
10769
|
+
async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
|
|
10770
|
+
let ps = phaseRunning(phaseState, 'builder');
|
|
10771
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
10637
10772
|
try {
|
|
10638
10773
|
await onProgress?.({
|
|
10639
10774
|
type: 'phase_start',
|
|
@@ -10641,15 +10776,24 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10641
10776
|
phase: 'builder',
|
|
10642
10777
|
});
|
|
10643
10778
|
const builderStart = Date.now();
|
|
10644
|
-
const builderTask = buildBuilderTask(ctx,
|
|
10645
|
-
const
|
|
10779
|
+
const builderTask = buildBuilderTask(ctx, currentMeta, config);
|
|
10780
|
+
const result = await executor.spawn(builderTask, {
|
|
10646
10781
|
thinking: config.thinking,
|
|
10647
10782
|
timeout: config.builderTimeout,
|
|
10648
10783
|
label: 'meta-builder',
|
|
10649
10784
|
});
|
|
10650
|
-
builderOutput = parseBuilderOutput(
|
|
10651
|
-
builderTokens =
|
|
10652
|
-
|
|
10785
|
+
const builderOutput = parseBuilderOutput(result.output);
|
|
10786
|
+
const builderTokens = result.tokens;
|
|
10787
|
+
// Builder success: builder → fresh, critic → pending
|
|
10788
|
+
ps = builderSuccess(ps);
|
|
10789
|
+
const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
|
|
10790
|
+
_content: builderOutput.content,
|
|
10791
|
+
_state: builderOutput.state,
|
|
10792
|
+
_builderTokens: builderTokens,
|
|
10793
|
+
_generatedAt: new Date().toISOString(),
|
|
10794
|
+
_error: undefined,
|
|
10795
|
+
...builderOutput.fields,
|
|
10796
|
+
});
|
|
10653
10797
|
await onProgress?.({
|
|
10654
10798
|
type: 'phase_complete',
|
|
10655
10799
|
path: node.ownerPath,
|
|
@@ -10657,38 +10801,37 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10657
10801
|
tokens: builderTokens,
|
|
10658
10802
|
durationMs: Date.now() - builderStart,
|
|
10659
10803
|
});
|
|
10804
|
+
return { executed: true, phaseState: ps, updatedMeta };
|
|
10660
10805
|
}
|
|
10661
10806
|
catch (err) {
|
|
10807
|
+
// §4.6 partial _state recovery on timeout
|
|
10808
|
+
let partialState;
|
|
10662
10809
|
if (err instanceof SpawnTimeoutError) {
|
|
10663
|
-
|
|
10664
|
-
err,
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
}
|
|
10675
|
-
|
|
10676
|
-
|
|
10677
|
-
|
|
10678
|
-
|
|
10679
|
-
|
|
10680
|
-
feedback: null,
|
|
10681
|
-
synthesisCount,
|
|
10682
|
-
error: stepError,
|
|
10683
|
-
});
|
|
10684
|
-
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
10810
|
+
try {
|
|
10811
|
+
const raw = await readFile(err.outputPath, 'utf8');
|
|
10812
|
+
const partial = parseBuilderOutput(raw);
|
|
10813
|
+
if (partial.state !== undefined &&
|
|
10814
|
+
JSON.stringify(partial.state) !== JSON.stringify(currentMeta._state)) {
|
|
10815
|
+
partialState = { _state: partial.state };
|
|
10816
|
+
}
|
|
10817
|
+
}
|
|
10818
|
+
catch {
|
|
10819
|
+
// Could not read partial output — no state recovery
|
|
10820
|
+
}
|
|
10821
|
+
}
|
|
10822
|
+
return handlePhaseFailure('builder', err, executor, ps, {
|
|
10823
|
+
metaPath: node.metaPath,
|
|
10824
|
+
current: currentMeta,
|
|
10825
|
+
structureHash,
|
|
10826
|
+
}, partialState);
|
|
10685
10827
|
}
|
|
10686
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10690
|
-
|
|
10691
|
-
|
|
10828
|
+
}
|
|
10829
|
+
// ── Critic executor ────────────────────────────────────────────────────
|
|
10830
|
+
async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
|
|
10831
|
+
let ps = phaseRunning(phaseState, 'critic');
|
|
10832
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
10833
|
+
// Build critic task using current meta's _content
|
|
10834
|
+
const metaForCritic = { ...currentMeta };
|
|
10692
10835
|
try {
|
|
10693
10836
|
await onProgress?.({
|
|
10694
10837
|
type: 'phase_start',
|
|
@@ -10697,14 +10840,32 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10697
10840
|
});
|
|
10698
10841
|
const criticStart = Date.now();
|
|
10699
10842
|
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
10700
|
-
const
|
|
10843
|
+
const result = await executor.spawn(criticTask, {
|
|
10701
10844
|
thinking: config.thinking,
|
|
10702
10845
|
timeout: config.criticTimeout,
|
|
10703
10846
|
label: 'meta-critic',
|
|
10704
10847
|
});
|
|
10705
|
-
feedback = parseCriticOutput(
|
|
10706
|
-
criticTokens =
|
|
10707
|
-
|
|
10848
|
+
const feedback = parseCriticOutput(result.output);
|
|
10849
|
+
const criticTokens = result.tokens;
|
|
10850
|
+
// Critic success: critic → fresh
|
|
10851
|
+
ps = criticSuccess(ps);
|
|
10852
|
+
const cycleComplete = isFullyFresh(ps);
|
|
10853
|
+
const updates = {
|
|
10854
|
+
_feedback: feedback,
|
|
10855
|
+
_criticTokens: criticTokens,
|
|
10856
|
+
_error: undefined,
|
|
10857
|
+
};
|
|
10858
|
+
// Full-cycle completion: increment _synthesisCount, archive, emit.
|
|
10859
|
+
// Per spec: architect resets to 0, full-cycle increments on top.
|
|
10860
|
+
if (cycleComplete) {
|
|
10861
|
+
updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
|
|
10862
|
+
}
|
|
10863
|
+
const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
|
|
10864
|
+
// Archive on full-cycle only
|
|
10865
|
+
if (cycleComplete) {
|
|
10866
|
+
await createSnapshot(node.metaPath, updatedMeta);
|
|
10867
|
+
await pruneArchive(node.metaPath, config.maxArchive);
|
|
10868
|
+
}
|
|
10708
10869
|
await onProgress?.({
|
|
10709
10870
|
type: 'phase_complete',
|
|
10710
10871
|
path: node.ownerPath,
|
|
@@ -10712,139 +10873,144 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10712
10873
|
tokens: criticTokens,
|
|
10713
10874
|
durationMs: Date.now() - criticStart,
|
|
10714
10875
|
});
|
|
10876
|
+
return {
|
|
10877
|
+
executed: true,
|
|
10878
|
+
phaseState: ps,
|
|
10879
|
+
updatedMeta,
|
|
10880
|
+
cycleComplete,
|
|
10881
|
+
};
|
|
10715
10882
|
}
|
|
10716
10883
|
catch (err) {
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
10723
|
-
builderOutput,
|
|
10724
|
-
feedback,
|
|
10725
|
-
synthesisCount,
|
|
10726
|
-
error: stepError,
|
|
10727
|
-
architectTokens,
|
|
10728
|
-
builderTokens,
|
|
10729
|
-
criticTokens,
|
|
10730
|
-
state: builderOutput.state,
|
|
10731
|
-
});
|
|
10732
|
-
return {
|
|
10733
|
-
synthesized: true,
|
|
10734
|
-
metaPath: node.metaPath,
|
|
10735
|
-
error: stepError ?? undefined,
|
|
10736
|
-
};
|
|
10884
|
+
return handlePhaseFailure('critic', err, executor, ps, {
|
|
10885
|
+
metaPath: node.metaPath,
|
|
10886
|
+
current: currentMeta,
|
|
10887
|
+
structureHash,
|
|
10888
|
+
});
|
|
10889
|
+
}
|
|
10737
10890
|
}
|
|
10738
10891
|
|
|
10739
10892
|
/**
|
|
10740
|
-
*
|
|
10893
|
+
* Phase-aware orchestration entry point.
|
|
10894
|
+
*
|
|
10895
|
+
* Replaces the old staleness-based orchestrate() with phase-state-machine
|
|
10896
|
+
* scheduling: each tick discovers all metas, computes invalidation,
|
|
10897
|
+
* auto-retries failed phases, selects the best phase candidate, and
|
|
10898
|
+
* executes exactly one phase.
|
|
10741
10899
|
*
|
|
10742
|
-
* @module orchestrator/
|
|
10900
|
+
* @module orchestrator/orchestratePhase
|
|
10743
10901
|
*/
|
|
10744
|
-
|
|
10745
|
-
|
|
10746
|
-
|
|
10902
|
+
/** Phase runner dispatch map — avoids repeating the same switch/case. */
|
|
10903
|
+
const phaseRunners = {
|
|
10904
|
+
architect: runArchitect,
|
|
10905
|
+
builder: runBuilder,
|
|
10906
|
+
critic: runCritic,
|
|
10907
|
+
};
|
|
10908
|
+
/**
|
|
10909
|
+
* Run a single phase-aware orchestration tick.
|
|
10910
|
+
*
|
|
10911
|
+
* When targetPath is provided (override entry), runs the owed phase for
|
|
10912
|
+
* that specific meta. Otherwise, discovers all metas, computes invalidation,
|
|
10913
|
+
* and selects the best phase candidate corpus-wide.
|
|
10914
|
+
*/
|
|
10915
|
+
async function orchestratePhase(config, executor, watcher, targetPath, onProgress, logger) {
|
|
10916
|
+
// ── Targeted path (override entry) ──
|
|
10747
10917
|
if (targetPath) {
|
|
10748
|
-
|
|
10749
|
-
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
10750
|
-
if (!existsSync(targetMetaJson))
|
|
10751
|
-
return { synthesized: false };
|
|
10752
|
-
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
10753
|
-
if (!acquireLock(node.metaPath))
|
|
10754
|
-
return { synthesized: false };
|
|
10755
|
-
try {
|
|
10756
|
-
const currentMeta = await readMetaJson(normalizedTarget);
|
|
10757
|
-
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
|
|
10758
|
-
}
|
|
10759
|
-
finally {
|
|
10760
|
-
releaseLock(node.metaPath);
|
|
10761
|
-
}
|
|
10918
|
+
return orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger);
|
|
10762
10919
|
}
|
|
10763
|
-
//
|
|
10764
|
-
|
|
10765
|
-
|
|
10766
|
-
|
|
10767
|
-
logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
|
|
10768
|
-
if (metaPaths.length === 0)
|
|
10769
|
-
return { synthesized: false };
|
|
10770
|
-
// Read meta.json for each discovered meta
|
|
10771
|
-
const metas = new Map();
|
|
10772
|
-
for (const mp of metaPaths) {
|
|
10773
|
-
try {
|
|
10774
|
-
metas.set(normalizePath(mp), await readMetaJson(mp));
|
|
10775
|
-
}
|
|
10776
|
-
catch {
|
|
10777
|
-
// Skip metas with unreadable meta.json
|
|
10778
|
-
continue;
|
|
10779
|
-
}
|
|
10920
|
+
// ── Corpus-wide discovery + phase selection ──
|
|
10921
|
+
let metaResult;
|
|
10922
|
+
try {
|
|
10923
|
+
metaResult = await listMetas(config, watcher);
|
|
10780
10924
|
}
|
|
10781
|
-
|
|
10782
|
-
|
|
10783
|
-
|
|
10784
|
-
|
|
10785
|
-
|
|
10786
|
-
|
|
10787
|
-
|
|
10788
|
-
|
|
10789
|
-
|
|
10790
|
-
|
|
10791
|
-
|
|
10792
|
-
|
|
10793
|
-
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
10797
|
-
|
|
10798
|
-
|
|
10799
|
-
|
|
10800
|
-
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10808
|
-
|
|
10809
|
-
|
|
10810
|
-
|
|
10811
|
-
|
|
10812
|
-
|
|
10813
|
-
|
|
10814
|
-
if (
|
|
10815
|
-
|
|
10816
|
-
|
|
10925
|
+
catch (err) {
|
|
10926
|
+
logger?.warn({ err }, 'Failed to list metas for phase selection');
|
|
10927
|
+
return { executed: false };
|
|
10928
|
+
}
|
|
10929
|
+
if (metaResult.entries.length === 0)
|
|
10930
|
+
return { executed: false };
|
|
10931
|
+
// Build candidates with phase state (including invalidation + auto-retry)
|
|
10932
|
+
const candidates = buildPhaseCandidates(metaResult.entries);
|
|
10933
|
+
// Select best phase candidate
|
|
10934
|
+
const winner = selectPhaseCandidate(candidates, config.depthWeight);
|
|
10935
|
+
if (!winner) {
|
|
10936
|
+
return { executed: false };
|
|
10937
|
+
}
|
|
10938
|
+
// Acquire lock
|
|
10939
|
+
if (!acquireLock(winner.node.metaPath)) {
|
|
10940
|
+
logger?.debug({ path: winner.node.metaPath }, 'Selected candidate is locked, skipping');
|
|
10941
|
+
return { executed: false };
|
|
10942
|
+
}
|
|
10943
|
+
try {
|
|
10944
|
+
// Re-read meta under lock for freshness
|
|
10945
|
+
const currentMeta = await readMetaJson(winner.node.metaPath);
|
|
10946
|
+
const phaseState = retryAllFailed(derivePhaseState(currentMeta));
|
|
10947
|
+
const owedPhase = getOwedPhase(phaseState);
|
|
10948
|
+
if (!owedPhase || phaseState[owedPhase] !== 'pending') {
|
|
10949
|
+
// Nothing to do (race: became fresh between selection and lock)
|
|
10950
|
+
return { executed: false };
|
|
10951
|
+
}
|
|
10952
|
+
// Compute structure hash for the phase
|
|
10953
|
+
const { scopeFiles } = await getScopeFiles(winner.node, watcher);
|
|
10954
|
+
const structureHash = computeStructureHash(scopeFiles);
|
|
10955
|
+
// skipUnchanged: bump _generatedAt without altering _phaseState
|
|
10956
|
+
if (config.skipUnchanged && currentMeta._generatedAt) {
|
|
10957
|
+
const verifiedStale = await isStale(getScopePrefix(winner.node), currentMeta, watcher);
|
|
10958
|
+
if (!verifiedStale) {
|
|
10959
|
+
await persistPhaseState({
|
|
10960
|
+
metaPath: winner.node.metaPath,
|
|
10961
|
+
current: currentMeta,
|
|
10962
|
+
config,
|
|
10963
|
+
structureHash,
|
|
10964
|
+
}, phaseState, { _generatedAt: new Date().toISOString() });
|
|
10965
|
+
logger?.debug({ path: winner.node.ownerPath }, 'Skipped unchanged meta, bumped _generatedAt');
|
|
10966
|
+
return { executed: false };
|
|
10967
|
+
}
|
|
10817
10968
|
}
|
|
10818
|
-
winner
|
|
10819
|
-
|
|
10969
|
+
return await executePhase(winner.node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
|
|
10970
|
+
}
|
|
10971
|
+
finally {
|
|
10972
|
+
releaseLock(winner.node.metaPath);
|
|
10973
|
+
}
|
|
10974
|
+
}
|
|
10975
|
+
/**
|
|
10976
|
+
* Orchestrate a targeted (override) meta path.
|
|
10977
|
+
* Resolves the owed phase at execution time (not enqueue time).
|
|
10978
|
+
*/
|
|
10979
|
+
async function orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger) {
|
|
10980
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
10981
|
+
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
10982
|
+
if (!acquireLock(node.metaPath)) {
|
|
10983
|
+
return { executed: false };
|
|
10820
10984
|
}
|
|
10821
|
-
if (!winner)
|
|
10822
|
-
return { synthesized: false };
|
|
10823
|
-
const node = winner.node;
|
|
10824
10985
|
try {
|
|
10825
|
-
const currentMeta = await readMetaJson(
|
|
10826
|
-
|
|
10986
|
+
const currentMeta = await readMetaJson(normalizedTarget);
|
|
10987
|
+
const phaseState = retryAllFailed(derivePhaseState(currentMeta));
|
|
10988
|
+
const owedPhase = getOwedPhase(phaseState);
|
|
10989
|
+
if (!owedPhase) {
|
|
10990
|
+
// Fully fresh — override is a no-op (silently dropped per spec)
|
|
10991
|
+
return { executed: false, metaPath: normalizedTarget };
|
|
10992
|
+
}
|
|
10993
|
+
// Compute structure hash
|
|
10994
|
+
const { scopeFiles } = await getScopeFiles(node, watcher);
|
|
10995
|
+
const structureHash = computeStructureHash(scopeFiles);
|
|
10996
|
+
return await executePhase(node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
|
|
10827
10997
|
}
|
|
10828
10998
|
finally {
|
|
10829
|
-
// Step 13: Release lock
|
|
10830
10999
|
releaseLock(node.metaPath);
|
|
10831
11000
|
}
|
|
10832
11001
|
}
|
|
10833
11002
|
/**
|
|
10834
|
-
*
|
|
10835
|
-
*
|
|
10836
|
-
* Selects the stalest candidate (or a specific target) and runs the
|
|
10837
|
-
* full architect/builder/critic pipeline.
|
|
10838
|
-
*
|
|
10839
|
-
* @param config - Validated synthesis config.
|
|
10840
|
-
* @param executor - Pluggable LLM executor.
|
|
10841
|
-
* @param watcher - Watcher HTTP client.
|
|
10842
|
-
* @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
|
|
10843
|
-
* @returns Array with a single result.
|
|
11003
|
+
* Execute exactly one phase on a meta.
|
|
10844
11004
|
*/
|
|
10845
|
-
async function
|
|
10846
|
-
const result = await
|
|
10847
|
-
return
|
|
11005
|
+
async function executePhase(node, currentMeta, phaseState, phase, config, executor, watcher, structureHash, onProgress, logger) {
|
|
11006
|
+
const result = await phaseRunners[phase](node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger);
|
|
11007
|
+
return {
|
|
11008
|
+
executed: true,
|
|
11009
|
+
metaPath: node.metaPath,
|
|
11010
|
+
phase,
|
|
11011
|
+
phaseResult: result,
|
|
11012
|
+
cycleComplete: result.cycleComplete,
|
|
11013
|
+
};
|
|
10848
11014
|
}
|
|
10849
11015
|
|
|
10850
11016
|
/**
|
|
@@ -10981,42 +11147,106 @@ class ProgressReporter {
|
|
|
10981
11147
|
}
|
|
10982
11148
|
|
|
10983
11149
|
/**
|
|
10984
|
-
*
|
|
11150
|
+
* Hybrid 3-layer synthesis queue.
|
|
11151
|
+
*
|
|
11152
|
+
* Layer 1: Current — the single item currently executing (at most one).
|
|
11153
|
+
* Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
|
|
11154
|
+
* FIFO among overrides, ahead of automatic candidates.
|
|
11155
|
+
* Layer 3: Automatic — computed on read, not persisted. All metas with a
|
|
11156
|
+
* pending phase, ranked by scheduler priority.
|
|
10985
11157
|
*
|
|
10986
|
-
*
|
|
10987
|
-
* synthesis requests get priority (inserted at front). A path appears at
|
|
10988
|
-
* most once in the queue; re-triggering returns the current position.
|
|
11158
|
+
* Legacy: `pending` array is the union of overrides + automatic.
|
|
10989
11159
|
*
|
|
10990
11160
|
* @module queue
|
|
10991
11161
|
*/
|
|
10992
11162
|
const DEPTH_WARNING_THRESHOLD = 3;
|
|
10993
11163
|
/**
|
|
10994
|
-
*
|
|
11164
|
+
* Hybrid 3-layer synthesis queue.
|
|
10995
11165
|
*
|
|
10996
|
-
* Only one synthesis runs at a time.
|
|
10997
|
-
*
|
|
10998
|
-
* position returned.
|
|
11166
|
+
* Only one synthesis runs at a time. Override items (explicit triggers)
|
|
11167
|
+
* take priority over automatic candidates.
|
|
10999
11168
|
*/
|
|
11000
11169
|
class SynthesisQueue {
|
|
11170
|
+
/** Legacy queue (used by processQueue for backward compat). */
|
|
11001
11171
|
queue = [];
|
|
11002
11172
|
currentItem = null;
|
|
11003
11173
|
processing = false;
|
|
11004
11174
|
logger;
|
|
11005
11175
|
onEnqueueCallback = null;
|
|
11006
|
-
/**
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
|
|
11010
|
-
*/
|
|
11176
|
+
/** Explicit override entries (3-layer model). */
|
|
11177
|
+
overrideEntries = [];
|
|
11178
|
+
/** Currently executing item with phase info (3-layer model). */
|
|
11179
|
+
currentPhaseItem = null;
|
|
11011
11180
|
constructor(logger) {
|
|
11012
11181
|
this.logger = logger;
|
|
11013
11182
|
}
|
|
11014
|
-
/**
|
|
11015
|
-
* Set a callback to invoke when a new (non-duplicate) item is enqueued.
|
|
11016
|
-
*/
|
|
11183
|
+
/** Set a callback to invoke when a new (non-duplicate) item is enqueued. */
|
|
11017
11184
|
onEnqueue(callback) {
|
|
11018
11185
|
this.onEnqueueCallback = callback;
|
|
11019
11186
|
}
|
|
11187
|
+
// ── Override layer (3-layer model) ─────────────────────────────────
|
|
11188
|
+
/**
|
|
11189
|
+
* Add an explicit override entry (from POST /synthesize with path).
|
|
11190
|
+
* Deduped by path. Returns position and whether already queued.
|
|
11191
|
+
*/
|
|
11192
|
+
enqueueOverride(path) {
|
|
11193
|
+
// Check if currently executing
|
|
11194
|
+
if (this.currentPhaseItem?.path === path ||
|
|
11195
|
+
this.currentItem?.path === path) {
|
|
11196
|
+
return { position: 0, alreadyQueued: true };
|
|
11197
|
+
}
|
|
11198
|
+
// Check if already in overrides
|
|
11199
|
+
const existing = this.overrideEntries.findIndex((e) => e.path === path);
|
|
11200
|
+
if (existing !== -1) {
|
|
11201
|
+
return { position: existing, alreadyQueued: true };
|
|
11202
|
+
}
|
|
11203
|
+
this.overrideEntries.push({
|
|
11204
|
+
path,
|
|
11205
|
+
enqueuedAt: new Date().toISOString(),
|
|
11206
|
+
});
|
|
11207
|
+
const position = this.overrideEntries.length - 1;
|
|
11208
|
+
if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
|
|
11209
|
+
this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
|
|
11210
|
+
}
|
|
11211
|
+
this.onEnqueueCallback?.();
|
|
11212
|
+
return { position, alreadyQueued: false };
|
|
11213
|
+
}
|
|
11214
|
+
/** Dequeue the next override entry, or undefined if empty. */
|
|
11215
|
+
dequeueOverride() {
|
|
11216
|
+
return this.overrideEntries.shift();
|
|
11217
|
+
}
|
|
11218
|
+
/** Get all override entries (shallow copy). */
|
|
11219
|
+
get overrides() {
|
|
11220
|
+
return [...this.overrideEntries];
|
|
11221
|
+
}
|
|
11222
|
+
/** Clear all override entries. Returns count removed. */
|
|
11223
|
+
clearOverrides() {
|
|
11224
|
+
const count = this.overrideEntries.length;
|
|
11225
|
+
this.overrideEntries = [];
|
|
11226
|
+
return count;
|
|
11227
|
+
}
|
|
11228
|
+
/** Check if a path is in the override layer. */
|
|
11229
|
+
hasOverride(path) {
|
|
11230
|
+
return this.overrideEntries.some((e) => e.path === path);
|
|
11231
|
+
}
|
|
11232
|
+
// ── Current-item tracking (3-layer model) ──────────────────────────
|
|
11233
|
+
/** Set the currently executing phase item. */
|
|
11234
|
+
setCurrentPhase(path, phase) {
|
|
11235
|
+
this.currentPhaseItem = {
|
|
11236
|
+
path,
|
|
11237
|
+
phase,
|
|
11238
|
+
startedAt: new Date().toISOString(),
|
|
11239
|
+
};
|
|
11240
|
+
}
|
|
11241
|
+
/** Clear the current phase item. */
|
|
11242
|
+
clearCurrentPhase() {
|
|
11243
|
+
this.currentPhaseItem = null;
|
|
11244
|
+
}
|
|
11245
|
+
/** The currently executing phase item, or null. */
|
|
11246
|
+
get currentPhase() {
|
|
11247
|
+
return this.currentPhaseItem;
|
|
11248
|
+
}
|
|
11249
|
+
// ── Legacy queue interface (preserved for backward compat) ─────────
|
|
11020
11250
|
/**
|
|
11021
11251
|
* Add a path to the synthesis queue.
|
|
11022
11252
|
*
|
|
@@ -11025,11 +11255,9 @@ class SynthesisQueue {
|
|
|
11025
11255
|
* @returns Position and whether the path was already queued.
|
|
11026
11256
|
*/
|
|
11027
11257
|
enqueue(path, priority = false) {
|
|
11028
|
-
// Check if currently being synthesized.
|
|
11029
11258
|
if (this.currentItem?.path === path) {
|
|
11030
11259
|
return { position: 0, alreadyQueued: true };
|
|
11031
11260
|
}
|
|
11032
|
-
// Check if already in queue.
|
|
11033
11261
|
const existingIndex = this.queue.findIndex((item) => item.path === path);
|
|
11034
11262
|
if (existingIndex !== -1) {
|
|
11035
11263
|
return { position: existingIndex, alreadyQueued: true };
|
|
@@ -11052,11 +11280,7 @@ class SynthesisQueue {
|
|
|
11052
11280
|
this.onEnqueueCallback?.();
|
|
11053
11281
|
return { position, alreadyQueued: false };
|
|
11054
11282
|
}
|
|
11055
|
-
/**
|
|
11056
|
-
* Remove and return the next item from the queue.
|
|
11057
|
-
*
|
|
11058
|
-
* @returns The next QueueItem, or undefined if the queue is empty.
|
|
11059
|
-
*/
|
|
11283
|
+
/** Remove and return the next item from the queue. */
|
|
11060
11284
|
dequeue() {
|
|
11061
11285
|
const item = this.queue.shift();
|
|
11062
11286
|
if (item) {
|
|
@@ -11086,7 +11310,6 @@ class SynthesisQueue {
|
|
|
11086
11310
|
}
|
|
11087
11311
|
/**
|
|
11088
11312
|
* Remove all pending items from the queue.
|
|
11089
|
-
*
|
|
11090
11313
|
* Does not affect the currently-running item.
|
|
11091
11314
|
*
|
|
11092
11315
|
* @returns The number of items removed.
|
|
@@ -11096,45 +11319,56 @@ class SynthesisQueue {
|
|
|
11096
11319
|
this.queue = [];
|
|
11097
11320
|
return count;
|
|
11098
11321
|
}
|
|
11099
|
-
/**
|
|
11100
|
-
* Check whether a path is in the queue or currently being synthesized.
|
|
11101
|
-
*
|
|
11102
|
-
* @param path - Meta path to look up.
|
|
11103
|
-
* @returns True if the path is queued or currently running.
|
|
11104
|
-
*/
|
|
11322
|
+
/** Check whether a path is in the queue or currently being synthesized. */
|
|
11105
11323
|
has(path) {
|
|
11106
11324
|
if (this.currentItem?.path === path)
|
|
11107
11325
|
return true;
|
|
11108
|
-
|
|
11326
|
+
if (this.currentPhaseItem?.path === path)
|
|
11327
|
+
return true;
|
|
11328
|
+
return (this.queue.some((item) => item.path === path) ||
|
|
11329
|
+
this.overrideEntries.some((e) => e.path === path));
|
|
11109
11330
|
}
|
|
11110
|
-
/**
|
|
11111
|
-
* Get the 0-indexed position of a path in the queue.
|
|
11112
|
-
*
|
|
11113
|
-
* @param path - Meta path to look up.
|
|
11114
|
-
* @returns Position index, or null if not found in the queue.
|
|
11115
|
-
*/
|
|
11331
|
+
/** Get the 0-indexed position of a path in the queue. */
|
|
11116
11332
|
getPosition(path) {
|
|
11333
|
+
// Check overrides first
|
|
11334
|
+
const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
|
|
11335
|
+
if (overrideIdx !== -1)
|
|
11336
|
+
return overrideIdx;
|
|
11117
11337
|
const index = this.queue.findIndex((item) => item.path === path);
|
|
11118
11338
|
return index === -1 ? null : index;
|
|
11119
11339
|
}
|
|
11120
|
-
/**
|
|
11121
|
-
|
|
11122
|
-
|
|
11123
|
-
|
|
11124
|
-
|
|
11340
|
+
/** Dequeue the next item: overrides first, then legacy queue. */
|
|
11341
|
+
nextItem() {
|
|
11342
|
+
const override = this.dequeueOverride();
|
|
11343
|
+
if (override)
|
|
11344
|
+
return { path: override.path, source: 'override' };
|
|
11345
|
+
const item = this.dequeue();
|
|
11346
|
+
if (item)
|
|
11347
|
+
return { path: item.path, source: 'legacy' };
|
|
11348
|
+
return undefined;
|
|
11349
|
+
}
|
|
11350
|
+
/** Return a snapshot of queue state for the /status endpoint. */
|
|
11125
11351
|
getState() {
|
|
11126
11352
|
return {
|
|
11127
|
-
depth: this.queue.length,
|
|
11128
|
-
items:
|
|
11129
|
-
|
|
11130
|
-
|
|
11131
|
-
|
|
11132
|
-
|
|
11353
|
+
depth: this.queue.length + this.overrideEntries.length,
|
|
11354
|
+
items: [
|
|
11355
|
+
...this.overrideEntries.map((e) => ({
|
|
11356
|
+
path: e.path,
|
|
11357
|
+
priority: true,
|
|
11358
|
+
enqueuedAt: e.enqueuedAt,
|
|
11359
|
+
})),
|
|
11360
|
+
...this.queue.map((item) => ({
|
|
11361
|
+
path: item.path,
|
|
11362
|
+
priority: item.priority,
|
|
11363
|
+
enqueuedAt: item.enqueuedAt,
|
|
11364
|
+
})),
|
|
11365
|
+
],
|
|
11133
11366
|
};
|
|
11134
11367
|
}
|
|
11135
11368
|
/**
|
|
11136
|
-
* Process queued items one at a time until
|
|
11369
|
+
* Process queued items one at a time until all queues are empty.
|
|
11137
11370
|
*
|
|
11371
|
+
* Override entries are processed first (FIFO), then legacy queue items.
|
|
11138
11372
|
* Re-entry is prevented: if already processing, the call returns
|
|
11139
11373
|
* immediately. Errors are logged and do not block subsequent items.
|
|
11140
11374
|
*
|
|
@@ -11145,19 +11379,22 @@ class SynthesisQueue {
|
|
|
11145
11379
|
return;
|
|
11146
11380
|
this.processing = true;
|
|
11147
11381
|
try {
|
|
11148
|
-
let
|
|
11149
|
-
while (
|
|
11382
|
+
let next = this.nextItem();
|
|
11383
|
+
while (next) {
|
|
11150
11384
|
try {
|
|
11151
|
-
await synthesizeFn(
|
|
11385
|
+
await synthesizeFn(next.path);
|
|
11152
11386
|
}
|
|
11153
11387
|
catch (err) {
|
|
11154
|
-
this.logger.error({ path:
|
|
11388
|
+
this.logger.error({ path: next.path, err }, 'Synthesis failed');
|
|
11155
11389
|
}
|
|
11156
|
-
this.
|
|
11157
|
-
|
|
11390
|
+
this.clearCurrentPhase();
|
|
11391
|
+
if (next.source === 'legacy')
|
|
11392
|
+
this.complete();
|
|
11393
|
+
next = this.nextItem();
|
|
11158
11394
|
}
|
|
11159
11395
|
}
|
|
11160
11396
|
finally {
|
|
11397
|
+
this.clearCurrentPhase();
|
|
11161
11398
|
this.processing = false;
|
|
11162
11399
|
}
|
|
11163
11400
|
}
|
|
@@ -11629,14 +11866,15 @@ async function autoSeedPass(rules, watcher, logger) {
|
|
|
11629
11866
|
}
|
|
11630
11867
|
|
|
11631
11868
|
/**
|
|
11632
|
-
* Croner-based scheduler that discovers the
|
|
11633
|
-
* and enqueues it for
|
|
11869
|
+
* Croner-based scheduler that discovers the highest-priority ready phase
|
|
11870
|
+
* across the corpus each tick and enqueues it for execution.
|
|
11634
11871
|
*
|
|
11635
11872
|
* @module scheduler
|
|
11636
11873
|
*/
|
|
11637
11874
|
const MAX_BACKOFF_MULTIPLIER = 4;
|
|
11638
11875
|
/**
|
|
11639
|
-
* Periodic scheduler that discovers
|
|
11876
|
+
* Periodic scheduler that discovers the highest-priority ready phase
|
|
11877
|
+
* across all metas and enqueues it for execution.
|
|
11640
11878
|
*
|
|
11641
11879
|
* Supports adaptive backoff when no candidates are found and hot-reloadable
|
|
11642
11880
|
* cron expressions via {@link Scheduler.updateSchedule}.
|
|
@@ -11691,10 +11929,10 @@ class Scheduler {
|
|
|
11691
11929
|
this.logger.info({ schedule: expression }, 'Schedule updated');
|
|
11692
11930
|
}
|
|
11693
11931
|
}
|
|
11694
|
-
/** Reset backoff multiplier (call after successful
|
|
11932
|
+
/** Reset backoff multiplier (call after successful phase execution). */
|
|
11695
11933
|
resetBackoff() {
|
|
11696
11934
|
if (this.backoffMultiplier > 1) {
|
|
11697
|
-
this.logger.debug('Backoff reset after successful
|
|
11935
|
+
this.logger.debug('Backoff reset after successful phase execution');
|
|
11698
11936
|
}
|
|
11699
11937
|
this.backoffMultiplier = 1;
|
|
11700
11938
|
}
|
|
@@ -11709,10 +11947,9 @@ class Scheduler {
|
|
|
11709
11947
|
return this.job.nextRun() ?? null;
|
|
11710
11948
|
}
|
|
11711
11949
|
/**
|
|
11712
|
-
* Single tick: discover
|
|
11950
|
+
* Single tick: discover the highest-priority ready phase and enqueue it.
|
|
11713
11951
|
*
|
|
11714
|
-
*
|
|
11715
|
-
* when no candidates are found.
|
|
11952
|
+
* Applies adaptive backoff when no candidates are found.
|
|
11716
11953
|
*/
|
|
11717
11954
|
async tick() {
|
|
11718
11955
|
this.tickCount++;
|
|
@@ -11737,14 +11974,15 @@ class Scheduler {
|
|
|
11737
11974
|
this.logger.warn({ err }, 'Auto-seed pass failed');
|
|
11738
11975
|
}
|
|
11739
11976
|
}
|
|
11740
|
-
const candidate = await this.
|
|
11977
|
+
const candidate = await this.discoverNextPhase();
|
|
11741
11978
|
if (!candidate) {
|
|
11742
11979
|
this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
|
|
11743
|
-
this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No
|
|
11980
|
+
this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
|
|
11744
11981
|
return;
|
|
11745
11982
|
}
|
|
11746
|
-
|
|
11747
|
-
this.
|
|
11983
|
+
// Enqueue using the legacy queue path (backward compat with processQueue)
|
|
11984
|
+
this.queue.enqueue(candidate.path);
|
|
11985
|
+
this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
|
|
11748
11986
|
// Opportunistic watcher restart detection
|
|
11749
11987
|
if (this.registrar) {
|
|
11750
11988
|
try {
|
|
@@ -11764,22 +12002,27 @@ class Scheduler {
|
|
|
11764
12002
|
}
|
|
11765
12003
|
}
|
|
11766
12004
|
/**
|
|
11767
|
-
* Discover the
|
|
12005
|
+
* Discover the highest-priority ready phase across the corpus.
|
|
12006
|
+
*
|
|
12007
|
+
* Uses phase-state-aware scheduling: priority order is
|
|
12008
|
+
* critic (band 1) \> builder (band 2) \> architect (band 3),
|
|
12009
|
+
* with weighted staleness as tiebreaker within a band.
|
|
11768
12010
|
*/
|
|
11769
|
-
async
|
|
12011
|
+
async discoverNextPhase() {
|
|
11770
12012
|
try {
|
|
11771
12013
|
const result = await listMetas(this.config, this.watcher);
|
|
11772
|
-
const
|
|
11773
|
-
|
|
11774
|
-
|
|
11775
|
-
|
|
11776
|
-
|
|
11777
|
-
|
|
11778
|
-
|
|
11779
|
-
|
|
12014
|
+
const candidates = buildPhaseCandidates(result.entries);
|
|
12015
|
+
const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
|
|
12016
|
+
if (!winner)
|
|
12017
|
+
return null;
|
|
12018
|
+
return {
|
|
12019
|
+
path: winner.node.metaPath,
|
|
12020
|
+
phase: winner.owedPhase,
|
|
12021
|
+
band: winner.band,
|
|
12022
|
+
};
|
|
11780
12023
|
}
|
|
11781
12024
|
catch (err) {
|
|
11782
|
-
this.logger.warn({ err }, 'Failed to discover
|
|
12025
|
+
this.logger.warn({ err }, 'Failed to discover next phase candidate');
|
|
11783
12026
|
return null;
|
|
11784
12027
|
}
|
|
11785
12028
|
}
|
|
@@ -11985,9 +12228,12 @@ function registerMetasRoutes(app, deps) {
|
|
|
11985
12228
|
'architectTokens',
|
|
11986
12229
|
'builderTokens',
|
|
11987
12230
|
'criticTokens',
|
|
12231
|
+
'phaseState',
|
|
12232
|
+
'owedPhase',
|
|
11988
12233
|
];
|
|
11989
12234
|
const projectedFields = fieldList ?? defaultFields;
|
|
11990
12235
|
const metas = entries.map((e) => {
|
|
12236
|
+
const ps = derivePhaseState(e.meta);
|
|
11991
12237
|
const full = {
|
|
11992
12238
|
path: e.path,
|
|
11993
12239
|
depth: e.depth,
|
|
@@ -12002,6 +12248,8 @@ function registerMetasRoutes(app, deps) {
|
|
|
12002
12248
|
architectTokens: e.architectTokens,
|
|
12003
12249
|
builderTokens: e.builderTokens,
|
|
12004
12250
|
criticTokens: e.criticTokens,
|
|
12251
|
+
phaseState: ps,
|
|
12252
|
+
owedPhase: getOwedPhase(ps),
|
|
12005
12253
|
};
|
|
12006
12254
|
const projected = {};
|
|
12007
12255
|
for (const f of projectedFields) {
|
|
@@ -12026,13 +12274,6 @@ function registerMetasRoutes(app, deps) {
|
|
|
12026
12274
|
}
|
|
12027
12275
|
const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
12028
12276
|
// Field projection
|
|
12029
|
-
const defaultExclude = new Set([
|
|
12030
|
-
'_architect',
|
|
12031
|
-
'_builder',
|
|
12032
|
-
'_critic',
|
|
12033
|
-
'_content',
|
|
12034
|
-
'_feedback',
|
|
12035
|
-
]);
|
|
12036
12277
|
const fieldList = query.fields?.split(',');
|
|
12037
12278
|
const projectMeta = (m) => {
|
|
12038
12279
|
if (fieldList) {
|
|
@@ -12043,7 +12284,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
12043
12284
|
}
|
|
12044
12285
|
const r = {};
|
|
12045
12286
|
for (const [k, v] of Object.entries(m)) {
|
|
12046
|
-
if (!
|
|
12287
|
+
if (!DEFAULT_EXCLUDE_FIELDS.has(k))
|
|
12047
12288
|
r[k] = v;
|
|
12048
12289
|
}
|
|
12049
12290
|
return r;
|
|
@@ -12056,6 +12297,10 @@ function registerMetasRoutes(app, deps) {
|
|
|
12056
12297
|
? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
|
|
12057
12298
|
: null;
|
|
12058
12299
|
const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
|
|
12300
|
+
// Phase state
|
|
12301
|
+
const entry = result.entries.find((e) => e.node.metaPath === targetNode.metaPath);
|
|
12302
|
+
const phaseState = entry ? derivePhaseState(entry.meta) : null;
|
|
12303
|
+
const owedPhase = phaseState ? getOwedPhase(phaseState) : null;
|
|
12059
12304
|
const response = {
|
|
12060
12305
|
path: targetNode.metaPath,
|
|
12061
12306
|
meta: projectMeta(meta),
|
|
@@ -12068,6 +12313,8 @@ function registerMetasRoutes(app, deps) {
|
|
|
12068
12313
|
seconds: staleSeconds,
|
|
12069
12314
|
score: Math.round(score * 100) / 100,
|
|
12070
12315
|
},
|
|
12316
|
+
phaseState,
|
|
12317
|
+
owedPhase,
|
|
12071
12318
|
};
|
|
12072
12319
|
// Cross-refs status
|
|
12073
12320
|
const crossRefsRaw = meta._crossRefs;
|
|
@@ -12170,16 +12417,9 @@ function registerMetasUpdateRoute(app, deps) {
|
|
|
12170
12417
|
Object.assign(updated, toSet);
|
|
12171
12418
|
await writeFile(metaJsonPath, JSON.stringify(updated, null, 2) + '\n');
|
|
12172
12419
|
// Project the response — exclude the same large fields as the detail route.
|
|
12173
|
-
const defaultExclude = new Set([
|
|
12174
|
-
'_architect',
|
|
12175
|
-
'_builder',
|
|
12176
|
-
'_critic',
|
|
12177
|
-
'_content',
|
|
12178
|
-
'_feedback',
|
|
12179
|
-
]);
|
|
12180
12420
|
const projected = {};
|
|
12181
12421
|
for (const [k, v] of Object.entries(updated)) {
|
|
12182
|
-
if (!
|
|
12422
|
+
if (!DEFAULT_EXCLUDE_FIELDS.has(k))
|
|
12183
12423
|
projected[k] = v;
|
|
12184
12424
|
}
|
|
12185
12425
|
return reply.send({
|
|
@@ -12241,6 +12481,19 @@ function registerPreviewRoute(app, deps) {
|
|
|
12241
12481
|
const structureChanged = structureHash !== meta._structureHash;
|
|
12242
12482
|
const latestArchive = await readLatestArchive(targetNode.metaPath);
|
|
12243
12483
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
12484
|
+
// _architect change detection
|
|
12485
|
+
const architectChanged = latestArchive
|
|
12486
|
+
? (meta._architect ?? '') !== (latestArchive._architect ?? '')
|
|
12487
|
+
: Boolean(meta._architect);
|
|
12488
|
+
// _crossRefs declaration change detection
|
|
12489
|
+
const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
|
|
12490
|
+
const archiveRefs = (latestArchive?._crossRefs ?? [])
|
|
12491
|
+
.slice()
|
|
12492
|
+
.sort()
|
|
12493
|
+
.join(',');
|
|
12494
|
+
const crossRefsDeclChanged = latestArchive
|
|
12495
|
+
? currentRefs !== archiveRefs
|
|
12496
|
+
: currentRefs.length > 0;
|
|
12244
12497
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
12245
12498
|
// Delta files
|
|
12246
12499
|
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
@@ -12255,6 +12508,40 @@ function registerPreviewRoute(app, deps) {
|
|
|
12255
12508
|
? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
|
|
12256
12509
|
: null;
|
|
12257
12510
|
const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
|
|
12511
|
+
// Phase state
|
|
12512
|
+
const phaseState = derivePhaseState(meta, {
|
|
12513
|
+
structureChanged,
|
|
12514
|
+
steerChanged,
|
|
12515
|
+
architectChanged,
|
|
12516
|
+
crossRefsChanged: crossRefsDeclChanged,
|
|
12517
|
+
architectEvery: config.architectEvery,
|
|
12518
|
+
});
|
|
12519
|
+
const owedPhase = getOwedPhase(phaseState);
|
|
12520
|
+
const priorityBand = getPriorityBand(phaseState);
|
|
12521
|
+
// Architect invalidators
|
|
12522
|
+
const architectInvalidators = [];
|
|
12523
|
+
if (owedPhase === 'architect') {
|
|
12524
|
+
if (structureChanged)
|
|
12525
|
+
architectInvalidators.push('structureHash');
|
|
12526
|
+
if (steerChanged)
|
|
12527
|
+
architectInvalidators.push('steer');
|
|
12528
|
+
if (architectChanged)
|
|
12529
|
+
architectInvalidators.push('_architect');
|
|
12530
|
+
if (crossRefsDeclChanged)
|
|
12531
|
+
architectInvalidators.push('_crossRefs');
|
|
12532
|
+
if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
|
|
12533
|
+
architectInvalidators.push('architectEvery');
|
|
12534
|
+
}
|
|
12535
|
+
}
|
|
12536
|
+
// Staleness inputs
|
|
12537
|
+
const stalenessInputs = {
|
|
12538
|
+
structureHash,
|
|
12539
|
+
steerChanged,
|
|
12540
|
+
architectChanged,
|
|
12541
|
+
crossRefsDeclChanged,
|
|
12542
|
+
scopeMtimeMax: null,
|
|
12543
|
+
crossRefContentChanged: false,
|
|
12544
|
+
};
|
|
12258
12545
|
return {
|
|
12259
12546
|
path: targetNode.metaPath,
|
|
12260
12547
|
staleness: {
|
|
@@ -12279,6 +12566,12 @@ function registerPreviewRoute(app, deps) {
|
|
|
12279
12566
|
deltaCount: deltaFiles.length,
|
|
12280
12567
|
},
|
|
12281
12568
|
estimatedTokens,
|
|
12569
|
+
// New phase-state fields (additive)
|
|
12570
|
+
owedPhase,
|
|
12571
|
+
priorityBand,
|
|
12572
|
+
phaseState,
|
|
12573
|
+
stalenessInputs,
|
|
12574
|
+
architectInvalidators,
|
|
12282
12575
|
};
|
|
12283
12576
|
});
|
|
12284
12577
|
}
|
|
@@ -12286,8 +12579,8 @@ function registerPreviewRoute(app, deps) {
|
|
|
12286
12579
|
/**
|
|
12287
12580
|
* Queue management and abort routes.
|
|
12288
12581
|
*
|
|
12289
|
-
* - GET /queue —
|
|
12290
|
-
* - POST /queue/clear — remove
|
|
12582
|
+
* - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
|
|
12583
|
+
* - POST /queue/clear — remove override entries only
|
|
12291
12584
|
* - POST /synthesize/abort — abort the current synthesis
|
|
12292
12585
|
*
|
|
12293
12586
|
* @module routes/queue
|
|
@@ -12295,33 +12588,134 @@ function registerPreviewRoute(app, deps) {
|
|
|
12295
12588
|
/** Register queue management routes. */
|
|
12296
12589
|
function registerQueueRoutes(app, deps) {
|
|
12297
12590
|
const { queue } = deps;
|
|
12298
|
-
app.get('/queue', () =>
|
|
12299
|
-
|
|
12300
|
-
|
|
12301
|
-
state
|
|
12302
|
-
|
|
12591
|
+
app.get('/queue', async () => {
|
|
12592
|
+
const currentPhase = queue.currentPhase;
|
|
12593
|
+
const overrides = queue.overrides;
|
|
12594
|
+
// Compute owedPhase for each override entry by reading meta state
|
|
12595
|
+
const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
|
|
12596
|
+
try {
|
|
12597
|
+
const metaDir = resolveMetaDir(o.path);
|
|
12598
|
+
const meta = await readMetaJson(metaDir);
|
|
12599
|
+
const ps = derivePhaseState(meta);
|
|
12600
|
+
return {
|
|
12601
|
+
path: o.path,
|
|
12602
|
+
owedPhase: getOwedPhase(ps),
|
|
12603
|
+
enqueuedAt: o.enqueuedAt,
|
|
12604
|
+
};
|
|
12605
|
+
}
|
|
12606
|
+
catch {
|
|
12607
|
+
return {
|
|
12608
|
+
path: o.path,
|
|
12609
|
+
owedPhase: null,
|
|
12610
|
+
enqueuedAt: o.enqueuedAt,
|
|
12611
|
+
};
|
|
12612
|
+
}
|
|
12613
|
+
}));
|
|
12614
|
+
// Compute automatic layer: all metas with a pending owed phase,
|
|
12615
|
+
// ranked by scheduler priority (computed on read, not persisted)
|
|
12616
|
+
let automatic = [];
|
|
12617
|
+
try {
|
|
12618
|
+
const metaResult = await listMetas(deps.config, deps.watcher);
|
|
12619
|
+
const candidates = buildPhaseCandidates(metaResult.entries);
|
|
12620
|
+
const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
|
|
12621
|
+
automatic = ranked.map((c) => ({
|
|
12622
|
+
path: c.node.metaPath,
|
|
12623
|
+
owedPhase: c.owedPhase,
|
|
12624
|
+
priorityBand: c.band,
|
|
12625
|
+
effectiveStaleness: c.effectiveStaleness,
|
|
12626
|
+
}));
|
|
12627
|
+
}
|
|
12628
|
+
catch {
|
|
12629
|
+
// If listing fails, automatic stays empty
|
|
12630
|
+
}
|
|
12631
|
+
// Legacy: pending is the union of overrides + automatic + legacy queue items
|
|
12632
|
+
const pendingItems = [
|
|
12633
|
+
...enrichedOverrides.map((o) => ({
|
|
12634
|
+
path: o.path,
|
|
12635
|
+
owedPhase: o.owedPhase,
|
|
12636
|
+
})),
|
|
12637
|
+
...automatic.map((a) => ({
|
|
12638
|
+
path: a.path,
|
|
12639
|
+
owedPhase: a.owedPhase,
|
|
12640
|
+
})),
|
|
12641
|
+
...queue.pending.map((item) => ({
|
|
12642
|
+
path: item.path,
|
|
12643
|
+
owedPhase: null,
|
|
12644
|
+
})),
|
|
12645
|
+
];
|
|
12646
|
+
return {
|
|
12647
|
+
current: currentPhase
|
|
12648
|
+
? {
|
|
12649
|
+
path: currentPhase.path,
|
|
12650
|
+
phase: currentPhase.phase,
|
|
12651
|
+
startedAt: currentPhase.startedAt,
|
|
12652
|
+
}
|
|
12653
|
+
: queue.current
|
|
12654
|
+
? {
|
|
12655
|
+
path: queue.current.path,
|
|
12656
|
+
phase: null,
|
|
12657
|
+
startedAt: queue.current.enqueuedAt,
|
|
12658
|
+
}
|
|
12659
|
+
: null,
|
|
12660
|
+
overrides: enrichedOverrides,
|
|
12661
|
+
automatic,
|
|
12662
|
+
pending: pendingItems,
|
|
12663
|
+
// Legacy state
|
|
12664
|
+
state: queue.getState(),
|
|
12665
|
+
};
|
|
12666
|
+
});
|
|
12303
12667
|
app.post('/queue/clear', () => {
|
|
12304
|
-
const removed = queue.
|
|
12668
|
+
const removed = queue.clearOverrides();
|
|
12305
12669
|
return { cleared: removed };
|
|
12306
12670
|
});
|
|
12307
12671
|
app.post('/synthesize/abort', async (_request, reply) => {
|
|
12308
|
-
|
|
12672
|
+
// Check 3-layer current first
|
|
12673
|
+
const currentPhase = queue.currentPhase;
|
|
12674
|
+
const current = currentPhase ?? queue.current;
|
|
12309
12675
|
if (!current) {
|
|
12310
|
-
return reply
|
|
12311
|
-
.status(404)
|
|
12312
|
-
.send({ error: 'NOT_FOUND', message: 'No synthesis in progress' });
|
|
12676
|
+
return reply.status(200).send({ status: 'idle' });
|
|
12313
12677
|
}
|
|
12314
12678
|
// Abort the executor
|
|
12315
12679
|
deps.executor?.abort();
|
|
12680
|
+
const metaDir = resolveMetaDir(current.path);
|
|
12681
|
+
const phase = currentPhase?.phase ?? null;
|
|
12682
|
+
// Transition running phase to failed and write _error to meta.json
|
|
12683
|
+
if (phase) {
|
|
12684
|
+
try {
|
|
12685
|
+
const meta = await readMetaJson(metaDir);
|
|
12686
|
+
let ps = derivePhaseState(meta);
|
|
12687
|
+
ps = phaseFailed(ps, phase);
|
|
12688
|
+
const updated = {
|
|
12689
|
+
...meta,
|
|
12690
|
+
_phaseState: ps,
|
|
12691
|
+
_error: {
|
|
12692
|
+
step: phase,
|
|
12693
|
+
code: 'ABORT',
|
|
12694
|
+
message: 'Aborted by operator',
|
|
12695
|
+
},
|
|
12696
|
+
};
|
|
12697
|
+
const lockPath = join(metaDir, '.lock');
|
|
12698
|
+
const metaJsonPath = join(metaDir, 'meta.json');
|
|
12699
|
+
await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
|
|
12700
|
+
await copyFile(lockPath, metaJsonPath);
|
|
12701
|
+
}
|
|
12702
|
+
catch {
|
|
12703
|
+
// Best-effort — meta may be unreadable
|
|
12704
|
+
}
|
|
12705
|
+
}
|
|
12316
12706
|
// Release the lock for the current meta path
|
|
12317
12707
|
try {
|
|
12318
|
-
releaseLock(
|
|
12708
|
+
releaseLock(metaDir);
|
|
12319
12709
|
}
|
|
12320
12710
|
catch {
|
|
12321
12711
|
// Lock may already be released
|
|
12322
12712
|
}
|
|
12323
12713
|
deps.logger.info({ path: current.path }, 'Synthesis aborted');
|
|
12324
|
-
return {
|
|
12714
|
+
return {
|
|
12715
|
+
status: 'aborted',
|
|
12716
|
+
path: current.path,
|
|
12717
|
+
...(phase ? { phase } : {}),
|
|
12718
|
+
};
|
|
12325
12719
|
});
|
|
12326
12720
|
}
|
|
12327
12721
|
|
|
@@ -12406,7 +12800,6 @@ async function checkDependency(url, path) {
|
|
|
12406
12800
|
return { url, status: 'unreachable', checkedAt };
|
|
12407
12801
|
}
|
|
12408
12802
|
}
|
|
12409
|
-
/** Check watcher, surfacing initialScan.active as indexing state. */
|
|
12410
12803
|
async function checkWatcher(url) {
|
|
12411
12804
|
const checkedAt = new Date().toISOString();
|
|
12412
12805
|
try {
|
|
@@ -12432,26 +12825,61 @@ async function checkWatcher(url) {
|
|
|
12432
12825
|
function deriveServiceState(deps) {
|
|
12433
12826
|
if (deps.shuttingDown)
|
|
12434
12827
|
return 'stopping';
|
|
12435
|
-
if (deps.queue.current)
|
|
12828
|
+
if (deps.queue.current || deps.queue.currentPhase)
|
|
12436
12829
|
return 'synthesizing';
|
|
12437
|
-
if (deps.queue.depth > 0)
|
|
12830
|
+
if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
|
|
12438
12831
|
return 'waiting';
|
|
12439
12832
|
return 'idle';
|
|
12440
12833
|
}
|
|
12834
|
+
function emptyPhaseCounts() {
|
|
12835
|
+
return { fresh: 0, stale: 0, pending: 0, running: 0, failed: 0 };
|
|
12836
|
+
}
|
|
12441
12837
|
function registerStatusRoute(app, deps) {
|
|
12442
12838
|
const statusHandler = createStatusHandler({
|
|
12443
12839
|
name: SERVICE_NAME,
|
|
12444
12840
|
version: SERVICE_VERSION,
|
|
12445
12841
|
getHealth: async () => {
|
|
12446
|
-
const { config, queue, scheduler, stats } = deps;
|
|
12842
|
+
const { config, queue, scheduler, stats, watcher } = deps;
|
|
12447
12843
|
// On-demand dependency checks
|
|
12448
12844
|
const [watcherHealth, gatewayHealth] = await Promise.all([
|
|
12449
12845
|
checkWatcher(config.watcherUrl),
|
|
12450
12846
|
checkDependency(config.gatewayUrl, '/status'),
|
|
12451
12847
|
]);
|
|
12848
|
+
// Phase state summary
|
|
12849
|
+
const phaseStateSummary = {
|
|
12850
|
+
architect: emptyPhaseCounts(),
|
|
12851
|
+
builder: emptyPhaseCounts(),
|
|
12852
|
+
critic: emptyPhaseCounts(),
|
|
12853
|
+
};
|
|
12854
|
+
let nextPhase = null;
|
|
12855
|
+
try {
|
|
12856
|
+
const metaResult = await listMetas(config, watcher);
|
|
12857
|
+
// Count raw phase states (before retry) for display
|
|
12858
|
+
for (const entry of metaResult.entries) {
|
|
12859
|
+
const ps = derivePhaseState(entry.meta);
|
|
12860
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
12861
|
+
phaseStateSummary[phase][ps[phase]]++;
|
|
12862
|
+
}
|
|
12863
|
+
}
|
|
12864
|
+
// Build candidates (with auto-retry) for scheduling
|
|
12865
|
+
const candidates = buildPhaseCandidates(metaResult.entries);
|
|
12866
|
+
// Find next phase candidate
|
|
12867
|
+
const winner = selectPhaseCandidate(candidates, config.depthWeight);
|
|
12868
|
+
if (winner) {
|
|
12869
|
+
nextPhase = {
|
|
12870
|
+
path: winner.node.metaPath,
|
|
12871
|
+
phase: winner.owedPhase,
|
|
12872
|
+
band: winner.band,
|
|
12873
|
+
staleness: winner.effectiveStaleness,
|
|
12874
|
+
};
|
|
12875
|
+
}
|
|
12876
|
+
}
|
|
12877
|
+
catch {
|
|
12878
|
+
// Watcher unreachable — phase summary unavailable
|
|
12879
|
+
}
|
|
12452
12880
|
return {
|
|
12453
12881
|
serviceState: deriveServiceState(deps),
|
|
12454
|
-
currentTarget: queue.current?.path ?? null,
|
|
12882
|
+
currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
|
|
12455
12883
|
queue: queue.getState(),
|
|
12456
12884
|
stats: {
|
|
12457
12885
|
totalSyntheses: stats.totalSyntheses,
|
|
@@ -12471,6 +12899,8 @@ function registerStatusRoute(app, deps) {
|
|
|
12471
12899
|
},
|
|
12472
12900
|
gateway: gatewayHealth,
|
|
12473
12901
|
},
|
|
12902
|
+
phaseStateSummary,
|
|
12903
|
+
nextPhase,
|
|
12474
12904
|
};
|
|
12475
12905
|
},
|
|
12476
12906
|
});
|
|
@@ -12483,6 +12913,9 @@ function registerStatusRoute(app, deps) {
|
|
|
12483
12913
|
/**
|
|
12484
12914
|
* POST /synthesize route handler.
|
|
12485
12915
|
*
|
|
12916
|
+
* Path-targeted triggers create explicit override entries in the queue.
|
|
12917
|
+
* Corpus-wide triggers discover the stalest candidate.
|
|
12918
|
+
*
|
|
12486
12919
|
* @module routes/synthesize
|
|
12487
12920
|
*/
|
|
12488
12921
|
const synthesizeBodySchema = z.object({
|
|
@@ -12493,44 +12926,70 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
12493
12926
|
app.post('/synthesize', async (request, reply) => {
|
|
12494
12927
|
const body = synthesizeBodySchema.parse(request.body);
|
|
12495
12928
|
const { config, watcher, queue } = deps;
|
|
12496
|
-
let targetPath;
|
|
12497
12929
|
if (body.path) {
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
let
|
|
12930
|
+
// Path-targeted trigger: create override entry
|
|
12931
|
+
const targetPath = resolveMetaDir(body.path);
|
|
12932
|
+
// Read meta to determine owed phase
|
|
12933
|
+
let owedPhase = null;
|
|
12934
|
+
let meta;
|
|
12503
12935
|
try {
|
|
12504
|
-
|
|
12936
|
+
meta = await readMetaJson(targetPath);
|
|
12937
|
+
const phaseState = derivePhaseState(meta);
|
|
12938
|
+
owedPhase = getOwedPhase(phaseState);
|
|
12505
12939
|
}
|
|
12506
12940
|
catch {
|
|
12507
|
-
|
|
12508
|
-
error: 'SERVICE_UNAVAILABLE',
|
|
12509
|
-
message: 'Watcher unreachable — cannot discover candidates',
|
|
12510
|
-
});
|
|
12941
|
+
// Meta unreadable — proceed, phase will be evaluated at dequeue time
|
|
12511
12942
|
}
|
|
12512
|
-
|
|
12513
|
-
|
|
12514
|
-
.
|
|
12515
|
-
node: e.node,
|
|
12516
|
-
meta: e.meta,
|
|
12517
|
-
actualStaleness: e.stalenessSeconds,
|
|
12518
|
-
}));
|
|
12519
|
-
const stalest = discoverStalestPath(stale, config.depthWeight);
|
|
12520
|
-
if (!stalest) {
|
|
12521
|
-
return reply.code(200).send({
|
|
12943
|
+
// Fully fresh meta → skip (reuse meta already read above)
|
|
12944
|
+
if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
|
|
12945
|
+
return await reply.code(200).send({
|
|
12522
12946
|
status: 'skipped',
|
|
12523
|
-
|
|
12947
|
+
path: targetPath,
|
|
12948
|
+
owedPhase: null,
|
|
12949
|
+
queuePosition: -1,
|
|
12950
|
+
alreadyQueued: false,
|
|
12524
12951
|
});
|
|
12525
12952
|
}
|
|
12526
|
-
|
|
12953
|
+
const result = queue.enqueueOverride(targetPath);
|
|
12954
|
+
return reply.code(202).send({
|
|
12955
|
+
status: 'queued',
|
|
12956
|
+
path: targetPath,
|
|
12957
|
+
owedPhase,
|
|
12958
|
+
queuePosition: result.position,
|
|
12959
|
+
alreadyQueued: result.alreadyQueued,
|
|
12960
|
+
});
|
|
12961
|
+
}
|
|
12962
|
+
// Corpus-wide trigger: discover stalest candidate
|
|
12963
|
+
let result;
|
|
12964
|
+
try {
|
|
12965
|
+
result = await listMetas(config, watcher);
|
|
12527
12966
|
}
|
|
12528
|
-
|
|
12967
|
+
catch {
|
|
12968
|
+
return reply.status(503).send({
|
|
12969
|
+
error: 'SERVICE_UNAVAILABLE',
|
|
12970
|
+
message: 'Watcher unreachable — cannot discover candidates',
|
|
12971
|
+
});
|
|
12972
|
+
}
|
|
12973
|
+
const stale = result.entries
|
|
12974
|
+
.filter((e) => e.stalenessSeconds > 0 && !e.disabled)
|
|
12975
|
+
.map((e) => ({
|
|
12976
|
+
node: e.node,
|
|
12977
|
+
meta: e.meta,
|
|
12978
|
+
actualStaleness: e.stalenessSeconds,
|
|
12979
|
+
}));
|
|
12980
|
+
const stalest = discoverStalestPath(stale, config.depthWeight);
|
|
12981
|
+
if (!stalest) {
|
|
12982
|
+
return reply.code(200).send({
|
|
12983
|
+
status: 'skipped',
|
|
12984
|
+
message: 'No stale metas found. Nothing to synthesize.',
|
|
12985
|
+
});
|
|
12986
|
+
}
|
|
12987
|
+
const enqueueResult = queue.enqueue(stalest);
|
|
12529
12988
|
return reply.code(202).send({
|
|
12530
12989
|
status: 'accepted',
|
|
12531
|
-
path:
|
|
12532
|
-
queuePosition:
|
|
12533
|
-
alreadyQueued:
|
|
12990
|
+
path: stalest,
|
|
12991
|
+
queuePosition: enqueueResult.position,
|
|
12992
|
+
alreadyQueued: enqueueResult.alreadyQueued,
|
|
12534
12993
|
});
|
|
12535
12994
|
});
|
|
12536
12995
|
}
|
|
@@ -12568,6 +13027,17 @@ function registerUnlockRoute(app, deps) {
|
|
|
12568
13027
|
*
|
|
12569
13028
|
* @module routes
|
|
12570
13029
|
*/
|
|
13030
|
+
/**
|
|
13031
|
+
* Large generated fields excluded from detail/update response projections.
|
|
13032
|
+
* Shared between metas detail and metasUpdate routes.
|
|
13033
|
+
*/
|
|
13034
|
+
const DEFAULT_EXCLUDE_FIELDS = new Set([
|
|
13035
|
+
'_architect',
|
|
13036
|
+
'_builder',
|
|
13037
|
+
'_critic',
|
|
13038
|
+
'_content',
|
|
13039
|
+
'_feedback',
|
|
13040
|
+
]);
|
|
12571
13041
|
/** Register all HTTP routes on the Fastify instance. */
|
|
12572
13042
|
function registerRoutes(app, deps) {
|
|
12573
13043
|
// Global error handler for validation + watcher errors
|
|
@@ -12822,10 +13292,9 @@ async function startService(config, configPath) {
|
|
|
12822
13292
|
}
|
|
12823
13293
|
// Progress reporter — uses shared config reference so hot-reload propagates
|
|
12824
13294
|
const progress = new ProgressReporter(config, logger);
|
|
12825
|
-
// Wire queue processing —
|
|
13295
|
+
// Wire queue processing — execute one phase per dequeue (phase-state machine)
|
|
12826
13296
|
const synthesizeFn = async (path) => {
|
|
12827
13297
|
const startMs = Date.now();
|
|
12828
|
-
let cycleTokens = 0;
|
|
12829
13298
|
// Strip .meta suffix for human-readable progress reporting
|
|
12830
13299
|
const ownerPath = path.replace(/\/?\.meta\/?$/, '');
|
|
12831
13300
|
await progress.report({
|
|
@@ -12833,49 +13302,50 @@ async function startService(config, configPath) {
|
|
|
12833
13302
|
path: ownerPath,
|
|
12834
13303
|
});
|
|
12835
13304
|
try {
|
|
12836
|
-
const
|
|
13305
|
+
const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
|
|
13306
|
+
// Wire current-phase tracking for GET /queue and POST /synthesize/abort
|
|
13307
|
+
if (evt.type === 'phase_start' && evt.phase) {
|
|
13308
|
+
queue.setCurrentPhase(ownerPath, evt.phase);
|
|
13309
|
+
}
|
|
12837
13310
|
// Track token stats from phase completions
|
|
12838
13311
|
if (evt.type === 'phase_complete') {
|
|
12839
13312
|
if (evt.tokens !== undefined) {
|
|
12840
13313
|
stats.totalTokens += evt.tokens;
|
|
12841
|
-
if (cycleTokens !== undefined) {
|
|
12842
|
-
cycleTokens += evt.tokens;
|
|
12843
|
-
}
|
|
12844
13314
|
}
|
|
12845
13315
|
else {
|
|
12846
|
-
cycleTokens = undefined;
|
|
12847
13316
|
logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
|
|
12848
13317
|
}
|
|
12849
13318
|
}
|
|
12850
13319
|
await progress.report(evt);
|
|
12851
13320
|
}, logger);
|
|
12852
|
-
// orchestrate() always returns exactly one result
|
|
12853
|
-
const result = results[0];
|
|
12854
13321
|
const durationMs = Date.now() - startMs;
|
|
12855
|
-
if (!result.
|
|
12856
|
-
|
|
12857
|
-
logger.debug({ path: ownerPath }, 'Synthesis skipped');
|
|
13322
|
+
if (!result.executed) {
|
|
13323
|
+
logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
|
|
12858
13324
|
return;
|
|
12859
13325
|
}
|
|
12860
13326
|
// Update stats
|
|
12861
13327
|
stats.totalSyntheses++;
|
|
12862
13328
|
stats.lastCycleDurationMs = durationMs;
|
|
12863
13329
|
stats.lastCycleAt = new Date().toISOString();
|
|
12864
|
-
|
|
13330
|
+
const phaseResult = result.phaseResult;
|
|
13331
|
+
if (phaseResult?.error) {
|
|
12865
13332
|
stats.totalErrors++;
|
|
12866
13333
|
await progress.report({
|
|
12867
13334
|
type: 'error',
|
|
12868
13335
|
path: ownerPath,
|
|
12869
|
-
phase:
|
|
12870
|
-
error:
|
|
13336
|
+
phase: phaseResult.error.step,
|
|
13337
|
+
error: phaseResult.error.message,
|
|
12871
13338
|
});
|
|
12872
13339
|
}
|
|
12873
13340
|
else {
|
|
13341
|
+
// Task #9: Reset backoff on ANY successful phase execution
|
|
12874
13342
|
scheduler.resetBackoff();
|
|
13343
|
+
}
|
|
13344
|
+
// Emit synthesis_complete only on full-cycle completion
|
|
13345
|
+
if (result.cycleComplete) {
|
|
12875
13346
|
await progress.report({
|
|
12876
13347
|
type: 'synthesis_complete',
|
|
12877
13348
|
path: ownerPath,
|
|
12878
|
-
tokens: cycleTokens,
|
|
12879
13349
|
durationMs,
|
|
12880
13350
|
});
|
|
12881
13351
|
}
|