@worca/app 1.1.1 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,7 @@ import {
32
32
  } from './artifacts.mjs';
33
33
  import { readQuestionsFile } from './protocol.mjs';
34
34
  import { classifyError } from './recoverable-error.mjs';
35
+ import { resolveFailure, markTerminal, isTerminal } from './failure-policy.mjs';
35
36
 
36
37
  /** Max ask-then-resume question rounds per execution (mirrors v1's constant). */
37
38
  const MAX_QUESTION_ROUNDS = 3;
@@ -145,14 +146,30 @@ export class GraphOrchestrator extends RunHarness {
145
146
  return this._buildResumePoint(null);
146
147
  }
147
148
 
149
+ /** hook: the last clean point (see run-harness.mjs). _graphSnapshot is never cleared
150
+ * (onSnapshot, the resume restore), and the scheduler's finish() takes one final
151
+ * snapshot, so after a clean 'done' this IS the all-terminal point. */
152
+ _engineLastPoint() {
153
+ return this._graphSnapshot ? this._buildResumePoint(this._graphSnapshot) : null;
154
+ }
155
+
156
+ /** hook: agent keys for a setup replay — from the frozen manifest, never the workflow row. */
157
+ _engineAgentKeys() {
158
+ if (this.resolved?.agentKeys) return new Set(this.resolved.agentKeys);
159
+ const manifest = this.state.stepper;
160
+ return manifest ? new Set(resolvedFromManifest(manifest, this.registry).agentKeys) : new Set();
161
+ }
162
+
148
163
  // ── hook 2: run the graph ──────────────────────────────────────────────────
