@evident-ai/cli 3.4.1-dev.9c22b93 → 3.4.1-dev.ab61560

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/README.md CHANGED
@@ -119,6 +119,9 @@ Options:
119
119
  - `--opencode-config-overlay <path>` — Apply a runner-provided OpenCode config
120
120
  before starting OpenCode. Relative paths are resolved from the working directory;
121
121
  an existing `opencode.jsonc` is replaced before `opencode.json`.
122
+ - `--credential-sync-marker <path>` — Own the interval credential sync and write
123
+ this marker once the shutdown flush has finished, so the runner image's lifecycle
124
+ hooks can wait on it.
122
125
  - `--claude-usage-reporting <mode>` — Whether to report the local Claude Code
123
126
  subscription's rate-limit usage to Evident, so it shows on the runner page:
124
127
  `auto` (default) reports it when a usable Claude Code login is found on this
package/dist/index.js CHANGED
@@ -1183,7 +1183,7 @@ async function claudeUsage() {
1183
1183
  }
1184
1184
 
1185
1185
  // src/commands/run.ts
1186
- import { chmodSync as chmodSync3, existsSync as existsSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
1186
+ import { chmodSync as chmodSync3, existsSync as existsSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
1187
1187
  import { homedir as homedir5 } from "os";
1188
1188
  import { isAbsolute as isAbsolute3, join as join9, parse, resolve as resolvePath2 } from "path";
1189
1189
  import chalk6 from "chalk";
@@ -1728,10 +1728,14 @@ function runSynchroniser(args, opts) {
1728
1728
  let stderr = "";
1729
1729
  let settled = false;
1730
1730
  const timer = {};
1731
+ let abortListener;
1732
+ let spawnListener;
1731
1733
  const finish = (result) => {
1732
1734
  if (settled) return;
1733
1735
  settled = true;
1734
1736
  if (timer.handle) clearTimeout(timer.handle);
1737
+ if (abortListener) opts.signal?.removeEventListener("abort", abortListener);
1738
+ if (spawnListener) child.removeListener("spawn", spawnListener);
1735
1739
  resolve4(result);
1736
1740
  };
1737
1741
  try {
@@ -1757,6 +1761,25 @@ function runSynchroniser(args, opts) {
1757
1761
  child.once("close", (code) => {
1758
1762
  finish({ code, stdout, stderr, timedOut: false });
1759
1763
  });
1764
+ if (opts.signal) {
1765
+ const killChild = () => {
1766
+ if (child.pid === void 0) {
1767
+ if (!spawnListener) {
1768
+ spawnListener = killChild;
1769
+ child.once("spawn", spawnListener);
1770
+ }
1771
+ return;
1772
+ }
1773
+ child.kill("SIGKILL");
1774
+ };
1775
+ abortListener = killChild;
1776
+ if (opts.signal.aborted) {
1777
+ abortListener();
1778
+ } else {
1779
+ opts.signal.addEventListener("abort", abortListener, { once: true });
1780
+ if (opts.signal.aborted) abortListener();
1781
+ }
1782
+ }
1760
1783
  timer.handle = setTimeout(
1761
1784
  () => {
1762
1785
  child.kill("SIGKILL");
@@ -3844,8 +3867,8 @@ function connectTunnel(options) {
3844
3867
  try {
3845
3868
  message = JSON.parse(data.toString());
3846
3869
  } catch (error2) {
3847
- const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
3848
- onError?.(`Failed to handle message: ${errorMessage}`);
3870
+ const errorMessage2 = error2 instanceof Error ? error2.message : "Unknown error";
3871
+ onError?.(`Failed to handle message: ${errorMessage2}`);
3849
3872
  return;
3850
3873
  }
3851
3874
  if (isStreamFrame(message)) {
@@ -5249,6 +5272,7 @@ var ChannelDriver = class _ChannelDriver {
5249
5272
  * and stops opencode.
5250
5273
  */
5251
5274
  stopped = false;
5275
+ recycleRequestedFlag = false;
5252
5276
  constructor(config) {
5253
5277
  this.agentId = config.agentId;
5254
5278
  this.port = config.port;
@@ -5354,6 +5378,9 @@ var ChannelDriver = class _ChannelDriver {
5354
5378
  let dispatched = 0;
5355
5379
  try {
5356
5380
  const conversations = await this.getPendingConversations();
5381
+ if (this.recycleRequestedFlag) {
5382
+ this.stop();
5383
+ }
5357
5384
  if (conversations.length > 0) {
5358
5385
  const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
5359
5386
  this.log({
@@ -5482,6 +5509,14 @@ var ChannelDriver = class _ChannelDriver {
5482
5509
  stop() {
5483
5510
  this.stopped = true;
5484
5511
  }
5512
+ /**
5513
+ * The server clears this request when a new MicroVM identity is recorded, so a
5514
+ * same-VM tunnel reconnect does not consume it. This is a plain read rather
5515
+ * than a consume; `run.ts` guards the action once-only.
5516
+ */
5517
+ get recycleRequested() {
5518
+ return this.recycleRequestedFlag;
5519
+ }
5485
5520
  /**
5486
5521
  * Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
5487
5522
  * graceful shutdown, so a turn whose reply is ready — or completes within the
@@ -5619,7 +5654,7 @@ var ChannelDriver = class _ChannelDriver {
5619
5654
  this.signalDispatchNotStarted(conv, message, "session_existence_unknown");
5620
5655
  break;
5621
5656
  }
5622
- const errorMessage = err instanceof Error ? err.message : String(err);
5657
+ const errorMessage2 = err instanceof Error ? err.message : String(err);
5623
5658
  this.sessions.delete(conv.id);
5624
5659
  this.supersede(conv.id, sessionId);
5625
5660
  this.log({
@@ -5628,7 +5663,7 @@ var ChannelDriver = class _ChannelDriver {
5628
5663
  conversation_id: conv.id,
5629
5664
  message_id: message.id
5630
5665
  });
5631
- await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
5666
+ await this.markFailed(conv.id, message.id, null, errorMessage2).catch((markErr) => {
5632
5667
  this.log({
5633
5668
  level: "warn",
5634
5669
  message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
@@ -5639,7 +5674,7 @@ var ChannelDriver = class _ChannelDriver {
5639
5674
  });
5640
5675
  this.log({
5641
5676
  level: "error",
5642
- message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
5677
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage2}`,
5643
5678
  conversation_id: conv.id,
5644
5679
  message_id: message.id
5645
5680
  });
@@ -5660,14 +5695,14 @@ var ChannelDriver = class _ChannelDriver {
5660
5695
  this.unconfirmedDispatchFailures.delete(message.id);
5661
5696
  this.sessions.delete(conv.id);
5662
5697
  this.supersede(conv.id, sessionId);
5663
- const errorMessage = `OpenCode accepted this message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
5698
+ const errorMessage2 = `OpenCode accepted this message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
5664
5699
  this.log({
5665
5700
  level: "error",
5666
- message: errorMessage,
5701
+ message: errorMessage2,
5667
5702
  conversation_id: conv.id,
5668
5703
  message_id: message.id
5669
5704
  });
5670
- await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
5705
+ await this.markFailed(conv.id, message.id, null, errorMessage2).catch((markErr) => {
5671
5706
  this.log({
5672
5707
  level: "warn",
5673
5708
  message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
@@ -7631,14 +7666,14 @@ var ChannelDriver = class _ChannelDriver {
7631
7666
  this.unconfirmedDispatchFailures.delete(row.id);
7632
7667
  this.sessions.delete(readoptConv.id);
7633
7668
  this.supersede(readoptConv.id, sessionId);
7634
- const errorMessage = `OpenCode accepted this re-dispatched message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
7669
+ const errorMessage2 = `OpenCode accepted this re-dispatched message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
7635
7670
  this.log({
7636
7671
  level: "error",
7637
- message: errorMessage,
7672
+ message: errorMessage2,
7638
7673
  conversation_id: row.conversation_id,
7639
7674
  message_id: row.id
7640
7675
  });
7641
- await this.markFailed(row.conversation_id, row.id, null, errorMessage).catch((markErr) => {
7676
+ await this.markFailed(row.conversation_id, row.id, null, errorMessage2).catch((markErr) => {
7642
7677
  this.log({
7643
7678
  level: "warn",
7644
7679
  message: `markFailed PATCH for message ${row.id.slice(0, 8)} (conversation ${row.conversation_id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
@@ -8253,6 +8288,7 @@ var ChannelDriver = class _ChannelDriver {
8253
8288
  throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
8254
8289
  }
8255
8290
  const data = await res.json();
8291
+ this.recycleRequestedFlag = data.recycle_requested === true;
8256
8292
  let conversations = data.conversations;
8257
8293
  if (this.conversationFilter) {
8258
8294
  conversations = conversations.filter((c) => c.id === this.conversationFilter);
@@ -9079,6 +9115,241 @@ function applyRunnerOpenCodeConfig({
9079
9115
  log3(`Applied runner OpenCode config ${source} to ${join8(cwd, target)}`);
9080
9116
  }
9081
9117
 
9118
+ // src/lib/credential-sync.ts
9119
+ import { renameSync, writeFileSync as writeFileSync5 } from "fs";
9120
+ var CREDENTIAL_SYNC_TICK_TIMEOUT_MS = 3e4;
9121
+ var CREDENTIAL_FLUSH_DEADLINE_MS = 8e3;
9122
+ var CREDENTIAL_FLUSH_ABORT_GRACE_MS = 500;
9123
+ var DEFAULT_CREDENTIAL_SYNC_INTERVAL_SECONDS = 60;
9124
+ var STORES = ["claude", "opencode"];
9125
+ var MAX_FLUSH_PASSES = 2;
9126
+ function outcomesWith(outcome) {
9127
+ return { claude: outcome, opencode: outcome };
9128
+ }
9129
+ function errorMessage(error2) {
9130
+ return error2 instanceof Error ? error2.message : String(error2);
9131
+ }
9132
+ function waitForSettlement(promise, timeoutMs) {
9133
+ return new Promise((resolve4) => {
9134
+ let settled = false;
9135
+ const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
9136
+ const finish = (value) => {
9137
+ if (settled) return;
9138
+ settled = true;
9139
+ clearTimeout(timer);
9140
+ resolve4(value);
9141
+ };
9142
+ promise.then(
9143
+ () => finish(true),
9144
+ () => finish(true)
9145
+ );
9146
+ });
9147
+ }
9148
+ function writeMarker(markerPath, outcomes, log3) {
9149
+ const body = `${STORES.map((store) => `${store}=${outcomes[store]}`).join("\n")}
9150
+ `;
9151
+ const temporaryPath = `${markerPath}.tmp`;
9152
+ try {
9153
+ writeFileSync5(temporaryPath, body, { mode: 384 });
9154
+ renameSync(temporaryPath, markerPath);
9155
+ } catch (error2) {
9156
+ log3(`CREDENTIAL-FLUSH-MARKER-UNWRITABLE: ${markerPath}: ${errorMessage(error2)}`, "warn");
9157
+ }
9158
+ }
9159
+ function intervalSeconds(env, log3) {
9160
+ const raw = env.CREDS_SYNC_INTERVAL;
9161
+ if (raw === void 0 || /^[1-9][0-9]*$/.test(raw) && Number.isSafeInteger(Number(raw))) {
9162
+ return raw === void 0 ? DEFAULT_CREDENTIAL_SYNC_INTERVAL_SECONDS : Number(raw);
9163
+ }
9164
+ log3(
9165
+ `CREDENTIAL-SYNC-INTERVAL-INVALID: CREDS_SYNC_INTERVAL='${raw}' is not a positive integer; using ${DEFAULT_CREDENTIAL_SYNC_INTERVAL_SECONDS}s`,
9166
+ "warn"
9167
+ );
9168
+ return DEFAULT_CREDENTIAL_SYNC_INTERVAL_SECONDS;
9169
+ }
9170
+ async function runFlushStore(store, env, synchroniserRunner, log3, deadlineAt) {
9171
+ const remainingMs = deadlineAt - Date.now();
9172
+ if (remainingMs <= 0) return { outcome: "timeout", orphaned: false };
9173
+ const controller = new AbortController();
9174
+ let result;
9175
+ let failed = false;
9176
+ const completion = Promise.resolve().then(
9177
+ () => synchroniserRunner(["sync-once", store], {
9178
+ timeoutMs: remainingMs,
9179
+ env,
9180
+ signal: controller.signal
9181
+ })
9182
+ ).then(
9183
+ (value) => {
9184
+ result = value;
9185
+ },
9186
+ (error2) => {
9187
+ failed = true;
9188
+ log3(`CREDENTIAL-FLUSH-ERROR: ${store}: ${errorMessage(error2)}`, "warn");
9189
+ }
9190
+ );
9191
+ const abortTimer = setTimeout(() => controller.abort(), remainingMs);
9192
+ const settledBeforeDeadline = await waitForSettlement(completion, remainingMs);
9193
+ clearTimeout(abortTimer);
9194
+ if (!settledBeforeDeadline) {
9195
+ controller.abort();
9196
+ const settledAfterAbort = await waitForSettlement(completion, CREDENTIAL_FLUSH_ABORT_GRACE_MS);
9197
+ if (!settledAfterAbort) return { outcome: "timeout", orphaned: true };
9198
+ return { outcome: "timeout", orphaned: false };
9199
+ }
9200
+ if (failed || !result) return { outcome: "failed", orphaned: false };
9201
+ if (result.timedOut || Date.now() >= deadlineAt) {
9202
+ return { outcome: "timeout", orphaned: false };
9203
+ }
9204
+ return { outcome: result.code === 0 ? "ok" : "failed", orphaned: false };
9205
+ }
9206
+ function createCredentialSync({
9207
+ markerPath,
9208
+ env,
9209
+ log: log3,
9210
+ synchroniserRunner = runSynchroniser
9211
+ }) {
9212
+ const persistenceDisabled = !env.PERSISTENCE_BUCKET;
9213
+ let disabled = persistenceDisabled;
9214
+ let armed = false;
9215
+ let stopped = false;
9216
+ let timer;
9217
+ let inFlight;
9218
+ let activeTickAbort;
9219
+ let lastTickFailed;
9220
+ let flushPromise;
9221
+ const scheduleTick = (intervalMs, startTick2) => {
9222
+ if (stopped) return;
9223
+ timer = setTimeout(() => {
9224
+ timer = void 0;
9225
+ startTick2();
9226
+ }, intervalMs);
9227
+ };
9228
+ const startTick = (intervalMs) => {
9229
+ if (stopped) return;
9230
+ const controller = new AbortController();
9231
+ activeTickAbort = controller;
9232
+ const tick = (async () => {
9233
+ const outcomes = {
9234
+ claude: "failed",
9235
+ opencode: "failed"
9236
+ };
9237
+ for (const store of STORES) {
9238
+ if (controller.signal.aborted) break;
9239
+ try {
9240
+ const result = await synchroniserRunner(["sync-once", store], {
9241
+ timeoutMs: CREDENTIAL_SYNC_TICK_TIMEOUT_MS,
9242
+ env,
9243
+ signal: controller.signal
9244
+ });
9245
+ outcomes[store] = !result.timedOut && result.code === 0 ? "ok" : "failed";
9246
+ } catch (error2) {
9247
+ outcomes[store] = "failed";
9248
+ log3(`CREDENTIAL-SYNC-TICK-ERROR: ${store}: ${errorMessage(error2)}`, "debug");
9249
+ }
9250
+ }
9251
+ const failed = STORES.some((store) => outcomes[store] === "failed");
9252
+ log3(
9253
+ `CREDENTIAL-SYNC-TICK: claude=${outcomes.claude}, opencode=${outcomes.opencode}`,
9254
+ "debug"
9255
+ );
9256
+ if (failed && lastTickFailed !== true) {
9257
+ log3(
9258
+ "CREDENTIAL-SYNC-FAILED: an interval credential sync failed; retrying on the next tick",
9259
+ "warn"
9260
+ );
9261
+ } else if (!failed && lastTickFailed === true) {
9262
+ log3("CREDENTIAL-SYNC-RECOVERED: interval credential sync succeeded again", "warn");
9263
+ }
9264
+ lastTickFailed = failed;
9265
+ })().finally(() => {
9266
+ if (activeTickAbort === controller) activeTickAbort = void 0;
9267
+ if (inFlight === tick) inFlight = void 0;
9268
+ scheduleTick(intervalMs, () => startTick(intervalMs));
9269
+ });
9270
+ inFlight = tick;
9271
+ };
9272
+ const performFlush = async () => {
9273
+ stopped = true;
9274
+ if (timer) {
9275
+ clearTimeout(timer);
9276
+ timer = void 0;
9277
+ }
9278
+ const deadlineAt = Date.now() + CREDENTIAL_FLUSH_DEADLINE_MS;
9279
+ if (inFlight) {
9280
+ const settled = await waitForSettlement(inFlight, CREDENTIAL_FLUSH_DEADLINE_MS);
9281
+ if (!settled) {
9282
+ activeTickAbort?.abort();
9283
+ const settledAfterAbort = await waitForSettlement(
9284
+ inFlight,
9285
+ CREDENTIAL_FLUSH_ABORT_GRACE_MS
9286
+ );
9287
+ if (!settledAfterAbort) {
9288
+ log3(
9289
+ "CREDENTIAL-FLUSH-ORPHANED-TICK: a sync-once child could not be proven settled; skipping the boundary flush rather than becoming a second writer",
9290
+ "warn"
9291
+ );
9292
+ return { outcomes: outcomesWith("timeout"), orphaned: true };
9293
+ }
9294
+ }
9295
+ }
9296
+ if (disabled) return { outcomes: outcomesWith("skipped"), orphaned: false };
9297
+ const outcomes = outcomesWith("timeout");
9298
+ for (const store of STORES) {
9299
+ const result = await runFlushStore(store, env, synchroniserRunner, log3, deadlineAt);
9300
+ if (result.orphaned) {
9301
+ log3(
9302
+ "CREDENTIAL-FLUSH-ORPHANED-CHILD: a flush sync-once child could not be proven settled; refusing another flush pass rather than becoming a second writer",
9303
+ "warn"
9304
+ );
9305
+ return { outcomes: outcomesWith("timeout"), orphaned: true };
9306
+ }
9307
+ outcomes[store] = result.outcome;
9308
+ }
9309
+ return { outcomes, orphaned: false };
9310
+ };
9311
+ let flushPasses = 0;
9312
+ let lastFlush;
9313
+ return {
9314
+ arm() {
9315
+ if (stopped || armed) return;
9316
+ armed = true;
9317
+ if (persistenceDisabled) {
9318
+ disabled = true;
9319
+ log3(
9320
+ "CREDENTIAL-SYNC-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; no interval credential sync this boot",
9321
+ "warn"
9322
+ );
9323
+ return;
9324
+ }
9325
+ disabled = false;
9326
+ const intervalMs = intervalSeconds(env, log3) * 1e3;
9327
+ scheduleTick(intervalMs, () => startTick(intervalMs));
9328
+ },
9329
+ async stopAndFlush(publish) {
9330
+ let result;
9331
+ const runningFlush = flushPromise;
9332
+ if (runningFlush) {
9333
+ result = await runningFlush;
9334
+ } else if (flushPasses >= MAX_FLUSH_PASSES || lastFlush?.orphaned) {
9335
+ result = lastFlush ?? { outcomes: outcomesWith("timeout"), orphaned: true };
9336
+ } else {
9337
+ flushPasses++;
9338
+ const currentFlush = performFlush();
9339
+ flushPromise = currentFlush;
9340
+ try {
9341
+ result = await currentFlush;
9342
+ lastFlush = result;
9343
+ } finally {
9344
+ if (flushPromise === currentFlush) flushPromise = void 0;
9345
+ }
9346
+ }
9347
+ if (publish) writeMarker(markerPath, result.outcomes, log3);
9348
+ return result.outcomes;
9349
+ }
9350
+ };
9351
+ }
9352
+
9082
9353
  // src/commands/run.ts
9083
9354
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
9084
9355
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
@@ -9376,6 +9647,10 @@ async function driveChannels(state, driver) {
9376
9647
  consecutiveDrainFailures = 0;
9377
9648
  unreachableMs = 0;
9378
9649
  state.messageCount += processed;
9650
+ if (driver.recycleRequested) {
9651
+ await beginGracefulShutdown(state, "recycle");
9652
+ return;
9653
+ }
9379
9654
  const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
9380
9655
  lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
9381
9656
  const fileActivitySnapshot = driver.fileSyncActivity();
@@ -9418,8 +9693,8 @@ async function driveChannels(state, driver) {
9418
9693
  state.running = false;
9419
9694
  break;
9420
9695
  }
9421
- const errorMessage = error2 instanceof Error ? error2.message : String(error2);
9422
- logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
9696
+ const errorMessage2 = error2 instanceof Error ? error2.message : String(error2);
9697
+ logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage2}` });
9423
9698
  if (state.interactive) displayStatus(state);
9424
9699
  if (driver.hasInFlightWatchers()) {
9425
9700
  consecutiveDrainFailures = 0;
@@ -9889,21 +10164,39 @@ async function cleanup(state, opts = {}) {
9889
10164
  clearTimeout(state.resourceUsageTimer);
9890
10165
  state.resourceUsageTimer = null;
9891
10166
  }
10167
+ const credentialSync = state.credentialSync;
10168
+ const flushCredentials = credentialSync ? async (phase, publish) => {
10169
+ await timeShutdownPhase(state, durations, phase, async () => {
10170
+ const outcomes = await credentialSync.stopAndFlush(publish);
10171
+ const level = Object.values(outcomes).every((outcome) => outcome === "ok") ? "info" : "warn";
10172
+ log2(
10173
+ state,
10174
+ `Credential flush: claude=${outcomes.claude}, opencode=${outcomes.opencode}`,
10175
+ level
10176
+ );
10177
+ });
10178
+ } : void 0;
10179
+ let drainSettled = true;
9892
10180
  if (opts.graceful && state.channelDriver) {
9893
10181
  state.channelDriver.stop();
10182
+ }
10183
+ if (flushCredentials) {
10184
+ await flushCredentials("credential_flush", !(opts.graceful && state.channelDriver));
10185
+ }
10186
+ if (opts.graceful && state.channelDriver) {
9894
10187
  log2(state, "Draining in-flight channel work before shutdown...");
9895
10188
  if (state.interactive) {
9896
10189
  logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
9897
10190
  displayStatus(state);
9898
10191
  }
9899
10192
  const driver = state.channelDriver;
9900
- const settled = await timeShutdownPhase(
10193
+ drainSettled = await timeShutdownPhase(
9901
10194
  state,
9902
10195
  durations,
9903
10196
  "drain",
9904
10197
  () => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
9905
10198
  );
9906
- if (!settled) {
10199
+ if (!drainSettled) {
9907
10200
  logActivity(state, {
9908
10201
  type: "info",
9909
10202
  message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
@@ -9911,6 +10204,9 @@ async function cleanup(state, opts = {}) {
9911
10204
  if (state.interactive) displayStatus(state);
9912
10205
  }
9913
10206
  }
10207
+ if (opts.graceful && state.channelDriver && flushCredentials && drainSettled) {
10208
+ await flushCredentials("credential_flush_final", true);
10209
+ }
9914
10210
  await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
9915
10211
  if (state.connection) {
9916
10212
  const connection = state.connection;
@@ -9946,6 +10242,44 @@ async function cleanup(state, opts = {}) {
9946
10242
  }
9947
10243
  return durations;
9948
10244
  }
10245
+ async function beginGracefulShutdown(state, trigger) {
10246
+ if (state.shuttingDown) return;
10247
+ state.shuttingDown = true;
10248
+ const shutdownStartedAt = Date.now();
10249
+ const shutdownMessage = trigger === "recycle" ? "Recycle requested \u2014 finishing in-flight work, then exiting" : "Shutting down...";
10250
+ if (state.interactive) {
10251
+ logActivity(state, { type: "info", message: shutdownMessage });
10252
+ displayStatus(state);
10253
+ } else {
10254
+ log2(state, shutdownMessage);
10255
+ }
10256
+ const durations = await cleanup(state, { graceful: true });
10257
+ const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
10258
+ await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
10259
+ let timer;
10260
+ const flushed = shutdownTelemetry().then(
10261
+ () => true,
10262
+ (error2) => {
10263
+ log2(
10264
+ state,
10265
+ `Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
10266
+ "warn"
10267
+ );
10268
+ return true;
10269
+ }
10270
+ );
10271
+ const timedOut = new Promise((resolve4) => {
10272
+ timer = setTimeout(() => resolve4(false), telemetryBudgetMs);
10273
+ });
10274
+ if (!await Promise.race([flushed, timedOut])) {
10275
+ log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
10276
+ }
10277
+ clearTimeout(timer);
10278
+ });
10279
+ const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
10280
+ log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
10281
+ process.exit(0);
10282
+ }
9949
10283
  async function run(options) {
9950
10284
  const interactive = isInteractive(options.json);
9951
10285
  let logLevel;
@@ -9997,9 +10331,23 @@ async function run(options) {
9997
10331
  openaiUsageTimer: null,
9998
10332
  openaiUsageRearm: null,
9999
10333
  resourceUsageTimer: null,
10334
+ credentialSync: null,
10000
10335
  authHeader: ""
10001
10336
  };
10002
10337
  setTelemetryAuthProvider(() => ({ authHeader: state.authHeader, agentId: state.agentId }));
10338
+ if (options.credentialSyncMarker) {
10339
+ state.credentialSync = createCredentialSync({
10340
+ markerPath: options.credentialSyncMarker,
10341
+ env: process.env,
10342
+ log: (message, level = "info") => {
10343
+ if (level === "error") {
10344
+ logActivity(state, { type: "error", error: message });
10345
+ } else {
10346
+ logActivity(state, { type: "info", level, message });
10347
+ }
10348
+ }
10349
+ });
10350
+ }
10003
10351
  if (fileSyncDirectories.length > 0) {
10004
10352
  log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
10005
10353
  } else {
@@ -10025,43 +10373,7 @@ async function run(options) {
10025
10373
  "warn"
10026
10374
  );
10027
10375
  }
10028
- const handleSignal = async () => {
10029
- if (state.shuttingDown) return;
10030
- state.shuttingDown = true;
10031
- const shutdownStartedAt = Date.now();
10032
- if (state.interactive) {
10033
- logActivity(state, { type: "info", message: "Shutting down..." });
10034
- displayStatus(state);
10035
- } else {
10036
- log2(state, "Shutting down...");
10037
- }
10038
- const durations = await cleanup(state, { graceful: true });
10039
- const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
10040
- await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
10041
- let timer;
10042
- const flushed = shutdownTelemetry().then(
10043
- () => true,
10044
- (error2) => {
10045
- log2(
10046
- state,
10047
- `Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
10048
- "warn"
10049
- );
10050
- return true;
10051
- }
10052
- );
10053
- const timedOut = new Promise((resolve4) => {
10054
- timer = setTimeout(() => resolve4(false), telemetryBudgetMs);
10055
- });
10056
- if (!await Promise.race([flushed, timedOut])) {
10057
- log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
10058
- }
10059
- clearTimeout(timer);
10060
- });
10061
- const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
10062
- log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
10063
- process.exit(0);
10064
- };
10376
+ const handleSignal = () => beginGracefulShutdown(state, "signal");
10065
10377
  process.on("SIGINT", handleSignal);
10066
10378
  process.on("SIGTERM", handleSignal);
10067
10379
  try {
@@ -10207,6 +10519,7 @@ async function run(options) {
10207
10519
  await restoreCredentialStores(credentialContext);
10208
10520
  if (githubTokenPopulated) await configureGitHubAccess(credentialContext);
10209
10521
  }
10522
+ state.credentialSync?.arm();
10210
10523
  let sessionDbVerifyFatal = false;
10211
10524
  if (!options.restoreSessionDb) {
10212
10525
  log2(state, "Skipping session-DB restore: --restore-session-db was not passed", "debug");
@@ -10276,7 +10589,7 @@ async function run(options) {
10276
10589
  state.opencodeVersion = oc.version;
10277
10590
  if (options.opencodePidFile && oc.process?.pid !== void 0) {
10278
10591
  try {
10279
- writeFileSync5(options.opencodePidFile, `${oc.process.pid}
10592
+ writeFileSync6(options.opencodePidFile, `${oc.process.pid}
10280
10593
  `, { mode: 384 });
10281
10594
  chmodSync3(options.opencodePidFile, 384);
10282
10595
  } catch (error2) {
@@ -10392,7 +10705,7 @@ async function run(options) {
10392
10705
  });
10393
10706
  try {
10394
10707
  if (litestreamProcess.pid !== void 0) {
10395
- writeFileSync5(options.litestreamPidFile, `${litestreamProcess.pid}
10708
+ writeFileSync6(options.litestreamPidFile, `${litestreamProcess.pid}
10396
10709
  `, {
10397
10710
  mode: 384
10398
10711
  });
@@ -10722,6 +11035,9 @@ program.command("run").description("Connect to Evident and process messages").op
10722
11035
  ).option(
10723
11036
  "--opencode-config-overlay <path>",
10724
11037
  "Apply this runner-provided OpenCode config before starting OpenCode."
11038
+ ).option(
11039
+ "--credential-sync-marker <path>",
11040
+ "Own the interval credential sync and write this marker once the shutdown flush has finished, so the runner image's lifecycle hooks can wait on it."
10725
11041
  ).action(
10726
11042
  (options) => {
10727
11043
  run({
@@ -10760,7 +11076,8 @@ program.command("run").description("Connect to Evident and process messages").op
10760
11076
  sessionDbNoReplicateMarker: options.sessionDbNoReplicateMarker,
10761
11077
  restoreSessionDb: options.restoreSessionDb,
10762
11078
  restoreRunnerCredentials: options.restoreRunnerCredentials,
10763
- opencodeConfigOverlay: options.opencodeConfigOverlay
11079
+ opencodeConfigOverlay: options.opencodeConfigOverlay,
11080
+ credentialSyncMarker: options.credentialSyncMarker
10764
11081
  });
10765
11082
  }
10766
11083
  );