pi-freeflow 1.11.0 → 1.11.2
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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client.ts +17 -0
- package/src/daemon.ts +6 -0
- package/src/index.ts +9 -2
- package/src/lease.ts +116 -116
- package/src/logger.ts +10 -2
- package/src/proxy.ts +2 -2
- package/src/relay.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.11.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 549b66e: Oversized requests no longer fail at the relay hop: when a relay answers 413 payload limit, the proxy transparently tries the next relay and then the direct route, keeping the stream alive. Long sessions that outgrow the relay payload cap now complete instead of surfacing a function payload error.
|
|
8
|
+
|
|
9
|
+
## 1.11.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Apologies for the daemon disconnect bug introduced in v1.11.0: in multi-agent workflows or when subagents, evaluations, and background tasks completed, the extension prematurely detached from the local proxy daemon, causing the proxy to shut down while your main session remained active. The heartbeat connection now persists throughout your active terminal session.
|
|
14
|
+
- Increased local health check and liveness probe timeouts from 800ms and 500ms to 2500ms and 1500ms, with an automatic probe retry to avoid false-alarm daemon restarts during heavy concurrent streaming.
|
|
15
|
+
- Added unhandled error logging inside the daemon to prevent silent process exits.
|
|
16
|
+
- Fresh installs now log full HTTP lifecycle debug output by default so diagnostic reports contain complete request context; turn it off anytime with `/freeflow debug off`.
|
|
17
|
+
|
|
3
18
|
## 1.11.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -250,7 +250,7 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 20
|
|
|
250
250
|
/freeflow logs
|
|
251
251
|
cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
252
252
|
|
|
253
|
-
# debug toggle
|
|
253
|
+
# debug toggle (full debug is on by default for complete error reports; `off` restores info)
|
|
254
254
|
/freeflow debug on
|
|
255
255
|
```
|
|
256
256
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.11.
|
|
4
|
+
"version": "1.11.2",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/client.ts
CHANGED
|
@@ -349,6 +349,14 @@ export async function watchdogCheck(port: number): Promise<void> {
|
|
|
349
349
|
} catch {
|
|
350
350
|
return;
|
|
351
351
|
}
|
|
352
|
+
if (health === null) {
|
|
353
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
354
|
+
try {
|
|
355
|
+
health = await getDaemonHealth(port);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
352
360
|
if (health && (health.activeRequests ?? 0) > 0) {
|
|
353
361
|
trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
|
|
354
362
|
} else {
|
|
@@ -566,6 +574,15 @@ export function getClientId(): string {
|
|
|
566
574
|
return CLIENT_ID;
|
|
567
575
|
}
|
|
568
576
|
|
|
577
|
+
export function hasFallbackServer(): boolean {
|
|
578
|
+
return fallbackServer !== null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Test seam: true when client heartbeat timer is active. */
|
|
582
|
+
export function isHeartbeatActive(): boolean {
|
|
583
|
+
return heartbeatTimer !== null;
|
|
584
|
+
}
|
|
585
|
+
|
|
569
586
|
export function _resetClientForTest(): void {
|
|
570
587
|
stopHeartbeatInternal();
|
|
571
588
|
heartbeatPort = 0;
|
package/src/daemon.ts
CHANGED
|
@@ -98,6 +98,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
98
98
|
};
|
|
99
99
|
process.on("SIGTERM", () => retire("SIGTERM"));
|
|
100
100
|
process.on("SIGINT", () => retire("SIGINT"));
|
|
101
|
+
process.on("uncaughtException", (err) => {
|
|
102
|
+
log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack });
|
|
103
|
+
});
|
|
104
|
+
process.on("unhandledRejection", (reason) => {
|
|
105
|
+
log("error", "daemon unhandledRejection", { error: String(reason) });
|
|
106
|
+
});
|
|
101
107
|
setShutdownShouldExit(true);
|
|
102
108
|
try {
|
|
103
109
|
const r = await startProxy();
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
refreshCatalog,
|
|
17
17
|
setAliveCatalog,
|
|
18
18
|
} from "./catalog.ts";
|
|
19
|
-
import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
|
|
19
|
+
import { ensureDaemon as ensureClientDaemon, getClientPort, hasFallbackServer, stopHeartbeat } from "./client.ts";
|
|
20
20
|
import { createCommandSpec, stopLogsFollow, updateStatusBar } from "./commands.ts";
|
|
21
21
|
import { HOST, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
22
22
|
import { logInfo, logWarn } from "./logger.ts";
|
|
@@ -328,7 +328,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
328
328
|
}
|
|
329
329
|
});
|
|
330
330
|
pi.on?.("session_shutdown", () => {
|
|
331
|
-
stopHeartbeat();
|
|
332
331
|
stopLogsFollow();
|
|
332
|
+
if (hasFallbackServer()) {
|
|
333
|
+
stopHeartbeat();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
process.once("exit", () => {
|
|
337
|
+
try {
|
|
338
|
+
stopHeartbeat();
|
|
339
|
+
} catch {}
|
|
333
340
|
});
|
|
334
341
|
}
|
package/src/lease.ts
CHANGED
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client lease registry for the detached pi-freeflow proxy daemon.
|
|
3
|
-
*
|
|
4
|
-
* Each OMP/Pi session is a client that registers a lease and renews it with a
|
|
5
|
-
* heartbeat while alive. The daemon drops expired leases and retires itself
|
|
6
|
-
* once NO client holds a live lease: the empty state must persist for the
|
|
7
|
-
* grace window (a fresh spawn's clients re-attach within seconds) with
|
|
8
|
-
* nothing in flight. Request-idleness alone NEVER retires the daemon.
|
|
9
|
-
*
|
|
10
|
-
* The request-touch (`lastActivityAt`) is still recorded and surfaced in
|
|
11
|
-
* /_health for observability, but it no longer gates retirement.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface LeaseOptions {
|
|
15
|
-
/** Lease lifetime (ms); a client that misses ~3 beats is dropped. */
|
|
16
|
-
ttlMs: number;
|
|
17
|
-
/** GC sweep interval (ms). */
|
|
18
|
-
gcMs: number;
|
|
19
|
-
/** Zero-lease persistence window (ms): how long leases must stay empty before a lease-less daemon exits. */
|
|
20
|
-
graceMs: number;
|
|
21
|
-
/** Current in-flight proxied requests — daemon never exits mid-stream. */
|
|
22
|
-
getActiveRequests: () => number;
|
|
23
|
-
/** Called once when the daemon should retire (close server + exit). */
|
|
24
|
-
onIdle: () => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const leases = new Map<string, number>();
|
|
28
|
-
let lastActivityAt =
|
|
29
|
-
let gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
-
/** First sweep timestamp at which leases were observed empty; null while any lease exists. */
|
|
31
|
-
let emptySince: number | null = null;
|
|
32
|
-
|
|
33
|
-
/** Register or refresh a client lease. */
|
|
34
|
-
export function registerClient(clientId: string): void {
|
|
35
|
-
leases.set(clientId, Date.now());
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Renew an existing client lease. Returns false when the id is unknown (daemon restarted). */
|
|
39
|
-
export function renewClient(clientId: string): boolean {
|
|
40
|
-
if (leases.has(clientId)) {
|
|
41
|
-
leases.set(clientId, Date.now());
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Remove a client lease (graceful detach on session end). */
|
|
48
|
-
export function unregisterClient(clientId: string): void {
|
|
49
|
-
leases.delete(clientId);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Number of clients holding a live lease. */
|
|
53
|
-
export function getLeaseCount(): number {
|
|
54
|
-
return leases.size;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Snapshot of live client leases (id -> lastSeenAt) for health/debugging. */
|
|
58
|
-
export function getLeaseSnapshot(): Record<string, number> {
|
|
59
|
-
return Object.fromEntries(leases);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Record proxy activity — any proxied request counts as a live user. */
|
|
63
|
-
export function touchActivity(): void {
|
|
64
|
-
lastActivityAt = Date.now();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Timestamp of the last proxied request (0 = never; daemon inits at bind). */
|
|
68
|
-
export function getLastActivityAt(): number {
|
|
69
|
-
return lastActivityAt;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Start the lease GC sweep. Prunes expired leases and, when no client holds a
|
|
74
|
-
* lease, nothing is in flight, and the lease-less state has persisted for the
|
|
75
|
-
* grace window, invokes `onIdle` (the daemon retires). Idempotent — a second
|
|
76
|
-
* call is a no-op.
|
|
77
|
-
*/
|
|
78
|
-
export function startLeaseGC(opts: LeaseOptions): void {
|
|
79
|
-
if (gcTimer !== null) return;
|
|
80
|
-
gcTimer = setInterval(() => {
|
|
81
|
-
const now = Date.now();
|
|
82
|
-
for (const [id, seenAt] of leases) {
|
|
83
|
-
if (now - seenAt > opts.ttlMs) {
|
|
84
|
-
leases.delete(id);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (leases.size > 0) {
|
|
88
|
-
emptySince = null;
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (emptySince === null) {
|
|
92
|
-
emptySince = now;
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (opts.getActiveRequests() === 0 && now - emptySince >= opts.graceMs) {
|
|
96
|
-
stopLeaseGC();
|
|
97
|
-
opts.onIdle();
|
|
98
|
-
}
|
|
99
|
-
}, opts.gcMs);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Stop the GC sweep (test teardown / daemon shutdown). */
|
|
103
|
-
export function stopLeaseGC(): void {
|
|
104
|
-
if (gcTimer !== null) {
|
|
105
|
-
clearInterval(gcTimer);
|
|
106
|
-
gcTimer = null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Test-only: reset all lease state. */
|
|
111
|
-
export function _resetLeaseStateForTest(): void {
|
|
112
|
-
leases.clear();
|
|
113
|
-
lastActivityAt =
|
|
114
|
-
emptySince = null;
|
|
115
|
-
stopLeaseGC();
|
|
116
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Client lease registry for the detached pi-freeflow proxy daemon.
|
|
3
|
+
*
|
|
4
|
+
* Each OMP/Pi session is a client that registers a lease and renews it with a
|
|
5
|
+
* heartbeat while alive. The daemon drops expired leases and retires itself
|
|
6
|
+
* once NO client holds a live lease: the empty state must persist for the
|
|
7
|
+
* grace window (a fresh spawn's clients re-attach within seconds) with
|
|
8
|
+
* nothing in flight. Request-idleness alone NEVER retires the daemon.
|
|
9
|
+
*
|
|
10
|
+
* The request-touch (`lastActivityAt`) is still recorded and surfaced in
|
|
11
|
+
* /_health for observability, but it no longer gates retirement.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface LeaseOptions {
|
|
15
|
+
/** Lease lifetime (ms); a client that misses ~3 beats is dropped. */
|
|
16
|
+
ttlMs: number;
|
|
17
|
+
/** GC sweep interval (ms). */
|
|
18
|
+
gcMs: number;
|
|
19
|
+
/** Zero-lease persistence window (ms): how long leases must stay empty before a lease-less daemon exits. */
|
|
20
|
+
graceMs: number;
|
|
21
|
+
/** Current in-flight proxied requests — daemon never exits mid-stream. */
|
|
22
|
+
getActiveRequests: () => number;
|
|
23
|
+
/** Called once when the daemon should retire (close server + exit). */
|
|
24
|
+
onIdle: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const leases = new Map<string, number>();
|
|
28
|
+
let lastActivityAt = 0;
|
|
29
|
+
let gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
/** First sweep timestamp at which leases were observed empty; null while any lease exists. */
|
|
31
|
+
let emptySince: number | null = null;
|
|
32
|
+
|
|
33
|
+
/** Register or refresh a client lease. */
|
|
34
|
+
export function registerClient(clientId: string): void {
|
|
35
|
+
leases.set(clientId, Date.now());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Renew an existing client lease. Returns false when the id is unknown (daemon restarted). */
|
|
39
|
+
export function renewClient(clientId: string): boolean {
|
|
40
|
+
if (leases.has(clientId)) {
|
|
41
|
+
leases.set(clientId, Date.now());
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Remove a client lease (graceful detach on session end). */
|
|
48
|
+
export function unregisterClient(clientId: string): void {
|
|
49
|
+
leases.delete(clientId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Number of clients holding a live lease. */
|
|
53
|
+
export function getLeaseCount(): number {
|
|
54
|
+
return leases.size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Snapshot of live client leases (id -> lastSeenAt) for health/debugging. */
|
|
58
|
+
export function getLeaseSnapshot(): Record<string, number> {
|
|
59
|
+
return Object.fromEntries(leases);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Record proxy activity — any proxied request counts as a live user. */
|
|
63
|
+
export function touchActivity(): void {
|
|
64
|
+
lastActivityAt = Date.now();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Timestamp of the last proxied request (0 = never; daemon inits at bind). */
|
|
68
|
+
export function getLastActivityAt(): number {
|
|
69
|
+
return lastActivityAt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Start the lease GC sweep. Prunes expired leases and, when no client holds a
|
|
74
|
+
* lease, nothing is in flight, and the lease-less state has persisted for the
|
|
75
|
+
* grace window, invokes `onIdle` (the daemon retires). Idempotent — a second
|
|
76
|
+
* call is a no-op.
|
|
77
|
+
*/
|
|
78
|
+
export function startLeaseGC(opts: LeaseOptions): void {
|
|
79
|
+
if (gcTimer !== null) return;
|
|
80
|
+
gcTimer = setInterval(() => {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
for (const [id, seenAt] of leases) {
|
|
83
|
+
if (now - seenAt > opts.ttlMs) {
|
|
84
|
+
leases.delete(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (leases.size > 0) {
|
|
88
|
+
emptySince = null;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (emptySince === null) {
|
|
92
|
+
emptySince = now;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (opts.getActiveRequests() === 0 && now - emptySince >= opts.graceMs) {
|
|
96
|
+
stopLeaseGC();
|
|
97
|
+
opts.onIdle();
|
|
98
|
+
}
|
|
99
|
+
}, opts.gcMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Stop the GC sweep (test teardown / daemon shutdown). */
|
|
103
|
+
export function stopLeaseGC(): void {
|
|
104
|
+
if (gcTimer !== null) {
|
|
105
|
+
clearInterval(gcTimer);
|
|
106
|
+
gcTimer = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Test-only: reset all lease state. */
|
|
111
|
+
export function _resetLeaseStateForTest(): void {
|
|
112
|
+
leases.clear();
|
|
113
|
+
lastActivityAt = 0;
|
|
114
|
+
emptySince = null;
|
|
115
|
+
stopLeaseGC();
|
|
116
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -128,7 +128,8 @@ export function getMinLogLevel(): number {
|
|
|
128
128
|
return LOG_LEVEL_ORDER[dbg.level];
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const rawEnv = process.env.FREEFLOW_LOG_LEVEL;
|
|
132
|
+
const raw = (typeof rawEnv === "string" ? rawEnv : "").toLowerCase();
|
|
132
133
|
|
|
133
134
|
if (isEmittedLogLevel(raw)) {
|
|
134
135
|
return LOG_LEVEL_ORDER[raw as LogLevel];
|
|
@@ -142,7 +143,14 @@ export function getMinLogLevel(): number {
|
|
|
142
143
|
return LOG_LEVEL_ORDER.debug;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
// Fresh installs default to full debug so users can attach complete
|
|
147
|
+
// request lifecycles when reporting errors (10MB rotation bounds volume).
|
|
148
|
+
// An explicit persisted off state or env level above still wins, so
|
|
149
|
+
// /freeflow debug off keeps working on every platform.
|
|
150
|
+
if (dbg !== null) {
|
|
151
|
+
return LOG_LEVEL_ORDER.info;
|
|
152
|
+
}
|
|
153
|
+
return LOG_LEVEL_ORDER.debug;
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
export function shouldLog(level: LogLevel): boolean {
|
package/src/proxy.ts
CHANGED
|
@@ -147,7 +147,7 @@ export async function isProxyAlive(port: number): Promise<boolean> {
|
|
|
147
147
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
148
148
|
try {
|
|
149
149
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
150
|
-
signal: AbortSignal.timeout(
|
|
150
|
+
signal: AbortSignal.timeout(1500),
|
|
151
151
|
});
|
|
152
152
|
const ct = res.headers.get("content-type") || "";
|
|
153
153
|
return res.ok && ct.includes("application/json");
|
|
@@ -182,7 +182,7 @@ export async function getDaemonHealth(
|
|
|
182
182
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
183
183
|
try {
|
|
184
184
|
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
185
|
-
signal: AbortSignal.timeout(
|
|
185
|
+
signal: AbortSignal.timeout(2500),
|
|
186
186
|
});
|
|
187
187
|
if (!res.ok) return null;
|
|
188
188
|
const data: unknown = await res.json();
|
package/src/relay.ts
CHANGED
|
@@ -155,6 +155,28 @@ export async function relayFetch(
|
|
|
155
155
|
}
|
|
156
156
|
break;
|
|
157
157
|
}
|
|
158
|
+
// Relay payload cap hit (413: request exceeds host payload limit):
|
|
159
|
+
// Not a relay health signal, so no failure marking — try the next
|
|
160
|
+
// relay (a different host may accept it), else the direct fallback.
|
|
161
|
+
if (res.status === 413) {
|
|
162
|
+
lastResponse?.body?.cancel().catch(() => {});
|
|
163
|
+
lastResponse = res;
|
|
164
|
+
log(
|
|
165
|
+
"warn",
|
|
166
|
+
`relay ${targetUrl} hit HTTP 413 payload limit in ${elapsed}s — trying next path`,
|
|
167
|
+
{ upstream: url, sizeKB: bodySizeKB },
|
|
168
|
+
rid,
|
|
169
|
+
);
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
if (now - lastRollNotify > ROLL_NOTIFY_MS) {
|
|
172
|
+
lastRollNotify = now;
|
|
173
|
+
const ui = getStatusUi();
|
|
174
|
+
if (ui?.notify) {
|
|
175
|
+
ui.notify(`relay ${shortRelayLabel(targetUrl)} hit payload limit — trying next path`, "warning");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
158
180
|
|
|
159
181
|
// Relay host infrastructure 404 (e.g. Vercel DEPLOYMENT_NOT_FOUND or non-JSON 404):
|
|
160
182
|
// When a relay URL is deleted, misconfigured, or has no deployment, Vercel/Cloudflare
|