ruvnet-brain 4.7.20 → 4.7.22
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 +618 -104
- package/console/activity.js +122 -38
- 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-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +49 -17
- 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 +241 -23
- package/plugin/scripts/continuation-gate.mjs +75 -11
- 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-gate.mjs +7 -39
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- 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/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +25 -5
- package/plugin/scripts/session-start-core.mjs +7 -16
- package/plugin/scripts/session-start-health.mjs +98 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -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-boot-surface-proof.mjs +208 -0
- 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 +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- 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 +26 -20
- 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 +41 -18
- package/scripts/onboarding-console.mjs +123 -23
- 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/recall-latency-probe.mjs +48 -0
- 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 +82 -28
- 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 +13 -5
- package/scripts/worktree-integrity.mjs +2 -2
package/scripts/issue-fix.mjs
CHANGED
|
@@ -46,7 +46,8 @@ import path from 'node:path';
|
|
|
46
46
|
import os from 'node:os';
|
|
47
47
|
import { spawn, spawnSync } from 'node:child_process';
|
|
48
48
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
49
|
-
import { BOT_MARKER, OWNER_LOGIN } from './issue-watch.mjs';
|
|
49
|
+
import { BOT_MARKER, OWNER_LOGIN, ghJsonWithRetry } from './issue-watch.mjs';
|
|
50
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
50
51
|
|
|
51
52
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
52
53
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
@@ -118,28 +119,10 @@ export function executionPolicy(mode = 'unattended') {
|
|
|
118
119
|
throw new Error(`unsupported issue-fix mode: ${mode}`);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// fix exactly one class (transient network), so retry exactly once, log the first failure, and
|
|
126
|
-
// still fail LOUD if it happens twice. Never a silent swallow.
|
|
127
|
-
let lastErr;
|
|
128
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
129
|
-
const res = spawnSync(GH_BIN, args, { encoding: 'utf8' });
|
|
130
|
-
if (res.status === 0) return JSON.parse(res.stdout);
|
|
131
|
-
const err = (res.stderr || res.stdout || '').trim();
|
|
132
|
-
lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
|
|
133
|
-
const transient = /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable/i.test(err);
|
|
134
|
-
if (attempt === 1 && transient) {
|
|
135
|
-
console.error(`issue-fix: transient gh/network failure (${err.slice(0, 90)}) — retrying once in 20s`);
|
|
136
|
-
spawnSync('sleep', ['20']);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
throw lastErr;
|
|
142
|
-
}
|
|
122
|
+
// Transient gh failures (TLS handshake, "error connecting to api.github.com", HTTP 502/503/504) are retried with
|
|
123
|
+
// backoff by the watcher's shared helper — one rule in one place (4.7.21: the old copy here missed the 1 a.m.
|
|
124
|
+
// "error connecting" blip and paged). Anything else still fails LOUD on the first attempt.
|
|
125
|
+
const ghJson = (args) => ghJsonWithRetry(args, { bin: GH_BIN, who: 'issue-fix' });
|
|
143
126
|
|
|
144
127
|
function loadState() {
|
|
145
128
|
try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { return {}; }
|
|
@@ -555,4 +538,4 @@ async function main() {
|
|
|
555
538
|
process.exit(failedAttempt ? 1 : 0);
|
|
556
539
|
}
|
|
557
540
|
|
|
558
|
-
if (
|
|
541
|
+
if (isDirectInvocation(import.meta.url)) await main();
|
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,8 @@ 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;
|
|
49
52
|
// WHO IS WAITING (owner, 2026-10-25: "10,747 notifications"). An issue opened by the owner's own account or a bot is
|
|
50
53
|
// work the owner's automation filed (Dream Cycle, dependabot), not a person waiting for an answer: it never pages
|
|
51
54
|
// and gets no acknowledgment. Only a person's issue pages.
|
|
@@ -69,26 +72,33 @@ const STATE_PATH = process.env.ISSUE_WATCH_STATE
|
|
|
69
72
|
const STATUS_PATH = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
70
73
|
const GH_BIN = process.env.GH_BIN || 'gh';
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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' } = {}) {
|
|
76
85
|
let lastErr;
|
|
77
|
-
for (let attempt = 1; attempt <=
|
|
78
|
-
const res = spawnSync(
|
|
86
|
+
for (let attempt = 1; attempt <= GH_RETRY_DELAYS_S.length + 1; attempt++) {
|
|
87
|
+
const res = spawnSync(bin, args, { encoding: 'utf8' });
|
|
79
88
|
if (res.status === 0) return JSON.parse(res.stdout);
|
|
80
89
|
const err = (res.stderr || res.stdout || '').trim();
|
|
81
90
|
lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
console.error(
|
|
85
|
-
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)]);
|
|
86
95
|
continue;
|
|
87
96
|
}
|
|
88
97
|
break;
|
|
89
98
|
}
|
|
90
99
|
throw lastErr;
|
|
91
100
|
}
|
|
101
|
+
const ghJson = (args) => ghJsonWithRetry(args);
|
|
92
102
|
|
|
93
103
|
/** Resolve the ntfy topic the same way the rest of the repo does: env, then the machine-wide
|
|
94
104
|
* cache file, then the repo .env — see scripts/notify.sh / scripts/job-heartbeat.sh / scripts/nightly-watchdog.mjs. */
|
|
@@ -109,11 +119,15 @@ function resolveTopic() {
|
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotating_light' }) {
|
|
122
|
+
const base = (process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/+$/, '');
|
|
112
123
|
try {
|
|
113
|
-
|
|
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)}`, {
|
|
114
127
|
method: 'POST',
|
|
115
|
-
headers: { Title: title, Priority: priority, Tags: tags },
|
|
128
|
+
headers: { Title: String(title).replace(/[^\x20-\x7e]+/g, ' ').trim(), Priority: priority, Tags: tags },
|
|
116
129
|
body,
|
|
130
|
+
signal: AbortSignal.timeout(10_000),
|
|
117
131
|
});
|
|
118
132
|
return res.ok;
|
|
119
133
|
} catch {
|
|
@@ -121,8 +135,15 @@ async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotat
|
|
|
121
135
|
}
|
|
122
136
|
}
|
|
123
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. */
|
|
124
141
|
function loadState() {
|
|
125
|
-
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 {};
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
function saveState(state) {
|
|
@@ -155,12 +176,43 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
|
|
|
155
176
|
const state = loadState();
|
|
156
177
|
const results = [];
|
|
157
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
|
|
158
182
|
|
|
159
183
|
for (const issue of issues) {
|
|
160
|
-
const detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']);
|
|
161
|
-
const { ageHours, ownerComment, breach } = judgeIssue(issue, detail.comments || [], now);
|
|
162
184
|
const url = `https://github.com/${repo}/issues/${issue.number}`;
|
|
163
185
|
const key = String(issue.number);
|
|
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);
|
|
164
216
|
// A person's issue pages ONCE when it first passes the SLA, never again (it used to repeat every 4 h, forever,
|
|
165
217
|
// which with ~14 old issues and an hourly run was most of the 10,747 notifications). The morning digest below
|
|
166
218
|
// carries whatever is still waiting. An existing lastAlertAt means that one page already went out.
|
|
@@ -175,7 +227,13 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
|
|
|
175
227
|
// Arrival is paged once, instantly, by .github/workflows/ntfy-alerts.yml (the `issues: opened` event). Paging it
|
|
176
228
|
// here too sent every new issue twice (review 2026-10-25), so this watcher owns only the SLA page and the digest.
|
|
177
229
|
const needsNewPage = false;
|
|
178
|
-
|
|
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;
|
|
179
237
|
|
|
180
238
|
results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url, person });
|
|
181
239
|
|
|
@@ -214,34 +272,47 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
|
|
|
214
272
|
}
|
|
215
273
|
}
|
|
216
274
|
|
|
217
|
-
if (dueForAlert) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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' });
|
|
237
304
|
}
|
|
305
|
+
if (sent) breachPaged = new Set(dueBreaches.map((b) => b.issue.number));
|
|
238
306
|
}
|
|
239
307
|
}
|
|
240
308
|
|
|
241
309
|
// The morning digest: one notification naming every person's issue still unanswered, oldest first.
|
|
242
310
|
const waiting = results.filter((r) => r.person && r.breach).sort((a, b) => b.ageHours - a.ageHours);
|
|
243
311
|
const digest = digestDue(state._digest?.day, now);
|
|
244
|
-
|
|
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) {
|
|
245
316
|
const title = `Waiting on a reply: ${waiting.length} contributor issue${waiting.length === 1 ? '' : 's'}`;
|
|
246
317
|
const body = waiting.slice(0, 8).map((r) => `#${r.number} (${fmtAge(r.ageHours)}) ${r.title.slice(0, 70)}`).join('\n')
|
|
247
318
|
+ (waiting.length > 8 ? `\n…and ${waiting.length - 8} more` : '') + `\nhttps://github.com/${repo}/issues`;
|
|
@@ -256,7 +327,7 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
|
|
|
256
327
|
|
|
257
328
|
if (!dryRun) saveState(state);
|
|
258
329
|
|
|
259
|
-
return { results, alertsSent, checkedAt: new Date(now).toISOString() };
|
|
330
|
+
return { results, alertsSent, degraded, unreadable, checkedAt: new Date(now).toISOString() };
|
|
260
331
|
}
|
|
261
332
|
|
|
262
333
|
/**
|
|
@@ -430,7 +501,12 @@ async function main() {
|
|
|
430
501
|
// heartbeat records the failure and the wrapper's own channel escalates — that duplicate-looking
|
|
431
502
|
// page IS the correct behavior when the primary page provably never left the building.
|
|
432
503
|
const undeliveredAlert = !dryRun && (output.alertsSent || []).some((a) => !a.sent && a.reason !== 'dry-run');
|
|
433
|
-
|
|
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);
|
|
434
510
|
}
|
|
435
511
|
|
|
436
|
-
if (
|
|
512
|
+
if (isDirectInvocation(import.meta.url)) await main();
|
package/scripts/job-heartbeat.sh
CHANGED
|
@@ -78,24 +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\":\"$(printf '%s %s' "$1" "${2:-}" | tr -d '"\\\n\r' | cut -c1-200)\"}"
|
|
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
112
|
[ -z "$topic" ] && return 1
|
|
96
113
|
# --fail: an HTTP error is a failed delivery. The caller records whether the page landed, so a page that
|
|
97
114
|
# never arrived is retried on the next run instead of muting the whole failing streak (review 2026-10-25).
|
|
98
|
-
curl -sS --fail -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: ${4:-rotating_light}" -d "$2" "https://ntfy.sh/$topic" >/dev/null 2>&1
|
|
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
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
# The run_id currently on disk for this label, or empty if the receipt is missing/unreadable/has
|
|
@@ -112,8 +129,10 @@ finish() {
|
|
|
112
129
|
timed_out=0
|
|
113
130
|
if [ -f "$HB.timeout-$RUN_ID" ]; then
|
|
114
131
|
# Anything the overrunning job left behind (started during its TERM handling, say) goes now.
|
|
115
|
-
timed_out=1; stop_job
|
|
132
|
+
timed_out=1; stop_job
|
|
116
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"
|
|
117
136
|
# An overrun is a failure however the job chose to exit on TERM, including 0 and 75 (the skip code), so this
|
|
118
137
|
# comes before the skip branch (GPT-6.1 review round 2).
|
|
119
138
|
[ "$timed_out" -eq 1 ] && [ "$code" -le 128 ] && code=143
|
|
@@ -152,28 +171,44 @@ finish() {
|
|
|
152
171
|
# PAGE ON THE TRANSITION, NOT ON EVERY RUN (owner, 2026-10-25: 10,747 notifications on one topic). A job that
|
|
153
172
|
# starts failing pages once, urgently, while it is still tonight's problem; while it KEEPS failing it stays
|
|
154
173
|
# quiet (failing_since carries the first failure; the nightly watchdog's digest still lists it); the run
|
|
155
|
-
# that passes again sends one low-priority "recovered" note
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# counts as
|
|
162
|
-
|
|
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"
|
|
163
186
|
since=""
|
|
164
187
|
if [ "$code" -ne 0 ]; then
|
|
165
188
|
if [ "$was_failing" -eq 1 ] && [ -n "$prev_since" ]; then since="$prev_since"; else since="$STARTED"; fi
|
|
166
189
|
extra="$extra,\"failing_since\":\"$since\""
|
|
167
190
|
fi
|
|
168
191
|
if [ "$code" -ne 0 ] && { [ "$was_failing" -eq 0 ] || [ "$prev_unpaged" -eq 1 ]; }; then
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
172
205
|
elif [ "$code" -ne 0 ]; then
|
|
173
206
|
extra="$extra,\"paged\":true"
|
|
174
|
-
elif [ "$was_failing" -eq 1 ]; then
|
|
175
|
-
notify "
|
|
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
|
|
176
209
|
fi
|
|
210
|
+
[ -n "$page_epoch" ] && extra="$extra,\"last_page_epoch\":$page_epoch"
|
|
211
|
+
[ -n "$page_at" ] && extra="$extra,\"last_page_at\":\"$page_at\""
|
|
177
212
|
write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
|
|
178
213
|
exit "$code"
|
|
179
214
|
}
|
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);
|