@karmaniverous/jeeves-meta 0.16.0 → 0.16.2

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.
@@ -806,6 +806,14 @@ const serviceConfigSchema = metaConfigSchema.extend({
806
806
  * Rules are evaluated in order; last match wins for steer/crossRefs.
807
807
  */
808
808
  autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
809
+ /** Optional workspace directory for synthesis staging files. Defaults to `os.tmpdir() + '/jeeves-meta'`. */
810
+ workspaceDir: z.string().optional(),
811
+ /** Max retries when staging file not yet visible after session completion. Default: 10. */
812
+ stagingRetries: z.number().int().min(0).default(10),
813
+ /** Delay between staging file retries in ms. Default: 250. */
814
+ stagingRetryDelayMs: z.number().int().min(0).default(250),
815
+ /** Maximum number of delta files returned in /preview response. Default: 50. */
816
+ previewDeltaFilesCap: z.number().int().min(1).default(50),
809
817
  });
810
818
 
811
819
  /**
@@ -933,12 +941,16 @@ class GatewayExecutor {
933
941
  apiKey;
934
942
  pollIntervalMs;
935
943
  workspaceDir;
944
+ stagingRetries;
945
+ stagingRetryDelayMs;
936
946
  controller = new AbortController();
937
947
  constructor(options = {}) {
938
948
  this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
939
949
  this.apiKey = options.apiKey;
940
950
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
941
951
  this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
952
+ this.stagingRetries = options.stagingRetries ?? 10;
953
+ this.stagingRetryDelayMs = options.stagingRetryDelayMs ?? 250;
942
954
  }
943
955
  /** Remove a temp output file if it exists. */
