ruvnet-brain 4.7.19 → 4.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/install.mjs +494 -97
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall.mjs +15 -5
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +8 -16
- package/plugin/scripts/session-start-health.mjs +97 -10
- package/plugin/scripts/session-start-issue-alert.mjs +105 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +164 -47
- package/scripts/job-heartbeat.sh +145 -13
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +30 -21
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +549 -0
- package/scripts/onboarding-console.mjs +47 -8
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +71 -26
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
package/scripts/issue-watch.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import path from 'node:path';
|
|
|
33
33
|
import os from 'node:os';
|
|
34
34
|
import { spawnSync } from 'node:child_process';
|
|
35
35
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
36
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
36
37
|
|
|
37
38
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
39
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
@@ -46,6 +47,23 @@ export const OWNER_LOGIN = 'stuinfla';
|
|
|
46
47
|
// moved here.)
|
|
47
48
|
export const BOT_MARKER = '🤖 Automated';
|
|
48
49
|
const SLA_HOURS = 4;
|
|
50
|
+
// A person's issue whose detail AND list comments cannot be read this many runs in a row fails the run once (review fix).
|
|
51
|
+
const UNREADABLE_RUNS = 3;
|
|
52
|
+
// WHO IS WAITING (owner, 2026-10-25: "10,747 notifications"). An issue opened by the owner's own account or a bot is
|
|
53
|
+
// work the owner's automation filed (Dream Cycle, dependabot), not a person waiting for an answer: it never pages
|
|
54
|
+
// and gets no acknowledgment. Only a person's issue pages.
|
|
55
|
+
const AUTOMATION_AUTHORS = new Set([OWNER_LOGIN, 'dependabot', 'dependabot[bot]', 'github-actions', 'github-actions[bot]', 'app/dependabot']);
|
|
56
|
+
export const isPersonIssue = (issue) => !AUTOMATION_AUTHORS.has(String(issue?.author?.login || '').toLowerCase())
|
|
57
|
+
&& !issue?.author?.is_bot && !String(issue?.author?.login || '').endsWith('[bot]');
|
|
58
|
+
// ONE morning summary instead of a page per issue per 4 hours: the first run at or after 9 a.m. Eastern sends it,
|
|
59
|
+
// only if a person's issue is still waiting, and at most once per Eastern calendar day.
|
|
60
|
+
const DIGEST_HOUR_ET = 9;
|
|
61
|
+
const etParts = (ms) => Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric',
|
|
62
|
+
month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }).formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
|
|
63
|
+
export function digestDue(lastDay, now) {
|
|
64
|
+
const p = etParts(now); const day = `${p.year}-${p.month}-${p.day}`;
|
|
65
|
+
return { due: Number(p.hour) % 24 >= DIGEST_HOUR_ET && lastDay !== day, day };
|
|
66
|
+
}
|
|
49
67
|
const STATE_PATH = process.env.ISSUE_WATCH_STATE
|
|
50
68
|
|| path.join(os.homedir(), '.claude', 'ruvnet-brain', 'issue-watch-state.json');
|
|
51
69
|
// A compact, always-current snapshot the SessionStart hook surfaces (2026-07-17). ntfy alerts are
|
|
@@ -54,31 +72,40 @@ const STATE_PATH = process.env.ISSUE_WATCH_STATE
|
|
|
54
72
|
const STATUS_PATH = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
55
73
|
const GH_BIN = process.env.GH_BIN || 'gh';
|
|
56
74
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
/** A network-shaped gh failure worth waiting out (2026-07-19 "TLS handshake timeout"; 4.7.21 adds the 1 a.m.
|
|
76
|
+
* "error connecting to api.github.com" and GitHub's 502/503/504). Anything else — auth, 404, bad JSON — is real. */
|
|
77
|
+
export function isTransientGhError(text) {
|
|
78
|
+
return /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable|error connecting to api\.github\.com|HTTP 50[234]/i.test(String(text || ''));
|
|
79
|
+
}
|
|
80
|
+
/** Seconds to wait before attempts 2 and 3. Bounded: three attempts, then the failure is loud. */
|
|
81
|
+
export const GH_RETRY_DELAYS_S = [10, 30];
|
|
82
|
+
|
|
83
|
+
/** `gh … --json` with backoff on transient failures. Shared by issue-fix.mjs (one rule, one place). */
|
|
84
|
+
export function ghJsonWithRetry(args, { bin = GH_BIN, who = 'issue-watch' } = {}) {
|
|
61
85
|
let lastErr;
|
|
62
|
-
for (let attempt = 1; attempt <=
|
|
63
|
-
const res = spawnSync(
|
|
86
|
+
for (let attempt = 1; attempt <= GH_RETRY_DELAYS_S.length + 1; attempt++) {
|
|
87
|
+
const res = spawnSync(bin, args, { encoding: 'utf8' });
|
|
64
88
|
if (res.status === 0) return JSON.parse(res.stdout);
|
|
65
89
|
const err = (res.stderr || res.stdout || '').trim();
|
|
66
90
|
lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
console.error(
|
|
70
|
-
spawnSync('sleep', [
|
|
91
|
+
if (attempt <= GH_RETRY_DELAYS_S.length && isTransientGhError(err)) {
|
|
92
|
+
const wait = GH_RETRY_DELAYS_S[attempt - 1];
|
|
93
|
+
console.error(`${who}: transient gh/network failure (${err.slice(0, 90)}) — retry ${attempt} in ${wait}s`);
|
|
94
|
+
spawnSync('sleep', [String(wait)]);
|
|
71
95
|
continue;
|
|
72
96
|
}
|
|
73
97
|
break;
|
|
74
98
|
}
|
|
75
99
|
throw lastErr;
|
|
76
100
|
}
|
|
101
|
+
const ghJson = (args) => ghJsonWithRetry(args);
|
|
77
102
|
|
|
78
103
|
/** Resolve the ntfy topic the same way the rest of the repo does: env, then the machine-wide
|
|
79
104
|
* cache file, then the repo .env — see scripts/notify.sh / scripts/job-heartbeat.sh / scripts/nightly-watchdog.mjs. */
|
|
80
105
|
function resolveTopic() {
|
|
81
|
-
|
|
106
|
+
// Set-but-EMPTY is an explicit opt-out (job-heartbeat.sh's convention): tests use it, and it must never fall
|
|
107
|
+
// through to the repo's real .env topic (2026-10-25: a unit test read the owner's topic that way).
|
|
108
|
+
if (process.env.NTFY_TOPIC !== undefined) return process.env.NTFY_TOPIC || null;
|
|
82
109
|
try {
|
|
83
110
|
const t = fs.readFileSync(path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ntfy-topic'), 'utf8').trim();
|
|
84
111
|
if (t) return t;
|
|
@@ -92,11 +119,15 @@ function resolveTopic() {
|
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotating_light' }) {
|
|
122
|
+
const base = (process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/+$/, '');
|
|
95
123
|
try {
|
|
96
|
-
|
|
124
|
+
// Title is ASCII only: a non-ByteString header makes fetch() throw before sending (4.7.21). Issue titles never
|
|
125
|
+
// go in the header — they are public, untrusted text — only the body.
|
|
126
|
+
const res = await fetch(`${base}/${encodeURIComponent(topic)}`, {
|
|
97
127
|
method: 'POST',
|
|
98
|
-
headers: { Title: title, Priority: priority, Tags: tags },
|
|
128
|
+
headers: { Title: String(title).replace(/[^\x20-\x7e]+/g, ' ').trim(), Priority: priority, Tags: tags },
|
|
99
129
|
body,
|
|
130
|
+
signal: AbortSignal.timeout(10_000),
|
|
100
131
|
});
|
|
101
132
|
return res.ok;
|
|
102
133
|
} catch {
|
|
@@ -104,8 +135,15 @@ async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotat
|
|
|
104
135
|
}
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
/** A missing or unreadable state file is an empty one. That is safe since 4.7.21 (a reset state replayed 17
|
|
139
|
+
* "SLA breach" pages in 11 s): every breach due in one run is ONE page, and an acknowledgment already on the
|
|
140
|
+
* thread is recognised from the thread itself, never posted twice. */
|
|
107
141
|
function loadState() {
|
|
108
|
-
try {
|
|
142
|
+
try {
|
|
143
|
+
const v = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
144
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) return v;
|
|
145
|
+
} catch { /* fall through */ }
|
|
146
|
+
return {};
|
|
109
147
|
}
|
|
110
148
|
|
|
111
149
|
function saveState(state) {
|
|
@@ -133,28 +171,71 @@ export function judgeIssue(issue, comments, now) {
|
|
|
133
171
|
|
|
134
172
|
export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}) {
|
|
135
173
|
const issues = ghJson(['issue', 'list', '--repo', repo, '--state', 'open', '--json',
|
|
136
|
-
'number,title,createdAt,comments,updatedAt']);
|
|
174
|
+
'number,title,createdAt,comments,updatedAt,author']);
|
|
137
175
|
|
|
138
176
|
const state = loadState();
|
|
139
177
|
const results = [];
|
|
140
178
|
const alertsSent = [];
|
|
179
|
+
const degraded = [];
|
|
180
|
+
const dueBreaches = [];
|
|
181
|
+
const unreadable = []; // person issues unreadable for UNREADABLE_RUNS runs and no other way to judge them: fail once
|
|
141
182
|
|
|
142
183
|
for (const issue of issues) {
|
|
143
|
-
const detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']);
|
|
144
|
-
const { ageHours, ownerComment, breach } = judgeIssue(issue, detail.comments || [], now);
|
|
145
184
|
const url = `https://github.com/${repo}/issues/${issue.number}`;
|
|
146
185
|
const key = String(issue.number);
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
// One issue whose detail cannot be read does not stop the run (4.7.21). It is judged from the comments the list
|
|
187
|
+
// call already returned (review fix: before, it was skipped forever and never got its SLA page); with no list
|
|
188
|
+
// comments either it is skipped, and once it has been unreadable on UNREADABLE_RUNS runs in a row the run fails
|
|
189
|
+
// ONCE so the heartbeat wrapper pages. Never acknowledged from the list copy (it may be incomplete, and a second
|
|
190
|
+
// public acknowledgment is worse than none).
|
|
191
|
+
let detail; let fromList = false;
|
|
192
|
+
try { detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']); }
|
|
193
|
+
catch (err) {
|
|
194
|
+
degraded.push(issue.number);
|
|
195
|
+
const prev = state[key] || {};
|
|
196
|
+
const runs = (Number(prev.unreadableRuns) || 0) + 1;
|
|
197
|
+
state[key] = { ...prev, unreadableRuns: runs, unreadableSince: prev.unreadableSince || new Date(now).toISOString() };
|
|
198
|
+
if (Array.isArray(issue.comments)) {
|
|
199
|
+
detail = { comments: issue.comments }; fromList = true;
|
|
200
|
+
console.error(`issue-watch: #${issue.number} detail unreadable — judged from the list's comments — ${err.message.slice(0, 160)}`);
|
|
201
|
+
} else {
|
|
202
|
+
console.error(`issue-watch: #${issue.number} skipped this run (${runs} in a row) — ${err.message.slice(0, 160)}`);
|
|
203
|
+
if (isPersonIssue(issue) && runs >= UNREADABLE_RUNS && !prev.unreadableEscalatedAt) {
|
|
204
|
+
unreadable.push(issue.number);
|
|
205
|
+
state[key].unreadableEscalatedAt = new Date(now).toISOString();
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!fromList && state[key]?.unreadableRuns) { // readable again: the next unreadable streak counts from zero
|
|
211
|
+
state[key] = { ...state[key] };
|
|
212
|
+
for (const f of ['unreadableRuns', 'unreadableSince', 'unreadableEscalatedAt']) delete state[key][f];
|
|
213
|
+
}
|
|
214
|
+
const comments = detail.comments || [];
|
|
215
|
+
const { ageHours, ownerComment, breach } = judgeIssue(issue, comments, now);
|
|
216
|
+
// A person's issue pages ONCE when it first passes the SLA, never again (it used to repeat every 4 h, forever,
|
|
217
|
+
// which with ~14 old issues and an hourly run was most of the 10,747 notifications). The morning digest below
|
|
218
|
+
// carries whatever is still waiting. An existing lastAlertAt means that one page already went out.
|
|
219
|
+
const person = isPersonIssue(issue);
|
|
220
|
+
const dueForAlert = person && breach && !state[key]?.lastAlertAt;
|
|
149
221
|
// First sighting → page once, immediately (rule 2 in the header). Delivery-derived like
|
|
150
222
|
// lastAlertAt: state is only written when the push actually went out, so a failed push
|
|
151
223
|
// retries next run instead of burying the sighting.
|
|
152
224
|
const firstSighting = !state[key];
|
|
153
|
-
// Acknowledgment delivery is not page delivery. Retry each missing channel independently.
|
|
154
|
-
|
|
155
|
-
|
|
225
|
+
// Acknowledgment delivery is not page delivery. Retry each missing channel independently. Neither applies to an
|
|
226
|
+
// issue the owner's automation opened.
|
|
227
|
+
// Arrival is paged once, instantly, by .github/workflows/ntfy-alerts.yml (the `issues: opened` event). Paging it
|
|
228
|
+
// here too sent every new issue twice (review 2026-10-25), so this watcher owns only the SLA page and the digest.
|
|
229
|
+
const needsNewPage = false;
|
|
230
|
+
// Our acknowledgment already on the thread (a lost or reset state file) is never posted twice in public.
|
|
231
|
+
const alreadyAcked = comments.some((c) => c.author?.login === OWNER_LOGIN
|
|
232
|
+
&& /^🤖 Automated acknowledgment/.test(String(c.body || '').trimStart()));
|
|
233
|
+
if (person && !state[key]?.ackAt && alreadyAcked) {
|
|
234
|
+
state[key] = { ...(state[key] || {}), ackAt: new Date(now).toISOString(), ackBaselined: true, title: issue.title, url };
|
|
235
|
+
}
|
|
236
|
+
const needsAck = person && !fromList && !state[key]?.ackAt;
|
|
156
237
|
|
|
157
|
-
results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url });
|
|
238
|
+
results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url, person });
|
|
158
239
|
|
|
159
240
|
if (needsNewPage || needsAck) {
|
|
160
241
|
if (dryRun) {
|
|
@@ -191,33 +272,62 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
|
|
|
191
272
|
}
|
|
192
273
|
}
|
|
193
274
|
|
|
194
|
-
if (dueForAlert) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
275
|
+
if (dueForAlert) dueBreaches.push({ issue, ageHours, url, key });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ONE page per run for every breach that is due (4.7.21), oldest first, body bounded to 8 lines. The title keeps
|
|
279
|
+
// the "SLA breach: issue #N" shape scripts/ntfy-inbox.mjs classifies. DERIVED, not asserted (F5, 2026-07-18):
|
|
280
|
+
// lastAlertAt is written only for a delivered page; a failed page retries next run as the same single page.
|
|
281
|
+
// Spread-merge, never overwrite (2026-07-24: clobbering firstSeenAt/newAlertAt re-paged forever).
|
|
282
|
+
let breachPaged = new Set();
|
|
283
|
+
if (dueBreaches.length) {
|
|
284
|
+
dueBreaches.sort((a, b) => b.ageHours - a.ageHours);
|
|
285
|
+
const [first, ...rest] = dueBreaches;
|
|
286
|
+
const title = rest.length
|
|
287
|
+
? `SLA breach: issue #${first.issue.number} (+${rest.length} more waiting > ${SLA_HOURS}h)`
|
|
288
|
+
: `SLA breach: issue #${first.issue.number}`;
|
|
289
|
+
const lines = dueBreaches.slice(0, 8).map((b) => `#${b.issue.number} (${fmtAge(b.ageHours)}) ${String(b.issue.title).slice(0, 70)}`);
|
|
290
|
+
// Past 8 lines the rest are still NAMED ("…and K more: #a #b"): scripts/ntfy-inbox.mjs files one alert per issue,
|
|
291
|
+
// so the session banner counts the breach truly (a 17-issue page used to count as 1).
|
|
292
|
+
const body = `${dueBreaches.length} contributor issue(s) past ${SLA_HOURS}h with no response from ${OWNER_LOGIN}:\n${lines.join('\n')}`
|
|
293
|
+
+ (dueBreaches.length > 8 ? `\n…and ${dueBreaches.length - 8} more: ${dueBreaches.slice(8).map((b) => `#${b.issue.number}`).join(' ')}` : '')
|
|
294
|
+
+ `\nhttps://github.com/${repo}/issues`;
|
|
295
|
+
if (dryRun) {
|
|
296
|
+
for (const b of dueBreaches) alertsSent.push({ number: b.issue.number, sent: false, kind: 'sla-breach', reason: 'dry-run' });
|
|
297
|
+
} else {
|
|
298
|
+
const topic = resolveTopic();
|
|
299
|
+
const sent = topic ? await pushNtfy(topic, { title, body, priority: 'urgent', tags: 'rotating_light,warning' }) : false;
|
|
300
|
+
for (const b of dueBreaches) {
|
|
301
|
+
if (sent) state[b.key] = { ...(state[b.key] || {}), lastAlertAt: new Date(now).toISOString(), title: b.issue.title, url: b.url };
|
|
302
|
+
else state[b.key] = { ...(state[b.key] || {}), lastAttemptAt: new Date(now).toISOString(), sent: false, title: b.issue.title, url: b.url };
|
|
303
|
+
alertsSent.push({ number: b.issue.number, sent, kind: 'sla-breach', reason: topic ? null : 'no ntfy topic configured' });
|
|
214
304
|
}
|
|
305
|
+
if (sent) breachPaged = new Set(dueBreaches.map((b) => b.issue.number));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The morning digest: one notification naming every person's issue still unanswered, oldest first.
|
|
310
|
+
const waiting = results.filter((r) => r.person && r.breach).sort((a, b) => b.ageHours - a.ageHours);
|
|
311
|
+
const digest = digestDue(state._digest?.day, now);
|
|
312
|
+
// Everything still waiting was named in this run's breach page: that page IS today's summary.
|
|
313
|
+
if (waiting.length && digest.due && waiting.every((r) => breachPaged.has(r.number))) {
|
|
314
|
+
state._digest = { day: digest.day, sentAt: new Date(now).toISOString(), count: waiting.length, viaBreachPage: true };
|
|
315
|
+
} else if (waiting.length && digest.due) {
|
|
316
|
+
const title = `Waiting on a reply: ${waiting.length} contributor issue${waiting.length === 1 ? '' : 's'}`;
|
|
317
|
+
const body = waiting.slice(0, 8).map((r) => `#${r.number} (${fmtAge(r.ageHours)}) ${r.title.slice(0, 70)}`).join('\n')
|
|
318
|
+
+ (waiting.length > 8 ? `\n…and ${waiting.length - 8} more` : '') + `\nhttps://github.com/${repo}/issues`;
|
|
319
|
+
if (dryRun) alertsSent.push({ kind: 'digest', sent: false, reason: 'dry-run', count: waiting.length });
|
|
320
|
+
else {
|
|
321
|
+
const topic = resolveTopic();
|
|
322
|
+
const sent = topic ? await pushNtfy(topic, { title, body, priority: 'default', tags: 'clipboard' }) : false;
|
|
323
|
+
if (sent) state._digest = { day: digest.day, sentAt: new Date(now).toISOString(), count: waiting.length };
|
|
324
|
+
alertsSent.push({ kind: 'digest', sent, count: waiting.length, reason: topic ? null : 'no ntfy topic configured' });
|
|
215
325
|
}
|
|
216
326
|
}
|
|
217
327
|
|
|
218
328
|
if (!dryRun) saveState(state);
|
|
219
329
|
|
|
220
|
-
return { results, alertsSent, checkedAt: new Date(now).toISOString() };
|
|
330
|
+
return { results, alertsSent, degraded, unreadable, checkedAt: new Date(now).toISOString() };
|
|
221
331
|
}
|
|
222
332
|
|
|
223
333
|
/**
|
|
@@ -353,8 +463,10 @@ async function main() {
|
|
|
353
463
|
if (r.dueForAlert) {
|
|
354
464
|
const alert = output.alertsSent.find((a) => a.number === r.number && a.kind === 'sla-breach');
|
|
355
465
|
console.log(` ${dryRun ? '[DRY-RUN] would push ntfy alert' : alert?.sent ? 'ntfy alert pushed' : `ntfy alert NOT sent (${alert?.reason})`}`);
|
|
466
|
+
} else if (r.breach && !r.person) {
|
|
467
|
+
console.log(' opened by the owner\'s own automation — never paged');
|
|
356
468
|
} else if (r.breach) {
|
|
357
|
-
console.log(
|
|
469
|
+
console.log(' already paged once — listed in the morning summary until answered');
|
|
358
470
|
}
|
|
359
471
|
console.log('');
|
|
360
472
|
}
|
|
@@ -389,7 +501,12 @@ async function main() {
|
|
|
389
501
|
// heartbeat records the failure and the wrapper's own channel escalates — that duplicate-looking
|
|
390
502
|
// page IS the correct behavior when the primary page provably never left the building.
|
|
391
503
|
const undeliveredAlert = !dryRun && (output.alertsSent || []).some((a) => !a.sent && a.reason !== 'dry-run');
|
|
392
|
-
|
|
504
|
+
// Skipping one unreadable issue is a degraded run, not a failed one; reading NONE of them is a failure.
|
|
505
|
+
const allDegraded = output.degraded.length > 0 && output.results.length === 0;
|
|
506
|
+
if (output.degraded.length) console.error(`issue-watch: ${output.degraded.length} issue(s) not fully readable this run: #${output.degraded.join(', #')}`);
|
|
507
|
+
const stuck = !dryRun && (output.unreadable || []).length > 0;
|
|
508
|
+
if (stuck) console.error(`issue-watch: FAILING this run once — a person's issue has been unreadable ${UNREADABLE_RUNS} runs in a row and cannot be judged: #${output.unreadable.join(', #')}`);
|
|
509
|
+
process.exit(undeliveredAlert || allDegraded || stuck ? 1 : 0);
|
|
393
510
|
}
|
|
394
511
|
|
|
395
|
-
if (
|
|
512
|
+
if (isDirectInvocation(import.meta.url)) await main();
|
package/scripts/job-heartbeat.sh
CHANGED
|
@@ -78,22 +78,41 @@ RUN_ID="$$-$START_EPOCH"
|
|
|
78
78
|
# SIGKILLed, the watchdog's started-and-never-finished detection had nothing left to see.
|
|
79
79
|
PREV_HB=""
|
|
80
80
|
[ -f "$HB" ] && PREV_HB="$(cat "$HB" 2>/dev/null)"
|
|
81
|
+
# The failing streak and the last page time ride in EVERY receipt, including "running" (4.7.21): a run that is
|
|
82
|
+
# SIGKILLed mid-streak must not erase them, or the next failure would page as a brand-new streak.
|
|
83
|
+
field_str() { printf '%s' "$PREV_HB" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" | head -1; }
|
|
84
|
+
field_num() { printf '%s' "$PREV_HB" | sed -n "s/.*\"$1\":\([0-9][0-9]*\).*/\1/p" | head -1; }
|
|
85
|
+
prev_state="$(field_str state)"
|
|
86
|
+
prev_since="$(field_str failing_since)"
|
|
87
|
+
prev_page_epoch="$(field_num last_page_epoch)"
|
|
88
|
+
prev_page_at="$(field_str last_page_at)"
|
|
89
|
+
prev_paged="$(printf '%s' "$PREV_HB" | sed -nE 's/.*"paged":("[a-z]*"|true|false).*/\1/p' | head -1)"
|
|
90
|
+
CARRY=""
|
|
91
|
+
[ -n "$prev_page_epoch" ] && CARRY="$CARRY,\"last_page_epoch\":$prev_page_epoch"
|
|
92
|
+
[ -n "$prev_page_at" ] && CARRY="$CARRY,\"last_page_at\":\"$prev_page_at\""
|
|
93
|
+
case "$prev_state" in failed|killed|running)
|
|
94
|
+
[ -n "$prev_since" ] && CARRY="$CARRY,\"failing_since\":\"$prev_since\""
|
|
95
|
+
[ -n "$prev_since" ] && [ -n "$prev_paged" ] && CARRY="$CARRY,\"paged\":$prev_paged" ;;
|
|
96
|
+
esac
|
|
81
97
|
|
|
82
98
|
# Start receipt. If the job vanishes without ever writing an end receipt, THIS is the evidence that
|
|
83
99
|
# it started and never finished — a state the watchdog reports as FAILING, not as silence.
|
|
84
|
-
write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(
|
|
100
|
+
write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(printf '%s %s' "$1" "${2:-}" | tr -d '"\\\n\r' | cut -c1-200)\"$CARRY}"
|
|
85
101
|
|
|
86
|
-
notify() { # notify <title> <body> <priority>
|
|
102
|
+
notify() { # notify <title> <body> <priority> — 0 delivered · 1 not delivered · 2 paging disabled (opt-out)
|
|
87
103
|
# NTFY_TOPIC set-but-EMPTY is an explicit opt-out (2026-07-18): unit tests wrap this script around
|
|
88
104
|
# deliberately-failing fixture commands (t-fail, t.bad, exit 7), and the topic-file fallback below
|
|
89
105
|
# meant every test run PAGED Stuart's real phone — 16+ fixture pages in one morning, indistinguishable
|
|
90
106
|
# from real job failures. Tests set NTFY_TOPIC="" and stay silent; production (unset) still falls
|
|
91
|
-
# through to the topic file.
|
|
92
|
-
|
|
107
|
+
# through to the topic file. An opt-out is NOT a delivered page: it returns 2 and the receipt records
|
|
108
|
+
# "paged":"disabled", never true (4.7.21).
|
|
109
|
+
if [ "${NTFY_TOPIC+set}" = "set" ] && [ -z "$NTFY_TOPIC" ]; then return 2; fi
|
|
93
110
|
topic="${NTFY_TOPIC:-}"
|
|
94
111
|
[ -z "$topic" ] && [ -f "$HOME/.cache/ruvnet-brain/ntfy-topic" ] && topic="$(cat "$HOME/.cache/ruvnet-brain/ntfy-topic")"
|
|
95
|
-
[ -z "$topic" ] && return
|
|
96
|
-
|
|
112
|
+
[ -z "$topic" ] && return 1
|
|
113
|
+
# --fail: an HTTP error is a failed delivery. The caller records whether the page landed, so a page that
|
|
114
|
+
# never arrived is retried on the next run instead of muting the whole failing streak (review 2026-10-25).
|
|
115
|
+
curl -sS --fail -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: ${4:-rotating_light}" -d "$2" "${NTFY_URL:-https://ntfy.sh}/$topic" >/dev/null 2>&1
|
|
97
116
|
}
|
|
98
117
|
|
|
99
118
|
# The run_id currently on disk for this label, or empty if the receipt is missing/unreadable/has
|
|
@@ -104,6 +123,19 @@ current_run_id() {
|
|
|
104
123
|
|
|
105
124
|
finish() {
|
|
106
125
|
code=${FORCED_CODE:-$?}
|
|
126
|
+
# Stop the run-time timer FIRST, on every exit path (skip, stale writer, normal), so it can never outlive
|
|
127
|
+
# this wrapper (Opus review 2026-10-25).
|
|
128
|
+
[ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null
|
|
129
|
+
timed_out=0
|
|
130
|
+
if [ -f "$HB.timeout-$RUN_ID" ]; then
|
|
131
|
+
# Anything the overrunning job left behind (started during its TERM handling, say) goes now.
|
|
132
|
+
timed_out=1; stop_job
|
|
133
|
+
fi
|
|
134
|
+
# Always, on every exit path: the timer may have written the marker after the check above (4.7.21).
|
|
135
|
+
rm -f "$HB.timeout-$RUN_ID"
|
|
136
|
+
# An overrun is a failure however the job chose to exit on TERM, including 0 and 75 (the skip code), so this
|
|
137
|
+
# comes before the skip branch (GPT-6.1 review round 2).
|
|
138
|
+
[ "$timed_out" -eq 1 ] && [ "$code" -le 128 ] && code=143
|
|
107
139
|
ended="$(ts)"
|
|
108
140
|
dur=$(( $(date +%s) - START_EPOCH ))
|
|
109
141
|
# Exit 75 = SKIP (lock held by a live run). Restore the pre-fire receipt so the live run's evidence
|
|
@@ -132,15 +164,52 @@ finish() {
|
|
|
132
164
|
elif [ "$code" -gt 128 ]; then
|
|
133
165
|
sig=$((code - 128))
|
|
134
166
|
state="killed"; extra=",\"signal\":$sig"
|
|
167
|
+
[ "$timed_out" -eq 1 ] && extra="$extra,\"timed_out_after_sec\":$MAX_SECONDS"
|
|
135
168
|
else
|
|
136
169
|
state="failed"; extra=""
|
|
137
170
|
fi
|
|
138
|
-
|
|
139
|
-
#
|
|
140
|
-
#
|
|
171
|
+
# PAGE ON THE TRANSITION, NOT ON EVERY RUN (owner, 2026-10-25: 10,747 notifications on one topic). A job that
|
|
172
|
+
# starts failing pages once, urgently, while it is still tonight's problem; while it KEEPS failing it stays
|
|
173
|
+
# quiet (failing_since carries the first failure; the nightly watchdog's digest still lists it); the run
|
|
174
|
+
# that passes again sends one low-priority "recovered" note — only if that streak was actually paged.
|
|
175
|
+
# FLAP DAMPING (4.7.21): a NEW streak within 6 h of the last delivered page is recorded "paged":"suppressed"
|
|
176
|
+
# instead of paging (4 fail/ok cycles were 8 notifications). A suppressed streak still failing after 6 h pages.
|
|
177
|
+
# A prior "running" receipt that carries failing_since (a SIGKILLed run) continues its streak.
|
|
178
|
+
was_failing=0
|
|
179
|
+
case "$prev_state" in failed|killed) was_failing=1 ;; running) [ -n "$prev_since" ] && was_failing=1 ;; esac
|
|
180
|
+
# A failing streak counts as already paged only if that page was PROVEN delivered ("paged":true). false (retry),
|
|
181
|
+
# "suppressed", "disabled", or a receipt from before this field existed are paged (again) when allowed.
|
|
182
|
+
prev_unpaged=1; [ "$prev_paged" = "true" ] && prev_unpaged=0
|
|
183
|
+
QUIET_SECONDS=21600
|
|
184
|
+
now_epoch="$(date +%s)"
|
|
185
|
+
page_epoch="$prev_page_epoch"; page_at="$prev_page_at"
|
|
186
|
+
since=""
|
|
141
187
|
if [ "$code" -ne 0 ]; then
|
|
142
|
-
|
|
188
|
+
if [ "$was_failing" -eq 1 ] && [ -n "$prev_since" ]; then since="$prev_since"; else since="$STARTED"; fi
|
|
189
|
+
extra="$extra,\"failing_since\":\"$since\""
|
|
143
190
|
fi
|
|
191
|
+
if [ "$code" -ne 0 ] && { [ "$was_failing" -eq 0 ] || [ "$prev_unpaged" -eq 1 ]; }; then
|
|
192
|
+
if [ -n "$prev_page_epoch" ] && [ $((now_epoch - prev_page_epoch)) -lt "$QUIET_SECONDS" ] \
|
|
193
|
+
&& ! { [ "$was_failing" -eq 1 ] && [ "$prev_paged" = "false" ]; }; then
|
|
194
|
+
# Inside the quiet window. (A streak whose own page simply failed to deliver is retried, not damped.)
|
|
195
|
+
extra="$extra,\"paged\":\"suppressed\""
|
|
196
|
+
else
|
|
197
|
+
reason="exit $code after ${dur}s"; [ "$timed_out" -eq 1 ] && reason="stopped after running past its ${MAX_SECONDS}s limit"
|
|
198
|
+
notify "SCHEDULED JOB FAILED: $LABEL" "$reason — see the job's log. Receipt: $HB. Further failures stay quiet until it recovers." "urgent"
|
|
199
|
+
rc=$?
|
|
200
|
+
if [ "$rc" -eq 0 ]; then
|
|
201
|
+
extra="$extra,\"paged\":true"; page_epoch="$now_epoch"; page_at="$(ts)"
|
|
202
|
+
elif [ "$rc" -eq 2 ]; then extra="$extra,\"paged\":\"disabled\""
|
|
203
|
+
else extra="$extra,\"paged\":false"; fi
|
|
204
|
+
fi
|
|
205
|
+
elif [ "$code" -ne 0 ]; then
|
|
206
|
+
extra="$extra,\"paged\":true"
|
|
207
|
+
elif [ "$was_failing" -eq 1 ] && [ "$prev_paged" = "true" ]; then
|
|
208
|
+
notify "Recovered: $LABEL" "passed again after failing since ${prev_since:-an earlier run}" "low" "white_check_mark" || true
|
|
209
|
+
fi
|
|
210
|
+
[ -n "$page_epoch" ] && extra="$extra,\"last_page_epoch\":$page_epoch"
|
|
211
|
+
[ -n "$page_at" ] && extra="$extra,\"last_page_at\":\"$page_at\""
|
|
212
|
+
write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
|
|
144
213
|
exit "$code"
|
|
145
214
|
}
|
|
146
215
|
trap finish EXIT
|
|
@@ -148,8 +217,46 @@ trap finish EXIT
|
|
|
148
217
|
# The exit codes (143 = 128+15 TERM, 130 = 128+2 INT) intentionally reuse the same 128+signal
|
|
149
218
|
# convention `wait` uses for a directly-signalled child, so finish() classifies BOTH as "killed"
|
|
150
219
|
# with the right signal number, whether the signal reached the child directly or via this wrapper.
|
|
151
|
-
|
|
152
|
-
|
|
220
|
+
# The job stays in this wrapper's process group, so launchd's own cleanup of a dead job (it kills the job's whole
|
|
221
|
+
# group) still covers everything, even if this wrapper is SIGKILLed (Opus review round 3: a separate group escaped it).
|
|
222
|
+
# job_pids: every process the job owns. Under launchd this wrapper leads its group, so that is every group member
|
|
223
|
+
# except this wrapper, its timer, and this wrapper's own helper processes (ps/awk/command substitutions) — which
|
|
224
|
+
# catches processes the job started late or that were reparented. Run any other way (a terminal, a test harness),
|
|
225
|
+
# it falls back to the job's current process tree.
|
|
226
|
+
OWN_GROUP=0; [ "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" = "$$" ] && OWN_GROUP=1
|
|
227
|
+
job_pids() {
|
|
228
|
+
if [ "$OWN_GROUP" = 1 ]; then
|
|
229
|
+
ps -A -o pid= -o ppid= -o pgid= 2>/dev/null | awk -v me=$$ -v t="${TIMER:-0}" -v c="${CHILD:-0}" '
|
|
230
|
+
{ pid[NR]=$1; pp[NR]=$2; pg[NR]=$3 }
|
|
231
|
+
END { helper[t]=1
|
|
232
|
+
for (i=1;i<=NR;i++) if (pp[i]==me && pid[i]!=c) helper[pid[i]]=1
|
|
233
|
+
for (i=1;i<=NR;i++) if (pg[i]==me && pid[i]!=me && !(pid[i] in helper) && !(pp[i] in helper)) print pid[i] }'
|
|
234
|
+
else
|
|
235
|
+
tree_of "$CHILD"
|
|
236
|
+
fi
|
|
237
|
+
}
|
|
238
|
+
tree_of() { kill -0 "$1" 2>/dev/null && printf '%s ' "$1"
|
|
239
|
+
for k in $(ps -A -o pid= -o ppid= 2>/dev/null | awk -v p="$1" '$2==p {print $1}'); do tree_of "$k"; done; }
|
|
240
|
+
# stop_job: TERM everything the job owns, give it up to 10 s (inside launchd's 20 s exit allowance), then KILL.
|
|
241
|
+
# Every pid seen during the stop is remembered, so a descendant that outlives its parent (and so drops out of the
|
|
242
|
+
# process tree in the fallback mode) still gets the final KILL. The full guarantee, including processes started
|
|
243
|
+
# later, holds when this wrapper leads its group, as under launchd.
|
|
244
|
+
stop_job() {
|
|
245
|
+
seen="$(job_pids)"; [ -n "$seen" ] && kill -TERM $seen 2>/dev/null
|
|
246
|
+
i=0; while [ "$i" -lt 10 ]; do
|
|
247
|
+
now_pids="$(job_pids)"; seen="$seen $now_pids"
|
|
248
|
+
alive=""; for p in $seen; do kill -0 "$p" 2>/dev/null && alive="$alive $p"; done
|
|
249
|
+
[ -z "$alive" ] && return 0
|
|
250
|
+
sleep 1; i=$((i + 1))
|
|
251
|
+
done
|
|
252
|
+
for p in $seen $(job_pids); do kill -KILL "$p" 2>/dev/null; done
|
|
253
|
+
return 0
|
|
254
|
+
}
|
|
255
|
+
trap 'FORCED_CODE=143; [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null; stop_job; exit 143' TERM
|
|
256
|
+
trap 'FORCED_CODE=130; [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null; stop_job; exit 130' INT
|
|
257
|
+
# The timer never kills anything itself: it marks the overrun and signals this wrapper, which stops the job.
|
|
258
|
+
OVERRUN=0
|
|
259
|
+
trap 'OVERRUN=1' USR1
|
|
153
260
|
|
|
154
261
|
# Run the job in the BACKGROUND and `wait` for it — do NOT run it in the foreground.
|
|
155
262
|
# Break-test finding (2026-07-13): a POSIX shell blocked on a FOREGROUND child does not run its trap
|
|
@@ -160,4 +267,29 @@ trap 'FORCED_CODE=130; kill -TERM "$CHILD" 2>/dev/null; exit 130' INT
|
|
|
160
267
|
# ("started and never finished"). Trap for catchable deaths, watchdog for uncatchable ones.
|
|
161
268
|
"$@" &
|
|
162
269
|
CHILD=$!
|
|
163
|
-
|
|
270
|
+
# RUN-TIME LIMIT (2026-10-25: five jobs finished their work and then never exited, each holding its own
|
|
271
|
+
# next run off for 7-9 hours). After JOB_MAX_SECONDS (default 2 h; a plist may set less) the job is
|
|
272
|
+
# stopped: TERM, then KILL 10 s later. It is recorded as killed and pages once like any other failure.
|
|
273
|
+
MAX_SECONDS="${JOB_MAX_SECONDS:-7200}"
|
|
274
|
+
WRAPPER_PID=$$
|
|
275
|
+
case "$MAX_SECONDS" in ''|*[!0-9]*) MAX_SECONDS=7200 ;; esac
|
|
276
|
+
[ "$MAX_SECONDS" -lt 2 ] && MAX_SECONDS=2
|
|
277
|
+
(
|
|
278
|
+
# Short sleeps, not one long one: killing this shell then leaves at most a 2 s sleep behind, never a 2 h one
|
|
279
|
+
# holding the job's output open (review round 2).
|
|
280
|
+
# A wrapper that is gone (SIGKILLed: launchd then cleans the group itself) ends the timer at the next tick.
|
|
281
|
+
n=0; while [ "$n" -lt "$MAX_SECONDS" ]; do sleep 2; n=$((n + 2)); kill -0 "$WRAPPER_PID" 2>/dev/null || exit 0; done
|
|
282
|
+
# Only for OUR child: still alive and still parented by this wrapper. If the PID was reused, do nothing.
|
|
283
|
+
[ "$(ps -o ppid= -p "$CHILD" 2>/dev/null | tr -d ' ')" = "$WRAPPER_PID" ] || exit 0
|
|
284
|
+
: > "$HB.timeout-$RUN_ID"
|
|
285
|
+
kill -USR1 "$WRAPPER_PID" 2>/dev/null
|
|
286
|
+
) </dev/null >/dev/null 2>&1 &
|
|
287
|
+
TIMER=$!
|
|
288
|
+
# `wait` returns early when USR1 arrives; stop the job, then wait again for its real exit status.
|
|
289
|
+
while :; do
|
|
290
|
+
if [ "$OVERRUN" = 1 ]; then OVERRUN=0; stop_job; fi
|
|
291
|
+
wait "$CHILD"; RC=$?
|
|
292
|
+
if [ "$OVERRUN" = 1 ]; then OVERRUN=0; stop_job; continue; fi
|
|
293
|
+
break
|
|
294
|
+
done
|
|
295
|
+
exit "$RC"
|
package/scripts/key-canary.mjs
CHANGED
|
@@ -53,10 +53,14 @@ async function probe(p, key) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** true only when notify.sh reports a delivered page (exit 0). A failed or opted-out page (NTFY_TOPIC="") is not
|
|
57
|
+
* delivered, so the key's previous state is kept and the transition fires again next run (4.7.21). Titles are
|
|
58
|
+
* plain ASCII; the emoji rides in Tags. */
|
|
56
59
|
function notify(title, message, priority, tags) {
|
|
57
60
|
try {
|
|
58
61
|
execFileSync('sh', [path.join(REPO, 'scripts', 'notify.sh'), title, message, priority, tags], { cwd: REPO, stdio: 'ignore', timeout: 15000 });
|
|
59
|
-
|
|
62
|
+
return true;
|
|
63
|
+
} catch { return false; /* notification failure must not break the canary */ }
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
const prev = (() => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return {}; } })();
|
|
@@ -73,13 +77,15 @@ for (const p of PROBES) {
|
|
|
73
77
|
if (status === 'DEAD') {
|
|
74
78
|
anyDead = true;
|
|
75
79
|
if (NOTIFY && prev[p.env]?.status !== 'DEAD') {
|
|
76
|
-
notify(
|
|
80
|
+
const sent = notify(`${p.provider} API key is DEAD`,
|
|
77
81
|
`${p.env} was rejected (401/403) by ${p.provider} just now. Every script using it is silently failing. `
|
|
78
82
|
+ `Rotate it: edit ~/Code/openclaw-stack/secrets.env, then run secrets-sync.sh seal.`,
|
|
79
83
|
'urgent', 'rotating_light,key');
|
|
84
|
+
if (!sent) now[p.env] = prev[p.env] ?? { status: 'unpaged', ts: now[p.env].ts };
|
|
80
85
|
}
|
|
81
86
|
} else if (status === 'alive' && NOTIFY && prev[p.env]?.status === 'DEAD') {
|
|
82
|
-
notify(
|
|
87
|
+
const sent = notify(`${p.provider} API key recovered`, `${p.env} is working again.`, 'default', 'white_check_mark,key');
|
|
88
|
+
if (!sent) now[p.env] = prev[p.env];
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import fs from 'node:fs';
|
|
44
44
|
import os from 'node:os';
|
|
45
45
|
import path from 'node:path';
|
|
46
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
46
47
|
|
|
47
48
|
const HOME = os.homedir();
|
|
48
49
|
|
|
@@ -206,7 +207,7 @@ export function humanMs(ms) {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
// ── CLI ────────────────────────────────────────────────────────────────────────────────────────────
|
|
209
|
-
if (import.meta.url
|
|
210
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
210
211
|
const { loadOutcomes } = await import('./advocacy-outcomes.mjs');
|
|
211
212
|
const rows = computeLatencies({ outcomes: loadOutcomes() });
|
|
212
213
|
const s = summarize(rows);
|