@retasc/cli 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/commands/gate.js +75 -0
- package/dist/commands/mcp.js +6 -1
- package/dist/index.js +18 -7
- package/dist/lib/watchdog.js +74 -13
- package/dist/proxy.js +37 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.22.0 (2026-08-12)
|
|
10
|
+
|
|
11
|
+
- **RTSC-646** — the watchdog now says when it is **not** renewing a lease. Its lease set is
|
|
12
|
+
in-memory and built only from claim traffic this proxy saw, so a claim made in another
|
|
13
|
+
terminal, over direct HTTP MCP, or by a previous proxy before a harness restart was renewed
|
|
14
|
+
by nobody — silently, until the reclaimer took the issue away mid-build. Touching such a
|
|
15
|
+
lease now prints a warning naming the issue and what to do about it (`release_issue` is
|
|
16
|
+
exempt: it ends a lease rather than needing one). A heartbeat rejected as `UNAUTHORIZED` is
|
|
17
|
+
also called out loudly, once: that means the session's **credential** died, not the lease,
|
|
18
|
+
so every claim stops renewing at the same moment and each lapses at its own expiry. The
|
|
19
|
+
proxy keeps tracking and keeps trying, because re-minting the key inside the TTL recovers
|
|
20
|
+
all of them. The `mcp install --watchdog` text no longer stops at "no per-claim
|
|
21
|
+
heartbeats" — it names both bounds: the proxy must be running, and it only renews claims
|
|
22
|
+
it saw this session make.
|
|
23
|
+
|
|
24
|
+
## 1.21.1 (2026-08-10)
|
|
25
|
+
|
|
26
|
+
- **RTSC-643** — `retasc gate install` keys the gate to **this folder's** project, not the
|
|
27
|
+
machine-wide default. It used to read `defaultProjectPrefix` (stamped by whichever project
|
|
28
|
+
you last ran `retasc init` for), so in a bound folder it could install a commit gate keyed
|
|
29
|
+
to a different project — rejecting every valid commit — while reporting success. It now asks
|
|
30
|
+
the folder's own binding first (`whoami` over the workspace key, or the keystore's cached
|
|
31
|
+
prefix offline; a subdirectory run checks the git toplevel too), prints where the prefix
|
|
32
|
+
came from, warns when the global default disagrees, and only uses the global default when
|
|
33
|
+
the folder is genuinely unbound. Bound-but-unresolvable fails loudly instead of guessing.
|
|
34
|
+
|
|
9
35
|
## 1.21.0 (2026-08-07)
|
|
10
36
|
|
|
11
37
|
- **RTSC-527** — the re-import warning dates the last import in **your** timezone. The day
|
package/dist/commands/gate.js
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdirSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
|
+
import { resolveMcpConn, readMcpJson, workspacePrefix } from "../lib/claim.js";
|
|
5
|
+
import { claudeLocalRetascEntry } from "../lib/binding.js";
|
|
6
|
+
import { getBinding } from "../lib/keystore.js";
|
|
7
|
+
/** The keystore's stored prefix for the workspace marker at `dir`, if any —
|
|
8
|
+
* the offline fallback when the server can't be asked. Entry selection matches
|
|
9
|
+
* resolveConn: env workspace id first, then claude-local, then the folder
|
|
10
|
+
* marker. The cache answers ONLY for the key the live path would have asked
|
|
11
|
+
* (`b.key === conn.key`) — an env-selected key from a different org must not
|
|
12
|
+
* inherit this folder's cached prefix. */
|
|
13
|
+
function storedPrefix(dir, conn, env = process.env) {
|
|
14
|
+
const entry = claudeLocalRetascEntry(dir) ?? readMcpJson(dir)?.mcpServers?.retasc;
|
|
15
|
+
const wsId = env.RETASC_WORKSPACE || entry?.env?.RETASC_WORKSPACE;
|
|
16
|
+
const b = wsId ? getBinding(String(wsId)) : undefined;
|
|
17
|
+
if (!b || b.key !== conn.key)
|
|
18
|
+
return undefined;
|
|
19
|
+
return typeof b.prefix === "string" && b.prefix ? b.prefix : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Server/keystore-derived text is validated BEFORE it's printed or trusted
|
|
22
|
+
* (same rule as whoami's control-char strip) — a malformed value is treated
|
|
23
|
+
* as unresolved, never echoed to the terminal. */
|
|
24
|
+
function validPrefix(p) {
|
|
25
|
+
if (typeof p !== "string")
|
|
26
|
+
return undefined;
|
|
27
|
+
const up = p.toUpperCase();
|
|
28
|
+
return PREFIX_RE.test(up) ? up : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** whoami must never hang gate install — same 10s bound as resolveBinding
|
|
31
|
+
* (binding.ts), which bare global fetch does not carry. */
|
|
32
|
+
const fetchWithTimeout = (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(10_000) });
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the prefix the gate is keyed to, folder-first (RTSC-643). The global
|
|
35
|
+
* `defaultProjectPrefix` is written only by `retasc init` — it's "whatever
|
|
36
|
+
* project you last init-ed", which in a bound folder can be a DIFFERENT
|
|
37
|
+
* workspace's prefix. So the folder's own binding is authoritative (whoami over
|
|
38
|
+
* the workspace's key, or the keystore's cached prefix offline), and the global
|
|
39
|
+
* default is consulted only when the folder is unbound. A bound folder whose
|
|
40
|
+
* prefix can't be resolved fails loudly rather than guessing: a gate keyed to
|
|
41
|
+
* the wrong project rejects every valid commit and ships to CI.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveGatePrefix(opts) {
|
|
44
|
+
// `!== undefined` so `--prefix ""` (an unset shell var) still reaches
|
|
45
|
+
// installGate's validator and fails loudly instead of silently resolving.
|
|
46
|
+
if (opts.flag !== undefined)
|
|
47
|
+
return { prefix: opts.flag.toUpperCase(), source: "--prefix" };
|
|
48
|
+
const cfgDefault = opts.defaultPrefix?.toUpperCase();
|
|
49
|
+
// The gate is written at the git toplevel, so when the cwd (a subdirectory)
|
|
50
|
+
// carries no binding, look at the toplevel too — otherwise a subdir run would
|
|
51
|
+
// silently fall back to the global default behind a false "isn't bound".
|
|
52
|
+
const dirs = opts.dir
|
|
53
|
+
? [opts.dir]
|
|
54
|
+
: Array.from(new Set([process.cwd(), repoRoot()].filter((d) => !!d)));
|
|
55
|
+
let conn;
|
|
56
|
+
let boundDir = dirs[0];
|
|
57
|
+
for (const d of dirs) {
|
|
58
|
+
const c = resolveMcpConn({ env: opts.env, mcpJson: readMcpJson(d), dir: d });
|
|
59
|
+
if (c.key) {
|
|
60
|
+
conn = c;
|
|
61
|
+
boundDir = d;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (conn) {
|
|
66
|
+
const live = validPrefix(await workspacePrefix(conn, opts.fetchImpl ?? fetchWithTimeout));
|
|
67
|
+
const prefix = live ?? validPrefix(storedPrefix(boundDir, conn, opts.env));
|
|
68
|
+
if (prefix) {
|
|
69
|
+
const source = live ? "workspace binding" : "workspace binding (cached)";
|
|
70
|
+
const shadowed = cfgDefault && cfgDefault !== prefix ? cfgDefault : undefined;
|
|
71
|
+
return shadowed ? { prefix, source, shadowedDefault: shadowed } : { prefix, source };
|
|
72
|
+
}
|
|
73
|
+
throw new Error("this folder is bound, but its project prefix couldn't be resolved (server didn't answer, no cached prefix) — pass --prefix <PREFIX>.");
|
|
74
|
+
}
|
|
75
|
+
if (cfgDefault)
|
|
76
|
+
return { prefix: cfgDefault, source: "global config" };
|
|
77
|
+
throw new Error("no project prefix — pass --prefix <PREFIX>, or bind this folder (`retasc bind`) / run `retasc init` so it's resolved from your project.");
|
|
78
|
+
}
|
|
4
79
|
// Mirror the server's project-prefix rule (convex/manage.ts PREFIX_RE): 2–10
|
|
5
80
|
// chars, A–Z/0–9, letter-first. The prefix is interpolated into a generated
|
|
6
81
|
// bash hook and a YAML grep, so validating here keeps a stray value from
|
package/dist/commands/mcp.js
CHANGED
|
@@ -186,7 +186,12 @@ export function installMcp(opts) {
|
|
|
186
186
|
}
|
|
187
187
|
console.log("\nWatchdog MCP config (Codex / OpenCode / any stdio MCP client):\n");
|
|
188
188
|
console.log(mcpProxyConfigBlock(opts.url, opts.key, resolved.launcher));
|
|
189
|
-
|
|
189
|
+
// RTSC-646: the old text stopped at "no per-claim heartbeats", which reads as
|
|
190
|
+
// "a claim never lapses while I'm working". It renews the claims THIS proxy saw
|
|
191
|
+
// you make, for as long as it is running — so name both bounds here, or the
|
|
192
|
+
// first surprise is the reclaimer taking an issue mid-build.
|
|
193
|
+
console.log("\nThe watchdog keeps claims alive automatically — it renews every claim it saw this session make, about every 10 minutes, so your agent needs no per-claim heartbeats. Long builds and test runs do NOT free a claim by themselves.");
|
|
194
|
+
console.log("Two things it does not cover, both safe (they fall back to the normal 30-minute lease timeout): if the proxy stops, renewal stops; and a claim made elsewhere — another terminal, direct HTTP MCP, or a previous proxy before a restart — is renewed by nobody. Heartbeat or checkpoint those yourself.");
|
|
190
195
|
return;
|
|
191
196
|
}
|
|
192
197
|
const res = tryClaudeCli(opts.url, opts.key, scope);
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { VERSION } from "./version.js";
|
|
4
4
|
import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
|
|
5
5
|
import { installMcp, normalizeScope } from "./commands/mcp.js";
|
|
6
|
-
import { installGate } from "./commands/gate.js";
|
|
6
|
+
import { installGate, resolveGatePrefix } from "./commands/gate.js";
|
|
7
7
|
import { claimAction } from "./commands/claim.js";
|
|
8
8
|
import { bindAction, setupFromToken } from "./commands/bind.js";
|
|
9
9
|
import { joinAction } from "./commands/join.js";
|
|
@@ -491,16 +491,27 @@ const gate = program.command("gate").description("Wire the commit↔issue tracea
|
|
|
491
491
|
gate
|
|
492
492
|
.command("install")
|
|
493
493
|
.description("Install a prefix-correct commit-msg hook + check-commit-message Action into this repo.")
|
|
494
|
-
.option("--prefix <PREFIX>", "Project prefix to enforce (default:
|
|
494
|
+
.option("--prefix <PREFIX>", "Project prefix to enforce (default: this folder's bound project)")
|
|
495
495
|
.option("--no-hook", "Skip the local commit-msg hook (CI Action only)")
|
|
496
496
|
.option("--no-action", "Skip the GitHub Action (local hook only)")
|
|
497
|
-
.action((opts) => {
|
|
497
|
+
.action(async (opts) => {
|
|
498
498
|
try {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
499
|
+
// RTSC-643: folder-first — the binding is the authoritative identity; the
|
|
500
|
+
// global default (stamped by the last `retasc init`) applies only when the
|
|
501
|
+
// folder is unbound.
|
|
502
|
+
const r = await resolveGatePrefix({
|
|
503
|
+
flag: opts.prefix,
|
|
504
|
+
defaultPrefix: loadConfig().defaultProjectPrefix,
|
|
505
|
+
});
|
|
506
|
+
if (r.source !== "--prefix")
|
|
507
|
+
console.log(`Prefix ${r.prefix} — from ${r.source}.`);
|
|
508
|
+
if (r.source === "global config") {
|
|
509
|
+
console.log(" (This folder isn't bound — `retasc bind` makes the prefix folder-scoped.)");
|
|
510
|
+
}
|
|
511
|
+
if (r.shadowedDefault) {
|
|
512
|
+
console.log(` ⚠ Ignoring global default prefix ${r.shadowedDefault} — this folder's binding wins.`);
|
|
502
513
|
}
|
|
503
|
-
installGate({ prefix, layers: { hook: opts.hook, action: opts.action } });
|
|
514
|
+
installGate({ prefix: r.prefix, layers: { hook: opts.hook, action: opts.action } });
|
|
504
515
|
}
|
|
505
516
|
catch (e) {
|
|
506
517
|
fail(e);
|
package/dist/lib/watchdog.js
CHANGED
|
@@ -111,32 +111,93 @@ export function heartbeatRequest(rpcId, issueId, claimToken) {
|
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
/**
|
|
114
|
-
*
|
|
114
|
+
* Does a tool result carry SERVER ERROR signal `code`? The shared matcher behind
|
|
115
|
+
* isClaimLost / isUnauthorized.
|
|
115
116
|
*
|
|
116
|
-
* The server throws `Error("CLAIM_LOST: …")`
|
|
117
|
-
* surfaces that as an MCP
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* lease and let the reclaimer hand our
|
|
117
|
+
* The server throws `Error("CLAIM_LOST: …")` / `Error("UNAUTHORIZED: …")`
|
|
118
|
+
* (convex/lib/claims.ts, convex/lib/auth.ts), and http.ts surfaces that as an MCP
|
|
119
|
+
* tool ERROR: `{ isError: true, content:[{text:"CLAIM_LOST: …"}] }`. Match ONLY that
|
|
120
|
+
* signal (or a top-level `error`/`code` field, for a future structured shape) —
|
|
121
|
+
* NEVER `JSON.stringify` the whole result and regex it, or a benign payload field
|
|
122
|
+
* that merely CONTAINS the code (an issue title, a checkpoint note quoting an error,
|
|
123
|
+
* a comment body) would silently drop a live lease and let the reclaimer hand our
|
|
124
|
+
* work to someone else (RTSC-150).
|
|
123
125
|
*/
|
|
124
|
-
|
|
126
|
+
function hasErrorCode(result, code) {
|
|
125
127
|
if (!result || typeof result !== "object")
|
|
126
128
|
return false;
|
|
127
129
|
const r = result;
|
|
130
|
+
// Substring match, NOT `new RegExp(code)`: the codes are fixed literals, so a
|
|
131
|
+
// regex buys nothing and turns any future caller-supplied code into a regex
|
|
132
|
+
// injection / ReDoS shape. `.includes` is the same match with none of that.
|
|
133
|
+
const carries = (s) => s.includes(code);
|
|
128
134
|
// A structured error signal on a top-level field (belt-and-suspenders; also the
|
|
129
135
|
// shape the older unit test asserts). Bounded to top-level scalars, not nested.
|
|
130
|
-
if (r.code ===
|
|
136
|
+
if (r.code === code)
|
|
131
137
|
return true;
|
|
132
|
-
if (typeof r.error === "string" &&
|
|
138
|
+
if (typeof r.error === "string" && carries(r.error))
|
|
133
139
|
return true;
|
|
134
140
|
// The live shape: only an MCP error envelope, and only its text blocks — success
|
|
135
|
-
// payloads (isError absent/false) are never treated as
|
|
141
|
+
// payloads (isError absent/false) are never treated as an error.
|
|
136
142
|
if (r.isError !== true || !Array.isArray(r.content))
|
|
137
143
|
return false;
|
|
138
144
|
return r.content.some((c) => c !== null &&
|
|
139
145
|
typeof c === "object" &&
|
|
140
146
|
typeof c.text === "string" &&
|
|
141
|
-
|
|
147
|
+
carries(c.text));
|
|
148
|
+
}
|
|
149
|
+
/** A heartbeat CLAIM_LOST means the lease is gone — stop tracking it. */
|
|
150
|
+
export function isClaimLost(result) {
|
|
151
|
+
return hasErrorCode(result, "CLAIM_LOST");
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A heartbeat UNAUTHORIZED means the CREDENTIAL died, not the lease (RTSC-646).
|
|
155
|
+
* `resolveAuth` throws it when the key was revoked or rotated, its parent key was
|
|
156
|
+
* revoked, or the member was suspended/retired — so EVERY lease in this session
|
|
157
|
+
* stops renewing at once and each one lapses at its own expiry.
|
|
158
|
+
*
|
|
159
|
+
* Deliberately NOT treated like CLAIM_LOST: the claims are still ours and still
|
|
160
|
+
* live server-side, so dropping them from the map would hide exactly the leases
|
|
161
|
+
* the human needs to hear about. We keep tracking, keep trying (a re-mint can fix
|
|
162
|
+
* it inside the TTL), and say so loudly.
|
|
163
|
+
*/
|
|
164
|
+
export function isUnauthorized(result) {
|
|
165
|
+
return hasErrorCode(result, "UNAUTHORIZED");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The renewal-visibility warning (RTSC-646): this session is touching a lease the
|
|
169
|
+
* watchdog is NOT renewing, so it runs on the bare 30-minute TTL.
|
|
170
|
+
*
|
|
171
|
+
* The lease map is in-memory and built ONLY from claim traffic this process saw, so
|
|
172
|
+
* a claim made in another terminal, over direct HTTP MCP, or by a previous proxy
|
|
173
|
+
* before a harness restart is renewed by nobody — and today that is silent until
|
|
174
|
+
* the reclaimer takes the issue away mid-work. Two observable tells, both meaning
|
|
175
|
+
* "we hold it, we aren't renewing it":
|
|
176
|
+
* 1. a request carrying a `claimToken` for an issue we don't track (the agent is
|
|
177
|
+
* hand-heartbeating/checkpointing a lease we never saw claimed), and
|
|
178
|
+
* 2. a `check_claim` answering `youHold: true` for an issue we don't track.
|
|
179
|
+
* Returns the warning text, or undefined when there's nothing to say. Pure so the
|
|
180
|
+
* decision is testable without a proxy; the caller does the printing.
|
|
181
|
+
*/
|
|
182
|
+
export function untrackedLeaseWarning(o, leases) {
|
|
183
|
+
const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
|
|
184
|
+
if (!id || leases.has(id))
|
|
185
|
+
return undefined;
|
|
186
|
+
// `release_issue` also carries a claimToken, but it ENDS the lease. Warning
|
|
187
|
+
// "nothing is renewing it, heartbeat it yourself" about a claim the agent is
|
|
188
|
+
// deliberately handing back is advice for the opposite of what it just did.
|
|
189
|
+
if (o.toolName === "release_issue")
|
|
190
|
+
return undefined;
|
|
191
|
+
const holds = typeof o.args?.claimToken === "string" ||
|
|
192
|
+
(!!o.result &&
|
|
193
|
+
typeof o.result === "object" &&
|
|
194
|
+
o.result.youHold === true);
|
|
195
|
+
if (!holds)
|
|
196
|
+
return undefined;
|
|
197
|
+
return (`lease for ${id} is NOT tracked by this proxy — nothing is renewing it, so it ` +
|
|
198
|
+
`runs on the bare 30-minute TTL and the reclaimer will take it mid-work. ` +
|
|
199
|
+
`This happens when the claim was made in another terminal, over direct HTTP MCP, ` +
|
|
200
|
+
`or by a previous proxy before a restart. Heartbeat or checkpoint it yourself ` +
|
|
201
|
+
`(checkpoint is better — it renews AND leaves the handoff note), or release it ` +
|
|
202
|
+
`and re-claim through this session.`);
|
|
142
203
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hostname } from "node:os";
|
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { dirname, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { applyObservation, heartbeatRequest, isClaimLost, shouldReapOnClose, } from "./lib/watchdog.js";
|
|
13
|
+
import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, shouldReapOnClose, untrackedLeaseWarning, } from "./lib/watchdog.js";
|
|
14
14
|
import { resolveConn } from "./lib/keystore.js";
|
|
15
15
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
16
16
|
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
@@ -23,6 +23,10 @@ const KEY = resolved.key;
|
|
|
23
23
|
const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
|
|
24
24
|
const leases = new Map();
|
|
25
25
|
let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
|
|
26
|
+
// RTSC-646: the credential-death warning fires ONCE with its full explanation. A
|
|
27
|
+
// revoked/rotated key fails every heartbeat of every lease, so repeating the whole
|
|
28
|
+
// paragraph on each tick (every 10 min, per lease) would bury the first one.
|
|
29
|
+
let authWarned = false;
|
|
26
30
|
// Per-session key (RTSC-50): starts as the workspace key; on startup we mint a
|
|
27
31
|
// session key and switch to it so this session is distinguishable from others.
|
|
28
32
|
let activeKey = KEY;
|
|
@@ -212,6 +216,12 @@ async function handleLine(line) {
|
|
|
212
216
|
// ownership). Pure decision — see shouldReapOnClose. We act on it AFTER relaying
|
|
213
217
|
// the response, below (RTSC-181).
|
|
214
218
|
reapId = shouldReapOnClose(obs, leases);
|
|
219
|
+
// RTSC-646: warn BEFORE applyObservation, while the map still reflects what we
|
|
220
|
+
// were renewing when the call was made — after it, a fresh claim would look
|
|
221
|
+
// tracked and a deliberate release would look untracked.
|
|
222
|
+
const warning = untrackedLeaseWarning(obs, leases);
|
|
223
|
+
if (warning)
|
|
224
|
+
log(`warning: ${warning}`);
|
|
215
225
|
applyObservation(leases, obs);
|
|
216
226
|
appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
|
|
217
227
|
}
|
|
@@ -227,11 +237,35 @@ async function handleLine(line) {
|
|
|
227
237
|
async function heartbeatAll() {
|
|
228
238
|
for (const [issueId, token] of [...leases]) {
|
|
229
239
|
try {
|
|
230
|
-
const
|
|
231
|
-
if (isClaimLost(
|
|
240
|
+
const result = toolResult(await postRemote(heartbeatRequest(hbSeq--, issueId, token)), "heartbeat");
|
|
241
|
+
if (isClaimLost(result)) {
|
|
232
242
|
leases.delete(issueId);
|
|
233
243
|
log(`lease for ${issueId} is gone — stopped tracking`);
|
|
234
244
|
}
|
|
245
|
+
else if (isUnauthorized(result)) {
|
|
246
|
+
// RTSC-646: the credential died, not the lease. Every claim in this session
|
|
247
|
+
// stops renewing at once and lapses at its own expiry, which reads exactly
|
|
248
|
+
// like "the watchdog is working" until the reclaimer takes the work away.
|
|
249
|
+
// Keep tracking and keep trying — re-minting the key inside the TTL recovers
|
|
250
|
+
// every lease — but say the cause out loud, once.
|
|
251
|
+
if (!authWarned) {
|
|
252
|
+
authWarned = true;
|
|
253
|
+
log(`warning: heartbeat for ${issueId} was rejected as UNAUTHORIZED — this session's key ` +
|
|
254
|
+
`no longer authenticates (revoked or rotated key, revoked parent key, or a suspended/` +
|
|
255
|
+
`retired member). NO lease in this session is being renewed now; each one lapses at ` +
|
|
256
|
+
`its own expiry and the reclaimer hands the work to another agent. Restore the ` +
|
|
257
|
+
`credential and restart this session, or finish and release your claims by hand.`);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
log(`heartbeat for ${issueId} still UNAUTHORIZED — lease not renewed`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
// A renewal that worked proves the credential works, so arm the full
|
|
265
|
+
// warning again: a key re-minted mid-session and then revoked a second
|
|
266
|
+
// time deserves the same loud explanation as the first time.
|
|
267
|
+
authWarned = false;
|
|
268
|
+
}
|
|
235
269
|
}
|
|
236
270
|
catch (e) {
|
|
237
271
|
log(`heartbeat for ${issueId} failed: ${String(e?.message ?? e)}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Retasc CLI
|
|
3
|
+
"version": "1.22.0",
|
|
4
|
+
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"retasc": "dist/index.js"
|