@enderfga/claw-orchestrator 7.11.0 → 7.12.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.
@@ -115,7 +115,7 @@ import { detectRepoLang } from './kernel/repo.js';
115
115
  import { RunKernel, runDir as kernelRunDir } from './kernel/engine.js';
116
116
  import { registerDefaultExecutors } from './kernel/nodes/index.js';
117
117
  import { autoloopStateFromRecord, makeAutoloopExecutor } from './kernel/nodes/autoloop.js';
118
- import { loadRun, readNodeOutput } from './kernel/store.js';
118
+ import { isValidRunId, loadRun, readNodeOutput } from './kernel/store.js';
119
119
  import { LEGACY_NODE, joinFindings, toCouncilSession, toFanoutSession, toUltraplanResult, toUltrareviewResult, } from './kernel/projections.js';
120
120
  import { legacyCouncilWorkflow, legacyFanoutWorkflow, legacyUltraplanWorkflow, splitAgentSecrets, } from './kernel/templates/index.js';
121
121
  import { normalizeContract } from './verify/contract.js';
@@ -143,7 +143,7 @@ import { isAgyConversationId } from './agy-conversation.js';
143
143
  import { Council } from './council.js';
144
144
  import { AutoloopRunner } from './autoloop/runner.js';
145
145
  import { ClaudeAgentDispatcher } from './autoloop/dispatcher.js';
146
- import { DEFAULT_PUSH_POLICY, DEFAULT_SEND_TIMEOUT_MS, validateAutoloopTimeoutConfig } from './autoloop/types.js';
146
+ import { DEFAULT_PUSH_POLICY, DEFAULT_SEND_TIMEOUT_MS, MAX_METRIC_HISTORY, validateAutoloopTimeoutConfig, } from './autoloop/types.js';
147
147
  import { Msg as AutoloopMsg } from './autoloop/messages.js';
148
148
  import { appendPushLog, notifyUserFallbackChain } from './autoloop/notify.js';
149
149
  import { UltraappManager } from './ultraapp/manager.js';
@@ -454,6 +454,165 @@ function porcelainHasDisallowedAutoloopDirt(workspace, porcelain, prefix) {
454
454
  }
455
455
  return false;
456
456
  }
