@karmaniverous/jeeves-meta 0.13.11 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/cli/jeeves-meta/index.js +1181 -607
- package/dist/index.d.ts +181 -52
- package/dist/index.js +1266 -149
- package/package.json +1 -1
|
@@ -8554,6 +8554,7 @@ function computeSummary(entries, depthWeight) {
|
|
|
8554
8554
|
let staleCount = 0;
|
|
8555
8555
|
let errorCount = 0;
|
|
8556
8556
|
let lockedCount = 0;
|
|
8557
|
+
let disabledCount = 0;
|
|
8557
8558
|
let neverSynthesizedCount = 0;
|
|
8558
8559
|
let totalArchitectTokens = 0;
|
|
8559
8560
|
let totalBuilderTokens = 0;
|
|
@@ -8569,6 +8570,8 @@ function computeSummary(entries, depthWeight) {
|
|
|
8569
8570
|
errorCount++;
|
|
8570
8571
|
if (e.locked)
|
|
8571
8572
|
lockedCount++;
|
|
8573
|
+
if (e.disabled)
|
|
8574
|
+
disabledCount++;
|
|
8572
8575
|
if (e.lastSynthesized === null)
|
|
8573
8576
|
neverSynthesizedCount++;
|
|
8574
8577
|
totalArchitectTokens += e.architectTokens ?? 0;
|
|
@@ -8593,6 +8596,7 @@ function computeSummary(entries, depthWeight) {
|
|
|
8593
8596
|
stale: staleCount,
|
|
8594
8597
|
errors: errorCount,
|
|
8595
8598
|
locked: lockedCount,
|
|
8599
|
+
disabled: disabledCount,
|
|
8596
8600
|
neverSynthesized: neverSynthesizedCount,
|
|
8597
8601
|
tokens: {
|
|
8598
8602
|
architect: totalArchitectTokens,
|
|
@@ -8913,22 +8917,6 @@ async function isStale(scopePrefix, meta, watcher) {
|
|
|
8913
8917
|
}
|
|
8914
8918
|
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
8915
8919
|
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
8916
|
-
/**
|
|
8917
|
-
* Compute actual staleness in seconds (now minus _generatedAt).
|
|
8918
|
-
*
|
|
8919
|
-
* Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
|
|
8920
|
-
* (1 year) so that depth weighting can differentiate them. Without
|
|
8921
|
-
* bounding, `Infinity * depthFactor` = `Infinity` for all depths.
|
|
8922
|
-
*
|
|
8923
|
-
* @param meta - Current meta.json content.
|
|
8924
|
-
* @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
|
|
8925
|
-
*/
|
|
8926
|
-
function actualStaleness(meta) {
|
|
8927
|
-
if (!meta._generatedAt)
|
|
8928
|
-
return MAX_STALENESS_SECONDS;
|
|
8929
|
-
const generatedMs = new Date(meta._generatedAt).getTime();
|
|
8930
|
-
return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
|
|
8931
|
-
}
|
|
8932
8920
|
/**
|
|
8933
8921
|
* Check whether the architect step should be triggered.
|
|
8934
8922
|
*
|
|
@@ -9089,6 +9077,7 @@ async function listMetas(config, watcher) {
|
|
|
9089
9077
|
const emphasis = meta._emphasis ?? 1;
|
|
9090
9078
|
const hasError = Boolean(meta._error);
|
|
9091
9079
|
const locked = isLocked(normalizePath(node.metaPath));
|
|
9080
|
+
const disabled = meta._disabled === true;
|
|
9092
9081
|
const neverSynth = !meta._generatedAt;
|
|
9093
9082
|
// Compute staleness
|
|
9094
9083
|
let stalenessSeconds;
|
|
@@ -9111,6 +9100,7 @@ async function listMetas(config, watcher) {
|
|
|
9111
9100
|
lastSynthesized: meta._generatedAt ?? null,
|
|
9112
9101
|
hasError,
|
|
9113
9102
|
locked,
|
|
9103
|
+
disabled,
|
|
9114
9104
|
architectTokens: archTokens > 0 ? archTokens : null,
|
|
9115
9105
|
builderTokens: buildTokens > 0 ? buildTokens : null,
|
|
9116
9106
|
criticTokens: critTokens > 0 ? critTokens : null,
|
|
@@ -9317,6 +9307,10 @@ class GatewayExecutor {
|
|
|
9317
9307
|
return undefined;
|
|
9318
9308
|
}
|
|
9319
9309
|
}
|
|
9310
|
+
/** Whether this executor has been aborted by the operator. */
|
|
9311
|
+
get aborted() {
|
|
9312
|
+
return this.controller.signal.aborted;
|
|
9313
|
+
}
|
|
9320
9314
|
/** Abort the currently running spawn, if any. */
|
|
9321
9315
|
abort() {
|
|
9322
9316
|
this.controller.abort();
|
|
@@ -9935,26 +9929,6 @@ function buildCriticTask(ctx, meta, config) {
|
|
|
9935
9929
|
return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
|
|
9936
9930
|
}
|
|
9937
9931
|
|
|
9938
|
-
/**
|
|
9939
|
-
* Exponential moving average helper for token tracking.
|
|
9940
|
-
*
|
|
9941
|
-
* @module ema
|
|
9942
|
-
*/
|
|
9943
|
-
const DEFAULT_DECAY = 0.3;
|
|
9944
|
-
/**
|
|
9945
|
-
* Compute exponential moving average.
|
|
9946
|
-
*
|
|
9947
|
-
* @param current - New observation.
|
|
9948
|
-
* @param previous - Previous EMA value, or undefined for first observation.
|
|
9949
|
-
* @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
|
|
9950
|
-
* @returns Updated EMA.
|
|
9951
|
-
*/
|
|
9952
|
-
function computeEma(current, previous, decay = DEFAULT_DECAY) {
|
|
9953
|
-
if (previous === undefined)
|
|
9954
|
-
return current;
|
|
9955
|
-
return decay * current + (1 - decay) * previous;
|
|
9956
|
-
}
|
|
9957
|
-
|
|
9958
9932
|
/**
|
|
9959
9933
|
* Structured error from a synthesis step failure.
|
|
9960
9934
|
*
|
|
@@ -9978,8 +9952,24 @@ const metaErrorSchema = z.object({
|
|
|
9978
9952
|
*
|
|
9979
9953
|
* @module schema/meta
|
|
9980
9954
|
*/
|
|
9955
|
+
/** Valid states for a synthesis phase. */
|
|
9956
|
+
const phaseStatuses = [
|
|
9957
|
+
'fresh',
|
|
9958
|
+
'stale',
|
|
9959
|
+
'pending',
|
|
9960
|
+
'running',
|
|
9961
|
+
'failed',
|
|
9962
|
+
];
|
|
9963
|
+
/** Zod schema for a per-phase status value. */
|
|
9964
|
+
const phaseStatusSchema = z.enum(phaseStatuses);
|
|
9965
|
+
/** Zod schema for the per-meta phase state record. */
|
|
9966
|
+
const phaseStateSchema = z.object({
|
|
9967
|
+
architect: phaseStatusSchema,
|
|
9968
|
+
builder: phaseStatusSchema,
|
|
9969
|
+
critic: phaseStatusSchema,
|
|
9970
|
+
});
|
|
9981
9971
|
/** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
|
|
9982
|
-
|
|
9972
|
+
z
|
|
9983
9973
|
.object({
|
|
9984
9974
|
/** Stable identity. Auto-generated on first synthesis if not provided. */
|
|
9985
9975
|
_id: z.uuid().optional(),
|
|
@@ -10056,105 +10046,17 @@ const metaJsonSchema = z
|
|
|
10056
10046
|
* Cleared on successful cycle.
|
|
10057
10047
|
*/
|
|
10058
10048
|
_error: metaErrorSchema.optional(),
|
|
10049
|
+
/** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
|
|
10050
|
+
_disabled: z.boolean().optional(),
|
|
10051
|
+
/**
|
|
10052
|
+
* Per-phase state machine record. Engine-managed.
|
|
10053
|
+
* Keyed by phase name (architect, builder, critic) with status values.
|
|
10054
|
+
* Persisted to survive ticks; derived on first load for back-compat.
|
|
10055
|
+
*/
|
|
10056
|
+
_phaseState: phaseStateSchema.optional(),
|
|
10059
10057
|
})
|
|
10060
10058
|
.loose();
|
|
10061
10059
|
|
|
10062
|
-
/**
|
|
10063
|
-
* Merge synthesis results into meta.json.
|
|
10064
|
-
*
|
|
10065
|
-
* Preserves human-set fields (_id, _steer, _depth).
|
|
10066
|
-
* Writes engine fields (_generatedAt, _structureHash, etc.).
|
|
10067
|
-
* Validates against schema before writing.
|
|
10068
|
-
*
|
|
10069
|
-
* @module orchestrator/merge
|
|
10070
|
-
*/
|
|
10071
|
-
/**
|
|
10072
|
-
* Merge results into meta.json and write atomically.
|
|
10073
|
-
*
|
|
10074
|
-
* @param options - Merge options.
|
|
10075
|
-
* @returns The updated MetaJson.
|
|
10076
|
-
* @throws If validation fails (malformed output).
|
|
10077
|
-
*/
|
|
10078
|
-
async function mergeAndWrite(options) {
|
|
10079
|
-
const merged = {
|
|
10080
|
-
// Preserve human-set fields (auto-generate _id on first synthesis)
|
|
10081
|
-
_id: options.current._id ?? randomUUID(),
|
|
10082
|
-
_steer: options.current._steer,
|
|
10083
|
-
_depth: options.current._depth,
|
|
10084
|
-
_emphasis: options.current._emphasis,
|
|
10085
|
-
// Engine fields
|
|
10086
|
-
_architect: options.architect,
|
|
10087
|
-
_builder: options.builder,
|
|
10088
|
-
_critic: options.critic,
|
|
10089
|
-
_generatedAt: options.stateOnly
|
|
10090
|
-
? options.current._generatedAt
|
|
10091
|
-
: new Date().toISOString(),
|
|
10092
|
-
_structureHash: options.structureHash,
|
|
10093
|
-
_synthesisCount: options.synthesisCount,
|
|
10094
|
-
// Token tracking
|
|
10095
|
-
_architectTokens: options.architectTokens,
|
|
10096
|
-
_builderTokens: options.builderTokens,
|
|
10097
|
-
_criticTokens: options.criticTokens,
|
|
10098
|
-
_architectTokensAvg: options.architectTokens !== undefined
|
|
10099
|
-
? computeEma(options.architectTokens, options.current._architectTokensAvg)
|
|
10100
|
-
: options.current._architectTokensAvg,
|
|
10101
|
-
_builderTokensAvg: options.builderTokens !== undefined
|
|
10102
|
-
? computeEma(options.builderTokens, options.current._builderTokensAvg)
|
|
10103
|
-
: options.current._builderTokensAvg,
|
|
10104
|
-
_criticTokensAvg: options.criticTokens !== undefined
|
|
10105
|
-
? computeEma(options.criticTokens, options.current._criticTokensAvg)
|
|
10106
|
-
: options.current._criticTokensAvg,
|
|
10107
|
-
// Content from builder (stateOnly preserves previous content)
|
|
10108
|
-
_content: options.stateOnly
|
|
10109
|
-
? options.current._content
|
|
10110
|
-
: (options.builderOutput?.content ?? options.current._content),
|
|
10111
|
-
// Feedback from critic
|
|
10112
|
-
_feedback: options.feedback ?? options.current._feedback,
|
|
10113
|
-
// Progressive state
|
|
10114
|
-
_state: options.state,
|
|
10115
|
-
// Error handling
|
|
10116
|
-
_error: options.error ?? undefined,
|
|
10117
|
-
// Spread structured fields from builder
|
|
10118
|
-
...options.builderOutput?.fields,
|
|
10119
|
-
};
|
|
10120
|
-
// Clean up undefined optional fields
|
|
10121
|
-
if (merged._steer === undefined)
|
|
10122
|
-
delete merged._steer;
|
|
10123
|
-
if (merged._depth === undefined)
|
|
10124
|
-
delete merged._depth;
|
|
10125
|
-
if (merged._emphasis === undefined)
|
|
10126
|
-
delete merged._emphasis;
|
|
10127
|
-
if (merged._architectTokens === undefined)
|
|
10128
|
-
delete merged._architectTokens;
|
|
10129
|
-
if (merged._builderTokens === undefined)
|
|
10130
|
-
delete merged._builderTokens;
|
|
10131
|
-
if (merged._criticTokens === undefined)
|
|
10132
|
-
delete merged._criticTokens;
|
|
10133
|
-
if (merged._architectTokensAvg === undefined)
|
|
10134
|
-
delete merged._architectTokensAvg;
|
|
10135
|
-
if (merged._builderTokensAvg === undefined)
|
|
10136
|
-
delete merged._builderTokensAvg;
|
|
10137
|
-
if (merged._criticTokensAvg === undefined)
|
|
10138
|
-
delete merged._criticTokensAvg;
|
|
10139
|
-
if (merged._state === undefined)
|
|
10140
|
-
delete merged._state;
|
|
10141
|
-
if (merged._error === undefined)
|
|
10142
|
-
delete merged._error;
|
|
10143
|
-
if (merged._content === undefined)
|
|
10144
|
-
delete merged._content;
|
|
10145
|
-
if (merged._feedback === undefined)
|
|
10146
|
-
delete merged._feedback;
|
|
10147
|
-
// Validate
|
|
10148
|
-
const result = metaJsonSchema.safeParse(merged);
|
|
10149
|
-
if (!result.success) {
|
|
10150
|
-
throw new Error(`Meta validation failed: ${result.error.message}`);
|
|
10151
|
-
}
|
|
10152
|
-
// Write to specified path (lock staging) or default meta.json
|
|
10153
|
-
const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
|
|
10154
|
-
await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
|
|
10155
|
-
return result.data;
|
|
10156
|
-
}
|
|
10157
|
-
|
|
10158
10060
|
/**
|
|
10159
10061
|
* Build a minimal MetaNode from a known meta path using watcher walk.
|
|
10160
10062
|
*
|
|
@@ -10335,43 +10237,6 @@ function computeStructureHash(filePaths) {
|
|
|
10335
10237
|
return createHash('sha256').update(content).digest('hex');
|
|
10336
10238
|
}
|
|
10337
10239
|
|
|
10338
|
-
/**
|
|
10339
|
-
* Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
|
|
10340
|
-
*
|
|
10341
|
-
* @module orchestrator/finalizeCycle
|
|
10342
|
-
*/
|
|
10343
|
-
/** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
|
|
10344
|
-
async function finalizeCycle(opts) {
|
|
10345
|
-
const lockPath = join(opts.metaPath, '.lock');
|
|
10346
|
-
const metaJsonPath = join(opts.metaPath, 'meta.json');
|
|
10347
|
-
// Stage: write merged result to .lock (sequential — ordering matters)
|
|
10348
|
-
const updated = await mergeAndWrite({
|
|
10349
|
-
metaPath: opts.metaPath,
|
|
10350
|
-
current: opts.current,
|
|
10351
|
-
architect: opts.architect,
|
|
10352
|
-
builder: opts.builder,
|
|
10353
|
-
critic: opts.critic,
|
|
10354
|
-
builderOutput: opts.builderOutput,
|
|
10355
|
-
feedback: opts.feedback,
|
|
10356
|
-
structureHash: opts.structureHash,
|
|
10357
|
-
synthesisCount: opts.synthesisCount,
|
|
10358
|
-
error: opts.error,
|
|
10359
|
-
architectTokens: opts.architectTokens,
|
|
10360
|
-
builderTokens: opts.builderTokens,
|
|
10361
|
-
criticTokens: opts.criticTokens,
|
|
10362
|
-
outputPath: lockPath,
|
|
10363
|
-
state: opts.state,
|
|
10364
|
-
stateOnly: opts.stateOnly,
|
|
10365
|
-
});
|
|
10366
|
-
// Commit: copy .lock → meta.json
|
|
10367
|
-
await copyFile(lockPath, metaJsonPath);
|
|
10368
|
-
// Archive + prune from the committed meta.json (sequential)
|
|
10369
|
-
await createSnapshot(opts.metaPath, updated);
|
|
10370
|
-
await pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
10371
|
-
// .lock is cleaned up by the finally block (releaseLock)
|
|
10372
|
-
return updated;
|
|
10373
|
-
}
|
|
10374
|
-
|
|
10375
10240
|
/**
|
|
10376
10241
|
* Parse subprocess outputs for each synthesis step.
|
|
10377
10242
|
*
|
|
@@ -10470,162 +10335,441 @@ function parseCriticOutput(output) {
|
|
|
10470
10335
|
}
|
|
10471
10336
|
|
|
10472
10337
|
/**
|
|
10473
|
-
*
|
|
10338
|
+
* Pure phase-state transition functions.
|
|
10339
|
+
*
|
|
10340
|
+
* Implements every row of the §8 "Transitions and invalidation cascade" table.
|
|
10341
|
+
* No I/O — pure functions over PhaseState and documented inputs.
|
|
10474
10342
|
*
|
|
10475
|
-
* @module
|
|
10343
|
+
* @module phaseState/phaseTransitions
|
|
10476
10344
|
*/
|
|
10477
10345
|
/**
|
|
10478
|
-
*
|
|
10346
|
+
* Create a fresh (fully-complete) phase state.
|
|
10347
|
+
*/
|
|
10348
|
+
function freshPhaseState() {
|
|
10349
|
+
return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
|
|
10350
|
+
}
|
|
10351
|
+
/**
|
|
10352
|
+
* Create a phase state for a never-synthesized meta (all pending from architect).
|
|
10353
|
+
*/
|
|
10354
|
+
function initialPhaseState() {
|
|
10355
|
+
return { architect: 'pending', builder: 'stale', critic: 'stale' };
|
|
10356
|
+
}
|
|
10357
|
+
/**
|
|
10358
|
+
* Enforce the per-meta invariant: at most one phase is pending or running,
|
|
10359
|
+
* and it is the first non-fresh phase in pipeline order.
|
|
10479
10360
|
*
|
|
10480
|
-
*
|
|
10481
|
-
* if the caller should fall through to a hard failure.
|
|
10361
|
+
* Stale phases that become the first non-fresh phase are promoted to pending.
|
|
10482
10362
|
*/
|
|
10483
|
-
|
|
10484
|
-
const
|
|
10485
|
-
let
|
|
10486
|
-
|
|
10487
|
-
const
|
|
10488
|
-
|
|
10363
|
+
function enforceInvariant(state) {
|
|
10364
|
+
const result = { ...state };
|
|
10365
|
+
let foundNonFresh = false;
|
|
10366
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
10367
|
+
const s = result[phase];
|
|
10368
|
+
if (s === 'fresh')
|
|
10369
|
+
continue;
|
|
10370
|
+
if (!foundNonFresh) {
|
|
10371
|
+
foundNonFresh = true;
|
|
10372
|
+
// First non-fresh: if stale, promote to pending
|
|
10373
|
+
if (s === 'stale') {
|
|
10374
|
+
result[phase] = 'pending';
|
|
10375
|
+
}
|
|
10376
|
+
// pending, running, failed stay as-is
|
|
10377
|
+
}
|
|
10378
|
+
else {
|
|
10379
|
+
// Subsequent non-fresh: must not be pending or running
|
|
10380
|
+
if (s === 'pending') {
|
|
10381
|
+
result[phase] = 'stale';
|
|
10382
|
+
}
|
|
10383
|
+
// running in non-first position would be a bug, but don't mask it
|
|
10384
|
+
}
|
|
10489
10385
|
}
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
10495
|
-
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10386
|
+
return result;
|
|
10387
|
+
}
|
|
10388
|
+
// ── Phase success transitions ──────────────────────────────────────────
|
|
10389
|
+
/**
|
|
10390
|
+
* Architect completes successfully.
|
|
10391
|
+
* architect → fresh; builder → pending; critic → stale.
|
|
10392
|
+
*/
|
|
10393
|
+
function architectSuccess(state) {
|
|
10394
|
+
return enforceInvariant({
|
|
10395
|
+
architect: 'fresh',
|
|
10396
|
+
builder: state.builder === 'failed' ? 'failed' : 'pending',
|
|
10397
|
+
critic: state.critic === 'fresh' ? 'stale' : state.critic,
|
|
10398
|
+
});
|
|
10399
|
+
}
|
|
10400
|
+
/**
|
|
10401
|
+
* Builder completes successfully.
|
|
10402
|
+
* builder → fresh; critic → pending.
|
|
10403
|
+
*/
|
|
10404
|
+
function builderSuccess(state) {
|
|
10405
|
+
return enforceInvariant({
|
|
10406
|
+
...state,
|
|
10407
|
+
builder: 'fresh',
|
|
10408
|
+
critic: state.critic === 'failed' ? 'failed' : 'pending',
|
|
10409
|
+
});
|
|
10410
|
+
}
|
|
10411
|
+
/**
|
|
10412
|
+
* Critic completes successfully.
|
|
10413
|
+
* critic → fresh. Meta becomes fully fresh.
|
|
10414
|
+
*/
|
|
10415
|
+
function criticSuccess(state) {
|
|
10416
|
+
return enforceInvariant({
|
|
10417
|
+
...state,
|
|
10418
|
+
critic: 'fresh',
|
|
10419
|
+
});
|
|
10420
|
+
}
|
|
10421
|
+
// ── Failure transition ─────────────────────────────────────────────────
|
|
10422
|
+
/**
|
|
10423
|
+
* A phase fails (error, timeout, or abort).
|
|
10424
|
+
* Target phase → failed; upstream and downstream unchanged.
|
|
10425
|
+
*/
|
|
10426
|
+
function phaseFailed(state, phase) {
|
|
10427
|
+
return enforceInvariant({
|
|
10428
|
+
...state,
|
|
10429
|
+
[phase]: 'failed',
|
|
10430
|
+
});
|
|
10431
|
+
}
|
|
10432
|
+
// ── Surgical retry ─────────────────────────────────────────────────────
|
|
10433
|
+
/**
|
|
10434
|
+
* Retry a failed phase: failed → pending.
|
|
10435
|
+
* Only valid when the phase is currently failed.
|
|
10436
|
+
*/
|
|
10437
|
+
function retryPhase(state, phase) {
|
|
10438
|
+
if (state[phase] !== 'failed')
|
|
10439
|
+
return state;
|
|
10440
|
+
return enforceInvariant({
|
|
10441
|
+
...state,
|
|
10442
|
+
[phase]: 'pending',
|
|
10443
|
+
});
|
|
10444
|
+
}
|
|
10445
|
+
/**
|
|
10446
|
+
* Retry all failed phases: each failed phase → pending.
|
|
10447
|
+
* Used by scheduler ticks and queue reads to auto-promote failed phases.
|
|
10448
|
+
*/
|
|
10449
|
+
function retryAllFailed(state) {
|
|
10450
|
+
let result = state;
|
|
10451
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
10452
|
+
if (result[phase] === 'failed') {
|
|
10453
|
+
result = retryPhase(result, phase);
|
|
10522
10454
|
}
|
|
10523
10455
|
}
|
|
10456
|
+
return result;
|
|
10457
|
+
}
|
|
10458
|
+
// ── Running transition ─────────────────────────────────────────────────
|
|
10459
|
+
/**
|
|
10460
|
+
* Mark a phase as running (scheduler picks it).
|
|
10461
|
+
*/
|
|
10462
|
+
function phaseRunning(state, phase) {
|
|
10463
|
+
return {
|
|
10464
|
+
...state,
|
|
10465
|
+
[phase]: 'running',
|
|
10466
|
+
};
|
|
10467
|
+
}
|
|
10468
|
+
// ── Query helpers ──────────────────────────────────────────────────────
|
|
10469
|
+
/**
|
|
10470
|
+
* Get the owed phase: first non-fresh phase in pipeline order, or null.
|
|
10471
|
+
*/
|
|
10472
|
+
function getOwedPhase(state) {
|
|
10473
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
10474
|
+
if (state[phase] !== 'fresh')
|
|
10475
|
+
return phase;
|
|
10476
|
+
}
|
|
10524
10477
|
return null;
|
|
10525
10478
|
}
|
|
10479
|
+
/**
|
|
10480
|
+
* Check if a meta is fully fresh (all phases fresh).
|
|
10481
|
+
*/
|
|
10482
|
+
function isFullyFresh(state) {
|
|
10483
|
+
return (state.architect === 'fresh' &&
|
|
10484
|
+
state.builder === 'fresh' &&
|
|
10485
|
+
state.critic === 'fresh');
|
|
10486
|
+
}
|
|
10487
|
+
/**
|
|
10488
|
+
* Get the scheduler priority band for a meta's owed phase.
|
|
10489
|
+
* 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
|
|
10490
|
+
*/
|
|
10491
|
+
function getPriorityBand(state) {
|
|
10492
|
+
const owed = getOwedPhase(state);
|
|
10493
|
+
if (!owed)
|
|
10494
|
+
return null;
|
|
10495
|
+
if (owed === 'critic')
|
|
10496
|
+
return 1;
|
|
10497
|
+
if (owed === 'builder')
|
|
10498
|
+
return 2;
|
|
10499
|
+
return 3;
|
|
10500
|
+
}
|
|
10526
10501
|
|
|
10527
10502
|
/**
|
|
10528
|
-
*
|
|
10503
|
+
* Backward-compatible derivation of _phaseState from existing meta fields.
|
|
10504
|
+
*
|
|
10505
|
+
* When a meta is loaded from disk without _phaseState, this reconstructs
|
|
10506
|
+
* the phase state from _content, _builder, _state, _error.step, and
|
|
10507
|
+
* the architect-invalidating inputs.
|
|
10529
10508
|
*
|
|
10530
|
-
* @module
|
|
10509
|
+
* @module phaseState/derivePhaseState
|
|
10531
10510
|
*/
|
|
10532
|
-
/**
|
|
10533
|
-
|
|
10534
|
-
|
|
10535
|
-
|
|
10536
|
-
|
|
10537
|
-
|
|
10538
|
-
|
|
10539
|
-
|
|
10540
|
-
|
|
10541
|
-
|
|
10542
|
-
|
|
10543
|
-
|
|
10544
|
-
|
|
10545
|
-
|
|
10546
|
-
|
|
10547
|
-
|
|
10548
|
-
|
|
10549
|
-
|
|
10550
|
-
|
|
10551
|
-
|
|
10552
|
-
|
|
10553
|
-
|
|
10554
|
-
|
|
10555
|
-
|
|
10556
|
-
|
|
10557
|
-
|
|
10558
|
-
|
|
10559
|
-
|
|
10560
|
-
|
|
10561
|
-
|
|
10562
|
-
|
|
10563
|
-
let builderBrief = currentMeta._builder ?? '';
|
|
10564
|
-
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
10565
|
-
let stepError = null;
|
|
10566
|
-
let architectTokens;
|
|
10567
|
-
let builderTokens;
|
|
10568
|
-
let criticTokens;
|
|
10569
|
-
// Shared base options for all finalizeCycle calls.
|
|
10570
|
-
// Note: synthesisCount is excluded because it mutates during the pipeline.
|
|
10571
|
-
const baseFinalizeOptions = {
|
|
10572
|
-
metaPath: node.metaPath,
|
|
10573
|
-
current: currentMeta,
|
|
10574
|
-
config,
|
|
10575
|
-
architect: currentMeta._architect ?? '',
|
|
10576
|
-
critic: currentMeta._critic ?? '',
|
|
10577
|
-
structureHash: newStructureHash,
|
|
10578
|
-
};
|
|
10579
|
-
if (architectTriggered) {
|
|
10580
|
-
try {
|
|
10581
|
-
await onProgress?.({
|
|
10582
|
-
type: 'phase_start',
|
|
10583
|
-
path: node.ownerPath,
|
|
10584
|
-
phase: 'architect',
|
|
10585
|
-
});
|
|
10586
|
-
const phaseStart = Date.now();
|
|
10587
|
-
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
10588
|
-
const architectResult = await executor.spawn(architectTask, {
|
|
10589
|
-
thinking: config.thinking,
|
|
10590
|
-
timeout: config.architectTimeout,
|
|
10591
|
-
label: 'meta-architect',
|
|
10592
|
-
});
|
|
10593
|
-
builderBrief = parseArchitectOutput(architectResult.output);
|
|
10594
|
-
architectTokens = architectResult.tokens;
|
|
10595
|
-
synthesisCount = 0;
|
|
10596
|
-
await onProgress?.({
|
|
10597
|
-
type: 'phase_complete',
|
|
10598
|
-
path: node.ownerPath,
|
|
10599
|
-
phase: 'architect',
|
|
10600
|
-
tokens: architectTokens,
|
|
10601
|
-
durationMs: Date.now() - phaseStart,
|
|
10602
|
-
});
|
|
10603
|
-
}
|
|
10604
|
-
catch (err) {
|
|
10605
|
-
stepError = toMetaError('architect', err);
|
|
10606
|
-
if (!currentMeta._builder) {
|
|
10607
|
-
// No cached builder — cycle fails
|
|
10608
|
-
await finalizeCycle({
|
|
10609
|
-
...baseFinalizeOptions,
|
|
10610
|
-
builder: '',
|
|
10611
|
-
builderOutput: null,
|
|
10612
|
-
feedback: null,
|
|
10613
|
-
synthesisCount,
|
|
10614
|
-
error: stepError,
|
|
10615
|
-
architectTokens,
|
|
10616
|
-
});
|
|
10617
|
-
return {
|
|
10618
|
-
synthesized: true,
|
|
10619
|
-
metaPath: node.metaPath,
|
|
10620
|
-
error: stepError,
|
|
10621
|
-
};
|
|
10511
|
+
/**
|
|
10512
|
+
* Derive _phaseState from existing meta fields.
|
|
10513
|
+
*
|
|
10514
|
+
* If the meta already has _phaseState, returns it as-is.
|
|
10515
|
+
*
|
|
10516
|
+
* Otherwise, reconstructs from available fields:
|
|
10517
|
+
* - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
|
|
10518
|
+
* - Errored meta: the failed phase is mapped from _error.step.
|
|
10519
|
+
* - Mid-cycle meta with cached _builder but no _content: builder pending.
|
|
10520
|
+
* - Fully-fresh meta: all phases fresh.
|
|
10521
|
+
* - Meta with stale architect inputs: architect pending, downstream stale.
|
|
10522
|
+
*
|
|
10523
|
+
* @param meta - The meta.json content.
|
|
10524
|
+
* @param inputs - Optional derivation inputs. If not provided, a simpler
|
|
10525
|
+
* heuristic is used (no architect invalidation check).
|
|
10526
|
+
* @returns The derived PhaseState.
|
|
10527
|
+
*/
|
|
10528
|
+
function derivePhaseState(meta, inputs) {
|
|
10529
|
+
// Already has _phaseState — use it
|
|
10530
|
+
if (meta._phaseState)
|
|
10531
|
+
return meta._phaseState;
|
|
10532
|
+
// Check for errors first — _error.step maps directly to failed phase
|
|
10533
|
+
if (meta._error) {
|
|
10534
|
+
const failedPhase = meta._error.step;
|
|
10535
|
+
const state = freshPhaseState();
|
|
10536
|
+
state[failedPhase] = 'failed';
|
|
10537
|
+
// If architect failed and no _builder, downstream is stale
|
|
10538
|
+
if (failedPhase === 'architect') {
|
|
10539
|
+
if (!meta._builder) {
|
|
10540
|
+
state.builder = 'stale';
|
|
10541
|
+
state.critic = 'stale';
|
|
10622
10542
|
}
|
|
10623
|
-
// Has cached builder — continue with existing
|
|
10624
10543
|
}
|
|
10544
|
+
// If builder failed, critic is stale
|
|
10545
|
+
if (failedPhase === 'builder') {
|
|
10546
|
+
state.critic = 'stale';
|
|
10547
|
+
}
|
|
10548
|
+
return state;
|
|
10549
|
+
}
|
|
10550
|
+
// Never synthesized: no _content AND no _builder (and no error)
|
|
10551
|
+
if (!meta._content && !meta._builder) {
|
|
10552
|
+
return initialPhaseState();
|
|
10553
|
+
}
|
|
10554
|
+
// Check architect invalidation (when inputs are provided)
|
|
10555
|
+
if (inputs) {
|
|
10556
|
+
const architectInvalidated = inputs.structureChanged ||
|
|
10557
|
+
inputs.steerChanged ||
|
|
10558
|
+
inputs.architectChanged ||
|
|
10559
|
+
inputs.crossRefsChanged ||
|
|
10560
|
+
(meta._synthesisCount ?? 0) >= inputs.architectEvery;
|
|
10561
|
+
if (architectInvalidated) {
|
|
10562
|
+
return {
|
|
10563
|
+
architect: 'pending',
|
|
10564
|
+
builder: 'stale',
|
|
10565
|
+
critic: 'stale',
|
|
10566
|
+
};
|
|
10567
|
+
}
|
|
10568
|
+
}
|
|
10569
|
+
// Has _builder but no _content: builder is pending
|
|
10570
|
+
if (meta._builder && !meta._content) {
|
|
10571
|
+
return {
|
|
10572
|
+
architect: 'fresh',
|
|
10573
|
+
builder: 'pending',
|
|
10574
|
+
critic: 'stale',
|
|
10575
|
+
};
|
|
10576
|
+
}
|
|
10577
|
+
// Has _content but no _feedback: critic is pending
|
|
10578
|
+
if (meta._content && !meta._feedback) {
|
|
10579
|
+
return {
|
|
10580
|
+
architect: 'fresh',
|
|
10581
|
+
builder: 'fresh',
|
|
10582
|
+
critic: 'pending',
|
|
10583
|
+
};
|
|
10584
|
+
}
|
|
10585
|
+
// Default: fully fresh
|
|
10586
|
+
return freshPhaseState();
|
|
10587
|
+
}
|
|
10588
|
+
|
|
10589
|
+
/**
|
|
10590
|
+
* Corpus-wide phase scheduler.
|
|
10591
|
+
*
|
|
10592
|
+
* Selects the highest-priority ready phase across all metas.
|
|
10593
|
+
* Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
|
|
10594
|
+
* Tiebreak within band: weighted staleness (§3.9).
|
|
10595
|
+
*
|
|
10596
|
+
* @module phaseState/phaseScheduler
|
|
10597
|
+
*/
|
|
10598
|
+
/**
|
|
10599
|
+
* Build phase candidates from listMetas entries.
|
|
10600
|
+
*
|
|
10601
|
+
* Derives phase state and auto-retries failed phases for each entry.
|
|
10602
|
+
* Used by orchestratePhase, queue route, and status route.
|
|
10603
|
+
*/
|
|
10604
|
+
function buildPhaseCandidates(entries) {
|
|
10605
|
+
return entries.map((entry) => ({
|
|
10606
|
+
node: entry.node,
|
|
10607
|
+
meta: entry.meta,
|
|
10608
|
+
phaseState: retryAllFailed(derivePhaseState(entry.meta)),
|
|
10609
|
+
actualStaleness: entry.stalenessSeconds,
|
|
10610
|
+
locked: entry.locked,
|
|
10611
|
+
disabled: entry.disabled,
|
|
10612
|
+
}));
|
|
10613
|
+
}
|
|
10614
|
+
/**
|
|
10615
|
+
* Rank all eligible phase candidates by priority.
|
|
10616
|
+
*
|
|
10617
|
+
* Filters to pending phases, computes effective staleness, and sorts by
|
|
10618
|
+
* band (ascending: critic first) then effective staleness (descending).
|
|
10619
|
+
*
|
|
10620
|
+
* Used by selectPhaseCandidate (returns first) and the queue route (returns all).
|
|
10621
|
+
*/
|
|
10622
|
+
function rankPhaseCandidates(metas, depthWeight) {
|
|
10623
|
+
// Filter to metas with a pending (scheduler-eligible) phase
|
|
10624
|
+
const eligible = metas.filter((m) => {
|
|
10625
|
+
if (m.locked)
|
|
10626
|
+
return false;
|
|
10627
|
+
if (m.disabled && !m.isOverride)
|
|
10628
|
+
return false;
|
|
10629
|
+
const owed = getOwedPhase(m.phaseState);
|
|
10630
|
+
if (!owed)
|
|
10631
|
+
return false;
|
|
10632
|
+
return m.phaseState[owed] === 'pending';
|
|
10633
|
+
});
|
|
10634
|
+
if (eligible.length === 0)
|
|
10635
|
+
return [];
|
|
10636
|
+
// Compute effective staleness for tiebreaking
|
|
10637
|
+
const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
|
|
10638
|
+
node: m.node,
|
|
10639
|
+
meta: m.meta,
|
|
10640
|
+
actualStaleness: m.actualStaleness,
|
|
10641
|
+
})), depthWeight);
|
|
10642
|
+
// Build candidates with band info
|
|
10643
|
+
const candidates = withStaleness.map((ws, i) => {
|
|
10644
|
+
const m = eligible[i];
|
|
10645
|
+
const owedPhase = getOwedPhase(m.phaseState);
|
|
10646
|
+
return {
|
|
10647
|
+
node: ws.node,
|
|
10648
|
+
meta: ws.meta,
|
|
10649
|
+
phaseState: m.phaseState,
|
|
10650
|
+
owedPhase,
|
|
10651
|
+
band: getPriorityBand(m.phaseState),
|
|
10652
|
+
actualStaleness: ws.actualStaleness,
|
|
10653
|
+
effectiveStaleness: ws.effectiveStaleness,
|
|
10654
|
+
};
|
|
10655
|
+
});
|
|
10656
|
+
// Sort by band (ascending = critic first) then effective staleness (descending)
|
|
10657
|
+
candidates.sort((a, b) => {
|
|
10658
|
+
if (a.band !== b.band)
|
|
10659
|
+
return a.band - b.band;
|
|
10660
|
+
return b.effectiveStaleness - a.effectiveStaleness;
|
|
10661
|
+
});
|
|
10662
|
+
return candidates;
|
|
10663
|
+
}
|
|
10664
|
+
/**
|
|
10665
|
+
* Select the best phase candidate across the corpus.
|
|
10666
|
+
*
|
|
10667
|
+
* @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
|
|
10668
|
+
* @param depthWeight - Config depthWeight for staleness tiebreak.
|
|
10669
|
+
* @returns The winning candidate, or null if no phase is ready.
|
|
10670
|
+
*/
|
|
10671
|
+
function selectPhaseCandidate(metas, depthWeight) {
|
|
10672
|
+
return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
|
|
10673
|
+
}
|
|
10674
|
+
|
|
10675
|
+
/**
|
|
10676
|
+
* Per-phase executors for the phase-state machine.
|
|
10677
|
+
*
|
|
10678
|
+
* Each function runs exactly one phase on one meta, updates _phaseState
|
|
10679
|
+
* via pure transitions, and persists via the lock-staged write.
|
|
10680
|
+
*
|
|
10681
|
+
* @module orchestrator/runPhase
|
|
10682
|
+
*/
|
|
10683
|
+
/** Write updated meta with phase state via lock staging. */
|
|
10684
|
+
async function persistPhaseState(base, phaseState, updates) {
|
|
10685
|
+
const lockPath = join(base.metaPath, '.lock');
|
|
10686
|
+
const metaJsonPath = join(base.metaPath, 'meta.json');
|
|
10687
|
+
const merged = {
|
|
10688
|
+
...base.current,
|
|
10689
|
+
...updates,
|
|
10690
|
+
_phaseState: phaseState,
|
|
10691
|
+
_structureHash: base.structureHash,
|
|
10692
|
+
};
|
|
10693
|
+
// Clean undefined
|
|
10694
|
+
if (merged._error === undefined)
|
|
10695
|
+
delete merged._error;
|
|
10696
|
+
await writeFile(lockPath, JSON.stringify(merged, null, 2) + '\n');
|
|
10697
|
+
await copyFile(lockPath, metaJsonPath);
|
|
10698
|
+
return merged;
|
|
10699
|
+
}
|
|
10700
|
+
/**
|
|
10701
|
+
* Handle phase failure (abort or error).
|
|
10702
|
+
*
|
|
10703
|
+
* Shared error path for all three phase executors. When the executor was
|
|
10704
|
+
* aborted, returns immediately without persisting (abort route handles it).
|
|
10705
|
+
* Otherwise, transitions the phase to failed and persists the error.
|
|
10706
|
+
*/
|
|
10707
|
+
async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpdates) {
|
|
10708
|
+
if (executor.aborted) {
|
|
10709
|
+
return {
|
|
10710
|
+
executed: true,
|
|
10711
|
+
phaseState: phaseFailed(ps, phase),
|
|
10712
|
+
error: { step: phase, code: 'ABORT', message: 'Aborted by operator' },
|
|
10713
|
+
};
|
|
10625
10714
|
}
|
|
10626
|
-
|
|
10627
|
-
const
|
|
10628
|
-
|
|
10715
|
+
const error = toMetaError(phase, err);
|
|
10716
|
+
const failedPs = phaseFailed(ps, phase);
|
|
10717
|
+
await persistPhaseState(base, failedPs, {
|
|
10718
|
+
_error: error,
|
|
10719
|
+
...additionalUpdates,
|
|
10720
|
+
});
|
|
10721
|
+
return { executed: true, phaseState: failedPs, error };
|
|
10722
|
+
}
|
|
10723
|
+
// ── Architect executor ─────────────────────────────────────────────────
|
|
10724
|
+
async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
|
|
10725
|
+
let ps = phaseRunning(phaseState, 'architect');
|
|
10726
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
10727
|
+
try {
|
|
10728
|
+
await onProgress?.({
|
|
10729
|
+
type: 'phase_start',
|
|
10730
|
+
path: node.ownerPath,
|
|
10731
|
+
phase: 'architect',
|
|
10732
|
+
});
|
|
10733
|
+
const phaseStart = Date.now();
|
|
10734
|
+
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
10735
|
+
const result = await executor.spawn(architectTask, {
|
|
10736
|
+
thinking: config.thinking,
|
|
10737
|
+
timeout: config.architectTimeout,
|
|
10738
|
+
label: 'meta-architect',
|
|
10739
|
+
});
|
|
10740
|
+
const builderBrief = parseArchitectOutput(result.output);
|
|
10741
|
+
const architectTokens = result.tokens;
|
|
10742
|
+
// Architect success: architect → fresh, _synthesisCount → 0
|
|
10743
|
+
ps = architectSuccess(ps);
|
|
10744
|
+
const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
|
|
10745
|
+
_builder: builderBrief,
|
|
10746
|
+
_architect: currentMeta._architect ?? config.defaultArchitect ?? '',
|
|
10747
|
+
_synthesisCount: 0,
|
|
10748
|
+
_architectTokens: architectTokens,
|
|
10749
|
+
_generatedAt: new Date().toISOString(),
|
|
10750
|
+
_error: undefined,
|
|
10751
|
+
});
|
|
10752
|
+
await onProgress?.({
|
|
10753
|
+
type: 'phase_complete',
|
|
10754
|
+
path: node.ownerPath,
|
|
10755
|
+
phase: 'architect',
|
|
10756
|
+
tokens: architectTokens,
|
|
10757
|
+
durationMs: Date.now() - phaseStart,
|
|
10758
|
+
});
|
|
10759
|
+
return { executed: true, phaseState: ps, updatedMeta };
|
|
10760
|
+
}
|
|
10761
|
+
catch (err) {
|
|
10762
|
+
return handlePhaseFailure('architect', err, executor, ps, {
|
|
10763
|
+
metaPath: node.metaPath,
|
|
10764
|
+
current: currentMeta,
|
|
10765
|
+
structureHash,
|
|
10766
|
+
});
|
|
10767
|
+
}
|
|
10768
|
+
}
|
|
10769
|
+
// ── Builder executor ───────────────────────────────────────────────────
|
|
10770
|
+
async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
|
|
10771
|
+
let ps = phaseRunning(phaseState, 'builder');
|
|
10772
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
10629
10773
|
try {
|
|
10630
10774
|
await onProgress?.({
|
|
10631
10775
|
type: 'phase_start',
|
|
@@ -10633,15 +10777,24 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10633
10777
|
phase: 'builder',
|
|
10634
10778
|
});
|
|
10635
10779
|
const builderStart = Date.now();
|
|
10636
|
-
const builderTask = buildBuilderTask(ctx,
|
|
10637
|
-
const
|
|
10780
|
+
const builderTask = buildBuilderTask(ctx, currentMeta, config);
|
|
10781
|
+
const result = await executor.spawn(builderTask, {
|
|
10638
10782
|
thinking: config.thinking,
|
|
10639
10783
|
timeout: config.builderTimeout,
|
|
10640
10784
|
label: 'meta-builder',
|
|
10641
10785
|
});
|
|
10642
|
-
builderOutput = parseBuilderOutput(
|
|
10643
|
-
builderTokens =
|
|
10644
|
-
|
|
10786
|
+
const builderOutput = parseBuilderOutput(result.output);
|
|
10787
|
+
const builderTokens = result.tokens;
|
|
10788
|
+
// Builder success: builder → fresh, critic → pending
|
|
10789
|
+
ps = builderSuccess(ps);
|
|
10790
|
+
const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
|
|
10791
|
+
_content: builderOutput.content,
|
|
10792
|
+
_state: builderOutput.state,
|
|
10793
|
+
_builderTokens: builderTokens,
|
|
10794
|
+
_generatedAt: new Date().toISOString(),
|
|
10795
|
+
_error: undefined,
|
|
10796
|
+
...builderOutput.fields,
|
|
10797
|
+
});
|
|
10645
10798
|
await onProgress?.({
|
|
10646
10799
|
type: 'phase_complete',
|
|
10647
10800
|
path: node.ownerPath,
|
|
@@ -10649,38 +10802,37 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10649
10802
|
tokens: builderTokens,
|
|
10650
10803
|
durationMs: Date.now() - builderStart,
|
|
10651
10804
|
});
|
|
10805
|
+
return { executed: true, phaseState: ps, updatedMeta };
|
|
10652
10806
|
}
|
|
10653
10807
|
catch (err) {
|
|
10808
|
+
// §4.6 partial _state recovery on timeout
|
|
10809
|
+
let partialState;
|
|
10654
10810
|
if (err instanceof SpawnTimeoutError) {
|
|
10655
|
-
|
|
10656
|
-
err,
|
|
10657
|
-
|
|
10658
|
-
|
|
10659
|
-
|
|
10660
|
-
|
|
10661
|
-
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
10666
|
-
}
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
feedback: null,
|
|
10673
|
-
synthesisCount,
|
|
10674
|
-
error: stepError,
|
|
10675
|
-
});
|
|
10676
|
-
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
10811
|
+
try {
|
|
10812
|
+
const raw = await readFile(err.outputPath, 'utf8');
|
|
10813
|
+
const partial = parseBuilderOutput(raw);
|
|
10814
|
+
if (partial.state !== undefined &&
|
|
10815
|
+
JSON.stringify(partial.state) !== JSON.stringify(currentMeta._state)) {
|
|
10816
|
+
partialState = { _state: partial.state };
|
|
10817
|
+
}
|
|
10818
|
+
}
|
|
10819
|
+
catch {
|
|
10820
|
+
// Could not read partial output — no state recovery
|
|
10821
|
+
}
|
|
10822
|
+
}
|
|
10823
|
+
return handlePhaseFailure('builder', err, executor, ps, {
|
|
10824
|
+
metaPath: node.metaPath,
|
|
10825
|
+
current: currentMeta,
|
|
10826
|
+
structureHash,
|
|
10827
|
+
}, partialState);
|
|
10677
10828
|
}
|
|
10678
|
-
|
|
10679
|
-
|
|
10680
|
-
|
|
10681
|
-
|
|
10682
|
-
|
|
10683
|
-
|
|
10829
|
+
}
|
|
10830
|
+
// ── Critic executor ────────────────────────────────────────────────────
|
|
10831
|
+
async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
|
|
10832
|
+
let ps = phaseRunning(phaseState, 'critic');
|
|
10833
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
10834
|
+
// Build critic task using current meta's _content
|
|
10835
|
+
const metaForCritic = { ...currentMeta };
|
|
10684
10836
|
try {
|
|
10685
10837
|
await onProgress?.({
|
|
10686
10838
|
type: 'phase_start',
|
|
@@ -10689,14 +10841,32 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10689
10841
|
});
|
|
10690
10842
|
const criticStart = Date.now();
|
|
10691
10843
|
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
10692
|
-
const
|
|
10844
|
+
const result = await executor.spawn(criticTask, {
|
|
10693
10845
|
thinking: config.thinking,
|
|
10694
10846
|
timeout: config.criticTimeout,
|
|
10695
10847
|
label: 'meta-critic',
|
|
10696
10848
|
});
|
|
10697
|
-
feedback = parseCriticOutput(
|
|
10698
|
-
criticTokens =
|
|
10699
|
-
|
|
10849
|
+
const feedback = parseCriticOutput(result.output);
|
|
10850
|
+
const criticTokens = result.tokens;
|
|
10851
|
+
// Critic success: critic → fresh
|
|
10852
|
+
ps = criticSuccess(ps);
|
|
10853
|
+
const cycleComplete = isFullyFresh(ps);
|
|
10854
|
+
const updates = {
|
|
10855
|
+
_feedback: feedback,
|
|
10856
|
+
_criticTokens: criticTokens,
|
|
10857
|
+
_error: undefined,
|
|
10858
|
+
};
|
|
10859
|
+
// Full-cycle completion: increment _synthesisCount, archive, emit.
|
|
10860
|
+
// Per spec: architect resets to 0, full-cycle increments on top.
|
|
10861
|
+
if (cycleComplete) {
|
|
10862
|
+
updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
|
|
10863
|
+
}
|
|
10864
|
+
const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
|
|
10865
|
+
// Archive on full-cycle only
|
|
10866
|
+
if (cycleComplete) {
|
|
10867
|
+
await createSnapshot(node.metaPath, updatedMeta);
|
|
10868
|
+
await pruneArchive(node.metaPath, config.maxArchive);
|
|
10869
|
+
}
|
|
10700
10870
|
await onProgress?.({
|
|
10701
10871
|
type: 'phase_complete',
|
|
10702
10872
|
path: node.ownerPath,
|
|
@@ -10704,139 +10874,144 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
10704
10874
|
tokens: criticTokens,
|
|
10705
10875
|
durationMs: Date.now() - criticStart,
|
|
10706
10876
|
});
|
|
10877
|
+
return {
|
|
10878
|
+
executed: true,
|
|
10879
|
+
phaseState: ps,
|
|
10880
|
+
updatedMeta,
|
|
10881
|
+
cycleComplete,
|
|
10882
|
+
};
|
|
10707
10883
|
}
|
|
10708
10884
|
catch (err) {
|
|
10709
|
-
|
|
10710
|
-
|
|
10711
|
-
|
|
10712
|
-
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
builderOutput,
|
|
10716
|
-
feedback,
|
|
10717
|
-
synthesisCount,
|
|
10718
|
-
error: stepError,
|
|
10719
|
-
architectTokens,
|
|
10720
|
-
builderTokens,
|
|
10721
|
-
criticTokens,
|
|
10722
|
-
state: builderOutput.state,
|
|
10723
|
-
});
|
|
10724
|
-
return {
|
|
10725
|
-
synthesized: true,
|
|
10726
|
-
metaPath: node.metaPath,
|
|
10727
|
-
error: stepError ?? undefined,
|
|
10728
|
-
};
|
|
10885
|
+
return handlePhaseFailure('critic', err, executor, ps, {
|
|
10886
|
+
metaPath: node.metaPath,
|
|
10887
|
+
current: currentMeta,
|
|
10888
|
+
structureHash,
|
|
10889
|
+
});
|
|
10890
|
+
}
|
|
10729
10891
|
}
|
|
10730
10892
|
|
|
10731
10893
|
/**
|
|
10732
|
-
*
|
|
10894
|
+
* Phase-aware orchestration entry point.
|
|
10895
|
+
*
|
|
10896
|
+
* Replaces the old staleness-based orchestrate() with phase-state-machine
|
|
10897
|
+
* scheduling: each tick discovers all metas, computes invalidation,
|
|
10898
|
+
* auto-retries failed phases, selects the best phase candidate, and
|
|
10899
|
+
* executes exactly one phase.
|
|
10733
10900
|
*
|
|
10734
|
-
* @module orchestrator/
|
|
10901
|
+
* @module orchestrator/orchestratePhase
|
|
10735
10902
|
*/
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10903
|
+
/** Phase runner dispatch map — avoids repeating the same switch/case. */
|
|
10904
|
+
const phaseRunners = {
|
|
10905
|
+
architect: runArchitect,
|
|
10906
|
+
builder: runBuilder,
|
|
10907
|
+
critic: runCritic,
|
|
10908
|
+
};
|
|
10909
|
+
/**
|
|
10910
|
+
* Run a single phase-aware orchestration tick.
|
|
10911
|
+
*
|
|
10912
|
+
* When targetPath is provided (override entry), runs the owed phase for
|
|
10913
|
+
* that specific meta. Otherwise, discovers all metas, computes invalidation,
|
|
10914
|
+
* and selects the best phase candidate corpus-wide.
|
|
10915
|
+
*/
|
|
10916
|
+
async function orchestratePhase(config, executor, watcher, targetPath, onProgress, logger) {
|
|
10917
|
+
// ── Targeted path (override entry) ──
|
|
10739
10918
|
if (targetPath) {
|
|
10740
|
-
|
|
10741
|
-
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
10742
|
-
if (!existsSync(targetMetaJson))
|
|
10743
|
-
return { synthesized: false };
|
|
10744
|
-
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
10745
|
-
if (!acquireLock(node.metaPath))
|
|
10746
|
-
return { synthesized: false };
|
|
10747
|
-
try {
|
|
10748
|
-
const currentMeta = await readMetaJson(normalizedTarget);
|
|
10749
|
-
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
|
|
10750
|
-
}
|
|
10751
|
-
finally {
|
|
10752
|
-
releaseLock(node.metaPath);
|
|
10753
|
-
}
|
|
10919
|
+
return orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger);
|
|
10754
10920
|
}
|
|
10755
|
-
//
|
|
10756
|
-
|
|
10757
|
-
|
|
10758
|
-
|
|
10759
|
-
logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
|
|
10760
|
-
if (metaPaths.length === 0)
|
|
10761
|
-
return { synthesized: false };
|
|
10762
|
-
// Read meta.json for each discovered meta
|
|
10763
|
-
const metas = new Map();
|
|
10764
|
-
for (const mp of metaPaths) {
|
|
10765
|
-
try {
|
|
10766
|
-
metas.set(normalizePath(mp), await readMetaJson(mp));
|
|
10767
|
-
}
|
|
10768
|
-
catch {
|
|
10769
|
-
// Skip metas with unreadable meta.json
|
|
10770
|
-
continue;
|
|
10771
|
-
}
|
|
10921
|
+
// ── Corpus-wide discovery + phase selection ──
|
|
10922
|
+
let metaResult;
|
|
10923
|
+
try {
|
|
10924
|
+
metaResult = await listMetas(config, watcher);
|
|
10772
10925
|
}
|
|
10773
|
-
|
|
10774
|
-
|
|
10775
|
-
|
|
10776
|
-
|
|
10777
|
-
|
|
10778
|
-
|
|
10779
|
-
|
|
10780
|
-
|
|
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
|
-
if (
|
|
10807
|
-
|
|
10808
|
-
|
|
10926
|
+
catch (err) {
|
|
10927
|
+
logger?.warn({ err }, 'Failed to list metas for phase selection');
|
|
10928
|
+
return { executed: false };
|
|
10929
|
+
}
|
|
10930
|
+
if (metaResult.entries.length === 0)
|
|
10931
|
+
return { executed: false };
|
|
10932
|
+
// Build candidates with phase state (including invalidation + auto-retry)
|
|
10933
|
+
const candidates = buildPhaseCandidates(metaResult.entries);
|
|
10934
|
+
// Select best phase candidate
|
|
10935
|
+
const winner = selectPhaseCandidate(candidates, config.depthWeight);
|
|
10936
|
+
if (!winner) {
|
|
10937
|
+
return { executed: false };
|
|
10938
|
+
}
|
|
10939
|
+
// Acquire lock
|
|
10940
|
+
if (!acquireLock(winner.node.metaPath)) {
|
|
10941
|
+
logger?.debug({ path: winner.node.metaPath }, 'Selected candidate is locked, skipping');
|
|
10942
|
+
return { executed: false };
|
|
10943
|
+
}
|
|
10944
|
+
try {
|
|
10945
|
+
// Re-read meta under lock for freshness
|
|
10946
|
+
const currentMeta = await readMetaJson(winner.node.metaPath);
|
|
10947
|
+
const phaseState = retryAllFailed(derivePhaseState(currentMeta));
|
|
10948
|
+
const owedPhase = getOwedPhase(phaseState);
|
|
10949
|
+
if (!owedPhase || phaseState[owedPhase] !== 'pending') {
|
|
10950
|
+
// Nothing to do (race: became fresh between selection and lock)
|
|
10951
|
+
return { executed: false };
|
|
10952
|
+
}
|
|
10953
|
+
// Compute structure hash for the phase
|
|
10954
|
+
const { scopeFiles } = await getScopeFiles(winner.node, watcher);
|
|
10955
|
+
const structureHash = computeStructureHash(scopeFiles);
|
|
10956
|
+
// skipUnchanged: bump _generatedAt without altering _phaseState
|
|
10957
|
+
if (config.skipUnchanged && currentMeta._generatedAt) {
|
|
10958
|
+
const verifiedStale = await isStale(getScopePrefix(winner.node), currentMeta, watcher);
|
|
10959
|
+
if (!verifiedStale) {
|
|
10960
|
+
await persistPhaseState({
|
|
10961
|
+
metaPath: winner.node.metaPath,
|
|
10962
|
+
current: currentMeta,
|
|
10963
|
+
config,
|
|
10964
|
+
structureHash,
|
|
10965
|
+
}, phaseState, { _generatedAt: new Date().toISOString() });
|
|
10966
|
+
logger?.debug({ path: winner.node.ownerPath }, 'Skipped unchanged meta, bumped _generatedAt');
|
|
10967
|
+
return { executed: false };
|
|
10968
|
+
}
|
|
10809
10969
|
}
|
|
10810
|
-
winner
|
|
10811
|
-
|
|
10970
|
+
return await executePhase(winner.node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
|
|
10971
|
+
}
|
|
10972
|
+
finally {
|
|
10973
|
+
releaseLock(winner.node.metaPath);
|
|
10974
|
+
}
|
|
10975
|
+
}
|
|
10976
|
+
/**
|
|
10977
|
+
* Orchestrate a targeted (override) meta path.
|
|
10978
|
+
* Resolves the owed phase at execution time (not enqueue time).
|
|
10979
|
+
*/
|
|
10980
|
+
async function orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger) {
|
|
10981
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
10982
|
+
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
10983
|
+
if (!acquireLock(node.metaPath)) {
|
|
10984
|
+
return { executed: false };
|
|
10812
10985
|
}
|
|
10813
|
-
if (!winner)
|
|
10814
|
-
return { synthesized: false };
|
|
10815
|
-
const node = winner.node;
|
|
10816
10986
|
try {
|
|
10817
|
-
const currentMeta = await readMetaJson(
|
|
10818
|
-
|
|
10987
|
+
const currentMeta = await readMetaJson(normalizedTarget);
|
|
10988
|
+
const phaseState = retryAllFailed(derivePhaseState(currentMeta));
|
|
10989
|
+
const owedPhase = getOwedPhase(phaseState);
|
|
10990
|
+
if (!owedPhase) {
|
|
10991
|
+
// Fully fresh — override is a no-op (silently dropped per spec)
|
|
10992
|
+
return { executed: false, metaPath: normalizedTarget };
|
|
10993
|
+
}
|
|
10994
|
+
// Compute structure hash
|
|
10995
|
+
const { scopeFiles } = await getScopeFiles(node, watcher);
|
|
10996
|
+
const structureHash = computeStructureHash(scopeFiles);
|
|
10997
|
+
return await executePhase(node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
|
|
10819
10998
|
}
|
|
10820
10999
|
finally {
|
|
10821
|
-
// Step 13: Release lock
|
|
10822
11000
|
releaseLock(node.metaPath);
|
|
10823
11001
|
}
|
|
10824
11002
|
}
|
|
10825
11003
|
/**
|
|
10826
|
-
*
|
|
10827
|
-
*
|
|
10828
|
-
* Selects the stalest candidate (or a specific target) and runs the
|
|
10829
|
-
* full architect/builder/critic pipeline.
|
|
10830
|
-
*
|
|
10831
|
-
* @param config - Validated synthesis config.
|
|
10832
|
-
* @param executor - Pluggable LLM executor.
|
|
10833
|
-
* @param watcher - Watcher HTTP client.
|
|
10834
|
-
* @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
|
|
10835
|
-
* @returns Array with a single result.
|
|
11004
|
+
* Execute exactly one phase on a meta.
|
|
10836
11005
|
*/
|
|
10837
|
-
async function
|
|
10838
|
-
const result = await
|
|
10839
|
-
return
|
|
11006
|
+
async function executePhase(node, currentMeta, phaseState, phase, config, executor, watcher, structureHash, onProgress, logger) {
|
|
11007
|
+
const result = await phaseRunners[phase](node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger);
|
|
11008
|
+
return {
|
|
11009
|
+
executed: true,
|
|
11010
|
+
metaPath: node.metaPath,
|
|
11011
|
+
phase,
|
|
11012
|
+
phaseResult: result,
|
|
11013
|
+
cycleComplete: result.cycleComplete,
|
|
11014
|
+
};
|
|
10840
11015
|
}
|
|
10841
11016
|
|
|
10842
11017
|
/**
|
|
@@ -10973,42 +11148,106 @@ class ProgressReporter {
|
|
|
10973
11148
|
}
|
|
10974
11149
|
|
|
10975
11150
|
/**
|
|
10976
|
-
*
|
|
11151
|
+
* Hybrid 3-layer synthesis queue.
|
|
11152
|
+
*
|
|
11153
|
+
* Layer 1: Current — the single item currently executing (at most one).
|
|
11154
|
+
* Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
|
|
11155
|
+
* FIFO among overrides, ahead of automatic candidates.
|
|
11156
|
+
* Layer 3: Automatic — computed on read, not persisted. All metas with a
|
|
11157
|
+
* pending phase, ranked by scheduler priority.
|
|
10977
11158
|
*
|
|
10978
|
-
*
|
|
10979
|
-
* synthesis requests get priority (inserted at front). A path appears at
|
|
10980
|
-
* most once in the queue; re-triggering returns the current position.
|
|
11159
|
+
* Legacy: `pending` array is the union of overrides + automatic.
|
|
10981
11160
|
*
|
|
10982
11161
|
* @module queue
|
|
10983
11162
|
*/
|
|
10984
11163
|
const DEPTH_WARNING_THRESHOLD = 3;
|
|
10985
11164
|
/**
|
|
10986
|
-
*
|
|
11165
|
+
* Hybrid 3-layer synthesis queue.
|
|
10987
11166
|
*
|
|
10988
|
-
* Only one synthesis runs at a time.
|
|
10989
|
-
*
|
|
10990
|
-
* position returned.
|
|
11167
|
+
* Only one synthesis runs at a time. Override items (explicit triggers)
|
|
11168
|
+
* take priority over automatic candidates.
|
|
10991
11169
|
*/
|
|
10992
11170
|
class SynthesisQueue {
|
|
11171
|
+
/** Legacy queue (used by processQueue for backward compat). */
|
|
10993
11172
|
queue = [];
|
|
10994
11173
|
currentItem = null;
|
|
10995
11174
|
processing = false;
|
|
10996
11175
|
logger;
|
|
10997
11176
|
onEnqueueCallback = null;
|
|
10998
|
-
/**
|
|
10999
|
-
|
|
11000
|
-
|
|
11001
|
-
|
|
11002
|
-
*/
|
|
11177
|
+
/** Explicit override entries (3-layer model). */
|
|
11178
|
+
overrideEntries = [];
|
|
11179
|
+
/** Currently executing item with phase info (3-layer model). */
|
|
11180
|
+
currentPhaseItem = null;
|
|
11003
11181
|
constructor(logger) {
|
|
11004
11182
|
this.logger = logger;
|
|
11005
11183
|
}
|
|
11006
|
-
/**
|
|
11007
|
-
* Set a callback to invoke when a new (non-duplicate) item is enqueued.
|
|
11008
|
-
*/
|
|
11184
|
+
/** Set a callback to invoke when a new (non-duplicate) item is enqueued. */
|
|
11009
11185
|
onEnqueue(callback) {
|
|
11010
11186
|
this.onEnqueueCallback = callback;
|
|
11011
11187
|
}
|
|
11188
|
+
// ── Override layer (3-layer model) ─────────────────────────────────
|
|
11189
|
+
/**
|
|
11190
|
+
* Add an explicit override entry (from POST /synthesize with path).
|
|
11191
|
+
* Deduped by path. Returns position and whether already queued.
|
|
11192
|
+
*/
|
|
11193
|
+
enqueueOverride(path) {
|
|
11194
|
+
// Check if currently executing
|
|
11195
|
+
if (this.currentPhaseItem?.path === path ||
|
|
11196
|
+
this.currentItem?.path === path) {
|
|
11197
|
+
return { position: 0, alreadyQueued: true };
|
|
11198
|
+
}
|
|
11199
|
+
// Check if already in overrides
|
|
11200
|
+
const existing = this.overrideEntries.findIndex((e) => e.path === path);
|
|
11201
|
+
if (existing !== -1) {
|
|
11202
|
+
return { position: existing, alreadyQueued: true };
|
|
11203
|
+
}
|
|
11204
|
+
this.overrideEntries.push({
|
|
11205
|
+
path,
|
|
11206
|
+
enqueuedAt: new Date().toISOString(),
|
|
11207
|
+
});
|
|
11208
|
+
const position = this.overrideEntries.length - 1;
|
|
11209
|
+
if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
|
|
11210
|
+
this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
|
|
11211
|
+
}
|
|
11212
|
+
this.onEnqueueCallback?.();
|
|
11213
|
+
return { position, alreadyQueued: false };
|
|
11214
|
+
}
|
|
11215
|
+
/** Dequeue the next override entry, or undefined if empty. */
|
|
11216
|
+
dequeueOverride() {
|
|
11217
|
+
return this.overrideEntries.shift();
|
|
11218
|
+
}
|
|
11219
|
+
/** Get all override entries (shallow copy). */
|
|
11220
|
+
get overrides() {
|
|
11221
|
+
return [...this.overrideEntries];
|
|
11222
|
+
}
|
|
11223
|
+
/** Clear all override entries. Returns count removed. */
|
|
11224
|
+
clearOverrides() {
|
|
11225
|
+
const count = this.overrideEntries.length;
|
|
11226
|
+
this.overrideEntries = [];
|
|
11227
|
+
return count;
|
|
11228
|
+
}
|
|
11229
|
+
/** Check if a path is in the override layer. */
|
|
11230
|
+
hasOverride(path) {
|
|
11231
|
+
return this.overrideEntries.some((e) => e.path === path);
|
|
11232
|
+
}
|
|
11233
|
+
// ── Current-item tracking (3-layer model) ──────────────────────────
|
|
11234
|
+
/** Set the currently executing phase item. */
|
|
11235
|
+
setCurrentPhase(path, phase) {
|
|
11236
|
+
this.currentPhaseItem = {
|
|
11237
|
+
path,
|
|
11238
|
+
phase,
|
|
11239
|
+
startedAt: new Date().toISOString(),
|
|
11240
|
+
};
|
|
11241
|
+
}
|
|
11242
|
+
/** Clear the current phase item. */
|
|
11243
|
+
clearCurrentPhase() {
|
|
11244
|
+
this.currentPhaseItem = null;
|
|
11245
|
+
}
|
|
11246
|
+
/** The currently executing phase item, or null. */
|
|
11247
|
+
get currentPhase() {
|
|
11248
|
+
return this.currentPhaseItem;
|
|
11249
|
+
}
|
|
11250
|
+
// ── Legacy queue interface (preserved for backward compat) ─────────
|
|
11012
11251
|
/**
|
|
11013
11252
|
* Add a path to the synthesis queue.
|
|
11014
11253
|
*
|
|
@@ -11017,11 +11256,9 @@ class SynthesisQueue {
|
|
|
11017
11256
|
* @returns Position and whether the path was already queued.
|
|
11018
11257
|
*/
|
|
11019
11258
|
enqueue(path, priority = false) {
|
|
11020
|
-
// Check if currently being synthesized.
|
|
11021
11259
|
if (this.currentItem?.path === path) {
|
|
11022
11260
|
return { position: 0, alreadyQueued: true };
|
|
11023
11261
|
}
|
|
11024
|
-
// Check if already in queue.
|
|
11025
11262
|
const existingIndex = this.queue.findIndex((item) => item.path === path);
|
|
11026
11263
|
if (existingIndex !== -1) {
|
|
11027
11264
|
return { position: existingIndex, alreadyQueued: true };
|
|
@@ -11044,11 +11281,7 @@ class SynthesisQueue {
|
|
|
11044
11281
|
this.onEnqueueCallback?.();
|
|
11045
11282
|
return { position, alreadyQueued: false };
|
|
11046
11283
|
}
|
|
11047
|
-
/**
|
|
11048
|
-
* Remove and return the next item from the queue.
|
|
11049
|
-
*
|
|
11050
|
-
* @returns The next QueueItem, or undefined if the queue is empty.
|
|
11051
|
-
*/
|
|
11284
|
+
/** Remove and return the next item from the queue. */
|
|
11052
11285
|
dequeue() {
|
|
11053
11286
|
const item = this.queue.shift();
|
|
11054
11287
|
if (item) {
|
|
@@ -11078,7 +11311,6 @@ class SynthesisQueue {
|
|
|
11078
11311
|
}
|
|
11079
11312
|
/**
|
|
11080
11313
|
* Remove all pending items from the queue.
|
|
11081
|
-
*
|
|
11082
11314
|
* Does not affect the currently-running item.
|
|
11083
11315
|
*
|
|
11084
11316
|
* @returns The number of items removed.
|
|
@@ -11088,45 +11320,56 @@ class SynthesisQueue {
|
|
|
11088
11320
|
this.queue = [];
|
|
11089
11321
|
return count;
|
|
11090
11322
|
}
|
|
11091
|
-
/**
|
|
11092
|
-
* Check whether a path is in the queue or currently being synthesized.
|
|
11093
|
-
*
|
|
11094
|
-
* @param path - Meta path to look up.
|
|
11095
|
-
* @returns True if the path is queued or currently running.
|
|
11096
|
-
*/
|
|
11323
|
+
/** Check whether a path is in the queue or currently being synthesized. */
|
|
11097
11324
|
has(path) {
|
|
11098
11325
|
if (this.currentItem?.path === path)
|
|
11099
11326
|
return true;
|
|
11100
|
-
|
|
11327
|
+
if (this.currentPhaseItem?.path === path)
|
|
11328
|
+
return true;
|
|
11329
|
+
return (this.queue.some((item) => item.path === path) ||
|
|
11330
|
+
this.overrideEntries.some((e) => e.path === path));
|
|
11101
11331
|
}
|
|
11102
|
-
/**
|
|
11103
|
-
* Get the 0-indexed position of a path in the queue.
|
|
11104
|
-
*
|
|
11105
|
-
* @param path - Meta path to look up.
|
|
11106
|
-
* @returns Position index, or null if not found in the queue.
|
|
11107
|
-
*/
|
|
11332
|
+
/** Get the 0-indexed position of a path in the queue. */
|
|
11108
11333
|
getPosition(path) {
|
|
11334
|
+
// Check overrides first
|
|
11335
|
+
const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
|
|
11336
|
+
if (overrideIdx !== -1)
|
|
11337
|
+
return overrideIdx;
|
|
11109
11338
|
const index = this.queue.findIndex((item) => item.path === path);
|
|
11110
11339
|
return index === -1 ? null : index;
|
|
11111
11340
|
}
|
|
11112
|
-
/**
|
|
11113
|
-
|
|
11114
|
-
|
|
11115
|
-
|
|
11116
|
-
|
|
11341
|
+
/** Dequeue the next item: overrides first, then legacy queue. */
|
|
11342
|
+
nextItem() {
|
|
11343
|
+
const override = this.dequeueOverride();
|
|
11344
|
+
if (override)
|
|
11345
|
+
return { path: override.path, source: 'override' };
|
|
11346
|
+
const item = this.dequeue();
|
|
11347
|
+
if (item)
|
|
11348
|
+
return { path: item.path, source: 'legacy' };
|
|
11349
|
+
return undefined;
|
|
11350
|
+
}
|
|
11351
|
+
/** Return a snapshot of queue state for the /status endpoint. */
|
|
11117
11352
|
getState() {
|
|
11118
11353
|
return {
|
|
11119
|
-
depth: this.queue.length,
|
|
11120
|
-
items:
|
|
11121
|
-
|
|
11122
|
-
|
|
11123
|
-
|
|
11124
|
-
|
|
11354
|
+
depth: this.queue.length + this.overrideEntries.length,
|
|
11355
|
+
items: [
|
|
11356
|
+
...this.overrideEntries.map((e) => ({
|
|
11357
|
+
path: e.path,
|
|
11358
|
+
priority: true,
|
|
11359
|
+
enqueuedAt: e.enqueuedAt,
|
|
11360
|
+
})),
|
|
11361
|
+
...this.queue.map((item) => ({
|
|
11362
|
+
path: item.path,
|
|
11363
|
+
priority: item.priority,
|
|
11364
|
+
enqueuedAt: item.enqueuedAt,
|
|
11365
|
+
})),
|
|
11366
|
+
],
|
|
11125
11367
|
};
|
|
11126
11368
|
}
|
|
11127
11369
|
/**
|
|
11128
|
-
* Process queued items one at a time until
|
|
11370
|
+
* Process queued items one at a time until all queues are empty.
|
|
11129
11371
|
*
|
|
11372
|
+
* Override entries are processed first (FIFO), then legacy queue items.
|
|
11130
11373
|
* Re-entry is prevented: if already processing, the call returns
|
|
11131
11374
|
* immediately. Errors are logged and do not block subsequent items.
|
|
11132
11375
|
*
|
|
@@ -11137,19 +11380,22 @@ class SynthesisQueue {
|
|
|
11137
11380
|
return;
|
|
11138
11381
|
this.processing = true;
|
|
11139
11382
|
try {
|
|
11140
|
-
let
|
|
11141
|
-
while (
|
|
11383
|
+
let next = this.nextItem();
|
|
11384
|
+
while (next) {
|
|
11142
11385
|
try {
|
|
11143
|
-
await synthesizeFn(
|
|
11386
|
+
await synthesizeFn(next.path);
|
|
11144
11387
|
}
|
|
11145
11388
|
catch (err) {
|
|
11146
|
-
this.logger.error({ path:
|
|
11389
|
+
this.logger.error({ path: next.path, err }, 'Synthesis failed');
|
|
11147
11390
|
}
|
|
11148
|
-
this.
|
|
11149
|
-
|
|
11391
|
+
this.clearCurrentPhase();
|
|
11392
|
+
if (next.source === 'legacy')
|
|
11393
|
+
this.complete();
|
|
11394
|
+
next = this.nextItem();
|
|
11150
11395
|
}
|
|
11151
11396
|
}
|
|
11152
11397
|
finally {
|
|
11398
|
+
this.clearCurrentPhase();
|
|
11153
11399
|
this.processing = false;
|
|
11154
11400
|
}
|
|
11155
11401
|
}
|
|
@@ -11621,14 +11867,15 @@ async function autoSeedPass(rules, watcher, logger) {
|
|
|
11621
11867
|
}
|
|
11622
11868
|
|
|
11623
11869
|
/**
|
|
11624
|
-
* Croner-based scheduler that discovers the
|
|
11625
|
-
* and enqueues it for
|
|
11870
|
+
* Croner-based scheduler that discovers the highest-priority ready phase
|
|
11871
|
+
* across the corpus each tick and enqueues it for execution.
|
|
11626
11872
|
*
|
|
11627
11873
|
* @module scheduler
|
|
11628
11874
|
*/
|
|
11629
11875
|
const MAX_BACKOFF_MULTIPLIER = 4;
|
|
11630
11876
|
/**
|
|
11631
|
-
* Periodic scheduler that discovers
|
|
11877
|
+
* Periodic scheduler that discovers the highest-priority ready phase
|
|
11878
|
+
* across all metas and enqueues it for execution.
|
|
11632
11879
|
*
|
|
11633
11880
|
* Supports adaptive backoff when no candidates are found and hot-reloadable
|
|
11634
11881
|
* cron expressions via {@link Scheduler.updateSchedule}.
|
|
@@ -11683,10 +11930,10 @@ class Scheduler {
|
|
|
11683
11930
|
this.logger.info({ schedule: expression }, 'Schedule updated');
|
|
11684
11931
|
}
|
|
11685
11932
|
}
|
|
11686
|
-
/** Reset backoff multiplier (call after successful
|
|
11933
|
+
/** Reset backoff multiplier (call after successful phase execution). */
|
|
11687
11934
|
resetBackoff() {
|
|
11688
11935
|
if (this.backoffMultiplier > 1) {
|
|
11689
|
-
this.logger.debug('Backoff reset after successful
|
|
11936
|
+
this.logger.debug('Backoff reset after successful phase execution');
|
|
11690
11937
|
}
|
|
11691
11938
|
this.backoffMultiplier = 1;
|
|
11692
11939
|
}
|
|
@@ -11701,10 +11948,9 @@ class Scheduler {
|
|
|
11701
11948
|
return this.job.nextRun() ?? null;
|
|
11702
11949
|
}
|
|
11703
11950
|
/**
|
|
11704
|
-
* Single tick: discover
|
|
11951
|
+
* Single tick: discover the highest-priority ready phase and enqueue it.
|
|
11705
11952
|
*
|
|
11706
|
-
*
|
|
11707
|
-
* when no candidates are found.
|
|
11953
|
+
* Applies adaptive backoff when no candidates are found.
|
|
11708
11954
|
*/
|
|
11709
11955
|
async tick() {
|
|
11710
11956
|
this.tickCount++;
|
|
@@ -11729,14 +11975,15 @@ class Scheduler {
|
|
|
11729
11975
|
this.logger.warn({ err }, 'Auto-seed pass failed');
|
|
11730
11976
|
}
|
|
11731
11977
|
}
|
|
11732
|
-
const candidate = await this.
|
|
11978
|
+
const candidate = await this.discoverNextPhase();
|
|
11733
11979
|
if (!candidate) {
|
|
11734
11980
|
this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
|
|
11735
|
-
this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No
|
|
11981
|
+
this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
|
|
11736
11982
|
return;
|
|
11737
11983
|
}
|
|
11738
|
-
|
|
11739
|
-
this.
|
|
11984
|
+
// Enqueue using the legacy queue path (backward compat with processQueue)
|
|
11985
|
+
this.queue.enqueue(candidate.path);
|
|
11986
|
+
this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
|
|
11740
11987
|
// Opportunistic watcher restart detection
|
|
11741
11988
|
if (this.registrar) {
|
|
11742
11989
|
try {
|
|
@@ -11756,22 +12003,27 @@ class Scheduler {
|
|
|
11756
12003
|
}
|
|
11757
12004
|
}
|
|
11758
12005
|
/**
|
|
11759
|
-
* Discover the
|
|
12006
|
+
* Discover the highest-priority ready phase across the corpus.
|
|
12007
|
+
*
|
|
12008
|
+
* Uses phase-state-aware scheduling: priority order is
|
|
12009
|
+
* critic (band 1) \> builder (band 2) \> architect (band 3),
|
|
12010
|
+
* with weighted staleness as tiebreaker within a band.
|
|
11760
12011
|
*/
|
|
11761
|
-
async
|
|
12012
|
+
async discoverNextPhase() {
|
|
11762
12013
|
try {
|
|
11763
12014
|
const result = await listMetas(this.config, this.watcher);
|
|
11764
|
-
const
|
|
11765
|
-
|
|
11766
|
-
|
|
11767
|
-
|
|
11768
|
-
|
|
11769
|
-
|
|
11770
|
-
|
|
11771
|
-
|
|
12015
|
+
const candidates = buildPhaseCandidates(result.entries);
|
|
12016
|
+
const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
|
|
12017
|
+
if (!winner)
|
|
12018
|
+
return null;
|
|
12019
|
+
return {
|
|
12020
|
+
path: winner.node.metaPath,
|
|
12021
|
+
phase: winner.owedPhase,
|
|
12022
|
+
band: winner.band,
|
|
12023
|
+
};
|
|
11772
12024
|
}
|
|
11773
12025
|
catch (err) {
|
|
11774
|
-
this.logger.warn({ err }, 'Failed to discover
|
|
12026
|
+
this.logger.warn({ err }, 'Failed to discover next phase candidate');
|
|
11775
12027
|
return null;
|
|
11776
12028
|
}
|
|
11777
12029
|
}
|
|
@@ -11921,6 +12173,10 @@ const metasQuerySchema = z.object({
|
|
|
11921
12173
|
.enum(['true', 'false'])
|
|
11922
12174
|
.transform((v) => v === 'true')
|
|
11923
12175
|
.optional(),
|
|
12176
|
+
disabled: z
|
|
12177
|
+
.enum(['true', 'false'])
|
|
12178
|
+
.transform((v) => v === 'true')
|
|
12179
|
+
.optional(),
|
|
11924
12180
|
fields: z.string().optional(),
|
|
11925
12181
|
});
|
|
11926
12182
|
const metaDetailQuerySchema = z.object({
|
|
@@ -11951,6 +12207,9 @@ function registerMetasRoutes(app, deps) {
|
|
|
11951
12207
|
if (query.locked !== undefined) {
|
|
11952
12208
|
entries = entries.filter((e) => e.locked === query.locked);
|
|
11953
12209
|
}
|
|
12210
|
+
if (query.disabled !== undefined) {
|
|
12211
|
+
entries = entries.filter((e) => e.disabled === query.disabled);
|
|
12212
|
+
}
|
|
11954
12213
|
if (typeof query.staleHours === 'number') {
|
|
11955
12214
|
entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
|
|
11956
12215
|
}
|
|
@@ -11966,12 +12225,16 @@ function registerMetasRoutes(app, deps) {
|
|
|
11966
12225
|
'lastSynthesized',
|
|
11967
12226
|
'hasError',
|
|
11968
12227
|
'locked',
|
|
12228
|
+
'disabled',
|
|
11969
12229
|
'architectTokens',
|
|
11970
12230
|
'builderTokens',
|
|
11971
12231
|
'criticTokens',
|
|
12232
|
+
'phaseState',
|
|
12233
|
+
'owedPhase',
|
|
11972
12234
|
];
|
|
11973
12235
|
const projectedFields = fieldList ?? defaultFields;
|
|
11974
12236
|
const metas = entries.map((e) => {
|
|
12237
|
+
const ps = derivePhaseState(e.meta);
|
|
11975
12238
|
const full = {
|
|
11976
12239
|
path: e.path,
|
|
11977
12240
|
depth: e.depth,
|
|
@@ -11982,9 +12245,12 @@ function registerMetasRoutes(app, deps) {
|
|
|
11982
12245
|
lastSynthesized: e.lastSynthesized,
|
|
11983
12246
|
hasError: e.hasError,
|
|
11984
12247
|
locked: e.locked,
|
|
12248
|
+
disabled: e.disabled,
|
|
11985
12249
|
architectTokens: e.architectTokens,
|
|
11986
12250
|
builderTokens: e.builderTokens,
|
|
11987
12251
|
criticTokens: e.criticTokens,
|
|
12252
|
+
phaseState: ps,
|
|
12253
|
+
owedPhase: getOwedPhase(ps),
|
|
11988
12254
|
};
|
|
11989
12255
|
const projected = {};
|
|
11990
12256
|
for (const f of projectedFields) {
|
|
@@ -12009,13 +12275,6 @@ function registerMetasRoutes(app, deps) {
|
|
|
12009
12275
|
}
|
|
12010
12276
|
const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
12011
12277
|
// Field projection
|
|
12012
|
-
const defaultExclude = new Set([
|
|
12013
|
-
'_architect',
|
|
12014
|
-
'_builder',
|
|
12015
|
-
'_critic',
|
|
12016
|
-
'_content',
|
|
12017
|
-
'_feedback',
|
|
12018
|
-
]);
|
|
12019
12278
|
const fieldList = query.fields?.split(',');
|
|
12020
12279
|
const projectMeta = (m) => {
|
|
12021
12280
|
if (fieldList) {
|
|
@@ -12026,7 +12285,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
12026
12285
|
}
|
|
12027
12286
|
const r = {};
|
|
12028
12287
|
for (const [k, v] of Object.entries(m)) {
|
|
12029
|
-
if (!
|
|
12288
|
+
if (!DEFAULT_EXCLUDE_FIELDS.has(k))
|
|
12030
12289
|
r[k] = v;
|
|
12031
12290
|
}
|
|
12032
12291
|
return r;
|
|
@@ -12039,6 +12298,10 @@ function registerMetasRoutes(app, deps) {
|
|
|
12039
12298
|
? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
|
|
12040
12299
|
: null;
|
|
12041
12300
|
const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
|
|
12301
|
+
// Phase state
|
|
12302
|
+
const entry = result.entries.find((e) => e.node.metaPath === targetNode.metaPath);
|
|
12303
|
+
const phaseState = entry ? derivePhaseState(entry.meta) : null;
|
|
12304
|
+
const owedPhase = phaseState ? getOwedPhase(phaseState) : null;
|
|
12042
12305
|
const response = {
|
|
12043
12306
|
path: targetNode.metaPath,
|
|
12044
12307
|
meta: projectMeta(meta),
|
|
@@ -12051,6 +12314,8 @@ function registerMetasRoutes(app, deps) {
|
|
|
12051
12314
|
seconds: staleSeconds,
|
|
12052
12315
|
score: Math.round(score * 100) / 100,
|
|
12053
12316
|
},
|
|
12317
|
+
phaseState,
|
|
12318
|
+
owedPhase,
|
|
12054
12319
|
};
|
|
12055
12320
|
// Cross-refs status
|
|
12056
12321
|
const crossRefsRaw = meta._crossRefs;
|
|
@@ -12087,6 +12352,84 @@ function registerMetasRoutes(app, deps) {
|
|
|
12087
12352
|
});
|
|
12088
12353
|
}
|
|
12089
12354
|
|
|
12355
|
+
/**
|
|
12356
|
+
* PATCH /metas/:path — update user-settable reserved properties on a meta.
|
|
12357
|
+
*
|
|
12358
|
+
* Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
|
|
12359
|
+
* Set a field to null to remove it. Unknown keys are rejected.
|
|
12360
|
+
*
|
|
12361
|
+
* @module routes/metasUpdate
|
|
12362
|
+
*/
|
|
12363
|
+
const updateBodySchema = z
|
|
12364
|
+
.object({
|
|
12365
|
+
_steer: z.union([z.string(), z.null()]).optional(),
|
|
12366
|
+
_emphasis: z.union([z.number().min(0), z.null()]).optional(),
|
|
12367
|
+
_depth: z.union([z.number(), z.null()]).optional(),
|
|
12368
|
+
_crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
|
|
12369
|
+
_disabled: z.union([z.boolean(), z.null()]).optional(),
|
|
12370
|
+
})
|
|
12371
|
+
.strict();
|
|
12372
|
+
function registerMetasUpdateRoute(app, deps) {
|
|
12373
|
+
app.patch('/metas/:path', async (request, reply) => {
|
|
12374
|
+
const parseResult = updateBodySchema.safeParse(request.body);
|
|
12375
|
+
if (!parseResult.success) {
|
|
12376
|
+
return reply.status(400).send({
|
|
12377
|
+
error: 'BAD_REQUEST',
|
|
12378
|
+
message: parseResult.error.message,
|
|
12379
|
+
});
|
|
12380
|
+
}
|
|
12381
|
+
const updates = parseResult.data;
|
|
12382
|
+
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
12383
|
+
const metaDir = resolveMetaDir(targetPath);
|
|
12384
|
+
let meta;
|
|
12385
|
+
try {
|
|
12386
|
+
meta = (await readMetaJson(metaDir));
|
|
12387
|
+
}
|
|
12388
|
+
catch {
|
|
12389
|
+
return reply.status(404).send({
|
|
12390
|
+
error: 'NOT_FOUND',
|
|
12391
|
+
message: 'Meta path not found: ' + targetPath,
|
|
12392
|
+
});
|
|
12393
|
+
}
|
|
12394
|
+
const metaJsonPath = join(metaDir, 'meta.json');
|
|
12395
|
+
const KEYS = [
|
|
12396
|
+
'_steer',
|
|
12397
|
+
'_emphasis',
|
|
12398
|
+
'_depth',
|
|
12399
|
+
'_crossRefs',
|
|
12400
|
+
'_disabled',
|
|
12401
|
+
];
|
|
12402
|
+
const toDelete = new Set();
|
|
12403
|
+
const toSet = {};
|
|
12404
|
+
for (const key of KEYS) {
|
|
12405
|
+
const value = updates[key];
|
|
12406
|
+
if (value === null) {
|
|
12407
|
+
toDelete.add(key);
|
|
12408
|
+
}
|
|
12409
|
+
else if (value !== undefined) {
|
|
12410
|
+
toSet[key] = value;
|
|
12411
|
+
}
|
|
12412
|
+
}
|
|
12413
|
+
const updated = {};
|
|
12414
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
12415
|
+
if (!toDelete.has(k))
|
|
12416
|
+
updated[k] = v;
|
|
12417
|
+
}
|
|
12418
|
+
Object.assign(updated, toSet);
|
|
12419
|
+
await writeFile(metaJsonPath, JSON.stringify(updated, null, 2) + '\n');
|
|
12420
|
+
// Project the response — exclude the same large fields as the detail route.
|
|
12421
|
+
const projected = {};
|
|
12422
|
+
for (const [k, v] of Object.entries(updated)) {
|
|
12423
|
+
if (!DEFAULT_EXCLUDE_FIELDS.has(k))
|
|
12424
|
+
projected[k] = v;
|
|
12425
|
+
}
|
|
12426
|
+
return reply.send({
|
|
12427
|
+
path: metaDir,
|
|
12428
|
+
meta: projected,
|
|
12429
|
+
});
|
|
12430
|
+
});
|
|
12431
|
+
}
|
|
12432
|
+
|
|
12090
12433
|
/**
|
|
12091
12434
|
* GET /preview — dry-run synthesis preview.
|
|
12092
12435
|
*
|
|
@@ -12139,6 +12482,19 @@ function registerPreviewRoute(app, deps) {
|
|
|
12139
12482
|
const structureChanged = structureHash !== meta._structureHash;
|
|
12140
12483
|
const latestArchive = await readLatestArchive(targetNode.metaPath);
|
|
12141
12484
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
12485
|
+
// _architect change detection
|
|
12486
|
+
const architectChanged = latestArchive
|
|
12487
|
+
? (meta._architect ?? '') !== (latestArchive._architect ?? '')
|
|
12488
|
+
: Boolean(meta._architect);
|
|
12489
|
+
// _crossRefs declaration change detection
|
|
12490
|
+
const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
|
|
12491
|
+
const archiveRefs = (latestArchive?._crossRefs ?? [])
|
|
12492
|
+
.slice()
|
|
12493
|
+
.sort()
|
|
12494
|
+
.join(',');
|
|
12495
|
+
const crossRefsDeclChanged = latestArchive
|
|
12496
|
+
? currentRefs !== archiveRefs
|
|
12497
|
+
: currentRefs.length > 0;
|
|
12142
12498
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
12143
12499
|
// Delta files
|
|
12144
12500
|
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
@@ -12153,6 +12509,40 @@ function registerPreviewRoute(app, deps) {
|
|
|
12153
12509
|
? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
|
|
12154
12510
|
: null;
|
|
12155
12511
|
const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
|
|
12512
|
+
// Phase state
|
|
12513
|
+
const phaseState = derivePhaseState(meta, {
|
|
12514
|
+
structureChanged,
|
|
12515
|
+
steerChanged,
|
|
12516
|
+
architectChanged,
|
|
12517
|
+
crossRefsChanged: crossRefsDeclChanged,
|
|
12518
|
+
architectEvery: config.architectEvery,
|
|
12519
|
+
});
|
|
12520
|
+
const owedPhase = getOwedPhase(phaseState);
|
|
12521
|
+
const priorityBand = getPriorityBand(phaseState);
|
|
12522
|
+
// Architect invalidators
|
|
12523
|
+
const architectInvalidators = [];
|
|
12524
|
+
if (owedPhase === 'architect') {
|
|
12525
|
+
if (structureChanged)
|
|
12526
|
+
architectInvalidators.push('structureHash');
|
|
12527
|
+
if (steerChanged)
|
|
12528
|
+
architectInvalidators.push('steer');
|
|
12529
|
+
if (architectChanged)
|
|
12530
|
+
architectInvalidators.push('_architect');
|
|
12531
|
+
if (crossRefsDeclChanged)
|
|
12532
|
+
architectInvalidators.push('_crossRefs');
|
|
12533
|
+
if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
|
|
12534
|
+
architectInvalidators.push('architectEvery');
|
|
12535
|
+
}
|
|
12536
|
+
}
|
|
12537
|
+
// Staleness inputs
|
|
12538
|
+
const stalenessInputs = {
|
|
12539
|
+
structureHash,
|
|
12540
|
+
steerChanged,
|
|
12541
|
+
architectChanged,
|
|
12542
|
+
crossRefsDeclChanged,
|
|
12543
|
+
scopeMtimeMax: null,
|
|
12544
|
+
crossRefContentChanged: false,
|
|
12545
|
+
};
|
|
12156
12546
|
return {
|
|
12157
12547
|
path: targetNode.metaPath,
|
|
12158
12548
|
staleness: {
|
|
@@ -12177,6 +12567,12 @@ function registerPreviewRoute(app, deps) {
|
|
|
12177
12567
|
deltaCount: deltaFiles.length,
|
|
12178
12568
|
},
|
|
12179
12569
|
estimatedTokens,
|
|
12570
|
+
// New phase-state fields (additive)
|
|
12571
|
+
owedPhase,
|
|
12572
|
+
priorityBand,
|
|
12573
|
+
phaseState,
|
|
12574
|
+
stalenessInputs,
|
|
12575
|
+
architectInvalidators,
|
|
12180
12576
|
};
|
|
12181
12577
|
});
|
|
12182
12578
|
}
|
|
@@ -12184,8 +12580,8 @@ function registerPreviewRoute(app, deps) {
|
|
|
12184
12580
|
/**
|
|
12185
12581
|
* Queue management and abort routes.
|
|
12186
12582
|
*
|
|
12187
|
-
* - GET /queue —
|
|
12188
|
-
* - POST /queue/clear — remove
|
|
12583
|
+
* - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
|
|
12584
|
+
* - POST /queue/clear — remove override entries only
|
|
12189
12585
|
* - POST /synthesize/abort — abort the current synthesis
|
|
12190
12586
|
*
|
|
12191
12587
|
* @module routes/queue
|
|
@@ -12193,33 +12589,134 @@ function registerPreviewRoute(app, deps) {
|
|
|
12193
12589
|
/** Register queue management routes. */
|
|
12194
12590
|
function registerQueueRoutes(app, deps) {
|
|
12195
12591
|
const { queue } = deps;
|
|
12196
|
-
app.get('/queue', () =>
|
|
12197
|
-
|
|
12198
|
-
|
|
12199
|
-
state
|
|
12200
|
-
|
|
12592
|
+
app.get('/queue', async () => {
|
|
12593
|
+
const currentPhase = queue.currentPhase;
|
|
12594
|
+
const overrides = queue.overrides;
|
|
12595
|
+
// Compute owedPhase for each override entry by reading meta state
|
|
12596
|
+
const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
|
|
12597
|
+
try {
|
|
12598
|
+
const metaDir = resolveMetaDir(o.path);
|
|
12599
|
+
const meta = await readMetaJson(metaDir);
|
|
12600
|
+
const ps = derivePhaseState(meta);
|
|
12601
|
+
return {
|
|
12602
|
+
path: o.path,
|
|
12603
|
+
owedPhase: getOwedPhase(ps),
|
|
12604
|
+
enqueuedAt: o.enqueuedAt,
|
|
12605
|
+
};
|
|
12606
|
+
}
|
|
12607
|
+
catch {
|
|
12608
|
+
return {
|
|
12609
|
+
path: o.path,
|
|
12610
|
+
owedPhase: null,
|
|
12611
|
+
enqueuedAt: o.enqueuedAt,
|
|
12612
|
+
};
|
|
12613
|
+
}
|
|
12614
|
+
}));
|
|
12615
|
+
// Compute automatic layer: all metas with a pending owed phase,
|
|
12616
|
+
// ranked by scheduler priority (computed on read, not persisted)
|
|
12617
|
+
let automatic = [];
|
|
12618
|
+
try {
|
|
12619
|
+
const metaResult = await listMetas(deps.config, deps.watcher);
|
|
12620
|
+
const candidates = buildPhaseCandidates(metaResult.entries);
|
|
12621
|
+
const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
|
|
12622
|
+
automatic = ranked.map((c) => ({
|
|
12623
|
+
path: c.node.metaPath,
|
|
12624
|
+
owedPhase: c.owedPhase,
|
|
12625
|
+
priorityBand: c.band,
|
|
12626
|
+
effectiveStaleness: c.effectiveStaleness,
|
|
12627
|
+
}));
|
|
12628
|
+
}
|
|
12629
|
+
catch {
|
|
12630
|
+
// If listing fails, automatic stays empty
|
|
12631
|
+
}
|
|
12632
|
+
// Legacy: pending is the union of overrides + automatic + legacy queue items
|
|
12633
|
+
const pendingItems = [
|
|
12634
|
+
...enrichedOverrides.map((o) => ({
|
|
12635
|
+
path: o.path,
|
|
12636
|
+
owedPhase: o.owedPhase,
|
|
12637
|
+
})),
|
|
12638
|
+
...automatic.map((a) => ({
|
|
12639
|
+
path: a.path,
|
|
12640
|
+
owedPhase: a.owedPhase,
|
|
12641
|
+
})),
|
|
12642
|
+
...queue.pending.map((item) => ({
|
|
12643
|
+
path: item.path,
|
|
12644
|
+
owedPhase: null,
|
|
12645
|
+
})),
|
|
12646
|
+
];
|
|
12647
|
+
return {
|
|
12648
|
+
current: currentPhase
|
|
12649
|
+
? {
|
|
12650
|
+
path: currentPhase.path,
|
|
12651
|
+
phase: currentPhase.phase,
|
|
12652
|
+
startedAt: currentPhase.startedAt,
|
|
12653
|
+
}
|
|
12654
|
+
: queue.current
|
|
12655
|
+
? {
|
|
12656
|
+
path: queue.current.path,
|
|
12657
|
+
phase: null,
|
|
12658
|
+
startedAt: queue.current.enqueuedAt,
|
|
12659
|
+
}
|
|
12660
|
+
: null,
|
|
12661
|
+
overrides: enrichedOverrides,
|
|
12662
|
+
automatic,
|
|
12663
|
+
pending: pendingItems,
|
|
12664
|
+
// Legacy state
|
|
12665
|
+
state: queue.getState(),
|
|
12666
|
+
};
|
|
12667
|
+
});
|
|
12201
12668
|
app.post('/queue/clear', () => {
|
|
12202
|
-
const removed = queue.
|
|
12669
|
+
const removed = queue.clearOverrides();
|
|
12203
12670
|
return { cleared: removed };
|
|
12204
12671
|
});
|
|
12205
12672
|
app.post('/synthesize/abort', async (_request, reply) => {
|
|
12206
|
-
|
|
12673
|
+
// Check 3-layer current first
|
|
12674
|
+
const currentPhase = queue.currentPhase;
|
|
12675
|
+
const current = currentPhase ?? queue.current;
|
|
12207
12676
|
if (!current) {
|
|
12208
|
-
return reply
|
|
12209
|
-
.status(404)
|
|
12210
|
-
.send({ error: 'NOT_FOUND', message: 'No synthesis in progress' });
|
|
12677
|
+
return reply.status(200).send({ status: 'idle' });
|
|
12211
12678
|
}
|
|
12212
12679
|
// Abort the executor
|
|
12213
12680
|
deps.executor?.abort();
|
|
12681
|
+
const metaDir = resolveMetaDir(current.path);
|
|
12682
|
+
const phase = currentPhase?.phase ?? null;
|
|
12683
|
+
// Transition running phase to failed and write _error to meta.json
|
|
12684
|
+
if (phase) {
|
|
12685
|
+
try {
|
|
12686
|
+
const meta = await readMetaJson(metaDir);
|
|
12687
|
+
let ps = derivePhaseState(meta);
|
|
12688
|
+
ps = phaseFailed(ps, phase);
|
|
12689
|
+
const updated = {
|
|
12690
|
+
...meta,
|
|
12691
|
+
_phaseState: ps,
|
|
12692
|
+
_error: {
|
|
12693
|
+
step: phase,
|
|
12694
|
+
code: 'ABORT',
|
|
12695
|
+
message: 'Aborted by operator',
|
|
12696
|
+
},
|
|
12697
|
+
};
|
|
12698
|
+
const lockPath = join(metaDir, '.lock');
|
|
12699
|
+
const metaJsonPath = join(metaDir, 'meta.json');
|
|
12700
|
+
await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
|
|
12701
|
+
await copyFile(lockPath, metaJsonPath);
|
|
12702
|
+
}
|
|
12703
|
+
catch {
|
|
12704
|
+
// Best-effort — meta may be unreadable
|
|
12705
|
+
}
|
|
12706
|
+
}
|
|
12214
12707
|
// Release the lock for the current meta path
|
|
12215
12708
|
try {
|
|
12216
|
-
releaseLock(
|
|
12709
|
+
releaseLock(metaDir);
|
|
12217
12710
|
}
|
|
12218
12711
|
catch {
|
|
12219
12712
|
// Lock may already be released
|
|
12220
12713
|
}
|
|
12221
12714
|
deps.logger.info({ path: current.path }, 'Synthesis aborted');
|
|
12222
|
-
return {
|
|
12715
|
+
return {
|
|
12716
|
+
status: 'aborted',
|
|
12717
|
+
path: current.path,
|
|
12718
|
+
...(phase ? { phase } : {}),
|
|
12719
|
+
};
|
|
12223
12720
|
});
|
|
12224
12721
|
}
|
|
12225
12722
|
|
|
@@ -12304,7 +12801,6 @@ async function checkDependency(url, path) {
|
|
|
12304
12801
|
return { url, status: 'unreachable', checkedAt };
|
|
12305
12802
|
}
|
|
12306
12803
|
}
|
|
12307
|
-
/** Check watcher, surfacing initialScan.active as indexing state. */
|
|
12308
12804
|
async function checkWatcher(url) {
|
|
12309
12805
|
const checkedAt = new Date().toISOString();
|
|
12310
12806
|
try {
|
|
@@ -12330,26 +12826,61 @@ async function checkWatcher(url) {
|
|
|
12330
12826
|
function deriveServiceState(deps) {
|
|
12331
12827
|
if (deps.shuttingDown)
|
|
12332
12828
|
return 'stopping';
|
|
12333
|
-
if (deps.queue.current)
|
|
12829
|
+
if (deps.queue.current || deps.queue.currentPhase)
|
|
12334
12830
|
return 'synthesizing';
|
|
12335
|
-
if (deps.queue.depth > 0)
|
|
12831
|
+
if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
|
|
12336
12832
|
return 'waiting';
|
|
12337
12833
|
return 'idle';
|
|
12338
12834
|
}
|
|
12835
|
+
function emptyPhaseCounts() {
|
|
12836
|
+
return { fresh: 0, stale: 0, pending: 0, running: 0, failed: 0 };
|
|
12837
|
+
}
|
|
12339
12838
|
function registerStatusRoute(app, deps) {
|
|
12340
12839
|
const statusHandler = createStatusHandler({
|
|
12341
12840
|
name: SERVICE_NAME,
|
|
12342
12841
|
version: SERVICE_VERSION,
|
|
12343
12842
|
getHealth: async () => {
|
|
12344
|
-
const { config, queue, scheduler, stats } = deps;
|
|
12843
|
+
const { config, queue, scheduler, stats, watcher } = deps;
|
|
12345
12844
|
// On-demand dependency checks
|
|
12346
12845
|
const [watcherHealth, gatewayHealth] = await Promise.all([
|
|
12347
12846
|
checkWatcher(config.watcherUrl),
|
|
12348
12847
|
checkDependency(config.gatewayUrl, '/status'),
|
|
12349
12848
|
]);
|
|
12849
|
+
// Phase state summary
|
|
12850
|
+
const phaseStateSummary = {
|
|
12851
|
+
architect: emptyPhaseCounts(),
|
|
12852
|
+
builder: emptyPhaseCounts(),
|
|
12853
|
+
critic: emptyPhaseCounts(),
|
|
12854
|
+
};
|
|
12855
|
+
let nextPhase = null;
|
|
12856
|
+
try {
|
|
12857
|
+
const metaResult = await listMetas(config, watcher);
|
|
12858
|
+
// Count raw phase states (before retry) for display
|
|
12859
|
+
for (const entry of metaResult.entries) {
|
|
12860
|
+
const ps = derivePhaseState(entry.meta);
|
|
12861
|
+
for (const phase of ['architect', 'builder', 'critic']) {
|
|
12862
|
+
phaseStateSummary[phase][ps[phase]]++;
|
|
12863
|
+
}
|
|
12864
|
+
}
|
|
12865
|
+
// Build candidates (with auto-retry) for scheduling
|
|
12866
|
+
const candidates = buildPhaseCandidates(metaResult.entries);
|
|
12867
|
+
// Find next phase candidate
|
|
12868
|
+
const winner = selectPhaseCandidate(candidates, config.depthWeight);
|
|
12869
|
+
if (winner) {
|
|
12870
|
+
nextPhase = {
|
|
12871
|
+
path: winner.node.metaPath,
|
|
12872
|
+
phase: winner.owedPhase,
|
|
12873
|
+
band: winner.band,
|
|
12874
|
+
staleness: winner.effectiveStaleness,
|
|
12875
|
+
};
|
|
12876
|
+
}
|
|
12877
|
+
}
|
|
12878
|
+
catch {
|
|
12879
|
+
// Watcher unreachable — phase summary unavailable
|
|
12880
|
+
}
|
|
12350
12881
|
return {
|
|
12351
12882
|
serviceState: deriveServiceState(deps),
|
|
12352
|
-
currentTarget: queue.current?.path ?? null,
|
|
12883
|
+
currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
|
|
12353
12884
|
queue: queue.getState(),
|
|
12354
12885
|
stats: {
|
|
12355
12886
|
totalSyntheses: stats.totalSyntheses,
|
|
@@ -12369,6 +12900,8 @@ function registerStatusRoute(app, deps) {
|
|
|
12369
12900
|
},
|
|
12370
12901
|
gateway: gatewayHealth,
|
|
12371
12902
|
},
|
|
12903
|
+
phaseStateSummary,
|
|
12904
|
+
nextPhase,
|
|
12372
12905
|
};
|
|
12373
12906
|
},
|
|
12374
12907
|
});
|
|
@@ -12381,6 +12914,9 @@ function registerStatusRoute(app, deps) {
|
|
|
12381
12914
|
/**
|
|
12382
12915
|
* POST /synthesize route handler.
|
|
12383
12916
|
*
|
|
12917
|
+
* Path-targeted triggers create explicit override entries in the queue.
|
|
12918
|
+
* Corpus-wide triggers discover the stalest candidate.
|
|
12919
|
+
*
|
|
12384
12920
|
* @module routes/synthesize
|
|
12385
12921
|
*/
|
|
12386
12922
|
const synthesizeBodySchema = z.object({
|
|
@@ -12391,44 +12927,70 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
12391
12927
|
app.post('/synthesize', async (request, reply) => {
|
|
12392
12928
|
const body = synthesizeBodySchema.parse(request.body);
|
|
12393
12929
|
const { config, watcher, queue } = deps;
|
|
12394
|
-
let targetPath;
|
|
12395
12930
|
if (body.path) {
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
let
|
|
12931
|
+
// Path-targeted trigger: create override entry
|
|
12932
|
+
const targetPath = resolveMetaDir(body.path);
|
|
12933
|
+
// Read meta to determine owed phase
|
|
12934
|
+
let owedPhase = null;
|
|
12935
|
+
let meta;
|
|
12401
12936
|
try {
|
|
12402
|
-
|
|
12937
|
+
meta = await readMetaJson(targetPath);
|
|
12938
|
+
const phaseState = derivePhaseState(meta);
|
|
12939
|
+
owedPhase = getOwedPhase(phaseState);
|
|
12403
12940
|
}
|
|
12404
12941
|
catch {
|
|
12405
|
-
|
|
12406
|
-
error: 'SERVICE_UNAVAILABLE',
|
|
12407
|
-
message: 'Watcher unreachable — cannot discover candidates',
|
|
12408
|
-
});
|
|
12942
|
+
// Meta unreadable — proceed, phase will be evaluated at dequeue time
|
|
12409
12943
|
}
|
|
12410
|
-
|
|
12411
|
-
|
|
12412
|
-
.
|
|
12413
|
-
node: e.node,
|
|
12414
|
-
meta: e.meta,
|
|
12415
|
-
actualStaleness: e.stalenessSeconds,
|
|
12416
|
-
}));
|
|
12417
|
-
const stalest = discoverStalestPath(stale, config.depthWeight);
|
|
12418
|
-
if (!stalest) {
|
|
12419
|
-
return reply.code(200).send({
|
|
12944
|
+
// Fully fresh meta → skip (reuse meta already read above)
|
|
12945
|
+
if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
|
|
12946
|
+
return await reply.code(200).send({
|
|
12420
12947
|
status: 'skipped',
|
|
12421
|
-
|
|
12948
|
+
path: targetPath,
|
|
12949
|
+
owedPhase: null,
|
|
12950
|
+
queuePosition: -1,
|
|
12951
|
+
alreadyQueued: false,
|
|
12422
12952
|
});
|
|
12423
12953
|
}
|
|
12424
|
-
|
|
12954
|
+
const result = queue.enqueueOverride(targetPath);
|
|
12955
|
+
return reply.code(202).send({
|
|
12956
|
+
status: 'queued',
|
|
12957
|
+
path: targetPath,
|
|
12958
|
+
owedPhase,
|
|
12959
|
+
queuePosition: result.position,
|
|
12960
|
+
alreadyQueued: result.alreadyQueued,
|
|
12961
|
+
});
|
|
12962
|
+
}
|
|
12963
|
+
// Corpus-wide trigger: discover stalest candidate
|
|
12964
|
+
let result;
|
|
12965
|
+
try {
|
|
12966
|
+
result = await listMetas(config, watcher);
|
|
12425
12967
|
}
|
|
12426
|
-
|
|
12968
|
+
catch {
|
|
12969
|
+
return reply.status(503).send({
|
|
12970
|
+
error: 'SERVICE_UNAVAILABLE',
|
|
12971
|
+
message: 'Watcher unreachable — cannot discover candidates',
|
|
12972
|
+
});
|
|
12973
|
+
}
|
|
12974
|
+
const stale = result.entries
|
|
12975
|
+
.filter((e) => e.stalenessSeconds > 0 && !e.disabled)
|
|
12976
|
+
.map((e) => ({
|
|
12977
|
+
node: e.node,
|
|
12978
|
+
meta: e.meta,
|
|
12979
|
+
actualStaleness: e.stalenessSeconds,
|
|
12980
|
+
}));
|
|
12981
|
+
const stalest = discoverStalestPath(stale, config.depthWeight);
|
|
12982
|
+
if (!stalest) {
|
|
12983
|
+
return reply.code(200).send({
|
|
12984
|
+
status: 'skipped',
|
|
12985
|
+
message: 'No stale metas found. Nothing to synthesize.',
|
|
12986
|
+
});
|
|
12987
|
+
}
|
|
12988
|
+
const enqueueResult = queue.enqueue(stalest);
|
|
12427
12989
|
return reply.code(202).send({
|
|
12428
12990
|
status: 'accepted',
|
|
12429
|
-
path:
|
|
12430
|
-
queuePosition:
|
|
12431
|
-
alreadyQueued:
|
|
12991
|
+
path: stalest,
|
|
12992
|
+
queuePosition: enqueueResult.position,
|
|
12993
|
+
alreadyQueued: enqueueResult.alreadyQueued,
|
|
12432
12994
|
});
|
|
12433
12995
|
});
|
|
12434
12996
|
}
|
|
@@ -12466,6 +13028,17 @@ function registerUnlockRoute(app, deps) {
|
|
|
12466
13028
|
*
|
|
12467
13029
|
* @module routes
|
|
12468
13030
|
*/
|
|
13031
|
+
/**
|
|
13032
|
+
* Large generated fields excluded from detail/update response projections.
|
|
13033
|
+
* Shared between metas detail and metasUpdate routes.
|
|
13034
|
+
*/
|
|
13035
|
+
const DEFAULT_EXCLUDE_FIELDS = new Set([
|
|
13036
|
+
'_architect',
|
|
13037
|
+
'_builder',
|
|
13038
|
+
'_critic',
|
|
13039
|
+
'_content',
|
|
13040
|
+
'_feedback',
|
|
13041
|
+
]);
|
|
12469
13042
|
/** Register all HTTP routes on the Fastify instance. */
|
|
12470
13043
|
function registerRoutes(app, deps) {
|
|
12471
13044
|
// Global error handler for validation + watcher errors
|
|
@@ -12487,6 +13060,7 @@ function registerRoutes(app, deps) {
|
|
|
12487
13060
|
});
|
|
12488
13061
|
registerStatusRoute(app, deps);
|
|
12489
13062
|
registerMetasRoutes(app, deps);
|
|
13063
|
+
registerMetasUpdateRoute(app);
|
|
12490
13064
|
registerSynthesizeRoute(app, deps);
|
|
12491
13065
|
registerPreviewRoute(app, deps);
|
|
12492
13066
|
registerSeedRoute(app, deps);
|
|
@@ -12719,10 +13293,9 @@ async function startService(config, configPath) {
|
|
|
12719
13293
|
}
|
|
12720
13294
|
// Progress reporter — uses shared config reference so hot-reload propagates
|
|
12721
13295
|
const progress = new ProgressReporter(config, logger);
|
|
12722
|
-
// Wire queue processing —
|
|
13296
|
+
// Wire queue processing — execute one phase per dequeue (phase-state machine)
|
|
12723
13297
|
const synthesizeFn = async (path) => {
|
|
12724
13298
|
const startMs = Date.now();
|
|
12725
|
-
let cycleTokens = 0;
|
|
12726
13299
|
// Strip .meta suffix for human-readable progress reporting
|
|
12727
13300
|
const ownerPath = path.replace(/\/?\.meta\/?$/, '');
|
|
12728
13301
|
await progress.report({
|
|
@@ -12730,49 +13303,50 @@ async function startService(config, configPath) {
|
|
|
12730
13303
|
path: ownerPath,
|
|
12731
13304
|
});
|
|
12732
13305
|
try {
|
|
12733
|
-
const
|
|
13306
|
+
const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
|
|
13307
|
+
// Wire current-phase tracking for GET /queue and POST /synthesize/abort
|
|
13308
|
+
if (evt.type === 'phase_start' && evt.phase) {
|
|
13309
|
+
queue.setCurrentPhase(ownerPath, evt.phase);
|
|
13310
|
+
}
|
|
12734
13311
|
// Track token stats from phase completions
|
|
12735
13312
|
if (evt.type === 'phase_complete') {
|
|
12736
13313
|
if (evt.tokens !== undefined) {
|
|
12737
13314
|
stats.totalTokens += evt.tokens;
|
|
12738
|
-
if (cycleTokens !== undefined) {
|
|
12739
|
-
cycleTokens += evt.tokens;
|
|
12740
|
-
}
|
|
12741
13315
|
}
|
|
12742
13316
|
else {
|
|
12743
|
-
cycleTokens = undefined;
|
|
12744
13317
|
logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
|
|
12745
13318
|
}
|
|
12746
13319
|
}
|
|
12747
13320
|
await progress.report(evt);
|
|
12748
13321
|
}, logger);
|
|
12749
|
-
// orchestrate() always returns exactly one result
|
|
12750
|
-
const result = results[0];
|
|
12751
13322
|
const durationMs = Date.now() - startMs;
|
|
12752
|
-
if (!result.
|
|
12753
|
-
|
|
12754
|
-
logger.debug({ path: ownerPath }, 'Synthesis skipped');
|
|
13323
|
+
if (!result.executed) {
|
|
13324
|
+
logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
|
|
12755
13325
|
return;
|
|
12756
13326
|
}
|
|
12757
13327
|
// Update stats
|
|
12758
13328
|
stats.totalSyntheses++;
|
|
12759
13329
|
stats.lastCycleDurationMs = durationMs;
|
|
12760
13330
|
stats.lastCycleAt = new Date().toISOString();
|
|
12761
|
-
|
|
13331
|
+
const phaseResult = result.phaseResult;
|
|
13332
|
+
if (phaseResult?.error) {
|
|
12762
13333
|
stats.totalErrors++;
|
|
12763
13334
|
await progress.report({
|
|
12764
13335
|
type: 'error',
|
|
12765
13336
|
path: ownerPath,
|
|
12766
|
-
phase:
|
|
12767
|
-
error:
|
|
13337
|
+
phase: phaseResult.error.step,
|
|
13338
|
+
error: phaseResult.error.message,
|
|
12768
13339
|
});
|
|
12769
13340
|
}
|
|
12770
13341
|
else {
|
|
13342
|
+
// Task #9: Reset backoff on ANY successful phase execution
|
|
12771
13343
|
scheduler.resetBackoff();
|
|
13344
|
+
}
|
|
13345
|
+
// Emit synthesis_complete only on full-cycle completion
|
|
13346
|
+
if (result.cycleComplete) {
|
|
12772
13347
|
await progress.report({
|
|
12773
13348
|
type: 'synthesis_complete',
|
|
12774
13349
|
path: ownerPath,
|
|
12775
|
-
tokens: cycleTokens,
|
|
12776
13350
|
durationMs,
|
|
12777
13351
|
});
|
|
12778
13352
|
}
|