ag-ai 0.4.0 → 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/dist/k8s/runner.js +0 -3
- package/package.json +2 -2
- package/runtime/bridge.mjs +40 -38
- package/runtime/push.mjs +56 -43
package/dist/k8s/runner.js
CHANGED
|
@@ -40,8 +40,6 @@ const runnerStack = (context) => {
|
|
|
40
40
|
throw new Error('AG_AI_GENERATION_CIDR is required');
|
|
41
41
|
const imageProvider = context.env.AG_AI_IMAGE_PROVIDER ?? 'openai';
|
|
42
42
|
const opencodeProvider = context.env.AG_AI_OPENCODE_PROVIDER;
|
|
43
|
-
// '1' makes the bridge claim cloud work when the queue pushes it (see runtime/push.mjs).
|
|
44
|
-
const push = context.env.AG_AI_PUSH;
|
|
45
43
|
const agAiPackage = context.env.AG_AI_PACKAGE;
|
|
46
44
|
if (!agAiPackage)
|
|
47
45
|
throw new Error('AG_AI_PACKAGE is required');
|
|
@@ -95,7 +93,6 @@ const runnerStack = (context) => {
|
|
|
95
93
|
(0, common_1.env)('AG_AI_OPENCODE_URL', opencodeUrl),
|
|
96
94
|
(0, common_1.env)('AG_AI_OPENCODE_PASSWORD_FILE', secrets.opencode.file),
|
|
97
95
|
...(opencodeProvider === undefined ? [] : [(0, common_1.env)('AG_AI_OPENCODE_PROVIDER', opencodeProvider)]),
|
|
98
|
-
...(push === undefined ? [] : [(0, common_1.env)('AG_AI_PUSH', push)]),
|
|
99
96
|
(0, common_1.env)('AG_AI_IMAGE_PROVIDER', imageProvider),
|
|
100
97
|
(0, common_1.env)('AG_AI_IMAGE_API_KEY_FILE', secrets.image.file),
|
|
101
98
|
(0, common_1.env)('AG_AI_RUNNER_ID', common_1.RUNNER_APP),
|
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 {
|
|
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;
|
|
@@ -828,12 +828,6 @@ const readConfig = async (env = process.env) => {
|
|
|
828
828
|
generationPort: env.AG_AI_GENERATION_PORT ?? DEFAULT_GENERATION_PORT,
|
|
829
829
|
generationStore: path.resolve(env.AG_AI_GENERATION_STORE ?? DEFAULT_GENERATION_STORE),
|
|
830
830
|
generationTokenFile: env.AG_AI_GENERATION_TOKEN_FILE ?? DEFAULT_GENERATION_TOKEN_FILE,
|
|
831
|
-
// AG_AI_PUSH=1 claims cloud work when the queue pushes it instead of every few seconds.
|
|
832
|
-
pushEnabled: env.AG_AI_PUSH === '1',
|
|
833
|
-
pushTicketPath: env.AG_AI_PUSH_TICKET_PATH,
|
|
834
|
-
pushHttpPath: env.AG_AI_PUSH_HTTP_PATH,
|
|
835
|
-
// How long a healthy push channel goes without a safety-net claim.
|
|
836
|
-
pushFallbackMs: parsePushFallbackMs(env.AG_AI_PUSH_FALLBACK_MS),
|
|
837
831
|
analyticaToken,
|
|
838
832
|
token,
|
|
839
833
|
};
|
|
@@ -927,21 +921,29 @@ const main = async () => {
|
|
|
927
921
|
return state.healthPromise;
|
|
928
922
|
};
|
|
929
923
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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;
|
|
934
933
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
+
})
|
|
939
941
|
.catch((error) => {
|
|
940
|
-
console.error(`[runner] runner
|
|
941
|
-
return false;
|
|
942
|
+
console.error(`[runner] runner status report failed: ${redact(error, [config.token])}`);
|
|
942
943
|
})
|
|
943
|
-
.then(async (
|
|
944
|
+
.then(async () => {
|
|
944
945
|
// Probe data is private to this sidecar and contains no credentials.
|
|
946
|
+
const workerReachable = push.connected();
|
|
945
947
|
const backendReady = state.health.serverReady && state.health.modelReady;
|
|
946
948
|
const lanReady = generationServer !== undefined && backendReady;
|
|
947
949
|
await writeFile(
|
|
@@ -956,9 +958,9 @@ const main = async () => {
|
|
|
956
958
|
})
|
|
957
959
|
.catch(() => console.error('[runner] could not update local health probe'))
|
|
958
960
|
.finally(() => {
|
|
959
|
-
|
|
961
|
+
statusPromise = undefined;
|
|
960
962
|
});
|
|
961
|
-
return
|
|
963
|
+
return statusPromise;
|
|
962
964
|
};
|
|
963
965
|
|
|
964
966
|
let shutdownPromise;
|
|
@@ -981,21 +983,21 @@ const main = async () => {
|
|
|
981
983
|
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
982
984
|
process.once('SIGINT', () => void stop('SIGINT'));
|
|
983
985
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
986
|
+
// The queue pushes queued work over a WebSocket; cloud claims follow pushes.
|
|
987
|
+
const workSignal = createWorkSignal();
|
|
988
|
+
const push = startPushChannel({
|
|
989
|
+
baseUrl: new URL(config.queueUrl).origin,
|
|
990
|
+
token: config.token,
|
|
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
|
+
},
|
|
997
|
+
log: (message) => console.log(`[runner] ${redact(message, [config.token])}`),
|
|
987
998
|
});
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
baseUrl: new URL(config.queueUrl).origin,
|
|
991
|
-
token: config.token,
|
|
992
|
-
ticketPath: config.pushTicketPath,
|
|
993
|
-
httpPath: config.pushHttpPath,
|
|
994
|
-
onWork: () => workSignal.wake(),
|
|
995
|
-
log: (message) => console.log(`[runner] ${redact(message, [config.token])}`),
|
|
996
|
-
})
|
|
997
|
-
: undefined;
|
|
998
|
-
const heartbeatTimer = setInterval(() => void sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
999
|
+
// Local only: refreshes the health probe and sends status if it changed.
|
|
1000
|
+
const statusTimer = setInterval(() => void reportStatus(), STATUS_INTERVAL_MS);
|
|
999
1001
|
const healthTimer = setInterval(() => void refreshHealth(), HEALTH_INTERVAL_MS);
|
|
1000
1002
|
const claimLocal = async () => {
|
|
1001
1003
|
if (!generationStore) return undefined;
|
|
@@ -1038,7 +1040,7 @@ const main = async () => {
|
|
|
1038
1040
|
let preferLocal = false;
|
|
1039
1041
|
try {
|
|
1040
1042
|
await refreshHealth();
|
|
1041
|
-
await
|
|
1043
|
+
await reportStatus();
|
|
1042
1044
|
while (!state.stopping) {
|
|
1043
1045
|
if (!state.health.serverReady || !state.health.modelReady) {
|
|
1044
1046
|
await refreshHealth();
|
|
@@ -1079,9 +1081,9 @@ const main = async () => {
|
|
|
1079
1081
|
await jobPromise;
|
|
1080
1082
|
}
|
|
1081
1083
|
} finally {
|
|
1082
|
-
clearInterval(
|
|
1084
|
+
clearInterval(statusTimer);
|
|
1083
1085
|
clearInterval(healthTimer);
|
|
1084
|
-
push
|
|
1086
|
+
push.stop();
|
|
1085
1087
|
await stop('shutdown');
|
|
1086
1088
|
}
|
|
1087
1089
|
};
|
package/runtime/push.mjs
CHANGED
|
@@ -1,43 +1,23 @@
|
|
|
1
1
|
/* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
|
|
2
2
|
// Push-driven cloud claims. The queue pushes "work queued" over a reactive
|
|
3
|
-
// WebSocket, so the runner claims only when there is work
|
|
4
|
-
//
|
|
3
|
+
// WebSocket, so the runner claims only when there is work. Every (re)connect
|
|
4
|
+
// delivers a fresh snapshot of queued work, so work queued while the socket was
|
|
5
|
+
// down is claimed on reconnect.
|
|
5
6
|
|
|
6
7
|
export const CLUSTER_WORK_PATH = 'cluster.work';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export const DEFAULT_PUSH_HTTP_PATH = '/api/cluster/reactive';
|
|
8
|
+
const TICKET_PATH = '/api/cluster/token';
|
|
9
|
+
const HTTP_PATH = '/api/cluster/reactive';
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
* so a typo can never stop the fallback claim.
|
|
14
|
-
*/
|
|
15
|
-
export const parsePushFallbackMs = (value) => {
|
|
16
|
-
const milliseconds = Number(value);
|
|
17
|
-
return value?.trim() && Number.isSafeInteger(milliseconds) && milliseconds >= 1_000
|
|
18
|
-
? milliseconds
|
|
19
|
-
: DEFAULT_PUSH_FALLBACK_MS;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Decides when a cloud claim is worth a request. Disabled, it always claims, which
|
|
24
|
-
* keeps the original polling behaviour.
|
|
25
|
-
*/
|
|
26
|
-
export const createWorkSignal = ({
|
|
27
|
-
enabled,
|
|
28
|
-
fallbackMs = DEFAULT_PUSH_FALLBACK_MS,
|
|
29
|
-
now = Date.now,
|
|
30
|
-
}) => {
|
|
11
|
+
/** Decides when a cloud claim is worth a request: on start, and after each push. */
|
|
12
|
+
export const createWorkSignal = () => {
|
|
31
13
|
// Claim once at start: work may have been queued while the runner was down.
|
|
32
14
|
let pending = true;
|
|
33
|
-
let nextFallbackAt = 0;
|
|
34
15
|
let wakeWaiter;
|
|
35
16
|
return {
|
|
36
|
-
shouldClaim: () =>
|
|
37
|
-
/** A claim found no work: wait for
|
|
17
|
+
shouldClaim: () => pending,
|
|
18
|
+
/** A claim found no work: wait for the next push. */
|
|
38
19
|
drained: () => {
|
|
39
20
|
pending = false;
|
|
40
|
-
nextFallbackAt = now() + fallbackMs;
|
|
41
21
|
},
|
|
42
22
|
/** Work was pushed: claim on the next loop, waking an idle wait now. */
|
|
43
23
|
wake: () => {
|
|
@@ -60,32 +40,36 @@ export const createWorkSignal = ({
|
|
|
60
40
|
|
|
61
41
|
/**
|
|
62
42
|
* Holds the reactive WebSocket and calls `onWork` whenever the pushed snapshot
|
|
63
|
-
* lists queued work. Never throws: failures are logged and the
|
|
64
|
-
*
|
|
43
|
+
* lists queued work. Never throws: failures are logged and the connection keeps
|
|
44
|
+
* retrying, and the reconnect snapshot catches up on anything queued meanwhile.
|
|
65
45
|
*/
|
|
66
|
-
export const startPushChannel = ({
|
|
67
|
-
baseUrl,
|
|
68
|
-
token,
|
|
69
|
-
ticketPath = DEFAULT_PUSH_TICKET_PATH,
|
|
70
|
-
httpPath = DEFAULT_PUSH_HTTP_PATH,
|
|
71
|
-
onWork,
|
|
72
|
-
log,
|
|
73
|
-
connect,
|
|
74
|
-
}) => {
|
|
46
|
+
export const startPushChannel = ({ baseUrl, token, onWork, onConnect, log, connect }) => {
|
|
75
47
|
const controller = new AbortController();
|
|
76
48
|
let client;
|
|
77
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;
|
|
78
53
|
const ready = (async () => {
|
|
79
54
|
const connectClusterWorker =
|
|
80
55
|
connect ?? (await import('dynamodb-reactive/cluster')).connectClusterWorker;
|
|
81
56
|
client = await connectClusterWorker({
|
|
82
57
|
baseUrl,
|
|
83
58
|
runnerToken: token,
|
|
84
|
-
ticketPath,
|
|
85
|
-
httpPath,
|
|
59
|
+
ticketPath: TICKET_PATH,
|
|
60
|
+
httpPath: HTTP_PATH,
|
|
86
61
|
signal: controller.signal,
|
|
87
62
|
onRetry: (error) => log(`push connect failed; retrying: ${error.message}`),
|
|
88
|
-
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
|
+
},
|
|
89
73
|
});
|
|
90
74
|
if (controller.signal.aborted) {
|
|
91
75
|
client.disconnect();
|
|
@@ -103,6 +87,7 @@ export const startPushChannel = ({
|
|
|
103
87
|
});
|
|
104
88
|
return {
|
|
105
89
|
ready,
|
|
90
|
+
connected: () => isConnected,
|
|
106
91
|
stop: () => {
|
|
107
92
|
controller.abort();
|
|
108
93
|
subscription?.unsubscribe();
|
|
@@ -110,3 +95,31 @@ export const startPushChannel = ({
|
|
|
110
95
|
},
|
|
111
96
|
};
|
|
112
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
|
+
};
|