diff --git a/CHANGELOG.md b/CHANGELOG.md index 874dd28..9cf0494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added per-rig Ralph mode config via `sgt config ralph`, with a normalized Ralph state model in status/plan-state/cockpit output and explicit concurrency-accounting rules for admissible vs support-only vs duplicate lanes. +- President and rig-activity logic now treat an enabled unmet Ralph condition as active work, emit dedicated Ralph underfill/idle intervention reasons, and keep stale `verified` / `waived` acceptance metadata from falsely terminating plan completion while Ralph remains unmet. +- Added `test_ralph_mode_latest_main_proof.sh` plus README guidance as the explicit latest-main proof path for Ralph mode, bundling config/state, President supervision, refill recovery, and cockpit visibility coverage. - Mayor now auto-refreshes at runtime when the estimated AI prompt exceeds `SGT_MAYOR_AUTO_REFRESH_TOKENS` (default `150000`), using the same handoff/archive path as `sgt mayor refresh` plus a cooldown guard. - `sgt status` and `sgt status --json` now expose the latest Mayor prompt-budget estimate and last automatic refresh metadata. - `sgt context index` now writes through unique fsynced temp files before `os.replace`, avoiding concurrent rebuild corruption of `~/.sgt/context//index.json`. diff --git a/README.md b/README.md index 3acb538..d45e159 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The repo includes a minimal Web UI for realtime monitoring and dispatch: - Docs / quick start: [`web/README.md`](web/README.md) - Default URL: `http://localhost:4747` +- First-class deploy path: `sgt web deploy` from the repo checkout, then `sgt web status` / `sgt web verify` to confirm the live served tree really matches the repo source - Latest-main cockpit proof: `./test_web_cockpit_latest_main_proof.sh` ## Why SGT exists (short) @@ -86,6 +87,7 @@ cat verification.md | sgt create blocker - What happens: - the blocker evidence is appended to the rig’s shared `SGT_CONTEXT.md` - a durable blocker record is created under `~/sgt/.sgt/acceptance-blockers/` +- blocker records now carry a stable `SEVERITY_CLASS` plus `DEDUPE_KEY` so operator surfaces can separate control-plane incidents from normal still-red candidate churn and collapse repeated same-key pulses into one summary - the Mayor is woken immediately to re-create actionable work if the rig is otherwise idle - periodic mayor cycles will no longer treat an empty issue/PR board as success while the blocker remains open @@ -206,6 +208,60 @@ sgt plan tick scrapegoat sgt wake-mayor "plan-update:scrapegoat" ``` +## Ralph Mode + +Ralph mode pins a rig into ongoing work until an explicit operator-owned Ralph condition is satisfied or the operator disables Ralph mode. + +Configure it per rig with: + +```bash +sgt config ralph pmkb \ + --enable \ + --condition "1K PNL for 5m btc pipeline" \ + --target 5 +``` + +Optional knobs: + +```bash +sgt config ralph pmkb --condition-status met +sgt config ralph pmkb --count-support yes +sgt config ralph pmkb --disable +``` + +Current model: +- Ralph config lives in `~/.sgt/rig-config/.json` under `ralph_mode`. +- Ralph runtime state is computed from rig config plus live rig work and is also persisted into `~/.sgt/plan-state/.json` under `ralph`. +- While Ralph is enabled and `condition_status` is not `met` or `waived`, plan completion is forced back to a non-terminal pending rollup (`ralph-condition-unmet` or `ralph-underfilled`) even if stale acceptance metadata says `verified` or `waived`. +- `sgt status`, `sgt status --json`, and the Web cockpit expose Ralph state, condition, target concurrency, active lane count, admissible lane count, underfill state, and the latest President action for that rig. +- President treats Ralph underfill with existing admissible backlog as a bounded refill wake, but upgrades idle, fake-complete, or no-refillable-backlog Ralph states into a stronger refresh/contradiction intervention so the rig does not silently drift. + +Concurrency accounting policy: +- A Ralph lane is a distinct open `sgt-authorized` issue number on that rig. +- Issues labeled `duplicate` or `redundant` never count toward Ralph concurrency. +- Issues labeled `support-only` or `cleanup-only` do not count by default; opt in with `sgt config ralph --count-support yes`. +- `active_lane_count` counts admissible lanes that currently have a live polecat on that issue. +- `backlog_lane_count` counts admissible open lanes that exist but are not currently active. + +Example: +- Issue `#101` with a live polecat counts as one active Ralph lane. +- Issue `#102` labeled `support-only` does not count unless support lanes are enabled. +- Issue `#103` labeled `duplicate` never counts. + +### Ralph latest-main proof path + +Run this on a fresh checkout of the latest `master`/mainline commit when you want repo-owned proof that Ralph mode configuration, state exposure, concurrency accounting, and bounded refill behavior still work end to end: + +```bash +./test_ralph_mode_latest_main_proof.sh +``` + +That proof path bundles the checks that matter for Ralph mode: +- `test_ralph_mode_config_and_state.sh` proves per-rig Ralph config, normalized state output, anti-false-completion semantics, and admissible/support/duplicate lane accounting. +- `test_president_runtime_supervision.sh` proves President distinguishes Ralph underfill, idle, and contradiction states and picks the expected bounded intervention. +- `test_mayor_stranded_zero_worker_recovery.sh` proves controller-side refill logic can top an actionable rig back up toward target live concurrency instead of leaving it quietly idle. +- `web/test/cockpit.test.js` proves the Web cockpit snapshot keeps Ralph state attached to each rig for operator visibility. + ## CI self-healing (Mayor watchdog) For rigs with `SGT_PLAN.json`, Mayor also watches the latest **master** GitHub Actions run. diff --git a/SGT_CONTEXT.md b/SGT_CONTEXT.md index 3f4db51..437b8df 100644 --- a/SGT_CONTEXT.md +++ b/SGT_CONTEXT.md @@ -1115,3 +1115,414 @@ A good finished result should make the following true: - 2026-03-31T22:26:37+02:00 — 2026-03-31 issue #323: web cockpit alert feed now parses recent PRESIDENT_OPERATOR_EVENT and PRESIDENT_INTERVENTION log lines, dedupes by President overlap/dedupe keys, merges them with blocker alerts, and shows President-tagged alert cards with detail snippets so important President incidents surface in the operator rail without double paging quiet rechecks or suppressed cooldown replays. - 2026-03-31T22:27:31+02:00 — 2026-03-31 issue #322: President now persists bounded structured operator-event history in ~/.sgt/president/president-operator-events.tsv; sgt status --json exposes it as president_events, human status shows recent President events, and the web cockpit Alert Center renders President activity with rig, reason, action, outcome, and priority alert surfacing. - 2026-03-31T22:32:49+02:00 — 2026-03-31 issue #322 follow-up: current master was missing the closed PR #325 implementation even though later context referenced it. Replacement fix persists bounded President operator-event history in ~/.sgt/president/president-operator-events.tsv, exposes it via status --json as president_events plus human status lines, and the web cockpit now renders a dedicated President Activity panel from that snapshot while leaving the newer alert-feed dedupe logic intact. +- 2026-03-31T22:38:13+02:00 — 2026-03-31 issue #328: web cockpit alert rail now defaults to recent and actionable items by filtering stale resolved/info noise, deduping blocker history to the latest transition per blocker, and trimming long alert titles/details for faster operator scanning. +- 2026-03-31T22:38:20+02:00 — 2026-03-31 cockpit issue #327: the web live monitor now removes the focused worker from the wall instead of duplicating it, and the stream wall layout/theme is locked as a blue-on-black automatic grid of tall vertical panes via web/public/* plus operator-shell tests. +- 2026-03-31T22:50:31+02:00 — 2026-03-31 issue #331: the web cockpit live panes now expose bottom-anchored per-pane nudge inputs via POST /api/nudge, and sgt nudge resolves mayor/, dog/, and crew/ to the same tmux sessions used by cockpit streams. +- 2026-03-31T22:57:56+02:00 — 2026-03-31 issue #333: docs/president-operator-surface-contract.md and ./test_president_operator_surface_latest_main_proof.sh are the repo-owned join point for the President operator-surface pass. Use that proof when verifying latest-main visibility across sgt status, sgt status --json president_events, scoped sgt peek, and the Web UI alert rail plus President Activity panel. + +## 2026-04-01 +- 2026-04-01T03:58:15+02:00 — 2026-04-01 operator policy for President/Mayor: PMKB should maintain multiple materially different live candidate lanes in parallel while acceptance remains pending. If PMKB trends serial or idle, President and rig-local Mayor should proactively top it back up rather than waiting for a human prompt. +- 2026-04-01T04:22:28+02:00 — 2026-04-01 hard operator policy for PMKB control plane: do not let PMKB acceptance verify on durable falsification alone; target is >= +1000 USDC widened-corpus net PnL under realistic assumptions unless a human waives it. President/Mayor should maintain at least 3 materially different live PMKB candidate lanes in parallel while acceptance remains pending, and proactively refill the rig if active candidate count drops. +- 2026-04-01T06:19:40+02:00 — Plan request sgt-1775017180-eee389c4 submitted by OpenClaw agent gastown. Full spec appended below. + +### Plan Request sgt-1775017180-eee389c4 + +- Requested at: 2026-04-01T06:19:40+02:00 +- Requesting OpenClaw agent: gastown + +```markdown +# SGT feature request: Ralph mode + +## Request + +Add a new SGT control-plane feature called **Ralph mode**. + +High-level idea: +- Ralph mode **pins a rig into active work** under ongoing **President oversight**. +- Instead of letting the rig naturally drift toward idle / acceptance-verified / parked states after each no-go or merge, Ralph mode keeps the rig continuously supplied with work until an explicit operator-defined condition is met or a human stops it. + +Requester: operator via Gastown +Date: 2026-04-01 +Rig for implementation: `sgt` + +## Example desired usage + +Something like: + +```yaml +ralph_mode: + enabled: true + concurrency: 5 + ralph_condition: "1K PNL for 5m btc pipeline" +``` + +Or equivalent CLI/config/runtime representation. + +The important semantics are: +- **constant pressure / keep working** +- **President-supervised** +- **target concurrency** +- **clear success/stop condition** + +## Why this is needed + +We keep running into the same class of operational failure: +- a rig is conceptually still open-ended +- but the control plane keeps drifting it toward idle, verified, parked, or under-filled +- even though the operator wants sustained forward pressure until a real objective is achieved + +Ralph mode should solve that by making the operator intent explicit. + +## Core behavior + +When Ralph mode is active for a rig: + +1. **President keeps the rig hot** + - continuously supervises the rig + - notices under-filled / idle / fake-complete / stalled states + - proactively tops the rig back up + - does not wait for the operator to notice serial drift every time + +2. **Concurrency is a first-class target** + - e.g. `concurrency: 5` + - if active candidate/worker count drops below target, the control plane refills the rig + - parallelism should apply to materially different admissible lanes, not just duplicate noise + +3. **Explicit Ralph condition controls completion** + - e.g. `ralph_condition: "1K PNL for 5m btc pipeline"` + - until that condition is satisfied (or explicitly waived/stopped by a human), the rig should remain in active continuation mode + - ordinary no-go / falsification evidence is informative but not terminal by itself unless that is what the Ralph condition says + +4. **No fake completion while condition remains unmet** + - if the rig produces only no-go evidence, it should continue into the next admissible lane + - Ralph mode should prevent accidental drift into `verified` / `parked` / `tasks-exhausted-awaiting-acceptance` when the operator-defined Ralph condition still says the mission is open + +5. **President-owned refill / escalation** + - President should own the higher-level "keep this rig moving" policy + - rig-local Mayor still owns the repo-local work decomposition and dispatch details + - if the rig becomes under-filled or contradictory, President should recheck / refresh / steer the Mayor until the Ralph policy is satisfied + +## Desired operator controls + +Please design operator-facing controls for Ralph mode that are simple and explicit. + +Likely needs: +- enable / disable Ralph mode per rig +- set / edit `concurrency` +- set / edit `ralph_condition` +- possibly set policy knobs like: + - minimum materially different lanes + - whether support-only work counts toward concurrency + - under-fill cooldown / refill aggressiveness + - whether falsification is terminal or not +- show current Ralph state in `sgt status` / Web UI / logs + +## Suggested semantics + +Ralph mode should probably include state like: + +- `enabled` +- `condition_text` (human-readable) +- optional machine-readable condition fields if available later +- `target_concurrency` +- `active_lane_count` +- `underfilled` +- `last_refill_at` +- `last_president_action` +- `completion_blocked_by_condition` + +## PMKB motivating use case + +The immediate motivating use case is PMKB. + +Example policy: +- keep PMKB running until it achieves something like: + - `1K PNL for 5m btc pipeline` +- maintain parallel candidate exploration, e.g. `concurrency: 5` +- do not let the rig park merely because a candidate family was falsified +- keep generating/admitting the next materially different candidate set until the condition is satisfied or a human says stop + +This should be expressible as a generic SGT feature, not a PMKB-only hardcode. + +## Guardrails + +- Do not treat duplicate/redundant work as useful concurrency. +- Do not count support-only or cleanup-only lanes as satisfying Ralph-mode parallelism unless explicitly configured. +- Do not let stale verified acceptance metadata override an active Ralph condition. +- Preserve human stop/waive authority. +- Keep observability clear: operator should be able to tell why a rig is still active, what the current Ralph condition is, and why President is refilling it. + +## Acceptance expectations + +A good implementation should likely include: + +1. Ralph mode architecture / config model +2. President integration +3. Mayor interplay / refill logic +4. acceptance/completion override semantics tied to Ralph condition +5. concurrency accounting rules +6. `sgt status` / Web UI / logs visibility +7. proof / latest-main validation with at least one rig example + +## Concrete acceptance criteria + +The delivered system should make the following true: + +- a rig can be explicitly pinned into ongoing work via Ralph mode +- President keeps the rig topped up toward a configured concurrency target +- the rig does not drift into false completion while the Ralph condition remains unmet +- no-go evidence triggers successor continuation rather than accidental stand-down, unless the Ralph condition explicitly allows closure +- operator can see Ralph mode state and condition in status/logs/UI +- operator can configure something equivalent to: + - `concurrency: 5` + - `ralph_condition: "1K PNL for 5m btc pipeline"` +- latest-main proof demonstrates the behavior end-to-end + +## Requested outcome from Mayor + +Please create and execute a real repo-local SGT plan for: + +**Ralph mode: President-pinned rig continuation with configurable concurrency and explicit completion condition.** +``` +- 2026-04-01T06:55:48+02:00 — 2026-04-01 operator hotfix brief: preserve PMKB semantics that falsification/no-go is informative not terminal; acceptance stays open until widened-corpus realistic net PnL >= +1000 USDC; keep >=3 materially different live candidate lanes; President/Mayor must proactively refill underfilled rigs. Outstanding SGT structural work beyond the active Ralph-mode plan: (1) create-plan/decomposition must be deterministic across mayor workspace vs canonical repo path and must not fall into wake loops or plan-sync-failed divergence, (2) GraphQL live-state revalidation failures must not block sling/redispatch, merge-in-progress must be fully idempotent/in-flight, and scary merge-failed alerts should be suppressed if later success self-heals, (3) continuation dedupe must not recreate already-completed lanes and should prefer explicit successor mapping over fuzzy recreation, (4) control plane must honor repo-local reopened-plan truth and not drift into fake verified/tasks-exhausted closeout when continuation intent remains open, (5) refill guarantee must key off live polecats not just open issues and must emit an explicit reason when active_count < target_concurrency, (6) web UI deploy path needs a first-class deploy/restart flow or single source of truth, and (7) acceptance-blocker signaling needs severity classes/dedupe so platform incidents are distinguishable from normal candidate no-go churn. Fresh live repro on 2026-04-01 ~06:50 CET: /root/sgt/rigs/pmkb/SGT_PLAN.json still says keep iterating toward >= +1000 USDC with multiple materially different live lanes, but sgt plan tick pmkb returned tasks-exhausted-awaiting-acceptance and PMKB stayed idle with zero live polecats even after wake-mayor + president refresh + mayor refresh/start. +- 2026-04-01T06:57:46+02:00 — Ralph mode now lives in ~/.sgt/rig-config/.json under ralph_mode; status/plan-state/cockpit expose active/admissible/backlog lane counts with default policy distinct open sgt-authorized issues excluding duplicate plus support-only labels unless count_support_lanes=true, and plan completion is forced back to pending while an enabled Ralph condition remains unmet. +- 2026-04-01T07:01:46+02:00 — Acceptance blockers now persist SEVERITY_CLASS plus DEDUPE_KEY. Web cockpit groups unresolved blocker alerts by that key and emits one still-red summary for repeated same-key churn, while control-plane blockers stay visually/severity-distinct from candidate no-go acceptance churn. +- 2026-04-01T07:03:15+02:00 — 2026-04-01 issue #339 added a first-class live Web UI operator path: use 'sgt web deploy' from the repo checkout to sync repo web/ into /root/sgt/web, restart the live sgt-web tmux session, and verify repo/live tree parity plus HTTP health; use 'sgt web status' or 'sgt web verify' to avoid assuming a merged checkout updated the served UI. +- 2026-04-01T07:04:39+02:00 — 2026-04-01 issue #337: plan control-plane truth now stays on the canonical rig repo path, not mayor-workspace or default-branch plan cache. plan tick/worker context read repo-local SGT_PLAN.json, mayor briefings call out canonical repo_path/plan_path, and mayor request complete can recover a plan accidentally written under mayor-workspace by copying it into the rig repo before syncing labels/state. +- 2026-04-01T07:06:36+02:00 — Plan tick now records a durable __continuation__ plan blocker when acceptance is still pending but live lanes fall below policy.max_in_flight; Mayor stranded-rig recovery refills replacement work up to target concurrency, and President treats pending-plan underfill as a stalled-purpose intervention reason instead of idling the rig. +- 2026-04-01T07:07:27+02:00 — 2026-04-01 issue #338: mayor sling revalidation now fails open only for flaky live GraphQL count reads, redispatch tolerates UNKNOWN/failed source-PR mergeability revalidation, refinery treats merge-in-progress/already-being-merged as in-flight without scary failure noise, and plan tick now binds/reopens lanes from explicit open plan- issue labels before recreating work. +- 2026-04-01T07:14:22+02:00 — 2026-04-01: plan tick now records a durable __continuation__ plan blocker when acceptance is still pending but tasks are exhausted and live polecats are below policy.max_in_flight; mayor activity snapshot and President treat that pending-plan underfill as active stalled-purpose state so rigs do not idle-green while continuation intent remains open. +- 2026-04-01T07:25:23+02:00 — 2026-04-01 issue #349: President Ralph supervision now distinguishes bounded refill from contradiction. If Ralph is underfilled but admissible backlog exists, President uses a wake (reason=ralph-underfilled) to refill. If Ralph is still unmet but the rig looks fake-complete or has no refillable backlog toward target concurrency, President upgrades to refresh with reason=ralph-contradiction so operators can see that the rig needs stronger intervention instead of silently drifting. +- 2026-04-01T07:26:01+02:00 — 2026-04-01 issue #350: plan tick now treats an unmet Ralph target_concurrency as continuation underfill when recording the durable __continuation__ blocker, and Mayor stranded-rig recovery refills underfilled Ralph/pending-plan rigs up to the live polecat gap instead of only recovering zero-worker rigs one lane at a time. +- 2026-04-01T07:26:09+02:00 — 2026-04-01 issue #348: plan-state reconciliation now treats explicit repo-local task.status values like planned/pending as authoritative reopen signals, clearing stale completed issue linkage so continuation lanes can refill; mayor refresh also infers live President/per-rig topology from running president or scoped mayor sessions so 'sgt mayor refresh ' stays per-rig even when the caller shell lacks SGT_MAYOR_ARCHITECTURE=per-rig. +- 2026-04-01T07:38:10+02:00 — 2026-04-01 issue #354: mayor loop wake-summary notifications now use the same fail-open helper as 'sgt mayor notify', so OpenClaw transport/current-session channel failures still persist MAYOR_NOTIFY_FAIL_OPEN warning state but no longer exit per-rig mayors under set -e or block stranded-rig refill progress. +- 2026-04-01T07:45:57+02:00 — 2026-04-01: President rig-local supervision must skip hibernated rigs entirely; otherwise actionable-no-forward-motion can trigger manual-refresh|rig= and restart a per-rig mayor despite manual-stop/hibernation. Regression coverage lives in test_president_runtime_supervision.sh. +- 2026-04-01T07:47:03+02:00 — Acceptance blocker sgt-acceptance-1775022423-817268fb reported by witness: Replacement work required after stalled polecat for issue #357 + +### Acceptance Blocker sgt-acceptance-1775022423-817268fb + +- Reported at: 2026-04-01T07:47:03+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #357 + +```markdown +Replacement work required after stalled polecat for issue #357 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #357 +- Issue URL: https://github.com/codejeet/sgt/issues/357 +- Issue title: unknown +- Polecat: sgt-bb313303 +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T07:50:48+02:00 — 2026-04-01 issue #356: plan tick now records duplicate-closeout lineage per task in plan-state, fences immediate redispatch of the same task signature after a duplicate-closing PR, clears the stale closed issue binding when the task materially changes, and only allows refill to resume once an explicit open successor exists or the task payload changes. +- 2026-04-01T07:51:44+02:00 — Acceptance blocker sgt-acceptance-1775022704-d829bc00 reported by witness: Replacement work required after stalled polecat for issue #356 + +### Acceptance Blocker sgt-acceptance-1775022704-d829bc00 + +- Reported at: 2026-04-01T07:51:44+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #356 + +```markdown +Replacement work required after stalled polecat for issue #356 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #356 +- Issue URL: https://github.com/codejeet/sgt/issues/356 +- Issue title: unknown +- Polecat: sgt-28f69197 +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T08:25:16+02:00 — 2026-04-01 issue #361: added test_ralph_mode_latest_main_proof.sh as the repo-owned latest-main proof path for Ralph mode; it bundles config/state, President intervention, stranded-rig refill, and cockpit visibility coverage, and README now points operators at that single entrypoint. +- 2026-04-01T08:54:09+02:00 — 2026-04-01 issue #364 duplicate redispatch fallout: plan tick no longer treats default task status pending/planned as an authoritative reopen of completed work. Explicit repo-local reopen now requires status=reopened/requeue or reopen=true, while unchanged tasks retain the latest merged plan-task issue lineage so duplicate open plan- issues do not reset completed tasks back to pending/dispatched. +- 2026-04-01T09:07:10+02:00 — 2026-04-01 issue #366: when Ralph is enabled and the condition remains unmet, plan-state completion.acceptance now mirrors the effective pending status instead of leaking stale verified/waived terminal metadata; the original declared terminal status/timestamp is preserved under completion.acceptance.declared_* for operator forensics, and regression coverage lives in test_ralph_mode_config_and_state.sh. +- 2026-04-01T09:08:14+02:00 — Acceptance blocker sgt-acceptance-1775027294-89db909c reported by witness: Replacement work required after stalled polecat for issue #366 + +### Acceptance Blocker sgt-acceptance-1775027294-89db909c + +- Reported at: 2026-04-01T09:08:14+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #366 + +```markdown +Replacement work required after stalled polecat for issue #366 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #366 +- Issue URL: https://github.com/codejeet/sgt/issues/366 +- Issue title: unknown +- Polecat: sgt-89881ba7 +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T09:09:57+02:00 — Issue #367: the Web cockpit now exposes inline per-rig Ralph controls via POST /api/ralph/:rig (enable/disable, condition text, target concurrency, condition status, count-support toggle), while test_ralph_mode_config_and_state.sh now asserts human status and durable event-log visibility for Ralph state changes. +- 2026-04-01T11:16:35+02:00 — 2026-04-01 issue #370: per-rig mayor helper calls must preserve the caller's rig scope and existing errexit mode. Status/notify/start-stop-refresh helpers that reset scope to shared or blindly restore set -e can make President-started mayor/ sessions fall off after startup/event handling with clean-exit or nonzero-exit noise instead of staying resident. +- 2026-04-01T11:18:05+02:00 — Acceptance blocker sgt-acceptance-1775035085-041bcff8 reported by witness: Replacement work required after stalled polecat for issue #370 + +### Acceptance Blocker sgt-acceptance-1775035085-041bcff8 + +- Reported at: 2026-04-01T11:18:05+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #370 + +```markdown +Replacement work required after stalled polecat for issue #370 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #370 +- Issue URL: https://github.com/codejeet/sgt/issues/370 +- Issue title: unknown +- Polecat: sgt-f1ac7cda +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T11:34:21+02:00 — 2026-04-01 issue #371: per-rig mayor liveness under President depends on preserving both caller rig scope and the current errexit mode across helper calls. In practice, _mayor_notify_rigger flipping set -e back on and helper paths like status/wake/start/stop/refresh resetting scope to shared can make mayor/ exit after its first startup/event cycle instead of staying resident. +- 2026-04-01T11:48:19+02:00 — 2026-04-01 issue #370: preserve caller mayor scope across President/Deacon per-rig supervision and mayor start/stop/refresh helpers. Those helpers must not force scope back to shared after rig-targeted calls, or President-started mayor/ sessions can lose scoped state/heartbeat paths and fall off after startup. Regression coverage now asserts scope preservation across cmd_mayor_start/stop/refresh, their _cmd_* helpers, and _president_supervise_rig_mayor. +- 2026-04-01T11:49:42+02:00 — 2026-04-01 issue #363: expanded test_plan_tick_duplicate_completed_task_guard.sh to pin the live Ralph duplicate-redispatch shape across SGT55/SGT56/SGT57. Default repo-local pending/planned task statuses must retain latest merged task lineage and stay completed; only explicit reopen markers (status=reopened/requeue or reopen=true) may clear completion and redispatch. +- 2026-04-01T11:50:01+02:00 — 2026-04-01 issue #357: President supervision now hard-skips hibernated rigs before any start/refresh/wake decision, so actionable-no-forward-motion and pending-plan/ralph checks cannot trigger manual-refresh|rig= or restart mayor/ while the rig remains hibernated. Regression coverage: test_president_runtime_supervision.sh. +- 2026-04-01T11:50:22+02:00 — 2026-04-01: President runtime supervision now short-circuits hibernated rigs before selecting any start/refresh/wake action, so actionable-no-forward-motion and actionable-rig-recheck no longer trigger manual-refresh|rig= or restart a per-rig mayor while the rig remains manually hibernated. Regression coverage: test_president_runtime_supervision.sh and test_president_runtime_latest_main_proof.sh. +- 2026-04-01T11:50:34+02:00 — 2026-04-01 issue #366: when Ralph keeps a rig pending, plan-state completion.acceptance now mirrors the effective pending status instead of leaking stale verified/waived/blocked terminal metadata; original declared terminal status and timestamps are preserved under completion.acceptance.declared_* for operator forensics, with regression coverage in test_ralph_mode_config_and_state.sh. +- 2026-04-01T11:50:48+02:00 — 2026-04-01 issue #356 follow-up: current origin/master already covers the duplicate-close continuation redispatch bug through the later plan-task lineage/reopen fix train from PR #365. Effective guardrails are retained merged plan-task issue lineage plus explicit reopen semantics; unchanged pending/planned task defaults no longer reopen completed work, so refill/top-up does not recreate the just duplicate-closed lane without a materially changed task payload or explicit open successor. +- 2026-04-01T11:50:54+02:00 — Acceptance blocker sgt-acceptance-1775037054-36b6a910 reported by witness: Replacement work required after stalled polecat for issue #357 + +### Acceptance Blocker sgt-acceptance-1775037054-36b6a910 + +- Reported at: 2026-04-01T11:50:54+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #357 + +```markdown +Replacement work required after stalled polecat for issue #357 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #357 +- Issue URL: https://github.com/codejeet/sgt/issues/357 +- Issue title: unknown +- Polecat: sgt-2255024f +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T11:51:30+02:00 — Acceptance blocker sgt-acceptance-1775037090-5552e67c reported by witness: Replacement work required after stalled polecat for issue #356 + +### Acceptance Blocker sgt-acceptance-1775037090-5552e67c + +- Reported at: 2026-04-01T11:51:30+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #356 + +```markdown +Replacement work required after stalled polecat for issue #356 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #356 +- Issue URL: https://github.com/codejeet/sgt/issues/356 +- Issue title: unknown +- Polecat: sgt-d90e6faf +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T11:51:35+02:00 — Acceptance blocker sgt-acceptance-1775037095-0d58f10b reported by witness: Replacement work required after stalled polecat for issue #366 + +### Acceptance Blocker sgt-acceptance-1775037095-0d58f10b + +- Reported at: 2026-04-01T11:51:35+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #366 + +```markdown +Replacement work required after stalled polecat for issue #366 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #366 +- Issue URL: https://github.com/codejeet/sgt/issues/366 +- Issue title: unknown +- Polecat: sgt-f6cc4fa1 +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T11:52:12+02:00 — Acceptance blocker sgt-acceptance-1775037131-162b91f4 reported by witness: Replacement work required after stalled polecat for issue #359 + +### Acceptance Blocker sgt-acceptance-1775037131-162b91f4 + +- Reported at: 2026-04-01T11:52:12+02:00 +- Reported by: witness +- Title: Replacement work required after stalled polecat for issue #359 + +```markdown +Replacement work required after stalled polecat for issue #359 + +Stalled polecat recovery did not produce replacement work. + +- Rig: sgt +- Repo: codejeet/sgt +- Issue: #359 +- Issue URL: https://github.com/codejeet/sgt/issues/359 +- Issue title: unknown +- Polecat: sgt-7cfb9131 +- Failure reason: witness-stalled-issue-title-unavailable + +Required follow-up: +- create replacement work (new PR or re-dispatched polecat) before closing the incident +- re-investigate why the worker exited without producing a PR + +``` +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775035085-041bcff8 resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775022704-d829bc00 resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775037090-5552e67c resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775027294-89db909c resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775037054-36b6a910 resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775022423-817268fb resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775037131-162b91f4 resolved. +- 2026-04-01T12:07:56+02:00 — Acceptance blocker sgt-acceptance-1775037095-0d58f10b resolved. diff --git a/docs/president-operator-notify-contract.md b/docs/president-operator-notify-contract.md index 3f119fc..fbf9857 100644 --- a/docs/president-operator-notify-contract.md +++ b/docs/president-operator-notify-contract.md @@ -51,6 +51,9 @@ Current President supervision reasons map as follows: - `mayor-session-missing` -> `intervention`, `severity=warning`, `notify=1` - `mayor-heartbeat-invalid` or `mayor-heartbeat-stale` -> `intervention`, `severity=warning`, `notify=1` +- `ralph-underfilled` -> `drift`, `severity=warning`, `notify=1` when President can refill by waking the rig-local Mayor toward existing admissible backlog +- `ralph-idle` -> `drift`, `severity=warning`, `notify=1` when Ralph is still unmet but no live admissible lane remains +- `ralph-contradiction` -> `contradiction`, `severity=warning`, `notify=1` when Ralph is still unmet but the rig looks fake-complete or has no refillable backlog toward target concurrency - `actionable-no-forward-motion` -> `stalled-purpose`, `severity=warning`, `notify=1` - `actionable-rig-recheck` -> `intervention`, `severity=info`, `notify=0` diff --git a/sgt b/sgt index 1350f67..fb91d79 100755 --- a/sgt +++ b/sgt @@ -149,18 +149,28 @@ _status_badge() { } _mayor_architecture() { + local rig_hint="${1:-}" case "${SGT_MAYOR_ARCHITECTURE:-shared}" in per-rig|per_rig) printf 'per-rig\n' - ;; - *) - printf 'shared\n' + return 0 ;; esac + if command -v tmux >/dev/null 2>&1; then + if tmux has-session -t "$(_president_session_name)" 2>/dev/null; then + printf 'per-rig\n' + return 0 + fi + if [[ -n "$rig_hint" ]] && tmux has-session -t "$(_mayor_scope_session_name "$rig_hint")" 2>/dev/null; then + printf 'per-rig\n' + return 0 + fi + fi + printf 'shared\n' } _mayor_architecture_per_rig() { - [[ "$(_mayor_architecture)" == "per-rig" ]] + [[ "$(_mayor_architecture "${1:-}")" == "per-rig" ]] } _president_enabled() { @@ -510,9 +520,26 @@ _mayor_target_rigs_for_reason() { } log_event() { + mkdir -p "$(dirname "$SGT_LOG")" echo "[$(date -Iseconds)] $*" >> "$SGT_LOG" } +_shell_errexit_state() { + if [[ $- == *e* ]]; then + printf '1\n' + else + printf '0\n' + fi +} + +_shell_errexit_restore() { + if [[ "${1:-0}" == "1" ]]; then + set -e + else + set +e + fi +} + _sgt_exec_path() { if [[ -n "${SGT_EXEC_PATH:-}" ]]; then printf '%s\n' "$SGT_EXEC_PATH" @@ -2012,7 +2039,8 @@ _mayor_wake_reason_targets_rig() { } _mayor_rig_activity_snapshot() { - local rig="${1:-}" repo open_prs open_authorized active_polecats merge_queue pending_plan_requests plan_rollup plan_status actionable state reason + local rig="${1:-}" repo open_prs open_authorized active_polecats merge_queue pending_plan_requests plan_rollup plan_status actionable state reason pending_plan_target + local ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked repo="$(rig_repo "$rig" 2>/dev/null || true)" open_prs=0 open_authorized=0 @@ -2055,13 +2083,22 @@ PY IFS='|' read -r plan_rollup plan_status <<< "$plan_snapshot" fi fi + IFS='|' read -r ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked _ _ _ _ <<< "$(_ralph_mode_snapshot_fields "$rig" "$repo" 2>/dev/null || true)" + pending_plan_target="$(_plan_pending_underfill_target "$rig" 2>/dev/null || true)" + [[ "$pending_plan_target" =~ ^[1-9][0-9]*$ ]] || pending_plan_target=0 actionable=0 if (( open_prs > 0 || open_authorized > 0 || active_polecats > 0 || merge_queue > 0 || pending_plan_requests > 0 )); then actionable=1 fi state="idle" reason="no actionable work" - if (( actionable > 0 )); then + if [[ "$ralph_completion_blocked" == "1" ]]; then + state="active" + reason="ralph state=${ralph_state:-active} target=${ralph_target:-1} active_lanes=${ralph_active_lanes:-0} admissible_lanes=${ralph_admissible_lanes:-0} underfilled=${ralph_underfilled:-0} condition=\"${ralph_condition:-}\"" + elif (( pending_plan_target > 0 && active_polecats < pending_plan_target )); then + state="active" + reason="pending-plan-underfilled target=${pending_plan_target} active_polecats=${active_polecats} open_issues=${open_authorized} open_prs=${open_prs} merge_queue=${merge_queue}" + elif (( actionable > 0 )); then state="active" reason="open_issues=${open_authorized} open_prs=${open_prs} active_polecats=${active_polecats} merge_queue=${merge_queue} pending_plan_requests=${pending_plan_requests}" elif [[ "$plan_rollup" == "blocked-pending-external" ]]; then @@ -4276,6 +4313,18 @@ _mayor_pre_dispatch_revalidate() { return 0 } +_mayor_pre_dispatch_fail_open_allowed() { + local reason_code="${1:-}" + case "$reason_code" in + live-query-failed|invalid-live-counts) + return 0 + ;; + *) + return 1 + ;; + esac +} + _pr_head_sha() { local repo="${1:-}" pr="${2:-}" [[ -n "$repo" && -n "$pr" ]] || return 1 @@ -4678,7 +4727,7 @@ EOF _refinery_branch_deleted_state() { local repo="${1:-}" branch="${2:-}" - local owner_repo encoded_branch api_out api_rc + local owner_repo encoded_branch api_out api_rc restore_errexit [[ -n "$repo" && -n "$branch" ]] || { echo "unknown" return 0 @@ -4686,10 +4735,11 @@ _refinery_branch_deleted_state() { owner_repo="$(_repo_owner_repo "$repo")" [[ -n "$owner_repo" ]] || owner_repo="$repo" encoded_branch="${branch//\//%2F}" + restore_errexit="$(_shell_errexit_state)" set +e api_out="$(gh api "repos/$owner_repo/branches/$encoded_branch" 2>&1 >/dev/null)" api_rc=$? - set -e + _shell_errexit_restore "$restore_errexit" if [[ "$api_rc" -eq 0 ]]; then echo "false" return 0 @@ -5237,6 +5287,8 @@ _refinery_merge_error_class() { lc="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" if [[ "$lc" =~ secondary[[:space:]-]*rate[[:space:]-]*limit|abuse[[:space:]]+detection ]]; then echo "secondary-rate-limit" + elif [[ "$lc" =~ merge[[:space:]-]*in[[:space:]-]*progress|being[[:space:]]+merged|already[[:space:]]+being[[:space:]]+merged|already[[:space:]]+merged ]]; then + echo "merge-in-progress" elif [[ "$lc" =~ timed?[[:space:]-]*out|timeout|deadline[[:space:]]+exceeded ]]; then echo "timeout" elif [[ "$lc" =~ (^|[^0-9])5[0-9][0-9]([^0-9]|$)|http[[:space:]]*5[0-9][0-9]|server[[:space:]]+error|bad[[:space:]]+gateway|service[[:space:]]+unavailable|gateway[[:space:]]+timeout ]]; then @@ -5360,18 +5412,25 @@ _resling_pre_dispatch_revalidate() { if [[ -n "$source_pr" && "$source_pr" != "0" ]]; then pr_snapshot=$(gh pr view "$source_pr" --repo "$canonical_repo" --json state,mergeable --jq '.state + "|" + (.mergeable // "")' 2>/dev/null || true) if [[ -z "$pr_snapshot" || "$pr_snapshot" != *"|"* ]]; then - echo "unable to query live PR state for #$source_pr" - return 1 + return 0 fi IFS='|' read -r pr_state pr_mergeable <<< "$pr_snapshot" if [[ "$pr_state" != "OPEN" ]]; then echo "source PR #$source_pr state=$pr_state" return 1 fi - if [[ "$pr_mergeable" != "MERGEABLE" ]]; then - echo "source PR #$source_pr mergeable=${pr_mergeable:-unknown}" - return 1 - fi + case "${pr_mergeable:-}" in + ""|MERGEABLE) + ;; + CONFLICTING) + echo "source PR #$source_pr mergeable=${pr_mergeable:-unknown}" + return 1 + ;; + *) + # GraphQL mergeability can temporarily flake to UNKNOWN/UNSTABLE/etc. + # Allow issue-based redispatch unless GitHub gave a definitive conflict. + ;; + esac fi } @@ -5643,7 +5702,7 @@ _context_index_is_fresh() { } _context_search_run() { - local rig="${1:-}" query="${2:-}" index_path py_bin model api_key context_file search_rc retrying err_file + local rig="${1:-}" query="${2:-}" index_path py_bin model api_key context_file search_rc retrying err_file restore_errexit [[ -n "$query" ]] || die "usage: sgt context search " context_file="$(_ensure_context_file "$rig")" if ! _context_index_is_fresh "$rig"; then @@ -5657,6 +5716,7 @@ _context_search_run() { retrying=0 err_file="$(mktemp)" while true; do + restore_errexit="$(_shell_errexit_state)" set +e "$py_bin" - "$rig" "$index_path" "$model" "$api_key" "$query" 2>"$err_file" <<'PY' import json @@ -5742,7 +5802,7 @@ for score, entry in top: print(f"{score:.3f}\t{date}\tline {line}\t{text}") PY search_rc=$? - set -e + _shell_errexit_restore "$restore_errexit" if [[ "$search_rc" -eq 0 ]]; then cat "$err_file" >&2 rm -f "$err_file" @@ -5911,6 +5971,31 @@ _acceptance_blocker_title() { printf '%.120s' "$title" } +_acceptance_blocker_severity_class() { + local requester="${1:-}" source="${2:-}" title="${3:-}" evidence="${4:-}" haystack + haystack="$(printf '%s\n%s\n%s\n%s\n' "$requester" "$source" "$title" "$evidence" | tr '[:upper:]' '[:lower:]')" + if [[ "$haystack" == *"witness-stalled"* || "$haystack" == *"replacement work required after stalled polecat"* || "$haystack" == *"control-plane"* ]]; then + echo "control-plane" + return 0 + fi + if [[ "$haystack" == *"usage limit"* || "$haystack" == *"usage_limit"* || "$haystack" == *"backend-limited"* || "$haystack" == *"quota"* || "$haystack" == *"candidate no-go"* || "$haystack" == *"no-go"* ]]; then + echo "candidate-no-go" + return 0 + fi + echo "acceptance-red" +} + +_acceptance_blocker_dedupe_key() { + local rig="${1:-}" severity_class="${2:-}" title="${3:-}" normalized_rig normalized_class normalized_title + normalized_rig="$(_normalize_label "$rig")" + normalized_class="$(_normalize_label "$severity_class")" + normalized_title="$(_normalize_label "$title")" + [[ -n "$normalized_rig" ]] || normalized_rig="unknown-rig" + [[ -n "$normalized_class" ]] || normalized_class="unknown-class" + [[ -n "$normalized_title" ]] || normalized_title="verified-acceptance-blocker" + printf 'acceptance-blocker:%s:%s:%s\n' "$normalized_rig" "$normalized_class" "$normalized_title" +} + _plan_request_dir() { local request_id="${1:-}" echo "$(_plan_requests_dir)/$request_id" @@ -5948,39 +6033,45 @@ _plan_cache_path() { _plan_effective_file_path() { local rig="${1:-}" - local canonical_plan_file rpath default_branch current_branch local_plan_dirty remote_plan cache_path cache_tmp + local canonical_plan_file canonical_plan_file="$(_plan_file_path "$rig")" [[ -f "$canonical_plan_file" ]] || return 1 + printf '%s\n' "$canonical_plan_file" +} - rpath="$(rig_path "$rig")" - if [[ ! -d "$rpath" ]] || ! command -v git >/dev/null 2>&1; then - printf '%s\n' "$canonical_plan_file" - return 0 - fi +_plan_request_reconcile_canonical_plan_file() { + local request_id="${1:-}" rig="${2:-}" canonical_plan_file="${3:-}" + local scope_dir workspace_plan_file workspace_rig_plan_file request_meta fallback_plan + [[ -n "$request_id" && -n "$rig" && -n "$canonical_plan_file" ]] || return 1 - git -C "$rpath" fetch origin >/dev/null 2>&1 || true - default_branch="$(git -C "$rpath" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' || true)" - [[ -n "$default_branch" ]] || default_branch="main" - current_branch="$(git -C "$rpath" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - local_plan_dirty="$(git -C "$rpath" status --short -- SGT_PLAN.json 2>/dev/null || true)" + request_meta="$(_plan_request_meta_path "$request_id")" + scope_dir="$(_mayor_scope_dir)" + workspace_plan_file="$scope_dir/mayor-workspace/SGT_PLAN.json" + workspace_rig_plan_file="$scope_dir/mayor-workspace/$rig/SGT_PLAN.json" - if [[ "$current_branch" == "$default_branch" && -z "$local_plan_dirty" ]]; then - printf '%s\n' "$canonical_plan_file" + if [[ -f "$canonical_plan_file" ]]; then + if [[ -f "$request_meta" ]]; then + _plan_request_update_field "$request_id" "PLAN_FILE" "$canonical_plan_file" || true + fi return 0 fi - remote_plan="$(git -C "$rpath" show "origin/$default_branch:SGT_PLAN.json" 2>/dev/null || true)" - if [[ -z "$remote_plan" ]]; then - printf '%s\n' "$canonical_plan_file" - return 0 + fallback_plan="" + if [[ -f "$workspace_rig_plan_file" ]]; then + fallback_plan="$workspace_rig_plan_file" + elif [[ -f "$workspace_plan_file" ]]; then + fallback_plan="$workspace_plan_file" fi - cache_path="$(_plan_cache_path "$rig")" - mkdir -p "$(_plan_cache_dir)" - cache_tmp="${cache_path}.tmp.$$" - printf '%s\n' "$remote_plan" > "$cache_tmp" - mv -f "$cache_tmp" "$cache_path" - printf '%s\n' "$cache_path" + [[ -n "$fallback_plan" ]] || return 1 + + mkdir -p "$(dirname "$canonical_plan_file")" + cp "$fallback_plan" "$canonical_plan_file" || return 1 + if [[ -f "$request_meta" ]]; then + _plan_request_update_field "$request_id" "PLAN_FILE" "$canonical_plan_file" || true + fi + log_event "PLAN_REQUEST_CANONICALIZED request_id=$request_id rig=$rig source=\"$(_escape_quotes "$fallback_plan")\" destination=\"$(_escape_quotes "$canonical_plan_file")\"" + printf '%s\n' "$canonical_plan_file" } _plan_request_id() { @@ -6090,13 +6181,15 @@ EOF _acceptance_blocker_write() { local rig="${1:-}" evidence="${2:-}" requester="${3:-}" source="${4:-argv}" - local blocker_id blocker_dir meta_path evidence_path created_at title + local blocker_id blocker_dir meta_path evidence_path created_at title severity_class dedupe_key blocker_id="$(_acceptance_blocker_id "$rig")" blocker_dir="$(_acceptance_blocker_dir "$blocker_id")" meta_path="$(_acceptance_blocker_meta_path "$blocker_id")" evidence_path="$(_acceptance_blocker_evidence_path "$blocker_id")" created_at="$(date -Iseconds)" title="$(_acceptance_blocker_title "$evidence")" + severity_class="$(_acceptance_blocker_severity_class "$requester" "$source" "$title" "$evidence")" + dedupe_key="$(_acceptance_blocker_dedupe_key "$rig" "$severity_class" "$title")" mkdir -p "$blocker_dir" printf '%s\n' "$evidence" > "$evidence_path" { @@ -6107,6 +6200,8 @@ _acceptance_blocker_write() { printf 'REQUESTING_AGENT_ID=%q\n' "$requester" printf 'SOURCE=%q\n' "$source" printf 'TITLE=%q\n' "$title" + printf 'SEVERITY_CLASS=%q\n' "$severity_class" + printf 'DEDUPE_KEY=%q\n' "$dedupe_key" printf 'EVIDENCE_FILE=%q\n' "$evidence_path" printf 'LAST_UPDATED_AT=%q\n' "$created_at" } > "$meta_path" @@ -6169,7 +6264,9 @@ _acceptance_blocker_list_active() { "${CREATED_AT:-}" \ "${REQUESTING_AGENT_ID:-unknown}" \ "${TITLE:-Verified acceptance blocker}" \ - "${EVIDENCE_FILE:-}" + "${EVIDENCE_FILE:-}" \ + "${SEVERITY_CLASS:-acceptance-red}" \ + "${DEDUPE_KEY:-$(_acceptance_blocker_dedupe_key "${RIG:-}" "${SEVERITY_CLASS:-acceptance-red}" "${TITLE:-Verified acceptance blocker}")}" ;; esac ) @@ -6324,6 +6421,34 @@ os.replace(tmp, path) PY } +_plan_pending_underfill_target() { + local rig="${1:-}" state_file + state_file="$(_plan_state_path "$rig")" + [[ -f "$state_file" ]] || return 0 + python3 - "$state_file" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + state = json.load(fh) +except Exception: + raise SystemExit(0) + +completion = state.get("completion") or {} +if str(completion.get("status") or "").strip().lower() != "pending": + raise SystemExit(0) +if str(completion.get("rollup") or "").strip() != "tasks-exhausted-awaiting-acceptance": + raise SystemExit(0) + +policy = state.get("policy") or {} +target = policy.get("max_in_flight", 1) +if not isinstance(target, int) or target <= 0: + target = 1 +print(target) +PY +} + _mayor_rig_actionable_work_counts() { local rig="${1:-}" repo="${2:-}" local open_prs open_authorized open_polecats pending_plan_requests @@ -6393,6 +6518,14 @@ completion_state = state.get("completion") if not isinstance(completion_state, dict): completion_state = {} +state_acceptance = completion_state.get("acceptance") +if not isinstance(state_acceptance, dict): + state_acceptance = {} + +ralph_state = state.get("ralph") +if not isinstance(ralph_state, dict): + ralph_state = {} + max_in_flight = 1 policy = plan.get("policy") if isinstance(policy, dict): @@ -6415,10 +6548,23 @@ acceptance = plan.get("acceptance") if not isinstance(acceptance, dict): acceptance = {} +state_completion_rollup = str(completion_state.get("rollup") or "").strip().lower() +ralph_pending_override = ( + str(completion_state.get("status") or "").strip().lower() == "pending" + and ( + bool(ralph_state.get("completion_blocked_by_condition")) + or state_completion_rollup.startswith("ralph-") + ) +) +if ralph_pending_override and state_acceptance: + acceptance = dict(state_acceptance) + allowed_completion_status = {"pending", "completed", "verified", "blocked", "waived"} plan_completion_status = str(acceptance.get("status") or "").strip().lower() state_completion_status = str(completion_state.get("status") or "").strip().lower() -if plan_completion_status in allowed_completion_status: +if ralph_pending_override: + completion_status = "pending" +elif plan_completion_status in allowed_completion_status: completion_status = plan_completion_status elif state_completion_status in allowed_completion_status: completion_status = state_completion_status @@ -6430,6 +6576,29 @@ else: def one_line(value): return str(value or "").replace("\t", " ").replace("\n", " ").strip() +def declared_task_status(value): + normalized = str(value or "").strip().lower().replace("-", "_") + if normalized in {"completed", "done", "closed", "verified"}: + return "completed" + if normalized in {"in_progress", "active", "working"}: + return "in_progress" + if normalized in {"dispatched", "queued", "issued"}: + return "dispatched" + if normalized in {"pending", "planned", "open", "todo", "ready"}: + return "pending" + return "" + +def explicit_reopen_requested(task): + if not isinstance(task, dict): + return False + raw_status = str(task.get("status") or "").strip().lower().replace("-", "_") + if raw_status in {"reopen", "reopened", "requeue", "requeued"}: + return True + raw_flag = task.get("reopen") + if isinstance(raw_flag, str): + return raw_flag.strip().lower() in {"1", "true", "yes", "on"} + return bool(raw_flag) + normalized = {} for task in tasks: if not isinstance(task, dict): @@ -6437,10 +6606,55 @@ for task in tasks: task_id = str(task.get("id", "")).strip() if not task_id: continue + title = one_line(task.get("title") or task.get("task") or task_id) + task_text = one_line(task.get("task") or task.get("title") or task_id) + deps = task.get("depends_on") or [] + if not isinstance(deps, list): + deps = [] + deps = [str(dep).strip() for dep in deps if str(dep).strip()] + labels = task.get("labels") or [] + if not isinstance(labels, list): + labels = [] + backend = str(task.get("backend") or "").strip() + task_signature = json.dumps( + { + "title": title, + "task": task_text, + "depends_on": deps, + "labels": [str(label).strip() for label in labels if str(label).strip()], + "backend": backend, + }, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) current = tasks_state.get(task_id) if not isinstance(current, dict): current = {} + current_signature = str(current.get("task_signature") or "").strip() + declared_status = declared_task_status(task.get("status")) + current_status = str(current.get("status") or "").strip() + reopen_requested = explicit_reopen_requested(task) + if reopen_requested and current_status != "pending": + # Explicit reopen intent wins over stale completed/dispatched state. + current = {"status": "pending"} + current_status = "pending" + if declared_status: + if declared_status == "completed": + current["status"] = "completed" + elif reopen_requested and current_status != declared_status: + # Repo-local plan truth can explicitly reopen or requeue a task; + # when that happens, drop stale issue/completion bookkeeping. + current = {"status": declared_status} + elif current_status == "": + current["status"] = declared_status + if current_signature and current_signature != task_signature and current.get("status") == "completed": + # A materially changed task definition should open fresh work instead + # of inheriting stale completion bookkeeping from the previous shape. + current = {"status": "pending"} current.setdefault("status", "pending") + current["task_signature"] = task_signature + current["reopen_requested"] = bool(reopen_requested) normalized[task_id] = current doc = { @@ -6452,7 +6666,7 @@ doc = { "tasks": normalized, } -completion_details = one_line(acceptance.get("details") or completion_state.get("details")) +completion_details = one_line(acceptance.get("details") or acceptance.get("note") or completion_state.get("details")) blocked_reason = one_line(acceptance.get("blocked_reason") or completion_state.get("blocked_reason")) if completion_status != "blocked": blocked_reason = "" @@ -6482,6 +6696,7 @@ updated_at = completion_state.get("updated_at") if updated_at: completion_doc["updated_at"] = str(updated_at) doc["completion"] = completion_doc +doc["ralph"] = ralph_state tmp = state_file + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: @@ -6534,6 +6749,8 @@ for task in tasks: issue_url, ",".join(str(label).strip() for label in labels if str(label).strip()), backend, + str(state_entry.get("task_signature") or ""), + "1" if state_entry.get("reopen_requested") else "0", ]) ) PY @@ -6568,6 +6785,8 @@ if status in ("dispatched", "in_progress") and "dispatched_at" not in entry: entry["dispatched_at"] = now if status == "completed": entry["completed_at"] = now +else: + entry.pop("completed_at", None) doc["updated_at"] = now tmp = state_file + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: @@ -6577,7 +6796,8 @@ PY } _plan_state_update_completion() { - local rig="${1:-}" condition="${2:-}" acceptance_json="${3:-{}}" status="${4:-not_declared}" rollup="${5:-not_declared}" details="${6:-}" blocked_reason="${7:-}" + local rig="${1:-}" condition="${2:-}" acceptance_json="${3:-}" status="${4:-not_declared}" rollup="${5:-not_declared}" details="${6:-}" blocked_reason="${7:-}" + [[ -n "$acceptance_json" ]] || acceptance_json="{}" local state_file state_file="$(_plan_state_path "$rig")" mkdir -p "$(dirname "$state_file")" @@ -6603,6 +6823,32 @@ try: acceptance = dict(previous_acceptance) except Exception: acceptance = dict(previous_acceptance) + +acceptance = dict(acceptance) +for key in list(acceptance): + if str(key).startswith("declared_"): + acceptance.pop(key, None) + +declared_status = str(acceptance.get("status") or "").strip().lower() +declared_terminal_fields = ("completed_at", "verified_at", "blocked_at", "blocked_reason", "waived_at") +if declared_status and declared_status != status: + acceptance["declared_status"] = declared_status + for field in declared_terminal_fields: + value = acceptance.get(field) + if value not in (None, ""): + acceptance[f"declared_{field}"] = value + +acceptance["status"] = status +if status != "completed": + acceptance.pop("completed_at", None) +if status != "verified": + acceptance.pop("verified_at", None) +if status != "blocked": + acceptance.pop("blocked_at", None) + acceptance.pop("blocked_reason", None) +if status != "waived": + acceptance.pop("waived_at", None) + now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") completion["declared"] = bool(condition.strip() or acceptance) completion["condition"] = condition.strip() @@ -6632,6 +6878,39 @@ os.replace(tmp, state_file) PY } +_plan_state_update_ralph() { + local rig="${1:-}" ralph_json="${2:-}" + [[ -n "$ralph_json" ]] || ralph_json="{}" + local state_file + state_file="$(_plan_state_path "$rig")" + mkdir -p "$(dirname "$state_file")" + SGT_PLAN_RALPH_JSON="$ralph_json" python3 - "$state_file" <<'PY' +import json +import os +import sys +from datetime import datetime, timezone + +state_file = sys.argv[1] +doc = {} +if os.path.exists(state_file): + with open(state_file, "r", encoding="utf-8") as fh: + doc = json.load(fh) +try: + ralph = json.loads(os.environ.get("SGT_PLAN_RALPH_JSON", "{}") or "{}") + if not isinstance(ralph, dict): + ralph = {} +except Exception: + ralph = {} + +doc["ralph"] = ralph +doc["updated_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") +tmp = state_file + ".tmp" +with open(tmp, "w", encoding="utf-8") as fh: + json.dump(doc, fh, ensure_ascii=True, indent=2, sort_keys=True) +os.replace(tmp, state_file) +PY +} + _plan_sync_state_and_labels() { local rig="${1:-}" plan_file="${2:-}" repo="${3:-}" [[ -n "$rig" && -n "$plan_file" && -n "$repo" ]] || return 1 @@ -6703,6 +6982,122 @@ print(f"{best[1]}|{best[2]}") PY } +_plan_find_open_task_issue() { + local repo="${1:-}" task_id="${2:-}" current_issue="${3:-}" + local issues_json + [[ -n "$repo" && -n "$task_id" ]] || return 1 + issues_json="$(gh issue list --repo "$repo" --state open --label "plan-$task_id" --limit 100 --json number,url,updatedAt,createdAt 2>/dev/null || true)" + [[ -n "$issues_json" ]] || return 1 + python3 - "$current_issue" "$issues_json" <<'PY' +import json +import sys + +current_issue = str(sys.argv[1] or "").strip() +try: + issues = json.loads(sys.argv[2]) +except Exception: + raise SystemExit(1) + +if not isinstance(issues, list) or not issues: + raise SystemExit(1) + +normalized = [] +for issue in issues: + if not isinstance(issue, dict): + continue + number = str(issue.get("number") or "").strip() + url = str(issue.get("url") or "").strip() + updated_at = str(issue.get("updatedAt") or "").strip() + created_at = str(issue.get("createdAt") or "").strip() + if not number or not url: + continue + normalized.append((number, url, updated_at, created_at)) + +if not normalized: + raise SystemExit(1) + +if current_issue: + for number, url, _updated_at, _created_at in normalized: + if number == current_issue: + print(f"{number}|{url}") + raise SystemExit(0) + +normalized.sort(key=lambda item: (item[2], item[3], item[0])) +number, url, _updated_at, _created_at = normalized[-1] +print(f"{number}|{url}") +PY +} + +_plan_find_latest_merged_task_issue() { + local repo="${1:-}" task_id="${2:-}" + local issues_json pr_json + [[ -n "$repo" && -n "$task_id" ]] || return 1 + issues_json="$(gh issue list --repo "$repo" --state all --label "plan-$task_id" --limit 100 --json number,url,state,closedAt,createdAt 2>/dev/null || true)" + [[ -n "$issues_json" ]] || return 1 + pr_json="$(gh pr list --repo "$repo" --state merged --limit 100 --json number,body,mergedAt 2>/dev/null || true)" + [[ -n "$pr_json" ]] || return 1 + python3 - "$issues_json" "$pr_json" "$(_repo_owner_repo "$repo")" <<'PY' +import json +import re +import sys + +try: + issues = json.loads(sys.argv[1]) +except Exception: + raise SystemExit(1) +try: + pulls = json.loads(sys.argv[2]) +except Exception: + raise SystemExit(1) +owner_repo = str(sys.argv[3] or "").strip().lower() + +if not isinstance(issues, list) or not isinstance(pulls, list): + raise SystemExit(1) + +issue_map = {} +for issue in issues: + if not isinstance(issue, dict): + continue + number = str(issue.get("number") or "").strip() + url = str(issue.get("url") or "").strip() + state = str(issue.get("state") or "").strip().upper() + if not number or not url: + continue + issue_map[number] = {"url": url, "state": state} + +if not issue_map: + raise SystemExit(1) + +pattern = re.compile( + r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:" + + re.escape(owner_repo) + + r"#|#)(\d+)\b" +) + +best = None +for pr in pulls: + if not isinstance(pr, dict): + continue + pr_number = str(pr.get("number") or "").strip() + merged_at = str(pr.get("mergedAt") or "").strip() + body = str(pr.get("body") or "") + if not pr_number or not merged_at: + continue + for issue_number in pattern.findall(body): + if issue_number not in issue_map: + continue + item = issue_map[issue_number] + sort_key = (merged_at, pr_number, issue_number) + if best is None or sort_key > best[0]: + best = (sort_key, issue_number, item["url"], pr_number, merged_at, item["state"]) + +if best is None: + raise SystemExit(1) + +print(f"{best[1]}|{best[2]}|{best[3]}|{best[4]}|{best[5]}") +PY +} + _plan_tick_run() { local rig="${1:-}" tick_source="${2:-manual}" local canonical_plan_file plan_file repo snapshot_line @@ -6714,9 +7109,33 @@ _plan_tick_run() { local max_in_flight=1 local plan_completion_status="not_declared" plan_completion_rollup="not_declared" plan_completion_condition="" plan_acceptance_json="{}" plan_completion_details="" plan_completion_blocked_reason="" + local ralph_json ralph_enabled ralph_condition ralph_condition_status ralph_target ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked + IFS='|' read -r ralph_enabled ralph_condition ralph_condition_status ralph_target ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked _ _ _ _ <<< "$(_ralph_mode_snapshot_fields "$rig" "$repo" 2>/dev/null || true)" + [[ "$ralph_target" =~ ^[0-9]+$ ]] || ralph_target=1 + [[ "$ralph_active_lanes" =~ ^[0-9]+$ ]] || ralph_active_lanes=0 + [[ "$ralph_admissible_lanes" =~ ^[0-9]+$ ]] || ralph_admissible_lanes=0 + [[ "$ralph_backlog_lanes" =~ ^[0-9]+$ ]] || ralph_backlog_lanes=0 + [[ "$ralph_support_lanes" =~ ^[0-9]+$ ]] || ralph_support_lanes=0 + [[ "$ralph_duplicate_lanes" =~ ^[0-9]+$ ]] || ralph_duplicate_lanes=0 + ralph_json="$(printf '{"rig":%s,"enabled":%s,"condition":%s,"condition_status":%s,"target_concurrency":%s,"count_support_lanes":%s,"state":%s,"reason":%s,"active_lane_count":%s,"admissible_lane_count":%s,"backlog_lane_count":%s,"support_lane_count":%s,"duplicate_lane_count":%s,"underfilled":%s,"completion_blocked_by_condition":%s}' \ + "$(_json_quote "$rig")" \ + "$( [[ "$ralph_enabled" == "1" ]] && printf 'true' || printf 'false' )" \ + "$(_json_quote "$ralph_condition")" \ + "$(_json_quote "${ralph_condition_status:-unmet}")" \ + "$ralph_target" \ + "$( [[ "$ralph_count_support" == "1" ]] && printf 'true' || printf 'false' )" \ + "$(_json_quote "${ralph_state:-disabled}")" \ + "$(_json_quote "$ralph_reason")" \ + "$ralph_active_lanes" \ + "$ralph_admissible_lanes" \ + "$ralph_backlog_lanes" \ + "$ralph_support_lanes" \ + "$ralph_duplicate_lanes" \ + "$( [[ "$ralph_underfilled" == "1" ]] && printf 'true' || printf 'false' )" \ + "$( [[ "$ralph_completion_blocked" == "1" ]] && printf 'true' || printf 'false' )")" local -a task_ids=() - local -A task_title=() task_text=() task_deps=() task_status=() task_issue=() task_issue_url=() task_labels=() task_backend=() - while IFS=$'\x1f' read -r record_type c1 c2 c3 c4 c5 c6 c7 c8 c9; do + local -A task_title=() task_text=() task_deps=() task_status=() task_issue=() task_issue_url=() task_labels=() task_backend=() task_signature=() task_reopen_requested=() + while IFS=$'\x1f' read -r record_type c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11; do [[ -n "$record_type" ]] || continue case "$record_type" in CONFIG) @@ -6724,7 +7143,8 @@ _plan_tick_run() { plan_completion_status="${c2:-not_declared}" plan_completion_rollup="${c3:-not_declared}" plan_completion_condition="${c4:-}" - plan_acceptance_json="${c5:-{}}" + plan_acceptance_json="${c5:-}" + [[ -n "$plan_acceptance_json" ]] || plan_acceptance_json="{}" plan_completion_details="${c6:-}" plan_completion_blocked_reason="${c7:-}" ;; @@ -6738,6 +7158,8 @@ _plan_tick_run() { task_issue_url["$c1"]="$c7" task_labels["$c1"]="$c8" task_backend["$c1"]="$c9" + task_signature["$c1"]="$c10" + task_reopen_requested["$c1"]="$c11" ;; esac done < <(_plan_state_snapshot "$rig" "$plan_file" "$canonical_plan_file") @@ -6755,10 +7177,48 @@ _plan_tick_run() { fi local inflight=0 completed=0 total="${#task_ids[@]}" dispatched_count=0 dispatch_failures=0 pending_ready=0 pending_blocked=0 last_failure_reason_code="" last_failure_detail="" - local task_id issue_number issue_state active_polecat merged_pr_snapshot merged_pr_number merged_pr_merged_at - local -A merged_closing_pr_cache=() + local task_id issue_number issue_state active_polecat merged_pr_snapshot merged_pr_number merged_pr_merged_at explicit_issue_snapshot explicit_issue_number explicit_issue_url previous_issue_number previous_task_status merged_task_snapshot merged_task_issue_number merged_task_issue_url merged_task_pr_number merged_task_pr_merged_at merged_task_issue_state + local -A merged_closing_pr_cache=() merged_task_issue_cache=() for task_id in "${task_ids[@]}"; do issue_number="${task_issue[$task_id]}" + if [[ "${task_reopen_requested[$task_id]:-0}" != "1" ]]; then + if [[ -v "merged_task_issue_cache[$task_id]" ]]; then + merged_task_snapshot="${merged_task_issue_cache[$task_id]}" + else + merged_task_snapshot="$(_plan_find_latest_merged_task_issue "$repo" "$task_id" 2>/dev/null || true)" + merged_task_issue_cache["$task_id"]="$merged_task_snapshot" + fi + if [[ -n "$merged_task_snapshot" && "$merged_task_snapshot" == *"|"* ]]; then + IFS='|' read -r merged_task_issue_number merged_task_issue_url merged_task_pr_number merged_task_pr_merged_at merged_task_issue_state <<< "$merged_task_snapshot" + if [[ -n "$merged_task_issue_number" && ( "${task_status[$task_id]}" != "completed" || "$issue_number" != "$merged_task_issue_number" ) ]]; then + _plan_state_update_task "$rig" "$task_id" "completed" "$merged_task_issue_number" "${merged_task_issue_url:-${task_issue_url[$task_id]}}" + task_status["$task_id"]="completed" + task_issue["$task_id"]="$merged_task_issue_number" + task_issue_url["$task_id"]="${merged_task_issue_url:-${task_issue_url[$task_id]}}" + issue_number="$merged_task_issue_number" + log_event "PLAN_TASK_RETAIN_MERGED_LINEAGE rig=$rig task=$task_id issue=#$merged_task_issue_number pr=#${merged_task_pr_number:-unknown} merged_at=${merged_task_pr_merged_at:-unknown} source=$tick_source" + fi + fi + fi + explicit_issue_snapshot="" + if [[ "${task_reopen_requested[$task_id]:-0}" == "1" ]]; then + explicit_issue_snapshot="$(_plan_find_open_task_issue "$repo" "$task_id" "$issue_number" 2>/dev/null || true)" + fi + if [[ -n "$explicit_issue_snapshot" && "$explicit_issue_snapshot" == *"|"* ]]; then + IFS='|' read -r explicit_issue_number explicit_issue_url <<< "$explicit_issue_snapshot" + if [[ -n "$explicit_issue_number" && ( "$issue_number" != "$explicit_issue_number" || "${task_status[$task_id]}" == "completed" ) ]]; then + previous_issue_number="${task_issue[$task_id]:-}" + previous_task_status="${task_status[$task_id]}" + issue_number="$explicit_issue_number" + task_issue["$task_id"]="$explicit_issue_number" + task_issue_url["$task_id"]="${explicit_issue_url:-${task_issue_url[$task_id]}}" + if [[ "${task_status[$task_id]}" == "completed" ]]; then + task_status["$task_id"]="pending" + fi + _plan_state_update_task "$rig" "$task_id" "${task_status[$task_id]}" "$issue_number" "${task_issue_url[$task_id]}" + log_event "PLAN_TASK_CANONICAL_ISSUE_BIND rig=$rig task=$task_id issue=#$issue_number previous_issue=#${previous_issue_number:-none} previous_status=${previous_task_status:-pending} source=$tick_source" + fi + fi if [[ -n "$issue_number" ]]; then issue_state="$(gh issue view "$issue_number" --repo "$repo" --json state --jq '.state // ""' 2>/dev/null || true)" if [[ "$issue_state" == "CLOSED" && "${task_status[$task_id]}" != "completed" ]]; then @@ -6916,6 +7376,45 @@ _plan_tick_run() { ;; esac + local live_active_polecats pending_plan_target continuation_blocker_id continuation_reason continuation_target continuation_context + live_active_polecats="$(_active_polecat_count "$rig" 2>/dev/null || true)" + [[ "$live_active_polecats" =~ ^[0-9]+$ ]] || live_active_polecats=0 + pending_plan_target=0 + if [[ "$plan_completion_status" == "pending" && "$plan_completion_rollup" == "tasks-exhausted-awaiting-acceptance" ]]; then + pending_plan_target="$max_in_flight" + fi + continuation_target="$pending_plan_target" + continuation_context="acceptance remains pending with repo-local continuation intent open" + if [[ "$ralph_completion_blocked" == "1" && "$ralph_target" =~ ^[1-9][0-9]*$ && "$ralph_target" -gt "$continuation_target" ]]; then + continuation_target="$ralph_target" + continuation_context="Ralph continuation intent remains active while condition is unmet" + fi + if (( continuation_target > 0 && live_active_polecats < continuation_target )); then + continuation_reason="${continuation_context}; live_polecats=${live_active_polecats} target=${continuation_target} rollup=${plan_completion_rollup}" + continuation_blocker_id="$(_plan_blocker_record "$rig" "__continuation__" "Plan continuation" "continuation-underfilled" "$continuation_reason" "$tick_source" 2>/dev/null || true)" + log_event "PLAN_MOVEMENT_BLOCKER rig=$rig task=__continuation__ blocker_id=${continuation_blocker_id:-unavailable} reason_code=continuation-underfilled source=$tick_source reason=\"$(_escape_quotes "$continuation_reason")\"" + if [[ "$tick_source" == "mayor" ]]; then + _mayor_record_decision "PLAN MOVEMENT BLOCKER rig=$rig task=__continuation__ blocker_id=${continuation_blocker_id:-unavailable} reason_code=continuation-underfilled retry=next-mayor-cycle reason=\"$(_escape_quotes "$continuation_reason")\"" "plan-movement-blocker" "$SGT_ROOT" || true + fi + else + _plan_blocker_resolve "$rig" "__continuation__" || true + fi + + if [[ "$ralph_completion_blocked" == "1" ]]; then + plan_completion_status="pending" + if [[ "$ralph_underfilled" == "1" ]]; then + plan_completion_rollup="ralph-underfilled" + else + plan_completion_rollup="ralph-condition-unmet" + fi + if [[ -n "$plan_completion_details" ]]; then + plan_completion_details="${plan_completion_details} Ralph mode remains active until condition is met or Ralph is disabled." + else + plan_completion_details="Ralph mode remains active until condition is met or Ralph is disabled." + fi + plan_completion_blocked_reason="ralph condition unmet: ${ralph_condition:-operator-defined}" + fi + _plan_state_update_completion \ "$rig" \ "$plan_completion_condition" \ @@ -6924,6 +7423,7 @@ _plan_tick_run() { "$plan_completion_rollup" \ "$plan_completion_details" \ "$plan_completion_blocked_reason" + _plan_state_update_ralph "$rig" "$ralph_json" printf 'PLAN_TICK|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ "$rig" \ @@ -7011,6 +7511,73 @@ cmd_config_notify() { info "rig $rig notify_agent set to: $agent_id" } +cmd_config_ralph() { + local rig="${1:-}" enabled="" condition="" condition_status="" target="" count_support="" current_json + [[ -n "$rig" ]] || die "usage: sgt config ralph [--enable|--disable] [--condition ] [--condition-status ] [--target ] [--count-support ]" + shift || true + ensure_init + ensure_rig "$rig" + + current_json="$(_ralph_mode_config_json "$rig" 2>/dev/null || printf '{}\n')" + if [[ -n "$current_json" ]]; then + IFS='|' read -r enabled condition condition_status target count_support _ _ _ _ _ _ _ _ _ _ _ _ _ <<< "$(_ralph_mode_snapshot_fields "$rig" "$(rig_repo "$rig" 2>/dev/null || true)" 2>/dev/null || true)" + fi + [[ "$enabled" == "1" ]] && enabled="true" || enabled="false" + [[ -n "$condition_status" ]] || condition_status="unmet" + [[ -n "$target" ]] || target="1" + [[ "$count_support" == "1" ]] && count_support="true" || count_support="false" + + while [[ $# -gt 0 ]]; do + case "$1" in + --enable) enabled="true"; shift ;; + --disable) enabled="false"; shift ;; + --condition) condition="${2:-}"; shift 2 ;; + --condition-status) + condition_status="${2:-}" + case "$condition_status" in + unmet|met|waived) ;; + *) die "invalid Ralph condition status: $condition_status (try: unmet, met, waived)" ;; + esac + shift 2 + ;; + --target) + target="${2:-}" + [[ "$target" =~ ^[1-9][0-9]*$ ]] || die "Ralph target must be a positive integer" + shift 2 + ;; + --count-support) + case "${2:-}" in + yes|true|1|on) count_support="true" ;; + no|false|0|off) count_support="false" ;; + *) die "invalid Ralph support-lane flag: ${2:-} (try: yes or no)" ;; + esac + shift 2 + ;; + *) + die "usage: sgt config ralph [--enable|--disable] [--condition ] [--condition-status ] [--target ] [--count-support ]" + ;; + esac + done + + if [[ "$enabled" == "true" && -z "$condition" ]]; then + die "Ralph mode requires a non-empty --condition when enabled" + fi + + _ralph_mode_config_write "$rig" "$enabled" "$condition" "$condition_status" "$target" "$count_support" || die "failed to write Ralph config for $rig" + log_event "RIG_RALPH_MODE_SET rig=$rig enabled=$enabled condition_status=$condition_status target=$target count_support_lanes=$count_support condition=\"$(_escape_quotes "$condition")\"" + + local ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked ralph_last_ts ralph_last_action ralph_last_reason ralph_last_outcome + IFS='|' read -r ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked ralph_last_ts ralph_last_action ralph_last_reason ralph_last_outcome <<< "$(_ralph_mode_snapshot_fields "$rig" "$(rig_repo "$rig" 2>/dev/null || true)" 2>/dev/null || true)" + + info "rig $rig Ralph mode: $( [[ "$ralph_enabled" == "1" ]] && printf 'enabled' || printf 'disabled' )" + info "condition: ${ralph_condition:-"(none)"}" + info "condition_status: ${ralph_condition_status:-unmet}" + info "target_concurrency: ${ralph_target:-1}" + info "active_lanes: ${ralph_active_lanes:-0} admissible_lanes: ${ralph_admissible_lanes:-0} backlog_lanes: ${ralph_backlog_lanes:-0}" + info "support_excluded: ${ralph_support_lanes:-0} duplicate_excluded: ${ralph_duplicate_lanes:-0} underfilled: ${ralph_underfilled:-0}" + info "state: ${ralph_state:-unknown} (${ralph_reason:-no detail})" +} + cmd_create_blocker() { local rig="${1:-}" explicit_agent="" evidence_file="" evidence="" evidence_source="argv" [[ -n "$rig" ]] || die "usage: sgt create blocker [--file ] [--agent ]" @@ -7034,32 +7601,43 @@ cmd_create_blocker() { evidence="$(printf '%s' "$evidence" | sed 's/[[:space:]]*$//')" [[ -n "$evidence" ]] || die "no blocker evidence provided" - local requester blocker_id title + local requester blocker_id title severity_class dedupe_key meta_path requester="$(_resolve_requesting_openclaw_agent_id "$explicit_agent")" title="$(_acceptance_blocker_title "$evidence")" _ensure_context_file "$rig" >/dev/null blocker_id="$(_acceptance_blocker_write "$rig" "$evidence" "$requester" "$evidence_source")" + meta_path="$(_acceptance_blocker_meta_path "$blocker_id")" + severity_class="" + dedupe_key="" + if [[ -f "$meta_path" ]]; then + # shellcheck disable=SC1090 + source "$meta_path" + severity_class="${SEVERITY_CLASS:-acceptance-red}" + dedupe_key="${DEDUPE_KEY:-}" + fi _context_append_acceptance_blocker "$rig" "$blocker_id" "$requester" "$title" "$evidence" if [[ -n "${OPENAI_API_KEY:-}" ]] && [[ -n "$(_context_python_bin)" ]]; then _context_index_build "$rig" >/dev/null 2>&1 || true fi _wake_mayor "acceptance-blocker:${rig}:${blocker_id}" - log_event "ACCEPTANCE_BLOCKER_CREATE rig=$rig blocker_id=$blocker_id requester=$requester source=$evidence_source title=\"$(_escape_quotes "$title")\"" + log_event "ACCEPTANCE_BLOCKER_CREATE rig=$rig blocker_id=$blocker_id requester=$requester source=$evidence_source severity_class=$severity_class dedupe_key=$dedupe_key title=\"$(_escape_quotes "$title")\"" info "acceptance blocker recorded: $blocker_id" info "title: $title" + info "class: ${severity_class:-acceptance-red}" info "context updated: $(_context_file_path "$rig")" info "mayor notified: acceptance-blocker:${rig}:${blocker_id}" } cmd_blocker_list() { - local rig="${1:-}" blocker_count=0 blocker_id blocker_rig blocker_status blocker_created blocker_requester blocker_title + local rig="${1:-}" blocker_count=0 blocker_id blocker_rig blocker_status blocker_created blocker_requester blocker_title blocker_evidence blocker_class blocker_dedupe_key ensure_init - while IFS='|' read -r blocker_id blocker_rig blocker_status blocker_created blocker_requester blocker_title _; do + while IFS='|' read -r blocker_id blocker_rig blocker_status blocker_created blocker_requester blocker_title blocker_evidence blocker_class blocker_dedupe_key; do [[ -n "$blocker_id" ]] || continue blocker_count=$((blocker_count + 1)) echo "$blocker_id [$blocker_status]" echo " rig: $blocker_rig" echo " title: $blocker_title" + echo " class: ${blocker_class:-acceptance-red}" echo " reporter: $blocker_requester" echo " created: $blocker_created" done < <(_acceptance_blocker_list_active "$rig") @@ -7179,6 +7757,425 @@ with open(path, "w") as f: ' "$config_path" "$agent_id" 2>/dev/null } +_ralph_mode_config_json() { + local rig="${1:-}" config_path="" py_bin="" + [[ -n "$rig" ]] || return 1 + config_path="$(_rig_config_path "$rig")" + if command -v python3 &>/dev/null; then + py_bin="python3" + elif command -v python &>/dev/null; then + py_bin="python" + else + return 1 + fi + "$py_bin" - "$config_path" <<'PY' 2>/dev/null +import json +import os +import sys + +path = sys.argv[1] +data = {} +if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception: + data = {} + +raw = data.get("ralph_mode") +if not isinstance(raw, dict): + raw = {} + +enabled = bool(raw.get("enabled", False)) +condition = str(raw.get("condition") or raw.get("condition_text") or "").strip() +target = raw.get("target_concurrency", raw.get("concurrency", 1)) +try: + target = int(target) +except Exception: + target = 1 +if target <= 0: + target = 1 + +condition_status = str(raw.get("condition_status") or "unmet").strip().lower() +if condition_status not in {"unmet", "met", "waived"}: + condition_status = "unmet" + +count_support = raw.get("count_support_lanes", False) +if isinstance(count_support, str): + count_support = count_support.strip().lower() in {"1", "true", "yes", "on"} +else: + count_support = bool(count_support) + +support_labels = raw.get("support_labels") +if not isinstance(support_labels, list): + support_labels = ["support-only", "cleanup-only"] +support_labels = [str(item).strip().lower() for item in support_labels if str(item).strip()] +if not support_labels: + support_labels = ["support-only", "cleanup-only"] + +duplicate_labels = raw.get("duplicate_labels") +if not isinstance(duplicate_labels, list): + duplicate_labels = ["duplicate", "redundant"] +duplicate_labels = [str(item).strip().lower() for item in duplicate_labels if str(item).strip()] +if not duplicate_labels: + duplicate_labels = ["duplicate", "redundant"] + +doc = { + "enabled": enabled, + "condition": condition, + "condition_status": condition_status, + "target_concurrency": target, + "count_support_lanes": count_support, + "support_labels": support_labels, + "duplicate_labels": duplicate_labels, + "lane_policy": "distinct-open-authorized-issues; exclude duplicate labels; exclude support-only labels unless count_support_lanes=true; active lanes require a live polecat on that issue", +} +print(json.dumps(doc, ensure_ascii=True, sort_keys=True)) +PY +} + +_ralph_mode_config_write() { + local rig="${1:-}" enabled="${2:-}" condition="${3:-}" condition_status="${4:-}" target="${5:-}" count_support="${6:-}" + local config_path="" py_bin="" + [[ -n "$rig" ]] || return 1 + config_path="$(_rig_config_path "$rig")" + mkdir -p "$SGT_RIG_CONFIG" + if command -v python3 &>/dev/null; then + py_bin="python3" + elif command -v python &>/dev/null; then + py_bin="python" + else + return 1 + fi + "$py_bin" - "$config_path" "$enabled" "$condition" "$condition_status" "$target" "$count_support" <<'PY' 2>/dev/null +import json +import os +import sys + +path, enabled_raw, condition, condition_status, target_raw, count_support_raw = sys.argv[1:7] +data = {} +if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception: + data = {} + +condition_status = str(condition_status or "unmet").strip().lower() +if condition_status not in {"unmet", "met", "waived"}: + condition_status = "unmet" + +try: + target = int(target_raw) +except Exception: + target = 1 +if target <= 0: + target = 1 + +count_support = str(count_support_raw or "").strip().lower() in {"1", "true", "yes", "on"} + +data["ralph_mode"] = { + "enabled": str(enabled_raw).strip().lower() in {"1", "true", "yes", "on"}, + "condition": str(condition or "").strip(), + "condition_status": condition_status, + "target_concurrency": target, + "count_support_lanes": count_support, + "support_labels": ["support-only", "cleanup-only"], + "duplicate_labels": ["duplicate", "redundant"], +} + +with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=True, indent=2, sort_keys=True) + fh.write("\n") +PY +} + +_president_operator_event_latest_for_rig_json() { + local rig="${1:-}" state_file py_bin="" + [[ -n "$rig" ]] || { + printf '{}\n' + return 0 + } + state_file="$(_president_operator_events_file)" + if command -v python3 &>/dev/null; then + py_bin="python3" + elif command -v python &>/dev/null; then + py_bin="python" + else + printf '{}\n' + return 0 + fi + "$py_bin" - "$rig" "$state_file" <<'PY' 2>/dev/null +import json +import os +import sys + +rig, path = sys.argv[1:3] +if not os.path.isfile(path): + print("{}") + raise SystemExit(0) + +latest = None +with open(path, "r", encoding="utf-8") as fh: + for raw in fh: + raw = raw.rstrip("\n") + if not raw: + continue + parts = raw.split("\t") + if len(parts) < 13: + continue + if parts[2] != rig: + continue + latest = { + "ts": parts[0], + "created_at": parts[1], + "rig": parts[2], + "kind": parts[3], + "severity": parts[4], + "notify": parts[5] == "1", + "dedupe_key": parts[6], + "overlap_key": parts[7], + "action": parts[8], + "reason": parts[9], + "outcome": parts[10], + "cycle_trigger": parts[11], + "detail": parts[12], + } + +print(json.dumps(latest or {}, ensure_ascii=True, sort_keys=True)) +PY +} + +_ralph_mode_snapshot_json() { + local rig="${1:-}" repo="${2:-}" config_json issues_json active_issues president_action_json py_bin="" + [[ -n "$rig" ]] || return 1 + if command -v python3 &>/dev/null; then + py_bin="python3" + elif command -v python &>/dev/null; then + py_bin="python" + else + return 1 + fi + + config_json="$(_ralph_mode_config_json "$rig" 2>/dev/null || printf '{}\n')" + issues_json='[]' + if [[ -n "$repo" ]]; then + issues_json="$(gh issue list --repo "$repo" --state open --label "sgt-authorized" --json number,labels,title 2>/dev/null || printf '[]\n')" + fi + active_issues="$( + local f issue session alive + for f in "$SGT_POLECATS"/*; do + [[ -f "$f" ]] || continue + issue="" + session="" + alive="dead" + ( + source "$f" 2>/dev/null || exit 0 + [[ -n "${ISSUE:-}" ]] || exit 0 + session="${SESSION:-}" + if [[ -n "$session" ]] && tmux has-session -t "$session" 2>/dev/null; then + printf '%s\n' "${ISSUE#\#}" + fi + ) + done + )" + president_action_json="$(_president_operator_event_latest_for_rig_json "$rig" 2>/dev/null || printf '{}\n')" + + SGT_RALPH_CONFIG_JSON="$config_json" \ + SGT_RALPH_ISSUES_JSON="$issues_json" \ + SGT_RALPH_ACTIVE_ISSUES="$active_issues" \ + SGT_RALPH_PRESIDENT_ACTION_JSON="$president_action_json" \ + "$py_bin" - "$rig" <<'PY' +import json +import os +import sys + +rig = sys.argv[1] + +try: + config = json.loads(os.environ.get("SGT_RALPH_CONFIG_JSON", "{}")) +except Exception: + config = {} +if not isinstance(config, dict): + config = {} + +try: + issues = json.loads(os.environ.get("SGT_RALPH_ISSUES_JSON", "[]")) +except Exception: + issues = [] +if not isinstance(issues, list): + issues = [] + +try: + president_action = json.loads(os.environ.get("SGT_RALPH_PRESIDENT_ACTION_JSON", "{}")) +except Exception: + president_action = {} +if not isinstance(president_action, dict): + president_action = {} + +active_issue_numbers = { + line.strip().lstrip("#") + for line in os.environ.get("SGT_RALPH_ACTIVE_ISSUES", "").splitlines() + if line.strip() +} + +enabled = bool(config.get("enabled", False)) +condition = str(config.get("condition") or "").strip() +condition_status = str(config.get("condition_status") or "unmet").strip().lower() +if condition_status not in {"unmet", "met", "waived"}: + condition_status = "unmet" +target = config.get("target_concurrency", 1) +try: + target = int(target) +except Exception: + target = 1 +if target <= 0: + target = 1 + +count_support = bool(config.get("count_support_lanes", False)) +support_labels = { + str(label).strip().lower() + for label in (config.get("support_labels") or ["support-only", "cleanup-only"]) + if str(label).strip() +} +duplicate_labels = { + str(label).strip().lower() + for label in (config.get("duplicate_labels") or ["duplicate", "redundant"]) + if str(label).strip() +} + +admissible = set() +support_only = set() +duplicate = set() +issue_titles = {} +for issue in issues: + if not isinstance(issue, dict): + continue + number = str(issue.get("number") or "").strip().lstrip("#") + if not number: + continue + labels = { + str((label or {}).get("name") or "").strip().lower() + for label in (issue.get("labels") or []) + if isinstance(label, dict) + } + issue_titles[number] = str(issue.get("title") or "").strip() + if labels & duplicate_labels: + duplicate.add(number) + continue + if (labels & support_labels) and not count_support: + support_only.add(number) + continue + admissible.add(number) + +active_admissible = sorted(admissible & active_issue_numbers, key=lambda item: int(item)) +backlog_admissible = sorted(admissible - set(active_admissible), key=lambda item: int(item)) + +condition_met = condition_status in {"met", "waived"} +completion_blocked = enabled and not condition_met +underfilled = completion_blocked and len(active_admissible) < target + +if not enabled: + state = "disabled" + reason = "Ralph mode disabled" +elif condition_met: + state = "satisfied" + reason = f"condition_status={condition_status}" +elif underfilled and not active_admissible: + state = "idle" + reason = f"condition unmet; no active admissible lanes toward target {target}" +elif underfilled: + state = "underfilled" + reason = f"condition unmet; active_lanes={len(active_admissible)} target={target}" +else: + state = "active" + reason = f"condition unmet; active_lanes={len(active_admissible)} target={target}" + +doc = { + "rig": rig, + "enabled": enabled, + "condition": condition, + "condition_status": condition_status, + "target_concurrency": target, + "count_support_lanes": count_support, + "lane_policy": config.get("lane_policy") or ( + "distinct-open-authorized-issues; exclude duplicate labels; " + "exclude support-only labels unless count_support_lanes=true; " + "active lanes require a live polecat on that issue" + ), + "state": state, + "reason": reason, + "active_lane_count": len(active_admissible), + "admissible_lane_count": len(admissible), + "backlog_lane_count": len(backlog_admissible), + "support_lane_count": len(support_only), + "duplicate_lane_count": len(duplicate), + "underfilled": underfilled, + "completion_blocked_by_condition": completion_blocked, + "active_issue_numbers": active_admissible, + "backlog_issue_numbers": backlog_admissible, + "support_issue_numbers": sorted(support_only, key=lambda item: int(item)), + "duplicate_issue_numbers": sorted(duplicate, key=lambda item: int(item)), + "active_issue_titles": {num: issue_titles.get(num, "") for num in active_admissible}, + "backlog_issue_titles": {num: issue_titles.get(num, "") for num in backlog_admissible}, + "last_president_action": president_action, +} + +print(json.dumps(doc, ensure_ascii=True, sort_keys=True)) +PY +} + +_ralph_mode_snapshot_fields() { + local rig="${1:-}" repo="${2:-}" py_bin="" + [[ -n "$rig" ]] || return 1 + if command -v python3 &>/dev/null; then + py_bin="python3" + elif command -v python &>/dev/null; then + py_bin="python" + else + return 1 + fi + local snapshot_json + snapshot_json="$(_ralph_mode_snapshot_json "$rig" "$repo" 2>/dev/null || printf '{}\n')" + SGT_RALPH_SNAPSHOT_JSON="$snapshot_json" "$py_bin" - <<'PY' +import json +import os + +try: + doc = json.loads(os.environ.get("SGT_RALPH_SNAPSHOT_JSON", "{}")) +except Exception: + doc = {} +if not isinstance(doc, dict): + doc = {} + +last = doc.get("last_president_action") +if not isinstance(last, dict): + last = {} + +fields = [ + "1" if doc.get("enabled") else "0", + str(doc.get("condition") or ""), + str(doc.get("condition_status") or "unmet"), + str(doc.get("target_concurrency") or 1), + "1" if doc.get("count_support_lanes") else "0", + str(doc.get("state") or "disabled"), + str(doc.get("reason") or ""), + str(doc.get("active_lane_count") or 0), + str(doc.get("admissible_lane_count") or 0), + str(doc.get("backlog_lane_count") or 0), + str(doc.get("support_lane_count") or 0), + str(doc.get("duplicate_lane_count") or 0), + "1" if doc.get("underfilled") else "0", + "1" if doc.get("completion_blocked_by_condition") else "0", + str(last.get("created_at") or ""), + str(last.get("action") or ""), + str(last.get("reason") or ""), + str(last.get("outcome") or ""), +] +print("|".join(fields)) +PY +} + # Notify via OpenClaw (delivered) if configured. No-op if missing. _openclaw_notify_config_read() { local notify_out="" py_bin="" py_parse_notify='import json, sys @@ -7353,12 +8350,14 @@ _mayor_notify_rigger() { verify_details="" local -a cmd=(openclaw agent --agent "$agent" --deliver --json --message "[SGT Mayor] $message" --channel "$channel") + local restore_errexit [[ -n "$to" ]] && cmd+=(--to "$to") [[ -n "$reply_to" ]] && cmd+=(--reply-to "$reply_to") + restore_errexit="$(_shell_errexit_state)" set +e verify_details="$("${cmd[@]}" 2>&1)" send_rc=$? - set -e + _shell_errexit_restore "$restore_errexit" verify_details="$(_one_line "$verify_details")" _mayor_notify_classify_receipt "$send_rc" "$verify_details" @@ -7422,11 +8421,37 @@ _mayor_notify_rigger() { return 1 } +_mayor_notify_rigger_fail_open() { + local message="${1:-}" + local suppress_decision_log="${2:-0}" + local notify_rig="${3:-${SGT_MAYOR_SCOPE_RIG:-}}" + local now_epoch + if _mayor_notify_rigger "$message" "$suppress_decision_log" "$notify_rig"; then + _mayor_notify_alert_state_clear || true + return 0 + fi + + now_epoch="$(date +%s)" + _mayor_notify_alert_state_write \ + "$now_epoch" \ + "${_MAYOR_NOTIFY_RESULT_CHANNEL:-rigger}" \ + "${_MAYOR_NOTIFY_RESULT_TARGET:-rigger}" \ + "${_MAYOR_NOTIFY_RESULT_MESSAGE_KEY:-unknown}" \ + "${_MAYOR_NOTIFY_RESULT_ATTEMPT:-0}" \ + "${_MAYOR_NOTIFY_RESULT_OUTCOME:-failed}" \ + "${_MAYOR_NOTIFY_RESULT_REASON:-unknown}" \ + "${_MAYOR_NOTIFY_RESULT_MATCHER:-unknown}" \ + "true" || true + log_event "MAYOR_NOTIFY_FAIL_OPEN channel=${_MAYOR_NOTIFY_RESULT_CHANNEL:-rigger} target=\"$(_escape_quotes "${_MAYOR_NOTIFY_RESULT_TARGET:-rigger}")\" message_key=${_MAYOR_NOTIFY_RESULT_MESSAGE_KEY:-unknown} attempt=${_MAYOR_NOTIFY_RESULT_ATTEMPT:-0} outcome=${_MAYOR_NOTIFY_RESULT_OUTCOME:-failed} reason=${_MAYOR_NOTIFY_RESULT_REASON:-unknown} matcher=${_MAYOR_NOTIFY_RESULT_MATCHER:-unknown} escalated=${_MAYOR_NOTIFY_RESULT_ESCALATED:-0} action=retain-warning-continue" + echo "[mayor] notify warning retained without failing command (outcome=${_MAYOR_NOTIFY_RESULT_OUTCOME:-failed} reason=${_MAYOR_NOTIFY_RESULT_REASON:-unknown} matcher=${_MAYOR_NOTIFY_RESULT_MATCHER:-unknown})" + return 0 +} + # Wake the mayor via FIFO (non-blocking, fire-and-forget) _wake_mayor() { local reason="${1:-unknown}" if _mayor_architecture_per_rig; then - local rig sent=0 + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" rig sent=0 for rig in $(_mayor_target_rigs_for_reason "$reason"); do _mayor_scope_apply "$rig" if [[ -p "$SGT_MAYOR_FIFO" ]]; then @@ -7437,7 +8462,7 @@ _wake_mayor() { sent=1 fi done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 0 fi if [[ -p "$SGT_MAYOR_FIFO" ]]; then @@ -7877,34 +8902,40 @@ cmd_sling() { mismatch_categories="${_MAYOR_PRE_DISPATCH_MISMATCH_CATEGORIES:-}" snapshot_summary="${_MAYOR_PRE_DISPATCH_SNAPSHOT_SUMMARY:-unavailable}" live_summary="${_MAYOR_PRE_DISPATCH_LIVE_SUMMARY:-unavailable}" - skip_context="dispatch-skip-stale" - skip_header="MAYOR DISPATCH SKIP (stale-state)" - skip_event="MAYOR_DISPATCH_SKIP_STALE" - if [[ "$reason_code" == "parallel-budget-exhausted" ]]; then - skip_context="dispatch-skip-budget" - skip_header="MAYOR DISPATCH SKIP (parallel-budget)" - skip_event="MAYOR_DISPATCH_SKIP_BUDGET" - elif [[ "$reason_code" == "consistency-mismatch" ]]; then - skip_context="dispatch-skip-consistency" - skip_header="MAYOR DISPATCH SKIP (consistency-mismatch)" - skip_event="MAYOR_DISPATCH_SKIP_CONSISTENCY" - fi - echo "[mayor] dispatch skipped on $rig - $revalidation_reason" - local stale_entry="" - stale_entry+="$skip_header"$'\n' - stale_entry+=" rig=$rig"$'\n' - stale_entry+=" reason_code=$reason_code"$'\n' - stale_entry+=" reason=$revalidation_reason"$'\n' - if [[ "$reason_code" == "consistency-mismatch" ]]; then - stale_entry+=" mismatch_categories=${mismatch_categories:-unknown}"$'\n' - stale_entry+=" snapshot_summary=$snapshot_summary"$'\n' - stale_entry+=" live_summary=$live_summary"$'\n' - stale_entry+=" retry=next-mayor-cycle"$'\n' - fi - stale_entry+=" task=\"$(_escape_quotes "$task")\"" - _mayor_record_decision "$stale_entry" "$skip_context" "$SGT_ROOT" || true - log_event "$skip_event reason_code=$reason_code rig=$rig mismatch_categories=\"${mismatch_categories:-}\" snapshot=\"$(_escape_quotes "$snapshot_summary")\" live=\"$(_escape_quotes "$live_summary")\" reason=\"$(_escape_quotes "$revalidation_reason")\" title=\"$(_escape_quotes "$task")\" retry=next-mayor-cycle" - return 0 + if _mayor_pre_dispatch_fail_open_allowed "$reason_code"; then + echo "[mayor] dispatch live revalidation flaked on $rig - $revalidation_reason (fail-open)" + _mayor_record_decision "MAYOR DISPATCH REVALIDATE FAIL-OPEN rig=$rig reason_code=$reason_code reason=$revalidation_reason live_summary=$live_summary task=\"$(_escape_quotes "$task")\"" "dispatch-revalidate-fail-open" "$SGT_ROOT" || true + log_event "MAYOR_DISPATCH_REVALIDATE_FAIL_OPEN reason_code=$reason_code rig=$rig live=\"$(_escape_quotes "$live_summary")\" reason=\"$(_escape_quotes "$revalidation_reason")\" title=\"$(_escape_quotes "$task")\"" + else + skip_context="dispatch-skip-stale" + skip_header="MAYOR DISPATCH SKIP (stale-state)" + skip_event="MAYOR_DISPATCH_SKIP_STALE" + if [[ "$reason_code" == "parallel-budget-exhausted" ]]; then + skip_context="dispatch-skip-budget" + skip_header="MAYOR DISPATCH SKIP (parallel-budget)" + skip_event="MAYOR_DISPATCH_SKIP_BUDGET" + elif [[ "$reason_code" == "consistency-mismatch" ]]; then + skip_context="dispatch-skip-consistency" + skip_header="MAYOR DISPATCH SKIP (consistency-mismatch)" + skip_event="MAYOR_DISPATCH_SKIP_CONSISTENCY" + fi + echo "[mayor] dispatch skipped on $rig - $revalidation_reason" + local stale_entry="" + stale_entry+="$skip_header"$'\n' + stale_entry+=" rig=$rig"$'\n' + stale_entry+=" reason_code=$reason_code"$'\n' + stale_entry+=" reason=$revalidation_reason"$'\n' + if [[ "$reason_code" == "consistency-mismatch" ]]; then + stale_entry+=" mismatch_categories=${mismatch_categories:-unknown}"$'\n' + stale_entry+=" snapshot_summary=$snapshot_summary"$'\n' + stale_entry+=" live_summary=$live_summary"$'\n' + stale_entry+=" retry=next-mayor-cycle"$'\n' + fi + stale_entry+=" task=\"$(_escape_quotes "$task")\"" + _mayor_record_decision "$stale_entry" "$skip_context" "$SGT_ROOT" || true + log_event "$skip_event reason_code=$reason_code rig=$rig mismatch_categories=\"${mismatch_categories:-}\" snapshot=\"$(_escape_quotes "$snapshot_summary")\" live=\"$(_escape_quotes "$live_summary")\" reason=\"$(_escape_quotes "$revalidation_reason")\" title=\"$(_escape_quotes "$task")\" retry=next-mayor-cycle" + return 0 + fi fi fi @@ -8118,6 +9149,7 @@ _json_print_array() { cmd_status_json() { ensure_init + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" local -a agents=() dogs=() crew=() merge_queue=() polecats=() mayor_rigs=() local daemon_alive="off" daemon_pid="" daemon_pid_json if [[ -f "$SGT_DAEMON_PID" ]] && kill -0 "$(cat "$SGT_DAEMON_PID")" 2>/dev/null; then @@ -8172,8 +9204,10 @@ cmd_status_json() { if _mayor_rig_activity_enabled; then _mayor_rig_activity_ensure_state "$rig_name" local mr_state mr_reason mr_changed_at mr_changed_epoch mr_mode mr_meaningful_at mr_meaningful_epoch mr_meaningful_reason mr_wake_at mr_wake_reason + local ralph_json IFS='|' read -r mr_state mr_reason mr_changed_at mr_changed_epoch mr_mode mr_meaningful_at mr_meaningful_epoch mr_meaningful_reason mr_wake_at mr_wake_reason <<< "$(_mayor_rig_activity_state_read "$rig_name")" - mayor_rigs+=("{\"rig\":$(_json_quote "$rig_name"),\"state\":$(_json_quote "$mr_state"),\"reason\":$(_json_quote "$mr_reason"),\"changed_at\":$(_json_quote "$mr_changed_at"),\"changed_epoch\":$(_json_quote "$mr_changed_epoch"),\"hibernation_mode\":$(_json_quote "$mr_mode"),\"last_meaningful_at\":$(_json_quote "$mr_meaningful_at"),\"last_meaningful_epoch\":$(_json_quote "$mr_meaningful_epoch"),\"last_meaningful_reason\":$(_json_quote "$mr_meaningful_reason"),\"last_wake_at\":$(_json_quote "$mr_wake_at"),\"last_wake_reason\":$(_json_quote "$mr_wake_reason")}") + ralph_json="$(_ralph_mode_snapshot_json "$rig_name" "$(rig_repo "$rig_name" 2>/dev/null || true)" 2>/dev/null || printf '{}')" + mayor_rigs+=("{\"rig\":$(_json_quote "$rig_name"),\"state\":$(_json_quote "$mr_state"),\"reason\":$(_json_quote "$mr_reason"),\"changed_at\":$(_json_quote "$mr_changed_at"),\"changed_epoch\":$(_json_quote "$mr_changed_epoch"),\"hibernation_mode\":$(_json_quote "$mr_mode"),\"last_meaningful_at\":$(_json_quote "$mr_meaningful_at"),\"last_meaningful_epoch\":$(_json_quote "$mr_meaningful_epoch"),\"last_meaningful_reason\":$(_json_quote "$mr_meaningful_reason"),\"last_wake_at\":$(_json_quote "$mr_wake_at"),\"last_wake_reason\":$(_json_quote "$mr_wake_reason"),\"ralph\":$ralph_json}") fi done @@ -8228,7 +9262,7 @@ cmd_status_json() { IFS='|' read -r review_watch_count review_watch_oldest review_watch_threshold <<< "$(_mayor_review_watchdog_status_snapshot)" agents+=("{\"name\":$(_json_quote "mayor/$mayor_scope_rig"),\"role\":$(_json_quote "mayor"),\"scope\":$(_json_quote "rig"),\"rig\":$(_json_quote "$mayor_scope_rig"),\"status\":$(_json_quote "$mayor_alive"),\"lock\":{\"state\":$(_json_quote "$mayor_lock_state"),\"owner_pid\":$(_json_quote "$mayor_lock_owner"),\"started_at\":$(_json_quote "$mayor_lock_started"),\"lease_until\":$(_json_quote "$mayor_lock_until")},\"heartbeat\":{\"age_seconds\":$(_json_quote "$mayor_hb_age"),\"timestamp\":$(_json_quote "$mayor_hb_ts"),\"state\":$(_json_quote "$mayor_hb_state"),\"health\":$(_json_quote "$mayor_hb_health"),\"stale_seconds\":$(_json_quote "$mayor_hb_stale_secs"),\"pid\":$(_json_quote "$mayor_hb_pid"),\"phase\":$(_json_quote "$mayor_hb_phase"),\"cycle\":$(_json_quote "$mayor_hb_cycle"),\"trigger\":$(_json_quote "$mayor_hb_trigger")},\"last_exit\":{\"timestamp\":$(_json_quote "$mayor_exit_ts"),\"age_seconds\":$(_json_quote "$mayor_exit_age"),\"reason_code\":$(_json_quote "$mayor_exit_reason"),\"exit_code\":$(_json_quote "$mayor_exit_code"),\"signal\":$(_json_quote "$mayor_exit_signal"),\"unexpected\":$(_json_quote "$mayor_exit_unexpected"),\"pid\":$(_json_quote "$mayor_exit_pid"),\"last_cycle_trigger\":$(_json_quote "$mayor_exit_trigger"),\"last_cycle_at\":$(_json_quote "$mayor_exit_cycle_at"),\"last_cycle_status\":$(_json_quote "$mayor_exit_cycle_status")},\"decision_log_warning\":{\"timestamp\":$(_json_quote "$log_fail_ts"),\"age_seconds\":$(_json_quote "$log_fail_age"),\"context\":$(_json_quote "$log_fail_context"),\"workspace\":$(_json_quote "$log_fail_workspace"),\"error\":$(_json_quote "$log_fail_error")},\"notify_warning\":{\"timestamp\":$(_json_quote "$notify_alert_ts"),\"age_seconds\":$(_json_quote "$notify_alert_age"),\"channel\":$(_json_quote "$notify_alert_channel"),\"target\":$(_json_quote "$notify_alert_target"),\"message_key\":$(_json_quote "$notify_alert_message_key"),\"attempt\":$(_json_quote "$notify_alert_attempt"),\"outcome\":$(_json_quote "$notify_alert_outcome"),\"reason\":$(_json_quote "$notify_alert_reason"),\"matcher\":$(_json_quote "$notify_alert_matcher"),\"fail_open\":$(_json_quote "$notify_alert_fail_open")},\"prompt_budget\":{\"timestamp\":$(_json_quote "$budget_ts"),\"age_seconds\":$(_json_quote "$budget_age"),\"measured_at\":$(_json_quote "$budget_measured_at"),\"estimated_tokens\":$(_json_quote "$budget_tokens"),\"char_count\":$(_json_quote "$budget_chars"),\"threshold\":$(_json_quote "$budget_threshold"),\"over_budget\":$(_json_quote "$budget_over"),\"source_path\":$(_json_quote "$budget_source")},\"auto_refresh\":{\"timestamp\":$(_json_quote "$refresh_ts"),\"age_seconds\":$(_json_quote "$refresh_age"),\"refreshed_at\":$(_json_quote "$refresh_at"),\"trigger\":$(_json_quote "$refresh_trigger"),\"estimated_tokens\":$(_json_quote "$refresh_tokens"),\"char_count\":$(_json_quote "$refresh_chars"),\"threshold\":$(_json_quote "$refresh_threshold"),\"handoff_path\":$(_json_quote "$refresh_handoff"),\"handoff_token\":$(_json_quote "$refresh_token"),\"status\":$(_json_quote "$refresh_status")},\"review_watchdog\":{\"count\":$(_json_quote "$review_watch_count"),\"oldest_seconds\":$(_json_quote "$review_watch_oldest"),\"threshold_seconds\":$(_json_quote "$review_watch_threshold")}}") done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" else local mayor_alive="off" tmux has-session -t "sgt-mayor" 2>/dev/null && mayor_alive="on" @@ -8451,6 +9485,7 @@ cmd_status_json() { _cmd_status_human() { ensure_init + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" local status_term_cols status_pr_title_cols status_term_cols="$(_term_cols)" [[ "$status_term_cols" =~ ^[0-9]+$ ]] || status_term_cols=80 @@ -8633,7 +9668,7 @@ _cmd_status_human() { printf " %-16s %s%s%s\n" "" "$(_dim)" "review watchdog: clear (threshold>=${review_watch_threshold}s)" "$(_reset)" fi done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" else local mayor_alive="off" tmux has-session -t "sgt-mayor" 2>/dev/null && mayor_alive="on" @@ -8725,13 +9760,24 @@ _cmd_status_human() { [[ -f "$rig_file" ]] || continue mayor_rig_count=$((mayor_rig_count + 1)) local rig_name mr_state mr_reason mr_changed_at mr_changed_epoch mr_mode mr_meaningful_at mr_meaningful_epoch mr_meaningful_reason mr_wake_at mr_wake_reason + local ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked ralph_last_ts ralph_last_action ralph_last_reason ralph_last_outcome rig_name="$(basename "$rig_file")" _mayor_rig_activity_ensure_state "$rig_name" IFS='|' read -r mr_state mr_reason mr_changed_at mr_changed_epoch mr_mode mr_meaningful_at mr_meaningful_epoch mr_meaningful_reason mr_wake_at mr_wake_reason <<< "$(_mayor_rig_activity_state_read "$rig_name")" + IFS='|' read -r ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked ralph_last_ts ralph_last_action ralph_last_reason ralph_last_outcome <<< "$(_ralph_mode_snapshot_fields "$rig_name" "$(rig_repo "$rig_name" 2>/dev/null || true)" 2>/dev/null || true)" printf " %-16s %s %s%s%s\n" "$rig_name" "$(_status_badge "$mr_state")" "$(_dim)" "mode=${mr_mode:-none}" "$(_reset)" if [[ -n "$mr_reason" ]]; then printf " %-16s %s%s%s\n" "" "$(_dim)" "reason: $mr_reason" "$(_reset)" fi + if [[ "$ralph_enabled" == "1" ]]; then + printf " %-16s %s%s%s\n" "" "$(_dim)" "ralph: state=${ralph_state:-unknown} target=${ralph_target:-1} active_lanes=${ralph_active_lanes:-0} admissible=${ralph_admissible_lanes:-0} backlog=${ralph_backlog_lanes:-0} support_excluded=${ralph_support_lanes:-0} duplicate_excluded=${ralph_duplicate_lanes:-0} underfilled=${ralph_underfilled:-0} condition_status=${ralph_condition_status:-unmet}" "$(_reset)" + if [[ -n "$ralph_condition" ]]; then + printf " %-16s %s%s%s\n" "" "$(_dim)" "ralph condition: $ralph_condition" "$(_reset)" + fi + if [[ -n "$ralph_last_action" || -n "$ralph_last_reason" ]]; then + printf " %-16s %s%s%s\n" "" "$(_dim)" "ralph president: ${ralph_last_ts:-unknown} action=${ralph_last_action:-unknown} reason=${ralph_last_reason:-unknown} outcome=${ralph_last_outcome:-unknown}" "$(_reset)" + fi + fi if [[ -n "$mr_changed_at" ]]; then printf " %-16s %s%s%s\n" "" "$(_dim)" "since: $mr_changed_at" "$(_reset)" fi @@ -9237,6 +10283,8 @@ _mayor_recover_stranded_actionable_rig() { local snapshot snapshot_state snapshot_reason open_authorized open_prs active_polecats merge_queue pending_plan_requests plan_rollup plan_status local issue_rows issue_number issue_title existing_polecat open_pr backend_limited_reason source_event source_event_key local resling_output blocked_reason_code blocked_detail dispatched_limit resling_log + local pending_plan_target ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked + local recovery_target recovery_due_to_underfill _MAYOR_STRANDED_RECOVERY_DISPATCHED=0 _MAYOR_STRANDED_RECOVERY_BLOCKED=0 @@ -9253,13 +10301,44 @@ _mayor_recover_stranded_actionable_rig() { [[ "$open_prs" =~ ^[0-9]+$ ]] || open_prs=0 [[ "$active_polecats" =~ ^[0-9]+$ ]] || active_polecats=0 [[ "$merge_queue" =~ ^[0-9]+$ ]] || merge_queue=0 + ralph_target=0 + ralph_completion_blocked=0 + + pending_plan_target=0 + if declare -F _plan_pending_underfill_target >/dev/null 2>&1; then + pending_plan_target="$(_plan_pending_underfill_target "$rig" 2>/dev/null || true)" + fi + [[ "$pending_plan_target" =~ ^[1-9][0-9]*$ ]] || pending_plan_target=0 + if declare -F _ralph_mode_snapshot_fields >/dev/null 2>&1; then + IFS='|' read -r ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked _ _ _ _ <<< "$(_ralph_mode_snapshot_fields "$rig" "$repo" 2>/dev/null || true)" + fi + [[ "$ralph_target" =~ ^[1-9][0-9]*$ ]] || ralph_target=0 + + recovery_target=0 + if [[ "$ralph_completion_blocked" == "1" && "$ralph_target" -gt "$recovery_target" ]]; then + recovery_target="$ralph_target" + fi + if (( pending_plan_target > recovery_target )); then + recovery_target="$pending_plan_target" + fi + recovery_due_to_underfill=0 + if (( recovery_target > 0 && active_polecats < recovery_target )); then + recovery_due_to_underfill=1 + fi - if (( open_authorized == 0 || open_prs > 0 || active_polecats > 0 || merge_queue > 0 )); then + if (( open_authorized == 0 )); then + return 0 + fi + if (( recovery_due_to_underfill == 0 )) && (( open_prs > 0 || active_polecats > 0 || merge_queue > 0 )); then return 0 fi source_event="mayor-zero-worker-recovery" dispatched_limit=1 + if (( recovery_due_to_underfill > 0 )); then + dispatched_limit=$((recovery_target - active_polecats)) + (( dispatched_limit < 1 )) && dispatched_limit=1 + fi issue_rows="$(gh issue list --repo "$repo" --state open --label "sgt-authorized" --limit 100 --json number,title --jq '.[] | "\(.number)\t\(.title)"' 2>/dev/null || true)" [[ -n "$issue_rows" ]] || return 0 @@ -9332,11 +10411,12 @@ cmd_sweep() { [[ -f "$f" ]] || continue local pname pname="$(basename "$f")" - local probe_output probe_rc + local probe_output probe_rc restore_errexit + restore_errexit="$(_shell_errexit_state)" set +e probe_output="$(_sweep_probe_polecat "$f")" probe_rc=$? - set -e + _shell_errexit_restore "$restore_errexit" if [[ "$probe_rc" -eq 141 ]]; then info "polecat $pname had a benign closed stream — skipping" @@ -11135,6 +12215,18 @@ _refinery_loop() { continue fi + if [[ "$merge_success" != "true" && "$merge_error_class" == "merge-in-progress" ]]; then + if _refinery_post_merge_verify_and_receipt "$rig" "$mq_repo" "$mq_pr" "$mq_issue" "$mq_branch" "$claimed_attempt_key" "$merge_expected_head_sha"; then + merge_success=true + merge_expected_head_sha="${_REFINERY_MERGE_RECEIPT_MERGED_SHA:-$merge_expected_head_sha}" + else + echo "[refinery/$rig] PR #$mq_pr merge already in flight — awaiting live confirmation" + log_event "REFINERY_MERGE_IN_FLIGHT pr=#$mq_pr issue=#$mq_issue class=$merge_error_class retry=await-live-confirmation error=\"$(_escape_quotes "$merge_result")\"" + _refinery_merge_attempt_release "$claimed_attempt_file" + continue + fi + fi + if [[ "$merge_success" == "true" ]]; then if _refinery_post_merge_verify_and_receipt "$rig" "$mq_repo" "$mq_pr" "$mq_issue" "$mq_branch" "$claimed_attempt_key" "$merge_expected_head_sha"; then local verified_merged_sha @@ -11409,10 +12501,16 @@ _president_intervention_allowed() { _president_operator_event_kind() { local reason="${1:-}" action="${2:-}" case "$reason" in + ralph-underfilled|ralph-idle) + echo "drift" + ;; + ralph-contradiction) + echo "contradiction" + ;; drift*|*drift*) echo "drift" ;; - actionable-no-forward-motion|stalled-purpose*|*stalled-purpose*) + actionable-no-forward-motion|pending-plan-underfilled|stalled-purpose*|*stalled-purpose*) echo "stalled-purpose" ;; contradiction*|*contradict*) @@ -11483,6 +12581,12 @@ _president_operator_dedupe_key() { _president_operator_overlap_key() { local rig="${1:-}" kind="${2:-unknown}" reason="${3:-unknown}" case "$reason" in + ralph-underfilled|ralph-idle|ralph-contradiction) + printf 'rig-incident:%s:ralph\n' "${rig:-unknown}" + ;; + pending-plan-underfilled) + printf 'rig-incident:%s:pending-plan-underfill\n' "${rig:-unknown}" + ;; actionable-no-forward-motion|actionable-rig-recheck) printf 'rig-incident:%s:actionable-no-forward-motion\n' "${rig:-unknown}" ;; @@ -11514,10 +12618,12 @@ _president_operator_log_event() { _president_supervise_rig_mayor() { local rig="${1:-}" cycle_reason="${2:-periodic}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" local mayor_session mayor_hb_age mayor_hb_ts mayor_hb_state mayor_hb_pid mayor_hb_phase mayor_hb_cycle mayor_hb_trigger mayor_hb_stale mayor_hb_health local snapshot snapshot_state snapshot_reason open_authorized open_prs active_polecats merge_queue pending_plan_requests plan_rollup plan_status local activity changed_at changed_epoch mode meaningful_at meaningful_epoch meaningful_reason wake_at wake_reason - local now_epoch action reason detail last_meaningful_age + local repo ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked + local now_epoch action reason detail last_meaningful_age pending_plan_target plan_terminal_rollup [[ -n "$rig" ]] || return 1 now_epoch="$(date +%s)" @@ -11530,6 +12636,30 @@ _president_supervise_rig_mayor() { snapshot="$(_mayor_rig_activity_snapshot "$rig")" IFS='|' read -r snapshot_state snapshot_reason open_authorized open_prs active_polecats merge_queue pending_plan_requests plan_rollup plan_status <<< "$snapshot" IFS='|' read -r activity snapshot_reason changed_at changed_epoch mode meaningful_at meaningful_epoch meaningful_reason wake_at wake_reason <<< "$(_mayor_rig_activity_state_read "$rig")" + if [[ "$activity" == "hibernated" && -n "$mode" && "$mode" != "none" ]]; then + _mayor_scope_apply "$prior_scope_rig" + return 1 + fi + repo="$(rig_repo "$rig" 2>/dev/null || true)" + IFS='|' read -r ralph_enabled ralph_condition ralph_condition_status ralph_target _ralph_count_support ralph_state ralph_reason ralph_active_lanes ralph_admissible_lanes ralph_backlog_lanes ralph_support_lanes ralph_duplicate_lanes ralph_underfilled ralph_completion_blocked _ _ _ _ <<< "$(_ralph_mode_snapshot_fields "$rig" "$repo" 2>/dev/null || true)" + pending_plan_target="$(_plan_pending_underfill_target "$rig" 2>/dev/null || true)" + [[ "$pending_plan_target" =~ ^[1-9][0-9]*$ ]] || pending_plan_target=0 + [[ "$ralph_target" =~ ^[0-9]+$ ]] || ralph_target=1 + [[ "$ralph_active_lanes" =~ ^[0-9]+$ ]] || ralph_active_lanes=0 + [[ "$ralph_admissible_lanes" =~ ^[0-9]+$ ]] || ralph_admissible_lanes=0 + [[ "$ralph_backlog_lanes" =~ ^[0-9]+$ ]] || ralph_backlog_lanes=0 + if _mayor_rig_hibernated "$rig"; then + _mayor_scope_apply "$prior_scope_rig" + return 1 + fi + case "${plan_rollup:-}" in + acceptance-completed|acceptance-verified|acceptance-waived|blocked-pending-external) + plan_terminal_rollup=1 + ;; + *) + plan_terminal_rollup=0 + ;; + esac action="" reason="" @@ -11547,6 +12677,30 @@ _president_supervise_rig_mayor() { action="refresh" reason="mayor-heartbeat-stale" detail="heartbeat_age=${mayor_hb_age}s threshold=${mayor_hb_stale}s" + elif [[ "$ralph_completion_blocked" == "1" && ( "${plan_status:-}" == "verified" || "${plan_status:-}" == "waived" || "${plan_status:-}" == "completed" || "$plan_terminal_rollup" == "1" ) ]]; then + action="refresh" + reason="ralph-contradiction" + detail="condition=\"${ralph_condition:-}\" completion=${plan_rollup:-unknown} completion_status=${plan_status:-unknown} active_lanes=${ralph_active_lanes:-0} backlog_lanes=${ralph_backlog_lanes:-0} target=${ralph_target:-1}" + elif [[ "$ralph_completion_blocked" == "1" && "${ralph_active_lanes:-0}" =~ ^[0-9]+$ && "${ralph_target:-0}" =~ ^[0-9]+$ && "${ralph_active_lanes:-0}" -eq 0 ]]; then + action="refresh" + reason="ralph-idle" + detail="condition=\"${ralph_condition:-}\" active_lanes=${ralph_active_lanes:-0} admissible_lanes=${ralph_admissible_lanes:-0} target=${ralph_target:-1}" + elif [[ "$ralph_underfilled" == "1" && "$ralph_backlog_lanes" -gt 0 ]]; then + action="wake" + reason="ralph-underfilled" + detail="condition=\"${ralph_condition:-}\" active_lanes=${ralph_active_lanes:-0} admissible_lanes=${ralph_admissible_lanes:-0} backlog_lanes=${ralph_backlog_lanes:-0} target=${ralph_target:-1}" + elif [[ "$ralph_underfilled" == "1" ]]; then + action="refresh" + reason="ralph-contradiction" + detail="condition=\"${ralph_condition:-}\" active_lanes=${ralph_active_lanes:-0} admissible_lanes=${ralph_admissible_lanes:-0} backlog_lanes=${ralph_backlog_lanes:-0} target=${ralph_target:-1} contradiction=no-refillable-backlog" + elif (( pending_plan_target > 0 && active_polecats < pending_plan_target )); then + if (( active_polecats == 0 )); then + action="refresh" + else + action="wake" + fi + reason="pending-plan-underfilled" + detail="plan_rollup=${plan_rollup:-tasks-exhausted-awaiting-acceptance} plan_status=${plan_status:-pending} active_polecats=${active_polecats:-0} target=${pending_plan_target}" elif [[ "$open_authorized" =~ ^[0-9]+$ && "$open_prs" =~ ^[0-9]+$ && "$active_polecats" =~ ^[0-9]+$ && "$merge_queue" =~ ^[0-9]+$ ]] \ && (( open_authorized > 0 )) && (( open_prs == 0 )) && (( active_polecats == 0 )) && (( merge_queue == 0 )); then if [[ "$meaningful_epoch" =~ ^[0-9]+$ ]]; then @@ -11565,13 +12719,13 @@ _president_supervise_rig_mayor() { fi if [[ -z "$action" ]]; then - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 1 fi if ! _president_intervention_allowed "$rig" "$reason" "$action"; then _president_operator_log_event "$rig" "$action" "$reason" "suppressed-by-cooldown" "$detail" "$cycle_reason" - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 1 fi @@ -11586,7 +12740,7 @@ _president_supervise_rig_mayor() { cmd_wake_mayor "president:${rig}:${reason}" >/dev/null ;; *) - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 1 ;; esac @@ -11594,7 +12748,7 @@ _president_supervise_rig_mayor() { _president_operator_log_event "$rig" "$action" "$reason" "intervened" "$detail" "$cycle_reason" log_event "PRESIDENT_INTERVENTION rig=$rig action=$action reason=$reason detail=\"$(_escape_quotes "$detail")\" cycle_trigger=\"$(_escape_quotes "$cycle_reason")\"" echo "[president] intervened on mayor/$rig — action=$action reason=$reason detail=$detail" - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 0 } @@ -11759,6 +12913,7 @@ cmd_wake_president() { _deacon_supervise_mayor() { local scope_rig="${1:-}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" _mayor_scope_apply "$scope_rig" local mayor_name mayor_session_name mayor_name="$(_mayor_scope_agent_name "$scope_rig")" @@ -11790,7 +12945,10 @@ _deacon_supervise_mayor() { restart_line="[deacon] mayor heartbeat stale (${hb_age}s > ${hb_stale_secs}s) — restarting" fi - [[ -n "$restart_reason" ]] || return 1 + if [[ -z "$restart_reason" ]]; then + _mayor_scope_apply "$prior_scope_rig" + return 1 + fi if [[ "$restart_reason" == heartbeat-* ]]; then tmux kill-session -t "$mayor_session_name" 2>/dev/null || true @@ -11815,6 +12973,7 @@ _deacon_supervise_mayor() { echo "$restart_line" log_event "DEACON_RESTART_MAYOR reason=$restart_reason rig=${scope_rig:-shared} session=$mayor_session heartbeat_state=${hb_state:-unknown} heartbeat_health=${hb_health:-unknown} heartbeat_age=${hb_age:-unknown}s heartbeat_phase=${hb_phase:-unknown} heartbeat_cycle=${hb_cycle:-unknown} trigger=\"$(_escape_quotes "${hb_trigger:-unknown}")\" last_exit_reason=${exit_reason:-none} last_exit_signal=${exit_signal:-none} last_exit_code=${exit_code:-unknown} last_exit_unexpected=${exit_unexpected:-unknown} last_exit_pid=${exit_pid:-unknown} last_cycle_trigger=\"$(_escape_quotes "${exit_trigger:-unknown}")\" last_cycle_status=${exit_cycle_status:-unknown} last_cycle_at=\"$(_escape_quotes "${exit_cycle_at:-unknown}")\"" cmd_mayor_start "$scope_rig" + _mayor_scope_apply "$prior_scope_rig" return 0 } @@ -12127,6 +13286,360 @@ cmd_daemon_status() { fi } +# ─── Web UI (deploy/start/restart/verify) ──────────────────────────── + +_web_session_name() { + printf '%s\n' "${SGT_WEB_SESSION_NAME:-sgt-web}" +} + +_web_live_dir() { + printf '%s\n' "${SGT_WEB_LIVE_DIR:-/root/sgt/web}" +} + +_web_repo_root() { + if [[ -n "${SGT_WEB_REPO_DIR:-}" ]]; then + printf '%s\n' "$SGT_WEB_REPO_DIR" + return 0 + fi + + local git_root="" + if git_root="$(git rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$git_root" ]]; then + printf '%s\n' "$git_root" + return 0 + fi + + local pwd_root="" + pwd_root="$(pwd -P 2>/dev/null || pwd)" + if [[ -f "$pwd_root/web/package.json" ]]; then + printf '%s\n' "$pwd_root" + return 0 + fi + + die "could not determine repo checkout for web source; run from the repo checkout or set SGT_WEB_REPO_DIR" +} + +_web_repo_web_dir() { + printf '%s/web\n' "$(_web_repo_root)" +} + +_web_sync_script() { + printf '%s/web/scripts/sync-live-copy.sh\n' "$(_web_repo_root)" +} + +_web_log_file() { + printf '%s/webui.log\n' "$(_web_live_dir)" +} + +_web_port() { + printf '%s\n' "${SGT_WEB_PORT:-4747}" +} + +_web_verify_url() { + if [[ -n "${SGT_WEB_VERIFY_URL:-}" ]]; then + printf '%s\n' "$SGT_WEB_VERIFY_URL" + return 0 + fi + printf 'http://127.0.0.1:%s/api/status\n' "$(_web_port)" +} + +_web_sha256_cmd() { + if command -v sha256sum >/dev/null 2>&1; then + printf 'sha256sum\n' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + printf 'shasum -a 256\n' + return 0 + fi + die "sha256sum or shasum is required for web verification" +} + +_web_tree_hash() { + local dir="${1:-}" + [[ -d "$dir" ]] || return 1 + + local hash_cmd + hash_cmd="$(_web_sha256_cmd)" + + ( + cd "$dir" + LC_ALL=C find . \ + -path './node_modules' -prune -o \ + -name 'webui.log' -prune -o \ + -type f -print0 + ) | LC_ALL=C sort -z | while IFS= read -r -d '' rel_path; do + local clean_path="${rel_path#./}" + local digest + digest="$(cd "$dir" && eval "$hash_cmd" "\"$clean_path\"" | awk '{print $1}')" + printf '%s %s\n' "$digest" "$clean_path" + done | eval "$hash_cmd" | awk '{print $1}' +} + +_web_tree_state() { + local repo_dir live_dir repo_hash live_hash sync_state + repo_dir="$(_web_repo_web_dir)" + live_dir="$(_web_live_dir)" + repo_hash="" + live_hash="" + sync_state="missing" + + if [[ -d "$repo_dir" ]]; then + repo_hash="$(_web_tree_hash "$repo_dir" 2>/dev/null || true)" + fi + if [[ -d "$live_dir" ]]; then + live_hash="$(_web_tree_hash "$live_dir" 2>/dev/null || true)" + fi + + if [[ -z "$repo_hash" ]]; then + sync_state="repo-missing" + elif [[ -z "$live_hash" ]]; then + sync_state="live-missing" + elif [[ "$repo_hash" == "$live_hash" ]]; then + sync_state="in-sync" + else + sync_state="mismatch" + fi + + printf '%s|%s|%s|%s|%s\n' "$repo_dir" "$live_dir" "$repo_hash" "$live_hash" "$sync_state" +} + +_web_http_probe() { + local url="${1:-}" + if command -v curl >/dev/null 2>&1; then + curl --silent --show-error --fail --max-time 3 "$url" >/dev/null + return $? + fi + + if command -v node >/dev/null 2>&1; then + node - "$url" <<'EOF' +const target = process.argv[2]; +const mod = target.startsWith('https:') ? require('https') : require('http'); +const req = mod.get(target, (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + res.resume(); + res.on('end', () => process.exit(0)); + return; + } + res.resume(); + res.on('end', () => process.exit(1)); +}); +req.setTimeout(3000, () => { + req.destroy(new Error('timeout')); +}); +req.on('error', () => process.exit(1)); +EOF + return $? + fi + + die "curl or node is required for web HTTP verification" +} + +_web_wait_for_http() { + local url="${1:-}" timeout_secs="${2:-20}" waited=0 + while (( waited < timeout_secs )); do + if _web_http_probe "$url"; then + return 0 + fi + sleep 1 + waited=$((waited + 1)) + done + return 1 +} + +cmd_web_help() { + cat < [args] + +Commands: + status Show repo source, live served tree, session, and HTTP state + start Start the live web UI from the deployed tree + stop Stop the live web UI tmux session + restart Restart the live web UI tmux session + deploy [--dry-run] Sync repo web/ -> live tree, then restart and verify by default + verify Fail unless live tree matches repo checkout and HTTP is healthy + help, --help, -h Show this help + +Environment: + SGT_WEB_REPO_DIR Repo checkout to treat as canonical source (default: current git root) + SGT_WEB_LIVE_DIR Live served tree (default: /root/sgt/web) + SGT_WEB_SESSION_NAME tmux session name for the live web server (default: sgt-web) + SGT_WEB_PORT HTTP port for start/verify defaults (default: 4747) + SGT_WEB_VERIFY_URL Override the HTTP health URL (default: http://127.0.0.1:\$SGT_WEB_PORT/api/status) +EOF +} + +cmd_web_status() { + local repo_dir live_dir repo_hash live_hash sync_state session url session_state http_state + IFS='|' read -r repo_dir live_dir repo_hash live_hash sync_state <<< "$(_web_tree_state)" + session="$(_web_session_name)" + url="$(_web_verify_url)" + session_state="off" + http_state="down" + + if tmux has-session -t "$session" 2>/dev/null; then + session_state="on" + if _web_http_probe "$url"; then + http_state="ok" + else + http_state="unreachable" + fi + fi + + info "web source: $repo_dir" + info "web live target: $live_dir" + info "tree state: $sync_state" + info "repo hash: ${repo_hash:-missing}" + info "live hash: ${live_hash:-missing}" + info "tmux session: $session ($session_state)" + info "http verify: $url ($http_state)" +} + +cmd_web_start() { + local live_dir session log_file port startup_timeout spawn_cmd + live_dir="$(_web_live_dir)" + session="$(_web_session_name)" + log_file="$(_web_log_file)" + port="$(_web_port)" + startup_timeout="${SGT_WEB_START_TIMEOUT_SECS:-20}" + + [[ -f "$live_dir/package.json" ]] || die "live web tree missing package.json at $live_dir; run: sgt web deploy" + + if tmux has-session -t "$session" 2>/dev/null; then + info "web UI already running (session: $session)" + return 0 + fi + + mkdir -p "$live_dir" + printf -v spawn_cmd 'SGT_ROOT=%q SGT_WEB_PORT=%q' "$SGT_ROOT" "$port" + if [[ -n "${SGT_BIN:-}" ]]; then + printf -v spawn_cmd '%s SGT_BIN=%q' "$spawn_cmd" "$SGT_BIN" + fi + if [[ -n "${SGT_WEB_ELEVENLABS_API_KEY:-}" ]]; then + printf -v spawn_cmd '%s SGT_WEB_ELEVENLABS_API_KEY=%q' "$spawn_cmd" "$SGT_WEB_ELEVENLABS_API_KEY" + fi + if [[ -n "${SGT_WEB_ELEVENLABS_VOICE_ID:-}" ]]; then + printf -v spawn_cmd '%s SGT_WEB_ELEVENLABS_VOICE_ID=%q' "$spawn_cmd" "$SGT_WEB_ELEVENLABS_VOICE_ID" + fi + if [[ -n "${SGT_WEB_ELEVENLABS_MODEL_ID:-}" ]]; then + printf -v spawn_cmd '%s SGT_WEB_ELEVENLABS_MODEL_ID=%q' "$spawn_cmd" "$SGT_WEB_ELEVENLABS_MODEL_ID" + fi + if [[ -n "${SGT_WEB_VOICE_EVENT_KINDS:-}" ]]; then + printf -v spawn_cmd '%s SGT_WEB_VOICE_EVENT_KINDS=%q' "$spawn_cmd" "$SGT_WEB_VOICE_EVENT_KINDS" + fi + if [[ -n "${SGT_WEB_VOICE_RATE_LIMIT_SECS:-}" ]]; then + printf -v spawn_cmd '%s SGT_WEB_VOICE_RATE_LIMIT_SECS=%q' "$spawn_cmd" "$SGT_WEB_VOICE_RATE_LIMIT_SECS" + fi + printf -v spawn_cmd '%s npm start >> %q 2>&1' "$spawn_cmd" "$log_file" + + if ! tmux new-session -d -s "$session" -c "$live_dir" "$spawn_cmd" 2>/dev/null; then + die "failed to start web UI tmux session '$session'" + fi + + if ! _web_wait_for_http "$(_web_verify_url)" "$startup_timeout"; then + warn "web UI tmux session started, but HTTP health check did not pass within ${startup_timeout}s" + warn "see $log_file" + return 1 + fi + + log_event "WEB_START session=$session live_dir=\"$(_escape_quotes "$live_dir")\" port=$port" + info "web UI started (session: $session, live: $live_dir, port: $port)" +} + +cmd_web_stop() { + local session + session="$(_web_session_name)" + if tmux has-session -t "$session" 2>/dev/null; then + tmux kill-session -t "$session" + log_event "WEB_STOP session=$session" + info "web UI stopped" + else + info "web UI not running" + fi +} + +cmd_web_restart() { + cmd_web_stop >/dev/null 2>&1 || true + cmd_web_start +} + +cmd_web_verify() { + local repo_dir live_dir repo_hash live_hash sync_state session url problems=0 + IFS='|' read -r repo_dir live_dir repo_hash live_hash sync_state <<< "$(_web_tree_state)" + session="$(_web_session_name)" + url="$(_web_verify_url)" + + if [[ "$sync_state" != "in-sync" ]]; then + warn "web tree mismatch: repo=$repo_dir live=$live_dir state=$sync_state" + problems=1 + fi + + if ! tmux has-session -t "$session" 2>/dev/null; then + warn "web UI tmux session is not running: $session" + problems=1 + fi + + if ! _web_http_probe "$url"; then + warn "web UI HTTP verify failed: $url" + problems=1 + fi + + if [[ "$problems" -ne 0 ]]; then + return 1 + fi + + info "web UI verify passed (repo/live hashes match, session running, HTTP healthy)" +} + +cmd_web_deploy() { + local dry_run=0 restart_after=1 verify_after=1 arg sync_script + while [[ $# -gt 0 ]]; do + arg="$1" + case "$arg" in + --dry-run) + dry_run=1 + shift + ;; + --no-restart) + restart_after=0 + shift + ;; + --no-verify) + verify_after=0 + shift + ;; + -h|--help) + cmd_web_help + return 0 + ;; + *) + die "unknown web deploy option: $arg" + ;; + esac + done + + sync_script="$(_web_sync_script)" + [[ -x "$sync_script" ]] || [[ -f "$sync_script" ]] || die "missing deploy helper: $sync_script" + + if [[ "$dry_run" -eq 1 ]]; then + SGT_WEB_LIVE_DIR="$(_web_live_dir)" bash "$sync_script" --dry-run + info "web deploy dry run complete" + return 0 + fi + + SGT_WEB_LIVE_DIR="$(_web_live_dir)" bash "$sync_script" + log_event "WEB_DEPLOY_SYNC repo_dir=\"$(_escape_quotes "$(_web_repo_web_dir)")\" live_dir=\"$(_escape_quotes "$(_web_live_dir)")\"" + + if [[ "$restart_after" -eq 1 ]]; then + cmd_web_restart + fi + + if [[ "$verify_after" -eq 1 ]]; then + cmd_web_verify + fi + + info "web deploy complete" +} + # ═══════════════════════════════════════════════════════════════════════ # MAYOR — AI-powered global coordinator # ═══════════════════════════════════════════════════════════════════════ @@ -12222,15 +13735,18 @@ HEADER done echo "## Pending Plan Requests" >> "$briefing_tmp" - local pending_plan_count=0 request_line request_id request_rig request_status request_requested_at request_requester request_prompt_file + local pending_plan_count=0 request_line request_id request_rig request_status request_requested_at request_requester request_prompt_file request_plan_file while IFS='|' read -r request_id request_rig request_status request_requested_at request_requester request_prompt_file; do [[ -n "$request_id" ]] || continue pending_plan_count=$((pending_plan_count + 1)) + request_plan_file="$(_plan_file_path "$request_rig")" echo "### $request_id ($request_rig)" >> "$briefing_tmp" echo "- status: $request_status" >> "$briefing_tmp" echo "- requested_at: $request_requested_at" >> "$briefing_tmp" echo "- requesting_agent: $request_requester" >> "$briefing_tmp" - echo "- plan_path: $(_plan_file_path "$request_rig")" >> "$briefing_tmp" + echo "- repo_path: $(rig_path "$request_rig")" >> "$briefing_tmp" + echo "- plan_path: $request_plan_file" >> "$briefing_tmp" + echo "- note: only this repo-local plan path is authoritative; ignore any SGT_PLAN.json under mayor-workspace" >> "$briefing_tmp" echo "- prompt_excerpt:" >> "$briefing_tmp" head -n 12 "$request_prompt_file" 2>/dev/null | sed 's/^/ /' >> "$briefing_tmp" || true echo "" >> "$briefing_tmp" @@ -13447,7 +14963,7 @@ _mayor_loop() { # Notify Rigger of significant actions via OpenClaw if [[ -n "$notify_rigger" ]]; then - _mayor_notify_rigger "$notify_rigger" + _mayor_notify_rigger_fail_open "$notify_rigger" "0" "${SGT_MAYOR_SCOPE_RIG:-}" fi fi @@ -13457,6 +14973,7 @@ _mayor_loop() { local mayor_workspace="$(_mayor_scope_dir)/mayor-workspace" local dispatch_snapshot_file="$(_mayor_scope_dir)/mayor-dispatch-snapshot.tsv" mkdir -p "$mayor_workspace" + rm -f "$mayor_workspace/SGT_PLAN.json" _mayor_dispatch_snapshot_file "$dispatch_snapshot_file" local briefing_gate briefing briefing_status briefing_generated_at briefing_age briefing_threshold briefing_stale_detected briefing_path briefing_content @@ -13505,6 +15022,8 @@ $briefing_content Decide what to do. Log decisions to $(_mayor_scope_dir)/mayor-decisions.log. Be fast. For each pending plan request, turn the request into a repo-local \`SGT_PLAN.json\` inside the rig repo when the request is clear enough. +Use the exact \`repo_path\` / \`plan_path\` listed in the briefing as canonical truth. +Never create or update \`SGT_PLAN.json\` under \`mayor-workspace\`; workspace-local plan files are ignored. For any rig-specific question, ambiguity, or missing decision, search shared rig context first with \`sgt context search ""\` before guessing or escalating. If you still need clarification after checking context, ask the requesting OpenClaw agent via \`sgt mayor request ask ...\`. After creating or updating \`SGT_PLAN.json\`, call \`sgt mayor request complete \`. @@ -13575,27 +15094,7 @@ MAYORMD # Public command: sgt mayor notify "" — lets AI mayor report to Rigger cmd_mayor_notify() { local message="${1:?usage: sgt mayor notify \"\"}" - local rc now_epoch - if _mayor_notify_rigger "$message" "0" "${SGT_MAYOR_SCOPE_RIG:-}"; then - _mayor_notify_alert_state_clear || true - return 0 - fi - rc=$? - - now_epoch="$(date +%s)" - _mayor_notify_alert_state_write \ - "$now_epoch" \ - "${_MAYOR_NOTIFY_RESULT_CHANNEL:-rigger}" \ - "${_MAYOR_NOTIFY_RESULT_TARGET:-rigger}" \ - "${_MAYOR_NOTIFY_RESULT_MESSAGE_KEY:-unknown}" \ - "${_MAYOR_NOTIFY_RESULT_ATTEMPT:-0}" \ - "${_MAYOR_NOTIFY_RESULT_OUTCOME:-failed}" \ - "${_MAYOR_NOTIFY_RESULT_REASON:-unknown}" \ - "${_MAYOR_NOTIFY_RESULT_MATCHER:-unknown}" \ - "true" || true - log_event "MAYOR_NOTIFY_FAIL_OPEN channel=${_MAYOR_NOTIFY_RESULT_CHANNEL:-rigger} target=\"$(_escape_quotes "${_MAYOR_NOTIFY_RESULT_TARGET:-rigger}")\" message_key=${_MAYOR_NOTIFY_RESULT_MESSAGE_KEY:-unknown} attempt=${_MAYOR_NOTIFY_RESULT_ATTEMPT:-0} outcome=${_MAYOR_NOTIFY_RESULT_OUTCOME:-failed} reason=${_MAYOR_NOTIFY_RESULT_REASON:-unknown} matcher=${_MAYOR_NOTIFY_RESULT_MATCHER:-unknown} escalated=${_MAYOR_NOTIFY_RESULT_ESCALATED:-0} action=retain-warning-continue" - echo "[mayor] notify warning retained without failing command (outcome=${_MAYOR_NOTIFY_RESULT_OUTCOME:-failed} reason=${_MAYOR_NOTIFY_RESULT_REASON:-unknown} matcher=${_MAYOR_NOTIFY_RESULT_MATCHER:-unknown})" - return 0 + _mayor_notify_rigger_fail_open "$message" "0" "${SGT_MAYOR_SCOPE_RIG:-}" } cmd_mayor_request_list() { @@ -13645,9 +15144,12 @@ cmd_mayor_request_complete() { local meta_path="$(_plan_request_meta_path "$request_id")" [[ -f "$meta_path" ]] || die "plan request '$request_id' not found" local rig plan_file repo sync_reason sync_blocker_id - eval "$(grep -E '^(RIG|REQUESTING_AGENT_ID)=' "$meta_path" 2>/dev/null || true)" + eval "$(grep -E '^(RIG|REQUESTING_AGENT_ID|PLAN_FILE)=' "$meta_path" 2>/dev/null || true)" rig="${RIG:-}" plan_file="$(_plan_file_path "$rig")" + if [[ -n "$rig" ]]; then + _plan_request_reconcile_canonical_plan_file "$request_id" "$rig" "$plan_file" >/dev/null 2>&1 || true + fi repo="$(rig_repo "$rig" 2>/dev/null || true)" if [[ -n "$rig" && -n "$repo" && -f "$plan_file" ]]; then if ! _plan_sync_state_and_labels "$rig" "$plan_file" "$repo"; then @@ -13752,10 +15254,12 @@ cmd_mayor_merge() { if [[ "$use_auto" == "true" ]]; then merge_cmd+=(--auto) fi + local restore_errexit + restore_errexit="$(_shell_errexit_state)" set +e merge_out="$("${merge_cmd[@]}" 2>&1)" merge_rc=$? - set -e + _shell_errexit_restore "$restore_errexit" pr_state="$(gh pr view "$pr" --repo "$repo" --json state --jq '.state // ""' 2>/dev/null || true)" merged_at="$(gh pr view "$pr" --repo "$repo" --json mergedAt --jq '.mergedAt // ""' 2>/dev/null || true)" merge_observed_state="pr_state=${pr_state:-unknown};merged_at=${merged_at:-none};merge_rc=${merge_rc}" @@ -13814,6 +15318,7 @@ EOF _cmd_mayor_start_one() { local scope_rig="${1:-}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" ensure_init _mayor_scope_apply "$scope_rig" @@ -13821,7 +15326,8 @@ _cmd_mayor_start_one() { session="$(_mayor_scope_session_name "$scope_rig")" if tmux has-session -t "$session" 2>/dev/null; then info "$(_mayor_scope_agent_name "$scope_rig") already running" - return + _mayor_scope_apply "$prior_scope_rig" + return 0 fi # Create FIFO for event-driven wake @@ -13864,11 +15370,13 @@ _cmd_mayor_start_one() { _mayor_exit_state_write "tmux-new-session-failed" "1" "START" "true" "$$" "" "" "spawn-failed" log_event "MAYOR_SPAWN_FAILED reason_code=tmux-new-session-failed attempt=$attempt_token exec_path=\"$(_escape_quotes "$sgt_exec")\" detail=\"$(_escape_quotes "$startup_detail")\"" warn "$(_mayor_scope_agent_name "$scope_rig") spawn failed before startup validation (attempt=$attempt_token). See $mayor_log" + _mayor_scope_apply "$prior_scope_rig" return 1 fi if _mayor_wait_for_start "$session" "$attempt_token" "$startup_timeout"; then info "$(_mayor_scope_agent_name "$scope_rig") started (session: $session, event-driven + fallback every ${SGT_MAYOR_INTERVAL}s)" + _mayor_scope_apply "$prior_scope_rig" return 0 fi @@ -13886,29 +15394,35 @@ _cmd_mayor_start_one() { log_event "MAYOR_SPAWN_FAILED reason_code=startup-validation-failed attempt=$attempt_token timeout=${startup_timeout}s log_path=\"$(_escape_quotes "$mayor_log")\" detail=\"$(_escape_quotes "$startup_detail")\"" log_event "MAYOR_START_FAILED reason_code=startup-validation-failed attempt=$attempt_token timeout=${startup_timeout}s log_path=\"$(_escape_quotes "$mayor_log")\" detail=\"$(_escape_quotes "$startup_detail")\"" warn "$(_mayor_scope_agent_name "$scope_rig") spawn failed validation (attempt=$attempt_token, timeout=${startup_timeout}s). See $mayor_log" + _mayor_scope_apply "$prior_scope_rig" return 1 } cmd_mayor_start() { local scope_rig="${1:-}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" if _mayor_architecture_per_rig; then _hierarchy_cutover_retire_shared_mayor || true if [[ -n "$scope_rig" ]]; then - _cmd_mayor_start_one "$scope_rig" - return $? + local start_rc=0 + _cmd_mayor_start_one "$scope_rig" || start_rc=$? + _mayor_scope_apply "$prior_scope_rig" + return "$start_rc" fi local rig for rig in $(_mayor_all_rigs); do _cmd_mayor_start_one "$rig" done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 0 fi _cmd_mayor_start_one "" + _mayor_scope_apply "$prior_scope_rig" } _cmd_mayor_stop_one() { local scope_rig="${1:-}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" _mayor_scope_apply "$scope_rig" local session session="$(_mayor_scope_session_name "$scope_rig")" @@ -13921,28 +15435,34 @@ _cmd_mayor_stop_one() { fi # Clean up FIFO [[ -p "$SGT_MAYOR_FIFO" ]] && rm -f "$SGT_MAYOR_FIFO" + _mayor_scope_apply "$prior_scope_rig" return 0 } cmd_mayor_stop() { local scope_rig="${1:-}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" if _mayor_architecture_per_rig; then if [[ -n "$scope_rig" ]]; then - _cmd_mayor_stop_one "$scope_rig" - return $? + local stop_rc=0 + _cmd_mayor_stop_one "$scope_rig" || stop_rc=$? + _mayor_scope_apply "$prior_scope_rig" + return "$stop_rc" fi local rig for rig in $(_mayor_all_rigs); do _cmd_mayor_stop_one "$rig" done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 0 fi _cmd_mayor_stop_one "" + _mayor_scope_apply "$prior_scope_rig" } _cmd_mayor_refresh_one() { local scope_rig="${1:-}" + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" local was_running="false" handoff_token handoff_dir handoff_file status_snapshot refresh_reason ensure_init _mayor_scope_apply "$scope_rig" @@ -13973,30 +15493,36 @@ _cmd_mayor_refresh_one() { refresh_reason="manual-refresh|handoff=$handoff_token" fi cmd_wake_mayor "$refresh_reason" + _mayor_scope_apply "$prior_scope_rig" echo "$handoff_file" } cmd_mayor_refresh() { local scope_rig="${1:-}" - if _mayor_architecture_per_rig; then + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" + if _mayor_architecture_per_rig "$scope_rig"; then if [[ -n "$scope_rig" ]]; then - _cmd_mayor_refresh_one "$scope_rig" - return $? + local refresh_rc=0 + _cmd_mayor_refresh_one "$scope_rig" || refresh_rc=$? + _mayor_scope_apply "$prior_scope_rig" + return "$refresh_rc" fi local rig for rig in $(_mayor_all_rigs); do _cmd_mayor_refresh_one "$rig" done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" return 0 fi _cmd_mayor_refresh_one "" + _mayor_scope_apply "$prior_scope_rig" } # Public command: wake the mayor on demand cmd_wake_mayor() { local reason="${1:-manual}" local target_rig + local prior_scope_rig="${SGT_MAYOR_SCOPE_RIG:-}" target_rig="$(_mayor_wake_reason_rig "$reason")" if _mayor_architecture_per_rig; then local rig started=0 @@ -14009,7 +15535,7 @@ cmd_wake_mayor() { fi _wake_mayor "$reason" done - _mayor_scope_apply "" + _mayor_scope_apply "$prior_scope_rig" if [[ "$started" -eq 1 && -z "$target_rig" ]]; then return 0 fi @@ -14877,14 +16403,15 @@ _worker_plan_completion_context_md() { status="${c2:-not_declared}" rollup="${c3:-not_declared}" condition="${c4:-}" - acceptance_json="${c5:-{}}" + acceptance_json="${c5:-}" + [[ -n "$acceptance_json" ]] || acceptance_json="{}" details="${c6:-}" blocked_reason="${c7:-}" ;; esac done < <(_plan_state_snapshot "$rig" "$plan_file" "$(_plan_file_path "$rig")" 2>/dev/null || true) - if [[ "$status" == "not_declared" && -z "$condition" && "${acceptance_json:-{}}" == "{}" ]]; then + if [[ "$status" == "not_declared" && -z "$condition" && "${acceptance_json:-}" == "{}" ]]; then return 0 fi @@ -15028,12 +16555,15 @@ Core Commands: create blocker Store a durable verified acceptance blocker and wake the mayor --file Read blocker evidence from a file --agent Reporter id for follow-up routing + config notify Set or show the per-rig OpenClaw notify route + config ralph [opts] Configure Ralph mode policy for a rig sling [opts] Dispatch coding work (issue + branch + polecat) --convoy Add to milestone --label