@humwick/runner 0.130.1 → 0.130.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.
Files changed (69) hide show
  1. package/package.json +12 -16
  2. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  3. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +31 -1
  4. package/src/adapters/claude-effort.ts +2 -4
  5. package/src/adapters/codex-launch-config.ts +4 -5
  6. package/src/adapters/codex.ts +1 -1
  7. package/src/adapters/opencode-provider.ts +7 -22
  8. package/src/adapters/opencode.ts +4 -4
  9. package/src/agent-runner/credentials.ts +42 -12
  10. package/src/agent-runner/inbox.ts +27 -0
  11. package/src/agent-runner/lifecycle-milestones.ts +0 -2
  12. package/src/bin/claude.ts +4 -0
  13. package/src/bin/codex.ts +4 -0
  14. package/src/bin/opencode.ts +4 -0
  15. package/src/index.ts +5 -2
  16. package/src/provider-setup.ts +34 -0
  17. package/src/providers.ts +2 -1
  18. package/src/runner-core.ts +18 -23
  19. package/src/spawn-input.ts +2 -1
  20. package/src/test-run-dedup.ts +59 -56
  21. package/node_modules/@humwick/offload/LICENSE +0 -167
  22. package/node_modules/@humwick/offload/README.md +0 -57
  23. package/node_modules/@humwick/offload/dist/async-runner.d.ts +0 -18
  24. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +0 -1
  25. package/node_modules/@humwick/offload/dist/async-runner.js +0 -158
  26. package/node_modules/@humwick/offload/dist/async-runner.js.map +0 -1
  27. package/node_modules/@humwick/offload/dist/envelope.d.ts +0 -25
  28. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +0 -1
  29. package/node_modules/@humwick/offload/dist/envelope.js +0 -80
  30. package/node_modules/@humwick/offload/dist/envelope.js.map +0 -1
  31. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +0 -10
  32. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +0 -1
  33. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +0 -59
  34. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +0 -1
  35. package/node_modules/@humwick/offload/dist/index.d.ts +0 -5
  36. package/node_modules/@humwick/offload/dist/index.d.ts.map +0 -1
  37. package/node_modules/@humwick/offload/dist/index.js +0 -5
  38. package/node_modules/@humwick/offload/dist/index.js.map +0 -1
  39. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +0 -62
  40. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +0 -1
  41. package/node_modules/@humwick/offload/dist/subprocess-runner.js +0 -295
  42. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +0 -1
  43. package/node_modules/@humwick/offload/dist/types.d.ts +0 -111
  44. package/node_modules/@humwick/offload/dist/types.d.ts.map +0 -1
  45. package/node_modules/@humwick/offload/dist/types.js +0 -176
  46. package/node_modules/@humwick/offload/dist/types.js.map +0 -1
  47. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +0 -23
  48. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +0 -1
  49. package/node_modules/@humwick/offload/dist/worker-pool.js +0 -435
  50. package/node_modules/@humwick/offload/dist/worker-pool.js.map +0 -1
  51. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +0 -2
  52. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +0 -1
  53. package/node_modules/@humwick/offload/dist/worker-runtime.js +0 -27
  54. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +0 -1
  55. package/node_modules/@humwick/offload/dist/worker.d.ts +0 -5
  56. package/node_modules/@humwick/offload/dist/worker.d.ts.map +0 -1
  57. package/node_modules/@humwick/offload/dist/worker.js +0 -1
  58. package/node_modules/@humwick/offload/dist/worker.js.map +0 -1
  59. package/node_modules/@humwick/offload/package.json +0 -42
  60. package/node_modules/@humwick/offload/src/async-runner.ts +0 -170
  61. package/node_modules/@humwick/offload/src/envelope.ts +0 -92
  62. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +0 -49
  63. package/node_modules/@humwick/offload/src/index.ts +0 -4
  64. package/node_modules/@humwick/offload/src/subprocess-runner.ts +0 -334
  65. package/node_modules/@humwick/offload/src/types.ts +0 -235
  66. package/node_modules/@humwick/offload/src/worker-pool.ts +0 -463
  67. package/node_modules/@humwick/offload/src/worker-runtime.ts +0 -32
  68. package/node_modules/@humwick/offload/src/worker.ts +0 -5
  69. package/src/gate-run-lease-probe.ts +0 -38
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@humwick/runner",
3
- "version": "0.130.1",
3
+ "version": "0.130.2",
4
4
  "description": "Unified provider lifecycle runner for Humwick",
5
5
  "type": "module",
6
6
  "bin": {
7
- "claude-relay": "src/index.ts",
8
- "codex-relay": "src/index.ts",
9
- "opencode-relay": "src/index.ts",
10
- "claude-humwick": "src/index.ts",
11
- "codex-humwick": "src/index.ts",
12
- "opencode-humwick": "src/index.ts"
7
+ "claude-relay": "src/bin/claude.ts",
8
+ "codex-relay": "src/bin/codex.ts",
9
+ "opencode-relay": "src/bin/opencode.ts",
10
+ "claude-humwick": "src/bin/claude.ts",
11
+ "codex-humwick": "src/bin/codex.ts",
12
+ "opencode-humwick": "src/bin/opencode.ts"
13
13
  },
14
14
  "files": [
15
15
  "src/**/*.ts",
@@ -29,12 +29,11 @@
29
29
  "directory": "runner"
30
30
  },