149
164
  /**
150
165
  * The scheduler owns readiness, loop budgets, gates and End; this method owns
151
166
  * the process side: the resume-time restoration (Task 6), the pre-rendered
152
167
  * task document, the executor binding, the event fan-out and the resume-v2
153
- * snapshot. Returns 'done' | 'paused'; a genuine execution failure is re-thrown
154
- * VERBATIM (AbortError/pause identity intact) so the base run()/resume() catch
155
- * classifies it exactly as v1 does.
168
+ * snapshot. Returns 'done' | 'paused'; only the user's STOP is re-thrown (its
169
+ * AbortError/plain-error identity intact) so the base run()/resume() catch
170
+ * classifies it exactly as v1 does. Every other failure pauses inside _execute
171
+ * (errors never end a run), and a pause that lands after the End card fired
172
+ * returns 'paused', never 'done'.
156
173
  * @param {{resume?:object|null, rehydrated?:object|null}} [o] the base passes
157
174
  * `{ resume: rp, rehydrated }` on a resume and `{ resume: null }` on a fresh run.
158
175
  * @returns {Promise<'done'|'paused'>}
@@ -212,6 +229,16 @@ export class GraphOrchestrator extends RunHarness {
212
229
  // agent's error otherwise). A scheduler abort with nothing recorded yet is a stop.
213
230
  throw this._graphError || (this.abort.signal.aborted ? abortError('stopped') : new Error('a graph execution failed'));
214
231
  }
232
+ if (outcome === 'done' && this.pauseRequested) {
233
+ // D17: the scheduler resolves `ended` BEFORE it looks at pauseRequested
234
+ // (scheduler.mjs:1033-1034), so a pause that landed on a straggler after the
235
+ // End card fired — an error-pause, or the user's — came back as 'done'. It is
236
+ // a pause: the point was frozen by onSnapshot the moment pause() ran (the
237
+ // straggler's row is still non-terminal in it), reattach() re-invokes that
238
+ // row on resume and the restored `ended` then quiesces the run to done.
239
+ this.state.resumePoint = this._buildResumePoint(this._graphSnapshot);
240
+ return 'paused';
241
+ }
215
242
  if (outcome === 'paused') {
216
243
  this.state.resumePoint = this._buildResumePoint(this._graphSnapshot);
217
244
  return 'paused';
@@ -256,6 +283,7 @@ export class GraphOrchestrator extends RunHarness {
256
283
  checkpointRefs: { ...this.checkpointRefs },
257
284
  workspace: this.isWorkspace ? { projects: this._workspaceProjects() } : null,
258
285
  pauseReason: this.pauseReason || null,
286
+ pauseDetail: this.pauseDetail || null,
259
287
  // The EFFECTIVE instruction at dispatch time (post in-worktree graph
260
288
  // build), not the detect-time tools.instruction.
261
289
  toolInstruction: this.toolInstruction ?? '',
@@ -401,16 +429,35 @@ export class GraphOrchestrator extends RunHarness {
401
429
  const nc = (this.resolved.nodeCtx || {})[node.id] || { nodeId: node.id, kind: node.kind, key: null };
402
430
  // The composite protocol: these three modes are the process side of a
403
431
  // fan-out — they spawn nothing, record no ledger row and allocate nothing,
404
- // so a composite shell never burns a plan version.
405
- if (args.composite === 'expand') return await this._expandDecomposition(node, args);
406
- if (args.composite === 'phase') return this._compositePhase(args);
407
- if (args.composite === 'finish') return await this._finishComposite(nc, args);
432
+ // so a composite shell never burns a plan version. Same policy as below: a
433
+ // throw pauses the run. Only the `finish` answer is read as a settlement
434
+ // (settle -> pausedExecution); runComposite ignores an `expand` answer
435
+ // without `phases` (it falls to runUnexpanded) and runPhase ignores the
436
+ // `phase` answers (scheduler.mjs:447/:469), so for those two the pause lands
437
+ // one call later, at the next ordinary execute's _checkPause() — the shell
438
+ // row ends 'paused' either way and the whole fan-out re-runs on resume.
439
+ if (args.composite) {
440
+ try {
441
+ if (args.composite === 'expand') return await this._expandDecomposition(node, args);
442
+ if (args.composite === 'phase') return this._compositePhase(args);
443
+ return await this._finishComposite(nc, args);
444
+ } catch (err) {
445
+ return this._settleUnstarted(nc, node, args, err);
446
+ }
447
+ }
408
448
 
409
- const ctx = this._execCtx(node, nc, args);
449
+ let ctx;
450
+ try {
451
+ ctx = this._execCtx(node, nc, args);
452
+ } catch (err) {
453
+ return this._settleUnstarted(nc, node, args, err); // allocation failed: no row to mark
454
+ }
410
455
  this._execStep(ctx, 'start');
411
- if (ctx.slice) updateTaskStatus(this.pipeline.id, ctx.slice.id, 'running', new Date().toISOString());
412
456
  let endMark = 'done';
413
457
  try {
458
+ // A real DB write — inside the try: a throw here pauses like anything
459
+ // else, and the finally skips updateTaskStatus for a 'paused' mark.
460
+ if (ctx.slice) updateTaskStatus(this.pipeline.id, ctx.slice.id, 'running', new Date().toISOString());
414
461
  // Exactly what v1's dispatcher loop does at every step boundary
415
462
  // (orchestrator.mjs:259-261): a stop or pause requested while nothing was
416
463
  // in flight (e.g. between executions, or during a flow card) must land
@@ -435,17 +482,47 @@ export class GraphOrchestrator extends RunHarness {
435
482
  endMark = 'paused';
436
483
  return { paused: true };
437
484
  }
438
- endMark = (isAbort(err) && this.abort.signal.aborted) ? 'stopped' : 'error';
439
- this._graphError ||= err; // preserve identity for the base catch
485
+ if (this.abort.signal.aborted || this.state.status === 'stopped') {
486
+ // The user's stop the ONLY path that still lets the scheduler see a
487
+ // failure. Its identity is kept for _engineRun's rethrow; the shell's catch
488
+ // classifies the run 'stopped' (status/isAbort), never 'error'. This also
489
+ // covers a child that died with a PLAIN error after the stop landed — that
490
+ // error still gets today's one error-level line (_logStepFailure skips
491
+ // AbortErrors itself).
492
+ endMark = 'stopped';
493
+ this._graphError ||= err; // preserve identity for the base catch
494
+ this._logStepFailure(nc, ctx, err);
495
+ throw err;
496
+ }
497
+ // The FLOW site (failure-policy.mjs): anything that escaped _runNodeAttempts'
498
+ // own verdict — a flow card, the questions loop, _afterExecution, an
499
+ // unexpected throw — is decided here. A verdict the node site already issued
500
+ // (a terminal error) is enacted, never re-decided.
501
+ const verdict = isTerminal(err) ? { outcome: 'error' }
502
+ : resolveFailure({ site: 'flow', cls: classifyError(err), auto: this.auto });
503
+ if (verdict.outcome === 'pause') {
504
+ // pause() rejects a sibling parked on a recovery prompt with the pause
505
+ // sentinel, so that slice unwinds as paused too.
506
+ this._pauseFor(verdict.reason, err, { nc, ctx });
507
+ endMark = 'paused';
508
+ return { paused: true };
509
+ }
510
+ // Terminal: the scheduler sees the failure (its row ends 'error', in-flight
511
+ // siblings 'skipped') and _engineRun rethrows it for the shell's error path.
512
+ endMark = 'error';
513
+ this._graphError ||= markTerminal(err); // preserve identity for the base catch
440
514
  this._logStepFailure(nc, ctx, err);
441
515
  // A sibling slice parked on an interactive recovery prompt is not
442
516
  // signal-reachable (_ask settles only via answer()/pause()/stop()), so a
443
- // genuine slice failure rejects that prompt exactly as v1's noteFailure
444
- // does — the phase is failing and must not wait on a now-meaningless answer.
517
+ // genuine slice failure rejects that prompt the phase is failing and must
518
+ // not wait on a now-meaningless answer.
445
519
  if (ctx.slice && this.pendingQuestion?.kind === 'recovery') {
446
520
  const pq = this.pendingQuestion;
447
521
  this.pendingQuestion = null;
448
- pq.reject(abortError());
522
+ // Stamped terminal: the released sibling's catch must ENACT this verdict
523
+ // (its row ends 'error' with the phase), never re-decide it at the flow site
524
+ // as a pause with the detail 'aborted'.
525
+ pq.reject(markTerminal(abortError()));
449
526
  }
450
527
  throw err;
451
528
  } finally {
@@ -458,6 +535,30 @@ export class GraphOrchestrator extends RunHarness {
458
535
  }
459
536
  }
460
537
 
538
+ /** A throw before the execution had a ledger row (the composite shell modes, or
539
+ * _execCtx's allocation): the SAME outcomes as _execute's catch, in the same
540
+ * order and with the same conditions — the FLOW site — just without a row to
541
+ * mark. args.executionId / args.ordinal exist on every composite call (argsFor,
542
+ * scheduler.mjs:333-342, is spread into each of them). */
543
+ _settleUnstarted(nc, node, args, err) {
544
+ if (isPause(err) || (this.pauseRequested && (isAbort(err) || this.pauseAbort.signal.aborted))) return { paused: true };
545
+ const ctx = { nodeId: node.id, executionId: args.executionId, ordinal: args.ordinal || 1 };
546
+ if (this.abort.signal.aborted || this.state.status === 'stopped') {
547
+ this._graphError ||= err; // the stop keeps its identity for _engineRun's rethrow
548
+ this._logStepFailure(nc, ctx, err);
549
+ throw err;
550
+ }
551
+ const verdict = isTerminal(err) ? { outcome: 'error' }
552
+ : resolveFailure({ site: 'flow', cls: classifyError(err), auto: this.auto });
553
+ if (verdict.outcome === 'pause') {
554
+ this._pauseFor(verdict.reason, err, { nc, ctx });
555
+ return { paused: true };
556
+ }
557
+ this._graphError ||= markTerminal(err);
558
+ this._logStepFailure(nc, ctx, err);
559
+ throw err;
560
+ }
561
+
461
562
  /** The five flow cards through P3's dispatcher. Engine-owned: instant, $0, no
462
563
  * semaphore slot, no spawn. runExecution reads ctx.taskArtifact (Task card),
463
564
  * ctx.allocatedPath (Combine) and derives Combine's headings from ctx.template. */
