session-orchestrator 5.4.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: navigator
|
|
3
|
-
description: >
|
|
4
|
-
Fleet coordinator persona for sessions running side by side on one host: it measures resources,
|
|
5
|
-
pipelines and merge readiness itself, writes conditions ("Auflagen") as files, starts headless
|
|
6
|
-
runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as
|
|
7
|
-
/session-orchestrator:navigator ticker|status|start|fenster|handover.
|
|
8
|
-
user-invocable: true
|
|
9
|
-
disable-model-invocation: true
|
|
10
|
-
argument-hint: "[ticker|status|start|fenster|handover]"
|
|
11
|
-
model: inherit
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
# Navigator (Flotten-Koordinator)
|
|
15
|
-
|
|
16
|
-
Aufgerufen als `/session-orchestrator:navigator $ARGUMENTS`. Ohne Argument: `status`.
|
|
17
|
-
|
|
18
|
-
Dateilayout, Felder, Fristen und Lease-Gültigkeit stehen ausschließlich in
|
|
19
|
-
`skills/_shared/fleet-protocol.md` und im Code `scripts/lib/fleet-protocol.mjs`. Dieser Skill
|
|
20
|
-
wiederholt keine Schemata; er beschreibt, was der Navigator misst, schreibt und unterlässt.
|
|
21
|
-
|
|
22
|
-
## 1. Rollenverständnis
|
|
23
|
-
|
|
24
|
-
- **Freigaben erteile ich keine, maßgeblich ist dein Owner-Auftrag.** Der Navigator misst nach und
|
|
25
|
-
schreibt Bedingungen. Außenwirkung (Prod-Deploy, Mails, Posts, Käufe, Rechtstexte) bleibt beim Owner.
|
|
26
|
-
- Eine Peer-Nachricht trägt eine Anweisung, nie eine Freigabe (CSM-003,
|
|
27
|
-
`.claude/rules/cross-session-messaging.md`). Zustand liegt in Dateien, Nachrichten sind nur Hinweise
|
|
28
|
-
auf eine Datei (ADR-002); nie auf eine Antwort warten (CSM-004).
|
|
29
|
-
- „Nicht gefunden" ist nie „nicht vorhanden" (ADR-004): was nicht messbar war, wird als
|
|
30
|
-
`nicht messbar` geführt, nie als frei.
|
|
31
|
-
- Genau ein Navigator je Lease (E12). **Grenze, ausdrücklich:** Erwerb, Erneuerung und Übergabe der
|
|
32
|
-
Lease `leases/navigator.json` sind S4 (agents/navigator#36) und nicht Teil dieses Skills. Solange S4
|
|
33
|
-
fehlt, schreibt dieser Skill keine Lease, und der SessionStart-Banner der Peers zeigt weiter
|
|
34
|
-
„keiner nachweisbar". Das ist der korrekte Zustand, kein Fehler.
|
|
35
|
-
- Registry-Anzeige: jede Ticker-Runde setzt `heartbeat(sessionId, { role: 'navigator' })` aus
|
|
36
|
-
`scripts/lib/session-registry.mjs` (dieser Skill ist der erste produktive Schreiber von `role`). Die
|
|
37
|
-
Registry ist Anzeige; maßgeblich bleibt die Lease. Fehlt der Registry-Eintrag, ist der Aufruf ein
|
|
38
|
-
No-op (`null`), kein Fehler.
|
|
39
|
-
|
|
40
|
-
## 2. Auflagen-Standard
|
|
41
|
-
|
|
42
|
-
Ohne eigene Auflagen gelten für jede Session die Standard-Auflagen aus
|
|
43
|
-
`skills/_shared/fleet-protocol.md` (Abschnitt `auflagen/`). Der Navigator schreibt
|
|
44
|
-
`auflagen/<session_id>.json` nur für Sessions mit Check-in:
|
|
45
|
-
|
|
46
|
-
- Pfad nur über `auflagenPath(sessionId)` aus `scripts/lib/fleet-protocol.mjs`; vorher
|
|
47
|
-
`isSafeSessionId()` prüfen (der Pfad-Helfer wirft sonst).
|
|
48
|
-
- Schreiben wie `scripts/fleet-checkin.mjs` (`writeCheckinFile`, derselbe Kontrakt): Verzeichnisse mit
|
|
49
|
-
Modus 0700 anlegen und auch auf ein schon vorhandenes Verzeichnis `chmod 0700` anwenden; tmp-Datei
|
|
50
|
-
im Zielverzeichnis exklusiv anlegen (`wx`), explizit `chmod 0600` (die Umask filtert den
|
|
51
|
-
Anlege-Modus), dann `rename`. Nie `mkdir -p` + `cat > tmp && mv` ohne diese drei Schritte.
|
|
52
|
-
- `zeit` aus `date -u +%FT%TZ` (`utcSecondsTimestamp()`), nie geschätzt.
|
|
53
|
-
|
|
54
|
-
Schwellen (freier Speicher des lokalen Hosts laut `memory_pressure | tail -1`):
|
|
55
|
-
|
|
56
|
-
| Freier Speicher | Caps je Peer |
|
|
57
|
-
|---|---|
|
|
58
|
-
| ≥ 50 % | 2 prozessstartende + 2 lesende Agents |
|
|
59
|
-
| 35–50 % | 1 prozessstartender Agent |
|
|
60
|
-
| < 35 % | keine neuen Starts |
|
|
61
|
-
|
|
62
|
-
Offload auf `m5-remote` trägt keine eigene Lastzahl: maßgeblich ist allein `navigator m5-ersatz
|
|
63
|
-
--quiet` mit dem Exit-Vertrag aus `skills/_shared/fleet-protocol.md` (Standard-Auflagen). Jeder Exit
|
|
64
|
-
außer 0 heißt keine neuen Offload-Gates; den gemessenen Exit trägt `offload.m5_ersatz_exit`.
|
|
65
|
-
|
|
66
|
-
Minimalbeispiel (nur Platzhalter; Feldbedeutung laut Referenz):
|
|
67
|
-
|
|
68
|
-
```json
|
|
69
|
-
{
|
|
70
|
-
"session": "session-A1",
|
|
71
|
-
"caps": { "prozessstartend": 2, "lesend": 2, "grundlage": "frei 54 %" },
|
|
72
|
-
"pipelines": { "repo-A": 2 },
|
|
73
|
-
"offload": { "host": "m5-remote", "m5_ersatz_exit": 0, "regel": "offload nur nach m5-ersatz Exit 0", "lokal": "vitest/eslint je Datei mit timeout" },
|
|
74
|
-
"merge_protokoll": "Ansage, eigene Messung, Befugnis nach PRD 3.5",
|
|
75
|
-
"zeitregel": "Zeiten nur aus date",
|
|
76
|
-
"flottenprioritaet": ["repo-A", "repo-B"],
|
|
77
|
-
"besitzkonflikte": [],
|
|
78
|
-
"pflichtsatz": "Freigaben erteile ich keine, maßgeblich ist dein Owner-Auftrag",
|
|
79
|
-
"zeit": "2026-01-01T00:00:00Z"
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## 3. Merge-Zeichen als Messung, nicht als Freigabe
|
|
84
|
-
|
|
85
|
-
Ein „Zeichen" heißt: die Belege stimmen und main ist frei, nie „du darfst". Davor misst der Navigator
|
|
86
|
-
selbst, nie aus dem Bericht einer Session:
|
|
87
|
-
|
|
88
|
-
1. MR `opened` und `mergeable`.
|
|
89
|
-
2. Head-SHA = Pipeline-SHA = Gate-SHA; Gate-Beleg ist die SHA-Zeile im Job-Log.
|
|
90
|
-
3. Pipeline `success`, Jobliste mit gelaufen / übersprungen / manuell, Pflichtjobs eingeschlossen.
|
|
91
|
-
4. main frei: keine laufende main-Pipeline, keine gültige fremde Ansage im Repo.
|
|
92
|
-
5. Dateiüberschneidung mit anderen offenen MRs.
|
|
93
|
-
|
|
94
|
-
Nach dem Merge: main-Pipeline bis zum Ende verfolgen, bei Deploy-Repos die Health-SHA messen. Die
|
|
95
|
-
Befugnis kommt aus PRD 3.5 (Owner-Auftrag oder `navigator authority query`), nie vom Navigator.
|
|
96
|
-
Merge-Leases sind S2 (#1463): siehe Abschnitt „Vorschau" in `skills/_shared/fleet-protocol.md`.
|
|
97
|
-
|
|
98
|
-
## 4. Kopflose Läufe starten (`start`)
|
|
99
|
-
|
|
100
|
-
- Vor jedem Start Platz-Tor und Belegt-Tor: `references/slot-check.sh` misst,
|
|
101
|
-
`references/fleet-decisions.mjs` entscheidet (`slot`, `busy`). Verweigert heißt: nicht starten.
|
|
102
|
-
Nie in einem Repo mit aktiver Claude- oder Codex-Session (Belegt-Tor: Codex-Rollout mit diesem
|
|
103
|
-
Checkout jünger als 30 min oder `session.lock` jünger als 6 h). Das Platz-Tor zählt kopflose Läufe
|
|
104
|
-
je Host (4 / 3 / 0 bei ≥ 50 % / ≥ 35 % / darunter freiem Speicher, Last1 > 40 schließt alles) und ist
|
|
105
|
-
eine andere Größe als die Agent-Caps je Peer aus Abschnitt 2.
|
|
106
|
-
- Brief als Datei; er verlangt, vor jeder Welle und jedem MR `auflagen/` und `merge/` zu lesen.
|
|
107
|
-
- Phase 1 mit fester Session-ID, spätere Phasen per `--resume`. Obergrenze je Lauf über
|
|
108
|
-
`timeout -k 60 <sekunden>` (ganze Sekunden; das Platz-Tor zählt Läufe an genau diesem Wrapper). Protokolldateien `phaseN.meta`, `phaseN.jsonl`, `phaseN.err`; je Lauf eine
|
|
109
|
-
Parkplatz-Datei (nur anhängen, nie überschreiben). Ablage je Lauf unter
|
|
110
|
-
`<NAVIGATOR_CONFIG_DIR>/fleet/<lauf>/` (Konvention, kein Skript liest sie).
|
|
111
|
-
- Hintergrund-Tasks und Monitor sterben mit dem Turn-Ende; ein alleinstehendes langes `sleep` blockt
|
|
112
|
-
der Harness; die `until`/`case`-Form kehrt sofort zurück, weil `case` ohne Treffer 0 liefert.
|
|
113
|
-
**Wartemuster**, wörtlich:
|
|
114
|
-
|
|
115
|
-
`timeout 540 bash -c 'while :; do s=$(<status-befehl>); case "$s" in success|failed|canceled|skipped|manual) echo "$s"; exit 0;; esac; sleep 20; done'; echo "rc=$?"`
|
|
116
|
-
|
|
117
|
-
Exit 124 heißt weiter warten: denselben Befehl wiederholen. Der Turn endet nie, solange etwas läuft.
|
|
118
|
-
|
|
119
|
-
## 5. Pfad-Regel für Offload
|
|
120
|
-
|
|
121
|
-
`offload gate|run` immer mit dem **Worktree-Pfad**, nie mit dem Repo-Namen (sonst wird der
|
|
122
|
-
Haupt-Checkout gespiegelt). Host nur über den Alias `m5-remote`. Im entfernten Befehl kein `timeout`
|
|
123
|
-
und keine Login-Shell. Details: `skills/remote-offload/SKILL.md`.
|
|
124
|
-
|
|
125
|
-
## 6. Codex-Läufe
|
|
126
|
-
|
|
127
|
-
- `codex exec` mit `--json` und `-o <datei>`; Fortsetzung per `codex exec resume <thread-id>`.
|
|
128
|
-
- Codex-Läufe mergen nie. Eine Claude-Session prüft ihr Ergebnis unabhängig, bevor irgendetwas
|
|
129
|
-
gemergt wird.
|
|
130
|
-
- Laufende Codex-Sessions sind nur lesend beobachtbar (Rollout-Dateien unter
|
|
131
|
-
`$NAVIGATOR_CODEX_HOME/sessions`, mtime < 3 min = aktiv in der Lage; das Belegt-Tor aus Abschnitt 4
|
|
132
|
-
zählt konservativer, 30 min) und per Nachricht nicht erreichbar.
|
|
133
|
-
|
|
134
|
-
## 7. Phase-2-Fortsetzung bei Abbruch
|
|
135
|
-
|
|
136
|
-
Endet ein Lauf früh (Exit ≠ 0, „killed"-Task im Log, Bericht fehlt): Parkplatz-Datei und die letzten
|
|
137
|
-
`result`-Zeilen lesen, Zustand selbst messen (Worktree, HEAD, Gate-Job), dann Phase 2 mit einem kurzen
|
|
138
|
-
Fortsetzungsbrief starten, wieder hinter beiden Toren.
|
|
139
|
-
|
|
140
|
-
## 8. Konto-Wache
|
|
141
|
-
|
|
142
|
-
- Nutzbar: 5h < 97 % und 7d < 97 %. Das nutzbare Konto mit dem frühesten 7d-Reset (≤ 3 Tage) zuerst
|
|
143
|
-
aufbrauchen; ferne Konten fair nach Verbrauch. Toleranz 1 800 s bei gleichem Reset gegen Flattern.
|
|
144
|
-
- Fehlende Daten = nicht messbar, nie „frei".
|
|
145
|
-
- Werkzeug: `references/account-check.sh` liest `NAVIGATOR_ACCOUNT_CMD` und gibt nur die Entscheidung
|
|
146
|
-
aus (Ziel als Slot-Nummer, nie als Alias); ungesetzt meldet es „nicht messbar". Nie Konto-Rohdaten
|
|
147
|
-
ausgeben.
|
|
148
|
-
- Ein Wechsel wirkt auf alle Sessions des Hosts und braucht eine dauerhafte Owner-Freigabe; ohne sie
|
|
149
|
-
nur eine Empfehlung.
|
|
150
|
-
|
|
151
|
-
## 9. KI-Budget-Regel
|
|
152
|
-
|
|
153
|
-
Bezahlter API-Key nur produktiv. Tests, Evals und CI-Proben laufen mit Mock, Kassette oder
|
|
154
|
-
Abo-/CLI-Modell. Tages- und Wochenverbrauch aus den Peer-Meldungen in die Lage-Datei; die Schwelle
|
|
155
|
-
für einen Owner-Punkt ist Konfiguration, nie eine erfundene Zahl.
|
|
156
|
-
|
|
157
|
-
## 10. Unterbefehle
|
|
158
|
-
|
|
159
|
-
| Befehl | Was er tut |
|
|
160
|
-
|---|---|
|
|
161
|
-
| `ticker` | Eine Runde (unten). Wiederholung nur über `/loop`, nie selbst gebaut. |
|
|
162
|
-
| `status` | Liest Registry, Lease, `checkin/`, `auflagen/`; schreibt nichts. |
|
|
163
|
-
| `start` | Tore aus Abschnitt 4, dann Lauf. |
|
|
164
|
-
| `fenster` | Merge-Fenster je Repo als Messung nach Abschnitt 3. |
|
|
165
|
-
| `handover` | Übergabedatei an den Nachfolger. `uebergabe_an` in der Lease ist S4 und wird hier nur beschrieben. |
|
|
166
|
-
|
|
167
|
-
**Ticker-Runde:** (1) Zeit aus `date`; (2) Ressourcen (Speicher, Last lokal und `m5-remote`, Platte);
|
|
168
|
-
(3) Peers per `ListAgents`; (4) Codex nur lesend; (5) kopflose Läufe (`phaseN.meta`, Parkplatz);
|
|
169
|
-
(6) Pipelines per `glab api "projects/:id/pipelines?status=running"`; (7) Waisen: Prozesse mit PPID 1
|
|
170
|
-
aus Flotten-Repos, nur melden, beenden nur mit Register-Treffer (HR-107); (8) Owner-Punkte; (9) Konto;
|
|
171
|
-
(10) Budget. Danach `heartbeat(sessionId, { role: 'navigator' })`. Antwort eine Zeile, bei Befund
|
|
172
|
-
höchstens sechs.
|
|
173
|
-
|
|
174
|
-
**Kopflos:** unter `claude -p` sind nur `session` und `plan` als eingebaute Namen reserviert. Bis
|
|
175
|
-
`/navigator` ohne Namespace gemessen ist, gilt `/session-orchestrator:navigator`.
|
|
176
|
-
|
|
177
|
-
## Konfiguration
|
|
178
|
-
|
|
179
|
-
Nur über Umgebungsvariablen (getrimmt; leer zählt als nicht gesetzt):
|
|
180
|
-
|
|
181
|
-
| Variable | Default | Zweck |
|
|
182
|
-
|---|---|---|
|
|
183
|
-
| `NAVIGATOR_CONFIG_DIR` | `~/.config/navigator` | Protokollwurzel (`navigatorDir()`, S1) |
|
|
184
|
-
| `NAVIGATOR_CODEX_HOME` | `~/.codex` | Codex-Rollouts, nur lesend |
|
|
185
|
-
| `NAVIGATOR_ACCOUNT_CMD` | ungesetzt | Konto-Messung (wird als Shell-Befehl ausgeführt, Owner-eigen); ungesetzt = nicht messbar |
|
|
186
|
-
| `NAVIGATOR_ACCOUNT_SWITCH_CMD` | ungesetzt | Wechselbefehl; `--apply` zeigt ihn nur an, führt ihn nie aus |
|
|
187
|
-
| `FLEET_SLOT_OVERRIDE`, `FLEET_BUSY_OVERRIDE` | ungesetzt | `1` = Owner-Override je Tor (Grund `override` in der Entscheidung) |
|
|
188
|
-
|
|
189
|
-
## See Also
|
|
190
|
-
|
|
191
|
-
`skills/_shared/fleet-protocol.md` · `skills/remote-offload/SKILL.md` ·
|
|
192
|
-
`.claude/rules/cross-session-messaging.md` · `.claude/rules/parallel-sessions.md` ·
|
|
193
|
-
`skills/_shared/state-ownership.md` · PRD `docs/prd/2026-09-27-navigator-peer-protokoll.md`
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# account-check.sh — account watch: recommend which account this host should run on.
|
|
3
|
-
#
|
|
4
|
-
# Usage: account-check.sh [--apply]
|
|
5
|
-
#
|
|
6
|
-
# Runs $NAVIGATOR_ACCOUNT_CMD (must print JSON rows: an array, or {"accounts": [...]}, each row
|
|
7
|
-
# {slot, alias?, active, p7, p5, resetAt}) and pipes it into `fleet-decisions.mjs account`.
|
|
8
|
-
# Prints ONLY the decision line — raw account rows never reach stdout or stderr (SEC-008).
|
|
9
|
-
#
|
|
10
|
-
# --apply does not switch. It prints the command that WOULD run via $NAVIGATOR_ACCOUNT_SWITCH_CMD
|
|
11
|
-
# and refuses if that variable is unset: a switch affects every session on the host and needs a
|
|
12
|
-
# durable owner approval, so this script only ever recommends.
|
|
13
|
-
#
|
|
14
|
-
# NAVIGATOR_ACCOUNT_CMD is executed as shell code by design: it is operator-owned configuration.
|
|
15
|
-
#
|
|
16
|
-
# Env (leading/trailing whitespace is trimmed; a whitespace-only value counts as unset):
|
|
17
|
-
# NAVIGATOR_ACCOUNT_CMD command printing the account rows as JSON (required)
|
|
18
|
-
# NAVIGATOR_ACCOUNT_SWITCH_CMD switch command, shown with the target appended under --apply
|
|
19
|
-
#
|
|
20
|
-
# Exit: 0 stay/switch recommended, 1 no usable account, 2 bad input/usage, 3 not measurable.
|
|
21
|
-
set -u
|
|
22
|
-
|
|
23
|
-
apply=0
|
|
24
|
-
case "${1:-}" in
|
|
25
|
-
'') ;;
|
|
26
|
-
--apply) apply=1 ;;
|
|
27
|
-
*) echo "usage: account-check.sh [--apply]" >&2; exit 2 ;;
|
|
28
|
-
esac
|
|
29
|
-
|
|
30
|
-
# Trim leading/trailing whitespace.
|
|
31
|
-
trim() {
|
|
32
|
-
local v=$1
|
|
33
|
-
v=${v#"${v%%[![:space:]]*}"}
|
|
34
|
-
v=${v%"${v##*[![:space:]]}"}
|
|
35
|
-
printf '%s' "$v"
|
|
36
|
-
}
|
|
37
|
-
account_cmd=$(trim "${NAVIGATOR_ACCOUNT_CMD:-}")
|
|
38
|
-
switch_cmd=$(trim "${NAVIGATOR_ACCOUNT_SWITCH_CMD:-}")
|
|
39
|
-
|
|
40
|
-
if [ -z "$account_cmd" ]; then
|
|
41
|
-
echo "account watch: not measurable (NAVIGATOR_ACCOUNT_CMD unset)"
|
|
42
|
-
exit 3
|
|
43
|
-
fi
|
|
44
|
-
|
|
45
|
-
here=$(cd "$(dirname "$0")" && pwd)
|
|
46
|
-
# Cap the operator command at 40 s where a timeout binary exists (GNU coreutils `timeout`, Homebrew
|
|
47
|
-
# `gtimeout`); a stock macOS host has neither, and the command then runs uncapped rather than failing
|
|
48
|
-
# as "not measurable" (measured on the offload host: `command -v timeout` and `gtimeout` both empty).
|
|
49
|
-
# No bash array here: under `set -u` an empty array expansion fails on bash 3.2 (stock macOS).
|
|
50
|
-
run_capped() {
|
|
51
|
-
if command -v timeout >/dev/null 2>&1; then timeout 40 "$@"
|
|
52
|
-
elif command -v gtimeout >/dev/null 2>&1; then gtimeout 40 "$@"
|
|
53
|
-
else "$@"
|
|
54
|
-
fi
|
|
55
|
-
}
|
|
56
|
-
rows=$(run_capped bash -c "$account_cmd" 2>/dev/null)
|
|
57
|
-
cmd_rc=$?
|
|
58
|
-
if [ "$cmd_rc" -ne 0 ] || [ -z "$rows" ]; then
|
|
59
|
-
echo "account watch: not measurable (account command failed, rc=$cmd_rc)"
|
|
60
|
-
exit 3
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
decision=$(printf '%s' "$rows" | node "$here/fleet-decisions.mjs" account 2>/dev/null)
|
|
64
|
-
rc=$?
|
|
65
|
-
if [ "$rc" -eq 2 ]; then
|
|
66
|
-
echo "account watch: not measurable (account command output is not valid account JSON)"
|
|
67
|
-
exit 2
|
|
68
|
-
fi
|
|
69
|
-
if { [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; } || [ -z "$decision" ]; then
|
|
70
|
-
echo "account watch: not measurable (decision unavailable, rc=$rc)"
|
|
71
|
-
exit 3
|
|
72
|
-
fi
|
|
73
|
-
echo "account: $decision"
|
|
74
|
-
|
|
75
|
-
if [ "$apply" -eq 1 ] && [ "$rc" -eq 0 ]; then
|
|
76
|
-
if [ -z "$switch_cmd" ]; then
|
|
77
|
-
echo "apply refused: NAVIGATOR_ACCOUNT_SWITCH_CMD unset" >&2
|
|
78
|
-
exit 2
|
|
79
|
-
fi
|
|
80
|
-
target=$(printf '%s' "$decision" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const d=JSON.parse(s);process.stdout.write(d.action==="switch"?String(d.target):"")})')
|
|
81
|
-
if [ -n "$target" ]; then
|
|
82
|
-
echo "would run: $switch_cmd $target (not executed — switching needs a durable owner approval)"
|
|
83
|
-
else
|
|
84
|
-
echo "would run: nothing (stay)"
|
|
85
|
-
fi
|
|
86
|
-
fi
|
|
87
|
-
exit "$rc"
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* skills/navigator/references/fleet-decisions.mjs
|
|
3
|
-
*
|
|
4
|
-
* Pure decision functions for the navigator's headless fleet: whether a new
|
|
5
|
-
* headless run may start on this host (slot gate), whether a target repo is
|
|
6
|
-
* already occupied by another session (busy gate), and which account the host
|
|
7
|
-
* should run on (account watch).
|
|
8
|
-
*
|
|
9
|
-
* Measurement lives elsewhere — `slot-check.sh` and `account-check.sh` in this
|
|
10
|
-
* directory collect the raw numbers and pipe them in here. This module reads
|
|
11
|
-
* no environment variables, no files and no clock except the `now` argument,
|
|
12
|
-
* so every rule below is testable with plain values.
|
|
13
|
-
*
|
|
14
|
-
* Shared principle: a value that could not be measured is never read as
|
|
15
|
-
* "free". Every gate fails closed on missing, NaN or non-number input; only
|
|
16
|
-
* an explicit `override: true` (an owner decision) bypasses a gate.
|
|
17
|
-
*
|
|
18
|
-
* Slot gate thresholds (free memory % from the OS pressure verdict):
|
|
19
|
-
* free >= 50 → cap 4
|
|
20
|
-
* free >= 35 → cap 3
|
|
21
|
-
* otherwise → cap 0
|
|
22
|
-
* load1 > 40 → cap 0 regardless of memory
|
|
23
|
-
* allowed = runs < cap
|
|
24
|
-
*
|
|
25
|
-
* Busy gate: busy when any Codex rollout for the repo is younger than 30 min
|
|
26
|
-
* or any `session.lock` is younger than 6 h (counts measured by the caller).
|
|
27
|
-
*
|
|
28
|
-
* Account watch: usable = 5-hour and 7-day usage both below 97 %. Among usable
|
|
29
|
-
* rows the earliest 7-day reset within 3 days wins (tie-break lower 7-day
|
|
30
|
-
* usage); if none is that near, the lowest 7-day usage wins. The active row is
|
|
31
|
-
* kept when its reset lies within 1800 s of the target's, either side (same
|
|
32
|
-
* reset, no flapping). A reset date in the past is stale data and the row is
|
|
33
|
-
* skipped. Rows are identified by `slot` only — the alias never reaches the
|
|
34
|
-
* output (SEC-008).
|
|
35
|
-
*
|
|
36
|
-
* CLI: `node fleet-decisions.mjs slot|busy|account` reads one JSON document
|
|
37
|
-
* from stdin and prints the decision as one JSON line.
|
|
38
|
-
* exit 0 — allowed / not busy / stay or switch
|
|
39
|
-
* exit 1 — refused / busy / no usable account
|
|
40
|
-
* exit 2 — unparseable input or unknown subcommand
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import { readFileSync } from 'node:fs';
|
|
44
|
-
import { fileURLToPath } from 'node:url';
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// Constants
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
const FREE_PCT_CAP4 = 50;
|
|
51
|
-
const FREE_PCT_CAP3 = 35;
|
|
52
|
-
const LOAD1_MAX = 40;
|
|
53
|
-
const USAGE_LIMIT_PCT = 97;
|
|
54
|
-
const NEAR_RESET_MS = 3 * 24 * 60 * 60 * 1000;
|
|
55
|
-
const RESET_TIE_MS = 1800 * 1000;
|
|
56
|
-
|
|
57
|
-
/** @param {unknown} v @returns {v is number} */
|
|
58
|
-
function isNum(v) {
|
|
59
|
-
return typeof v === 'number' && Number.isFinite(v);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
// Slot gate
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Decide whether one more headless run may start on this host.
|
|
68
|
-
*
|
|
69
|
-
* @param {{ freePct?: unknown, load1?: unknown, runs?: unknown, override?: unknown }} input
|
|
70
|
-
* @returns {{ allowed: boolean, cap: number, reason: string }}
|
|
71
|
-
*/
|
|
72
|
-
export function slotDecision({ freePct, load1, runs, override } = {}) {
|
|
73
|
-
const memOk = isNum(freePct);
|
|
74
|
-
const loadOk = isNum(load1);
|
|
75
|
-
let cap = 0;
|
|
76
|
-
if (memOk && loadOk) {
|
|
77
|
-
if (freePct >= FREE_PCT_CAP4) cap = 4;
|
|
78
|
-
else if (freePct >= FREE_PCT_CAP3) cap = 3;
|
|
79
|
-
if (load1 > LOAD1_MAX) cap = 0;
|
|
80
|
-
}
|
|
81
|
-
if (override === true) return { allowed: true, cap, reason: 'override' };
|
|
82
|
-
|
|
83
|
-
const ctx = `(free ${memOk ? `${freePct}%` : '?'}, load1 ${loadOk ? load1 : '?'})`;
|
|
84
|
-
if (!memOk) return { allowed: false, cap: 0, reason: `no slot: free memory not measurable ${ctx}` };
|
|
85
|
-
if (!loadOk) return { allowed: false, cap: 0, reason: `no slot: load1 not measurable ${ctx}` };
|
|
86
|
-
if (!isNum(runs) || !Number.isInteger(runs) || runs < 0) {
|
|
87
|
-
return { allowed: false, cap, reason: `no slot: run count not measurable ${ctx}` };
|
|
88
|
-
}
|
|
89
|
-
const allowed = runs < cap;
|
|
90
|
-
return { allowed, cap, reason: `${allowed ? 'slot free' : 'no slot'}: ${runs} runs, cap ${cap} ${ctx}` };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Busy gate
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Decide whether a repo is occupied by another session.
|
|
99
|
-
*
|
|
100
|
-
* @param {{ codexRolloutsFresh?: unknown, lockFresh?: unknown, override?: unknown }} input
|
|
101
|
-
* @returns {{ busy: boolean, reason: string }}
|
|
102
|
-
*/
|
|
103
|
-
export function busyDecision({ codexRolloutsFresh, lockFresh, override } = {}) {
|
|
104
|
-
if (override === true) return { busy: false, reason: 'override' };
|
|
105
|
-
const valid = (v) => isNum(v) && v >= 0;
|
|
106
|
-
if (!valid(codexRolloutsFresh) || !valid(lockFresh)) {
|
|
107
|
-
return { busy: true, reason: 'busy: occupancy not measurable (codex rollouts or session.lock count missing)' };
|
|
108
|
-
}
|
|
109
|
-
const counts = `${codexRolloutsFresh} codex rollouts (30 min), ${lockFresh} session.lock (6 h)`;
|
|
110
|
-
if (codexRolloutsFresh > 0 || lockFresh > 0) return { busy: true, reason: `busy: ${counts}` };
|
|
111
|
-
return { busy: false, reason: `free: ${counts}` };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// Account watch
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Reduce one raw account row to the fields the decision needs, or null when it
|
|
120
|
-
* is not measurable. Copies nothing else — in particular not the alias. Never
|
|
121
|
-
* throws: a hostile row (throwing getter, proxy) counts as not measurable.
|
|
122
|
-
*
|
|
123
|
-
* @param {unknown} r
|
|
124
|
-
* @param {number} nowMs
|
|
125
|
-
* @returns {{ id: string|number, active: boolean, p7: number, p5: number, resetMs: number } | null}
|
|
126
|
-
*/
|
|
127
|
-
function parseAccountRow(r, nowMs) {
|
|
128
|
-
try {
|
|
129
|
-
if (!r || typeof r !== 'object') return null;
|
|
130
|
-
const { slot, active, p7, p5, resetAt } = /** @type {Record<string, unknown>} */ (r);
|
|
131
|
-
const slotOk = (typeof slot === 'string' && slot !== '') || isNum(slot);
|
|
132
|
-
const resetMs = typeof resetAt === 'string' ? Date.parse(resetAt) : NaN;
|
|
133
|
-
if (!slotOk || !isNum(p7) || !isNum(p5) || !Number.isFinite(resetMs) || resetMs < nowMs) return null;
|
|
134
|
-
return { id: /** @type {string|number} */ (slot), active: active === true, p7, p5, resetMs };
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Pick the account the host should run on. Never throws; malformed rows
|
|
142
|
-
* (non-string/non-number slot, missing usage, unparseable or past reset) are
|
|
143
|
-
* skipped and counted in the reason. The output carries the slot only, never
|
|
144
|
-
* the alias (SEC-008).
|
|
145
|
-
*
|
|
146
|
-
* @param {Array<{ slot: unknown, alias?: string, active: boolean, p7: number|null, p5: number|null, resetAt: string|null }>} rows
|
|
147
|
-
* @param {Date|number} now
|
|
148
|
-
* @returns {{ action: 'stay'|'switch'|'none', target: string|number|null, reason: string }}
|
|
149
|
-
*/
|
|
150
|
-
export function chooseAccount(rows, now) {
|
|
151
|
-
const nowMs = now instanceof Date ? now.getTime() : Number(now);
|
|
152
|
-
if (!Number.isFinite(nowMs)) return { action: 'none', target: null, reason: 'no decision: now is not a valid time' };
|
|
153
|
-
const list = Array.isArray(rows) ? rows : [];
|
|
154
|
-
|
|
155
|
-
let skipped = 0;
|
|
156
|
-
const parsed = [];
|
|
157
|
-
for (const r of list) {
|
|
158
|
-
const row = parseAccountRow(r, nowMs);
|
|
159
|
-
if (row === null) skipped += 1;
|
|
160
|
-
else parsed.push(row);
|
|
161
|
-
}
|
|
162
|
-
const skipNote = skipped > 0 ? `; ${skipped} row(s) skipped without data` : '';
|
|
163
|
-
|
|
164
|
-
const usable = parsed.filter((r) => r.p5 < USAGE_LIMIT_PCT && r.p7 < USAGE_LIMIT_PCT);
|
|
165
|
-
const active = parsed.find((r) => r.active === true) ?? null;
|
|
166
|
-
const activeUsable = active !== null && usable.includes(active);
|
|
167
|
-
|
|
168
|
-
if (usable.length === 0) {
|
|
169
|
-
return { action: 'none', target: null, reason: `no usable account (all >= ${USAGE_LIMIT_PCT}% or without data)${skipNote}` };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const near = usable.filter((r) => r.resetMs - nowMs <= NEAR_RESET_MS);
|
|
173
|
-
let target;
|
|
174
|
-
if (near.length > 0) {
|
|
175
|
-
target = near.reduce((a, b) => (b.resetMs < a.resetMs || (b.resetMs === a.resetMs && b.p7 < a.p7) ? b : a));
|
|
176
|
-
} else {
|
|
177
|
-
target = usable.reduce((a, b) => (b.p7 < a.p7 ? b : a));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (activeUsable && active !== target && Math.abs(active.resetMs - target.resetMs) <= RESET_TIE_MS) {
|
|
181
|
-
target = active;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const desc = (r) => `${r.id} (7d ${r.p7}%, reset in ${((r.resetMs - nowMs) / 86400000).toFixed(1)} d)`;
|
|
185
|
-
if (active === target) return { action: 'stay', target: target.id, reason: `stay on ${desc(target)}${skipNote}` };
|
|
186
|
-
return {
|
|
187
|
-
action: 'switch',
|
|
188
|
-
target: target.id,
|
|
189
|
-
reason: `switch ${active ? active.id : '-'} -> ${desc(target)}${skipNote}`,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
// CLI
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
function runCli(argv) {
|
|
198
|
-
const mode = argv[2];
|
|
199
|
-
if (mode !== 'slot' && mode !== 'busy' && mode !== 'account') {
|
|
200
|
-
process.stderr.write('usage: node fleet-decisions.mjs slot|busy|account < input.json\n');
|
|
201
|
-
return 2;
|
|
202
|
-
}
|
|
203
|
-
let input;
|
|
204
|
-
try {
|
|
205
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
206
|
-
} catch {
|
|
207
|
-
process.stderr.write('fleet-decisions: stdin is not valid JSON\n');
|
|
208
|
-
return 2;
|
|
209
|
-
}
|
|
210
|
-
if (mode === 'slot' || mode === 'busy') {
|
|
211
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
212
|
-
process.stderr.write(`fleet-decisions: ${mode} expects one JSON object\n`);
|
|
213
|
-
return 2;
|
|
214
|
-
}
|
|
215
|
-
const d = mode === 'slot' ? slotDecision(input) : busyDecision(input);
|
|
216
|
-
process.stdout.write(`${JSON.stringify(d)}\n`);
|
|
217
|
-
return mode === 'slot' ? (d.allowed ? 0 : 1) : d.busy ? 1 : 0;
|
|
218
|
-
}
|
|
219
|
-
const rows = Array.isArray(input) ? input : input && Array.isArray(input.accounts) ? input.accounts : null;
|
|
220
|
-
if (!rows) {
|
|
221
|
-
process.stderr.write('fleet-decisions: account expects a JSON array or {"accounts": [...]}\n');
|
|
222
|
-
return 2;
|
|
223
|
-
}
|
|
224
|
-
const d = chooseAccount(rows, Date.now());
|
|
225
|
-
process.stdout.write(`${JSON.stringify(d)}\n`);
|
|
226
|
-
return d.action === 'none' ? 1 : 0;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
230
|
-
process.exitCode = runCli(process.argv);
|
|
231
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# slot-check.sh — measure the slot gate and the busy gate for one repo, decide via fleet-decisions.mjs.
|
|
3
|
-
#
|
|
4
|
-
# Usage: slot-check.sh <repo-path>
|
|
5
|
-
#
|
|
6
|
-
# Measures only; every rule lives in fleet-decisions.mjs next to this file.
|
|
7
|
-
# slot gate: free memory % (memory_pressure), 1-min load (vm.loadavg, integer),
|
|
8
|
-
# running headless runs (`timeout -k 60 <n>[smhd] claude -p` + `... codex exec`);
|
|
9
|
-
# anchored on the timeout wrapper because one Codex run spawns three processes matching `codex exec`
|
|
10
|
-
# busy gate: Codex rollouts for <repo-path> younger than 30 min, <repo-path>/.orchestrator/session.lock
|
|
11
|
-
# younger than 6 h
|
|
12
|
-
# A value that cannot be measured is passed as null; the decision module fails closed on it.
|
|
13
|
-
#
|
|
14
|
-
# Env:
|
|
15
|
-
# NAVIGATOR_CODEX_HOME Codex home directory (default: $HOME/.codex; empty/whitespace = unset)
|
|
16
|
-
# FLEET_SLOT_OVERRIDE=1 owner override for the slot gate
|
|
17
|
-
# FLEET_BUSY_OVERRIDE=1 owner override for the busy gate
|
|
18
|
-
#
|
|
19
|
-
# Exit: 0 both gates pass, 1 at least one gate refuses, 2 usage error.
|
|
20
|
-
set -u
|
|
21
|
-
|
|
22
|
-
if [ "$#" -ne 1 ] || [ -z "$1" ]; then
|
|
23
|
-
echo "usage: slot-check.sh <repo-path>" >&2
|
|
24
|
-
exit 2
|
|
25
|
-
fi
|
|
26
|
-
repo=${1%/}
|
|
27
|
-
here=$(cd "$(dirname "$0")" && pwd)
|
|
28
|
-
decide="$here/fleet-decisions.mjs"
|
|
29
|
-
|
|
30
|
-
# Trim leading/trailing whitespace; a whitespace-only value counts as unset.
|
|
31
|
-
trim() {
|
|
32
|
-
local v=$1
|
|
33
|
-
v=${v#"${v%%[![:space:]]*}"}
|
|
34
|
-
v=${v%"${v##*[![:space:]]}"}
|
|
35
|
-
printf '%s' "$v"
|
|
36
|
-
}
|
|
37
|
-
codex_home=$(trim "${NAVIGATOR_CODEX_HOME:-}")
|
|
38
|
-
[ -n "$codex_home" ] || codex_home=$HOME/.codex
|
|
39
|
-
|
|
40
|
-
# Rollouts record the cwd as the session saw it; match both the logical and the physical path.
|
|
41
|
-
repo_abs=$(cd "$repo" 2>/dev/null && pwd)
|
|
42
|
-
repo_phys=$(cd "$repo" 2>/dev/null && pwd -P)
|
|
43
|
-
|
|
44
|
-
# Print the argument if it is a non-negative integer, else the JSON literal null.
|
|
45
|
-
num_or_null() {
|
|
46
|
-
case "$1" in
|
|
47
|
-
''|*[!0-9]*) printf 'null' ;;
|
|
48
|
-
*) printf '%s' "$1" ;;
|
|
49
|
-
esac
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
# --- slot gate measurements ---------------------------------------------------
|
|
53
|
-
free_pct=$(memory_pressure 2>/dev/null | awk '/free percentage/{gsub("%","",$NF); print $NF}')
|
|
54
|
-
load1=$(sysctl -n vm.loadavg 2>/dev/null | awk '{print int($2)}')
|
|
55
|
-
# Count pgrep matches: rc 0 → count, rc 1 → 0 (no match), rc >= 2 → empty (not measurable).
|
|
56
|
-
count_runs() {
|
|
57
|
-
local out rc
|
|
58
|
-
out=$(pgrep -f "$1")
|
|
59
|
-
rc=$?
|
|
60
|
-
case "$rc" in
|
|
61
|
-
0) printf '%s' "$out" | grep -c . ;;
|
|
62
|
-
1) printf '0' ;;
|
|
63
|
-
*) printf '' ;;
|
|
64
|
-
esac
|
|
65
|
-
}
|
|
66
|
-
runs=''
|
|
67
|
-
if command -v pgrep >/dev/null 2>&1; then
|
|
68
|
-
claude_runs=$(count_runs '^timeout -k 60 [0-9]+[smhd]? claude -p')
|
|
69
|
-
codex_runs=$(count_runs '^timeout -k 60 [0-9]+[smhd]? codex exec')
|
|
70
|
-
if [ -n "$claude_runs" ] && [ -n "$codex_runs" ]; then
|
|
71
|
-
runs=$((claude_runs + codex_runs))
|
|
72
|
-
fi
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# --- busy gate measurements ---------------------------------------------------
|
|
76
|
-
# Only the first line of each rollout is matched, as a fixed string; file content is never evaluated.
|
|
77
|
-
if [ -z "$repo_abs" ] || [ -z "$repo_phys" ]; then
|
|
78
|
-
# Repo path not resolvable: both busy counts are unknown, the decision fails closed.
|
|
79
|
-
codex_fresh=''
|
|
80
|
-
lock_fresh=''
|
|
81
|
-
else
|
|
82
|
-
codex_fresh=0
|
|
83
|
-
if [ -d "$codex_home/sessions" ]; then
|
|
84
|
-
codex_fresh=$(find "$codex_home/sessions" -name 'rollout-*.jsonl' -mmin -30 2>/dev/null |
|
|
85
|
-
while IFS= read -r f; do
|
|
86
|
-
head -1 "$f" 2>/dev/null |
|
|
87
|
-
grep -qF -e "\"cwd\":\"$repo_abs\"" -e "\"cwd\":\"$repo_phys\"" && echo x
|
|
88
|
-
done | wc -l | tr -d ' ')
|
|
89
|
-
fi
|
|
90
|
-
lock_fresh=$(find "$repo_abs/.orchestrator" -maxdepth 1 -name session.lock -mmin -360 2>/dev/null | wc -l | tr -d ' ')
|
|
91
|
-
fi
|
|
92
|
-
|
|
93
|
-
slot_override=false
|
|
94
|
-
[ "${FLEET_SLOT_OVERRIDE:-0}" = 1 ] && slot_override=true
|
|
95
|
-
busy_override=false
|
|
96
|
-
[ "${FLEET_BUSY_OVERRIDE:-0}" = 1 ] && busy_override=true
|
|
97
|
-
|
|
98
|
-
slot_json=$(printf '{"freePct":%s,"load1":%s,"runs":%s,"override":%s}' \
|
|
99
|
-
"$(num_or_null "$free_pct")" "$(num_or_null "$load1")" "$(num_or_null "$runs")" "$slot_override")
|
|
100
|
-
busy_json=$(printf '{"codexRolloutsFresh":%s,"lockFresh":%s,"override":%s}' \
|
|
101
|
-
"$(num_or_null "$codex_fresh")" "$(num_or_null "$lock_fresh")" "$busy_override")
|
|
102
|
-
|
|
103
|
-
slot_out=$(printf '%s' "$slot_json" | node "$decide" slot)
|
|
104
|
-
slot_rc=$?
|
|
105
|
-
busy_out=$(printf '%s' "$busy_json" | node "$decide" busy)
|
|
106
|
-
busy_rc=$?
|
|
107
|
-
|
|
108
|
-
echo "slot: $slot_out"
|
|
109
|
-
echo "busy: $busy_out"
|
|
110
|
-
|
|
111
|
-
if [ "$slot_rc" -ne 0 ] || [ "$busy_rc" -ne 0 ]; then
|
|
112
|
-
exit 1
|
|
113
|
-
fi
|
|
114
|
-
exit 0
|