@wenrwa/marketplace-cli 0.6.3 → 0.6.4
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/README.md +22 -9
- package/dist/__tests__/bridge-relay-fast-wake.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-fast-wake.test.js +91 -0
- package/dist/__tests__/bridge-relay-heartbeat.test.js +10 -3
- package/dist/__tests__/bridge-resume-detector.test.d.ts +1 -0
- package/dist/__tests__/bridge-resume-detector.test.js +58 -0
- package/dist/bridge/relayClient.d.ts +20 -0
- package/dist/bridge/relayClient.js +58 -11
- package/dist/bridge/resumeDetector.d.ts +28 -0
- package/dist/bridge/resumeDetector.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,11 +14,19 @@ npx @wenrwa/marketplace-cli
|
|
|
14
14
|
## Setup
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
#
|
|
18
|
-
wen-market auth login
|
|
17
|
+
# Sign in with your browser (recommended for interactive use)
|
|
18
|
+
wen-market auth login
|
|
19
19
|
|
|
20
|
-
#
|
|
21
|
-
|
|
20
|
+
# Prints an approval URL and code, opens your browser when possible, and
|
|
21
|
+
# works over SSH too (approve from any device — the code and URL are
|
|
22
|
+
# always printed). Approving on marketplace.wenrwa.com/settings/cli-login
|
|
23
|
+
# mints an API key named after this machine.
|
|
24
|
+
|
|
25
|
+
# CI / scripts: use a key created in Settings → API Keys instead
|
|
26
|
+
wen-market auth login --api-key wm_sk_...
|
|
27
|
+
|
|
28
|
+
# Point at a non-production API (local dev)
|
|
29
|
+
MARKETPLACE_API_URL=http://localhost:3002/api/v1 wen-market auth login
|
|
22
30
|
```
|
|
23
31
|
|
|
24
32
|
## Commands
|
|
@@ -26,8 +34,10 @@ wen-market auth login --api-key YOUR_KEY --api-url http://localhost:3002/api/v1
|
|
|
26
34
|
### auth — Authentication
|
|
27
35
|
|
|
28
36
|
```bash
|
|
29
|
-
wen-market auth login
|
|
30
|
-
wen-market auth
|
|
37
|
+
wen-market auth login # Browser device-code login (interactive)
|
|
38
|
+
wen-market auth login --api-key wm_sk_... # CI/scripts: use an existing API key
|
|
39
|
+
wen-market auth whoami # Show current session
|
|
40
|
+
wen-market auth logout # Revoke the key server-side and remove it locally
|
|
31
41
|
```
|
|
32
42
|
|
|
33
43
|
### bounty — Bounty Lifecycle & Management
|
|
@@ -168,16 +178,19 @@ Config is saved to `~/.wen-market/config.json` after `auth login`:
|
|
|
168
178
|
|
|
169
179
|
```json
|
|
170
180
|
{
|
|
171
|
-
"apiKey": "
|
|
172
|
-
"apiUrl": "
|
|
181
|
+
"apiKey": "wm_sk_...",
|
|
182
|
+
"apiUrl": "https://api.wenrwa.com/api/v1"
|
|
173
183
|
}
|
|
174
184
|
```
|
|
175
185
|
|
|
186
|
+
`apiKey` is always a real API key (`wm_sk_...`), sent as `X-API-Key` on every
|
|
187
|
+
request — never a wallet public key.
|
|
188
|
+
|
|
176
189
|
## Environment Variables
|
|
177
190
|
|
|
178
191
|
| Variable | Default | Description |
|
|
179
192
|
|----------|---------|-------------|
|
|
180
|
-
| `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` | Override API URL |
|
|
193
|
+
| `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` (production) | Override API URL, e.g. for local development |
|
|
181
194
|
|
|
182
195
|
## Development
|
|
183
196
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Fast wake — the relay socket is replaced within ~1s of a Fly resume, and
|
|
5
|
+
* within a few seconds even if the resume can't be seen.
|
|
6
|
+
*
|
|
7
|
+
* Production 2026-09-25: machine resumed in 0.25s, terminal back ~21s later —
|
|
8
|
+
* the bridge only noticed its dead socket on the next 20s heartbeat.
|
|
9
|
+
*/
|
|
10
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
11
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
12
|
+
class FakePty {
|
|
13
|
+
write() { }
|
|
14
|
+
resize() { }
|
|
15
|
+
kill() { }
|
|
16
|
+
onData() { }
|
|
17
|
+
onExit() { }
|
|
18
|
+
}
|
|
19
|
+
const factory = { spawn: () => new FakePty() };
|
|
20
|
+
function fakeSocket() {
|
|
21
|
+
const handlers = new Map();
|
|
22
|
+
const sock = {
|
|
23
|
+
readyState: 1, pings: 0, terminated: 0,
|
|
24
|
+
send() { }, close() { handlers.get('close')?.(1006); },
|
|
25
|
+
terminate() { sock.terminated += 1; handlers.get('close')?.(1006); },
|
|
26
|
+
on(e, cb) { handlers.set(e, cb); },
|
|
27
|
+
fire(e, ...a) { handlers.get(e)?.(...a); },
|
|
28
|
+
ping() { sock.pings += 1; },
|
|
29
|
+
};
|
|
30
|
+
return sock;
|
|
31
|
+
}
|
|
32
|
+
function start(opts = {}) {
|
|
33
|
+
const socket = fakeSocket();
|
|
34
|
+
const intervals = [];
|
|
35
|
+
const deadlines = [];
|
|
36
|
+
(0, relayClient_1.startRelayClient)({
|
|
37
|
+
relayUrl: 'ws://relay.test', relayToken: 'tok', sessions: new sessionManager_1.SessionManager(factory),
|
|
38
|
+
wsFactory: () => socket,
|
|
39
|
+
setHeartbeatIntervalFn: (cb, ms) => { intervals.push({ cb, ms }); return intervals.length; },
|
|
40
|
+
clearHeartbeatIntervalFn: () => { },
|
|
41
|
+
setPongDeadlineFn: (cb, ms) => { const d = { cb, ms, cleared: false }; deadlines.push(d); return d; },
|
|
42
|
+
clearPongDeadlineFn: (h) => { h.cleared = true; },
|
|
43
|
+
...(opts.resumed ? { resumeDetectorFactory: () => ({ check: opts.resumed }) } : {}),
|
|
44
|
+
setTimeoutFn: () => 0, clearTimeoutFn: () => { },
|
|
45
|
+
});
|
|
46
|
+
socket.fire('open');
|
|
47
|
+
const tick = (ms) => { for (const i of intervals)
|
|
48
|
+
if (i.ms === ms)
|
|
49
|
+
i.cb(); };
|
|
50
|
+
return { socket, intervals, deadlines, tick };
|
|
51
|
+
}
|
|
52
|
+
describe('fast wake: resume detection', () => {
|
|
53
|
+
it('checks for a resume every second while connected', () => {
|
|
54
|
+
const { intervals } = start({ resumed: () => false });
|
|
55
|
+
expect(intervals.some((i) => i.ms === 1000)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('a detected resume terminates the socket at once, so reconnect runs immediately', () => {
|
|
58
|
+
let resumed = false;
|
|
59
|
+
const { socket, tick } = start({ resumed: () => resumed });
|
|
60
|
+
tick(1000);
|
|
61
|
+
expect(socket.terminated).toBe(0);
|
|
62
|
+
resumed = true;
|
|
63
|
+
tick(1000);
|
|
64
|
+
expect(socket.terminated).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('fast wake: a ping must be answered within seconds', () => {
|
|
68
|
+
it('heartbeat defaults to every 5s (was 20s)', () => {
|
|
69
|
+
const { intervals } = start({ resumed: () => false });
|
|
70
|
+
expect(intervals.some((i) => i.ms === 5000)).toBe(true);
|
|
71
|
+
expect(intervals.some((i) => i.ms === 20_000)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
it('an unanswered ping is declared dead after its own 4s deadline, not a whole interval later', () => {
|
|
74
|
+
const { socket, tick, deadlines } = start({ resumed: () => false });
|
|
75
|
+
tick(5000);
|
|
76
|
+
expect(socket.pings).toBe(1);
|
|
77
|
+
const d = deadlines[deadlines.length - 1];
|
|
78
|
+
expect(d.ms).toBe(4000);
|
|
79
|
+
d.cb();
|
|
80
|
+
expect(socket.terminated).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
it('a pong cancels the deadline — a healthy link is never killed', () => {
|
|
83
|
+
const { socket, tick, deadlines } = start({ resumed: () => false });
|
|
84
|
+
tick(5000);
|
|
85
|
+
socket.fire('pong');
|
|
86
|
+
const d = deadlines[deadlines.length - 1];
|
|
87
|
+
expect(d.cleared).toBe(true);
|
|
88
|
+
d.cb(); // even if a cleared timer somehow fired
|
|
89
|
+
expect(socket.terminated).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -56,6 +56,12 @@ function startWithFakeSocket(socket, heartbeatMs) {
|
|
|
56
56
|
...(heartbeatMs === undefined ? {} : { heartbeatMs }),
|
|
57
57
|
setHeartbeatIntervalFn: (cb, ms) => { intervals.push({ cb, ms }); return intervals.length; },
|
|
58
58
|
clearHeartbeatIntervalFn: () => { },
|
|
59
|
+
// Fast wake (2026-09-27) added a per-ping deadline and a resume check;
|
|
60
|
+
// both have their own suite (bridge-relay-fast-wake.test.ts). Kept inert
|
|
61
|
+
// here so these tests still pin the original two-missed-beats rule.
|
|
62
|
+
setPongDeadlineFn: () => 0,
|
|
63
|
+
clearPongDeadlineFn: () => { },
|
|
64
|
+
resumeCheckMs: 0,
|
|
59
65
|
// Never let a real reconnect timer fire during a unit test.
|
|
60
66
|
setTimeoutFn: () => 0,
|
|
61
67
|
clearTimeoutFn: () => { },
|
|
@@ -65,7 +71,7 @@ function startWithFakeSocket(socket, heartbeatMs) {
|
|
|
65
71
|
// would be a different cadence, and isn't configured here anyway).
|
|
66
72
|
const beat = () => {
|
|
67
73
|
for (const i of intervals)
|
|
68
|
-
if (i.ms === (heartbeatMs ??
|
|
74
|
+
if (i.ms === (heartbeatMs ?? 5_000))
|
|
69
75
|
i.cb();
|
|
70
76
|
};
|
|
71
77
|
return { handle, sessions, beat, intervals };
|
|
@@ -147,8 +153,9 @@ describe('relay keepalive (twr-18)', () => {
|
|
|
147
153
|
// so the default must leave a wide margin or the self-heal races a reboot.
|
|
148
154
|
const socket = fakeSocket();
|
|
149
155
|
const { intervals, handle } = startWithFakeSocket(socket);
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
// Fast wake (2026-09-27): default 5s (was 20s), plus a 4s per-ping deadline.
|
|
157
|
+
const beatMs = intervals.find((i) => i.ms === 5_000)?.ms;
|
|
158
|
+
expect(beatMs).toBe(5_000);
|
|
152
159
|
expect(beatMs * 2).toBeLessThan(300_000);
|
|
153
160
|
handle.close();
|
|
154
161
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Fast wake — the bridge notices a Fly SUSPEND/resume at once.
|
|
5
|
+
*
|
|
6
|
+
* Production 2026-09-25: Fly resumed the machine in 0.25s, but the terminal
|
|
7
|
+
* came back ~21s later. Suspend freezes the guest (and its MONOTONIC clock,
|
|
8
|
+
* which Node timers run on), the relay drops the old socket meanwhile, and the
|
|
9
|
+
* bridge only found out on its next 20s heartbeat. A 1s tick comparing wall-
|
|
10
|
+
* clock progress with monotonic progress sees the frozen stretch immediately.
|
|
11
|
+
*/
|
|
12
|
+
const resumeDetector_1 = require("../bridge/resumeDetector");
|
|
13
|
+
function clocks() {
|
|
14
|
+
let wall = 1_000_000;
|
|
15
|
+
let mono = 5_000;
|
|
16
|
+
return {
|
|
17
|
+
wallNow: () => wall,
|
|
18
|
+
monoNow: () => mono,
|
|
19
|
+
run(ms) { wall += ms; mono += ms; }, // normal time passing
|
|
20
|
+
suspend(ms) { wall += ms; }, // VM frozen: wall moves, mono does not
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
describe('createResumeDetector', () => {
|
|
24
|
+
it('says nothing while time passes normally', () => {
|
|
25
|
+
const c = clocks();
|
|
26
|
+
const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow });
|
|
27
|
+
for (let i = 0; i < 10; i++) {
|
|
28
|
+
c.run(1000);
|
|
29
|
+
expect(d.check()).toBe(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
it('flags a resume after the machine was frozen for longer than the threshold', () => {
|
|
33
|
+
const c = clocks();
|
|
34
|
+
const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow, thresholdMs: 3000 });
|
|
35
|
+
c.run(1000);
|
|
36
|
+
expect(d.check()).toBe(false);
|
|
37
|
+
c.suspend(45 * 60_000);
|
|
38
|
+
c.run(1000);
|
|
39
|
+
expect(d.check()).toBe(true);
|
|
40
|
+
// Only once per freeze.
|
|
41
|
+
c.run(1000);
|
|
42
|
+
expect(d.check()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
it('ignores a short stall below the threshold (GC, a busy event loop)', () => {
|
|
45
|
+
const c = clocks();
|
|
46
|
+
const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow, thresholdMs: 3000 });
|
|
47
|
+
c.suspend(1500);
|
|
48
|
+
c.run(1000);
|
|
49
|
+
expect(d.check()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it('ignores the wall clock stepping BACKWARDS (an NTP correction is not a resume)', () => {
|
|
52
|
+
const c = clocks();
|
|
53
|
+
const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow });
|
|
54
|
+
c.suspend(-60_000);
|
|
55
|
+
c.run(1000);
|
|
56
|
+
expect(d.check()).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -335,6 +335,26 @@ export interface RelayClientOpts {
|
|
|
335
335
|
*/
|
|
336
336
|
setHeartbeatIntervalFn?: (cb: () => void, ms: number) => unknown;
|
|
337
337
|
clearHeartbeatIntervalFn?: (handle: unknown) => void;
|
|
338
|
+
/**
|
|
339
|
+
* Fast wake (2026-09-27) — how long a single ping may go unanswered before
|
|
340
|
+
* the socket is declared dead. Default 4000. Before this, the deadline WAS
|
|
341
|
+
* the heartbeat interval (20s), so a dead socket survived up to 40s; that is
|
|
342
|
+
* most of why a resumed machine's terminal took ~21s to come back.
|
|
343
|
+
*/
|
|
344
|
+
pongTimeoutMs?: number;
|
|
345
|
+
/** Injectable for the pong deadline only (tests). Defaults to setTimeout. */
|
|
346
|
+
setPongDeadlineFn?: (cb: () => void, ms: number) => unknown;
|
|
347
|
+
clearPongDeadlineFn?: (handle: unknown) => void;
|
|
348
|
+
/**
|
|
349
|
+
* Fast wake — how often to check for a Fly suspend/resume (resumeDetector.ts).
|
|
350
|
+
* A detected resume terminates the socket at once so the redial starts
|
|
351
|
+
* immediately. Default 1000; 0 disables. Runs on the heartbeat timer seam.
|
|
352
|
+
*/
|
|
353
|
+
resumeCheckMs?: number;
|
|
354
|
+
/** Injectable for tests. */
|
|
355
|
+
resumeDetectorFactory?: () => {
|
|
356
|
+
check(): boolean;
|
|
357
|
+
};
|
|
338
358
|
/**
|
|
339
359
|
* Rock-solid sleep/wake — how often to push the PTY inventory (`sessions`
|
|
340
360
|
* frame) while connected. Default 30_000ms.
|
|
@@ -56,6 +56,7 @@ exports.startRelayClient = startRelayClient;
|
|
|
56
56
|
* that) — on re-pair the pane sends `attach` again and gets a fresh
|
|
57
57
|
* `replay`, exactly as it would against the local server.
|
|
58
58
|
*/
|
|
59
|
+
const resumeDetector_1 = require("./resumeDetector");
|
|
59
60
|
const fs = __importStar(require("fs"));
|
|
60
61
|
const path = __importStar(require("path"));
|
|
61
62
|
const ws_1 = require("ws");
|
|
@@ -239,7 +240,18 @@ function startRelayClient(opts) {
|
|
|
239
240
|
const cancelInterval = opts.clearIntervalFn ?? ((h) => clearInterval(h));
|
|
240
241
|
// pucc-int-03 activity-tap config (enabled by default; the container bridge
|
|
241
242
|
// runs Claude Code, so observing its tool-use is the norm).
|
|
242
|
-
|
|
243
|
+
// Fast wake (2026-09-27): 5s, not 20s — with the 4s pong deadline below a
|
|
244
|
+
// dead socket is found within ~9s even when a resume goes undetected.
|
|
245
|
+
const heartbeatMs = opts.heartbeatMs ?? 5_000;
|
|
246
|
+
const pongTimeoutMs = opts.pongTimeoutMs ?? 4_000;
|
|
247
|
+
const schedulePongDeadline = opts.setPongDeadlineFn ?? ((cb, ms) => {
|
|
248
|
+
const t = setTimeout(cb, ms);
|
|
249
|
+
t.unref?.();
|
|
250
|
+
return t;
|
|
251
|
+
});
|
|
252
|
+
const cancelPongDeadline = opts.clearPongDeadlineFn ?? ((h) => clearTimeout(h));
|
|
253
|
+
const resumeCheckMs = opts.resumeCheckMs ?? 1_000;
|
|
254
|
+
const makeResumeDetector = opts.resumeDetectorFactory ?? (() => (0, resumeDetector_1.createResumeDetector)());
|
|
243
255
|
const scheduleHeartbeat = opts.setHeartbeatIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
|
|
244
256
|
const cancelHeartbeat = opts.clearHeartbeatIntervalFn ?? ((h) => clearInterval(h));
|
|
245
257
|
const sessionsReportMs = opts.sessionsReportMs ?? 30_000;
|
|
@@ -746,16 +758,38 @@ function startRelayClient(opts) {
|
|
|
746
758
|
// twr-18 relay keepalive (see RelayClientOpts#heartbeatMs). Per-connection:
|
|
747
759
|
// recreated on every dial, torn down in onDown.
|
|
748
760
|
let hbHandle = null;
|
|
761
|
+
let resumeHandle = null;
|
|
762
|
+
let pongDeadline = null;
|
|
749
763
|
let awaitingPong = false;
|
|
764
|
+
const clearPongDeadline = () => {
|
|
765
|
+
if (pongDeadline != null)
|
|
766
|
+
cancelPongDeadline(pongDeadline);
|
|
767
|
+
pongDeadline = null;
|
|
768
|
+
};
|
|
750
769
|
const stopHeartbeat = () => {
|
|
751
770
|
if (hbHandle != null)
|
|
752
771
|
cancelHeartbeat(hbHandle);
|
|
772
|
+
if (resumeHandle != null)
|
|
773
|
+
cancelHeartbeat(resumeHandle);
|
|
753
774
|
hbHandle = null;
|
|
775
|
+
resumeHandle = null;
|
|
776
|
+
clearPongDeadline();
|
|
754
777
|
awaitingPong = false;
|
|
755
778
|
};
|
|
779
|
+
// Declare this socket dead and let the close handler's reconnect run.
|
|
780
|
+
// terminate() (not close()) — a half-open socket never completes a close
|
|
781
|
+
// handshake.
|
|
782
|
+
const dropSocket = (why) => {
|
|
783
|
+
console.error(`[bridge] ${why} — reconnecting`);
|
|
784
|
+
stopHeartbeat();
|
|
785
|
+
try {
|
|
786
|
+
(socket.terminate ?? socket.close).call(socket);
|
|
787
|
+
}
|
|
788
|
+
catch { /* the close handler still runs */ }
|
|
789
|
+
};
|
|
756
790
|
// ANY inbound traffic proves the link is alive — a busy socket never needs
|
|
757
791
|
// to ping at all, and a pong is just the idle case of the same proof.
|
|
758
|
-
const markLinkAlive = () => { awaitingPong = false; };
|
|
792
|
+
const markLinkAlive = () => { awaitingPong = false; clearPongDeadline(); };
|
|
759
793
|
const startHeartbeat = () => {
|
|
760
794
|
// No ping() (test double, or a non-ws implementation) ⇒ no keepalive.
|
|
761
795
|
// Never fake it: a heartbeat that can't be answered would kill every
|
|
@@ -767,23 +801,36 @@ function startRelayClient(opts) {
|
|
|
767
801
|
return;
|
|
768
802
|
if (awaitingPong) {
|
|
769
803
|
// Previous ping went unanswered for a full interval: the server is
|
|
770
|
-
// gone even though TCP still believes otherwise.
|
|
771
|
-
|
|
772
|
-
console.error('[bridge] relay heartbeat timed out — reconnecting');
|
|
773
|
-
stopHeartbeat();
|
|
774
|
-
try {
|
|
775
|
-
(socket.terminate ?? socket.close).call(socket);
|
|
776
|
-
}
|
|
777
|
-
catch { /* the close handler still runs */ }
|
|
804
|
+
// gone even though TCP still believes otherwise.
|
|
805
|
+
dropSocket('relay heartbeat timed out');
|
|
778
806
|
return;
|
|
779
807
|
}
|
|
780
808
|
awaitingPong = true;
|
|
781
809
|
try {
|
|
782
810
|
socket.ping();
|
|
783
811
|
}
|
|
784
|
-
catch { /* a failed ping surfaces
|
|
812
|
+
catch { /* a failed ping surfaces via the deadline */ }
|
|
813
|
+
// Fast wake: the ping gets its OWN short deadline instead of waiting a
|
|
814
|
+
// whole interval for the next tick to notice.
|
|
815
|
+
clearPongDeadline();
|
|
816
|
+
pongDeadline = schedulePongDeadline(() => {
|
|
817
|
+
pongDeadline = null;
|
|
818
|
+
if (connected && awaitingPong)
|
|
819
|
+
dropSocket(`relay ping unanswered for ${pongTimeoutMs}ms`);
|
|
820
|
+
}, pongTimeoutMs);
|
|
785
821
|
}, heartbeatMs);
|
|
786
822
|
hbHandle?.unref?.();
|
|
823
|
+
// Fast wake: a Fly suspend froze this process; the socket it holds is
|
|
824
|
+
// almost certainly dead. Notice within a second of resuming and redial
|
|
825
|
+
// now, not on the next heartbeat (resumeDetector.ts).
|
|
826
|
+
if (resumeCheckMs > 0) {
|
|
827
|
+
const detector = makeResumeDetector();
|
|
828
|
+
resumeHandle = scheduleHeartbeat(() => {
|
|
829
|
+
if (connected && detector.check())
|
|
830
|
+
dropSocket('machine resumed from suspend');
|
|
831
|
+
}, resumeCheckMs);
|
|
832
|
+
resumeHandle?.unref?.();
|
|
833
|
+
}
|
|
787
834
|
};
|
|
788
835
|
socket.on('pong', markLinkAlive);
|
|
789
836
|
socket.on('open', () => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast wake: has this machine just been RESUMED from a Fly suspend?
|
|
3
|
+
*
|
|
4
|
+
* Suspend freezes the whole guest, including the monotonic clock Node's
|
|
5
|
+
* timers run on, while real (wall-clock) time keeps going. On resume the wall
|
|
6
|
+
* clock is corrected forward and the monotonic clock simply continues. So in
|
|
7
|
+
* one short tick, wall-clock progress far exceeding monotonic progress means
|
|
8
|
+
* the process was frozen, and every socket it held is almost certainly dead
|
|
9
|
+
* (the relay drops a silent socket within ~90s).
|
|
10
|
+
*
|
|
11
|
+
* Production 2026-09-25: Fly resumed in 0.25s but the terminal took ~21s,
|
|
12
|
+
* because the bridge only learned its socket was dead on the next 20s
|
|
13
|
+
* heartbeat. relayClient checks this every second and redials at once.
|
|
14
|
+
* (browserIdle.ts relies on the same clock property for the browser.)
|
|
15
|
+
*
|
|
16
|
+
* Pure: both clocks injectable.
|
|
17
|
+
*/
|
|
18
|
+
export interface ResumeDetectorOpts {
|
|
19
|
+
/** Wall-clock gap beyond monotonic progress that counts as a freeze. */
|
|
20
|
+
thresholdMs?: number;
|
|
21
|
+
wallNow?: () => number;
|
|
22
|
+
monoNow?: () => number;
|
|
23
|
+
}
|
|
24
|
+
export interface ResumeDetector {
|
|
25
|
+
/** True exactly once after a freeze longer than the threshold. */
|
|
26
|
+
check(): boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function createResumeDetector(opts?: ResumeDetectorOpts): ResumeDetector;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createResumeDetector = createResumeDetector;
|
|
4
|
+
const defaultMono = () => Number(process.hrtime.bigint() / 1000000n);
|
|
5
|
+
function createResumeDetector(opts = {}) {
|
|
6
|
+
const threshold = opts.thresholdMs ?? 3000;
|
|
7
|
+
const wallNow = opts.wallNow ?? Date.now;
|
|
8
|
+
const monoNow = opts.monoNow ?? defaultMono;
|
|
9
|
+
let lastWall = wallNow();
|
|
10
|
+
let lastMono = monoNow();
|
|
11
|
+
return {
|
|
12
|
+
check() {
|
|
13
|
+
const w = wallNow();
|
|
14
|
+
const m = monoNow();
|
|
15
|
+
const frozenFor = (w - lastWall) - (m - lastMono);
|
|
16
|
+
lastWall = w;
|
|
17
|
+
lastMono = m;
|
|
18
|
+
// Only a FORWARD jump: a backwards NTP step is not a resume.
|
|
19
|
+
return frozenFor > threshold;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenrwa/marketplace-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "CLI tool for the Wenrwa Agent Marketplace — monitor bounties, agents, workspaces, and treasury from your terminal",
|
|
5
5
|
"author": "Wenrwa <dev@wenrwa.com>",
|
|
6
6
|
"license": "MIT",
|