ag-ai 0.3.5 → 0.3.7
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 +6 -2
- package/package.json +4 -2
- package/runtime/bridge.mjs +23 -2
- package/runtime/push.mjs +101 -0
package/dist/k8s/runner.js
CHANGED
|
@@ -40,6 +40,8 @@ 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;
|
|
43
45
|
const agAiPackage = context.env.AG_AI_PACKAGE;
|
|
44
46
|
if (!agAiPackage)
|
|
45
47
|
throw new Error('AG_AI_PACKAGE is required');
|
|
@@ -93,6 +95,7 @@ const runnerStack = (context) => {
|
|
|
93
95
|
(0, common_1.env)('AG_AI_OPENCODE_URL', opencodeUrl),
|
|
94
96
|
(0, common_1.env)('AG_AI_OPENCODE_PASSWORD_FILE', secrets.opencode.file),
|
|
95
97
|
...(opencodeProvider === undefined ? [] : [(0, common_1.env)('AG_AI_OPENCODE_PROVIDER', opencodeProvider)]),
|
|
98
|
+
...(push === undefined ? [] : [(0, common_1.env)('AG_AI_PUSH', push)]),
|
|
96
99
|
(0, common_1.env)('AG_AI_IMAGE_PROVIDER', imageProvider),
|
|
97
100
|
(0, common_1.env)('AG_AI_IMAGE_API_KEY_FILE', secrets.image.file),
|
|
98
101
|
(0, common_1.env)('AG_AI_RUNNER_ID', common_1.RUNNER_APP),
|
|
@@ -168,8 +171,9 @@ const runnerStack = (context) => {
|
|
|
168
171
|
name: 'install-ag-ai',
|
|
169
172
|
image: facilities.image('ag-ai-runner'),
|
|
170
173
|
command: ['npm'],
|
|
171
|
-
// The runtime
|
|
172
|
-
// optional, so omitting them keeps the
|
|
174
|
+
// The runtime needs only dynamodb-reactive (for the push channel) beyond Node
|
|
175
|
+
// builtins. Client-only dependencies are optional, so omitting them keeps the
|
|
176
|
+
// pod install lean.
|
|
173
177
|
args: [
|
|
174
178
|
'install',
|
|
175
179
|
'--prefix',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ag-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"./image": "./runtime/image/adapter.mjs",
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
|
-
"dependencies": {
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"dynamodb-reactive": "^0.3.0"
|
|
29
|
+
},
|
|
28
30
|
"devDependencies": {
|
|
29
31
|
"@kubernetes/client-node": "2.0.0",
|
|
30
32
|
"@types/node": "^26.6.4",
|
package/runtime/bridge.mjs
CHANGED
|
@@ -10,6 +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
14
|
import { createLocalRunner, startGenerationServer } from './server.mjs';
|
|
14
15
|
import { GenerationStore } from './store.mjs';
|
|
15
16
|
import { AnalyticaEvents } from './telemetry.mjs';
|
|
@@ -827,6 +828,10 @@ const readConfig = async (env = process.env) => {
|
|
|
827
828
|
generationPort: env.AG_AI_GENERATION_PORT ?? DEFAULT_GENERATION_PORT,
|
|
828
829
|
generationStore: path.resolve(env.AG_AI_GENERATION_STORE ?? DEFAULT_GENERATION_STORE),
|
|
829
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,
|
|
830
835
|
analyticaToken,
|
|
831
836
|
token,
|
|
832
837
|
};
|
|
@@ -974,6 +979,17 @@ const main = async () => {
|
|
|
974
979
|
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
975
980
|
process.once('SIGINT', () => void stop('SIGINT'));
|
|
976
981
|
|
|
982
|
+
const workSignal = createWorkSignal({ enabled: config.pushEnabled });
|
|
983
|
+
const push = config.pushEnabled
|
|
984
|
+
? startPushChannel({
|
|
985
|
+
baseUrl: new URL(config.queueUrl).origin,
|
|
986
|
+
token: config.token,
|
|
987
|
+
ticketPath: config.pushTicketPath,
|
|
988
|
+
httpPath: config.pushHttpPath,
|
|
989
|
+
onWork: () => workSignal.wake(),
|
|
990
|
+
log: (message) => console.log(`[runner] ${redact(message, [config.token])}`),
|
|
991
|
+
})
|
|
992
|
+
: undefined;
|
|
977
993
|
const heartbeatTimer = setInterval(() => void sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
978
994
|
const healthTimer = setInterval(() => void refreshHealth(), HEALTH_INTERVAL_MS);
|
|
979
995
|
const claimLocal = async () => {
|
|
@@ -994,6 +1010,7 @@ const main = async () => {
|
|
|
994
1010
|
}
|
|
995
1011
|
};
|
|
996
1012
|
const claimCloud = async () => {
|
|
1013
|
+
if (!workSignal.shouldClaim()) return undefined;
|
|
997
1014
|
let claimResponse;
|
|
998
1015
|
try {
|
|
999
1016
|
claimResponse = await runner.claim();
|
|
@@ -1002,7 +1019,10 @@ const main = async () => {
|
|
|
1002
1019
|
if (error instanceof UncertainRequestError) return { uncertain: true };
|
|
1003
1020
|
return undefined;
|
|
1004
1021
|
}
|
|
1005
|
-
if (!claimResponse?.job)
|
|
1022
|
+
if (!claimResponse?.job) {
|
|
1023
|
+
workSignal.drained();
|
|
1024
|
+
return undefined;
|
|
1025
|
+
}
|
|
1006
1026
|
try {
|
|
1007
1027
|
return { source: 'cloud', runner, job: validateJobEnvelope(claimResponse.job) };
|
|
1008
1028
|
} catch (error) {
|
|
@@ -1037,7 +1057,7 @@ const main = async () => {
|
|
|
1037
1057
|
continue;
|
|
1038
1058
|
}
|
|
1039
1059
|
if (!selection) {
|
|
1040
|
-
await
|
|
1060
|
+
await workSignal.wait(CLAIM_INTERVAL_MS);
|
|
1041
1061
|
continue;
|
|
1042
1062
|
}
|
|
1043
1063
|
const activeRunner = selection.runner;
|
|
@@ -1056,6 +1076,7 @@ const main = async () => {
|
|
|
1056
1076
|
} finally {
|
|
1057
1077
|
clearInterval(heartbeatTimer);
|
|
1058
1078
|
clearInterval(healthTimer);
|
|
1079
|
+
push?.stop();
|
|
1059
1080
|
await stop('shutdown');
|
|
1060
1081
|
}
|
|
1061
1082
|
};
|
package/runtime/push.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
|
|
2
|
+
// Push-driven cloud claims. The queue pushes "work queued" over a reactive
|
|
3
|
+
// WebSocket, so the runner claims only when there is work, plus a slow fallback
|
|
4
|
+
// claim that covers missed pushes and queues without a push channel.
|
|
5
|
+
|
|
6
|
+
export const CLUSTER_WORK_PATH = 'cluster.work';
|
|
7
|
+
export const DEFAULT_PUSH_FALLBACK_MS = 60_000;
|
|
8
|
+
export const DEFAULT_PUSH_TICKET_PATH = '/api/cluster/token';
|
|
9
|
+
export const DEFAULT_PUSH_HTTP_PATH = '/api/cluster/reactive';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decides when a cloud claim is worth a request. Disabled, it always claims, which
|
|
13
|
+
* keeps the original polling behaviour.
|
|
14
|
+
*/
|
|
15
|
+
export const createWorkSignal = ({
|
|
16
|
+
enabled,
|
|
17
|
+
fallbackMs = DEFAULT_PUSH_FALLBACK_MS,
|
|
18
|
+
now = Date.now,
|
|
19
|
+
}) => {
|
|
20
|
+
// Claim once at start: work may have been queued while the runner was down.
|
|
21
|
+
let pending = true;
|
|
22
|
+
let nextFallbackAt = 0;
|
|
23
|
+
let wakeWaiter;
|
|
24
|
+
return {
|
|
25
|
+
shouldClaim: () => !enabled || pending || now() >= nextFallbackAt,
|
|
26
|
+
/** A claim found no work: wait for a push or the fallback. */
|
|
27
|
+
drained: () => {
|
|
28
|
+
pending = false;
|
|
29
|
+
nextFallbackAt = now() + fallbackMs;
|
|
30
|
+
},
|
|
31
|
+
/** Work was pushed: claim on the next loop, waking an idle wait now. */
|
|
32
|
+
wake: () => {
|
|
33
|
+
pending = true;
|
|
34
|
+
wakeWaiter?.();
|
|
35
|
+
},
|
|
36
|
+
/** Idle wait that a push ends early. */
|
|
37
|
+
wait: (milliseconds) =>
|
|
38
|
+
new Promise((resolve) => {
|
|
39
|
+
const done = () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
if (wakeWaiter === done) wakeWaiter = undefined;
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
const timer = setTimeout(done, milliseconds);
|
|
45
|
+
wakeWaiter = done;
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Holds the reactive WebSocket and calls `onWork` whenever the pushed snapshot
|
|
52
|
+
* lists queued work. Never throws: failures are logged and the fallback claim
|
|
53
|
+
* keeps the runner working.
|
|
54
|
+
*/
|
|
55
|
+
export const startPushChannel = ({
|
|
56
|
+
baseUrl,
|
|
57
|
+
token,
|
|
58
|
+
ticketPath = DEFAULT_PUSH_TICKET_PATH,
|
|
59
|
+
httpPath = DEFAULT_PUSH_HTTP_PATH,
|
|
60
|
+
onWork,
|
|
61
|
+
log,
|
|
62
|
+
connect,
|
|
63
|
+
}) => {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
let client;
|
|
66
|
+
let subscription;
|
|
67
|
+
const ready = (async () => {
|
|
68
|
+
const connectClusterWorker =
|
|
69
|
+
connect ?? (await import('dynamodb-reactive/cluster')).connectClusterWorker;
|
|
70
|
+
client = await connectClusterWorker({
|
|
71
|
+
baseUrl,
|
|
72
|
+
runnerToken: token,
|
|
73
|
+
ticketPath,
|
|
74
|
+
httpPath,
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
onRetry: (error) => log(`push connect failed; retrying: ${error.message}`),
|
|
77
|
+
client: { onError: (error) => log(`push socket error: ${error.message}`) },
|
|
78
|
+
});
|
|
79
|
+
if (controller.signal.aborted) {
|
|
80
|
+
client.disconnect();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
subscription = client.subscribe(CLUSTER_WORK_PATH, {
|
|
84
|
+
onData: (items) => {
|
|
85
|
+
if (Array.isArray(items) && items.length > 0) onWork();
|
|
86
|
+
},
|
|
87
|
+
onError: (error) => log(`push subscription error: ${error.message}`),
|
|
88
|
+
});
|
|
89
|
+
log('push channel connected');
|
|
90
|
+
})().catch((error) => {
|
|
91
|
+
if (!controller.signal.aborted) log(`push channel stopped: ${error?.message ?? error}`);
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
ready,
|
|
95
|
+
stop: () => {
|
|
96
|
+
controller.abort();
|
|
97
|
+
subscription?.unsubscribe();
|
|
98
|
+
client?.disconnect();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|