session-orchestrator 5.2.0 → 5.3.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/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -2,21 +2,39 @@
|
|
|
2
2
|
* autopilot/stall-sampler.mjs — Stateless progress sampler for STALL_TIMEOUT
|
|
3
3
|
* kill-switch (ADR-364, issue #371).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* autopilot loop writes one record per session atomically (tmp+rename), so the
|
|
7
|
-
* file mtime advances exactly when the loop makes progress. No false-positive
|
|
8
|
-
* churn from in-place writes, no need to parse JSON, microsecond precision.
|
|
5
|
+
* PROGRESS MARKER PRECEDENCE (HR-102: a better signal REPLACES a worse one).
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* 1. `session.lock` `last_heartbeat` (schema v2) — advances DURING a run:
|
|
8
|
+
* `hooks/on-stop.mjs` refreshes it at every turn-end and
|
|
9
|
+
* `hooks/post-tool-batch-wave-signal.mjs` at every tool batch.
|
|
10
|
+
* 2. `autopilot.jsonl` mtime — the legacy marker, used only when no lock path
|
|
11
|
+
* is supplied or the lock is absent/corrupt/timestamp-less.
|
|
12
|
+
*
|
|
13
|
+
* The header of this module used to claim `autopilot.jsonl` carries "one record
|
|
14
|
+
* per session". It does not: `telemetry.mjs` writes ONE record per /autopilot
|
|
15
|
+
* INVOCATION, and `loop.mjs` calls it exactly twice — once for the dry-run
|
|
16
|
+
* preview and once AFTER the `for(;;)` loop. So during a real run the mtime the
|
|
17
|
+
* sampler read belonged to the PREVIOUS autopilot run, typically hours or days
|
|
18
|
+
* old, and the post-session check fired STALL_TIMEOUT after iteration 1 every
|
|
19
|
+
* time. The append-once contract is correct and stays; the marker was wrong.
|
|
20
|
+
*
|
|
21
|
+
* CEILING (BV-004). The heartbeat only advances while some live session owns the
|
|
22
|
+
* lock: `updateHeartbeat()` refuses on a session_id mismatch, and a child
|
|
23
|
+
* `claude -p` spawned while a parent session already holds the lock never
|
|
24
|
+
* acquires one (`hooks/_lib/lock-bootstrap.mjs` bails on `reason: 'active'`).
|
|
25
|
+
* In that one invocation shape — the headless driver started FROM a chat
|
|
26
|
+
* session rather than from a bare shell — the heartbeat freezes and the check
|
|
27
|
+
* degrades to today's behaviour. Revisit if STALL_TIMEOUT is ever observed
|
|
28
|
+
* firing on a run whose child session did real work.
|
|
29
|
+
*
|
|
30
|
+
* Stateless — at most two sync reads per invocation, no side effects, no async.
|
|
13
31
|
*
|
|
14
32
|
* Exports:
|
|
15
33
|
* SAMPLE_CADENCE_MS — shared constant (30_000) defining the "fresh" window
|
|
16
34
|
* sampleProgress(opts) — returns progress descriptor; never throws
|
|
17
35
|
*/
|
|
18
36
|
|
|
19
|
-
import { statSync } from 'node:fs';
|
|
37
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
20
38
|
|
|
21
39
|
// ---------------------------------------------------------------------------
|
|
22
40
|
// Constants
|
|
@@ -32,9 +50,40 @@ export const SAMPLE_CADENCE_MS = 30_000;
|
|
|
32
50
|
const DEFAULT_AUTOPILOT_JSONL = '.orchestrator/metrics/autopilot.jsonl';
|
|
33
51
|
const DEFAULT_STALL_TIMEOUT_SECONDS = 600;
|
|
34
52
|
const MARKER_SUCCESS = 'autopilot.jsonl:mtime';
|
|
53
|
+
const MARKER_HEARTBEAT = 'session.lock:last_heartbeat';
|
|
35
54
|
const MARKER_MISSING = 'missing';
|
|
36
55
|
const MARKER_INVALID = 'invalid';
|
|
37
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Read the live heartbeat timestamp (ms) out of `session.lock`.
|
|
59
|
+
*
|
|
60
|
+
* Returns `null` — meaning "fall back to the mtime marker" — for every failure
|
|
61
|
+
* mode: no path supplied, file absent, unreadable, not JSON, or carrying no
|
|
62
|
+
* parsable timestamp. A missing measurement is NOT a zero here; conflating the
|
|
63
|
+
* two would report a stall of `now` seconds the moment the lock is unreadable.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} [sessionLockPath]
|
|
66
|
+
* @returns {number | null}
|
|
67
|
+
*/
|
|
68
|
+
function readHeartbeatMs(sessionLockPath) {
|
|
69
|
+
if (typeof sessionLockPath !== 'string' || sessionLockPath.length === 0) return null;
|
|
70
|
+
let lock;
|
|
71
|
+
try {
|
|
72
|
+
lock = JSON.parse(readFileSync(sessionLockPath, 'utf8'));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (typeof lock !== 'object' || lock === null) return null;
|
|
77
|
+
// Same precedence `session-lock.mjs` uses on read: a v1 lock has no
|
|
78
|
+
// `last_heartbeat`, and its `started_at` is the best available stand-in.
|
|
79
|
+
const raw = typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0
|
|
80
|
+
? lock.last_heartbeat
|
|
81
|
+
: lock.started_at;
|
|
82
|
+
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
83
|
+
const ms = Date.parse(raw);
|
|
84
|
+
return Number.isFinite(ms) ? ms : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
38
87
|
// ---------------------------------------------------------------------------
|
|
39
88
|
// Progress sampler
|
|
40
89
|
// ---------------------------------------------------------------------------
|
|
@@ -45,6 +94,10 @@ const MARKER_INVALID = 'invalid';
|
|
|
45
94
|
* @param {object} [opts]
|
|
46
95
|
* @param {string} [opts.autopilotJsonlPath] — path to autopilot.jsonl (default
|
|
47
96
|
* '.orchestrator/metrics/autopilot.jsonl').
|
|
97
|
+
* @param {string} [opts.sessionLockPath] — path to `session.lock`. When supplied
|
|
98
|
+
* and readable, its `last_heartbeat` (falling back to `started_at`, the schema
|
|
99
|
+
* v1 normalisation `session-lock.mjs` applies on read) REPLACES the mtime
|
|
100
|
+
* marker. Omitted → mtime only, which is what every pre-existing caller gets.
|
|
48
101
|
* @param {number} [opts.stallTimeoutSeconds] — kill-switch threshold (default
|
|
49
102
|
* 600). NOT range-clamped here; caller is responsible for bounds.
|
|
50
103
|
* @param {() => number} [opts.nowMs] — wall-clock supplier (default Date.now).
|
|
@@ -66,22 +119,26 @@ export function sampleProgress(opts = {}) {
|
|
|
66
119
|
void _stallTimeoutSeconds;
|
|
67
120
|
const nowMs = typeof opts.nowMs === 'function' ? opts.nowMs : Date.now;
|
|
68
121
|
|
|
69
|
-
let
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
122
|
+
let markerMs = readHeartbeatMs(opts.sessionLockPath);
|
|
123
|
+
let markerName = MARKER_HEARTBEAT;
|
|
124
|
+
|
|
125
|
+
if (markerMs === null) {
|
|
126
|
+
markerName = MARKER_SUCCESS;
|
|
127
|
+
try {
|
|
128
|
+
markerMs = statSync(autopilotJsonlPath).mtimeMs;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const marker = err && err.code === 'ENOENT' ? MARKER_MISSING : MARKER_INVALID;
|
|
131
|
+
return {
|
|
132
|
+
progressed: false,
|
|
133
|
+
lastMarker: null,
|
|
134
|
+
stallSeconds: 0,
|
|
135
|
+
marker,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
81
138
|
}
|
|
82
139
|
|
|
83
140
|
const now = nowMs();
|
|
84
|
-
const deltaMs = now -
|
|
141
|
+
const deltaMs = now - markerMs;
|
|
85
142
|
// Clamp negative deltas (clock skew, file mtime in the future) to 0.
|
|
86
143
|
const stallSeconds = Math.max(0, Math.round(deltaMs / 1000));
|
|
87
144
|
// Fresh if within the sample cadence window (also covers the clock-skew
|
|
@@ -90,8 +147,8 @@ export function sampleProgress(opts = {}) {
|
|
|
90
147
|
|
|
91
148
|
return {
|
|
92
149
|
progressed,
|
|
93
|
-
lastMarker:
|
|
150
|
+
lastMarker: markerMs,
|
|
94
151
|
stallSeconds,
|
|
95
|
-
marker:
|
|
152
|
+
marker: markerName,
|
|
96
153
|
};
|
|
97
154
|
}
|
|
@@ -104,7 +104,8 @@ function degradedResult(reason, detail) {
|
|
|
104
104
|
// Escape here so BOTH routes are covered at their common exit. Slice first,
|
|
105
105
|
// escape after: the budget is 200 chars of DETAIL, and escaping cannot then
|
|
106
106
|
// leave a cut mid-`\uXXXX`.
|
|
107
|
-
const
|
|
107
|
+
const escaped = detail ? escapeControlBytes(String(detail).trim().slice(0, 200)) : '';
|
|
108
|
+
const tail = escaped ? ` — ${escaped}` : '';
|
|
108
109
|
return {
|
|
109
110
|
severity: 'warn',
|
|
110
111
|
ok: false,
|
|
@@ -112,6 +113,14 @@ function degradedResult(reason, detail) {
|
|
|
112
113
|
`⚠ ci-status: CI status for HEAD could not be determined (${reason}) — ` +
|
|
113
114
|
`state UNKNOWN, not "green".${tail}`,
|
|
114
115
|
degraded: reason,
|
|
116
|
+
// The SAME already-clamped, already-escaped tail, carried as a field rather
|
|
117
|
+
// than only inside the banner sentence. Consumers that render their own row
|
|
118
|
+
// (the release preflight's `ci-green-on-head`) otherwise had to discard the
|
|
119
|
+
// only distinguishing part: a short sha, an unknown commit and a missing
|
|
120
|
+
// pipeline all printed the identical `CI status unknown (query-failed)`.
|
|
121
|
+
// Absent when there is no detail, so `ci.detail` stays falsy exactly where
|
|
122
|
+
// there is nothing to add.
|
|
123
|
+
...(escaped ? { detail: escaped } : {}),
|
|
115
124
|
};
|
|
116
125
|
}
|
|
117
126
|
|
|
@@ -155,6 +164,40 @@ function escapeControlBytes(text) {
|
|
|
155
164
|
);
|
|
156
165
|
}
|
|
157
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Ceiling (BV-004) for one API-supplied fragment embedded in a reading's
|
|
169
|
+
* operator-facing fields. 60 characters fits every real GitLab pipeline status
|
|
170
|
+
* and every CI job name this repo has ever produced (`test (macos-latest)` is
|
|
171
|
+
* 20), and keeps the release-preflight row on one line. Revisit if a platform
|
|
172
|
+
* starts emitting job names that need more.
|
|
173
|
+
*/
|
|
174
|
+
const API_TEXT_MAX = 60;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Clamp AND control-byte-escape a string that came from a CI platform's API
|
|
178
|
+
* before it is interpolated into a reading (`details.reason`,
|
|
179
|
+
* `failingJobName`, `allowFailureJobs`).
|
|
180
|
+
*
|
|
181
|
+
* The degraded branch has done this since #1031 ({@link degradedResult}); the
|
|
182
|
+
* READ branch had not, so a pipeline `status` or job `name` reached the
|
|
183
|
+
* operator's terminal verbatim — including
|
|
184
|
+
* `scripts/release.mjs`'s `ci-green-on-head` row, the one line that decides
|
|
185
|
+
* whether a release proceeds. A status shaped `x\r\u001b[32m✓ ... PASS`
|
|
186
|
+
* repainted that row green while the row's own verdict stayed `ok:false`.
|
|
187
|
+
*
|
|
188
|
+
* Escaped at GENERATION, not at each render: there are four renderers
|
|
189
|
+
* (session-start banner, release preflight × 2, session-start probes) and only
|
|
190
|
+
* one producer, so the invariant "a reading carries no raw control bytes"
|
|
191
|
+
* belongs here. Slice first, escape after — the budget is 60 chars of API
|
|
192
|
+
* TEXT, and escaping afterwards cannot leave a cut mid-`\uXXXX`.
|
|
193
|
+
*
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function sanitizeApiText(value) {
|
|
198
|
+
return escapeControlBytes(String(value ?? '').trim().slice(0, API_TEXT_MAX));
|
|
199
|
+
}
|
|
200
|
+
|
|
158
201
|
/**
|
|
159
202
|
* Name a parsed JSON value's type WITHOUT quoting any of its content.
|
|
160
203
|
*
|
|
@@ -493,6 +536,246 @@ async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
|
493
536
|
return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
|
|
494
537
|
}
|
|
495
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Look up the branch HEAD currently sits on, for pipeline-REF preference.
|
|
541
|
+
*
|
|
542
|
+
* **Never throws and never degrades the reading.** A missing branch is not a
|
|
543
|
+
* failure to report — it only costs the ref preference below, which then falls
|
|
544
|
+
* back to "every same-sha pipeline is an acceptable candidate", i.e. exactly the
|
|
545
|
+
* pre-#857 behaviour. Degrading here instead would turn a detached HEAD, a
|
|
546
|
+
* worktree oddity or a `git` hiccup into a dark banner on a repo whose CI state
|
|
547
|
+
* is perfectly readable.
|
|
548
|
+
*
|
|
549
|
+
* `HEAD` (detached) is reported as "unknown", not as a branch named HEAD.
|
|
550
|
+
*
|
|
551
|
+
* @param {string} repoRoot
|
|
552
|
+
* @param {{ execFile?: Function, timeoutMs?: number }} deps
|
|
553
|
+
* @returns {Promise<string|undefined>}
|
|
554
|
+
*/
|
|
555
|
+
async function getCurrentBranch(repoRoot, deps = {}) {
|
|
556
|
+
const gitTimeout = Math.min(2000, deps.timeoutMs ?? 2000);
|
|
557
|
+
try {
|
|
558
|
+
const result = await execWithTimeout(
|
|
559
|
+
'git',
|
|
560
|
+
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
561
|
+
{ cwd: repoRoot, timeoutMs: gitTimeout, execFile: deps.execFile },
|
|
562
|
+
);
|
|
563
|
+
const branch = String(result?.stdout ?? '').trim();
|
|
564
|
+
if (branch === '' || branch === 'HEAD') return undefined;
|
|
565
|
+
return branch;
|
|
566
|
+
} catch {
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Does a pipeline's `ref` name the branch HEAD is on?
|
|
573
|
+
*
|
|
574
|
+
* GitLab reports a branch pipeline's ref as the bare branch name (`main`);
|
|
575
|
+
* `refs/heads/main` is accepted too because nothing in the API contract forbids
|
|
576
|
+
* the long form. An MR pipeline's ref is `refs/merge-requests/<iid>/head`,
|
|
577
|
+
* which matches no branch and is therefore never a confident match.
|
|
578
|
+
*
|
|
579
|
+
* @param {unknown} ref
|
|
580
|
+
* @param {string|undefined} branch
|
|
581
|
+
* @returns {boolean}
|
|
582
|
+
*/
|
|
583
|
+
function refMatchesBranch(ref, branch) {
|
|
584
|
+
if (!branch || typeof ref !== 'string' || ref === '') return false;
|
|
585
|
+
return ref === branch || ref === `refs/heads/${branch}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Does a pipeline's `ref` name a merge-request HEAD (`detached`) pipeline?
|
|
590
|
+
*
|
|
591
|
+
* GitLab spells it `refs/merge-requests/<iid>/head` — the DETACHED pipeline,
|
|
592
|
+
* which runs the MR source commit itself. The merged-results counterpart
|
|
593
|
+
* (`refs/merge-requests/<iid>/merge`) runs a synthetic merge commit whose sha is
|
|
594
|
+
* NOT the local HEAD, so it can never reach a same-sha candidate tier and is
|
|
595
|
+
* deliberately not matched here.
|
|
596
|
+
*
|
|
597
|
+
* @param {unknown} ref
|
|
598
|
+
* @returns {boolean}
|
|
599
|
+
*/
|
|
600
|
+
function isMergeRequestHeadRef(ref) {
|
|
601
|
+
return typeof ref === 'string' && /^refs\/merge-requests\/[^/]+\/head$/.test(ref);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Rank a GitLab pipeline status by how ALARMING it is (#857).
|
|
606
|
+
*
|
|
607
|
+
* Used only to pick ONE reading out of several pipelines for the SAME commit on
|
|
608
|
+
* the SAME ref, whose statuses contradict each other — measured on this project
|
|
609
|
+
* 2026-09-18: 14 of ~86 distinct shas in the last 100 pipelines carry 2-4
|
|
610
|
+
* pipelines, 7 of them with contradicting statuses (`canceled`+`success`,
|
|
611
|
+
* `failed`+`success`, …), produced by different `source`s (`push`, `api`,
|
|
612
|
+
* `merge_request_event`).
|
|
613
|
+
*
|
|
614
|
+
* Worst-status-wins is this module's stated fail-toward-visible posture (see
|
|
615
|
+
* {@link SILENT_QUERY_FAILURES}) applied to disagreement: a `success` beside a
|
|
616
|
+
* `failed` or a `canceled` cannot establish that the commit is green — the two
|
|
617
|
+
* runs may have executed different job sets, and which one is "the real one" is
|
|
618
|
+
* not derivable from the pipeline list. The disagreement itself is published as
|
|
619
|
+
* evidence (`details.ambiguous` + `details.candidateStatuses`), so the operator
|
|
620
|
+
* is never left guessing WHY the verdict reads the way it does.
|
|
621
|
+
*
|
|
622
|
+
* `failed` outranks `canceled` so a real failure can never be masked by a
|
|
623
|
+
* cancellation; everything UNSETTLED outranks `success`, because an in-flight
|
|
624
|
+
* sibling run means the commit's verdict is not settled.
|
|
625
|
+
*
|
|
626
|
+
* "Unsettled" is a category, not a leftover bucket. `skipped` and `manual` are
|
|
627
|
+
* TERMINAL non-failing states — the ordinary shape of a `rules:`-gated or
|
|
628
|
+
* manual-trigger pipeline — and lumping them in with `running`/`pending` turned
|
|
629
|
+
* the everyday `success` + `skipped` pair into `unknown /
|
|
630
|
+
* unrecognised-status-skipped`, where the pre-#857 reading was `green`. They
|
|
631
|
+
* therefore rank BELOW `success`: they never beat a real reading in either
|
|
632
|
+
* direction (`success` beside them → green, `failed` beside them → red), while
|
|
633
|
+
* a LONE `skipped`/`manual` still falls through `checkGitlab`'s tail to
|
|
634
|
+
* `unknown`, unchanged. Category separation, not a threshold tweak.
|
|
635
|
+
*
|
|
636
|
+
* @param {unknown} status
|
|
637
|
+
* @returns {number} Higher = more alarming
|
|
638
|
+
*/
|
|
639
|
+
function statusSeverity(status) {
|
|
640
|
+
if (status === 'failed') return 4;
|
|
641
|
+
if (status === 'canceled') return 3;
|
|
642
|
+
if (status === 'success') return 1;
|
|
643
|
+
// Terminal, non-failing, carries no verdict of its own.
|
|
644
|
+
if (status === 'skipped' || status === 'manual') return 0;
|
|
645
|
+
// running / pending / created / preparing / waiting_for_resource / scheduled
|
|
646
|
+
// — and anything unrecognised, which cannot be assumed settled.
|
|
647
|
+
return 2;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Split every pipeline carrying the queried sha into the candidate set this
|
|
652
|
+
* reading may speak for, and the foreign-ref remainder it must not (#857).
|
|
653
|
+
*
|
|
654
|
+
* Before this, `pipelines.find(p => p.sha === currentSha)` took the most
|
|
655
|
+
* recently updated row in a 15-row window that MIXES refs (`main`,
|
|
656
|
+
* `refs/merge-requests/39/head`, a `codex/…` branch — measured 2026-09-18), so
|
|
657
|
+
* a foreign branch's or an MR's pipeline could silently become the local HEAD's
|
|
658
|
+
* verdict.
|
|
659
|
+
*
|
|
660
|
+
* Preference, first non-empty tier wins:
|
|
661
|
+
* 1. pipelines whose `ref` names the current branch (the confident match);
|
|
662
|
+
* 2. pipelines whose ref cannot be judged — no `ref` field, or no branch
|
|
663
|
+
* resolved (detached HEAD, `git` unavailable). Judging is impossible, so
|
|
664
|
+
* this is the pre-#857 CANDIDATE SET, now resolved worst-status-wins like
|
|
665
|
+
* every other tier (a ref-less `success` + `canceled` pair read green
|
|
666
|
+
* before #857 and reads red now — the change is deliberate, see
|
|
667
|
+
* {@link statusSeverity});
|
|
668
|
+
* 3. pipelines on an MR HEAD ref ({@link isMergeRequestHeadRef}) carrying the
|
|
669
|
+
* queried sha. A repo whose `workflow: rules:` only admit
|
|
670
|
+
* `merge_request_event` produces NO branch pipeline at all, so tiers 1-2
|
|
671
|
+
* are empty and the pre-tier-3 code reported `pipeline-unmatched-ref` for a
|
|
672
|
+
* commit whose verdict was sitting right there. SHA EQUALITY is the
|
|
673
|
+
* correctness argument — the ref is only a tie-breaker, and a
|
|
674
|
+
* merged-results pipeline runs a DIFFERENT sha, so it cannot reach this
|
|
675
|
+
* tier at all. `matchedRef` is reported, so the operator sees which ref
|
|
676
|
+
* spoke;
|
|
677
|
+
* 4. otherwise NOTHING is selected: every same-sha pipeline belongs to a
|
|
678
|
+
* genuinely foreign ref (another branch), which the caller reports as
|
|
679
|
+
* `unknown` with its own reason rather than adopting.
|
|
680
|
+
*
|
|
681
|
+
* `foreign` is EVERY same-sha row outside the selected tier — on every branch,
|
|
682
|
+
* not only on tier 4 (#1390). It used to be `[]` whenever a tier matched, so a
|
|
683
|
+
* same-sha `failed` on another ref vanished with no trace behind a `green`
|
|
684
|
+
* reading. The ref preference still decides the verdict; the rows it set aside
|
|
685
|
+
* are handed back so the caller can publish them as evidence
|
|
686
|
+
* ({@link candidateEvidence} `droppedStatuses`). Linear `includes` scan over
|
|
687
|
+
* at most the 15-row API window — revisit if `per_page` grows past ~200.
|
|
688
|
+
*
|
|
689
|
+
* @param {Array<any>} pipelines
|
|
690
|
+
* @param {string} sha
|
|
691
|
+
* @param {string|undefined} branch
|
|
692
|
+
* @returns {{ selected: Array<any>, foreign: Array<any> }}
|
|
693
|
+
*/
|
|
694
|
+
function selectShaPipelines(pipelines, sha, branch) {
|
|
695
|
+
const sameSha = pipelines.filter((p) => p && p.sha === sha);
|
|
696
|
+
const split = (selected) => ({
|
|
697
|
+
selected,
|
|
698
|
+
foreign: sameSha.filter((p) => !selected.includes(p)),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const onBranch = sameSha.filter((p) => refMatchesBranch(p.ref, branch));
|
|
702
|
+
if (onBranch.length > 0) return split(onBranch);
|
|
703
|
+
|
|
704
|
+
const unjudgeable = sameSha.filter(
|
|
705
|
+
(p) => !branch || typeof p.ref !== 'string' || p.ref === '',
|
|
706
|
+
);
|
|
707
|
+
if (unjudgeable.length > 0) return split(unjudgeable);
|
|
708
|
+
|
|
709
|
+
const mrHead = sameSha.filter((p) => isMergeRequestHeadRef(p.ref));
|
|
710
|
+
if (mrHead.length > 0) return split(mrHead);
|
|
711
|
+
|
|
712
|
+
return split([]);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Pick the pipeline a reading speaks for: the most alarming of the candidates,
|
|
717
|
+
* ties resolved by the API's own `updated_at desc` ordering (first wins).
|
|
718
|
+
*
|
|
719
|
+
* @param {Array<any>} candidates Non-empty
|
|
720
|
+
* @returns {any}
|
|
721
|
+
*/
|
|
722
|
+
function worstPipeline(candidates) {
|
|
723
|
+
let worst = candidates[0];
|
|
724
|
+
for (const p of candidates) {
|
|
725
|
+
if (statusSeverity(p.status) > statusSeverity(worst.status)) worst = p;
|
|
726
|
+
}
|
|
727
|
+
return worst;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* ADDITIVE evidence fields (#857) describing how a reading was chosen.
|
|
732
|
+
*
|
|
733
|
+
* Additive by hard constraint: the `status` vocabulary stays
|
|
734
|
+
* `green | red | unknown`, because `dispatcher/rank.mjs` and
|
|
735
|
+
* `autonomy/suitability.mjs` treat only the literal `'red'` as bad and the
|
|
736
|
+
* session-start renderer prints nothing for a status string it does not know.
|
|
737
|
+
* So ambiguity is published BESIDE the verdict, never as a new verdict.
|
|
738
|
+
*
|
|
739
|
+
* - `matchedRef` the chosen pipeline's ref (present whenever the API
|
|
740
|
+
* supplied one), so the operator can see WHICH ref the
|
|
741
|
+
* verdict speaks for.
|
|
742
|
+
* - `candidateCount` number of same-sha candidates — only when > 1, so an
|
|
743
|
+
* ordinary one-pipeline reading is byte-identical to
|
|
744
|
+
* before.
|
|
745
|
+
* - `candidateStatuses` their statuses, in API order, same gate.
|
|
746
|
+
* - `ambiguous` `true` only when those statuses DISAGREE.
|
|
747
|
+
* - `droppedCount` number of same-sha pipelines the ref preference SET
|
|
748
|
+
* ASIDE (another branch, an MR HEAD beside a branch
|
|
749
|
+
* match, a ref-less row beside a judged one) — only when
|
|
750
|
+
* > 0 (#1390). Without it a `failed` run of the very
|
|
751
|
+
* commit on another ref disappeared behind a `green`.
|
|
752
|
+
* - `droppedStatuses` their statuses, in API order, same gate. Evidence
|
|
753
|
+
* only: they never enter the verdict or `ambiguous`,
|
|
754
|
+
* which stay the #857 ref-preference result.
|
|
755
|
+
*
|
|
756
|
+
* @param {Array<any>} selected
|
|
757
|
+
* @param {any} chosen
|
|
758
|
+
* @param {Array<any>} dropped Same-sha rows outside the selected tier
|
|
759
|
+
* @returns {{ matchedRef?: string, candidateCount?: number, candidateStatuses?: string[], ambiguous?: true, droppedCount?: number, droppedStatuses?: string[] }}
|
|
760
|
+
*/
|
|
761
|
+
function candidateEvidence(selected, chosen, dropped) {
|
|
762
|
+
/** @type {any} */
|
|
763
|
+
const out = {};
|
|
764
|
+
if (typeof chosen?.ref === 'string' && chosen.ref !== '') {
|
|
765
|
+
out.matchedRef = sanitizeApiText(chosen.ref);
|
|
766
|
+
}
|
|
767
|
+
if (selected.length > 1) {
|
|
768
|
+
out.candidateCount = selected.length;
|
|
769
|
+
out.candidateStatuses = selected.map((p) => sanitizeApiText(p.status));
|
|
770
|
+
if (new Set(out.candidateStatuses).size > 1) out.ambiguous = true;
|
|
771
|
+
}
|
|
772
|
+
if (dropped.length > 0) {
|
|
773
|
+
out.droppedCount = dropped.length;
|
|
774
|
+
out.droppedStatuses = dropped.map((p) => sanitizeApiText(p.status));
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
|
|
496
779
|
/**
|
|
497
780
|
* Compute age in whole days between an ISO date string and `now`.
|
|
498
781
|
*
|
|
@@ -534,6 +817,10 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
534
817
|
// #1332: an explicit `deps.sha` (validated full hex SHA, see checkCiStatus)
|
|
535
818
|
// replaces the local HEAD lookup — the caller asks about a NAMED commit.
|
|
536
819
|
const currentSha = deps.sha ?? (await getHeadSha(repoRoot, deps));
|
|
820
|
+
// #857 ref preference — resolved alongside the sha, and best-effort: an
|
|
821
|
+
// unresolvable branch costs the preference, never the reading. See
|
|
822
|
+
// {@link getCurrentBranch}.
|
|
823
|
+
const branch = await getCurrentBranch(repoRoot, deps);
|
|
537
824
|
const apiDeps = { ...deps, repoHost: project.host };
|
|
538
825
|
const projectPath = `projects/${project.encodedProjectPath}`;
|
|
539
826
|
// `'array'` is load-bearing, not decoration: before it, a `glab api` that
|
|
@@ -548,9 +835,32 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
548
835
|
'array',
|
|
549
836
|
);
|
|
550
837
|
|
|
551
|
-
|
|
838
|
+
// #857: the window mixes refs and carries duplicate pipelines per commit, so
|
|
839
|
+
// the reading is CHOSEN (ref preference, then worst-status-wins), never
|
|
840
|
+
// `.find()`-ed off the most recently updated row.
|
|
841
|
+
const { selected, foreign } = selectShaPipelines(pipelines, currentSha, branch);
|
|
842
|
+
|
|
843
|
+
if (selected.length === 0 && foreign.length > 0) {
|
|
844
|
+
// Pipelines EXIST for this commit, but every one of them belongs to a
|
|
845
|
+
// foreign ref (another branch, or `refs/merge-requests/<iid>/head`).
|
|
846
|
+
// Adopting one would let an MR's or a foreign branch's verdict stand in for
|
|
847
|
+
// the local HEAD's; claiming "no pipeline" would hide that runs exist. So:
|
|
848
|
+
// `unknown`, with a reason that names the actual situation. The status
|
|
849
|
+
// vocabulary is unchanged — the finding is delivered as a REASON.
|
|
850
|
+
return {
|
|
851
|
+
status: 'unknown',
|
|
852
|
+
ok: false,
|
|
853
|
+
details: {
|
|
854
|
+
currentPipelineId: null,
|
|
855
|
+
cliUsed: 'glab',
|
|
856
|
+
reason: 'pipeline-unmatched-ref',
|
|
857
|
+
candidateCount: foreign.length,
|
|
858
|
+
candidateStatuses: foreign.map((p) => sanitizeApiText(p.status)),
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
}
|
|
552
862
|
|
|
553
|
-
if (
|
|
863
|
+
if (selected.length === 0) {
|
|
554
864
|
return {
|
|
555
865
|
status: 'unknown',
|
|
556
866
|
ok: false,
|
|
@@ -562,6 +872,8 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
562
872
|
};
|
|
563
873
|
}
|
|
564
874
|
|
|
875
|
+
const currentPipeline = worstPipeline(selected);
|
|
876
|
+
const evidence = candidateEvidence(selected, currentPipeline, foreign);
|
|
565
877
|
const pipelineStatus = currentPipeline.status;
|
|
566
878
|
|
|
567
879
|
if (pipelineStatus === 'success') {
|
|
@@ -581,7 +893,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
581
893
|
if (Array.isArray(jobs)) {
|
|
582
894
|
const softFailed = jobs
|
|
583
895
|
.filter((j) => j.status === 'failed' && j.allow_failure === true)
|
|
584
|
-
.map((j) => j.name);
|
|
896
|
+
.map((j) => sanitizeApiText(j.name));
|
|
585
897
|
if (softFailed.length > 0) allowFailureJobs = softFailed;
|
|
586
898
|
}
|
|
587
899
|
} catch {
|
|
@@ -595,6 +907,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
595
907
|
details: {
|
|
596
908
|
currentPipelineId: currentPipeline.id,
|
|
597
909
|
cliUsed: 'glab',
|
|
910
|
+
...evidence,
|
|
598
911
|
},
|
|
599
912
|
};
|
|
600
913
|
}
|
|
@@ -606,33 +919,50 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
606
919
|
details: {
|
|
607
920
|
currentPipelineId: currentPipeline.id,
|
|
608
921
|
cliUsed: 'glab',
|
|
609
|
-
reason: `pipeline-${pipelineStatus}`,
|
|
922
|
+
reason: `pipeline-${sanitizeApiText(pipelineStatus)}`,
|
|
923
|
+
...evidence,
|
|
610
924
|
},
|
|
611
925
|
};
|
|
612
926
|
}
|
|
613
927
|
|
|
614
928
|
if (pipelineStatus === 'failed' || pipelineStatus === 'canceled') {
|
|
615
929
|
// Find the last green pipeline in the history.
|
|
930
|
+
//
|
|
931
|
+
// #857: the look-back slice EXCLUDES every row carrying the queried sha, not
|
|
932
|
+
// just the chosen one. Pipelines are NOT one-per-commit on this project —
|
|
933
|
+
// measured 2026-09-18, 14 of ~86 distinct shas in the last 100 pipelines
|
|
934
|
+
// carry 2-4 — so a `success` duplicate of the very commit being reported red
|
|
935
|
+
// used to be found here and named as its own `lastGreen`. Reproduced on
|
|
936
|
+
// `52ae12f1` (a `canceled` push pipeline + a `success` api pipeline for the
|
|
937
|
+
// same commit): status `red`, `lastGreen.sha` === HEAD.
|
|
616
938
|
const currentIdx = pipelines.indexOf(currentPipeline);
|
|
617
|
-
const rest = pipelines.slice(currentIdx + 1);
|
|
939
|
+
const rest = pipelines.slice(currentIdx + 1).filter((p) => p && p.sha !== currentSha);
|
|
618
940
|
const lastGreenPipeline = rest.find((p) => p.status === 'success');
|
|
619
941
|
|
|
620
|
-
//
|
|
621
|
-
|
|
942
|
+
// The unbroken non-success run from the current pipeline back to the last
|
|
943
|
+
// green, as ROWS. Same meaning `redCount` always had.
|
|
944
|
+
const redRun = [currentPipeline];
|
|
622
945
|
for (const p of rest) {
|
|
623
946
|
if (p.status === 'success') break;
|
|
624
|
-
|
|
947
|
+
redRun.push(p);
|
|
625
948
|
}
|
|
949
|
+
const redCount = redRun.length;
|
|
626
950
|
|
|
627
951
|
let lastGreen;
|
|
628
952
|
if (lastGreenPipeline) {
|
|
629
953
|
const ageDays = ageDaysFrom(lastGreenPipeline.created_at, now);
|
|
630
|
-
// Approximate commit distance: redCount is the number of red pipelines
|
|
631
|
-
// before reaching the last green (pipelines are one-per-commit on this project).
|
|
632
954
|
lastGreen = {
|
|
633
955
|
sha: lastGreenPipeline.sha,
|
|
634
956
|
pipelineId: lastGreenPipeline.id,
|
|
635
|
-
|
|
957
|
+
// Commit distance, counted over DISTINCT shas in the red run. The old
|
|
958
|
+
// value was the ROW count under a comment asserting "pipelines are
|
|
959
|
+
// one-per-commit on this project" — measured false at 16%, so the
|
|
960
|
+
// distance was overstated whenever a commit had been re-run.
|
|
961
|
+
ageCommits: new Set(redRun.map((p) => p.sha)).size,
|
|
962
|
+
// The row count is kept under its own name rather than dropped: it is
|
|
963
|
+
// what `redCount` reports, and "3 red pipelines over 2 commits" is a
|
|
964
|
+
// different (and also useful) statement from either number alone.
|
|
965
|
+
agePipelines: redCount,
|
|
636
966
|
ageDays,
|
|
637
967
|
};
|
|
638
968
|
}
|
|
@@ -648,7 +978,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
648
978
|
);
|
|
649
979
|
if (Array.isArray(jobs)) {
|
|
650
980
|
const failedJob = jobs.find((j) => j.status === 'failed');
|
|
651
|
-
failingJobName = failedJob ? failedJob.name : undefined;
|
|
981
|
+
failingJobName = failedJob ? sanitizeApiText(failedJob.name) : undefined;
|
|
652
982
|
}
|
|
653
983
|
} catch {
|
|
654
984
|
// Non-fatal — we still report red status without job name.
|
|
@@ -663,6 +993,15 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
663
993
|
details: {
|
|
664
994
|
currentPipelineId: currentPipeline.id,
|
|
665
995
|
cliUsed: 'glab',
|
|
996
|
+
// A user-cancelled pipeline printed `🚨 CI RED on HEAD` indistinguishably
|
|
997
|
+
// from a real failure. The STATUS stays `red` on purpose (no consumer
|
|
998
|
+
// churn — `dispatcher/rank.mjs` and `autonomy/suitability.mjs` key on the
|
|
999
|
+
// literal `'red'`), and the distinction is delivered through the reason
|
|
1000
|
+
// both the banner and the release-preflight row already interpolate.
|
|
1001
|
+
// Named like the `pipeline-running` / `pipeline-pending` reasons above;
|
|
1002
|
+
// `failed` keeps carrying no reason — its detail is `failingJobName`.
|
|
1003
|
+
...(pipelineStatus === 'canceled' ? { reason: 'pipeline-canceled' } : {}),
|
|
1004
|
+
...evidence,
|
|
666
1005
|
},
|
|
667
1006
|
};
|
|
668
1007
|
}
|
|
@@ -674,7 +1013,8 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
674
1013
|
details: {
|
|
675
1014
|
currentPipelineId: currentPipeline.id,
|
|
676
1015
|
cliUsed: 'glab',
|
|
677
|
-
reason: `unrecognised-status-${pipelineStatus}`,
|
|
1016
|
+
reason: `unrecognised-status-${sanitizeApiText(pipelineStatus)}`,
|
|
1017
|
+
...evidence,
|
|
678
1018
|
},
|
|
679
1019
|
};
|
|
680
1020
|
}
|
|
@@ -757,7 +1097,7 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
757
1097
|
return {
|
|
758
1098
|
status: 'red',
|
|
759
1099
|
ok: false,
|
|
760
|
-
failingJobName: failedRun.name,
|
|
1100
|
+
failingJobName: sanitizeApiText(failedRun.name),
|
|
761
1101
|
details: {
|
|
762
1102
|
cliUsed: 'gh',
|
|
763
1103
|
reason: 'lastGreen-not-implemented-for-github',
|
|
@@ -835,7 +1175,13 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
835
1175
|
* } | {
|
|
836
1176
|
* status: 'green'|'red'|'unknown',
|
|
837
1177
|
* ok: boolean,
|
|
838
|
-
* lastGreen?: {
|
|
1178
|
+
* lastGreen?: {
|
|
1179
|
+
* sha: string,
|
|
1180
|
+
* pipelineId: number,
|
|
1181
|
+
* ageCommits: number,
|
|
1182
|
+
* agePipelines?: number,
|
|
1183
|
+
* ageDays: number|null,
|
|
1184
|
+
* },
|
|
839
1185
|
* redCount?: number,
|
|
840
1186
|
* failingJobName?: string,
|
|
841
1187
|
* details: {
|
|
@@ -843,8 +1189,22 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
843
1189
|
* cliUsed: 'glab'|'gh',
|
|
844
1190
|
* reason?: string,
|
|
845
1191
|
* error?: string,
|
|
1192
|
+
* matchedRef?: string,
|
|
1193
|
+
* candidateCount?: number,
|
|
1194
|
+
* candidateStatuses?: string[],
|
|
1195
|
+
* ambiguous?: true,
|
|
1196
|
+
* droppedCount?: number,
|
|
1197
|
+
* droppedStatuses?: string[],
|
|
846
1198
|
* },
|
|
847
1199
|
* }>}
|
|
1200
|
+
*
|
|
1201
|
+
* The `status` vocabulary is FROZEN at `green | red | unknown` (#857/#856): the
|
|
1202
|
+
* #857 "several contradicting pipelines" and "only foreign-ref pipelines"
|
|
1203
|
+
* findings — and the #1390 "same-sha rows the ref preference set aside"
|
|
1204
|
+
* (`droppedCount`/`droppedStatuses`) — are delivered through ADDITIVE `details`
|
|
1205
|
+
* fields and `details.reason`,
|
|
1206
|
+
* never as a new status value — consumers fail open on an unknown status string
|
|
1207
|
+
* and the session-start renderer prints nothing for one.
|
|
848
1208
|
*/
|
|
849
1209
|
export async function checkCiStatus(opts = {}, deps = {}) {
|
|
850
1210
|
const {
|