oh-my-copilot 4.13.2 → 4.13.21
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bridge/cli.cjs +2522 -949
- package/bridge/mcp-server.cjs +97 -9
- package/bridge/runtime-cli.cjs +1256 -133
- package/bridge/team.js +1241 -65
- package/dist/__tests__/auto-slash-aliases.test.js +91 -0
- package/dist/__tests__/auto-slash-aliases.test.js.map +1 -1
- package/dist/__tests__/hud/labels.test.d.ts +2 -0
- package/dist/__tests__/hud/labels.test.d.ts.map +1 -0
- package/dist/__tests__/hud/labels.test.js +158 -0
- package/dist/__tests__/hud/labels.test.js.map +1 -0
- package/dist/__tests__/hud/render-rate-limits-priority.test.js +26 -0
- package/dist/__tests__/hud/render-rate-limits-priority.test.js.map +1 -1
- package/dist/__tests__/hud-marketplace-resolution.test.js +34 -0
- package/dist/__tests__/hud-marketplace-resolution.test.js.map +1 -1
- package/dist/__tests__/installer-hud-skip.test.js +6 -0
- package/dist/__tests__/installer-hud-skip.test.js.map +1 -1
- package/dist/__tests__/installer-omc-reference.test.js +1 -1
- package/dist/__tests__/installer-omc-reference.test.js.map +1 -1
- package/dist/__tests__/mnemosyne/detector.test.js +2 -0
- package/dist/__tests__/mnemosyne/detector.test.js.map +1 -1
- package/dist/__tests__/notepad.test.js +2 -2
- package/dist/__tests__/notepad.test.js.map +1 -1
- package/dist/__tests__/pre-tool-enforcer.test.js +84 -1
- package/dist/__tests__/pre-tool-enforcer.test.js.map +1 -1
- package/dist/__tests__/ralph-prd-mandatory.test.js +11 -1
- package/dist/__tests__/ralph-prd-mandatory.test.js.map +1 -1
- package/dist/__tests__/ralph-prd.test.js +40 -2
- package/dist/__tests__/ralph-prd.test.js.map +1 -1
- package/dist/__tests__/skills.test.js +363 -4
- package/dist/__tests__/skills.test.js.map +1 -1
- package/dist/__tests__/state-root-resolution.test.js +56 -6
- package/dist/__tests__/state-root-resolution.test.js.map +1 -1
- package/dist/autoresearch/__tests__/runtime.test.js +225 -28
- package/dist/autoresearch/__tests__/runtime.test.js.map +1 -1
- package/dist/autoresearch/runtime.d.ts.map +1 -1
- package/dist/autoresearch/runtime.js +9 -5
- package/dist/autoresearch/runtime.js.map +1 -1
- package/dist/cli/autoresearch.d.ts +1 -1
- package/dist/cli/autoresearch.d.ts.map +1 -1
- package/dist/cli/autoresearch.js +4 -4
- package/dist/cli/commands/team.d.ts +1 -0
- package/dist/cli/commands/team.d.ts.map +1 -1
- package/dist/cli/commands/team.js +14 -2
- package/dist/cli/commands/team.js.map +1 -1
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/team.d.ts +6 -0
- package/dist/cli/team.d.ts.map +1 -1
- package/dist/cli/team.js +23 -1
- package/dist/cli/team.js.map +1 -1
- package/dist/config/__tests__/loader.test.js.map +1 -1
- package/dist/features/boulder-state/constants.d.ts +1 -1
- package/dist/features/boulder-state/constants.d.ts.map +1 -1
- package/dist/features/builtin-skills/skills.d.ts.map +1 -1
- package/dist/features/builtin-skills/skills.js +11 -3
- package/dist/features/builtin-skills/skills.js.map +1 -1
- package/dist/features/builtin-skills/types.d.ts +7 -0
- package/dist/features/builtin-skills/types.d.ts.map +1 -1
- package/dist/features/magic-keywords.d.ts.map +1 -1
- package/dist/features/magic-keywords.js +30 -1
- package/dist/features/magic-keywords.js.map +1 -1
- package/dist/features/rate-limit-wait/tmux-detector.d.ts +4 -4
- package/dist/features/rate-limit-wait/tmux-detector.js +9 -9
- package/dist/features/rate-limit-wait/tmux-detector.js.map +1 -1
- package/dist/hooks/__tests__/bridge-routing.test.js +156 -0
- package/dist/hooks/__tests__/bridge-routing.test.js.map +1 -1
- package/dist/hooks/auto-slash-command/constants.d.ts.map +1 -1
- package/dist/hooks/auto-slash-command/constants.js +1 -0
- package/dist/hooks/auto-slash-command/constants.js.map +1 -1
- package/dist/hooks/auto-slash-command/executor.d.ts.map +1 -1
- package/dist/hooks/auto-slash-command/executor.js +29 -0
- package/dist/hooks/auto-slash-command/executor.js.map +1 -1
- package/dist/hooks/bridge.d.ts.map +1 -1
- package/dist/hooks/bridge.js +184 -1
- package/dist/hooks/bridge.js.map +1 -1
- package/dist/hooks/learner/detector.js +1 -1
- package/dist/hooks/mode-registry/__tests__/session-isolation.test.js +16 -0
- package/dist/hooks/mode-registry/__tests__/session-isolation.test.js.map +1 -1
- package/dist/hooks/notepad/index.d.ts +4 -1
- package/dist/hooks/notepad/index.d.ts.map +1 -1
- package/dist/hooks/notepad/index.js +7 -4
- package/dist/hooks/notepad/index.js.map +1 -1
- package/dist/hooks/persistent-mode/__tests__/ralph-verification-flow.test.js +70 -3
- package/dist/hooks/persistent-mode/__tests__/ralph-verification-flow.test.js.map +1 -1
- package/dist/hooks/persistent-mode/index.d.ts.map +1 -1
- package/dist/hooks/persistent-mode/index.js +10 -9
- package/dist/hooks/persistent-mode/index.js.map +1 -1
- package/dist/hooks/persistent-mode/stop-hook-blocking.test.js +64 -9
- package/dist/hooks/persistent-mode/stop-hook-blocking.test.js.map +1 -1
- package/dist/hooks/project-memory/__tests__/integration.test.js +117 -3
- package/dist/hooks/project-memory/__tests__/integration.test.js.map +1 -1
- package/dist/hooks/project-memory/__tests__/storage.test.js +11 -11
- package/dist/hooks/project-memory/__tests__/storage.test.js.map +1 -1
- package/dist/hooks/project-memory/index.d.ts.map +1 -1
- package/dist/hooks/project-memory/index.js +24 -12
- package/dist/hooks/project-memory/index.js.map +1 -1
- package/dist/hooks/ralph/index.d.ts +1 -1
- package/dist/hooks/ralph/index.d.ts.map +1 -1
- package/dist/hooks/ralph/index.js +1 -1
- package/dist/hooks/ralph/index.js.map +1 -1
- package/dist/hooks/ralph/loop.d.ts +6 -6
- package/dist/hooks/ralph/loop.d.ts.map +1 -1
- package/dist/hooks/ralph/loop.js +18 -18
- package/dist/hooks/ralph/loop.js.map +1 -1
- package/dist/hooks/ralph/prd.d.ts +23 -12
- package/dist/hooks/ralph/prd.d.ts.map +1 -1
- package/dist/hooks/ralph/prd.js +88 -42
- package/dist/hooks/ralph/prd.js.map +1 -1
- package/dist/hooks/session-end/__tests__/mode-state-cleanup.test.js +20 -0
- package/dist/hooks/session-end/__tests__/mode-state-cleanup.test.js.map +1 -1
- package/dist/hooks/session-end/index.d.ts +9 -0
- package/dist/hooks/session-end/index.d.ts.map +1 -1
- package/dist/hooks/session-end/index.js +67 -0
- package/dist/hooks/session-end/index.js.map +1 -1
- package/dist/hooks/skill-bridge.cjs +6 -5
- package/dist/hooks/skill-state/index.d.ts.map +1 -1
- package/dist/hooks/skill-state/index.js +1 -0
- package/dist/hooks/skill-state/index.js.map +1 -1
- package/dist/hud/__tests__/git-status.test.js +18 -0
- package/dist/hud/__tests__/git-status.test.js.map +1 -1
- package/dist/hud/elements/background.d.ts +3 -3
- package/dist/hud/elements/background.d.ts.map +1 -1
- package/dist/hud/elements/background.js +5 -4
- package/dist/hud/elements/background.js.map +1 -1
- package/dist/hud/elements/call-counts.d.ts +2 -2
- package/dist/hud/elements/call-counts.d.ts.map +1 -1
- package/dist/hud/elements/call-counts.js +7 -6
- package/dist/hud/elements/call-counts.js.map +1 -1
- package/dist/hud/elements/context.d.ts +3 -3
- package/dist/hud/elements/context.d.ts.map +1 -1
- package/dist/hud/elements/context.js +5 -4
- package/dist/hud/elements/context.js.map +1 -1
- package/dist/hud/elements/git.d.ts +2 -1
- package/dist/hud/elements/git.d.ts.map +1 -1
- package/dist/hud/elements/git.js +7 -6
- package/dist/hud/elements/git.js.map +1 -1
- package/dist/hud/elements/ralph.d.ts +2 -2
- package/dist/hud/elements/ralph.d.ts.map +1 -1
- package/dist/hud/elements/ralph.js +3 -2
- package/dist/hud/elements/ralph.js.map +1 -1
- package/dist/hud/elements/thinking.d.ts +2 -2
- package/dist/hud/elements/thinking.d.ts.map +1 -1
- package/dist/hud/elements/thinking.js +3 -2
- package/dist/hud/elements/thinking.js.map +1 -1
- package/dist/hud/elements/token-usage.d.ts +2 -2
- package/dist/hud/elements/token-usage.d.ts.map +1 -1
- package/dist/hud/elements/token-usage.js +3 -2
- package/dist/hud/elements/token-usage.js.map +1 -1
- package/dist/hud/render.d.ts.map +1 -1
- package/dist/hud/render.js +11 -10
- package/dist/hud/render.js.map +1 -1
- package/dist/hud/state.d.ts.map +1 -1
- package/dist/hud/state.js +18 -1
- package/dist/hud/state.js.map +1 -1
- package/dist/hud/types.d.ts +26 -0
- package/dist/hud/types.d.ts.map +1 -1
- package/dist/hud/types.js +58 -0
- package/dist/hud/types.js.map +1 -1
- package/dist/installer/__tests__/hud-cache-wrapper.test.d.ts +2 -0
- package/dist/installer/__tests__/hud-cache-wrapper.test.d.ts.map +1 -0
- package/dist/installer/__tests__/hud-cache-wrapper.test.js +235 -0
- package/dist/installer/__tests__/hud-cache-wrapper.test.js.map +1 -0
- package/dist/installer/__tests__/standalone-hook-reconcile.test.js +2 -0
- package/dist/installer/__tests__/standalone-hook-reconcile.test.js.map +1 -1
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +19 -7
- package/dist/installer/index.js.map +1 -1
- package/dist/lib/__tests__/worktree-paths.test.js +162 -39
- package/dist/lib/__tests__/worktree-paths.test.js.map +1 -1
- package/dist/lib/worktree-paths.d.ts +87 -9
- package/dist/lib/worktree-paths.d.ts.map +1 -1
- package/dist/lib/worktree-paths.js +209 -27
- package/dist/lib/worktree-paths.js.map +1 -1
- package/dist/team/__tests__/auto-merge-restart.test.d.ts +2 -0
- package/dist/team/__tests__/auto-merge-restart.test.d.ts.map +1 -0
- package/dist/team/__tests__/auto-merge-restart.test.js +128 -0
- package/dist/team/__tests__/auto-merge-restart.test.js.map +1 -0
- package/dist/team/__tests__/auto-merge.perf.test.d.ts +2 -0
- package/dist/team/__tests__/auto-merge.perf.test.d.ts.map +1 -0
- package/dist/team/__tests__/auto-merge.perf.test.js +85 -0
- package/dist/team/__tests__/auto-merge.perf.test.js.map +1 -0
- package/dist/team/__tests__/conflict-mailbox.test.d.ts +2 -0
- package/dist/team/__tests__/conflict-mailbox.test.d.ts.map +1 -0
- package/dist/team/__tests__/conflict-mailbox.test.js +238 -0
- package/dist/team/__tests__/conflict-mailbox.test.js.map +1 -0
- package/dist/team/__tests__/helpers/git-fixture.d.ts +72 -0
- package/dist/team/__tests__/helpers/git-fixture.d.ts.map +1 -0
- package/dist/team/__tests__/helpers/git-fixture.js +252 -0
- package/dist/team/__tests__/helpers/git-fixture.js.map +1 -0
- package/dist/team/__tests__/leader-inbox.test.d.ts +2 -0
- package/dist/team/__tests__/leader-inbox.test.d.ts.map +1 -0
- package/dist/team/__tests__/leader-inbox.test.js +152 -0
- package/dist/team/__tests__/leader-inbox.test.js.map +1 -0
- package/dist/team/__tests__/merge-orchestrator.test.d.ts +2 -0
- package/dist/team/__tests__/merge-orchestrator.test.d.ts.map +1 -0
- package/dist/team/__tests__/merge-orchestrator.test.js +779 -0
- package/dist/team/__tests__/merge-orchestrator.test.js.map +1 -0
- package/dist/team/__tests__/rebase-smoke.test.d.ts +2 -0
- package/dist/team/__tests__/rebase-smoke.test.d.ts.map +1 -0
- package/dist/team/__tests__/rebase-smoke.test.js +272 -0
- package/dist/team/__tests__/rebase-smoke.test.js.map +1 -0
- package/dist/team/__tests__/runtime-cli.test.js +9 -1
- package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
- package/dist/team/__tests__/runtime-v2.dispatch.test.js +150 -1
- package/dist/team/__tests__/runtime-v2.dispatch.test.js.map +1 -1
- package/dist/team/__tests__/teardown-invariant.test.d.ts +2 -0
- package/dist/team/__tests__/teardown-invariant.test.d.ts.map +1 -0
- package/dist/team/__tests__/teardown-invariant.test.js +176 -0
- package/dist/team/__tests__/teardown-invariant.test.js.map +1 -0
- package/dist/team/__tests__/worker-commit-cadence.test.d.ts +2 -0
- package/dist/team/__tests__/worker-commit-cadence.test.d.ts.map +1 -0
- package/dist/team/__tests__/worker-commit-cadence.test.js +315 -0
- package/dist/team/__tests__/worker-commit-cadence.test.js.map +1 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.d.ts +2 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.d.ts.map +1 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.js +182 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.js.map +1 -0
- package/dist/team/conflict-mailbox.d.ts +49 -0
- package/dist/team/conflict-mailbox.d.ts.map +1 -0
- package/dist/team/conflict-mailbox.js +98 -0
- package/dist/team/conflict-mailbox.js.map +1 -0
- package/dist/team/leader-inbox.d.ts +28 -0
- package/dist/team/leader-inbox.d.ts.map +1 -0
- package/dist/team/leader-inbox.js +68 -0
- package/dist/team/leader-inbox.js.map +1 -0
- package/dist/team/merge-coordinator.d.ts +6 -0
- package/dist/team/merge-coordinator.d.ts.map +1 -1
- package/dist/team/merge-coordinator.js +6 -2
- package/dist/team/merge-coordinator.js.map +1 -1
- package/dist/team/merge-orchestrator.d.ts +41 -0
- package/dist/team/merge-orchestrator.d.ts.map +1 -0
- package/dist/team/merge-orchestrator.js +736 -0
- package/dist/team/merge-orchestrator.js.map +1 -0
- package/dist/team/runtime-cli.d.ts +1 -0
- package/dist/team/runtime-cli.d.ts.map +1 -1
- package/dist/team/runtime-cli.js +15 -2
- package/dist/team/runtime-cli.js.map +1 -1
- package/dist/team/runtime-flags.d.ts +5 -0
- package/dist/team/runtime-flags.d.ts.map +1 -0
- package/dist/team/runtime-flags.js +12 -0
- package/dist/team/runtime-flags.js.map +1 -0
- package/dist/team/runtime-v2.d.ts +7 -1
- package/dist/team/runtime-v2.d.ts.map +1 -1
- package/dist/team/runtime-v2.js +185 -8
- package/dist/team/runtime-v2.js.map +1 -1
- package/dist/team/worker-bootstrap.d.ts +4 -0
- package/dist/team/worker-bootstrap.d.ts.map +1 -1
- package/dist/team/worker-bootstrap.js +10 -1
- package/dist/team/worker-bootstrap.js.map +1 -1
- package/dist/team/worker-commit-cadence.d.ts +70 -0
- package/dist/team/worker-commit-cadence.d.ts.map +1 -0
- package/dist/team/worker-commit-cadence.js +271 -0
- package/dist/team/worker-commit-cadence.js.map +1 -0
- package/dist/tools/__tests__/memory-tools.test.js +4 -4
- package/dist/tools/__tests__/memory-tools.test.js.map +1 -1
- package/dist/tools/__tests__/state-tools.test.js +13 -1
- package/dist/tools/__tests__/state-tools.test.js.map +1 -1
- package/docs/GETTING-STARTED.md +415 -0
- package/docs/MIGRATION.md +66 -66
- package/docs/REFERENCE.md +21 -4
- package/docs/company-context-interface.md +5 -16
- package/docs/copilot-instructions.md +1 -1
- package/package.json +1 -1
- package/scripts/lib/hud-cache-wrapper.sh +122 -0
- package/scripts/plugin-setup.mjs +10 -1
- package/scripts/post-tool-verifier.mjs +118 -5
- package/scripts/pre-tool-enforcer.mjs +54 -6
- package/scripts/session-start.mjs +171 -3
- package/skills/AGENTS.md +22 -91
- package/skills/ai-slop-cleaner/SKILL.md +118 -120
- package/skills/ask/SKILL.md +6 -8
- package/skills/autopilot/SKILL.md +1 -28
- package/skills/autoresearch/SKILL.md +5 -5
- package/skills/cancel/SKILL.md +13 -13
- package/skills/cccg/SKILL.md +1 -0
- package/skills/configure-notifications/SKILL.md +80 -215
- package/skills/debug/SKILL.md +3 -2
- package/skills/deep-dive/SKILL.md +20 -9
- package/skills/deep-interview/SKILL.md +32 -4
- package/skills/deepinit/SKILL.md +1 -0
- package/skills/external-context/SKILL.md +2 -1
- package/skills/hud/SKILL.md +22 -52
- package/skills/learner/SKILL.md +90 -57
- package/skills/mcp-setup/SKILL.md +45 -60
- package/skills/omc-doctor/SKILL.md +1 -1
- package/skills/omc-reference/SKILL.md +6 -6
- package/skills/omc-setup/SKILL.md +30 -74
- package/skills/omc-setup/phases/01-install-claude-md.md +97 -0
- package/skills/omc-setup/phases/02-configure.md +25 -40
- package/skills/omc-setup/phases/03-integrations.md +100 -69
- package/skills/omc-setup/phases/04-welcome.md +102 -44
- package/skills/omc-teams/SKILL.md +63 -26
- package/skills/plan/SKILL.md +11 -9
- package/skills/ralph/SKILL.md +42 -41
- package/skills/ralplan/SKILL.md +3 -10
- package/skills/release/SKILL.md +168 -57
- package/skills/remember/SKILL.md +2 -1
- package/skills/sciomc/SKILL.md +12 -11
- package/skills/self-improve/SKILL.md +4 -4
- package/skills/setup/SKILL.md +1 -0
- package/skills/skill/SKILL.md +21 -20
- package/skills/skillify/SKILL.md +34 -3
- package/skills/team/SKILL.md +62 -44
- package/skills/ultraqa/SKILL.md +1 -2
- package/skills/ultrawork/SKILL.md +28 -21
- package/skills/verify/SKILL.md +2 -1
- package/skills/writer-memory/SKILL.md +2 -1
- package/templates/hooks/pre-tool-use.mjs +144 -45
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// src/team/merge-orchestrator.ts
|
|
2
|
+
//
|
|
3
|
+
// Auto-merge / auto-rebase orchestrator for v2 team runtime.
|
|
4
|
+
//
|
|
5
|
+
// Responsibilities:
|
|
6
|
+
// - Watch each registered worker branch HEAD (commit watcher).
|
|
7
|
+
// - On observed SHA change: drive auto-merge from a dedicated "merger" worktree
|
|
8
|
+
// into the leader branch (mutex-serialised, with pre-flight reset).
|
|
9
|
+
// - On clean merge: fan-out auto-rebase to every other registered worker, with
|
|
10
|
+
// the M1 existing-rebase short-circuit, sentinel pause/resume, and M4 dirty
|
|
11
|
+
// audit on resolution.
|
|
12
|
+
// - Persist last-observed SHAs atomically so we can resume across restarts.
|
|
13
|
+
// - On stop: drain pending merges within drainTimeoutMs and emit a teardown
|
|
14
|
+
// audit row + leader-inbox audit message for any worker we couldn't merge.
|
|
15
|
+
//
|
|
16
|
+
// Hardening flags wired in:
|
|
17
|
+
// M1: existing rebase short-circuit (gitdir-aware `rebase-merge`).
|
|
18
|
+
// M3: refuse main/master leader branch.
|
|
19
|
+
// M4: dirty-tree audit on rebase resolution.
|
|
20
|
+
// M5: v2 gate via default-on runtime-v2 flag semantics.
|
|
21
|
+
// M6: persisted SHA state + restart recovery.
|
|
22
|
+
//
|
|
23
|
+
// All git invocations go through `execFileSync` (sync) so vitest can mock the
|
|
24
|
+
// node:child_process module via `vi.mock(...)` without spawning real processes.
|
|
25
|
+
//
|
|
26
|
+
// ## Plan divergences
|
|
27
|
+
//
|
|
28
|
+
// The implementation diverges from the original design plan in three places.
|
|
29
|
+
// These are intentional and known; flagged here so future readers don't try to
|
|
30
|
+
// "fix" them by aligning with the plan:
|
|
31
|
+
//
|
|
32
|
+
// 1. Events are written to a dedicated `orchestrator-events.jsonl` log under
|
|
33
|
+
// `.omcp/state/team/{team}/`, NOT to the shared `events.jsonl` used by the
|
|
34
|
+
// rest of the runtime. This keeps orchestrator-internal state isolated and
|
|
35
|
+
// avoids interleaving with worker/leader event streams that have different
|
|
36
|
+
// consumers and retention rules.
|
|
37
|
+
//
|
|
38
|
+
// 2. The leader inbox is written on conflict (merge or rebase) and on
|
|
39
|
+
// teardown (drain audit) only — NOT on every merge success. Successful
|
|
40
|
+
// merges are recorded in `orchestrator-events.jsonl` and don't need to
|
|
41
|
+
// page the leader. This keeps the leader inbox actionable instead of a
|
|
42
|
+
// noisy log.
|
|
43
|
+
//
|
|
44
|
+
// 3. `extendLeaderBootstrapPrompt` produces a directive that is appended to
|
|
45
|
+
// the leader inbox file at startup; it is NOT injected into a leader-pane
|
|
46
|
+
// spawn prompt because v2 has no leader-pane prompt hook. The v2 leader
|
|
47
|
+
// runs in the parent Claude Code session and discovers the inbox via the
|
|
48
|
+
// file path that gets written into the leader inbox itself.
|
|
49
|
+
//
|
|
50
|
+
// 4. Sentinel forgeability: `.hook-paused` is a plain file in the worktree
|
|
51
|
+
// root that any process inside the worktree can create or remove. This is
|
|
52
|
+
// a deliberate trade-off — workers and the orchestrator both need
|
|
53
|
+
// synchronous, lock-free coordination, and the worktree is already a
|
|
54
|
+
// trust boundary owned by the worker process. A worker that forges or
|
|
55
|
+
// deletes the sentinel is already inside the trust boundary.
|
|
56
|
+
import { execFileSync } from 'node:child_process';
|
|
57
|
+
import { existsSync } from 'node:fs';
|
|
58
|
+
import { mkdir, appendFile } from 'node:fs/promises';
|
|
59
|
+
import { dirname, join } from 'node:path';
|
|
60
|
+
import { atomicWriteJson, ensureDirWithMode, validateResolvedPath } from './fs-utils.js';
|
|
61
|
+
import { isRuntimeV2Enabled } from './runtime-flags.js';
|
|
62
|
+
import { sanitizeName } from './tmux-session.js';
|
|
63
|
+
import { listTeamWorktrees, getWorktreePath, getBranchName } from './git-worktree.js';
|
|
64
|
+
import { checkMergeConflicts, mergeWorkerBranch, validateBranchName } from './merge-coordinator.js';
|
|
65
|
+
import { appendToInbox } from './worker-bootstrap.js';
|
|
66
|
+
import { appendToLeaderInbox, ensureLeaderInbox } from './leader-inbox.js';
|
|
67
|
+
import { formatMergeConflictForLeader, formatRebaseConflictForWorker, } from './conflict-mailbox.js';
|
|
68
|
+
import { pauseHookViaSentinel, resumeHookViaSentinel, } from './worker-commit-cadence.js';
|
|
69
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
70
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 10000;
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Path helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
function mergerWorktreePathFor(repoRoot, teamName) {
|
|
75
|
+
return join(repoRoot, '.omcp', 'team', sanitizeName(teamName), 'merger');
|
|
76
|
+
}
|
|
77
|
+
function persistedStatePath(repoRoot, teamName) {
|
|
78
|
+
return join(repoRoot, '.omcp', 'state', 'team', sanitizeName(teamName), 'auto-merge-state.json');
|
|
79
|
+
}
|
|
80
|
+
function teardownAuditPath(repoRoot, teamName) {
|
|
81
|
+
return join(repoRoot, '.omcp', 'state', 'team', sanitizeName(teamName), 'teardown-audit.jsonl');
|
|
82
|
+
}
|
|
83
|
+
function orchestratorEventLogPath(repoRoot, teamName) {
|
|
84
|
+
return join(repoRoot, '.omcp', 'state', 'team', sanitizeName(teamName), 'orchestrator-events.jsonl');
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Guards (M3, M5)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
function assertLeaderBranchAllowed(leaderBranch) {
|
|
90
|
+
const stripped = leaderBranch.replace(/^refs\/heads\//i, '').toLowerCase();
|
|
91
|
+
if (stripped === 'main' || stripped === 'master') {
|
|
92
|
+
throw new Error('auto-merge refuses main/master leader branch — use a feature branch');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function assertRuntimeV2Gate() {
|
|
96
|
+
if (!isRuntimeV2Enabled()) {
|
|
97
|
+
throw new Error('auto-merge requires runtime v2 (OMC_RUNTIME_V2 is explicitly disabled).');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function appendEvent(repoRoot, teamName, event) {
|
|
101
|
+
const path = orchestratorEventLogPath(repoRoot, teamName);
|
|
102
|
+
await mkdir(dirname(path), { recursive: true });
|
|
103
|
+
const full = {
|
|
104
|
+
ts: new Date().toISOString(),
|
|
105
|
+
team: teamName,
|
|
106
|
+
...event,
|
|
107
|
+
};
|
|
108
|
+
await appendFile(path, `${JSON.stringify(full)}\n`, 'utf-8');
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Mutex (chained-Promise serialisation)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
function createMutex() {
|
|
114
|
+
let lock = Promise.resolve();
|
|
115
|
+
return (fn) => {
|
|
116
|
+
const next = lock.then(fn, fn);
|
|
117
|
+
// Swallow rejections in the chain so a failed merge doesn't poison the lock.
|
|
118
|
+
lock = next.catch(() => undefined);
|
|
119
|
+
return next;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Git helpers (all sync; mockable via vi.mock('node:child_process'))
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
function gitRevParseHead(repoRoot, branch) {
|
|
126
|
+
return execFileSync('git', ['rev-parse', `refs/heads/${branch}`], {
|
|
127
|
+
cwd: repoRoot,
|
|
128
|
+
encoding: 'utf-8',
|
|
129
|
+
stdio: 'pipe',
|
|
130
|
+
}).trim();
|
|
131
|
+
}
|
|
132
|
+
function gitPath(worktreePath, gitPathName) {
|
|
133
|
+
try {
|
|
134
|
+
const resolved = execFileSync('git', ['rev-parse', '--git-path', gitPathName], {
|
|
135
|
+
cwd: worktreePath,
|
|
136
|
+
encoding: 'utf-8',
|
|
137
|
+
stdio: 'pipe',
|
|
138
|
+
}).trim();
|
|
139
|
+
if (resolved)
|
|
140
|
+
return resolved;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Fall through to the legacy in-worktree .git directory path. This keeps
|
|
144
|
+
// unit tests and non-git placeholder worktrees safe while real git
|
|
145
|
+
// worktrees use Git's own gitdir-aware resolution above.
|
|
146
|
+
}
|
|
147
|
+
return join(worktreePath, '.git', gitPathName);
|
|
148
|
+
}
|
|
149
|
+
function isRebaseInProgress(worktreePath) {
|
|
150
|
+
return existsSync(gitPath(worktreePath, 'rebase-merge'));
|
|
151
|
+
}
|
|
152
|
+
function isWorktreeRegistered(repoRoot, wtPath) {
|
|
153
|
+
try {
|
|
154
|
+
const out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
155
|
+
cwd: repoRoot,
|
|
156
|
+
encoding: 'utf-8',
|
|
157
|
+
stdio: 'pipe',
|
|
158
|
+
});
|
|
159
|
+
for (const line of out.split('\n')) {
|
|
160
|
+
if (line.startsWith('worktree ')) {
|
|
161
|
+
if (line.slice('worktree '.length).trim() === wtPath)
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// best-effort
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
function ensureMergerWorktree(repoRoot, mergerPath, leaderBranch) {
|
|
172
|
+
ensureDirWithMode(dirname(mergerPath));
|
|
173
|
+
if (existsSync(mergerPath) && isWorktreeRegistered(repoRoot, mergerPath)) {
|
|
174
|
+
return; // reuse
|
|
175
|
+
}
|
|
176
|
+
// First-time create — allow the hidden merger worktree to check out the
|
|
177
|
+
// leader branch even when the normal leader repo already has it checked out.
|
|
178
|
+
execFileSync('git', ['worktree', 'add', '--force', mergerPath, leaderBranch], {
|
|
179
|
+
cwd: repoRoot,
|
|
180
|
+
stdio: 'pipe',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function preflightMergerWorktree(mergerPath, leaderBranch) {
|
|
184
|
+
// Best-effort fetch (ignore failures — offline OK).
|
|
185
|
+
try {
|
|
186
|
+
execFileSync('git', ['fetch', '--no-tags', 'origin', leaderBranch], {
|
|
187
|
+
cwd: mergerPath,
|
|
188
|
+
stdio: 'pipe',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// ignore
|
|
193
|
+
}
|
|
194
|
+
execFileSync('git', ['reset', '--hard', leaderBranch], {
|
|
195
|
+
cwd: mergerPath,
|
|
196
|
+
stdio: 'pipe',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function parseUUFiles(porcelainOutput) {
|
|
200
|
+
const files = [];
|
|
201
|
+
for (const line of porcelainOutput.split('\n')) {
|
|
202
|
+
if (line.startsWith('UU ')) {
|
|
203
|
+
files.push(line.slice(3).trim());
|
|
204
|
+
}
|
|
205
|
+
else if (line.startsWith('AA ') || line.startsWith('DD ')) {
|
|
206
|
+
// Both-modified variants count as conflicts too.
|
|
207
|
+
files.push(line.slice(3).trim());
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// startMergeOrchestrator
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
export async function startMergeOrchestrator(config) {
|
|
216
|
+
assertRuntimeV2Gate();
|
|
217
|
+
assertLeaderBranchAllowed(config.leaderBranch);
|
|
218
|
+
// Refuse leader branch names that could be misread as flags by git
|
|
219
|
+
// (e.g. `--upload-pack=evil`, `-e/payload`).
|
|
220
|
+
validateBranchName(config.leaderBranch);
|
|
221
|
+
const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
222
|
+
const drainTimeoutMs = config.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
223
|
+
const mergerPath = mergerWorktreePathFor(config.repoRoot, config.teamName);
|
|
224
|
+
// Validate paths stay under repoRoot (defence-in-depth).
|
|
225
|
+
validateResolvedPath(mergerPath, config.repoRoot);
|
|
226
|
+
// Bootstrap merger worktree + leader inbox.
|
|
227
|
+
ensureMergerWorktree(config.repoRoot, mergerPath, config.leaderBranch);
|
|
228
|
+
await ensureLeaderInbox(config.teamName, config.cwd);
|
|
229
|
+
// Hydrate from persisted state if present (M6).
|
|
230
|
+
const persistedPath = persistedStatePath(config.repoRoot, config.teamName);
|
|
231
|
+
let persisted = { lastShas: {} };
|
|
232
|
+
if (existsSync(persistedPath)) {
|
|
233
|
+
try {
|
|
234
|
+
const { readFileSync } = await import('node:fs');
|
|
235
|
+
persisted = JSON.parse(readFileSync(persistedPath, 'utf-8'));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
persisted = { lastShas: {} };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const workers = new Map();
|
|
242
|
+
const pausedWorkers = new Set(); // workers mid-rebase (cadence paused)
|
|
243
|
+
const mutex = createMutex();
|
|
244
|
+
let stopped = false;
|
|
245
|
+
function persistState() {
|
|
246
|
+
const payload = {
|
|
247
|
+
lastShas: Object.fromEntries(Array.from(workers.values()).map((w) => [w.workerName, w.lastObservedSha])),
|
|
248
|
+
};
|
|
249
|
+
atomicWriteJson(persistedPath, payload);
|
|
250
|
+
}
|
|
251
|
+
async function fanOutRebase(triggeringWorker) {
|
|
252
|
+
for (const other of workers.values()) {
|
|
253
|
+
if (other.workerName === triggeringWorker)
|
|
254
|
+
continue;
|
|
255
|
+
const wtPath = other.workerWorktreePath;
|
|
256
|
+
// M1: existing rebase short-circuit.
|
|
257
|
+
if (isRebaseInProgress(wtPath)) {
|
|
258
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
259
|
+
type: 'rebase_skipped_in_progress',
|
|
260
|
+
worker: other.workerName,
|
|
261
|
+
reason: 'rebase-already-in-progress',
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
266
|
+
type: 'rebase_triggered',
|
|
267
|
+
worker: other.workerName,
|
|
268
|
+
});
|
|
269
|
+
// Pause cadence before touching the worktree.
|
|
270
|
+
await pauseHookViaSentinel(wtPath);
|
|
271
|
+
pausedWorkers.add(other.workerName);
|
|
272
|
+
// Best-effort fetch.
|
|
273
|
+
try {
|
|
274
|
+
execFileSync('git', ['fetch', '--no-tags', 'origin', config.leaderBranch], {
|
|
275
|
+
cwd: wtPath,
|
|
276
|
+
stdio: 'pipe',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// offline OK
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
execFileSync('git', ['rebase', config.leaderBranch], {
|
|
284
|
+
cwd: wtPath,
|
|
285
|
+
stdio: 'pipe',
|
|
286
|
+
});
|
|
287
|
+
// Clean rebase — resume immediately.
|
|
288
|
+
await resumeHookViaSentinel(wtPath);
|
|
289
|
+
pausedWorkers.delete(other.workerName);
|
|
290
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
291
|
+
type: 'rebase_succeeded',
|
|
292
|
+
worker: other.workerName,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Conflict — leave worktree mid-rebase, parse UU files, deliver mailbox.
|
|
297
|
+
let conflictingFiles = [];
|
|
298
|
+
try {
|
|
299
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
300
|
+
cwd: wtPath,
|
|
301
|
+
encoding: 'utf-8',
|
|
302
|
+
stdio: 'pipe',
|
|
303
|
+
});
|
|
304
|
+
conflictingFiles = parseUUFiles(status);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
conflictingFiles = ['(rebase status unavailable)'];
|
|
308
|
+
}
|
|
309
|
+
const baseSha = (() => {
|
|
310
|
+
try {
|
|
311
|
+
return execFileSync('git', ['rev-parse', `refs/heads/${config.leaderBranch}`], {
|
|
312
|
+
cwd: config.repoRoot,
|
|
313
|
+
encoding: 'utf-8',
|
|
314
|
+
stdio: 'pipe',
|
|
315
|
+
}).trim();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return 'unknown';
|
|
319
|
+
}
|
|
320
|
+
})();
|
|
321
|
+
const message = formatRebaseConflictForWorker({
|
|
322
|
+
workerName: other.workerName,
|
|
323
|
+
workerBranch: other.workerBranch,
|
|
324
|
+
leaderBranch: config.leaderBranch,
|
|
325
|
+
conflictingFiles,
|
|
326
|
+
baseSha,
|
|
327
|
+
worktreePath: wtPath,
|
|
328
|
+
observedAt: Date.now(),
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
await appendToInbox(config.teamName, other.workerName, message, config.cwd);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// best-effort — don't crash poller on inbox write failure
|
|
335
|
+
}
|
|
336
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
337
|
+
type: 'rebase_conflict',
|
|
338
|
+
worker: other.workerName,
|
|
339
|
+
data: { conflictingFiles },
|
|
340
|
+
});
|
|
341
|
+
// Cadence stays paused. The rebase resolution watcher resumes on
|
|
342
|
+
// git rebase state removal (M4 audit, then resume).
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function attemptMergeForWorker(entry) {
|
|
347
|
+
await mutex(async () => {
|
|
348
|
+
// Snapshot the SHA at the time the lock is taken — this is the merge target.
|
|
349
|
+
const targetSha = entry.lastObservedSha;
|
|
350
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
351
|
+
type: 'merge_attempted',
|
|
352
|
+
worker: entry.workerName,
|
|
353
|
+
data: { targetSha },
|
|
354
|
+
});
|
|
355
|
+
try {
|
|
356
|
+
preflightMergerWorktree(mergerPath, config.leaderBranch);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
360
|
+
entry.consecutiveFailures += 1;
|
|
361
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
362
|
+
type: 'merge_conflict',
|
|
363
|
+
worker: entry.workerName,
|
|
364
|
+
reason: `preflight_failed:${reason}`,
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const conflicts = checkMergeConflicts(entry.workerBranch, config.leaderBranch, mergerPath);
|
|
369
|
+
if (conflicts.length > 0) {
|
|
370
|
+
// Deliver merge-conflict mailbox to leader.
|
|
371
|
+
let mergeBaseSha = 'unknown';
|
|
372
|
+
try {
|
|
373
|
+
mergeBaseSha = execFileSync('git', ['merge-base', config.leaderBranch, entry.workerBranch], { cwd: mergerPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// best-effort
|
|
377
|
+
}
|
|
378
|
+
const message = formatMergeConflictForLeader({
|
|
379
|
+
workerName: entry.workerName,
|
|
380
|
+
workerBranch: entry.workerBranch,
|
|
381
|
+
leaderBranch: config.leaderBranch,
|
|
382
|
+
conflictingFiles: conflicts,
|
|
383
|
+
mergeBaseSha,
|
|
384
|
+
observedAt: Date.now(),
|
|
385
|
+
});
|
|
386
|
+
try {
|
|
387
|
+
await appendToLeaderInbox(config.teamName, message, config.cwd);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// best-effort
|
|
391
|
+
}
|
|
392
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
393
|
+
type: 'merge_conflict',
|
|
394
|
+
worker: entry.workerName,
|
|
395
|
+
data: { conflictingFiles: conflicts, mergeBaseSha },
|
|
396
|
+
});
|
|
397
|
+
entry.consecutiveFailures += 1;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// Clean — execute the merge in the merger worktree.
|
|
401
|
+
const result = mergeWorkerBranch(entry.workerBranch, config.leaderBranch, mergerPath);
|
|
402
|
+
if (!result.success) {
|
|
403
|
+
// Race: conflict-detection passed but the actual merge failed (e.g. a new
|
|
404
|
+
// commit landed). Fall back to the conflict-mailbox path.
|
|
405
|
+
const message = formatMergeConflictForLeader({
|
|
406
|
+
workerName: entry.workerName,
|
|
407
|
+
workerBranch: entry.workerBranch,
|
|
408
|
+
leaderBranch: config.leaderBranch,
|
|
409
|
+
conflictingFiles: result.conflicts.length > 0 ? result.conflicts : ['(merge failed after clean check)'],
|
|
410
|
+
mergeBaseSha: 'unknown',
|
|
411
|
+
observedAt: Date.now(),
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
await appendToLeaderInbox(config.teamName, message, config.cwd);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// best-effort
|
|
418
|
+
}
|
|
419
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
420
|
+
type: 'merge_conflict',
|
|
421
|
+
worker: entry.workerName,
|
|
422
|
+
data: { conflictingFiles: result.conflicts },
|
|
423
|
+
});
|
|
424
|
+
entry.consecutiveFailures += 1;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
entry.lastMergedSha = targetSha;
|
|
428
|
+
entry.consecutiveFailures = 0;
|
|
429
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
430
|
+
type: 'merge_succeeded',
|
|
431
|
+
worker: entry.workerName,
|
|
432
|
+
data: { mergeCommit: result.mergeCommit, targetSha },
|
|
433
|
+
});
|
|
434
|
+
// Clean merge → fan out rebase to other workers.
|
|
435
|
+
// Skip fan-out after `stopped` is set so a final drain merge cannot race
|
|
436
|
+
// with `cleanupTeamWorktrees` reaching into other workers' worktrees.
|
|
437
|
+
if (stopped)
|
|
438
|
+
return;
|
|
439
|
+
await fanOutRebase(entry.workerName);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async function runPollOnce() {
|
|
443
|
+
if (stopped)
|
|
444
|
+
return;
|
|
445
|
+
for (const entry of workers.values()) {
|
|
446
|
+
// Apply per-worker exponential backoff: skip ticks based on consecutiveFailures.
|
|
447
|
+
// Cap so we never fully starve a worker.
|
|
448
|
+
const skipModulo = Math.min(30, Math.pow(2, entry.consecutiveFailures));
|
|
449
|
+
if (skipModulo > 1 && pollTickCount % skipModulo !== 0) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
// Rebase resolution check (M4): if the worker is paused and the
|
|
453
|
+
// git rebase state has gone away, audit & resume.
|
|
454
|
+
if (pausedWorkers.has(entry.workerName)) {
|
|
455
|
+
if (!isRebaseInProgress(entry.workerWorktreePath)) {
|
|
456
|
+
// Resolved (--continue or --abort).
|
|
457
|
+
await handleRebaseResolution(entry);
|
|
458
|
+
// Fall through — also check for new commits.
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
// Still mid-rebase; don't try to merge this worker's branch yet.
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
let currentSha;
|
|
466
|
+
try {
|
|
467
|
+
currentSha = gitRevParseHead(config.repoRoot, entry.workerBranch);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
entry.consecutiveFailures += 1;
|
|
471
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
472
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
473
|
+
type: 'commit_observed',
|
|
474
|
+
worker: entry.workerName,
|
|
475
|
+
reason: `rev_parse_failed:${reason}`,
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (currentSha && currentSha !== entry.lastObservedSha) {
|
|
480
|
+
// SHA advanced → record + persist + try to merge.
|
|
481
|
+
entry.lastObservedSha = currentSha;
|
|
482
|
+
try {
|
|
483
|
+
persistState();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// best-effort persistence
|
|
487
|
+
}
|
|
488
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
489
|
+
type: 'commit_observed',
|
|
490
|
+
worker: entry.workerName,
|
|
491
|
+
data: { sha: currentSha },
|
|
492
|
+
});
|
|
493
|
+
try {
|
|
494
|
+
await attemptMergeForWorker(entry);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
entry.consecutiveFailures += 1;
|
|
498
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
499
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
500
|
+
type: 'merge_conflict',
|
|
501
|
+
worker: entry.workerName,
|
|
502
|
+
reason: `merge_threw:${reason}`,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function handleRebaseResolution(entry) {
|
|
509
|
+
pausedWorkers.delete(entry.workerName);
|
|
510
|
+
// M4: dirty-tree audit before resuming cadence.
|
|
511
|
+
try {
|
|
512
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
513
|
+
cwd: entry.workerWorktreePath,
|
|
514
|
+
encoding: 'utf-8',
|
|
515
|
+
stdio: 'pipe',
|
|
516
|
+
}).trim();
|
|
517
|
+
if (status.length > 0) {
|
|
518
|
+
const dirtyFiles = status
|
|
519
|
+
.split('\n')
|
|
520
|
+
.map((l) => l.trim().replace(/^\S+\s+/, ''))
|
|
521
|
+
.filter((s) => s.length > 0);
|
|
522
|
+
const audit = `## Auto-commit audit: the following files were modified during rebase pause and will be folded into the next auto-commit:\n${dirtyFiles.map((f) => `- \`${f}\``).join('\n')}`;
|
|
523
|
+
try {
|
|
524
|
+
await appendToInbox(config.teamName, entry.workerName, audit, config.cwd);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
// best-effort
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// status failed — proceed without audit
|
|
533
|
+
}
|
|
534
|
+
await resumeHookViaSentinel(entry.workerWorktreePath);
|
|
535
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
536
|
+
type: 'rebase_resolved',
|
|
537
|
+
worker: entry.workerName,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
// ----- Polling driver -----
|
|
541
|
+
let pollTickCount = 0;
|
|
542
|
+
const interval = setInterval(() => {
|
|
543
|
+
pollTickCount += 1;
|
|
544
|
+
void runPollOnce().catch(() => {
|
|
545
|
+
// Errors bubble up here as a defence; per-worker failures already
|
|
546
|
+
// emit events and bump consecutiveFailures inside pollOnce().
|
|
547
|
+
});
|
|
548
|
+
}, pollIntervalMs);
|
|
549
|
+
// Don't keep the event loop alive on its own.
|
|
550
|
+
if (typeof interval.unref === 'function')
|
|
551
|
+
interval.unref();
|
|
552
|
+
// ----- Public handle -----
|
|
553
|
+
return {
|
|
554
|
+
async registerWorker(workerName) {
|
|
555
|
+
if (workers.has(workerName))
|
|
556
|
+
return;
|
|
557
|
+
const workerBranch = getBranchName(config.teamName, workerName);
|
|
558
|
+
// Defence-in-depth: reject worker branch names that could be misread as
|
|
559
|
+
// flags by git (e.g. `--upload-pack=foo`). `getBranchName` is supposed to
|
|
560
|
+
// produce safe names, but we re-validate here so any future change to
|
|
561
|
+
// the naming scheme can't accidentally bypass this guard.
|
|
562
|
+
validateBranchName(workerBranch);
|
|
563
|
+
const wtPath = getWorktreePath(config.repoRoot, config.teamName, workerName);
|
|
564
|
+
let seedSha = persisted.lastShas[workerName] ?? '';
|
|
565
|
+
if (!seedSha) {
|
|
566
|
+
try {
|
|
567
|
+
seedSha = gitRevParseHead(config.repoRoot, workerBranch);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
seedSha = '';
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
workers.set(workerName, {
|
|
574
|
+
workerName,
|
|
575
|
+
workerBranch,
|
|
576
|
+
workerWorktreePath: wtPath,
|
|
577
|
+
lastObservedSha: seedSha,
|
|
578
|
+
lastMergedSha: seedSha,
|
|
579
|
+
consecutiveFailures: 0,
|
|
580
|
+
});
|
|
581
|
+
try {
|
|
582
|
+
persistState();
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// best-effort
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
async unregisterWorker(workerName) {
|
|
589
|
+
workers.delete(workerName);
|
|
590
|
+
pausedWorkers.delete(workerName);
|
|
591
|
+
try {
|
|
592
|
+
persistState();
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// best-effort
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
async pollOnce() {
|
|
599
|
+
await runPollOnce();
|
|
600
|
+
},
|
|
601
|
+
async drainAndStop() {
|
|
602
|
+
stopped = true;
|
|
603
|
+
clearInterval(interval);
|
|
604
|
+
const start = Date.now();
|
|
605
|
+
const unmerged = [];
|
|
606
|
+
const candidates = Array.from(workers.values()).filter((w) => w.lastObservedSha && w.lastObservedSha !== w.lastMergedSha);
|
|
607
|
+
for (const entry of candidates) {
|
|
608
|
+
const remaining = drainTimeoutMs - (Date.now() - start);
|
|
609
|
+
if (remaining <= 0) {
|
|
610
|
+
unmerged.push({ workerName: entry.workerName, reason: 'drain-timeout' });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const merged = await Promise.race([
|
|
614
|
+
(async () => {
|
|
615
|
+
try {
|
|
616
|
+
await attemptMergeForWorker(entry);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
})(),
|
|
623
|
+
new Promise((resolve) => {
|
|
624
|
+
const t = setTimeout(() => resolve(false), remaining);
|
|
625
|
+
if (typeof t.unref === 'function')
|
|
626
|
+
t.unref();
|
|
627
|
+
}),
|
|
628
|
+
]);
|
|
629
|
+
if (!merged || entry.lastMergedSha !== entry.lastObservedSha) {
|
|
630
|
+
unmerged.push({
|
|
631
|
+
workerName: entry.workerName,
|
|
632
|
+
reason: merged ? 'merge-conflict' : 'drain-timeout',
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// Audit any unmerged drain residue.
|
|
637
|
+
if (unmerged.length > 0) {
|
|
638
|
+
const auditPath = teardownAuditPath(config.repoRoot, config.teamName);
|
|
639
|
+
await mkdir(dirname(auditPath), { recursive: true });
|
|
640
|
+
for (const u of unmerged) {
|
|
641
|
+
const row = JSON.stringify({
|
|
642
|
+
type: 'unmerged_at_shutdown',
|
|
643
|
+
ts: new Date().toISOString(),
|
|
644
|
+
team: config.teamName,
|
|
645
|
+
worker: u.workerName,
|
|
646
|
+
reason: u.reason,
|
|
647
|
+
});
|
|
648
|
+
try {
|
|
649
|
+
await appendFile(auditPath, `${row}\n`, 'utf-8');
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// best-effort
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const message = `## Teardown audit: unmerged worker branches at shutdown\n\n${unmerged.map((u) => `- ${u.workerName}: ${u.reason}`).join('\n')}`;
|
|
656
|
+
try {
|
|
657
|
+
await appendToLeaderInbox(config.teamName, message, config.cwd);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
// best-effort
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return { unmerged };
|
|
664
|
+
},
|
|
665
|
+
getState() {
|
|
666
|
+
return {
|
|
667
|
+
workers: Array.from(workers.keys()),
|
|
668
|
+
lastShas: Object.fromEntries(Array.from(workers.values()).map((w) => [w.workerName, w.lastObservedSha])),
|
|
669
|
+
mergerWorktreePath: mergerPath,
|
|
670
|
+
};
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// recoverFromRestart (M6)
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
export async function recoverFromRestart(config) {
|
|
678
|
+
// Load persisted SHA state.
|
|
679
|
+
const persistedPath = persistedStatePath(config.repoRoot, config.teamName);
|
|
680
|
+
let persistedShasLoaded = 0;
|
|
681
|
+
if (existsSync(persistedPath)) {
|
|
682
|
+
try {
|
|
683
|
+
const { readFileSync } = await import('node:fs');
|
|
684
|
+
const persisted = JSON.parse(readFileSync(persistedPath, 'utf-8'));
|
|
685
|
+
persistedShasLoaded = Object.keys(persisted.lastShas ?? {}).length;
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
persistedShasLoaded = 0;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// Walk every known worker worktree (from worktrees.json metadata) and look
|
|
692
|
+
// for orphaned gitdir-aware `rebase-merge` dirs.
|
|
693
|
+
const orphanedRebases = [];
|
|
694
|
+
let entries = [];
|
|
695
|
+
try {
|
|
696
|
+
entries = listTeamWorktrees(config.teamName, config.repoRoot).map((w) => ({
|
|
697
|
+
workerName: w.workerName,
|
|
698
|
+
path: w.path,
|
|
699
|
+
}));
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
entries = [];
|
|
703
|
+
}
|
|
704
|
+
for (const entry of entries) {
|
|
705
|
+
if (!isRebaseInProgress(entry.path))
|
|
706
|
+
continue;
|
|
707
|
+
orphanedRebases.push(entry.workerName);
|
|
708
|
+
const message = `### Runtime restart recovery — your branch is mid-rebase
|
|
709
|
+
|
|
710
|
+
Runtime restarted while your branch was mid-rebase onto \`${config.leaderBranch}\`.
|
|
711
|
+
|
|
712
|
+
**Worktree:** \`${entry.path}\`
|
|
713
|
+
|
|
714
|
+
Cadence remains paused. Resolve and \`git rebase --continue\`, or \`git rebase --abort\` to bail.
|
|
715
|
+
Cadence resumes once the git rebase state is gone.`;
|
|
716
|
+
try {
|
|
717
|
+
await appendToInbox(config.teamName, entry.workerName, message, config.cwd);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// best-effort
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (orphanedRebases.length > 0 || persistedShasLoaded > 0) {
|
|
724
|
+
try {
|
|
725
|
+
await appendEvent(config.repoRoot, config.teamName, {
|
|
726
|
+
type: 'restart_recovery',
|
|
727
|
+
data: { orphanedRebases, persistedShasLoaded },
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// best-effort
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return { orphanedRebases, persistedShasLoaded };
|
|
735
|
+
}
|
|
736
|
+
//# sourceMappingURL=merge-orchestrator.js.map
|