shraga 0.1.63 → 0.1.65
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/boot.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { registerModuleRoutes, reconcileInstalledModules } from './modules/index
|
|
|
41
41
|
import { hydrateSlackUserToken } from './slack/oauth.ts';
|
|
42
42
|
import { registerMcpOAuthRoutes } from './mcp-oauth.ts';
|
|
43
43
|
import { registerEventRoutes } from './events/routes.ts';
|
|
44
|
+
import { reclaimStalePort } from './port-reclaim.ts';
|
|
44
45
|
import { startHeartbeat, recordBootGap, buildReport } from './downtime.ts';
|
|
45
46
|
import { registerWebhook } from './events/webhook.ts';
|
|
46
47
|
import { startEventDispatcher } from './events/dispatcher.ts';
|
|
@@ -1989,12 +1990,13 @@ async function recoverInterruptedSessions() {
|
|
|
1989
1990
|
}
|
|
1990
1991
|
|
|
1991
1992
|
let _draining = false;
|
|
1993
|
+
const DRAIN_MS = Number(process.env.SHRAGA_DRAIN_MS) || 8_000;
|
|
1992
1994
|
|
|
1993
1995
|
async function gracefulShutdown(signal: string, opts: { exit?: boolean } = {}) {
|
|
1994
1996
|
const exit = opts.exit ?? true;
|
|
1995
1997
|
if (_draining) return;
|
|
1996
1998
|
_draining = true;
|
|
1997
|
-
console.log(`[server] ${signal} — draining (up to
|
|
1999
|
+
console.log(`[server] ${signal} — draining (up to ${Math.round(DRAIN_MS / 1000)}s)…`);
|
|
1998
2000
|
setShuttingDown();
|
|
1999
2001
|
stopSidecars();
|
|
2000
2002
|
|
|
@@ -2007,7 +2009,12 @@ async function gracefulShutdown(signal: string, opts: { exit?: boolean } = {}) {
|
|
|
2007
2009
|
// index at shutdown (the crash-recovery marker), so index entries can never go idle mid-drain —
|
|
2008
2010
|
// polling the index made every restart with an active session (or a stale marker from a prior
|
|
2009
2011
|
// crash) sit out the full 90s.
|
|
2010
|
-
|
|
2012
|
+
// Bounded by what the service manager ACTUALLY grants, not by what we'd like. launchd's default
|
|
2013
|
+
// ExitTimeOut is 20s and our power-guard wrapper SIGKILLs the child 10s after SIGTERM, so a 90s
|
|
2014
|
+
// drain was fiction: we never reached the clean exit below, and the in-flight turn was shredded
|
|
2015
|
+
// mid-write instead of aborted and persisted as a resumable partial. Override with
|
|
2016
|
+
// SHRAGA_DRAIN_MS where the manager really does grant longer.
|
|
2017
|
+
const deadline = Date.now() + DRAIN_MS;
|
|
2011
2018
|
while (Date.now() < deadline) {
|
|
2012
2019
|
const running = getActiveLockCount();
|
|
2013
2020
|
if (running === 0) break;
|
|
@@ -2071,7 +2078,17 @@ await new Promise<void>((resolve) => {
|
|
|
2071
2078
|
// EADDRINUSE is the common case: `kickstart -k` starts the replacement while the old process is
|
|
2072
2079
|
// still draining (up to 90s). Exiting non-zero is the correct answer — the manager restarts us,
|
|
2073
2080
|
// and by then the port is free.
|
|
2081
|
+
//
|
|
2082
|
+
// EXCEPT when the holder is an ORPHANED copy of ourselves: nothing will ever signal it, so it
|
|
2083
|
+
// holds the port forever and every respawn dies here while the orphan serves stale code. That is
|
|
2084
|
+
// not a transient drain and exiting cannot fix it — reclaim the port once, then bind.
|
|
2085
|
+
let reclaimed = false;
|
|
2074
2086
|
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
2087
|
+
if (err.code === 'EADDRINUSE' && !reclaimed && reclaimStalePort(PORT)) {
|
|
2088
|
+
reclaimed = true; // once only: a second EADDRINUSE means a live owner we must not fight
|
|
2089
|
+
server.listen(PORT);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2075
2092
|
const why = err.code === 'EADDRINUSE'
|
|
2076
2093
|
? `port ${PORT} is already in use (previous instance still draining?)`
|
|
2077
2094
|
: (err.message ?? String(err));
|
|
@@ -225,6 +225,15 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
225
225
|
sdkEnv.SHRAGA_USER_UID = sdkEnv.UNCLAW_USER_UID = opts.uid;
|
|
226
226
|
if (opts.userEmail) sdkEnv.SHRAGA_USER_EMAIL = sdkEnv.UNCLAW_USER_EMAIL = opts.userEmail;
|
|
227
227
|
sdkEnv.SHRAGA_SESSION_ID = sdkEnv.UNCLAW_SESSION_ID = opts.sessionId ?? '';
|
|
228
|
+
// Per-command wall-clock cap for the SDK's Bash tool. Without it a single unbounded command
|
|
229
|
+
// (e.g. `curl` with no `-m` against an SSE endpoint that streams nothing) eats the whole turn and
|
|
230
|
+
// the agent answers with "reads were interrupted". The model still sees `[exit 124]` + partial
|
|
231
|
+
// output and can adapt, and may ask for up to BASH_MAX_TIMEOUT_MS on a legitimately slow command.
|
|
232
|
+
// `??=` is not enough: the SDK treats a set-but-empty var as absent and silently falls back to
|
|
233
|
+
// its own 120s, so a blank host value must take our default too — only a real value wins.
|
|
234
|
+
const setIfBlank = (key: string, value: string) => { if (!sdkEnv[key]?.trim()) sdkEnv[key] = value; };
|
|
235
|
+
setIfBlank('BASH_DEFAULT_TIMEOUT_MS', process.env.AGENT_SHELL_TIMEOUT_MS?.trim() || '60000');
|
|
236
|
+
setIfBlank('BASH_MAX_TIMEOUT_MS', process.env.AGENT_SHELL_MAX_TIMEOUT_MS?.trim() || '600000');
|
|
228
237
|
sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
|
|
229
238
|
|
|
230
239
|
const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Reclaim the listen port from a STALE COPY OF OURSELVES.
|
|
4
|
+
*
|
|
5
|
+
* The EADDRINUSE handler's assumption — "the old instance is draining, it will free the port, the
|
|
6
|
+
* service manager restarts us" — holds only while something still owns that old process. It doesn't
|
|
7
|
+
* when the server's launchd/systemd parent dies first: the server reparents to pid 1, nothing will
|
|
8
|
+
* ever signal it, and it holds the port FOREVER. The replacement then dies on every respawn while
|
|
9
|
+
* the orphan keeps serving the OLD code, so `kickstart -k` looks successful and changes nothing
|
|
10
|
+
* (observed on feedox 2026-08-28: a deploy "restarted" the service four times, and the process
|
|
11
|
+
* answering :3032 was three hours and one version old).
|
|
12
|
+
*
|
|
13
|
+
* Reclaiming is deliberately narrow — an over-broad "kill whatever holds my port" is how a deploy
|
|
14
|
+
* takes down an unrelated service. Every condition must hold: the holder is orphaned (ppid 1, so no
|
|
15
|
+
* manager owns it), it runs THIS deployment's entrypoint from THIS working directory, and it isn't
|
|
16
|
+
* us. Anything else (a sibling deployment, a manager-owned process mid-drain, an unrelated server)
|
|
17
|
+
* is left alone and the caller keeps today's exit-and-let-the-manager-retry behavior. */
|
|
18
|
+
export function reclaimStalePort(port: number, opts: { entrypoint?: string; cwd?: string; termGraceMs?: number } = {}): boolean {
|
|
19
|
+
const entrypoint = opts.entrypoint ?? 'src/main.ts';
|
|
20
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
21
|
+
const grace = opts.termGraceMs ?? 5000;
|
|
22
|
+
|
|
23
|
+
const holders = listeners(port).filter((pid) => pid !== process.pid && isStaleSelf(pid, entrypoint, cwd));
|
|
24
|
+
if (!holders.length) return false;
|
|
25
|
+
|
|
26
|
+
for (const pid of holders) {
|
|
27
|
+
console.warn(`[server] port ${port} held by ORPHANED stale instance pid=${pid} (ppid 1, same cwd + entrypoint) — reclaiming`);
|
|
28
|
+
signal(pid, 'SIGTERM');
|
|
29
|
+
if (!waitGone(pid, grace)) {
|
|
30
|
+
console.warn(`[server] pid=${pid} ignored SIGTERM for ${grace}ms — SIGKILL`);
|
|
31
|
+
signal(pid, 'SIGKILL');
|
|
32
|
+
waitGone(pid, 2000);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const free = listeners(port).filter((pid) => pid !== process.pid).length === 0;
|
|
36
|
+
console.warn(`[server] port ${port} ${free ? 'reclaimed' : 'STILL held after reclaim'}`);
|
|
37
|
+
return free;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listeners(port: number): number[] {
|
|
41
|
+
return sh('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'])
|
|
42
|
+
.split('\n').map((l) => Number(l.trim())).filter((n) => Number.isInteger(n) && n > 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Orphaned (ppid 1) + our entrypoint + our cwd. All three, or it is not ours to kill. */
|
|
46
|
+
function isStaleSelf(pid: number, entrypoint: string, cwd: string): boolean {
|
|
47
|
+
if (sh('ps', ['-o', 'ppid=', '-p', String(pid)]).trim() !== '1') return false;
|
|
48
|
+
if (!sh('ps', ['-o', 'command=', '-p', String(pid)]).includes(entrypoint)) return false;
|
|
49
|
+
// `lsof -d cwd -Fn` prints the cwd on an `n`-prefixed line.
|
|
50
|
+
const holderCwd = sh('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'])
|
|
51
|
+
.split('\n').find((l) => l.startsWith('n'))?.slice(1).trim();
|
|
52
|
+
return !!holderCwd && holderCwd === cwd;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function signal(pid: number, sig: NodeJS.Signals): void {
|
|
56
|
+
try { process.kill(pid, sig); } catch (err: any) { console.warn(`[server] ${sig} pid=${pid} failed: ${err?.message ?? err}`); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function waitGone(pid: number, ms: number): boolean {
|
|
60
|
+
const deadline = Date.now() + ms;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
try { process.kill(pid, 0); } catch { return true; }
|
|
63
|
+
// Synchronous on purpose: this runs inside the listen-error path, before the event loop is
|
|
64
|
+
// serving anything, and the caller must decide bind-or-exit before returning.
|
|
65
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
66
|
+
}
|
|
67
|
+
try { process.kill(pid, 0); return false; } catch { return true; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sh(cmd: string, args: string[]): string {
|
|
71
|
+
try { return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }); }
|
|
72
|
+
catch { return ''; } // non-zero exit = no match (lsof) or no such pid (ps) — both mean "nothing there"
|
|
73
|
+
}
|