shraga 0.1.82 → 0.1.84
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 +1 -1
- package/src/server/background-jobs.ts +5 -0
- package/src/server/boot.ts +16 -17
- package/src/server/listen-retry.ts +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.84",
|
|
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",
|
|
@@ -239,6 +239,11 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
|
|
|
239
239
|
// so one started in the foreground orphans silently when its 60s tool call is killed.
|
|
240
240
|
SHRAGA_JOB_ID: id,
|
|
241
241
|
SHRAGA_BG_JOB: '1',
|
|
242
|
+
// The owning session, so a launcher never has to be HANDED it. A model writing the command
|
|
243
|
+
// has to remember `export` for a var to survive into `sh script.sh`; a plain assignment
|
|
244
|
+
// silently doesn't, which failed every scheduled social run for a day. The runner already
|
|
245
|
+
// knows whose job this is — passing it removes the caller from the loop entirely.
|
|
246
|
+
SHRAGA_SESSION_ID: owner.sessionId,
|
|
242
247
|
},
|
|
243
248
|
detached: true, stdio: ['ignore', fd, fd],
|
|
244
249
|
});
|
package/src/server/boot.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { hydrateSlackUserToken } from './slack/oauth.ts';
|
|
|
44
44
|
import { registerMcpOAuthRoutes } from './mcp-oauth.ts';
|
|
45
45
|
import { registerEventRoutes } from './events/routes.ts';
|
|
46
46
|
import { reclaimStalePort } from './port-reclaim.ts';
|
|
47
|
+
import { listenWithRetry } from './listen-retry.ts';
|
|
47
48
|
import { startHeartbeat, recordBootGap, buildReport } from './downtime.ts';
|
|
48
49
|
import { registerWebhook } from './events/webhook.ts';
|
|
49
50
|
import { startEventDispatcher } from './events/dispatcher.ts';
|
|
@@ -2127,26 +2128,18 @@ await new Promise<void>((resolve) => {
|
|
|
2127
2128
|
// forever: process running, port unbound, nothing served. The service manager sees a healthy job
|
|
2128
2129
|
// and a port watchdog sees a dead one, so it kickstarts on a loop and shreds in-flight runs.
|
|
2129
2130
|
// EADDRINUSE is the common case: `kickstart -k` starts the replacement while the old process is
|
|
2130
|
-
// still draining (up to 90s). Exiting
|
|
2131
|
-
// and
|
|
2131
|
+
// still draining (up to 90s). Exiting used to be the answer, but the manager respawns on a ~10s
|
|
2132
|
+
// throttle and meets the same owner: MEASURED 2026-09-06, one watchdog kick became ~20 start/exit
|
|
2133
|
+
// cycles across five minutes, and every scheduled run in that window died with them. Wait the
|
|
2134
|
+
// drain out here instead — one process holding still, rather than N racing (see listen-retry.ts).
|
|
2132
2135
|
//
|
|
2133
2136
|
// EXCEPT when the holder is an ORPHANED copy of ourselves: nothing will ever signal it, so it
|
|
2134
|
-
// holds the port forever and
|
|
2135
|
-
// not a transient drain and exiting cannot fix it — reclaim the port once, then bind.
|
|
2137
|
+
// holds the port forever and waiting alone cannot fix it — reclaim it, then the next retry binds.
|
|
2136
2138
|
let reclaimed = false;
|
|
2137
|
-
server
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
return;
|
|
2142
|
-
}
|
|
2143
|
-
const why = err.code === 'EADDRINUSE'
|
|
2144
|
-
? `port ${PORT} is already in use (previous instance still draining?)`
|
|
2145
|
-
: (err.message ?? String(err));
|
|
2146
|
-
console.error(`[server] FATAL: cannot listen on ${PORT} — ${why}. Exiting so the service manager restarts us.`);
|
|
2147
|
-
process.exit(1);
|
|
2148
|
-
});
|
|
2149
|
-
server.listen(PORT, () => {
|
|
2139
|
+
listenWithRetry(server, PORT, {
|
|
2140
|
+
onBusy: () => { if (!reclaimed) reclaimed = reclaimStalePort(PORT); },
|
|
2141
|
+
onWaiting: (ms) => console.warn(`[server] port ${PORT} is held (previous instance draining?) — waiting up to ${Math.round(ms / 1000)}s for it`),
|
|
2142
|
+
}).then(() => {
|
|
2150
2143
|
console.log(`[server] Running on http://0.0.0.0:${PORT}`);
|
|
2151
2144
|
resolve();
|
|
2152
2145
|
if (PASSIVE) return; // no sidecars, recovery, or MCP warmers in passive mode
|
|
@@ -2160,6 +2153,12 @@ await new Promise<void>((resolve) => {
|
|
|
2160
2153
|
// The disk MCP catalog is warmed off the turn path by whichever engine consumes it — the CE default
|
|
2161
2154
|
// (Claude Code) hands MCP servers straight to its SDK and needs no catalog. An add-on engine that
|
|
2162
2155
|
// uses the shared catalog registers its own boot/interval warm-up through the overlay.
|
|
2156
|
+
}, (err: NodeJS.ErrnoException) => {
|
|
2157
|
+
const why = err.code === 'EADDRINUSE'
|
|
2158
|
+
? `port ${PORT} is still in use after waiting for the previous instance to drain`
|
|
2159
|
+
: (err.message ?? String(err));
|
|
2160
|
+
console.error(`[server] FATAL: cannot listen on ${PORT} — ${why}. Exiting so the service manager restarts us.`);
|
|
2161
|
+
process.exit(1);
|
|
2163
2162
|
});
|
|
2164
2163
|
});
|
|
2165
2164
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind the HTTP port, waiting out a predecessor that is still draining.
|
|
3
|
+
*
|
|
4
|
+
* `kickstart -k` starts the replacement while the old process drains (up to 90s), so the new one
|
|
5
|
+
* meets EADDRINUSE. Exiting immediately hands the problem to the service manager — which respawns
|
|
6
|
+
* on a ~10s throttle, meets the same still-draining owner, and exits again. MEASURED 2026-09-06 on
|
|
7
|
+
* the feedox box: one watchdog kick produced ~20 start/exit cycles over five minutes, every one of
|
|
8
|
+
* them `port already in use`, and every scheduled run in that window died with it. The manager was
|
|
9
|
+
* doing exactly what it was asked to; the retry it was standing in for just belonged here, where
|
|
10
|
+
* the wait is one process holding still instead of N processes racing.
|
|
11
|
+
*
|
|
12
|
+
* So: retry the bind on a fixed cadence for a bounded window. A drain finishes and we bind; a
|
|
13
|
+
* genuinely occupied port still ends in a non-zero exit, just once instead of twenty times.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Server } from 'node:http';
|
|
17
|
+
|
|
18
|
+
export interface ListenRetryOptions {
|
|
19
|
+
/** Total time to keep retrying EADDRINUSE before giving up. Sized over the ~90s drain ceiling. */
|
|
20
|
+
waitMs?: number;
|
|
21
|
+
/** Gap between attempts. */
|
|
22
|
+
intervalMs?: number;
|
|
23
|
+
/** Called once, on the first EADDRINUSE, so a wait is never silent. */
|
|
24
|
+
onWaiting?: (waitMs: number) => void;
|
|
25
|
+
/** Called on EVERY EADDRINUSE, before the wait — a caller may use it to reclaim an orphan holder. */
|
|
26
|
+
onBusy?: () => void;
|
|
27
|
+
/** Injectable clock for tests. */
|
|
28
|
+
sleep?: (ms: number) => Promise<void>;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves when `server` is listening on `port`. Rejects with the underlying error if the port is
|
|
34
|
+
* held for the whole window, or immediately for any error that waiting cannot fix.
|
|
35
|
+
*/
|
|
36
|
+
export function listenWithRetry(server: Server, port: number, opts: ListenRetryOptions = {}): Promise<void> {
|
|
37
|
+
const waitMs = opts.waitMs ?? (Number(process.env.SHRAGA_BIND_WAIT_MS) || 120_000);
|
|
38
|
+
const intervalMs = opts.intervalMs ?? 2_000;
|
|
39
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
40
|
+
const now = opts.now ?? Date.now;
|
|
41
|
+
const deadline = now() + waitMs;
|
|
42
|
+
let announced = false;
|
|
43
|
+
|
|
44
|
+
return new Promise<void>((resolve, reject) => {
|
|
45
|
+
const attempt = (): void => {
|
|
46
|
+
const onError = (err: NodeJS.ErrnoException): void => {
|
|
47
|
+
// Only a busy port is worth waiting on: a bad address, EACCES, or anything else is as true
|
|
48
|
+
// in two minutes as it is now, and retrying it would only delay the operator's error.
|
|
49
|
+
if (err.code !== 'EADDRINUSE' || now() >= deadline) { reject(err); return; }
|
|
50
|
+
try { opts.onBusy?.(); } catch { /* a caller's reclaim attempt must not abort the wait */ }
|
|
51
|
+
if (!announced) { announced = true; opts.onWaiting?.(waitMs); }
|
|
52
|
+
void sleep(intervalMs).then(attempt);
|
|
53
|
+
};
|
|
54
|
+
server.once('error', onError);
|
|
55
|
+
server.listen(port, () => { server.off('error', onError); resolve(); });
|
|
56
|
+
};
|
|
57
|
+
attempt();
|
|
58
|
+
});
|
|
59
|
+
}
|