@retasc/cli 1.2.0 → 1.2.1
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/dist/lib/claim.js +4 -10
- package/dist/lib/session.js +93 -0
- package/dist/lib/toolresult.js +94 -0
- package/dist/proxy.js +25 -31
- package/package.json +1 -1
package/dist/lib/claim.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { resolveConn } from "./keystore.js";
|
|
8
|
+
// Tool-payload extraction lives in the shared tolerant parser (RTSC-143);
|
|
9
|
+
// re-exported so existing importers of this module keep working.
|
|
10
|
+
import { toolResult } from "./toolresult.js";
|
|
11
|
+
export { toolResult };
|
|
8
12
|
const DEFAULT_MCP_URL = "https://mcp.retasc.com/mcp";
|
|
9
13
|
/**
|
|
10
14
|
* Resolve the agent API key + MCP URL the same way the workspace's MCP wiring does,
|
|
@@ -84,16 +88,6 @@ export function parseClaimResult(result) {
|
|
|
84
88
|
const title = typeof r.issue === "object" ? r.issue?.title : undefined;
|
|
85
89
|
return { issueId, title, claimToken: r.claimToken };
|
|
86
90
|
}
|
|
87
|
-
/** The tool payload (JSON inside result.content[0].text), or the raw result. */
|
|
88
|
-
export function toolResult(resp) {
|
|
89
|
-
try {
|
|
90
|
-
const t = resp?.result?.content?.[0]?.text;
|
|
91
|
-
return t ? JSON.parse(t) : resp?.result;
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return resp?.result;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
91
|
/**
|
|
98
92
|
* Call one MCP tool over JSON-RPC with the agent key. Throws on transport errors
|
|
99
93
|
* and on tool errors (isError) — surfacing the server's message (CLAIM_LOST,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Session-key adoption core (RTSC-143) — the mint call the proxy runs on startup
|
|
2
|
+
// (RTSC-50), extracted here so the retry + failure behavior is unit-testable with
|
|
3
|
+
// a stubbed fetch. Fail-soft stands: a broken mint must never take the MCP down —
|
|
4
|
+
// but the fallback must be LOUD, because on the shared workspace key concurrent
|
|
5
|
+
// sessions collapse into one server-side identity and the per-session claim fence
|
|
6
|
+
// (RTSC-49/50) is silently inert.
|
|
7
|
+
import { toolResult } from "./toolresult.js";
|
|
8
|
+
// Out-of-band ids are negative so they never collide with the harness's (the
|
|
9
|
+
// same convention as the proxy's heartbeat ids; announceBinding uses -1001).
|
|
10
|
+
const MINT_RPC_ID = -1000;
|
|
11
|
+
// The mint runs BEFORE the proxy serves `initialize`, so a hung server must not
|
|
12
|
+
// stall startup past the harness's MCP timeout — that would turn "degraded
|
|
13
|
+
// identity" into "no Retasc tools at all", the exact failure fail-soft exists
|
|
14
|
+
// to avoid. Two attempts fit comfortably inside a ~30s harness budget.
|
|
15
|
+
const MINT_TIMEOUT_MS = 5_000;
|
|
16
|
+
const FALLBACK_CONSEQUENCE = "concurrent sessions will be indistinguishable to the server and the " +
|
|
17
|
+
"per-session claim fence is OFF for this session. Restart the session to retry.";
|
|
18
|
+
/**
|
|
19
|
+
* Mint a per-session key, retrying once (a startup network blip must not demote
|
|
20
|
+
* the whole session's identity). Never throws. On double failure, emits an
|
|
21
|
+
* unmissable multi-line warning stating the consequence and returns {ok:false}
|
|
22
|
+
* so the caller can keep flagging the degraded state (e.g. on whoami).
|
|
23
|
+
*/
|
|
24
|
+
export async function mintSessionKey(opts) {
|
|
25
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
26
|
+
const warn = opts.warn ?? ((msg) => process.stderr.write(`[retasc] ${msg}\n`));
|
|
27
|
+
const retryDelayMs = opts.retryDelayMs ?? 500;
|
|
28
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetchImpl(opts.url, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${opts.key}`,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Accept: "application/json",
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
jsonrpc: "2.0",
|
|
39
|
+
id: MINT_RPC_ID,
|
|
40
|
+
method: "tools/call",
|
|
41
|
+
params: { name: "mint_session_key", arguments: { label: opts.label } },
|
|
42
|
+
}),
|
|
43
|
+
signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
// A non-2xx (revoked key → 401, server error → 5xx) must not masquerade
|
|
47
|
+
// as "no key in response" — surface the status so a dead key is obvious.
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const snippet = text ? `: ${text.slice(0, 120).replace(/\s+/g, " ").trim()}` : "";
|
|
50
|
+
warn(`mint_session_key attempt ${attempt} failed (HTTP ${res.status})${snippet}`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const r = toolResult(text ? JSON.parse(text) : null, warn);
|
|
54
|
+
if (r && typeof r === "object" && typeof r.key === "string") {
|
|
55
|
+
return { ok: true, key: r.key, session: r.session };
|
|
56
|
+
}
|
|
57
|
+
warn(`mint_session_key attempt ${attempt} returned no key`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
warn(`mint_session_key attempt ${attempt} failed: ${String(e?.message ?? e)}`);
|
|
62
|
+
}
|
|
63
|
+
if (attempt === 1 && retryDelayMs > 0) {
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
warn("WARNING: could not mint a per-session key (retried once).");
|
|
68
|
+
warn(`WARNING: continuing on the SHARED WORKSPACE KEY — ${FALLBACK_CONSEQUENCE}`);
|
|
69
|
+
return { ok: false };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The warning content block appended to whoami responses when the session is
|
|
73
|
+
* running on the workspace-key fallback — so the AGENT sees the degraded state,
|
|
74
|
+
* not just whoever reads the MCP log files.
|
|
75
|
+
*/
|
|
76
|
+
export const MINT_FALLBACK_NOTICE = "⚠ Retasc watchdog: this session could not mint a per-session key and is using " +
|
|
77
|
+
`the shared workspace key — ${FALLBACK_CONSEQUENCE}`;
|
|
78
|
+
/**
|
|
79
|
+
* Append MINT_FALLBACK_NOTICE to a whoami response when degraded. Defensive on
|
|
80
|
+
* purpose: anything unexpected about the shape (error response, missing/non-array
|
|
81
|
+
* content) leaves the response untouched — a malformed notice must never break
|
|
82
|
+
* the protocol stream this rides on.
|
|
83
|
+
*/
|
|
84
|
+
export function appendFallbackNotice(toolName, resp, degraded) {
|
|
85
|
+
if (!degraded || toolName !== "whoami")
|
|
86
|
+
return;
|
|
87
|
+
const result = resp?.result;
|
|
88
|
+
if (result?.isError)
|
|
89
|
+
return; // don't decorate an error response
|
|
90
|
+
if (Array.isArray(result?.content)) {
|
|
91
|
+
result.content.push({ type: "text", text: MINT_FALLBACK_NOTICE });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Tolerant MCP tool-result parsing (RTSC-143) — pure, shared by proxy.ts and
|
|
2
|
+
// lib/claim.ts (previously two silent near-identical copies). The RTSC-91 footer
|
|
3
|
+
// regression survived unnoticed because a payload of `{json}\n\n— footer` failed
|
|
4
|
+
// JSON.parse and the parser silently handed back the raw result envelope —
|
|
5
|
+
// downstream code (lease tracking, claim parsing) then quietly misbehaved. This
|
|
6
|
+
// parser (a) recovers the JSON prefix when the text has trailing non-JSON, and
|
|
7
|
+
// (b) says so, so a shape regression is visible in MCP logs instead of vanishing.
|
|
8
|
+
/** Pure core: classify + extract the payload from a JSON-RPC tools/call response. */
|
|
9
|
+
export function parseToolResult(resp) {
|
|
10
|
+
const result = resp?.result;
|
|
11
|
+
const text = result?.content?.[0]?.text;
|
|
12
|
+
if (typeof text !== "string" || !text)
|
|
13
|
+
return { kind: "raw", value: result };
|
|
14
|
+
try {
|
|
15
|
+
return { kind: "json", value: JSON.parse(text) };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* fall through to prefix recovery */
|
|
19
|
+
}
|
|
20
|
+
// Never RECOVER from an error response: error text is prose that may embed
|
|
21
|
+
// JSON-looking fragments (issue ids, tokens), and a recovered fragment would
|
|
22
|
+
// flow into lease tracking as if the call had succeeded.
|
|
23
|
+
if (!result?.isError) {
|
|
24
|
+
const recovered = parseJsonPrefix(text);
|
|
25
|
+
if (recovered)
|
|
26
|
+
return { kind: "recovered", value: recovered.value };
|
|
27
|
+
}
|
|
28
|
+
return { kind: "raw", value: result, unparseable: true };
|
|
29
|
+
}
|
|
30
|
+
const MAX_PREFIX_ATTEMPTS = 16;
|
|
31
|
+
/**
|
|
32
|
+
* "Strip trailing junk after the top-level JSON value". Fast path: JSON.parse
|
|
33
|
+
* errors name the offending position (V8/JSC), which for a value-plus-footer is
|
|
34
|
+
* exactly where the junk starts. Fallback: a bounded backward scan for the
|
|
35
|
+
* payload's closing `}`/`]`. Either way a candidate is only accepted when the
|
|
36
|
+
* discarded tail is NOT itself JSON-like — concatenated JSON values (NDJSON
|
|
37
|
+
* style) are a shape break where silently dropping the second value would be
|
|
38
|
+
* worse than the raw fallback.
|
|
39
|
+
*/
|
|
40
|
+
function parseJsonPrefix(text) {
|
|
41
|
+
if (!/^\s*[{[]/.test(text))
|
|
42
|
+
return undefined; // only object/array payloads
|
|
43
|
+
const accept = (end) => {
|
|
44
|
+
if (/^\s*[{[]/.test(text.slice(end)))
|
|
45
|
+
return undefined; // tail is more JSON — refuse
|
|
46
|
+
try {
|
|
47
|
+
return { value: JSON.parse(text.slice(0, end)) };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
JSON.parse(text);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
const m = /position\s+(\d+)/i.exec(String(e?.message ?? e));
|
|
58
|
+
if (m) {
|
|
59
|
+
const hit = accept(Number(m[1]));
|
|
60
|
+
if (hit)
|
|
61
|
+
return hit;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let end = text.length;
|
|
65
|
+
for (let attempt = 0; attempt < MAX_PREFIX_ATTEMPTS; attempt++) {
|
|
66
|
+
const close = Math.max(text.lastIndexOf("}", end - 1), text.lastIndexOf("]", end - 1));
|
|
67
|
+
if (close < 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
const hit = accept(close + 1);
|
|
70
|
+
if (hit)
|
|
71
|
+
return hit;
|
|
72
|
+
end = close; // that candidate wasn't the payload's end — try the previous one
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
function warnStderr(msg) {
|
|
77
|
+
process.stderr.write(`[retasc] ${msg}\n`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The tool payload (JSON inside result.content[0].text), or the raw result.
|
|
81
|
+
* Drop-in for the old silent copies, except a degraded parse now WARNS (stderr —
|
|
82
|
+
* stdout may be the MCP channel): callers that only care about the value keep
|
|
83
|
+
* their one-liner, and a server shape regression is at least visible in logs.
|
|
84
|
+
*/
|
|
85
|
+
export function toolResult(resp, warn = warnStderr) {
|
|
86
|
+
const p = parseToolResult(resp);
|
|
87
|
+
if (p.kind === "recovered") {
|
|
88
|
+
warn("tool result had trailing non-JSON text — recovered the leading JSON payload");
|
|
89
|
+
}
|
|
90
|
+
else if (p.unparseable) {
|
|
91
|
+
warn("tool result text is not JSON — falling back to the raw result envelope");
|
|
92
|
+
}
|
|
93
|
+
return p.value;
|
|
94
|
+
}
|
package/dist/proxy.js
CHANGED
|
@@ -9,6 +9,8 @@ import { createInterface } from "node:readline";
|
|
|
9
9
|
import { hostname } from "node:os";
|
|
10
10
|
import { applyObservation, heartbeatRequest, isClaimLost, } from "./lib/watchdog.js";
|
|
11
11
|
import { resolveConn } from "./lib/keystore.js";
|
|
12
|
+
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
13
|
+
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
12
14
|
// RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
|
|
13
15
|
// the direct commands (claim/tidy/done) can never diverge. The proxy carries its
|
|
14
16
|
// binding in its own env (RETASC_MCP_KEY legacy, or RETASC_WORKSPACE → keystore).
|
|
@@ -21,6 +23,9 @@ let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with
|
|
|
21
23
|
// Per-session key (RTSC-50): starts as the workspace key; on startup we mint a
|
|
22
24
|
// session key and switch to it so this session is distinguishable from others.
|
|
23
25
|
let activeKey = KEY;
|
|
26
|
+
// Set when session-key minting failed and we fell back to the workspace key —
|
|
27
|
+
// whoami responses get a warning block appended so the agent sees the degraded state.
|
|
28
|
+
let sessionKeyFallback = false;
|
|
24
29
|
// stderr only: stdout is the MCP channel and must carry ONLY protocol messages.
|
|
25
30
|
function log(msg) {
|
|
26
31
|
process.stderr.write(`[retasc-watchdog] ${msg}\n`);
|
|
@@ -42,30 +47,20 @@ async function postRemote(body) {
|
|
|
42
47
|
* Auto-adopt a per-session key (RTSC-50). Mint one bound to our identity and use
|
|
43
48
|
* it for the rest of the session, so concurrent sessions of the same agent are
|
|
44
49
|
* distinguishable. Fail-soft: if minting is unavailable (older server, error),
|
|
45
|
-
* keep using the workspace key — no worse than before
|
|
50
|
+
* keep using the workspace key — no worse than before — but LOUDLY (RTSC-143),
|
|
51
|
+
* because on the shared key the per-session claim fence is inert.
|
|
46
52
|
*/
|
|
47
53
|
async function adoptSessionKey() {
|
|
48
54
|
if (!KEY)
|
|
49
55
|
return;
|
|
50
56
|
const label = process.env.RETASC_SESSION_LABEL || `${hostname()}#${process.pid}`;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
method: "tools/call",
|
|
56
|
-
params: { name: "mint_session_key", arguments: { label } },
|
|
57
|
-
});
|
|
58
|
-
const r = toolResult(resp);
|
|
59
|
-
if (r && typeof r === "object" && typeof r.key === "string") {
|
|
60
|
-
activeKey = r.key; // switch to the session key
|
|
61
|
-
log(`adopted session key "${r.session ?? label}"`);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
log("mint_session_key unavailable — using the workspace key");
|
|
65
|
-
}
|
|
57
|
+
const outcome = await mintSessionKey({ url: MCP_URL, key: KEY, label, warn: log });
|
|
58
|
+
if (outcome.ok) {
|
|
59
|
+
activeKey = outcome.key; // switch to the session key
|
|
60
|
+
log(`adopted session key "${outcome.session ?? label}"`);
|
|
66
61
|
}
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
else {
|
|
63
|
+
sessionKeyFallback = true;
|
|
69
64
|
}
|
|
70
65
|
}
|
|
71
66
|
/**
|
|
@@ -81,7 +76,7 @@ async function announceBinding() {
|
|
|
81
76
|
method: "tools/call",
|
|
82
77
|
params: { name: "whoami", arguments: {} },
|
|
83
78
|
});
|
|
84
|
-
const r = toolResult(resp);
|
|
79
|
+
const r = toolResult(resp, "whoami");
|
|
85
80
|
if (r?.org && r?.project) {
|
|
86
81
|
log(`bound → org "${r.org.name}" / project ${r.project.prefix}`);
|
|
87
82
|
}
|
|
@@ -90,15 +85,11 @@ async function announceBinding() {
|
|
|
90
85
|
/* announcement is best-effort */
|
|
91
86
|
}
|
|
92
87
|
}
|
|
93
|
-
// The tool result payload —
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return resp?.result;
|
|
101
|
-
}
|
|
88
|
+
// The tool result payload — shared tolerant parser (RTSC-143), warning through
|
|
89
|
+
// our stderr logger (tool-labeled, so a busy MCP log names which call degraded)
|
|
90
|
+
// when it had to recover a JSON prefix or fall back raw.
|
|
91
|
+
function toolResult(resp, tool) {
|
|
92
|
+
return parseTool(resp, (msg) => log(tool ? `${tool}: ${msg}` : msg));
|
|
102
93
|
}
|
|
103
94
|
async function handleLine(line) {
|
|
104
95
|
const trimmed = line.trim();
|
|
@@ -126,13 +117,16 @@ async function handleLine(line) {
|
|
|
126
117
|
}
|
|
127
118
|
return;
|
|
128
119
|
}
|
|
129
|
-
// Watch tools/call traffic for claims/releases (request args + result)
|
|
120
|
+
// Watch tools/call traffic for claims/releases (request args + result), and
|
|
121
|
+
// flag the workspace-key fallback on whoami so the AGENT sees the degraded
|
|
122
|
+
// state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
|
|
130
123
|
if (msg.method === "tools/call" && resp) {
|
|
131
124
|
applyObservation(leases, {
|
|
132
125
|
toolName: msg.params?.name,
|
|
133
126
|
args: msg.params?.arguments,
|
|
134
|
-
result: toolResult(resp),
|
|
127
|
+
result: toolResult(resp, msg.params?.name),
|
|
135
128
|
});
|
|
129
|
+
appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
|
|
136
130
|
}
|
|
137
131
|
// Relay the response (requests have an id; notifications don't).
|
|
138
132
|
if (resp != null && msg.id !== undefined) {
|
|
@@ -143,7 +137,7 @@ async function heartbeatAll() {
|
|
|
143
137
|
for (const [issueId, token] of [...leases]) {
|
|
144
138
|
try {
|
|
145
139
|
const resp = await postRemote(heartbeatRequest(hbSeq--, issueId, token));
|
|
146
|
-
if (isClaimLost(toolResult(resp))) {
|
|
140
|
+
if (isClaimLost(toolResult(resp, "heartbeat"))) {
|
|
147
141
|
leases.delete(issueId);
|
|
148
142
|
log(`lease for ${issueId} is gone — stopped tracking`);
|
|
149
143
|
}
|
package/package.json
CHANGED