31
31
  "dependencies": {
32
- "@humwick/offload": "0.0.0",
33
- "@humwick/providers": "0.104.7",
34
- "@humwick/sdk": "0.2.136",
32
+ "@humwick/offload": "0.130.2",
33
+ "@humwick/providers": "0.104.8",
34
+ "@humwick/sdk": "0.2.137",
35
35
  "callmux": "0.25.0",
36
- "unbash": "6.0.0",
37
- "p-queue": "^9.3.3"
36
+ "unbash": "6.0.0"
38
37
  },
39
38
  "devDependencies": {
40
39
  "@types/bun": "1.4.2",
@@ -42,8 +41,5 @@
42
41
  },
43
42
  "engines": {
44
43
  "bun": ">=1.4.4"
45
- },
46
- "bundleDependencies": [
47
- "@humwick/offload"
48
- ]
44
+ }
49
45
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Humwick runner bridge for Claude Code",
4
- "version": "0.130.1",
4
+ "version": "0.130.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -260,6 +260,9 @@ function classifyGateRun(command, commands = {}) {
260
260
  return null;
261
261
  }
262
262
 
263
+ // sdk/src/types/host.ts
264
+ var HOST_GATE_KINDS = ["land", "iterate", "validation"];
265
+
263
266
  // sdk/src/land-gates.ts
264
267
  import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3 } from "path";
265
268
 
@@ -420,7 +423,34 @@ function cleanLandGatesConfigFull(value, worktreePath) {
420
423
  const requirements = cleanLandGateRequirements(value);
421
424
  const cd = value.cd === true;
422
425
  const scope = cleanLandGateScope(value);
423
- return { gates, ...deliveryPolicy, ...iterate ? { iterate } : {}, ...cd ? { cd } : {}, ...placement ? { placement } : {}, ...requirements ? { requirements } : {}, ...scope };
426
+ const resources = cleanGateResources(value);
427
+ return { gates, ...resources ? { resources } : {}, ...deliveryPolicy, ...iterate ? { iterate } : {}, ...cd ? { cd } : {}, ...placement ? { placement } : {}, ...requirements ? { requirements } : {}, ...scope };
428
+ }
429
+ function cleanGateResources(value) {
430
+ if (!isRecord(value))
431
+ throw new ValidationError("land-gates config must be an object");
432
+ if (value.resources === undefined)
433
+ return;
434
+ if (!isRecord(value.resources))
435
+ throw new ValidationError("resources must be an object");
436
+ const resources = {};
437
+ for (const [kind, request] of Object.entries(value.resources)) {
438
+ if (!HOST_GATE_KINDS.includes(kind))
439
+ throw new ValidationError(`resources: unknown gate kind '${kind}'; use land, iterate or validation`);
440
+ if (!isRecord(request))
441
+ throw new ValidationError(`resources.${kind} must be an object`);
442
+ const parsed = {};
443
+ for (const [key, n] of Object.entries(request)) {
444
+ if (key !== "cpus" && key !== "memoryBytes")
445
+ throw new ValidationError(`resources.${kind}: unknown field '${key}'`);
446
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0 || key === "memoryBytes" && !Number.isSafeInteger(n)) {
447
+ throw new ValidationError(`resources.${kind}.${key} must be a positive ${key === "memoryBytes" ? "safe integer" : "finite number"}`);
448
+ }
449
+ parsed[key] = n;
450
+ }
451
+ resources[kind] = parsed;
452
+ }
453
+ return resources;
424
454
  }