457
+ /** Fixed generic refusal — no runId, path, file bytes, or git stderr. */
458
+ const AUTOLOOP_LEDGER_ISOLATION_ERROR = 'Autoloop could not isolate the run ledger from Git';
459
+ function refuseAutoloopLedgerIsolation() {
460
+ throw new Error(AUTOLOOP_LEDGER_ISOLATION_ERROR);
461
+ }
462
+ /** Ensure absPath is a real directory (not a symlink); mkdir non-recursively if missing. */
463
+ function ensureRealAutoloopDir(absPath) {
464
+ if (!fs.existsSync(absPath))
465
+ fs.mkdirSync(absPath);
466
+ let st;
467
+ try {
468
+ st = fs.lstatSync(absPath);
469
+ }
470
+ catch {
471
+ refuseAutoloopLedgerIsolation();
472
+ }
473
+ if (!st.isDirectory() || st.isSymbolicLink())
474
+ refuseAutoloopLedgerIsolation();
475
+ }
476
+ /**
477
+ * After dirty refusal: create `tasks/<runId>/.gitignore` (`*`) and prove via
478
+ * check-ignore. Never deletes ledger state. Invalid runIds skip (kernel rejects).
479
+ */
480
+ function ensureAutoloopRunLedgerIgnored(workspace, runId) {
481
+ if (!isValidRunId(runId))
482
+ return;
483
+ const relLedger = path.posix.join('tasks', runId);
484
+ const gitEnv = { ...process.env, LC_ALL: 'C' };
485
+ let tracked;
486
+ try {
487
+ tracked = execFileSync('git', ['-C', workspace, '--literal-pathspecs', 'ls-files', '-z', '--', relLedger], {
488
+ encoding: 'utf8',
489
+ stdio: ['ignore', 'pipe', 'pipe'],
490
+ env: gitEnv,
491
+ });
492
+ }
493
+ catch {
494
+ refuseAutoloopLedgerIsolation();
495
+ }
496
+ if (tracked.length > 0)
497
+ refuseAutoloopLedgerIsolation();
498
+ const tasksAbs = path.join(workspace, 'tasks');
499
+ const ledgerAbs = path.join(tasksAbs, runId);
500
+ try {
501
+ ensureRealAutoloopDir(tasksAbs);
502
+ ensureRealAutoloopDir(ledgerAbs);
503
+ if (fs.realpathSync(ledgerAbs) !== path.join(fs.realpathSync(workspace), 'tasks', runId)) {
504
+ refuseAutoloopLedgerIsolation();
505
+ }
506
+ }
507
+ catch (err) {
508
+ if (err instanceof Error && err.message === AUTOLOOP_LEDGER_ISOLATION_ERROR)
509
+ throw err;
510
+ refuseAutoloopLedgerIsolation();
511
+ }
512
+ const ignoreAbs = path.join(ledgerAbs, '.gitignore');
513
+ try {
514
+ const fd = fs.openSync(ignoreAbs, 'wx');
515
+ try {
516
+ fs.writeFileSync(fd, '*\n');
517
+ }
518
+ finally {
519
+ fs.closeSync(fd);
520
+ }
521
+ }
522
+ catch (err) {
523
+ if (err.code !== 'EEXIST')
524
+ refuseAutoloopLedgerIsolation();
525
+ try {
526
+ const st = fs.lstatSync(ignoreAbs);
527
+ if (!st.isFile() || st.isSymbolicLink())
528
+ refuseAutoloopLedgerIsolation();
529
+ const bytes = fs.readFileSync(ignoreAbs, 'utf8');
530
+ if (bytes !== '*' && bytes !== '*\n' && bytes !== '*\r\n')
531
+ refuseAutoloopLedgerIsolation();
532
+ }
533
+ catch (inner) {
534
+ if (inner instanceof Error && inner.message === AUTOLOOP_LEDGER_ISOLATION_ERROR)
535
+ throw inner;
536
+ refuseAutoloopLedgerIsolation();
537
+ }
538
+ }
539
+ // check-ignore rejects --literal-pathspecs; valid runIds have no pathspec magic.
540
+ for (const probe of [
541
+ `${relLedger}/.gitignore`,
542
+ `${relLedger}/chat.jsonl`,
543
+ `${relLedger}/decisions.jsonl`,
544
+ `${relLedger}/iter/0/verdict.json`,
545
+ `${relLedger}/reviewer_sandbox/prior_verdict.json`,
546
+ ]) {
547
+ try {
548
+ execFileSync('git', ['-C', workspace, 'check-ignore', '-q', '--', probe], {
549
+ stdio: ['ignore', 'pipe', 'pipe'],
550
+ env: gitEnv,
551
+ });
552
+ }
553
+ catch {
554
+ refuseAutoloopLedgerIsolation();
555
+ }
556
+ }
557
+ }
558
+ /**
559
+ * Fresh-start workspace boundary shared by `autoloopStart` and a fresh workflow
560
+ * `autoloop` boot: refuse a dirty Git worktree, then isolate the run ledger.
561
+ * `invalidRunId: 'defer'` skips isolation for invalid ids (direct start — the
562
+ * kernel rejects them). `'refuse'` fails closed with the fixed isolation error
563
+ * (fresh workflow path — config.runId is not the kernel run id).
564
+ */
565
+ function applyAutoloopFreshStartBoundary(workspace, runId, opts) {
566
+ // Refuse a dirty git worktree before the run directory or Planner exists.
567
+ // --porcelain=v1 -z keeps paths literal (spaces/quotes). --untracked-files=all
568
+ // overrides status.showUntrackedFiles=no, while --ignore-submodules=none
569
+ // prevents repository config from hiding submodule dirt; do not pass
570
+ // --ignored. Only the deterministic not-a-git-repository error allows the
571
+ // start; every other status failure rejects. Do not raise maxBuffer:
572
+ // ENOBUFS or already-nonempty stdout means dirt. Untracked (??) or
573
+ // index-clean worktree mods
574
+ // (` M`) under tasks/<id>/ are ignored only when tasks/<id>/goal.json is a
575
+ // regular file — every other status (staged/rename/copy/delete/conflict/
576
+ // malformed) rejects.
577
+ // Nested workspaces: status paths are repo-root-relative; strip a verified
578
+ // `rev-parse --show-prefix` before the ledger check; paths outside the
579
+ // workspace still refuse.
580
+ const dirtyStartError = new Error('Autoloop workspace has uncommitted changes; commit or stash them before starting');
581
+ let porcelain = '';
582
+ let inGitRepository = false;
583
+ try {
584
+ porcelain = execFileSync('git', ['-C', workspace, 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'], {
585
+ encoding: 'utf8',
586
+ stdio: ['ignore', 'pipe', 'pipe'],
587
+ env: { ...process.env, LC_ALL: 'C' },
588
+ });
589
+ inGitRepository = true;
590
+ }
591
+ catch (err) {
592
+ const e = err;
593
+ const partial = typeof e.stdout === 'string' ? e.stdout : Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf8') : '';
594
+ if (e.code === 'ENOBUFS' || partial.trim() !== '' || !isNotGitRepositoryError(err)) {
595
+ throw dirtyStartError;
596
+ }
597
+ porcelain = '';
598
+ }
599
+ if (porcelain.length > 0) {
600
+ const prefix = readVerifiedGitShowPrefix(workspace);
601
+ if (prefix === null || porcelainHasDisallowedAutoloopDirt(workspace, porcelain, prefix)) {
602
+ throw dirtyStartError;
603
+ }
604
+ }
605
+ // Run-local ledger isolation: tasks/<runId>/.gitignore (*) + check-ignore proof.
606
+ // Git workspaces only; skips invalid ids so kernel.start rejects them unchanged.
607
+ if (inGitRepository) {
608
+ if (!isValidRunId(runId)) {
609
+ if (opts.invalidRunId === 'refuse')
610
+ refuseAutoloopLedgerIsolation();
611
+ return;
612
+ }
613
+ ensureAutoloopRunLedgerIgnored(workspace, runId);
614
+ }
615
+ }
457
616
  /**
458
617
  * The tool calls a turn's `result` event says the engine refused, normalized.
459
618
  *
@@ -487,6 +646,128 @@ function readPermissionDenials(evt) {
487
646
  }
488
647
  return out;
489
648
  }
649
+ /** Fixed refusal when a non-fresh boot cannot trust the closed-ledger prefix. */
650
+ const AUTOLOOP_CLOSED_LEDGER_REFUSAL = 'Autoloop resume refused: ledger closed-prefix is incomplete or malformed';
651
+ const REVIEWER_DECISIONS = new Set(['advance', 'hold', 'rollback']);
652
+ /**
653
+ * One-reader closed-ledger restore for non-fresh `_bootAutoloop`.
654
+ *
655
+ * Derives typed `iter` / `metric_history` from the contiguous valid closed
656
+ * prefix starting at 0. Checkpoint bytes are never progress authority.
657
+ * Returns null when there is no valid prefix (legacy lone verdict, empty).
658
+ * Throws {@link AUTOLOOP_CLOSED_LEDGER_REFUSAL} on gap / malformed tail.
659
+ */
660
+ /** Canonical numeric iter directory names — no leading zeros. */
661
+ const CLOSED_LEDGER_ITER_DIR = /^(0|[1-9][0-9]*)$/;
662
+ function readClosedLedgerRestore(ledgerDir) {
663
+ const iterRoot = path.join(ledgerDir, 'iter');
664
+ if (!fs.existsSync(iterRoot))
665
+ return null;
666
+ let numericDirs;
667
+ try {
668
+ numericDirs = fs
669
+ .readdirSync(iterRoot)
670
+ .filter((name) => CLOSED_LEDGER_ITER_DIR.test(name))
671
+ .map((name) => Number(name))
672
+ .sort((a, b) => a - b);
673
+ }
674
+ catch {
675
+ return null;
676
+ }
677
+ if (numericDirs.length === 0)
678
+ return null;
679
+ const present = new Set(numericDirs);
680
+ const metrics = [];
681
+ let i = 0;
682
+ for (;;) {
683
+ const iterDir = path.join(iterRoot, String(i));
684
+ if (!fs.existsSync(iterDir)) {
685
+ // K=-1 / no valid closed prefix: never refuse on higher dirs (base Planner-only).
686
+ if (i === 0)
687
+ return null;
688
+ for (const j of present) {
689
+ if (j > i)
690
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
691
+ }
692
+ break;
693
+ }
694
+ const directivePath = path.join(iterDir, 'directive.json');
695
+ const verdictPath = path.join(iterDir, 'verdict.json');
696
+ const hasDirective = fs.existsSync(directivePath);
697
+ const hasVerdict = fs.existsSync(verdictPath);
698
+ if (!hasDirective) {
699
+ if (hasVerdict) {
700
+ // Legacy lone verdict (or directive-less "closed" appearance).
701
+ if (i === 0)
702
+ return null;
703
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
704
+ }
705
+ // Empty/non-closed at 0: prefixless compat before any gap check.
706
+ if (i === 0)
707
+ return null;
708
+ for (const j of present) {
709
+ if (j > i)
710
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
711
+ }
712
+ break;
713
+ }
714
+ if (!hasVerdict) {
715
+ // Open / directive-only / committed-without-review tail at i.
716
+ // No closed prefix yet: return null before gap checks (open0+lateriter1 compat).
717
+ if (i === 0)
718
+ return null;
719
+ for (const j of present) {
720
+ if (j > i)
721
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
722
+ }
723
+ const history = metrics.length > MAX_METRIC_HISTORY ? metrics.slice(metrics.length - MAX_METRIC_HISTORY) : metrics.slice();
724
+ return { iter: i, metric_history: history };
725
+ }
726
+ let raw;
727
+ try {
728
+ raw = fs.readFileSync(verdictPath, 'utf8');
729
+ }
730
+ catch {
731
+ // Prefixless unreadable verdict at 0: no-restore / no-refusal (base compat).
732
+ if (i === 0)
733
+ return null;
734
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
735
+ }
736
+ let parsed;
737
+ try {
738
+ parsed = JSON.parse(raw);
739
+ }
740
+ catch {
741
+ if (i === 0)
742
+ return null;
743
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
744
+ }
745
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
746
+ if (i === 0)
747
+ return null;
748
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
749
+ }
750
+ const verdict = parsed;
751
+ if (verdict.iter !== i) {
752
+ if (i === 0)
753
+ return null;
754
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
755
+ }
756
+ if (typeof verdict.decision !== 'string' || !REVIEWER_DECISIONS.has(verdict.decision)) {
757
+ if (i === 0)
758
+ return null;
759
+ throw new Error(AUTOLOOP_CLOSED_LEDGER_REFUSAL);
760
+ }
761
+ if (typeof verdict.metric === 'number' && Number.isFinite(verdict.metric)) {
762
+ metrics.push(verdict.metric);
763
+ }
764
+ i += 1;
765
+ }
766
+ if (i === 0)
767
+ return null;
768
+ const history = metrics.length > MAX_METRIC_HISTORY ? metrics.slice(metrics.length - MAX_METRIC_HISTORY) : metrics.slice();
769
+ return { iter: i, metric_history: history };
770
+ }
490
771
  export class SessionManager {
491
772
  sessions = new Map();
492
773
  _pendingSessions = new Map();
@@ -1140,12 +1421,17 @@ export class SessionManager {
1140
1421
  // its executor is registered here with a builder closed over `this`
1141
1422
  // rather than living in the kernel.
1142
1423
  kernel.setExecutor('autoloop', makeAutoloopExecutor({
1143
- boot: (config, secrets) => this._bootAutoloop({
1424
+ boot: (config, secrets, { freshStart }) => this._bootAutoloop({
1144
1425
  ...config,
1145
1426
  // Custom-engine configs never reach the spec, so they come from
1146
1427
  // the run's in-memory secret bag — supplied at start, and
1147
1428
  // re-supplied by the caller on a resume.
1148
1429
  ...secrets,
1430
+ // Internal never-persisted marker LAST so spec.config / secrets
1431
+ // cannot override the kernel visits===1 fresh-start signal.
1432
+ // Always set (including false on resume) — omitting false left a
1433
+ // `_freshStart: true` from config/secrets intact.
1434
+ _freshStart: freshStart,
1149
1435
  }),
1150
1436
  ready: (key, value) => {
1151
1437
  const deferred = this._autoloopReady.get(key);
@@ -2916,9 +3202,19 @@ export class SessionManager {
2916
3202
  }
2917
3203
  }
2918
3204
  const ledgerDir = path.join(opts.workspace, 'tasks', opts.runId);
3205
+ if (opts._freshStart === true) {
3206
+ applyAutoloopFreshStartBoundary(opts.workspace, opts.runId, { invalidRunId: 'refuse' });
3207
+ }
2919
3208
  if (!fs.existsSync(ledgerDir)) {
2920
3209
  fs.mkdirSync(ledgerDir, { recursive: true });
2921
3210
  }
3211
+ // Non-fresh boots: restore typed progress from the contiguous closed ledger
3212
+ // prefix before any dispatcher/runner/session work. Refusal throws here so
3213
+ // no Planner start or checkpoint publish occurs.
3214
+ let closedLedgerRestore = null;
3215
+ if (opts._freshStart !== true) {
3216
+ closedLedgerRestore = readClosedLedgerRestore(ledgerDir);
3217
+ }
2922
3218
  // Per-run policy object — mutable so Planner's update_push_policy is visible
2923
3219
  // to the runner without re-wiring.
2924
3220
  const pushPolicy = JSON.parse(JSON.stringify(DEFAULT_PUSH_POLICY));
@@ -2990,6 +3286,10 @@ export class SessionManager {
2990
3286
  autoloopHardTimeoutMs: opts.autoloopHardTimeoutMs,
2991
3287
  });
2992
3288
  runnerRef = runner;
3289
+ if (closedLedgerRestore) {
3290
+ runner.state.iter = closedLedgerRestore.iter;
3291
+ runner.state.metric_history = closedLedgerRestore.metric_history.slice();
3292
+ }
2993
3293
  try {
2994
3294
  await runner.start();
2995
3295
  // A reconstructed migration becomes externally visible only after its
@@ -3095,43 +3395,7 @@ export class SessionManager {
3095
3395
  throw new Error(`Autoloop session name '${sessionName}' is already in use`);
3096
3396
  }
3097
3397
  }
3098
- // Refuse a dirty git worktree before the run directory or Planner exists.
3099
- // --porcelain=v1 -z keeps paths literal (spaces/quotes). --untracked-files=all
3100
- // overrides status.showUntrackedFiles=no, while --ignore-submodules=none
3101
- // prevents repository config from hiding submodule dirt; do not pass
3102
- // --ignored. Only the deterministic not-a-git-repository error allows the
3103
- // start; every other status failure rejects. Do not raise maxBuffer:
3104
- // ENOBUFS or already-nonempty stdout means dirt. Untracked (??) or
3105
- // index-clean worktree mods
3106
- // (` M`) under tasks/<id>/ are ignored only when tasks/<id>/goal.json is a
3107
- // regular file — every other status (staged/rename/copy/delete/conflict/
3108
- // malformed) rejects.
3109
- // Nested workspaces: status paths are repo-root-relative; strip a verified
3110
- // `rev-parse --show-prefix` before the ledger check; paths outside the
3111
- // workspace still refuse.
3112
- const dirtyStartError = new Error('Autoloop workspace has uncommitted changes; commit or stash them before starting');
3113
- let porcelain = '';
3114
- try {
3115
- porcelain = execFileSync('git', ['-C', opts.workspace, 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'], {
3116
- encoding: 'utf8',
3117
- stdio: ['ignore', 'pipe', 'pipe'],
3118
- env: { ...process.env, LC_ALL: 'C' },
3119
- });
3120
- }
3121
- catch (err) {
3122
- const e = err;
3123
- const partial = typeof e.stdout === 'string' ? e.stdout : Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf8') : '';
3124
- if (e.code === 'ENOBUFS' || partial.trim() !== '' || !isNotGitRepositoryError(err)) {
3125
- throw dirtyStartError;
3126
- }
3127
- porcelain = '';
3128
- }
3129
- if (porcelain.length > 0) {
3130
- const prefix = readVerifiedGitShowPrefix(opts.workspace);
3131
- if (prefix === null || porcelainHasDisallowedAutoloopDirt(opts.workspace, porcelain, prefix)) {
3132
- throw dirtyStartError;
3133
- }
3134
- }
3398
+ applyAutoloopFreshStartBoundary(opts.workspace, opts.runId, { invalidRunId: 'defer' });
3135
3399
  const tag = `${opts.runId}:${randomUUID()}`;
3136
3400
  const ready = new Promise((resolve, reject) => {
3137
3401
  this._autoloopReady.set(tag, { resolve, reject });