@@ -675,9 +776,12 @@ export class GraphOrchestrator extends RunHarness {
675
776
  this._persist().catch(() => {});
676
777
  }
677
778
 
678
- /** The recoverable-error retry loop around ONE execution. The pause paths throw
679
- * pauseErr() with pauseRequested already set (_pauseForLimit calls pause()),
680
- * so _execute's catch reproduces the 'paused' mark. */
779
+ /** The retry loop around ONE execution the NODE site of failure-policy.mjs.
780
+ * _recover() resolves the verdict (running the backoff or the recovery prompt
781
+ * on the way); this loop enacts it. A pause throws pauseErr() with
782
+ * pauseRequested already set (_pauseFor calls pause()), so _execute's catch
783
+ * reproduces the 'paused' mark; a terminal error is stamped so _execute's
784
+ * catch enacts it instead of re-deciding at the flow site. */
681
785
  async _runNodeAttempts(nc, ctx) {
682
786
  for (let attempt = 1; ; attempt++) {
683
787
  try {
@@ -685,12 +789,13 @@ export class GraphOrchestrator extends RunHarness {
685
789
  } catch (err) {
686
790
  if (this.pauseRequested && (isAbort(err) || isPause(err) || this.pauseAbort.signal.aborted)) throw pauseErr();
687
791
  if (isAbort(err) || isPause(err)) throw err;
792
+ if (this.abort.signal.aborted || this.state.status === 'stopped') throw err; // a stop is in flight: _execute marks it 'stopped'
688
793
  const cls = classifyError(err);
689
- if (!cls) throw err; // not recoverable -> today's path
690
- if (cls === 'usage_limit') { this._pauseForLimit(nc, ctx, err); throw pauseErr(); }
691
- const decision = await this._recover({ node: { key: nc.key || ctx.nodeId }, cls, err, attempt });
692
- if (decision === 'abort') throw err; // user/auto gave up -> fail as today
693
- this._execStep(ctx, 'start'); // back to running for the retry
794
+ const verdict = await this._recover({ node: { key: nc.key || ctx.nodeId }, cls, err, attempt });
795
+ if (this.pauseRequested) throw pauseErr(); // a pause landed during backoff/prompt: its reason stands
796
+ if (verdict.outcome === 'retry') { this._execStep(ctx, 'start'); continue; } // back to running for the retry
797
+ if (verdict.outcome === 'pause') { this._pauseFor(verdict.reason, err, { nc, ctx, cls }); throw pauseErr(); }
798
+ throw markTerminal(err); // terminal: _execute's catch ends the run
694
799
  }
695
800
  }
696
801
  }
@@ -724,18 +829,6 @@ export class GraphOrchestrator extends RunHarness {
724
829
  });
