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.63",
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",
@@ -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 90s)…`);
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
- const deadline = Date.now() + 90_000;
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
+ }