@yemi33/minions 0.1.2379 → 0.1.2381
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/bin/minions.js +19 -9
- package/dashboard/js/refresh.js +3 -4
- package/dashboard/js/render-other.js +43 -23
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard/js/settings.js +40 -17
- package/dashboard.js +435 -768
- package/docs/architecture-review-2026-07-09.md +2 -4
- package/docs/auto-discovery.md +36 -49
- package/docs/blog-first-successful-dispatch.md +1 -1
- package/docs/branch-derivation.md +2 -2
- package/docs/command-center.md +1 -1
- package/docs/completion-reports.md +14 -4
- package/docs/constellation-bridge.md +59 -10
- package/docs/constellation-style-telemetry.md +6 -6
- package/docs/cooldown-merge-semantics.md +4 -0
- package/docs/copilot-cli-schema.md +3 -3
- package/docs/cross-repo-plans.md +17 -17
- package/docs/deprecated.json +2 -2
- package/docs/design-state-storage.md +1 -1
- package/docs/documentation-audit-2026-07-09.md +2 -2
- package/docs/engine-restart.md +20 -8
- package/docs/harness-mode.md +1 -1
- package/docs/live-checkout-mode.md +45 -26
- package/docs/managed-spawn.md +4 -4
- package/docs/onboarding.md +1 -2
- package/docs/pr-comment-followup.md +3 -3
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
- package/docs/qa-runbook-lifecycle.md +4 -4
- package/docs/qa-runbooks.md +2 -2
- package/docs/rfc-completion-json.md +4 -1
- package/docs/runtime-adapters.md +1 -1
- package/docs/self-improvement.md +4 -5
- package/docs/shared-lifecycle-module-map.md +3 -1
- package/docs/slim-ux/architecture-suggestions.md +5 -6
- package/docs/slim-ux/concepts.md +23 -25
- package/docs/watches.md +7 -7
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +1 -1
- package/engine/abandoned-pr-reconciliation.js +4 -5
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +58 -1
- package/engine/bridge.js +260 -5
- package/engine/cleanup.js +48 -131
- package/engine/cli.js +125 -83
- package/engine/cooldown.js +9 -16
- package/engine/db/index.js +22 -9
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/020-qa-session-scopes.js +23 -0
- package/engine/db/migrations/021-archived-work-items.js +72 -0
- package/engine/db/migrations/022-global-cc-session.js +31 -0
- package/engine/db/migrations/023-engine-state.js +30 -0
- package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
- package/engine/dispatch-store.js +2 -7
- package/engine/dispatch.js +39 -44
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +279 -354
- package/engine/live-checkout.js +193 -149
- package/engine/llm.js +1 -1
- package/engine/logs-store.js +2 -2
- package/engine/managed-spawn.js +2 -23
- package/engine/meeting.js +6 -6
- package/engine/metrics-store.js +2 -2
- package/engine/note-link-backfill.js +6 -11
- package/engine/pipeline.js +18 -36
- package/engine/playbook.js +13 -16
- package/engine/prd-store.js +73 -54
- package/engine/preflight.js +2 -5
- package/engine/projects.js +15 -62
- package/engine/pull-requests-store.js +0 -17
- package/engine/qa-runbooks.js +2 -2
- package/engine/qa-runs.js +1 -8
- package/engine/qa-sessions.js +41 -64
- package/engine/queries.js +120 -219
- package/engine/routing.js +4 -6
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +268 -699
- package/engine/small-state-store.js +89 -10
- package/engine/state-operations.js +16 -4
- package/engine/stdio-timestamps.js +1 -1
- package/engine/timeout.js +5 -12
- package/engine/watch-actions.js +20 -22
- package/engine/watches-store.js +1 -1
- package/engine/watches.js +6 -10
- package/engine/work-item-validation.js +52 -0
- package/engine/work-items-store.js +127 -29
- package/engine/worktree-gc.js +2 -2
- package/engine/worktree-pool.js +8 -18
- package/engine.js +197 -358
- package/minions.js +2 -2
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -2
- package/playbooks/shared-rules.md +3 -3
- package/playbooks/templates/followup-dispatch.md +1 -1
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +9 -9
package/engine/live-checkout.js
CHANGED
|
@@ -110,9 +110,11 @@
|
|
|
110
110
|
* MOSTLY PURE — does NOT call completeDispatch. It does NOT mutate the tree or
|
|
111
111
|
* write inbox alerts on any default path. The ONE opt-in exception is auto-reset
|
|
112
112
|
* (W-mqvejug6000eeb20): when `liveCheckoutAutoReset` is enabled AND the tree is
|
|
113
|
-
* dirty, it runs `git
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* dirty, it runs `git reset --hard HEAD` + `git clean -fd` and writes a single
|
|
114
|
+
* `live-checkout-autoreset-<wiId>` inbox note. This preserves the current branch
|
|
115
|
+
* and committed history while explicitly discarding tracked and untracked WIP.
|
|
116
|
+
* Both destructive git ops and the note writer are injectable (`_git`,
|
|
117
|
+
* `_writeInboxNote`) and the
|
|
116
118
|
* decision is injectable (`autoReset` / `_resolveAutoReset`), so unit tests stay
|
|
117
119
|
* filesystem-free. Default (auto-reset off) keeps the helper free of side
|
|
118
120
|
* effects — the dirty/blocked translation belongs in spawnAgent (engine.js).
|
|
@@ -568,6 +570,63 @@ async function _maybeAutoFreshenLocalMain(opts = {}) {
|
|
|
568
570
|
return { freshened: true, before: beforeSha, after: afterSha, behind };
|
|
569
571
|
}
|
|
570
572
|
|
|
573
|
+
async function _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists }) {
|
|
574
|
+
let gitDir;
|
|
575
|
+
try {
|
|
576
|
+
const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
|
|
577
|
+
const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
|
|
578
|
+
if (!gitDirStr) throw new Error('git rev-parse --git-dir returned empty output');
|
|
579
|
+
gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
throw new Error('prepareLiveCheckout: could not resolve git dir for ' + localPath + ' — ' + (e && e.message));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const midOperationSentinels = [
|
|
585
|
+
{ name: 'MERGE_HEAD', op: 'merge' },
|
|
586
|
+
{ name: 'rebase-merge', op: 'rebase' },
|
|
587
|
+
{ name: 'rebase-apply', op: 'rebase' },
|
|
588
|
+
{ name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
|
|
589
|
+
{ name: 'REVERT_HEAD', op: 'revert' },
|
|
590
|
+
{ name: 'BISECT_LOG', op: 'bisect' },
|
|
591
|
+
];
|
|
592
|
+
for (const sentinel of midOperationSentinels) {
|
|
593
|
+
const sentinelPath = path.join(gitDir, sentinel.name);
|
|
594
|
+
if (exists(sentinelPath)) {
|
|
595
|
+
return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
|
|
601
|
+
} catch (symErr) {
|
|
602
|
+
if (!symErr || symErr.code !== 1) throw symErr;
|
|
603
|
+
let sha = '';
|
|
604
|
+
try {
|
|
605
|
+
const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
606
|
+
sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
|
|
607
|
+
} catch {
|
|
608
|
+
// Best-effort diagnostic only.
|
|
609
|
+
}
|
|
610
|
+
return { ok: false, reason: 'detached-head', sha };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let originalRef = '';
|
|
614
|
+
let originalRefType = 'branch';
|
|
615
|
+
try {
|
|
616
|
+
const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
|
|
617
|
+
originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
|
|
618
|
+
} catch {
|
|
619
|
+
originalRefType = 'detached';
|
|
620
|
+
try {
|
|
621
|
+
const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
622
|
+
originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
|
|
623
|
+
} catch {
|
|
624
|
+
originalRef = '';
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return { ok: true, originalRef, originalRefType };
|
|
628
|
+
}
|
|
629
|
+
|
|
571
630
|
async function prepareLiveCheckout(opts = {}) {
|
|
572
631
|
const {
|
|
573
632
|
localPath,
|
|
@@ -655,6 +714,19 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
655
714
|
// currently-running git process is never removed out from under it.
|
|
656
715
|
shared.removeStaleIndexLock(localPath, { log: (level, msg) => logFn(msg, level) });
|
|
657
716
|
|
|
717
|
+
let originalRef = '';
|
|
718
|
+
let originalRefType = 'branch';
|
|
719
|
+
let headInspected = false;
|
|
720
|
+
const ensureHeadInspected = async () => {
|
|
721
|
+
if (headInspected) return null;
|
|
722
|
+
const state = await _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists });
|
|
723
|
+
if (!state.ok) return state;
|
|
724
|
+
originalRef = state.originalRef;
|
|
725
|
+
originalRefType = state.originalRefType;
|
|
726
|
+
headInspected = true;
|
|
727
|
+
return null;
|
|
728
|
+
};
|
|
729
|
+
|
|
658
730
|
// ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
|
|
659
731
|
// Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
|
|
660
732
|
// report all un-hydrated virtual files as modified — this is normal GVFS
|
|
@@ -726,6 +798,8 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
726
798
|
if (dirtyFiles.length > 0) {
|
|
727
799
|
const strayBranch = _extractBranchFromPorcelainHeader(branchInfo);
|
|
728
800
|
if (strayBranch && strayBranch !== branchName && _looksLikeAgentBranch(strayBranch)) {
|
|
801
|
+
const blocked = await ensureHeadInspected();
|
|
802
|
+
if (blocked) return blocked;
|
|
729
803
|
const commitRes = await _commitAgentWipWithRetry(
|
|
730
804
|
git, baseOpts,
|
|
731
805
|
`minions: auto-save orphaned WIP from ${strayBranch} (dispatch ${dispatchId || '(unknown)'})`,
|
|
@@ -737,8 +811,19 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
737
811
|
`, switching back to '${mainRef}'`, 'info');
|
|
738
812
|
try {
|
|
739
813
|
await git(['checkout', mainRef], baseOpts);
|
|
814
|
+
originalRef = mainRef;
|
|
815
|
+
originalRefType = 'branch';
|
|
740
816
|
} catch (backErr) {
|
|
741
817
|
logFn(`[live-checkout] could not switch back to '${mainRef}' after self-healing '${strayBranch}': ${backErr && backErr.message}`, 'warn');
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
reason: 'wrong-base',
|
|
821
|
+
headBranch: strayBranch,
|
|
822
|
+
expectedBase: mainRef,
|
|
823
|
+
switchError: String((backErr && backErr.message) || '').slice(0, 500),
|
|
824
|
+
originalRef,
|
|
825
|
+
originalRefType,
|
|
826
|
+
};
|
|
742
827
|
}
|
|
743
828
|
// Re-run the dirty check. Either the tree is now clean (self-heal
|
|
744
829
|
// succeeded end-to-end) or something new appeared (rare) — either
|
|
@@ -751,13 +836,9 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
751
836
|
}
|
|
752
837
|
}
|
|
753
838
|
if (dirtyFiles.length > 0) {
|
|
754
|
-
// W-mqvejug6000eeb20 — opt-in
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// passed an explicit `autoReset` boolean, or the config-based resolver says
|
|
758
|
-
// so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
|
|
759
|
-
// origin/<branch>` and continue. This is destructive (the operator’s
|
|
760
|
-
// uncommitted work is gone), which is why it is strictly opt-in.
|
|
839
|
+
// W-mqvejug6000eeb20 — opt-in destructive recovery. It may discard
|
|
840
|
+
// tracked and untracked working-tree changes, but must never repoint the
|
|
841
|
+
// operator's current branch or discard committed history.
|
|
761
842
|
let wantAutoReset = false;
|
|
762
843
|
if (typeof autoReset === 'boolean') {
|
|
763
844
|
wantAutoReset = autoReset;
|
|
@@ -769,176 +850,126 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
769
850
|
return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
|
|
770
851
|
}
|
|
771
852
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
// branch) keep resetting to `origin/<branchName>` exactly as before.
|
|
782
|
-
let resetOk = true;
|
|
783
|
-
let resetTarget = `origin/${branchName}`;
|
|
853
|
+
const blocked = await ensureHeadInspected();
|
|
854
|
+
if (blocked) return blocked;
|
|
855
|
+
const autoResetAttempt = {
|
|
856
|
+
resetSucceeded: false,
|
|
857
|
+
cleanAttempted: false,
|
|
858
|
+
cleanSucceeded: false,
|
|
859
|
+
recheckSucceeded: false,
|
|
860
|
+
errors: [],
|
|
861
|
+
};
|
|
784
862
|
try {
|
|
785
|
-
await git(['fetch', 'origin'], baseOpts);
|
|
786
|
-
try {
|
|
787
|
-
await git(['rev-parse', '--verify', resetTarget], baseOpts);
|
|
788
|
-
} catch {
|
|
789
|
-
resetTarget = `origin/${mainRef}`;
|
|
790
|
-
if (typeof log === 'function') {
|
|
791
|
-
log(`live-checkout auto-reset: origin/${branchName} does not exist (never pushed) — falling back to '${resetTarget}'`);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
863
|
if (typeof log === 'function') {
|
|
795
|
-
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s)
|
|
864
|
+
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) without changing the current branch or HEAD`);
|
|
796
865
|
}
|
|
797
|
-
await git(['reset', '--hard',
|
|
866
|
+
await git(['reset', '--hard', 'HEAD'], baseOpts);
|
|
867
|
+
autoResetAttempt.resetSucceeded = true;
|
|
798
868
|
} catch (e) {
|
|
799
|
-
|
|
869
|
+
const error = e && e.message ? e.message : String(e);
|
|
870
|
+
autoResetAttempt.errors.push(`reset --hard HEAD: ${error}`);
|
|
800
871
|
if (typeof log === 'function') {
|
|
801
|
-
log(`live-checkout auto-reset FAILED (
|
|
872
|
+
log(`live-checkout auto-reset FAILED (reset --hard HEAD): ${error}`);
|
|
802
873
|
}
|
|
803
874
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
// otherwise (reset failed, or something is still dirty) we fall back to the
|
|
807
|
-
// safe dirty refusal so we never dispatch onto an unexpected tree.
|
|
808
|
-
let stillDirty = dirtyFiles;
|
|
809
|
-
if (resetOk) {
|
|
875
|
+
if (autoResetAttempt.resetSucceeded) {
|
|
876
|
+
autoResetAttempt.cleanAttempted = true;
|
|
810
877
|
try {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
stillDirty = recheckStr
|
|
814
|
-
.split(/\r?\n/)
|
|
815
|
-
.map((line) => line.replace(/\s+$/, ''))
|
|
816
|
-
.filter((line) => line.length > 0 && !line.startsWith('## '));
|
|
878
|
+
await git(['clean', '-fd'], baseOpts);
|
|
879
|
+
autoResetAttempt.cleanSucceeded = true;
|
|
817
880
|
} catch (e) {
|
|
818
|
-
|
|
881
|
+
const error = e && e.message ? e.message : String(e);
|
|
882
|
+
autoResetAttempt.errors.push(`clean -fd: ${error}`);
|
|
819
883
|
if (typeof log === 'function') {
|
|
820
|
-
log(`live-checkout auto-reset
|
|
884
|
+
log(`live-checkout auto-reset FAILED (clean -fd): ${error}`);
|
|
821
885
|
}
|
|
822
886
|
}
|
|
823
887
|
}
|
|
824
888
|
|
|
825
|
-
|
|
826
|
-
|
|
889
|
+
// A failed git command may still have changed part of the tree. Always
|
|
890
|
+
// re-run porcelain after the destructive attempt so both the refusal and
|
|
891
|
+
// audit note report what actually remains.
|
|
892
|
+
let stillDirty = dirtyFiles;
|
|
893
|
+
try {
|
|
894
|
+
const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
895
|
+
const rechecked = parsePorcelain(recheckRaw);
|
|
896
|
+
stillDirty = rechecked.dirtyFiles;
|
|
897
|
+
branchInfo = rechecked.branchInfo || branchInfo;
|
|
898
|
+
autoResetAttempt.recheckSucceeded = true;
|
|
899
|
+
} catch (e) {
|
|
900
|
+
const error = e && e.message ? e.message : String(e);
|
|
901
|
+
autoResetAttempt.errors.push(`status re-check: ${error}`);
|
|
902
|
+
if (typeof log === 'function') {
|
|
903
|
+
log(`live-checkout auto-reset re-check FAILED: ${error}`);
|
|
904
|
+
}
|
|
827
905
|
}
|
|
828
906
|
|
|
829
|
-
|
|
830
|
-
//
|
|
907
|
+
const recoveryVerified = autoResetAttempt.recheckSucceeded && stillDirty.length === 0;
|
|
908
|
+
// Audit every destructive attempt, including partial failures. A git
|
|
909
|
+
// command can mutate some paths before returning an error.
|
|
831
910
|
try {
|
|
832
911
|
const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
|
|
833
|
-
const slug = `live-checkout-autoreset-${
|
|
912
|
+
const slug = `live-checkout-autoreset-${shared.uid()}-${dispatchId || wiId || 'unknown'}`;
|
|
913
|
+
autoResetAttempt.auditSlug = slug;
|
|
914
|
+
const resetOutcome = autoResetAttempt.resetSucceeded ? 'succeeded' : 'FAILED';
|
|
915
|
+
let cleanOutcome = 'not run because reset failed';
|
|
916
|
+
if (autoResetAttempt.cleanAttempted) {
|
|
917
|
+
cleanOutcome = autoResetAttempt.cleanSucceeded ? 'succeeded' : 'FAILED or partially applied';
|
|
918
|
+
}
|
|
919
|
+
let verifyOutcome = 'FAILED — remaining tree state is unknown';
|
|
920
|
+
if (autoResetAttempt.recheckSucceeded) {
|
|
921
|
+
verifyOutcome = stillDirty.length === 0 ? 'clean' : `${stillDirty.length} dirty path(s) remain`;
|
|
922
|
+
}
|
|
834
923
|
const body = [
|
|
835
|
-
`# Live-checkout auto-reset
|
|
924
|
+
`# Live-checkout auto-reset ${recoveryVerified ? 'completed' : 'incomplete'} before '${branchName}'`,
|
|
925
|
+
'',
|
|
926
|
+
`The live-checkout tree at \`${localPath}\` was dirty and \`liveCheckoutAutoReset\``,
|
|
927
|
+
'attempted destructive WIP cleanup. The current branch and committed history were preserved.',
|
|
928
|
+
'',
|
|
929
|
+
'## Outcome',
|
|
836
930
|
'',
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
931
|
+
`- \`git reset --hard HEAD\`: ${resetOutcome}`,
|
|
932
|
+
`- \`git clean -fd\`: ${cleanOutcome}`,
|
|
933
|
+
`- post-cleanup status check: ${verifyOutcome}`,
|
|
934
|
+
...(autoResetAttempt.errors.length > 0
|
|
935
|
+
? ['', 'Errors:', ...autoResetAttempt.errors.map(error => `- ${error}`)]
|
|
936
|
+
: []),
|
|
937
|
+
'',
|
|
938
|
+
'## Changes present before the attempt',
|
|
841
939
|
'',
|
|
842
940
|
'```',
|
|
843
941
|
...dirtyFiles,
|
|
844
942
|
'```',
|
|
943
|
+
...(autoResetAttempt.recheckSucceeded
|
|
944
|
+
? [
|
|
945
|
+
'',
|
|
946
|
+
'## Changes remaining after the attempt',
|
|
947
|
+
'',
|
|
948
|
+
'```',
|
|
949
|
+
...(stillDirty.length > 0 ? stillDirty : ['(clean)']),
|
|
950
|
+
'```',
|
|
951
|
+
]
|
|
952
|
+
: []),
|
|
845
953
|
].join('\n');
|
|
846
954
|
writeNote(slug, body);
|
|
847
955
|
} catch { /* best-effort audit note */ }
|
|
956
|
+
|
|
957
|
+
if (!recoveryVerified) {
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
reason: 'dirty',
|
|
961
|
+
dirtyFiles: stillDirty,
|
|
962
|
+
branchInfo,
|
|
963
|
+
autoResetAttempt,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
848
966
|
// Fall through — tree is now clean, continue with normal preparation.
|
|
849
967
|
}
|
|
850
968
|
}
|
|
851
969
|
}
|
|
852
970
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// Resolve the git dir via rev-parse (NOT localPath/.git) so submodule /
|
|
856
|
-
// gitdir-file / repo-managed trees resolve correctly. A FAILURE here is a
|
|
857
|
-
// real git-health problem (and on the very submodule / gitdir-file /
|
|
858
|
-
// repo-managed trees live mode targets, <localPath>/.git is NOT a directory,
|
|
859
|
-
// so the old fabricated-path fallback made every sentinel probe miss and
|
|
860
|
-
// silently skipped mid-operation detection). Throw so the caller classifies
|
|
861
|
-
// it as a retryable LIVE_CHECKOUT_FAILED instead of barging into a branch
|
|
862
|
-
// switch on top of a half-finished operation we couldn't see.
|
|
863
|
-
let gitDir;
|
|
864
|
-
try {
|
|
865
|
-
const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
|
|
866
|
-
const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
|
|
867
|
-
if (!gitDirStr) {
|
|
868
|
-
throw new Error('git rev-parse --git-dir returned empty output');
|
|
869
|
-
}
|
|
870
|
-
gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
|
|
871
|
-
} catch (e) {
|
|
872
|
-
throw new Error('prepareLiveCheckout: could not resolve git dir for ' + localPath + ' — ' + (e && e.message));
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Probe sentinel paths under the resolved git dir. First hit wins; map each
|
|
876
|
-
// to its in-progress operation. rebase-merge/ (interactive/merge rebase) and
|
|
877
|
-
// rebase-apply/ (am-based rebase) both mean a rebase is underway. BISECT_LOG
|
|
878
|
-
// covers an in-progress `git bisect` (bisect state is HEAD-keyed, so a branch
|
|
879
|
-
// switch would silently destroy it — the doc + header imply bisect coverage).
|
|
880
|
-
const MID_OP_SENTINELS = [
|
|
881
|
-
{ name: 'MERGE_HEAD', op: 'merge' },
|
|
882
|
-
{ name: 'rebase-merge', op: 'rebase' },
|
|
883
|
-
{ name: 'rebase-apply', op: 'rebase' },
|
|
884
|
-
{ name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
|
|
885
|
-
{ name: 'REVERT_HEAD', op: 'revert' },
|
|
886
|
-
{ name: 'BISECT_LOG', op: 'bisect' },
|
|
887
|
-
];
|
|
888
|
-
for (const sentinel of MID_OP_SENTINELS) {
|
|
889
|
-
const sentinelPath = path.join(gitDir, sentinel.name);
|
|
890
|
-
if (exists(sentinelPath)) {
|
|
891
|
-
return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// Detached HEAD: `git symbolic-ref -q HEAD` exits 1 when HEAD does not point
|
|
896
|
-
// at a branch. Branching off a detached HEAD would strand the operator's
|
|
897
|
-
// anonymous commits, so refuse. But distinguish a REAL detachment (git ran
|
|
898
|
-
// and returned exit code 1) from a TRANSIENT failure (spawn error / timeout /
|
|
899
|
-
// ENOENT) — the latter is not proof of detachment and must NOT permanently
|
|
900
|
-
// refuse an on-a-branch tree; rethrow so it becomes a retryable
|
|
901
|
-
// LIVE_CHECKOUT_FAILED instead.
|
|
902
|
-
let detached = false;
|
|
903
|
-
try {
|
|
904
|
-
await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
|
|
905
|
-
} catch (symErr) {
|
|
906
|
-
if (symErr && symErr.code === 1) {
|
|
907
|
-
detached = true; // git ran, HEAD is not a symbolic ref → genuinely detached
|
|
908
|
-
} else {
|
|
909
|
-
throw symErr; // spawn failure / timeout / other → transient, not detachment
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
if (detached) {
|
|
913
|
-
let sha = '';
|
|
914
|
-
try {
|
|
915
|
-
const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
916
|
-
sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
|
|
917
|
-
} catch {
|
|
918
|
-
// best-effort sha; leave empty if even rev-parse fails
|
|
919
|
-
}
|
|
920
|
-
return { ok: false, reason: 'detached-head', sha };
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// ── Step 3: original-ref capture (P-b2e8d4a6). ────────────────────────
|
|
924
|
-
// Capture the operator's starting ref BEFORE any checkout so the
|
|
925
|
-
// dispatch-end auto-restore can put the tree back. Prefer the symbolic
|
|
926
|
-
// branch name; fall back to the raw HEAD sha when symbolic-ref fails.
|
|
927
|
-
let originalRef = '';
|
|
928
|
-
let originalRefType = 'branch';
|
|
929
|
-
try {
|
|
930
|
-
const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
|
|
931
|
-
originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
|
|
932
|
-
originalRefType = 'branch';
|
|
933
|
-
} catch {
|
|
934
|
-
originalRefType = 'detached';
|
|
935
|
-
try {
|
|
936
|
-
const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
937
|
-
originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
|
|
938
|
-
} catch {
|
|
939
|
-
originalRef = '';
|
|
940
|
-
}
|
|
941
|
-
}
|
|
971
|
+
const blocked = await ensureHeadInspected();
|
|
972
|
+
if (blocked) return blocked;
|
|
942
973
|
|
|
943
974
|
// ── Step 4: branch resolution + checkout. ─────────────────────────────
|
|
944
975
|
// NO `git fetch` (issue #226): the operator's existing HEAD is the
|
|
@@ -1195,6 +1226,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
1195
1226
|
}
|
|
1196
1227
|
logFn('info',
|
|
1197
1228
|
`live-checkout: HEAD was on '${headBranch}' (not base '${mainRef}') — switched to local '${mainRef}' before forking '${branchName}' (no fetch, issue #226 preserved)`);
|
|
1229
|
+
curBranch = mainRef;
|
|
1198
1230
|
// ── originalRef correction (W-mrb6an8300058fd8). ─────────────────────
|
|
1199
1231
|
// originalRef/originalRefType were captured at Step 3 from the STALE
|
|
1200
1232
|
// headBranch, BEFORE this self-heal ran. The tree has now been switched
|
|
@@ -1883,6 +1915,8 @@ async function performLiveCheckoutAutoStash(opts = {}) {
|
|
|
1883
1915
|
* or — W-mrawgw4q000a6bff — to an explicit `liveCheckoutAutoReset` fallback
|
|
1884
1916
|
* when that policy also resolves true; see engine.js spawnAgent).
|
|
1885
1917
|
* - `{ outcome:'stashed', liveResult }` — stashed + re-preflighted; proceed.
|
|
1918
|
+
* - `{ outcome:'blocked', liveResult }` — mutation preflight found an
|
|
1919
|
+
* in-progress repository operation or detached HEAD; caller must refuse.
|
|
1886
1920
|
* - `{ outcome:'threw', error }` — the re-preflight threw; caller must
|
|
1887
1921
|
* fail the dispatch as LIVE_CHECKOUT_FAILED (engine-specific completion).
|
|
1888
1922
|
*
|
|
@@ -1903,7 +1937,7 @@ async function performLiveCheckoutAutoStash(opts = {}) {
|
|
|
1903
1937
|
* tree if the ff fails — so it was deliberately left out.
|
|
1904
1938
|
*
|
|
1905
1939
|
* @param {object} opts
|
|
1906
|
-
* @returns {Promise<{outcome:'unchanged'|'stashed'|'threw', liveResult?:object, error?:Error}>}
|
|
1940
|
+
* @returns {Promise<{outcome:'unchanged'|'blocked'|'stashed'|'threw', liveResult?:object, error?:Error}>}
|
|
1907
1941
|
*/
|
|
1908
1942
|
async function applyLiveCheckoutAutoStash(opts = {}) {
|
|
1909
1943
|
const {
|
|
@@ -1911,12 +1945,22 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
|
|
|
1911
1945
|
gitOpts, dispatchId, wiId, log,
|
|
1912
1946
|
clearDirtyStamp, writeStashNote,
|
|
1913
1947
|
_git, // private injection for testing — forwarded to performLiveCheckoutAutoStash + the re-preflight prepareLiveCheckout call
|
|
1948
|
+
_exists,
|
|
1914
1949
|
} = opts;
|
|
1915
1950
|
const logFn = (typeof log === 'function') ? log : () => {};
|
|
1916
1951
|
const reason = (liveResult && liveResult.ok === false) ? liveResult.reason : null;
|
|
1917
1952
|
if (reason !== 'dirty' || !resolveLiveCheckoutAutoStash({ project, engine })) {
|
|
1918
1953
|
return { outcome: 'unchanged', liveResult };
|
|
1919
1954
|
}
|
|
1955
|
+
try {
|
|
1956
|
+
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
1957
|
+
const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
|
|
1958
|
+
const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
|
|
1959
|
+
const state = await _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists });
|
|
1960
|
+
if (!state.ok) return { outcome: 'blocked', liveResult: state };
|
|
1961
|
+
} catch (preflightErr) {
|
|
1962
|
+
return { outcome: 'threw', error: preflightErr };
|
|
1963
|
+
}
|
|
1920
1964
|
let stashResult;
|
|
1921
1965
|
try {
|
|
1922
1966
|
stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log, _git });
|
package/engine/llm.js
CHANGED
|
@@ -33,7 +33,7 @@ const EXIT_DRAIN_FALLBACK_MS = 100;
|
|
|
33
33
|
//
|
|
34
34
|
// Updates accumulate in an in-memory buffer and flush every
|
|
35
35
|
// metricsFlushIntervalMs (default 10s). Replaces the per-call mutateJsonFileLocked
|
|
36
|
-
// that was
|
|
36
|
+
// that was serializing the LLM hot path
|
|
37
37
|
// on every call — defeating the dashboard fast-state mtime-based early-exit.
|
|
38
38
|
|
|
39
39
|
let _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
|
package/engine/logs-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// engine/logs-store.js — SQL-backed
|
|
1
|
+
// engine/logs-store.js — SQL-backed engine logs.
|
|
2
2
|
//
|
|
3
3
|
// Logs differ from work_items / pull_requests / dispatches: they are
|
|
4
4
|
// pure append-only with a tail-cap, so the store exposes a different
|
|
@@ -168,7 +168,7 @@ function readRecentLogs(n, opts) {
|
|
|
168
168
|
return out;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// Convenience:
|
|
171
|
+
// Convenience: return the latest N entries in chronological order.
|
|
172
172
|
// — chronological order (oldest first). Inverts the readRecentLogs DESC
|
|
173
173
|
// output so existing callers can drop in without re-sorting.
|
|
174
174
|
function readRecentLogsChronological(n, opts) {
|
package/engine/managed-spawn.js
CHANGED
|
@@ -28,7 +28,6 @@ const queries = require('./queries');
|
|
|
28
28
|
const { log, ENGINE_DEFAULTS } = shared;
|
|
29
29
|
|
|
30
30
|
const MANAGED_SPAWN_FILENAME = 'managed-spawn.json';
|
|
31
|
-
const MANAGED_PROCESSES_STATE_FILE = 'managed-processes.json';
|
|
32
31
|
const MANAGED_LOGS_DIR = 'managed-logs';
|
|
33
32
|
const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
|
|
34
33
|
|
|
@@ -760,7 +759,7 @@ function buildManagedSpawnHint(opts) {
|
|
|
760
759
|
// spawnDashboard detached-spawn pattern.
|
|
761
760
|
// Returns {pid, started_at, log_path}.
|
|
762
761
|
// 3. recordManagedSpec(spec, → writes one entry to
|
|
763
|
-
// runtime, ctx)
|
|
762
|
+
// runtime, ctx) SQL managed-process state via
|
|
764
763
|
// mutateJsonFileLocked. Replaces any
|
|
765
764
|
// existing entry with the same name
|
|
766
765
|
// (idempotent under retry).
|
|
@@ -777,16 +776,6 @@ function buildManagedSpawnHint(opts) {
|
|
|
777
776
|
// (playbook injection) + items 3/7
|
|
778
777
|
// (sweep + boot reconcile).
|
|
779
778
|
//
|
|
780
|
-
// All state writes go through mutateJsonFileLocked per the repo convention
|
|
781
|
-
// ('Key conventions' in copilot-instructions.md). Callbacks stay synchronous
|
|
782
|
-
// and fast — no kill / no spawn inside the lock callback. Healthcheck loops,
|
|
783
|
-
// dispatch ERROR gating on healthcheck failure, per-tick sweep, boot
|
|
784
|
-
// reconcile, and dashboard endpoints are deferred to items 3/4/5/7.
|
|
785
|
-
|
|
786
|
-
function _getStatePath() {
|
|
787
|
-
return path.join(shared.MINIONS_DIR, 'engine', MANAGED_PROCESSES_STATE_FILE);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
779
|
function _getLogsDir() {
|
|
791
780
|
return path.join(shared.MINIONS_DIR, 'engine', MANAGED_LOGS_DIR);
|
|
792
781
|
}
|
|
@@ -906,7 +895,6 @@ function _toStateRecord(spec, runtime, ctx) {
|
|
|
906
895
|
|
|
907
896
|
function recordManagedSpec(spec, runtime, ctx) {
|
|
908
897
|
if (!spec || !spec.name) throw new Error('recordManagedSpec: spec.name required');
|
|
909
|
-
const statePath = _getStatePath();
|
|
910
898
|
shared.mutateManagedProcesses((data) => {
|
|
911
899
|
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
|
|
912
900
|
data = _initialStateShape();
|
|
@@ -921,7 +909,6 @@ function recordManagedSpec(spec, runtime, ctx) {
|
|
|
921
909
|
|
|
922
910
|
function recordManagedBatch(items, ctx) {
|
|
923
911
|
if (!Array.isArray(items) || items.length === 0) return;
|
|
924
|
-
const statePath = _getStatePath();
|
|
925
912
|
shared.mutateManagedProcesses((data) => {
|
|
926
913
|
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
|
|
927
914
|
data = _initialStateShape();
|
|
@@ -942,7 +929,6 @@ function recordManagedBatch(items, ctx) {
|
|
|
942
929
|
function removeManagedSpec(name) {
|
|
943
930
|
if (typeof name !== 'string' || name.length === 0) return;
|
|
944
931
|
let killPid = null;
|
|
945
|
-
const statePath = _getStatePath();
|
|
946
932
|
shared.mutateManagedProcesses((data) => {
|
|
947
933
|
if (!data || !Array.isArray(data.specs)) return data;
|
|
948
934
|
const idx = data.specs.findIndex(s => s && s.name === name);
|
|
@@ -968,8 +954,7 @@ function removeManagedSpec(name) {
|
|
|
968
954
|
|
|
969
955
|
function listManagedSpecs(opts) {
|
|
970
956
|
opts = opts || {};
|
|
971
|
-
const
|
|
972
|
-
const parsed = shared.safeJsonObj(statePath);
|
|
957
|
+
const parsed = require('./small-state-store').readManagedProcesses();
|
|
973
958
|
const specs = (parsed && Array.isArray(parsed.specs)) ? parsed.specs : [];
|
|
974
959
|
if (opts.project) return specs.filter(s => s && s.owner_project === opts.project);
|
|
975
960
|
return specs.slice();
|
|
@@ -1090,7 +1075,6 @@ async function runHealthcheck(spec) {
|
|
|
1090
1075
|
// waitForFirstHealth on the first pass. Callers MUST be outside any other
|
|
1091
1076
|
// lock — this acquires its own.
|
|
1092
1077
|
function _markHealthy(name, now) {
|
|
1093
|
-
const statePath = _getStatePath();
|
|
1094
1078
|
shared.mutateManagedProcesses((data) => {
|
|
1095
1079
|
if (!data || !Array.isArray(data.specs)) return data;
|
|
1096
1080
|
const rec = data.specs.find(s => s && s.name === name);
|
|
@@ -1404,7 +1388,6 @@ function _runManagedReconcile(opts) {
|
|
|
1404
1388
|
rotatedLogs: 0,
|
|
1405
1389
|
malformed: 0,
|
|
1406
1390
|
};
|
|
1407
|
-
const statePath = _getStatePath();
|
|
1408
1391
|
const ttlPidsToKill = [];
|
|
1409
1392
|
const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
|
|
1410
1393
|
shared.mutateManagedProcesses((data) => {
|
|
@@ -1459,7 +1442,6 @@ function sweepManagedSpawn(opts) {
|
|
|
1459
1442
|
// rely on the per-spec loop to refresh post-success, or item 7 boot reconcile
|
|
1460
1443
|
// to (re)establish initial truth on engine restart).
|
|
1461
1444
|
function _markBootProbe(name, result) {
|
|
1462
|
-
const statePath = _getStatePath();
|
|
1463
1445
|
shared.mutateManagedProcesses((data) => {
|
|
1464
1446
|
if (!data || !Array.isArray(data.specs)) return data;
|
|
1465
1447
|
const rec = data.specs.find(s => s && s.name === name);
|
|
@@ -1510,7 +1492,6 @@ function removeManagedSpecsForProject(projectName) {
|
|
|
1510
1492
|
if (typeof projectName !== 'string' || projectName.length === 0) {
|
|
1511
1493
|
return { killed: 0, unlinked: 0, scanned: 0 };
|
|
1512
1494
|
}
|
|
1513
|
-
const statePath = _getStatePath();
|
|
1514
1495
|
let toKill = [];
|
|
1515
1496
|
let logPaths = [];
|
|
1516
1497
|
shared.mutateManagedProcesses((data) => {
|
|
@@ -1546,7 +1527,6 @@ function removeManagedSpecsForProject(projectName) {
|
|
|
1546
1527
|
|
|
1547
1528
|
module.exports = {
|
|
1548
1529
|
MANAGED_SPAWN_FILENAME: MANAGED_SPAWN_FILENAME,
|
|
1549
|
-
MANAGED_PROCESSES_STATE_FILE: MANAGED_PROCESSES_STATE_FILE,
|
|
1550
1530
|
MANAGED_LOGS_DIR: MANAGED_LOGS_DIR,
|
|
1551
1531
|
INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
|
|
1552
1532
|
HEALTHCHECK_TYPES: HEALTHCHECK_TYPES,
|
|
@@ -1561,7 +1541,6 @@ module.exports = {
|
|
|
1561
1541
|
recordManagedBatch: recordManagedBatch,
|
|
1562
1542
|
removeManagedSpec: removeManagedSpec,
|
|
1563
1543
|
listManagedSpecs: listManagedSpecs,
|
|
1564
|
-
getStatePath: _getStatePath,
|
|
1565
1544
|
// Item 3 (P-9c1f47a6): healthcheck implementations + first-pass waiter.
|
|
1566
1545
|
runHealthcheck: runHealthcheck,
|
|
1567
1546
|
waitForFirstHealth: waitForFirstHealth,
|
package/engine/meeting.js
CHANGED
|
@@ -10,6 +10,7 @@ const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT, MEE
|
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { getDispatch, getConfig } = queries;
|
|
12
12
|
const { renderPlaybook } = require('./playbook');
|
|
13
|
+
const dispatchModule = require('./dispatch');
|
|
13
14
|
|
|
14
15
|
/** Patterns that indicate an agent returned no meaningful output */
|
|
15
16
|
const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
|
|
@@ -514,7 +515,7 @@ function discoverMeetingWork(config) {
|
|
|
514
515
|
|
|
515
516
|
// Pre-flight validation: meetings missing required template vars (agenda)
|
|
516
517
|
// would otherwise fail playbook rendering on every tick (~1/min), spamming
|
|
517
|
-
// log
|
|
518
|
+
// the engine log with the same "missing required template variables: agenda"
|
|
518
519
|
// error. Skip them silently here; emit one structured warning per meeting
|
|
519
520
|
// ID per process so the operator still has signal without the spam.
|
|
520
521
|
if (!meeting.agenda || !String(meeting.agenda).trim()) {
|
|
@@ -757,12 +758,11 @@ function addMeetingNote(meetingId, note) {
|
|
|
757
758
|
|
|
758
759
|
function _killMeetingDispatches(meetingId) {
|
|
759
760
|
try {
|
|
760
|
-
const DISPATCH_PATH = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
|
|
761
761
|
const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
|
|
762
762
|
const entriesToStop = [];
|
|
763
763
|
const filesToDelete = [];
|
|
764
764
|
const dispatchDirsToRemove = [];
|
|
765
|
-
|
|
765
|
+
dispatchModule.mutateDispatch((dp) => {
|
|
766
766
|
dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
|
|
767
767
|
dp.active = Array.isArray(dp.active) ? dp.active : [];
|
|
768
768
|
dp.completed = Array.isArray(dp.completed) ? dp.completed : [];
|
|
@@ -828,7 +828,7 @@ function advanceMeetingRound(meetingId) {
|
|
|
828
828
|
if (!existing || existing.status === MEETING_STATUS.COMPLETED || existing.status === MEETING_STATUS.ARCHIVED) return null;
|
|
829
829
|
|
|
830
830
|
// CRITICAL: kill BEFORE acquiring the meeting lock. _killMeetingDispatches
|
|
831
|
-
//
|
|
831
|
+
// mutates dispatch state and shells out to kill processes — never
|
|
832
832
|
// run that under the meeting lock (per CLAUDE.md, lock callbacks must
|
|
833
833
|
// stay fast and never spawn / kill / await).
|
|
834
834
|
_killMeetingDispatches(meetingId);
|
|
@@ -923,13 +923,13 @@ function checkMeetingTimeouts(config) {
|
|
|
923
923
|
// BUG-H11: when a hard timeout will fire, kill in-flight meeting
|
|
924
924
|
// dispatches BEFORE entering the meeting lock so they stop consuming
|
|
925
925
|
// worker slots / LLM tokens immediately rather than draining over the
|
|
926
|
-
// next tens of minutes. _killMeetingDispatches takes the dispatch
|
|
926
|
+
// next tens of minutes. _killMeetingDispatches takes the dispatch
|
|
927
927
|
// lock and shells out to kill processes — per CLAUDE.md and the
|
|
928
928
|
// advanceMeetingRound/endMeeting/deleteMeeting pattern at L834, that
|
|
929
929
|
// MUST run outside the meeting lock. We pre-evaluate eligibility from
|
|
930
930
|
// the snapshot using the same predicate the mutate callback below
|
|
931
931
|
// re-checks under lock; if the timeout has already advanced/cleared,
|
|
932
|
-
// the kill is a no-op
|
|
932
|
+
// the kill is a no-op in dispatch state (no matching meetingId entries).
|
|
933
933
|
const snapshotRoundName = snapshot.status === 'investigating'
|
|
934
934
|
? 'investigate'
|
|
935
935
|
: snapshot.status === 'debating'
|