@retasc/cli 1.2.4 → 1.3.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/api.js +65 -13
- package/dist/auth.js +68 -0
- package/dist/config.js +69 -17
- package/dist/index.js +5 -1
- package/dist/lib/watchdog.js +53 -9
- package/dist/proxy.js +97 -6
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConvexHttpClient } from "convex/browser";
|
|
2
2
|
import { makeFunctionReference } from "convex/server";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
|
+
import { refreshSession, deviceLogin } from "./auth.js";
|
|
4
5
|
// Typed-ish references to the public management functions in convex/manage.ts.
|
|
5
6
|
// The CLI is a standalone package, so we reference functions by name rather than
|
|
6
7
|
// importing the parent's generated api.
|
|
@@ -26,18 +27,69 @@ function client() {
|
|
|
26
27
|
c.setAuth(cfg.token);
|
|
27
28
|
return c;
|
|
28
29
|
}
|
|
30
|
+
// Does this error mean "the access token is missing/expired", i.e. a refresh
|
|
31
|
+
// might fix it? The access-token JWT lives ~1h, so any long-lived login trips
|
|
32
|
+
// this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
|
|
33
|
+
// (requireUser), and the Convex platform rejects a stale JWT with "Could not
|
|
34
|
+
// verify OIDC token"/"Unauthenticated". Match either, case-insensitively.
|
|
35
|
+
export function isAuthError(e) {
|
|
36
|
+
const msg = String(e?.message ?? e);
|
|
37
|
+
return /unauthenticated|could not verify oidc|oidc token/i.test(msg);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run an authed management call, transparently recovering from an expired
|
|
41
|
+
* access token. The two-token design (short access JWT + long refresh token) is
|
|
42
|
+
* the point: the CLI silently rides the refresh token across the hourly access
|
|
43
|
+
* boundary instead of forcing `retasc login` every hour (RTSC-165).
|
|
44
|
+
*
|
|
45
|
+
* On an auth error we try ONE recovery, then retry the call ONCE:
|
|
46
|
+
* 1. If there is no stored token at all, the user was never signed in — this
|
|
47
|
+
* isn't an expiry, so rethrow the clean "sign in first" error rather than
|
|
48
|
+
* launching a surprise device flow under a misleading "session expired".
|
|
49
|
+
* 2. `refreshSession()` — swap the refresh token for a fresh access token. A
|
|
50
|
+
* transient backend failure here (network/5xx/masked "Server Error") is
|
|
51
|
+
* rethrown by `refreshSession`, so it propagates out as a retryable error
|
|
52
|
+
* instead of forcing a device-flow re-login on a valid session (RTSC-178).
|
|
53
|
+
* 3. If the refresh token is genuinely expired/invalid (a clean `false`, not a
|
|
54
|
+
* throw), fall back to a full `deviceLogin()` — but only when stdout is a
|
|
55
|
+
* TTY, since the device flow prints a code to stdout for a human to
|
|
56
|
+
* authorize; in a captured/piped context we surface a clear "run `retasc
|
|
57
|
+
* login`" error instead of hanging or spilling the prompt into someone's
|
|
58
|
+
* `$(retasc …)` capture.
|
|
59
|
+
* A second auth failure after recovery is real (rethrown) — we never loop.
|
|
60
|
+
*/
|
|
61
|
+
async function withAuth(call) {
|
|
62
|
+
try {
|
|
63
|
+
return await call();
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (!isAuthError(e))
|
|
67
|
+
throw e;
|
|
68
|
+
// No session to recover — never signed in. Surface the original error.
|
|
69
|
+
if (!loadConfig().token)
|
|
70
|
+
throw e;
|
|
71
|
+
if (await refreshSession())
|
|
72
|
+
return await call();
|
|
73
|
+
if (!process.stdout.isTTY) {
|
|
74
|
+
throw new Error("Session expired and could not refresh. Run `retasc login`.");
|
|
75
|
+
}
|
|
76
|
+
console.error("Session expired — re-authenticating with GitHub…");
|
|
77
|
+
await deviceLogin();
|
|
78
|
+
return await call();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
29
81
|
export const api = {
|
|
30
|
-
me: () => client().query(fns.me, {}),
|
|
31
|
-
listProjects: (args) => client().query(fns.listProjects, args),
|
|
32
|
-
createOrg: (args) => client().mutation(fns.createOrg, args),
|
|
33
|
-
createProject: (args) => client().mutation(fns.createProject, args),
|
|
34
|
-
renameProjectPrefix: (args) => client().mutation(fns.renameProjectPrefix, args),
|
|
35
|
-
listKeys: (args) => client().query(fns.listKeys, args),
|
|
36
|
-
mintKey: (args) => client().action(fns.mintKey, args),
|
|
37
|
-
rotateKey: (args) => client().action(fns.rotateKey, args),
|
|
38
|
-
revokeKey: (args) => client().mutation(fns.revokeKey, args),
|
|
39
|
-
createInvite: (args) => client().action(fns.createInvite, args),
|
|
40
|
-
acceptInvite: (args) => client().mutation(fns.acceptInvite, args),
|
|
41
|
-
listInvites: (args) => client().query(fns.listInvites, args),
|
|
42
|
-
revokeInvite: (args) => client().mutation(fns.revokeInvite, args),
|
|
82
|
+
me: () => withAuth(() => client().query(fns.me, {})),
|
|
83
|
+
listProjects: (args) => withAuth(() => client().query(fns.listProjects, args)),
|
|
84
|
+
createOrg: (args) => withAuth(() => client().mutation(fns.createOrg, args)),
|
|
85
|
+
createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
|
|
86
|
+
renameProjectPrefix: (args) => withAuth(() => client().mutation(fns.renameProjectPrefix, args)),
|
|
87
|
+
listKeys: (args) => withAuth(() => client().query(fns.listKeys, args)),
|
|
88
|
+
mintKey: (args) => withAuth(() => client().action(fns.mintKey, args)),
|
|
89
|
+
rotateKey: (args) => withAuth(() => client().action(fns.rotateKey, args)),
|
|
90
|
+
revokeKey: (args) => withAuth(() => client().mutation(fns.revokeKey, args)),
|
|
91
|
+
createInvite: (args) => withAuth(() => client().action(fns.createInvite, args)),
|
|
92
|
+
acceptInvite: (args) => withAuth(() => client().mutation(fns.acceptInvite, args)),
|
|
93
|
+
listInvites: (args) => withAuth(() => client().query(fns.listInvites, args)),
|
|
94
|
+
revokeInvite: (args) => withAuth(() => client().mutation(fns.revokeInvite, args)),
|
|
43
95
|
};
|
package/dist/auth.js
CHANGED
|
@@ -76,3 +76,71 @@ export async function deviceLogin() {
|
|
|
76
76
|
}
|
|
77
77
|
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Does this thrown error clearly mean "this refresh token can never mint a new
|
|
81
|
+
* session" — as opposed to a transient backend blip? RTSC-178.
|
|
82
|
+
*
|
|
83
|
+
* Note the asymmetry in how Convex Auth's refresh grant fails:
|
|
84
|
+
* - A genuinely expired/invalid/reused refresh token does NOT throw — the
|
|
85
|
+
* server returns `{ tokens: null }`, which `refreshSession` already reads as
|
|
86
|
+
* `false` (no exception reaches here).
|
|
87
|
+
* - The `catch` only sees *thrown* errors: network failures, 5xx, and the
|
|
88
|
+
* opaque masked "Server Error" prod returns for any uncaught server-side
|
|
89
|
+
* throw — all transient — plus the rare corrupt/unparseable stored token
|
|
90
|
+
* (`parseRefreshToken` throws "Can't parse refresh token …").
|
|
91
|
+
*
|
|
92
|
+
* So we default to "transient" and only classify as unrefreshable when the
|
|
93
|
+
* message is unmistakably refresh-token-shaped. In prod a corrupt-token throw is
|
|
94
|
+
* itself masked to "Server Error" and so reads as transient — the deliberate,
|
|
95
|
+
* safe bias (RTSC-165 saw exactly this masking): never drag a user with a valid
|
|
96
|
+
* session through the whole device flow on a one-off hiccup. A truly dead
|
|
97
|
+
* refresh token still reaches `deviceLogin` via the `{ tokens: null }` → `false`
|
|
98
|
+
* path above, unaffected by this classifier.
|
|
99
|
+
*/
|
|
100
|
+
export function isUnrefreshableRefreshToken(err) {
|
|
101
|
+
const msg = String(err?.message ?? err);
|
|
102
|
+
return /can'?t parse refresh token|cannot parse refresh token|invalid refresh token|expired refresh token|refresh token (?:is |has )?(?:invalid|expired)/i.test(msg);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Silently mint a fresh access token from the stored refresh token and persist
|
|
106
|
+
* the rotated pair to ~/.retasc/config.json.
|
|
107
|
+
*
|
|
108
|
+
* Convex Auth access tokens (the JWT in `token`) live only ~1h; the refresh
|
|
109
|
+
* token is long-lived (session default ~30d) and exists precisely to swap for a
|
|
110
|
+
* new access token without re-running the device flow. Calling `auth:signIn`
|
|
111
|
+
* with `{ refreshToken }` (no provider/params) is Convex Auth's refresh grant —
|
|
112
|
+
* it returns `{ tokens: { token, refreshToken } }` with BOTH rotated (refresh
|
|
113
|
+
* tokens are single-use; the old one is invalidated), so we must write the new
|
|
114
|
+
* pair back immediately and never reuse the old refresh token.
|
|
115
|
+
*
|
|
116
|
+
* Returns true on success (config now holds a fresh session), false when there
|
|
117
|
+
* is no refresh token or the grant is cleanly rejected (expired/invalid/reused,
|
|
118
|
+
* which the server signals as `{ tokens: null }`) — the caller then falls back
|
|
119
|
+
* to a full `deviceLogin()`. A *transient* failure (network/5xx/opaque backend
|
|
120
|
+
* blip) is RE-THROWN so the caller can surface a retryable error instead of
|
|
121
|
+
* forcing a needless device-flow re-login (RTSC-178). Never prints token values.
|
|
122
|
+
*/
|
|
123
|
+
export async function refreshSession() {
|
|
124
|
+
const cfg = loadConfig();
|
|
125
|
+
if (!cfg.refreshToken)
|
|
126
|
+
return false;
|
|
127
|
+
try {
|
|
128
|
+
const convex = new ConvexHttpClient(cfg.deploymentUrl);
|
|
129
|
+
const res = await convex.action(signIn, { refreshToken: cfg.refreshToken });
|
|
130
|
+
const tokens = res?.tokens;
|
|
131
|
+
if (!tokens?.token)
|
|
132
|
+
return false;
|
|
133
|
+
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
// A clearly refresh-token-shaped rejection means we genuinely cannot
|
|
138
|
+
// refresh — fall through to device login. Anything else (network, 5xx,
|
|
139
|
+
// masked prod "Server Error") is transient: rethrow so `withAuth` surfaces a
|
|
140
|
+
// retryable error rather than dragging a valid session through the device
|
|
141
|
+
// flow at an hourly access-token boundary.
|
|
142
|
+
if (isUnrefreshableRefreshToken(err))
|
|
143
|
+
return false;
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, chmodSync, } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
// Production defaults. Overridable via env for dev/testing.
|
|
5
6
|
// RETASC_DEPLOYMENT_URL — Convex deployment (.cloud) for management calls
|
|
6
7
|
// RETASC_MCP_URL — the MCP endpoint agents connect to
|
|
@@ -8,20 +9,41 @@ export const DEFAULTS = {
|
|
|
8
9
|
deploymentUrl: process.env.RETASC_DEPLOYMENT_URL ?? "https://unique-lyrebird-934.convex.cloud",
|
|
9
10
|
mcpUrl: process.env.RETASC_MCP_URL ?? "https://mcp.retasc.com/mcp",
|
|
10
11
|
};
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
/** The dir config lives in. RETASC_DIR overrides it (tests, sandboxes) — same
|
|
13
|
+
* knob the keystore reads, so the two stay co-located. */
|
|
14
|
+
function configDir() {
|
|
15
|
+
return process.env.RETASC_DIR || join(homedir(), ".retasc");
|
|
16
|
+
}
|
|
13
17
|
export function configPath() {
|
|
14
|
-
return
|
|
18
|
+
return join(configDir(), "config.json");
|
|
15
19
|
}
|
|
16
20
|
export function loadConfig() {
|
|
21
|
+
const FILE = configPath();
|
|
17
22
|
let stored = {};
|
|
18
23
|
if (existsSync(FILE)) {
|
|
24
|
+
let raw;
|
|
19
25
|
try {
|
|
20
|
-
|
|
26
|
+
raw = readFileSync(FILE, "utf8");
|
|
21
27
|
}
|
|
22
28
|
catch {
|
|
23
|
-
//
|
|
24
|
-
|
|
29
|
+
// A read that FAILS (file lock, EIO, EMFILE, stale NFS handle) is transient,
|
|
30
|
+
// not corruption. Leave the file untouched and fall back to defaults for this
|
|
31
|
+
// run — self-heal next time — rather than renaming a possibly-valid config
|
|
32
|
+
// aside and turning a blip into a permanent logout.
|
|
33
|
+
raw = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (raw !== undefined) {
|
|
36
|
+
try {
|
|
37
|
+
stored = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Read succeeded but the bytes aren't valid JSON → genuinely corrupt/
|
|
41
|
+
// truncated (e.g. an interrupted write). Preserve them under a backup name
|
|
42
|
+
// rather than silently discarding — the file may still hold the only copy
|
|
43
|
+
// of the user's tokens, recoverable by hand. Then start fresh.
|
|
44
|
+
backupCorruptConfig(FILE);
|
|
45
|
+
stored = {};
|
|
46
|
+
}
|
|
25
47
|
}
|
|
26
48
|
}
|
|
27
49
|
// Defaults fill in; a saved value always wins.
|
|
@@ -35,19 +57,49 @@ export function loadConfig() {
|
|
|
35
57
|
defaultProjectPrefix: stored.defaultProjectPrefix,
|
|
36
58
|
};
|
|
37
59
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// pre-existing 0644 file — it can't undo a world-readable creation window).
|
|
42
|
-
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
43
|
-
writeFileSync(FILE, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
44
|
-
// Tokens live here — keep it user-only readable (covers overwriting an
|
|
45
|
-
// existing 0644 file, where the write mode above doesn't apply).
|
|
60
|
+
/** Move a corrupt config aside to a unique sibling so a human can recover any
|
|
61
|
+
* tokens it still holds. Best effort — never throw from the load path. */
|
|
62
|
+
function backupCorruptConfig(file) {
|
|
46
63
|
try {
|
|
47
|
-
|
|
64
|
+
renameSync(file, `${file}.corrupt-${randomUUID()}`);
|
|
48
65
|
}
|
|
49
66
|
catch {
|
|
50
|
-
/* best effort
|
|
67
|
+
/* best effort — if we can't back it up, saveConfig will overwrite it */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function saveConfig(cfg) {
|
|
71
|
+
const dir = configDir();
|
|
72
|
+
const FILE = configPath();
|
|
73
|
+
// Create the dir user-only and the temp file 0600, so tokens never exist
|
|
74
|
+
// world-readable even briefly (the chmod below only closes a pre-existing
|
|
75
|
+
// 0644 file — it can't undo a world-readable creation window).
|
|
76
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
77
|
+
// Atomic write: fully write a sibling temp file, then rename it over the real
|
|
78
|
+
// one. rename(2) is atomic on POSIX, so a crash or an interleaved write leaves
|
|
79
|
+
// either the old complete config or the new one — never a truncated file that
|
|
80
|
+
// loadConfig would read as "logged out". The temp lives in the same dir so the
|
|
81
|
+
// rename stays on one filesystem (a cross-device rename is not atomic).
|
|
82
|
+
const tmp = join(dir, `.config.json.${randomUUID()}.tmp`);
|
|
83
|
+
const body = JSON.stringify(cfg, null, 2) + "\n";
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(tmp, body, { encoding: "utf8", mode: 0o600 });
|
|
86
|
+
try {
|
|
87
|
+
chmodSync(tmp, 0o600); // keep user-only if a umask/prior file loosened it
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* best effort (e.g. Windows) */
|
|
91
|
+
}
|
|
92
|
+
renameSync(tmp, FILE);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// Don't leave a stray temp file behind on failure.
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(tmp);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* already gone */
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
51
103
|
}
|
|
52
104
|
}
|
|
53
105
|
export function patchConfig(patch) {
|
package/dist/index.js
CHANGED
|
@@ -434,7 +434,11 @@ program
|
|
|
434
434
|
.option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
|
|
435
435
|
.option("--force", "Also delete orphans (issue done but branch unmerged)")
|
|
436
436
|
.option("--json", "Emit the reconciled branch table as JSON")
|
|
437
|
-
.
|
|
437
|
+
.option("--only <RTSC-NN>", "Scope the sweep to a single issue's branch (used by the MCP auto-reap)")
|
|
438
|
+
.action((opts) =>
|
|
439
|
+
// Normalize --only to the canonical uppercase id so it matches the branch ids
|
|
440
|
+
// scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
|
|
441
|
+
tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
|
|
438
442
|
program
|
|
439
443
|
.command("done")
|
|
440
444
|
.description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
|
package/dist/lib/watchdog.js
CHANGED
|
@@ -38,15 +38,59 @@ export function applyObservation(leases, o) {
|
|
|
38
38
|
}
|
|
39
39
|
// Releases / terminal status drop the lease — the issue is named in the REQUEST.
|
|
40
40
|
const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
|
|
41
|
-
if (id)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
if (id && o.toolName === "release_issue")
|
|
42
|
+
leases.delete(id);
|
|
43
|
+
const closeId = terminalCloseId(o);
|
|
44
|
+
if (closeId)
|
|
45
|
+
leases.delete(closeId);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The issue id to reap if this observation is a terminal close (done|canceled) via
|
|
49
|
+
* `save_issue`, else undefined. This is the SAME rule applyObservation uses to drop
|
|
50
|
+
* the lease — factored out so "which calls end an issue" lives in one place and the
|
|
51
|
+
* proxy's auto-reap (RTSC-181) can't drift from the lease bookkeeping. The caller
|
|
52
|
+
* decides whether it actually held the lease and whether the call succeeded
|
|
53
|
+
* (`isToolError`) before acting — this only classifies the request shape.
|
|
54
|
+
*/
|
|
55
|
+
export function terminalCloseId(o) {
|
|
56
|
+
if (o.toolName !== "save_issue")
|
|
57
|
+
return undefined;
|
|
58
|
+
const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
|
|
59
|
+
if (!id)
|
|
60
|
+
return undefined;
|
|
61
|
+
const status = o.args?.status;
|
|
62
|
+
return status === "done" || status === "canceled" ? id : undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Did a tools/call result come back as an MCP error envelope (`{ isError: true }`)?
|
|
66
|
+
* Used to gate side effects — the proxy must never reap a branch off a `save_issue`
|
|
67
|
+
* that actually FAILED server-side. Distinct from isClaimLost (which matches a
|
|
68
|
+
* specific error string); this is the generic "did the tool error at all" check.
|
|
69
|
+
*/
|
|
70
|
+
export function isToolError(result) {
|
|
71
|
+
return (!!result && typeof result === "object" && result.isError === true);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The full RTSC-181 reap gate as ONE pure decision: given an observed tool call and
|
|
75
|
+
* the leases this session holds, return the issue id to reap — or undefined. Reap ONLY
|
|
76
|
+
* when ALL hold:
|
|
77
|
+
* 1. it's a terminal close (save_issue done|canceled),
|
|
78
|
+
* 2. of an issue THIS session actually claimed (in `leases` — never someone else's),
|
|
79
|
+
* 3. and the call genuinely succeeded — a truthy payload that is not an error
|
|
80
|
+
* envelope. A transport-level failure yields an undefined result (no envelope to
|
|
81
|
+
* catch); requiring a truthy payload means we never reap off a failed close.
|
|
82
|
+
* This is the security-critical check — keeping it pure makes every branch testable
|
|
83
|
+
* (the proxy just spawns on a non-undefined return).
|
|
84
|
+
*/
|
|
85
|
+
export function shouldReapOnClose(o, leases) {
|
|
86
|
+
const id = terminalCloseId(o);
|
|
87
|
+
if (!id)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (!leases.has(id))
|
|
90
|
+
return undefined;
|
|
91
|
+
if (!o.result || isToolError(o.result))
|
|
92
|
+
return undefined;
|
|
93
|
+
return id;
|
|
50
94
|
}
|
|
51
95
|
/** A JSON-RPC `heartbeat` tool call for one lease (the proxy's timer sends these). */
|
|
52
96
|
export function heartbeatRequest(rpcId, issueId, claimToken) {
|
package/dist/proxy.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// no watchdog. Self-enforcing: no proxy → no Retasc tools → can't orphan a lease.
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { hostname } from "node:os";
|
|
10
|
-
import {
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { applyObservation, heartbeatRequest, isClaimLost, shouldReapOnClose, } from "./lib/watchdog.js";
|
|
11
14
|
import { resolveConn } from "./lib/keystore.js";
|
|
12
15
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
13
16
|
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
@@ -30,6 +33,87 @@ let sessionKeyFallback = false;
|
|
|
30
33
|
function log(msg) {
|
|
31
34
|
process.stderr.write(`[retasc-watchdog] ${msg}\n`);
|
|
32
35
|
}
|
|
36
|
+
// RTSC-181: closing an issue over MCP (save_issue status:done) marks it done on the
|
|
37
|
+
// control plane but the server never touches git — so the branch/worktree orphans.
|
|
38
|
+
// The proxy is the local hands: when it sees a session's own claim close terminally,
|
|
39
|
+
// it reaps that issue's branch out-of-band via the CLI's existing `tidy --prune`,
|
|
40
|
+
// which reuses every safety guard (skips dirty/unmerged/current-branch). Resolved
|
|
41
|
+
// once at startup; null if we're not in a git repo (nothing to reap).
|
|
42
|
+
const MAIN_CHECKOUT = resolveMainCheckout();
|
|
43
|
+
// The compiled CLI entrypoint (dist/index.js) — resolved relative to this module so
|
|
44
|
+
// it's correct however the proxy was launched (not process.argv, which a wrapper
|
|
45
|
+
// could rewrite). Reaps re-invoke it as `retasc tidy`.
|
|
46
|
+
const CLI_ENTRY = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
47
|
+
// Issues whose reap child is in flight — so a duplicate/retried close doesn't spawn
|
|
48
|
+
// two concurrent teardowns racing on the same branch.
|
|
49
|
+
const reaping = new Set();
|
|
50
|
+
// Reaps are SERIALIZED through this chain: concurrent `tidy` children mutating the
|
|
51
|
+
// same main checkout contend on `.git` locks (index.lock, ref locks) — a wave-close
|
|
52
|
+
// (next_batch → N × save_issue done) would otherwise fire N parallel worktree/branch
|
|
53
|
+
// deletes and hit "cannot lock ref" errors + partial teardown. One at a time avoids it.
|
|
54
|
+
let reapChain = Promise.resolve();
|
|
55
|
+
const REAP_TIMEOUT_MS = 60_000; // a stuck git (e.g. a credential prompt) must not wedge the queue
|
|
56
|
+
/** The main checkout root (worktree/branch ops always target it), or null if not in
|
|
57
|
+
* a git repo. `--git-common-dir` resolves to the MAIN `.git` even from a worktree. */
|
|
58
|
+
function resolveMainCheckout() {
|
|
59
|
+
const r = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
|
|
60
|
+
if (r.status !== 0)
|
|
61
|
+
return null;
|
|
62
|
+
const commonDir = (r.stdout ?? "").trim();
|
|
63
|
+
return commonDir ? dirname(resolve(commonDir)) : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reap a terminally-closed issue's branch/worktree, out-of-band (RTSC-181). Enqueues
|
|
67
|
+
* a `retasc tidy --prune --only <id>` run (serialized — see `reapChain`) as a SEPARATE
|
|
68
|
+
* process rooted at the main checkout so (a) blocking git never stalls the proxy's
|
|
69
|
+
* stdio loop, and (b) cwd is the main checkout, not the agent's worktree — so `tidy`'s
|
|
70
|
+
* "you're inside its worktree" guard doesn't skip the very tree we mean to remove.
|
|
71
|
+
* `tidy` still enforces every safety check: an unmerged (or dirty) branch is left as an
|
|
72
|
+
* orphan, never force-deleted — so only a clean, merged worktree is ever removed.
|
|
73
|
+
*/
|
|
74
|
+
function reapClosedIssue(issueId) {
|
|
75
|
+
if (!MAIN_CHECKOUT)
|
|
76
|
+
return; // not in a git repo — nothing local to reap
|
|
77
|
+
if (reaping.has(issueId))
|
|
78
|
+
return; // a teardown for this issue is already queued/running
|
|
79
|
+
reaping.add(issueId);
|
|
80
|
+
// Chain after any in-flight reap so git ops on the shared checkout never overlap.
|
|
81
|
+
reapChain = reapChain.then(() => runReap(issueId));
|
|
82
|
+
}
|
|
83
|
+
/** Run one `tidy --prune --only` child to completion (resolve on exit/error/timeout).
|
|
84
|
+
* Never rejects — a failed reap is best-effort cleanup, logged, never fatal. */
|
|
85
|
+
function runReap(issueId) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
log(`${issueId} closed → reaping its branch/worktree…`);
|
|
88
|
+
const child = spawn(process.execPath, [CLI_ENTRY, "tidy", "--prune", "--only", issueId], {
|
|
89
|
+
cwd: MAIN_CHECKOUT,
|
|
90
|
+
env: process.env,
|
|
91
|
+
// stdout is ignored (tidy reports on stderr); stderr inherits so its reap log
|
|
92
|
+
// lands in the same MCP stderr stream as the rest of the watchdog's output.
|
|
93
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
94
|
+
});
|
|
95
|
+
let settled = false;
|
|
96
|
+
const finish = () => {
|
|
97
|
+
if (settled)
|
|
98
|
+
return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reaping.delete(issueId);
|
|
102
|
+
resolve();
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
log(`reap of ${issueId} timed out — killing it`);
|
|
106
|
+
child.kill("SIGKILL"); // 'exit' still fires → finish() runs
|
|
107
|
+
}, REAP_TIMEOUT_MS);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
child.on("error", (e) => {
|
|
110
|
+
log(`reap of ${issueId} couldn't start: ${String(e?.message ?? e)}`);
|
|
111
|
+
finish();
|
|
112
|
+
});
|
|
113
|
+
child.on("exit", finish);
|
|
114
|
+
child.unref?.(); // a pending reap must not keep the proxy alive
|
|
115
|
+
});
|
|
116
|
+
}
|
|
33
117
|
async function postRemote(body) {
|
|
34
118
|
const res = await fetch(MCP_URL, {
|
|
35
119
|
method: "POST",
|
|
@@ -120,18 +204,25 @@ async function handleLine(line) {
|
|
|
120
204
|
// Watch tools/call traffic for claims/releases (request args + result), and
|
|
121
205
|
// flag the workspace-key fallback on whoami so the AGENT sees the degraded
|
|
122
206
|
// state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
|
|
207
|
+
let reapId;
|
|
123
208
|
if (msg.method === "tools/call" && resp) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
209
|
+
const result = toolResult(resp, msg.params?.name);
|
|
210
|
+
const obs = { toolName: msg.params?.name, args: msg.params?.arguments, result };
|
|
211
|
+
// Decide the reap BEFORE applyObservation drops the lease (the gate checks lease
|
|
212
|
+
// ownership). Pure decision — see shouldReapOnClose. We act on it AFTER relaying
|
|
213
|
+
// the response, below (RTSC-181).
|
|
214
|
+
reapId = shouldReapOnClose(obs, leases);
|
|
215
|
+
applyObservation(leases, obs);
|
|
129
216
|
appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
|
|
130
217
|
}
|
|
131
218
|
// Relay the response (requests have an id; notifications don't).
|
|
132
219
|
if (resp != null && msg.id !== undefined) {
|
|
133
220
|
process.stdout.write(JSON.stringify(resp) + "\n");
|
|
134
221
|
}
|
|
222
|
+
// Reap only after the tool result is on the wire — spawns its own process, so git
|
|
223
|
+
// never delays the response the agent is blocked on (RTSC-181).
|
|
224
|
+
if (reapId)
|
|
225
|
+
reapClosedIssue(reapId);
|
|
135
226
|
}
|
|
136
227
|
async function heartbeatAll() {
|
|
137
228
|
for (const [issueId, token] of [...leases]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Retasc CLI — sign in with GitHub, 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": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"convex": "^1.41.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^
|
|
49
|
+
"@types/node": "^26.1.0",
|
|
50
50
|
"typescript": "^5.6.0"
|
|
51
51
|
}
|
|
52
52
|
}
|