944
956
  cleanupOutputFile(outputPath) {
@@ -975,16 +987,20 @@ class GatewayExecutor {
975
987
  : '';
976
988
  return text && text.trim() !== 'ANNOUNCE_SKIP' ? text : undefined;
977
989
  }
978
- /** Check history messages for terminal completion. */
990
+ /**
991
+ * Check history messages for timeout detection.
992
+ *
993
+ * Does NOT determine completion — session completion is authoritative
994
+ * (via sessions_list). History stop reasons can false-positive on
995
+ * sessions_yield artifacts (#200).
996
+ */
979
997
  static checkHistoryCompletion(messages) {
980
998
  if (messages.length === 0)
981
- return { done: false, timedOut: false };
999
+ return { timedOut: false };
982
1000
  const last = messages[messages.length - 1];
983
1001
  if (last.role !== 'assistant' || !last.stopReason)
984
- return { done: false, timedOut: false };
985
- if (last.stopReason === 'toolUse' || last.stopReason === 'error')
986
- return { done: false, timedOut: false };
987
- return { done: true, timedOut: last.stopReason === 'timeout' };
1002
+ return { timedOut: false };
1003
+ return { timedOut: last.stopReason === 'timeout' };
988
1004
  }
989
1005
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
990
1006
  async invoke(tool, args, sessionKey) {
@@ -1147,12 +1163,14 @@ class GatewayExecutor {
1147
1163
  historyResult.result?.messages ??
1148
1164
  [];
1149
1165
  const msgArray = messages;
1150
- // Check 1: terminal stop reason in history
1151
- const { done: historyDone, timedOut: historyTimedOut } = GatewayExecutor.checkHistoryCompletion(msgArray);
1166
+ // Check 1: history-based timeout detection (stop reason)
1167
+ const { timedOut: historyTimedOut } = GatewayExecutor.checkHistoryCompletion(msgArray);
1152
1168
  // Check 2: session completion status via sessions_list
1169
+ // Gate on session completion only — history stop reasons can
1170
+ // false-positive on sessions_yield artifacts (#200).
1153
1171
  const sessionInfo = await this.getSessionInfo(sessionKey);
1154
1172
  const timedOut = sessionInfo.timedOut || historyTimedOut;
1155
- if (historyDone || sessionInfo.completed) {
1173
+ if (sessionInfo.completed) {
1156
1174
  const tokens = sessionInfo.tokens;
1157
1175
  // Gateway-side timeout detected — check staging file for recovery
1158
1176
  if (timedOut) {
@@ -1162,10 +1180,23 @@ class GatewayExecutor {
1162
1180
  // No output or partial output — throw for _state recovery (§3.16.6)
1163
1181
  throw new SpawnTimeoutError('Gateway-side timeout detected (session status: timeout)', outputPath);
1164
1182
  }
1165
- // Normal completion — read output from file
1183
+ // Normal completion — read staging file with retry for delayed visibility
1166
1184
  const output = this.readStagingFile(outputPath);
1167
1185
  if (output !== undefined)
1168
1186
  return { output, tokens };
1187
+ // Staging file not yet visible — retry with bounded grace window
1188
+ for (let i = 0; i < this.stagingRetries; i++) {
1189
+ await sleepAsync(this.stagingRetryDelayMs);
1190
+ // Check abort after yield point — signal may have been
1191
+ // set externally (e.g. operator abort) during the sleep.
1192
+ if (this.aborted) {
1193
+ this.cleanupOutputFile(outputPath);
1194
+ throw new SpawnAbortedError();
1195
+ }
1196
+ const retryOutput = this.readStagingFile(outputPath);
1197
+ if (retryOutput !== undefined)
1198
+ return { output: retryOutput, tokens };
1199
+ }
1169
1200
  // Fallback: extract from message content if file wasn't written.
1170
1201
  // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
1171
1202
  // a preceding assistant message (the file write).
@@ -3167,36 +3198,33 @@ class ProgressReporter {
3167
3198
  }
3168
3199
 
3169
3200
  /**
3170
- * Hybrid 3-layer synthesis queue.
3201
+ * Synthesis queue.
3171
3202
  *
3172
3203
  * Layer 1: Current — the single item currently executing (at most one).
3173
- * Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
3174
- * FIFO among overrides, ahead of automatic candidates.
3175
- * Layer 3: Automatic — computed on read, not persisted. All metas with a
3176
- * pending phase, ranked by scheduler priority.
3177
- *
3178
- * Legacy: `pending` array is the union of overrides + automatic.
3204
+ * Layer 2: Pending — items enqueued via POST /synthesize (targeted) or
3205
+ * scheduler tick (automatic). FIFO, ahead of automatic candidates.
3206
+ * Layer 3: Automatic — computed on read (GET /queue), not persisted. All
3207
+ * metas with a pending phase, ranked by scheduler priority.
3179
3208
  *
3180
3209
  * @module queue
3181
3210
  */
3182
3211
  const DEPTH_WARNING_THRESHOLD = 3;
3212
+ /** Strip trailing .meta suffix for consistent path comparison. */
3213
+ function normQueuePath(p) {
3214
+ return p.endsWith('.meta') ? p.slice(0, -5).replace(/[/\\]$/, '') : p;
3215
+ }
3183
3216
  /**
3184
- * Hybrid 3-layer synthesis queue.
3217
+ * Synthesis queue.
3185
3218
  *
3186
- * Only one synthesis runs at a time. Override items (explicit triggers)
3187
- * take priority over automatic candidates.
3219
+ * Only one synthesis runs at a time. Explicitly enqueued items
3220
+ * take priority over automatic (computed-on-read) candidates.
3188
3221
  */
3189
3222
  class SynthesisQueue {
3190
- /** Legacy queue (used by processQueue for backward compat). */
3191
- queue = [];
3192
- currentItem = null;
3223
+ entries = [];
3224
+ currentPhaseItem = null;
3193
3225
  processing = false;
3194
3226
  logger;
3195
3227
  onEnqueueCallback = null;
3196
- /** Explicit override entries (3-layer model). */
3197
- overrideEntries = [];
3198
- /** Currently executing item with phase info (3-layer model). */
3199
- currentPhaseItem = null;
3200
3228
  constructor(logger) {
3201
3229
  this.logger = logger;
3202
3230
  }
@@ -3204,52 +3232,67 @@ class SynthesisQueue {
3204
3232
  onEnqueue(callback) {
3205
3233
  this.onEnqueueCallback = callback;
3206
3234
  }
3207
- // ── Override layer (3-layer model) ─────────────────────────────────
3235
+ // ── Enqueue / dequeue ──────────────────────────────────────────────
3208
3236
  /**
3209
- * Add an explicit override entry (from POST /synthesize with path).
3237
+ * Add an entry to the queue.
3210
3238
  * Deduped by path. Returns position and whether already queued.
3211
3239
  */
3212
- enqueueOverride(path) {
3240
+ enqueue(path) {
3241
+ const norm = normQueuePath(path);
3213
3242
  // Check if currently executing
3214
- if (this.currentPhaseItem?.path === path ||
3215
- this.currentItem?.path === path) {
3243
+ if (this.currentPhaseItem &&
3244
+ normQueuePath(this.currentPhaseItem.path) === norm) {
3216
3245
  return { position: 0, alreadyQueued: true };
3217
3246
  }
3218
- // Check if already in overrides
3219
- const existing = this.overrideEntries.findIndex((e) => e.path === path);
3247
+ // Check if already in queue
3248
+ const existing = this.entries.findIndex((e) => normQueuePath(e.path) === norm);
3220
3249
  if (existing !== -1) {
3221
3250
  return { position: existing, alreadyQueued: true };
3222
3251
  }
3223
- this.overrideEntries.push({
3252
+ this.entries.push({
3224
3253
  path,
3225
3254
  enqueuedAt: new Date().toISOString(),
3226
3255
  });
3227
- const position = this.overrideEntries.length - 1;
3228
- if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
3229
- this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
3256
+ const position = this.entries.length - 1;
3257
+ if (this.entries.length > DEPTH_WARNING_THRESHOLD) {
3258
+ this.logger.warn({ depth: this.entries.length }, 'Queue depth exceeds threshold');
3230
3259
  }
3231
3260
  this.onEnqueueCallback?.();
3232
3261
  return { position, alreadyQueued: false };
3233
3262
  }
3234
- /** Dequeue the next override entry, or undefined if empty. */
3235
- dequeueOverride() {
3236
- return this.overrideEntries.shift();
3263
+ /** Dequeue the next entry, or undefined if empty. */
3264
+ dequeue() {
3265
+ return this.entries.shift();
3266
+ }
3267
+ /** Get all queued entries (shallow copy). */
3268
+ get items() {
3269
+ return [...this.entries];
3237
3270
  }
3238
- /** Get all override entries (shallow copy). */
3239
- get overrides() {
3240
- return [...this.overrideEntries];
3271
+ /** Number of items waiting in the queue (excludes current). */
3272
+ get depth() {
3273
+ return this.entries.length;
3241
3274
  }
3242
- /** Clear all override entries. Returns count removed. */
3243
- clearOverrides() {
3244
- const count = this.overrideEntries.length;
3245
- this.overrideEntries = [];
3275
+ /**
3276
+ * Remove all pending items from the queue.
3277
+ * Does not affect the currently-running item.
3278
+ *
3279
+ * @returns The number of items removed.
3280
+ */
3281
+ clear() {
3282
+ const count = this.entries.length;
3283
+ this.entries = [];
3246
3284
  return count;
3247
3285
  }
3248
- /** Check if a path is in the override layer. */
3249
- hasOverride(path) {
3250
- return this.overrideEntries.some((e) => e.path === path);
3286
+ /** Check whether a path is in the queue or currently being synthesized. */
3287
+ has(path) {
3288
+ const norm = normQueuePath(path);
3289
+ if (this.currentPhaseItem &&
3290
+ normQueuePath(this.currentPhaseItem.path) === norm) {
3291
+ return true;
3292
+ }
3293
+ return this.entries.some((e) => normQueuePath(e.path) === norm);
3251
3294
  }
3252
- // ── Current-item tracking (3-layer model) ──────────────────────────
3295
+ // ── Current-item tracking ──────────────────────────────────────────
3253
3296
  /** Set the currently executing phase item. */
3254
3297
  setCurrentPhase(path, phase) {
3255
3298
  this.currentPhaseItem = {
@@ -3266,129 +3309,21 @@ class SynthesisQueue {
3266
3309
  get currentPhase() {
3267
3310
  return this.currentPhaseItem;
3268
3311
  }
3269
- // ── Legacy queue interface (preserved for backward compat) ─────────
3270
- /**
3271
- * Add a path to the synthesis queue.
3272
- *
3273
- * @param path - Meta path to synthesize.
3274
- * @param priority - If true, insert at front of queue.
3275
- * @returns Position and whether the path was already queued.
3276
- */
3277
- enqueue(path, priority = false) {
3278
- if (this.currentItem?.path === path) {
3279
- return { position: 0, alreadyQueued: true };
3280
- }
3281
- const existingIndex = this.queue.findIndex((item) => item.path === path);
3282
- if (existingIndex !== -1) {
3283
- return { position: existingIndex, alreadyQueued: true };
3284
- }
3285
- const item = {
3286
- path,
3287
- priority,
3288
- enqueuedAt: new Date().toISOString(),
3289
- };
3290
- if (priority) {
3291
- this.queue.unshift(item);
3292
- }
3293
- else {
3294
- this.queue.push(item);
3295
- }
3296
- if (this.queue.length > DEPTH_WARNING_THRESHOLD) {
3297
- this.logger.warn({ depth: this.queue.length }, 'Queue depth exceeds threshold');
3298
- }
3299
- const position = this.queue.findIndex((i) => i.path === path);
3300
- this.onEnqueueCallback?.();
3301
- return { position, alreadyQueued: false };
3302
- }
3303
- /** Remove and return the next item from the queue. */
3304
- dequeue() {
3305
- const item = this.queue.shift();
3306
- if (item) {
3307
- this.currentItem = item;
3308
- }
3309
- return item;
3310
- }
3311
- /** Mark the currently-running synthesis as complete. */
3312
- complete() {
3313
- this.currentItem = null;
3314
- }
3315
- /** Number of items waiting in the queue (excludes current). */
3316
- get depth() {
3317
- return this.queue.length;
3318
- }
3319
- /** The item currently being synthesized, or null. */
3320
- get current() {
3321
- return this.currentItem;
3322
- }
3323
- /** A shallow copy of the queued items. */
3324
- get items() {
3325
- return [...this.queue];
3326
- }
3327
- /** A shallow copy of the pending items (alias for items). */
3328
- get pending() {
3329
- return [...this.queue];
3330
- }
3331
- /**
3332
- * Remove all pending items from the queue.
3333
- * Does not affect the currently-running item.
3334
- *
3335
- * @returns The number of items removed.
3336
- */
3337
- clear() {
3338
- const count = this.queue.length;
3339
- this.queue = [];
3340
- return count;
3341
- }
3342
- /** Check whether a path is in the queue or currently being synthesized. */
3343
- has(path) {
3344
- if (this.currentItem?.path === path)
3345
- return true;
3346
- if (this.currentPhaseItem?.path === path)
3347
- return true;
3348
- return (this.queue.some((item) => item.path === path) ||
3349
- this.overrideEntries.some((e) => e.path === path));
3350
- }
3351
- /** Get the 0-indexed position of a path in the queue. */
3352
- getPosition(path) {
3353
- // Check overrides first
3354
- const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
3355
- if (overrideIdx !== -1)
3356
- return overrideIdx;
3357
- const index = this.queue.findIndex((item) => item.path === path);
3358
- return index === -1 ? null : index;
3359
- }
3360
- /** Dequeue the next item: overrides first, then legacy queue. */
3361
- nextItem() {
3362
- const override = this.dequeueOverride();
3363
- if (override)
3364
- return { path: override.path, source: 'override' };
3365
- const item = this.dequeue();
3366
- if (item)
3367
- return { path: item.path, source: 'legacy' };
3368
- return undefined;
3369
- }
3312
+ // ── Queue state ────────────────────────────────────────────────────
3370
3313
  /** Return a snapshot of queue state for the /status endpoint. */
3371
3314
  getState() {
3372
3315
  return {
3373
- depth: this.queue.length + this.overrideEntries.length,
3374
- items: [
3375
- ...this.overrideEntries.map((e) => ({
3376
- path: e.path,
3377
- priority: true,
3378
- enqueuedAt: e.enqueuedAt,
3379
- })),
3380
- ...this.queue.map((item) => ({
3381
- path: item.path,
3382
- priority: item.priority,
3383
- enqueuedAt: item.enqueuedAt,
3384
- })),
3385
- ],
3316
+ depth: this.entries.length,
3317
+ items: this.entries.map((e) => ({
3318
+ path: e.path,
3319
+ enqueuedAt: e.enqueuedAt,
3320
+ })),
3386
3321
  };
3387
3322
  }
3323
+ // ── Processing ─────────────────────────────────────────────────────
3388
3324
  /**
3389
- * Process queued items one at a time until all queues are empty.
3325
+ * Process queued items one at a time until the queue is empty.
3390
3326
  *
3391
- * Override entries are processed first (FIFO), then legacy queue items.
3392
3327
  * Re-entry is prevented: if already processing, the call returns
3393
3328
  * immediately. Errors are logged and do not block subsequent items.
3394
3329
  *
@@ -3399,7 +3334,7 @@ class SynthesisQueue {
3399
3334
  return;
3400
3335
  this.processing = true;
3401
3336
  try {
3402
- let next = this.nextItem();
3337
+ let next = this.dequeue();
3403
3338
  while (next) {
3404
3339
  try {
3405
3340
  await synthesizeFn(next.path);
@@ -3408,9 +3343,7 @@ class SynthesisQueue {
3408
3343
  this.logger.error({ path: next.path, err }, 'Synthesis failed');
3409
3344
  }
3410
3345
  this.clearCurrentPhase();
3411
- if (next.source === 'legacy')
3412
- this.complete();
3413
- next = this.nextItem();
3346
+ next = this.dequeue();
3414
3347
  }
3415
3348
  }
3416
3349
  finally {
@@ -3964,7 +3897,6 @@ class Scheduler {
3964
3897
  this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
3965
3898
  return;
3966
3899
  }
3967
- // Enqueue using the legacy queue path (backward compat with processQueue)
3968
3900
  this.queue.enqueue(candidate.path);
3969
3901
  this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
3970
3902
  // Opportunistic watcher restart detection
@@ -4545,9 +4477,10 @@ function registerPreviewRoute(app, deps) {
4545
4477
  ownedFiles: scopeFiles.length,
4546
4478
  childMetas: targetNode.children.length,
4547
4479
  deltaFiles: deltaFiles
4548
- .slice(0, 50)
4480
+ .slice(0, config.previewDeltaFilesCap)
4549
4481
  .map((f) => ({ path: f, action: 'modified' })),
4550
4482
  deltaCount: deltaFiles.length,
4483
+ deltaFilesTruncated: deltaFiles.length > config.previewDeltaFilesCap,
4551
4484
  },
4552
4485
  estimatedTokens,
4553
4486
  // New phase-state fields (additive)
@@ -4563,8 +4496,8 @@ function registerPreviewRoute(app, deps) {
4563
4496
  /**
4564
4497
  * Queue management and abort routes.
4565
4498
  *
4566
- * - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
4567
- * - POST /queue/clear — remove override entries only
4499
+ * - GET /queue — 3-layer queue model (current, pending, automatic)
4500
+ * - POST /queue/clear — remove pending entries
4568
4501
  * - POST /synthesize/abort — abort the current synthesis
4569
4502
  *
4570
4503
  * @module routes/queue
@@ -4574,24 +4507,24 @@ function registerQueueRoutes(app, deps) {
4574
4507
  const { queue } = deps;
4575
4508
  app.get(getEndpoint('queue').path, async () => {
4576
4509
  const currentPhase = queue.currentPhase;
4577
- const overrides = queue.overrides;
4578
- // Compute owedPhase for each override entry by reading meta state
4579
- const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
4510
+ const pending = queue.items;
4511
+ // Compute owedPhase for each pending entry by reading meta state
4512
+ const enrichedPending = await Promise.all(pending.map(async (entry) => {
4580
4513
  try {
4581
- const metaDir = resolveMetaDir(o.path);
4514
+ const metaDir = resolveMetaDir(entry.path);
4582
4515
  const meta = await readMetaJson(metaDir);
4583
4516
  const ps = derivePhaseState(meta);
4584
4517
  return {
4585
- path: o.path,
4518
+ path: entry.path,
4586
4519
  owedPhase: getOwedPhase(ps),
4587
- enqueuedAt: o.enqueuedAt,
4520
+ enqueuedAt: entry.enqueuedAt,
4588
4521
  };
4589
4522
  }
4590
4523
  catch {
4591
4524
  return {
4592
- path: o.path,
4525
+ path: entry.path,
4593
4526
  owedPhase: null,
4594
- enqueuedAt: o.enqueuedAt,
4527
+ enqueuedAt: entry.enqueuedAt,
4595
4528
  };
4596
4529
  }
4597
4530
  }));
@@ -4612,21 +4545,6 @@ function registerQueueRoutes(app, deps) {
4612
4545
  catch {
4613
4546
  // If listing fails, automatic stays empty
4614
4547
  }
4615
- // Legacy: pending is the union of overrides + automatic + legacy queue items
4616
- const pendingItems = [
4617
- ...enrichedOverrides.map((o) => ({
4618
- path: o.path,
4619
- owedPhase: o.owedPhase,
4620
- })),
4621
- ...automatic.map((a) => ({
4622
- path: a.path,
4623
- owedPhase: a.owedPhase,
4624
- })),
4625
- ...queue.pending.map((item) => ({
4626
- path: item.path,
4627
- owedPhase: null,
4628
- })),
4629
- ];
4630
4548
  return {
4631
4549
  current: currentPhase
4632
4550
  ? {
@@ -4634,58 +4552,45 @@ function registerQueueRoutes(app, deps) {
4634
4552
  phase: currentPhase.phase,
4635
4553
  startedAt: currentPhase.startedAt,
4636
4554
  }
4637
- : queue.current
4638
- ? {
4639
- path: queue.current.path,
4640
- phase: null,
4641
- startedAt: queue.current.enqueuedAt,
4642
- }
4643
- : null,
4644
- overrides: enrichedOverrides,
4555
+ : null,
4556
+ pending: enrichedPending,
4645
4557
  automatic,
4646
- pending: pendingItems,
4647
- // Legacy state
4648
- state: queue.getState(),
4649
4558
  };
4650
4559
  });
4651
4560
  app.post(getEndpoint('queueClear').path, () => {
4652
- const removed = queue.clearOverrides();
4561
+ const removed = queue.clear();
4653
4562
  return { cleared: removed };
4654
4563
  });
4655
4564
  app.post(getEndpoint('abort').path, async (_request, reply) => {
4656
- // Check 3-layer current first
4657
4565
  const currentPhase = queue.currentPhase;
4658
- const current = currentPhase ?? queue.current;
4659
- if (!current) {
4566
+ if (!currentPhase) {
4660
4567
  return reply.status(200).send({ status: 'idle' });
4661
4568
  }
4662
4569
  // Abort the executor
4663
4570
  deps.executor?.abort();
4664
- const metaDir = resolveMetaDir(current.path);
4665
- const phase = currentPhase?.phase ?? null;
4571
+ const metaDir = resolveMetaDir(currentPhase.path);
4572
+ const { phase } = currentPhase;
4666
4573
  // Transition running phase to failed and write _error to meta.json
4667
- if (phase) {
4668
- try {
4669
- const meta = await readMetaJson(metaDir);
4670
- let ps = derivePhaseState(meta);
4671
- ps = phaseFailed(ps, phase);
4672
- const updated = {
4673
- ...meta,
4674
- _phaseState: ps,
4675
- _error: {
4676
- step: phase,
4677
- code: 'ABORT',
4678
- message: 'Aborted by operator',
4679
- },
4680
- };
4681
- const lockPath = join(metaDir, '.lock');
4682
- const metaJsonPath = join(metaDir, 'meta.json');
4683
- await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
4684
- await copyFile(lockPath, metaJsonPath);
4685
- }
4686
- catch {
4687
- // Best-effort — meta may be unreadable
4688
- }
4574
+ try {
4575
+ const meta = await readMetaJson(metaDir);
4576
+ let ps = derivePhaseState(meta);
4577
+ ps = phaseFailed(ps, phase);
4578
+ const updated = {
4579
+ ...meta,
4580
+ _phaseState: ps,
4581
+ _error: {
4582
+ step: phase,
4583
+ code: 'ABORT',
4584
+ message: 'Aborted by operator',
4585
+ },
4586
+ };
4587
+ const lockPath = join(metaDir, '.lock');
4588
+ const metaJsonPath = join(metaDir, 'meta.json');
4589
+ await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
4590
+ await copyFile(lockPath, metaJsonPath);
4591
+ }
4592
+ catch {
4593
+ // Best-effort — meta may be unreadable
4689
4594
  }
4690
4595
  // Release the lock for the current meta path
4691
4596
  try {
@@ -4694,11 +4599,11 @@ function registerQueueRoutes(app, deps) {
4694
4599
  catch {
4695
4600
  // Lock may already be released
4696
4601
  }
4697
- deps.logger.info({ path: current.path }, 'Synthesis aborted');
4602
+ deps.logger.info({ path: currentPhase.path }, 'Synthesis aborted');
4698
4603
  return {
4699
4604
  status: 'aborted',
4700
- path: current.path,
4701
- ...(phase ? { phase } : {}),
4605
+ path: currentPhase.path,
4606
+ phase,
4702
4607
  };
4703
4608
  });
4704
4609
  }
@@ -4809,9 +4714,9 @@ async function checkWatcher(url) {
4809
4714
  function deriveServiceState(deps) {
4810
4715
  if (deps.shuttingDown)
4811
4716
  return 'stopping';
4812
- if (deps.queue.current || deps.queue.currentPhase)
4717
+ if (deps.queue.currentPhase)
4813
4718
  return 'synthesizing';
4814
- if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
4719
+ if (deps.queue.depth > 0)
4815
4720
  return 'waiting';
4816
4721
  return 'idle';
4817
4722
  }
@@ -4863,7 +4768,7 @@ function registerStatusRoute(app, deps) {
4863
4768
  }
4864
4769
  return {
4865
4770
  serviceState: deriveServiceState(deps),
4866
- currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
4771
+ currentTarget: queue.currentPhase?.path ?? null,
4867
4772
  queue: queue.getState(),
4868
4773
  stats: {
4869
4774
  totalSyntheses: stats.totalSyntheses,
@@ -4951,7 +4856,7 @@ function registerSynthesizeRoute(app, deps) {
4951
4856
  alreadyQueued: false,
4952
4857
  });
4953
4858
  }
4954
- const result = queue.enqueueOverride(targetPath);
4859
+ const result = queue.enqueue(targetPath);
4955
4860
  return reply.code(202).send({
4956
4861
  status: 'queued',
4957
4862
  path: targetPath,
@@ -4982,8 +4887,9 @@ function registerSynthesizeRoute(app, deps) {
4982
4887
  const stalest = winner.node.metaPath;
4983
4888
  const enqueueResult = queue.enqueue(stalest);
4984
4889
  return reply.code(202).send({
4985
- status: 'accepted',
4890
+ status: 'queued',
4986
4891
  path: stalest,
4892
+ owedPhase: winner.owedPhase,
4987
4893
  queuePosition: enqueueResult.position,
4988
4894
  alreadyQueued: enqueueResult.alreadyQueued,
4989
4895
  });
@@ -5132,26 +5038,14 @@ function registerShutdownHandlers(deps) {
5132
5038
  deps.logger.info('Scheduler stopped');
5133
5039
  }
5134
5040
  // 2. Release lock for in-progress synthesis
5135
- const current = deps.queue.current;
5136
- if (current) {
5137
- try {
5138
- releaseLock(current.path);
5139
- deps.logger.info({ path: current.path }, 'Released lock for in-progress synthesis');
5140
- }
5141
- catch {
5142
- deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
5143
- }
5144
- }
5145
- // Release lock for in-progress override synthesis (only when it
5146
- // differs from the legacy current item to avoid double-release)
5147
5041
  const currentPhase = deps.queue.currentPhase;
5148
- if (currentPhase && currentPhase.path !== current?.path) {
5042
+ if (currentPhase) {
5149
5043
  try {
5150
5044
  releaseLock(resolveMetaDir(currentPhase.path));
5151
- deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress override synthesis');
5045
+ deps.logger.info({ path: currentPhase.path }, 'Released lock for in-progress synthesis');
5152
5046
  }
5153
5047
  catch {
5154
- deps.logger.warn({ path: currentPhase.path }, 'Failed to release override lock during shutdown');
5048
+ deps.logger.warn({ path: currentPhase.path }, 'Failed to release lock during shutdown');
5155
5049
  }
5156
5050
  }
5157
5051
  // 3. Close server
@@ -5288,6 +5182,9 @@ async function startService(config, configPath) {
5288
5182
  const executor = new GatewayExecutor({
5289
5183
  gatewayUrl: config.gatewayUrl,
5290
5184
  apiKey: config.gatewayApiKey,
5185
+ workspaceDir: config.workspaceDir,
5186
+ stagingRetries: config.stagingRetries,
5187
+ stagingRetryDelayMs: config.stagingRetryDelayMs,
5291
5188
  });
5292
5189
  // Runtime stats (mutable, shared with routes)
5293
5190
  const stats = {