425
455
  function cleanDeliveryGatePolicy(value) {
426
456
  if (!isRecord(value))
@@ -1,5 +1,5 @@
1
1
  import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
- import { findProviderCatalog, findProviderCatalogModel, type SpawnProvider } from "@humwick/sdk";
2
+ import { type SpawnProvider } from "@humwick/sdk";
3
3
  import { tmuxCommand, tmuxHasSession } from "@humwick/sdk/tmux-utils";
4
4
  import type { ManagedProcess, RunnerSpawnConfig } from "../adapter";
5
5
  import { effortSwitchEnabledFromEnv } from "../config";
@@ -24,9 +24,7 @@ export function claudeEffortLaunch(effort: string | undefined, inheritedEnv: Rec
24
24
 
25
25
  /** Persistent transports can switch effort. Host-base `/effort` would save into the human's own settings. */
26
26
  export function claudeEffortSwitchMeta(provider: SpawnProvider, config: RunnerSpawnConfig, oneShot: boolean): { effortSwitch?: true } {
27
- const catalog = findProviderCatalog(provider);
28
- const model = catalog && findProviderCatalogModel(catalog, config.model ?? catalog.defaultModel);
29
- return effortSwitchEnabledFromEnv() && !oneShot && providerHomePathFor(provider, config) && model?.efforts.length ? { effortSwitch: true } : {};
27
+ return effortSwitchEnabledFromEnv() && !oneShot && providerHomePathFor(provider, config) && (config.modelRecord?.efforts?.length || config.effort) ? { effortSwitch: true } : {};
30
28
  }
31
29
 
32
30
  interface ClaudeEffortOutcome { applied?: string; message: string }
@@ -10,6 +10,7 @@ import { hostProviderDefaultArgs } from "../host-asset-plan";
10
10
  import { bundledSkillConfigArgs } from "../launch-assembly";
11
11
  import { codexRetiredMcpConfigArgs } from "./codex-effective-config";
12
12
  import { codexWebToolsConfigArgs } from "../web-tools";
13
+ import type { ResolvedModelRecord } from "@humwick/sdk/provider-catalog";
13
14
  import { tomlString } from "../relay-mcp";
14
15
 
15
16
  export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
@@ -31,14 +32,12 @@ export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
31
32
  // advertise longer polls to the model (#2776): agents are steered to end the turn and take the wake.
32
33
  const CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS = 900000;
33
34
 
34
- export function codexModelConfigArgs(model?: string, effort?: string): string[] {
35
+ export function codexModelConfigArgs(model?: string, effort?: string, record?: ResolvedModelRecord): string[] {
35
36
  const args: string[] = [];
36
37
  if (model) args.push("-c", `model=${tomlString(model)}`);
37
38
  if (effort) args.push("-c", `model_reasoning_effort=${tomlString(effort)}`);
38
- // The catalog alias `astra` resolves to this provider model before runner launch.
39
- if (model === "gpt-6-astra") {
40
- args.push("-c", "model_context_window=1000000", "-c", "model_auto_compact_token_limit=272000");
41
- }
39
+ if (record?.contextWindowTokens) args.push("-c", `model_context_window=${record.contextWindowTokens}`);
40
+ if (record?.contextWindowTokens && record.contextThreshold) args.push("-c", `model_auto_compact_token_limit=${Math.floor(record.contextWindowTokens * record.contextThreshold.selfCompactUtilization)}`);
42
41
  return args;
43
42
  }
44
43
 
@@ -688,7 +688,7 @@ export class CodexAdapter implements ProviderAdapter {
688
688
  ? [
689
689
  "app-server",
690
690
  ...codexAppServerConfigArgs(defaultArgs, config.providerArgs),
691
- ...codexModelConfigArgs(config.model, config.effort),
691
+ ...codexModelConfigArgs(config.model, config.effort, config.modelRecord),
692
692
  ...codexApprovalConfigArgs(config.approvalMode),
693
693
  ...codexToolOutputTokenLimitConfigArgs(config), ...codexHarnessConfigArgs(config), ...codexWebToolsConfigArgs(config),
694
694
  ...assembled.args,
@@ -35,14 +35,6 @@ export function openCodeBackendApiKeyEnvVar(backendID: string): string | undefin
35
35
  return OPENCODE_STATIC_BACKENDS[backendID]?.envVar;
36
36
  }
37
37
 
38
- /** Catalog alias → provider model (older runner protocols carry the alias without a record). */
39
- function openCodeAliasResolved(model: string | undefined, manifest: ProviderManifest, provider: string): string {
40
- const alias = model?.trim() || manifest.catalog.defaultModel;
41
- const raw = manifest.catalog.models.find((entry) => entry.alias === alias)?.providerModel || alias;
42
- if (!raw) throw new Error(`OpenCode provider ${provider} has no default model.`);
43
- return raw;
44
- }
45
-
46
38
  /**
47
39
  * #2659 — the launch model: THE one normalization of a spawn's model through the shared seam
48
40
  * (`resolveOpenCodeModelId`). A server-threaded record's qualified name wins over the alias.
@@ -51,8 +43,9 @@ function openCodeAliasResolved(model: string | undefined, manifest: ProviderMani
51
43
  * default-backend model id.
52
44
  */
53
45
  export function openCodeLaunchModel(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord, registry?: OpenCodeModelRegistry): OpenCodeModelId {
54
- const { manifest, backend } = openCodeBackend(provider);
55
- const input = record?.qualifiedModel || openCodeAliasResolved(model, manifest, provider);
46
+ const { backend } = openCodeBackend(provider);
47
+ const input = record?.qualifiedModel || model?.trim();
48
+ if (!input) throw new Error(`OpenCode provider ${provider} requires a hub-resolved model.`);
56
49
  return resolveOpenCodeModelId(input, backend.opencodeProviderID, registry);
57
50
  }
58
51
 
@@ -111,18 +104,10 @@ export function openCodeCustomBackendIDs(): string[] {
111
104
  return getAllManifests().flatMap((entry) => entry.opencode && OPENCODE_STATIC_BACKENDS[entry.opencode.opencodeProviderID]?.custom ? [entry.opencode.opencodeProviderID] : []);
112
105
  }
113
106
 
114
- /** Read the current backend's declared context window from the manifest catalog. */
115
- export function openCodeContextWindowTokens(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): number | undefined {
116
- // #2599 — precedence payload > manifest > undefined: the record's window wins; a malformed
117
- // record window never reaches here (the shared parser drops it), so no second gate is needed.
118
- if (typeof record?.contextWindowTokens === "number" && Number.isFinite(record.contextWindowTokens) && record.contextWindowTokens > 0) {
119
- openCodeBackend(provider); // keep the unknown-provider hard error on the record path too.
120
- return record.contextWindowTokens;
121
- }
122
- const { manifest } = openCodeBackend(provider);
123
- const { modelID } = openCodeLaunchModel(model, provider);
124
- const value = manifest.catalog.models.find((entry) => entry.alias === modelID || entry.providerModel === modelID)?.limits?.contextWindowTokens?.value;
125
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
107
+ /** Context limits come from the hub-resolved launch record. */
108
+ export function openCodeContextWindowTokens(_model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): number | undefined {
109
+ openCodeBackend(provider);
110
+ return record?.contextWindowTokens;
126
111
  }
127
112
 
128
113
  /**
@@ -1505,7 +1505,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
1505
1505
  const tokens = countTokens(info?.tokens);
1506
1506
  const sessionUnattributable = Boolean(activeSessionId && sessionID !== activeSessionId);
1507
1507
  if (process && role === "assistant" && tokens !== undefined && !sessionUnattributable) {
1508
- const model = stringValue(process.meta?.model) ?? openCodeQualifiedModel(undefined, this.provider);
1508
+ const model = stringValue(process.meta?.model);
1509
1509
  this.reportContextUsage(process, model, tokens);
1510
1510
  }
1511
1511
  // #2484 — same door, reused for the spine report: model/createdAt prefer the message's
@@ -1516,7 +1516,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
1516
1516
  scheduleOpenCodeTokenUsage(this.tokenUsagePoster, {
1517
1517
  provider: this.provider,
1518
1518
  sessionId: activeSessionId ?? sessionID,
1519
- model: openCodeMessageModel(info) ?? stringValue(process.meta?.model) ?? openCodeQualifiedModel(undefined, this.provider),
1519
+ model: openCodeMessageModel(info) ?? stringValue(process.meta?.model),
1520
1520
  messageId: id,
1521
1521
  tokens: info?.tokens,
1522
1522
  createdAt: openCodeMessageObservedAt(info),
@@ -1832,8 +1832,8 @@ export class OpenCodeAdapter implements ProviderAdapter {
1832
1832
  for (const relayContextInput = prepareOpenCodeRelayContext(process, text);;) {
1833
1833
  const client = process.meta?.client as OpenCodeClient | undefined;
1834
1834
  const sessionId = stringValue(process.meta?.sessionId);
1835
- const model = stringValue(process.meta?.model) ?? openCodeQualifiedModel(undefined, this.provider);
1836
- if (!client || !sessionId) throw new Error("OpenCode session is not ready");
1835
+ const model = stringValue(process.meta?.model);
1836
+ if (!client || !sessionId || !model) throw new Error("OpenCode session is not ready with a hub-resolved model");
1837
1837
  // #2397 round 5 review (LOW-2) — raised only once we're actually about to run an attempt
1838
1838
  // (past the guard above), so a "session not ready" throw never emits a close for a turn
1839
1839
  // that never started (never emitted its own busy edge below).
@@ -14,13 +14,26 @@ interface CredentialsDeps {
14
14
  publishRenewalFailure(): void;
15
15
  logRunnerDiagnostic(message: string): void;
16
16
  reportBackgroundRejection(scope: string, status: string, title: string, error: unknown): void;
17
+ /** Recovery gave up: the runner must exit so Relay sees a terminal failure, not a mint loop (#3775). */
18
+ onTerminalAuthFailure(reason: string): void;
17
19
  }
18
20
 
19
21
  const TOKEN_RENEW_LOG_INTERVAL_MS = 5 * 60 * 1000;
20
22
  const TOKEN_RENEW_RETRY_MS = 60_000;
21
- // Debounce reactive token recovery so a burst of 401-ing calls in the same window
22
- // triggers a single re-mint attempt, not one per failing request.
23
- const REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS = 10_000;
23
+ // #3775 — a successful mint does not prove the hub accepts the runner: an orphaned runner kept
24
+ // getting 401 on its fresh token and re-minted ~2/s. Auth recoveries back off (1 s doubling to
25
+ // 5 min, jittered) and stop after a bounded count; only a quiet spell resets the count.
26
+ export const AUTH_RECOVERY_MAX_ATTEMPTS = 10;
27
+ const AUTH_RECOVERY_BASE_MS = 1_000;
28
+ const AUTH_RECOVERY_MAX_DELAY_MS = 5 * 60_000;
29
+ const AUTH_RECOVERY_QUIET_RESET_MS = 30 * 60_000;
30
+
31
+ /** Delay before auth recovery `attempt` (0-based): the first heals at once, later ones back off with equal jitter. */
32
+ export function authRecoveryDelayMs(attempt: number, random: () => number = Math.random): number {
33
+ if (attempt <= 0) return 0;
34
+ const ceiling = Math.min(AUTH_RECOVERY_MAX_DELAY_MS, AUTH_RECOVERY_BASE_MS * 2 ** (attempt - 1));
35
+ return Math.round(ceiling / 2 + random() * (ceiling / 2));
36
+ }
24
37
 
25
38
  export class Credentials {
26
39
  private currentToken?: string;
@@ -30,7 +43,10 @@ export class Credentials {
30
43
  private tokenRenewTimer?: Timer;
31
44
  private readonly tokenRotation = new TokenRotation();
32
45
  private tokenRenewLastLog?: { key: string; at: number };
33
- private reactiveTokenRecoveryAt?: number;
46
+ private authRecoveryAttempts = 0;
47
+ private authRecoveryLastAt?: number;
48
+ private authRecoveryTimer?: Timer;
49
+ private authRecoveryExhausted = false;
34
50
 
35
51
  constructor(options: { token?: string; tokenJti?: string; tokenProfileId?: string; tokenExpiresAt?: number }, private readonly deps: CredentialsDeps) {
36
52
  this.currentToken = options.token;
@@ -47,6 +63,8 @@ export class Credentials {
47
63
  stop(): void {
48
64
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
49
65
  this.tokenRenewTimer = undefined;
66
+ if (this.authRecoveryTimer) clearTimeout(this.authRecoveryTimer);
67
+ this.authRecoveryTimer = undefined;
50
68
  }
51
69
 
52
70
  // Timer/edge callers cannot await renewal; report an escaping rejection so it cannot
@@ -55,22 +73,35 @@ export class Credentials {
55
73
  void this.renewRuntimeToken().catch((error) => this.deps.reportBackgroundRejection("runtime token renewal", "runtime-token-renewal-failed", "Runtime token renewal aborted", error));
56
74
  }
57
75
 
58
- // The TTL timer cannot detect revocation: recover on auth failure, coalescing a burst.
76
+ // The TTL timer cannot detect revocation: recover on auth failure, coalescing a burst into the
77
+ // one recovery in flight or scheduled.
59
78
  // Orchestrator re-mint heals stale/expired tokens but preserves hard revocation (#484).
60
79
  recoverRuntimeTokenAfterAuthFailure(source: string): void {
61
- if (this.deps.isStopped() || this.tokenRotation.inFlight) return;
80
+ if (this.deps.isStopped() || this.authRecoveryExhausted || this.tokenRotation.inFlight || this.authRecoveryTimer) return;
62
81
  if (!this.isRuntimeTokenRenewable() && !this.canRemintViaOrchestrator()) return;
63
82
  const now = Date.now();
64
- if (this.reactiveTokenRecoveryAt && now - this.reactiveTokenRecoveryAt < REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS) return;
65
- this.reactiveTokenRecoveryAt = now;
66
- this.deps.logRunnerDiagnostic(`[runner] relay auth failure on ${source}; recovering runtime token`);
67
- this.renewRuntimeTokenInBackground();
83
+ if (this.authRecoveryLastAt !== undefined && now - this.authRecoveryLastAt >= AUTH_RECOVERY_QUIET_RESET_MS) this.authRecoveryAttempts = 0;
84
+ if (this.authRecoveryAttempts >= AUTH_RECOVERY_MAX_ATTEMPTS) {
85
+ this.authRecoveryExhausted = true;
86
+ this.stop();
87
+ this.deps.onTerminalAuthFailure(`runtime-token-auth-failed: relay still rejects the runner (last on ${source}) after ${AUTH_RECOVERY_MAX_ATTEMPTS} token recoveries`);
88
+ return;
89
+ }
90
+ const delayMs = authRecoveryDelayMs(this.authRecoveryAttempts);
91
+ this.authRecoveryAttempts += 1;
92
+ this.authRecoveryLastAt = now;
93
+ this.deps.logRunnerDiagnostic(`[runner] relay auth failure on ${source}; recovering runtime token (attempt ${this.authRecoveryAttempts}/${AUTH_RECOVERY_MAX_ATTEMPTS}${delayMs ? ` in ${Math.ceil(delayMs / 1000)}s` : ""})`);
94
+ if (!delayMs) return this.renewRuntimeTokenInBackground();
95
+ this.authRecoveryTimer = setTimeout(() => {
96
+ this.authRecoveryTimer = undefined;
97
+ this.renewRuntimeTokenInBackground();
98
+ }, delayMs);
68
99
  }
69
100
 
70
101
  scheduleRuntimeTokenRenewal(delayMs?: number): void {
71
102
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
72
103
  this.tokenRenewTimer = undefined;
73
- if (this.deps.isStopped()) return;
104
+ if (this.deps.isStopped() || this.authRecoveryExhausted) return;
74
105
  const canSelfRenew = this.isRuntimeTokenRenewable();
75
106
  const canRemint = this.canRemintViaOrchestrator();
76
107
  // Keep the renewal clock ticking as long as the session can recover its token
@@ -152,7 +183,6 @@ export class Credentials {
152
183
  this.currentTokenProfileId = record.profileId ?? this.currentTokenProfileId;
153
184
  this.currentTokenExpiresAt = record.expiresAt;
154
185
  this.deps.onTokenChanged(token, record);
155
- this.reactiveTokenRecoveryAt = undefined;
156
186
  this.deps.resumeLivenessAndPublishRotation(status, record.jti);
157
187
  this.scheduleRuntimeTokenRenewal();
158
188
  }
@@ -0,0 +1,27 @@
1
+ import type { Message } from "@humwick/sdk";
2
+ import type { HumwickHttpClient } from "@humwick/sdk/http-client";
3
+ import { isNotificationOnlyInput } from "../inbox-carrier";
4
+ import { logger } from "../logger";
5
+
6
+ export async function bootstrapUnreadMessages(http: Pick<HumwickHttpClient, "pollMessages">, agentId: string, enqueue: (message: Message) => void): Promise<void> {
7
+ try {
8
+ const messages = await http.pollMessages({ for: agentId, unread: true, limit: 100, turnDelivery: true });
9
+ for (const message of messages) enqueue(message);
10
+ } catch (error) { logger.error("runner", `inbox bootstrap failed: ${error}`); }
11
+ }
12
+
13
+ /** Keep startup failure visible to each sender, including mail queued before the check (#3751). */
14
+ export async function reportUnavailableMessages(
15
+ http: Pick<HumwickHttpClient, "sendMessage" | "markRead">, agentId: string,
16
+ pending: Map<number, Message>, settled: Set<number>, reason: string,
17
+ ): Promise<void> {
18
+ for (const [id, message] of pending) {
19
+ try {
20
+ if (!isNotificationOnlyInput(message)) await http.sendMessage({ from: agentId, to: message.from, replyTo: id,
21
+ body: reason, idempotencyKey: `provider-startup-${agentId}-${id}`, payload: { kind: "notification" } });
22
+ await http.markRead(id, agentId);
23
+ pending.delete(id);
24
+ settled.add(id);
25
+ } catch (error) { logger.error("runner", `startup failure reply failed: ${error}`); }
26
+ }
27
+ }
@@ -15,7 +15,6 @@ interface LifecycleMilestoneDeps {
15
15
  process(): ManagedProcess | undefined;
16
16
  stopAttachmentCacheSweep(): void;
17
17
  stopTestRunDedupSweep(): void;
18
- stopGateRunLeaseProbe(): void;
19
18
  disposeEmptyTurns(): void;
20
19
  resetEmptyTurns(): void;
21
20
  resetLateClearRecovery(): void;
@@ -92,7 +91,6 @@ export class LifecycleMilestones {
92
91
  private backgroundStopped(): void {
93
92
  this.deps.stopAttachmentCacheSweep();
94
93
  this.deps.stopTestRunDedupSweep();
95
- this.deps.stopGateRunLeaseProbe();
96
94
  // #2389 — before the shutdown await, not after: an escalation or settle timer firing during a
97
95
  // graceful provider shutdown would publish a "produced nothing" verdict about a turn the
98
96
  // shutdown itself ended.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env -S bun --no-env-file
2
+ import { runCli } from "../index";
3
+
4
+ runCli("claude");
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env -S bun --no-env-file
2
+ import { runCli } from "../index";
3
+
4
+ runCli("codex");
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env -S bun --no-env-file
2
+ import { runCli } from "../index";
3
+
4
+ runCli("opencode");
package/src/index.ts CHANGED
@@ -375,8 +375,9 @@ export function detectApprovalModeFromArgs(provider: string, defaultArgs: string
375
375
  return detector ? detector([...defaultArgs, ...providerArgs]) : undefined;
376
376
  }
377
377
 
378
- if (import.meta.main) {
379
- main().catch((error) => {
378
+ export function runCli(provider?: string): void {
379
+ const argv = provider ? [...process.argv.slice(0, 2), provider, ...process.argv.slice(2)] : process.argv;
380
+ main(argv).catch((error) => {
380
381
  // A fatal startup failure (e.g. the runner could not register with the relay within the
381
382
  // deadline) must exit non-zero, not hang or swallow — the orchestrator's exit detection
382
383
  // then reports it and the spawn-parent is notified instead of waiting on a silent zombie.
@@ -384,3 +385,5 @@ if (import.meta.main) {
384
385
  exitRunner(1, `runner fatal: ${errMessage(error)}`);
385
386
  });
386
387
  }
388
+
389
+ if (import.meta.main) runCli();
@@ -0,0 +1,34 @@
1
+ import { errMessage } from "@humwick/sdk";
2
+ import { checkProviderAuth } from "@humwick/sdk/provider-probe";
3
+ import { selectProviderConfigEnv } from "@humwick/sdk/child-env";
4
+ import type { ProviderAdapter, ProviderStatusEvent } from "./adapter";
5
+ import { logger } from "./logger";
6
+ import { hostSourcesFor } from "./host-asset-plan";
7
+
8
+ /** All real adapters share the managed-spawn login check, including replacements (#3751). */
9
+ export function withProviderSetup(adapter: ProviderAdapter): ProviderAdapter {
10
+ const spawn = adapter.spawn.bind(adapter);
11
+ adapter.spawn = async (config) => {
12
+ const env = selectProviderConfigEnv(config.providerConfig.env, hostSourcesFor(config).settings);
13
+ const auth = await checkProviderAuth(config.provider, undefined, env, config.providerConfig.command);
14
+ if (!auth.ok) throw new Error(auth.reason);
15
+ return spawn(config);
16
+ };
17
+ return adapter;
18
+ }
19
+
20
+ export function providerStartupFailure(error: unknown): ProviderStatusEvent {
21
+ const message = `Provider startup failed: ${errMessage(error)}`;
22
+ return { status: "error", reason: "provider-turn", providerState: {
23
+ state: "blocked", terminal: true, reason: "providerSetupRequired", label: "Provider setup required", message,
24
+ recommendedAction: "Complete the provider login and first-run setup on this host, then restart the runner.",
25
+ } };
26
+ }
27
+
28
+ export async function attemptProviderStartup(start: () => Promise<void>, failed: (status: ProviderStatusEvent) => void): Promise<void> {
29
+ try { await start(); }
30
+ catch (error) {
31
+ logger.error("startup", errMessage(error));
32
+ failed(providerStartupFailure(error));
33
+ }
34
+ }
package/src/providers.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { withProviderSetup } from "./provider-setup";
1
2
  import { ClaudeApiAdapter } from "./adapters/claude-api";
2
3
  import type { LaunchMode, SpawnProvider } from "@humwick/sdk";
3
4
  import { getAllManifests, supportedLaunchModesForProvider } from "@humwick/providers";
@@ -31,5 +32,5 @@ export function createProviderAdapter(provider: SpawnProvider, providerArgs: str
31
32
  if (launchMode && !supportedLaunchModesForProvider(provider).includes(launchMode)) {
32
33
  throw new Error(`${provider} does not support launchMode=${launchMode}`);
33
34
  }
34
- return registration.createAdapter(providerArgs, launchMode);
35
+ return withProviderSetup(registration.createAdapter(providerArgs, launchMode));
35
36
  }
@@ -1,3 +1,5 @@
1
+ import { attemptProviderStartup } from "./provider-setup";
2
+ import { bootstrapUnreadMessages, reportUnavailableMessages } from "./agent-runner/inbox";
1
3
  import { writeChildEnv } from "@humwick/sdk/env";
2
4
  import { normalizeDeliverResult } from "./delivery-result";
3
5
  import { TokenUsageReportCoverage } from "./agent-runner/token-usage-coverage";
@@ -34,7 +36,6 @@ import { AttachmentCacheSweep } from "./attachment-cache-sweep";
34
36
  import { resolveTestRunRootPid, TEST_RUN_DEDUP_SWEEP_MS, TestRunDedupSweep } from "./test-run-dedup";
35
37
  import { cachedGateRunCommandsForCwd } from "./gate-run-config";
36
38
  import { AreaMemoryTracker } from "./area-memory";
37
- import { GateRunLeaseProbe } from "./gate-run-lease-probe";
38
39
  import { ClaimTracker } from "./claim-tracker";
39
40
  import { ProviderQuotaHarvest } from "./adapters/provider-quota-harvest";
40
41
  import { agentShellLaunchEnv } from "./adapters/project-shell";
@@ -224,7 +225,6 @@ export class AgentRunner {
224
225
  private livenessReconcileTimer?: Timer;
225
226
  private readonly attachmentCacheSweep: AttachmentCacheSweep;
226
227
  private readonly testRunDedupSweep: TestRunDedupSweep;
227
- private readonly gateRunLeaseProbe: GateRunLeaseProbe;
228
228
  private processStartedAt = 0;
229
229
  private providerSessionId = crypto.randomUUID();
230
230
  // Last transcript path seen this session — used by end-of-session Insights (#184)
@@ -363,7 +363,7 @@ export class AgentRunner {
363
363
  });
364
364
  this.lifecycleMilestones = new LifecycleMilestones({
365
365
  options: this.options, agentId: this.agentId, process: () => this.process,
366
- stopAttachmentCacheSweep: () => this.attachmentCacheSweep.stop(), stopTestRunDedupSweep: () => this.testRunDedupSweep.stop(), stopGateRunLeaseProbe: () => this.gateRunLeaseProbe.stop(),
366
+ stopAttachmentCacheSweep: () => this.attachmentCacheSweep.stop(), stopTestRunDedupSweep: () => this.testRunDedupSweep.stop(),
367
367
  disposeEmptyTurns: () => this.emptyTurns.dispose(), resetEmptyTurns: () => this.emptyTurns.reset(), resetLateClearRecovery: () => { this.lateClearRecovery.reset(); this.continuationFence.reset(); },
368
368
  clearLifecycleAction: () => this.lifecycle.setLifecycleAction(undefined), setOffline: () => this.claims.setTerminalStatus("offline"), publishStatus: () => this.publishStatus(),
369
369
  stopDrainTimer: () => { if (this.drainTimer) clearTimeout(this.drainTimer); this.drainTimer = undefined; }, stopSubmitRecoveryPoll: () => this.stopSubmitRecoveryPoll(),
@@ -409,14 +409,12 @@ export class AgentRunner {
409
409
  const sessionProviderPid = () => resolveTestRunRootPid(this.process, (managedProcess) => this.options.adapter.resolveBusyPid?.(managedProcess));
410
410
  // #2461 — ancestry alone made this sweep a no-op for `relay_run` gate runs (a disjoint tree
411
411
  // under the orchestrator). It now also owns anything env-tagged with this agent's id, classifies
412
- // against the repo's CONFIGURED gate commands, and defers to the launch-time lease holder. The
412
+ // against the repo's CONFIGURED gate commands, and reads live leases during each sweep. The
413
413
  // config read is cached for the session — it is a repo file, so re-reading it every 15s for the
414
- // agent's whole life buys nothing. All three helpers live in their own leaves.
415
- this.gateRunLeaseProbe = new GateRunLeaseProbe(this.agentId, TEST_RUN_DEDUP_SWEEP_MS);
414
+ // agent's whole life buys nothing.
416
415
  this.testRunDedupSweep = new TestRunDedupSweep({
417
416
  rootPid: sessionProviderPid, agentId: () => this.agentId,
418
417
  gateCommands: () => cachedGateRunCommandsForCwd(this.options.cwd),
419
- leaseHolderPid: () => this.gateRunLeaseProbe.pid(),
420
418
  protectedPids: () => [process.pid], log: (message) => this.logRunnerDiagnostic(message),
421
419
  });
422
420
  this.context.configureLogger();
@@ -450,6 +448,7 @@ export class AgentRunner {
450
448
  },
451
449
  logRunnerDiagnostic: (message) => this.logRunnerDiagnostic(message),
452
450
  reportBackgroundRejection: (scope, status, title, error) => this.reportBackgroundRejection(scope, status, title, error),
451
+ onTerminalAuthFailure: (reason) => { logger.error("runner", `exiting: ${reason}`); this.options.onProviderExit?.(1, reason); },
453
452
  });
454
453
  const token = this.credentials.current();
455
454
  if (token) publishRuntimeTokenForCli(token, this.credentials.expiresAt, (message) => this.logRunnerDiagnostic(message));
@@ -733,16 +732,17 @@ export class AgentRunner {
733
732
  this.ensureScratch();
734
733
  void sweepRunnerStaleScratch({ listAgents: () => this.http.listAgents(), agentId: this.agentId, cwd: this.options.cwd, fallbackBaseDir: orchestratorBaseDirFromEnv(), log: (message) => this.logRunnerDiagnostic(message) });
735
734
  this.scheduleAttachmentCacheSweep();
736
- this.adoptProcess(await this.spawnProvider());
737
- this.registration.writeRunnerInfoFile();
738
- await this.promptCommands.startLaunchSeededTurn();
739
- this.publishStatus();
740
- await this.promptCommands.deliverInitialPrompt();
735
+ await attemptProviderStartup(async () => {
736
+ this.adoptProcess(await this.spawnProvider());
737
+ this.registration.writeRunnerInfoFile();
738
+ await this.promptCommands.startLaunchSeededTurn();
739
+ this.publishStatus();
740
+ await this.promptCommands.deliverInitialPrompt();
741
+ }, (status) => this.setProviderStatus(status));
741
742
  await this.bootstrapUnreadMessages();
742
743
  this.taskClaims.start();
743
744
  this.startHttpLiveness();
744
745
  this.testRunDedupSweep.start();
745
- this.gateRunLeaseProbe.start();
746
746
  this.credentials.scheduleRuntimeTokenRenewal();
747
747
  this.scheduleDrain();
748
748
  }
@@ -790,6 +790,7 @@ export class AgentRunner {
790
790
  }
791
791
 
792
792
  private handleBusRegistered(): void {
793
+ if (this.process) this.publishStatus();
793
794
  void this.nativeSelfResume.reconcile((id) => this.http.request("GET", `/api/commands/${encodeURIComponent(id)}`)).catch((error) => this.sessionLog(`compact settlement reconcile: ${errMessage(error)}`));
794
795
  // #2973 — a re-register follows a relay restart (a deploy): re-list so changed tools reach the agent.
795
796
  void this.proxy?.refreshTools().catch(() => {}); this.effort.trigger();
@@ -941,14 +942,7 @@ export class AgentRunner {
941
942
  this.scheduleDrain();
942
943
  }
943
944
 
944
- private async bootstrapUnreadMessages(): Promise<void> {
945
- try {
946
- const messages = await this.http.pollMessages({ for: this.agentId, unread: true, limit: 100, turnDelivery: true });
947
- for (const message of messages) this.enqueueMessage(message);
948
- } catch (error) {
949
- logger.error("runner", `inbox bootstrap failed: ${error}`);
950
- }
951
- }
945
+ private bootstrapUnreadMessages = () => bootstrapUnreadMessages(this.http, this.agentId, (message) => this.enqueueMessage(message));
952
946
 
953
947
  private scheduleDrain(delayMs = 0): void {
954
948
  if (this.lifecycle.stopped || this.drainTimer || this.deliverySubmitHold || this.submitResendInFlight) return;
@@ -1363,7 +1357,8 @@ export class AgentRunner {
1363
1357
  private async drainMessages(): Promise<void> {
1364
1358
  if (this.lifecycle.stopped || this.delivering || this.deliverySubmitHold || this.submitResendInFlight || this.pendingMessages.size === 0) return;
1365
1359
  if (!this.process) {
1366
- this.scheduleDrain(500);
1360
+ if (this.terminalFailure) await reportUnavailableMessages(this.http, this.agentId, this.pendingMessages, this.settledMessageIds, this.terminalFailure.message);
1361
+ if (this.pendingMessages.size) this.scheduleDrain(500);
1367
1362
  return;
1368
1363
  }
1369
1364
  this.delivering = true;
@@ -2822,7 +2817,7 @@ export class AgentRunner {
2822
2817
  ?? (activeWork.length === 0 ? this.emptyTurns.providerState() ?? null : null);
2823
2818
  const published = this.publishRunnerStatus({
2824
2819
  agentStatus,
2825
- ready: agentStatus !== "offline" && !this.lifecycle.stopped && !teardownLifecycle && !this.startupPaneDialog && !this.deliverySubmitHold,
2820
+ ready: Boolean(this.process) && agentStatus !== "offline" && !this.lifecycle.stopped && !teardownLifecycle && !this.startupPaneDialog && !this.deliverySubmitHold,
2826
2821
  meta: {
2827
2822
  ...runnerIdentityMeta(this.options, { tmuxSession: providerTerminalSession(this.process), tmuxSocket: providerTerminalSocket(this.process), auth: { profileId: this.credentials.profileId, jti: this.credentials.jti, expiresAt: this.credentials.expiresAt } }, "status"),
2828
2823
  lifecycleAction: this.lifecycle.lifecycleAction ?? null,
@@ -105,6 +105,7 @@ export function parseRunnerCliArgs(
105
105
  else if (arg === "--model-record-provider-model" && ownArgs[i + 1]) modelRecordRaw.providerModel = ownArgs[++i];
106
106
  else if (arg === "--model-record-qualified" && ownArgs[i + 1]) modelRecordRaw.qualifiedModel = ownArgs[++i];
107
107
  else if (arg === "--model-record-effort" && ownArgs[i + 1]) modelRecordRaw.effort = ownArgs[++i];
108
+ else if (arg === "--model-record-efforts" && ownArgs[i + 1]) { try { modelRecordRaw.efforts = JSON.parse(ownArgs[++i]!); } catch {} }
108
109
  else if (arg === "--model-record-window" && ownArgs[i + 1]) {
109
110
  const parsed = Number(ownArgs[++i]);
110
111
  if (Number.isSafeInteger(parsed) && parsed > 0) modelRecordRaw.contextWindowTokens = parsed;
@@ -143,5 +144,5 @@ export function parseRunnerCliArgs(
143
144
  }
144
145
 
145
146
  export function runnerHelp(provider: string): string {
146
- return `${provider}-relay [--headless] [--launch-mode interactive|headless] [--model ALIAS] [--effort LEVEL] [--context-window-tokens N] [--model-record-alias A --model-record-provider-model M --model-record-qualified Q --model-record-effort E --model-record-window N --model-record-max-output N --model-record-threshold JSON] [--rig NAME] [--profile NAME] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [--prompt TEXT] [--system-prompt-append TEXT] [--tags a,b] [--caps x,y] [-- provider-args...]`;
147
+ return `${provider}-humwick [--headless] [--launch-mode interactive|headless] [--model ALIAS] [--effort LEVEL] [--context-window-tokens N] [--model-record-alias A --model-record-provider-model M --model-record-qualified Q --model-record-effort E --model-record-window N --model-record-max-output N --model-record-threshold JSON] [--rig NAME] [--profile NAME] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [--prompt TEXT] [--system-prompt-append TEXT] [--tags a,b] [--caps x,y] [-- provider-args...]`;
147
148
  }