725
830
  }
726
831
 
727
- /** A session/usage cap that only clears after a multi-hour reset: pause the run
728
- * (v1's _pauseForLimit, orchestrator.mjs:756, re-keyed by execution). */
729
- _pauseForLimit(nc, ctx, err) {
730
- const label = nc.key || ctx.nodeId;
731
- const reason = firstLine(err?.message || String(err));
732
- if (!this.pauseReason) this.pauseReason = reason;
733
- this._log(label, 'warn', `session/usage limit reached — pausing for manual resume: ${reason}`,
734
- { nodeId: ctx.nodeId, executionId: ctx.executionId, cycle: ctx.ordinal });
735
- appendAudit(this.pipeline.dir, `Pipeline **paused**: session/usage limit on ${label} — ${reason}. Resume after the reset.`).catch(() => {});
736
- this.pause();
737
- }
738
-
739
832
  /**
740
833
  * Everything between an agent returning and the scheduler publishing its tokens:
741
834
  * - the verdict lands in the AUTHORITATIVE reviews table, keyed by the generic
@@ -978,7 +1071,7 @@ export class GraphOrchestrator extends RunHarness {
978
1071
  this._resumeSnapshot = rp.snapshot || null;
979
1072
  this._graphSnapshot = rp.snapshot || null;
980
1073
  this._planVersion = Number.isFinite(rp.planVersion) ? rp.planVersion : 0;
981
- this.pauseReason = null;
1074
+ this._clearPauseReason();
982
1075
  const manifest = rp.manifest || this.state.stepper;
983
1076
  this.state.stepper = manifest;
984
1077
  this._adoptResolvedGraph(resolvedFromManifest(manifest, this.registry));
@@ -16,6 +16,7 @@ import { spawn } from 'node:child_process';
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { envFlag } from './model-env.mjs';
19
20
  import { WORCA_PLUGIN_API } from './plugin-api.mjs';
20
21
  import { normalizeManifest, negotiatedApi } from './plugin-manifest.mjs';
21
22
  import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
@@ -66,10 +67,9 @@ const MOCK_DEFAULTS = {
66
67
  validateConfig: () => ({ ok: true }),
67
68
  };
68
69
 
69
- /** Same env-flag semantics as claude-runner.mjs#mockEnabled (claude-runner.mjs:100-104). */
70
+ /** Same env-flag semantics as claude-runner.mjs#mockEnabled — one shared rule. */
70
71
  function mockMode() {
71
- const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
72
- return !!v && v !== '0' && v.toLowerCase() !== 'false';
72
+ return envFlag('WORCA_MOCK', 'ORCH_MOCK');
73
73
  }
74
74
 
75
75
  async function mockCall(op, args) {