ag-ai 0.3.5 → 0.3.6

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.
@@ -168,8 +168,9 @@ const runnerStack = (context) => {
168
168
  name: 'install-ag-ai',
169
169
  image: facilities.image('ag-ai-runner'),
170
170
  command: ['npm'],
171
- // The runtime imports only Node builtins. Client-only dependencies are
172
- // optional, so omitting them keeps the pod install lean.
171
+ // The runtime needs only dynamodb-reactive (for the push channel) beyond Node
172
+ // builtins. Client-only dependencies are optional, so omitting them keeps the
173
+ // pod install lean.
173
174
  args: [
174
175
  'install',
175
176
  '--prefix',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ag-ai",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -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) return undefined;
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 delay(CLAIM_INTERVAL_MS);
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
  };
@@ -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
+ };