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.
@@ -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.0",
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.3.0"
28
+ "dynamodb-reactive": "^0.5.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@kubernetes/client-node": "2.0.0",
@@ -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, parsePushFallbackMs, 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 HEARTBEAT_INTERVAL_MS = 10_000;
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
- let heartbeatPromise;
931
- const sendHeartbeat = async () => {
932
- if (heartbeatPromise) {
933
- return heartbeatPromise;
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
- heartbeatPromise = runner
936
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
937
- .heartbeat(state.job ? 1 : 0, state.health.serverReady, state.health.modelReady)
938
- .then(() => true)
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 heartbeat failed: ${redact(error, [config.token])}`);
941
- return false;
942
+ console.error(`[runner] runner status report failed: ${redact(error, [config.token])}`);
942
943
  })
943
- .then(async (workerReachable) => {
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
- heartbeatPromise = undefined;
961
+ statusPromise = undefined;
960
962
  });
961
- return heartbeatPromise;
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
- const workSignal = createWorkSignal({
985
- enabled: config.pushEnabled,
986
- fallbackMs: config.pushFallbackMs,
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
- const push = config.pushEnabled
989
- ? startPushChannel({
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 sendHeartbeat();
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(heartbeatTimer);
1084
+ clearInterval(statusTimer);
1083
1085
  clearInterval(healthTimer);
1084
- push?.stop();
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, plus a slow fallback
4
- // claim that covers missed pushes and queues without a push channel.
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
- 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';
8
+ const TICKET_PATH = '/api/cluster/token';
9
+ const HTTP_PATH = '/api/cluster/reactive';
10
10
 
11
- /**
12
- * Reads AG_AI_PUSH_FALLBACK_MS. Unset, blank or invalid values keep the default,
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: () => !enabled || pending || now() >= nextFallbackAt,
37
- /** A claim found no work: wait for a push or the fallback. */
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 fallback claim
64
- * keeps the runner working.
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: { onError: (error) => log(`push socket error: ${error.message}`) },
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
+ };