ag-ai 0.4.1 → 0.4.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/package.json +2 -2
- package/runtime/bridge.mjs +32 -18
- package/runtime/push.mjs +43 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ag-ai",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Provider-neutral AI client and cluster runtime for the rpi-mega cluster.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "admin@gec.dev",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"dynamodb-reactive": "^0.
|
|
28
|
+
"dynamodb-reactive": "^0.5.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@kubernetes/client-node": "2.0.0",
|
package/runtime/bridge.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { createOpenCodeExecutor, DEFAULT_OPENCODE_URL } from './executor/opencode.mjs';
|
|
11
11
|
import { createImageAdapter } from './image/adapter.mjs';
|
|
12
12
|
import { normalizeGenerationRequest } from './protocol.mjs';
|
|
13
|
-
import { createWorkSignal, startPushChannel } from './push.mjs';
|
|
13
|
+
import { createStatusReporter, createWorkSignal, startPushChannel } from './push.mjs';
|
|
14
14
|
import { createLocalRunner, startGenerationServer } from './server.mjs';
|
|
15
15
|
import { GenerationStore } from './store.mjs';
|
|
16
16
|
import { AnalyticaEvents } from './telemetry.mjs';
|
|
@@ -25,7 +25,7 @@ const DEFAULT_GENERATION_STORE = '/generation-jobs';
|
|
|
25
25
|
const DEFAULT_GENERATION_TOKEN_FILE = '/var/run/secrets/ag-ai-generation/token';
|
|
26
26
|
const DEFAULT_ANALYTICA_TOKEN_FILE = '/var/run/secrets/ag-ai-analytica/token';
|
|
27
27
|
const DEFAULT_IMAGE_PROVIDER = 'openai';
|
|
28
|
-
const
|
|
28
|
+
const STATUS_INTERVAL_MS = 10_000;
|
|
29
29
|
const LEASE_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
30
30
|
const HEALTH_INTERVAL_MS = 30_000;
|
|
31
31
|
const CLAIM_INTERVAL_MS = 5_000;
|
|
@@ -921,21 +921,29 @@ const main = async () => {
|
|
|
921
921
|
return state.healthPromise;
|
|
922
922
|
};
|
|
923
923
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
924
|
+
// The queue learns the runner is alive from the push socket's pings, so status is
|
|
925
|
+
// sent only when it changes or the socket reconnects, never on a timer.
|
|
926
|
+
const statusReporter = createStatusReporter(({ activeJobs, serverReady, modelReady }) =>
|
|
927
|
+
runner.heartbeat(activeJobs, serverReady, modelReady),
|
|
928
|
+
);
|
|
929
|
+
let statusPromise;
|
|
930
|
+
const reportStatus = async () => {
|
|
931
|
+
if (statusPromise) {
|
|
932
|
+
return statusPromise;
|
|
928
933
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
934
|
+
statusPromise = statusReporter
|
|
935
|
+
.update({
|
|
936
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
|
|
937
|
+
activeJobs: state.job ? 1 : 0,
|
|
938
|
+
serverReady: state.health.serverReady,
|
|
939
|
+
modelReady: state.health.modelReady,
|
|
940
|
+
})
|
|
933
941
|
.catch((error) => {
|
|
934
|
-
console.error(`[runner] runner
|
|
935
|
-
return false;
|
|
942
|
+
console.error(`[runner] runner status report failed: ${redact(error, [config.token])}`);
|
|
936
943
|
})
|
|
937
|
-
.then(async (
|
|
944
|
+
.then(async () => {
|
|
938
945
|
// Probe data is private to this sidecar and contains no credentials.
|
|
946
|
+
const workerReachable = push.connected();
|
|
939
947
|
const backendReady = state.health.serverReady && state.health.modelReady;
|
|
940
948
|
const lanReady = generationServer !== undefined && backendReady;
|
|
941
949
|
await writeFile(
|
|
@@ -950,9 +958,9 @@ const main = async () => {
|
|
|
950
958
|
})
|
|
951
959
|
.catch(() => console.error('[runner] could not update local health probe'))
|
|
952
960
|
.finally(() => {
|
|
953
|
-
|
|
961
|
+
statusPromise = undefined;
|
|
954
962
|
});
|
|
955
|
-
return
|
|
963
|
+
return statusPromise;
|
|
956
964
|
};
|
|
957
965
|
|
|
958
966
|
let shutdownPromise;
|
|
@@ -981,9 +989,15 @@ const main = async () => {
|
|
|
981
989
|
baseUrl: new URL(config.queueUrl).origin,
|
|
982
990
|
token: config.token,
|
|
983
991
|
onWork: () => workSignal.wake(),
|
|
992
|
+
// A fresh connection may follow a queue restart, so the status is sent again.
|
|
993
|
+
onConnect: () => {
|
|
994
|
+
statusReporter.reset();
|
|
995
|
+
void reportStatus();
|
|
996
|
+
},
|
|
984
997
|
log: (message) => console.log(`[runner] ${redact(message, [config.token])}`),
|
|
985
998
|
});
|
|
986
|
-
|
|
999
|
+
// Local only: refreshes the health probe and sends status if it changed.
|
|
1000
|
+
const statusTimer = setInterval(() => void reportStatus(), STATUS_INTERVAL_MS);
|
|
987
1001
|
const healthTimer = setInterval(() => void refreshHealth(), HEALTH_INTERVAL_MS);
|
|
988
1002
|
const claimLocal = async () => {
|
|
989
1003
|
if (!generationStore) return undefined;
|
|
@@ -1026,7 +1040,7 @@ const main = async () => {
|
|
|
1026
1040
|
let preferLocal = false;
|
|
1027
1041
|
try {
|
|
1028
1042
|
await refreshHealth();
|
|
1029
|
-
await
|
|
1043
|
+
await reportStatus();
|
|
1030
1044
|
while (!state.stopping) {
|
|
1031
1045
|
if (!state.health.serverReady || !state.health.modelReady) {
|
|
1032
1046
|
await refreshHealth();
|
|
@@ -1067,7 +1081,7 @@ const main = async () => {
|
|
|
1067
1081
|
await jobPromise;
|
|
1068
1082
|
}
|
|
1069
1083
|
} finally {
|
|
1070
|
-
clearInterval(
|
|
1084
|
+
clearInterval(statusTimer);
|
|
1071
1085
|
clearInterval(healthTimer);
|
|
1072
1086
|
push.stop();
|
|
1073
1087
|
await stop('shutdown');
|
package/runtime/push.mjs
CHANGED
|
@@ -43,10 +43,13 @@ export const createWorkSignal = () => {
|
|
|
43
43
|
* lists queued work. Never throws: failures are logged and the connection keeps
|
|
44
44
|
* retrying, and the reconnect snapshot catches up on anything queued meanwhile.
|
|
45
45
|
*/
|
|
46
|
-
export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
|
|
46
|
+
export const startPushChannel = ({ baseUrl, token, onWork, onConnect, log, connect }) => {
|
|
47
47
|
const controller = new AbortController();
|
|
48
48
|
let client;
|
|
49
49
|
let subscription;
|
|
50
|
+
// The socket is the runner's liveness: the hub sees its pings, and the client
|
|
51
|
+
// replaces it when a ping goes unanswered, so this flag tracks real reachability.
|
|
52
|
+
let isConnected = false;
|
|
50
53
|
const ready = (async () => {
|
|
51
54
|
const connectClusterWorker =
|
|
52
55
|
connect ?? (await import('dynamodb-reactive/cluster')).connectClusterWorker;
|
|
@@ -57,7 +60,16 @@ export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
|
|
|
57
60
|
httpPath: HTTP_PATH,
|
|
58
61
|
signal: controller.signal,
|
|
59
62
|
onRetry: (error) => log(`push connect failed; retrying: ${error.message}`),
|
|
60
|
-
client: {
|
|
63
|
+
client: {
|
|
64
|
+
onError: (error) => log(`push socket error: ${error.message}`),
|
|
65
|
+
onConnect: () => {
|
|
66
|
+
isConnected = true;
|
|
67
|
+
onConnect?.();
|
|
68
|
+
},
|
|
69
|
+
onDisconnect: () => {
|
|
70
|
+
isConnected = false;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
61
73
|
});
|
|
62
74
|
if (controller.signal.aborted) {
|
|
63
75
|
client.disconnect();
|
|
@@ -75,6 +87,7 @@ export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
|
|
|
75
87
|
});
|
|
76
88
|
return {
|
|
77
89
|
ready,
|
|
90
|
+
connected: () => isConnected,
|
|
78
91
|
stop: () => {
|
|
79
92
|
controller.abort();
|
|
80
93
|
subscription?.unsubscribe();
|
|
@@ -82,3 +95,31 @@ export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
|
|
|
82
95
|
},
|
|
83
96
|
};
|
|
84
97
|
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sends the runner's status only when it changes, or after `reset` (each push
|
|
101
|
+
* reconnect), instead of on a timer: the push socket already proves liveness.
|
|
102
|
+
* A failed send is retried by the next `update`.
|
|
103
|
+
*/
|
|
104
|
+
export const createStatusReporter = (send) => {
|
|
105
|
+
let reported;
|
|
106
|
+
let inFlight;
|
|
107
|
+
return {
|
|
108
|
+
update: (status) => {
|
|
109
|
+
const key = JSON.stringify(status);
|
|
110
|
+
if (inFlight) return inFlight;
|
|
111
|
+
if (key === reported) return Promise.resolve();
|
|
112
|
+
inFlight = send(status)
|
|
113
|
+
.then(() => {
|
|
114
|
+
reported = key;
|
|
115
|
+
})
|
|
116
|
+
.finally(() => {
|
|
117
|
+
inFlight = undefined;
|
|
118
|
+
});
|
|
119
|
+
return inFlight;
|
|
120
|
+
},
|
|
121
|
+
reset: () => {
|
|
122
|
+
reported = undefined;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|