@humwick/runner 0.130.1 → 0.130.3

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 (84) 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/claude-headless.ts +1 -1
  6. package/src/adapters/claude-launch-exit.ts +26 -0
  7. package/src/adapters/claude-session-probe.ts +1 -1
  8. package/src/adapters/claude-tmux.ts +38 -10
  9. package/src/adapters/claude.ts +28 -75
  10. package/src/adapters/codex-launch-config.ts +4 -5
  11. package/src/adapters/codex.ts +1 -1
  12. package/src/adapters/opencode-provider.ts +7 -22
  13. package/src/adapters/opencode.ts +4 -4
  14. package/src/adapters/provider-quota-harvest.ts +20 -71
  15. package/src/agent-runner/credentials.ts +42 -12
  16. package/src/agent-runner/inbox.ts +27 -0
  17. package/src/agent-runner/lifecycle-milestones.ts +2 -4
  18. package/src/agent-runner/liveness-exchange.ts +104 -0
  19. package/src/bin/claude.ts +4 -0
  20. package/src/bin/codex.ts +4 -0
  21. package/src/bin/opencode.ts +4 -0
  22. package/src/claude-system-prompt.ts +49 -0
  23. package/src/index.ts +5 -2
  24. package/src/launch-assembly.ts +14 -10
  25. package/src/profile-home.ts +1 -1
  26. package/src/project-instructions.ts +2 -2
  27. package/src/provider-command.ts +18 -0
  28. package/src/provider-setup.ts +35 -0
  29. package/src/providers.ts +2 -1
  30. package/src/runner-core.ts +32 -38
  31. package/src/runner-helpers.ts +0 -8
  32. package/src/session-scratch.ts +6 -2
  33. package/src/spawn-input.ts +2 -1
  34. package/src/test-run-dedup.ts +59 -56
  35. package/node_modules/@humwick/offload/LICENSE +0 -167
  36. package/node_modules/@humwick/offload/README.md +0 -57
  37. package/node_modules/@humwick/offload/dist/async-runner.d.ts +0 -18
  38. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +0 -1
  39. package/node_modules/@humwick/offload/dist/async-runner.js +0 -158
  40. package/node_modules/@humwick/offload/dist/async-runner.js.map +0 -1
  41. package/node_modules/@humwick/offload/dist/envelope.d.ts +0 -25
  42. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +0 -1
  43. package/node_modules/@humwick/offload/dist/envelope.js +0 -80
  44. package/node_modules/@humwick/offload/dist/envelope.js.map +0 -1
  45. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +0 -10
  46. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +0 -1
  47. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +0 -59
  48. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +0 -1
  49. package/node_modules/@humwick/offload/dist/index.d.ts +0 -5
  50. package/node_modules/@humwick/offload/dist/index.d.ts.map +0 -1
  51. package/node_modules/@humwick/offload/dist/index.js +0 -5
  52. package/node_modules/@humwick/offload/dist/index.js.map +0 -1
  53. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +0 -62
  54. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +0 -1
  55. package/node_modules/@humwick/offload/dist/subprocess-runner.js +0 -295
  56. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +0 -1
  57. package/node_modules/@humwick/offload/dist/types.d.ts +0 -111
  58. package/node_modules/@humwick/offload/dist/types.d.ts.map +0 -1
  59. package/node_modules/@humwick/offload/dist/types.js +0 -176
  60. package/node_modules/@humwick/offload/dist/types.js.map +0 -1
  61. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +0 -23
  62. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +0 -1
  63. package/node_modules/@humwick/offload/dist/worker-pool.js +0 -435
  64. package/node_modules/@humwick/offload/dist/worker-pool.js.map +0 -1
  65. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +0 -2
  66. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +0 -1
  67. package/node_modules/@humwick/offload/dist/worker-runtime.js +0 -27
  68. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +0 -1
  69. package/node_modules/@humwick/offload/dist/worker.d.ts +0 -5
  70. package/node_modules/@humwick/offload/dist/worker.d.ts.map +0 -1
  71. package/node_modules/@humwick/offload/dist/worker.js +0 -1
  72. package/node_modules/@humwick/offload/dist/worker.js.map +0 -1
  73. package/node_modules/@humwick/offload/package.json +0 -42
  74. package/node_modules/@humwick/offload/src/async-runner.ts +0 -170
  75. package/node_modules/@humwick/offload/src/envelope.ts +0 -92
  76. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +0 -49
  77. package/node_modules/@humwick/offload/src/index.ts +0 -4
  78. package/node_modules/@humwick/offload/src/subprocess-runner.ts +0 -334
  79. package/node_modules/@humwick/offload/src/types.ts +0 -235
  80. package/node_modules/@humwick/offload/src/worker-pool.ts +0 -463
  81. package/node_modules/@humwick/offload/src/worker-runtime.ts +0 -32
  82. package/node_modules/@humwick/offload/src/worker.ts +0 -5
  83. package/src/agent-runner/http-liveness.ts +0 -85
  84. package/src/gate-run-lease-probe.ts +0 -38
@@ -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";
@@ -9,7 +11,7 @@ import { PromptCommands, promptOrigin } from "./agent-runner/commands/prompt";
9
11
  import type { NativeSelfResumeHandoff } from "./native-self-resume";
10
12
  import { LifecyclePhase } from "./agent-runner/lifecycle-phase";
11
13
  import { startRunnerMcpProxy, runnerWebToolLog, runnerWebSearch, runnerMcpHiddenTools } from "./runner-mcp-proxy";
12
- import { HttpLiveness } from "./agent-runner/http-liveness";
14
+ import { LivenessExchange } from "./agent-runner/liveness-exchange";
13
15
  import { TaskClaims } from "./agent-runner/task-claims";
14
16
  import { TurnReport } from "./agent-runner/turn-report";
15
17
  import { SessionMirror } from "./agent-runner/session-mirror";
@@ -20,7 +22,7 @@ import { hostname } from "node:os";
20
22
  import { resolveEnvLayers, selectProviderConfigEnv } from "@humwick/sdk/child-env";
21
23
  import { createHash } from "node:crypto";
22
24
  import { join } from "node:path";
23
- import { type ContextState, type Message, type ProviderState, HELD_FOR_PAYLOAD_KEY, PROVIDER_SUBMIT_UNCONFIRMED, PROXY_AUTH_HOLD_KIND, errMessage, frameSuppressedFromTurn, stringValue } from "@humwick/sdk";
25
+ import { type ContextState, type Message, type ProviderState, HELD_FOR_PAYLOAD_KEY, PROVIDER_SUBMIT_UNCONFIRMED, PROXY_AUTH_HOLD_KIND, errMessage, frameSuppressedFromTurn, relayBusUrl, stringValue } from "@humwick/sdk";
24
26
  import { exitRunner } from "./runner-exit";
25
27
  import { isResumeWake, ProviderHoldEpisodes } from "./provider-hold";
26
28
  import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "@humwick/sdk/context-probe";
@@ -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";
@@ -87,7 +88,6 @@ import {
87
88
  providerMonitorUnavailableMessage,
88
89
  providerStateFromActiveWork,
89
90
  registerWithinDeadline,
90
- relayBusUrl,
91
91
  runnerAgentStatus,
92
92
  runnerBusErrorAction,
93
93
  runnerMessageMatches,
@@ -217,14 +217,13 @@ export class AgentRunner {
217
217
  private terminalProviderExit?: Record<string, unknown>;
218
218
  private nativeSelfResume = new NativeSelfResumeTracker();
219
219
  private readonly registration: Registration;
220
- private readonly httpLiveness: HttpLiveness;
220
+ private readonly liveness: LivenessExchange;
221
221
  private readonly taskClaims: TaskClaims;
222
222
  private delivering = false;
223
223
  private drainTimer?: Timer;
224
224
  private livenessReconcileTimer?: Timer;
225
225
  private readonly attachmentCacheSweep: AttachmentCacheSweep;
226
226
  private readonly testRunDedupSweep: TestRunDedupSweep;
227
- private readonly gateRunLeaseProbe: GateRunLeaseProbe;
228
227
  private processStartedAt = 0;
229
228
  private providerSessionId = crypto.randomUUID();
230
229
  // Last transcript path seen this session — used by end-of-session Insights (#184)
@@ -363,11 +362,11 @@ export class AgentRunner {
363
362
  });
364
363
  this.lifecycleMilestones = new LifecycleMilestones({
365
364
  options: this.options, agentId: this.agentId, process: () => this.process,
366
- stopAttachmentCacheSweep: () => this.attachmentCacheSweep.stop(), stopTestRunDedupSweep: () => this.testRunDedupSweep.stop(), stopGateRunLeaseProbe: () => this.gateRunLeaseProbe.stop(),
365
+ stopAttachmentCacheSweep: () => this.attachmentCacheSweep.stop(), stopTestRunDedupSweep: () => this.testRunDedupSweep.stop(),
367
366
  disposeEmptyTurns: () => this.emptyTurns.dispose(), resetEmptyTurns: () => this.emptyTurns.reset(), resetLateClearRecovery: () => { this.lateClearRecovery.reset(); this.continuationFence.reset(); },
368
367
  clearLifecycleAction: () => this.lifecycle.setLifecycleAction(undefined), setOffline: () => this.claims.setTerminalStatus("offline"), publishStatus: () => this.publishStatus(),
369
368
  stopDrainTimer: () => { if (this.drainTimer) clearTimeout(this.drainTimer); this.drainTimer = undefined; }, stopSubmitRecoveryPoll: () => this.stopSubmitRecoveryPoll(),
370
- stopTaskClaims: () => this.taskClaims.stop(), stopHttpLiveness: () => this.httpLiveness.stop(), stopCredentials: () => this.credentials.stop(),
369
+ stopTaskClaims: () => this.taskClaims.stop(), stopLiveness: () => this.liveness.stop(), stopCredentials: () => this.credentials.stop(),
371
370
  disarmBusyLivenessReconcile: () => this.disarmBusyLivenessReconcile(), disarmBusyReconciler: () => this.busyReconciler.disarm(),
372
371
  disposeProviderInput: () => this.providerInput.dispose(), disposeParkPosts: () => this.parkPosts.dispose(),
373
372
  stopReasoningTail: () => this.sessionMirror.stopReasoningTail(), clearNativeSessionCapture: () => this.sessionMirror.clearNativeSessionCapture(), stopObligationCache: () => this.obligationCache.stop(),
@@ -409,14 +408,12 @@ export class AgentRunner {
409
408
  const sessionProviderPid = () => resolveTestRunRootPid(this.process, (managedProcess) => this.options.adapter.resolveBusyPid?.(managedProcess));
410
409
  // #2461 — ancestry alone made this sweep a no-op for `relay_run` gate runs (a disjoint tree
411
410
  // 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
411
+ // against the repo's CONFIGURED gate commands, and reads live leases during each sweep. The
413
412
  // 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);
413
+ // agent's whole life buys nothing.
416
414
  this.testRunDedupSweep = new TestRunDedupSweep({
417
415
  rootPid: sessionProviderPid, agentId: () => this.agentId,
418
416
  gateCommands: () => cachedGateRunCommandsForCwd(this.options.cwd),
419
- leaseHolderPid: () => this.gateRunLeaseProbe.pid(),
420
417
  protectedPids: () => [process.pid], log: (message) => this.logRunnerDiagnostic(message),
421
418
  });
422
419
  this.context.configureLogger();
@@ -435,11 +432,11 @@ export class AgentRunner {
435
432
  void this.proxy?.refreshTools().catch(() => {});
436
433
  },
437
434
  resumeLivenessAndPublishRotation: (status, jti) => {
438
- this.httpLiveness.resetAuthFailure();
435
+ this.liveness.resetAuthFailure();
439
436
  // An earlier auth failure may have stopped the liveness loop; restart it so the
440
- // agent reports live again on the fresh token. startHttpLiveness clears any
437
+ // agent reports live again on the fresh token. startLiveness clears any
441
438
  // existing timer first, so this is safe on the normal (proactive) renew path too.
442
- this.startHttpLiveness();
439
+ this.startLiveness();
443
440
  this.pendingTimelineEvent = { status, id: jti, timestamp: Date.now() };
444
441
  this.bus.reconnectTransport(status === "runtime-token-reminted" ? "runtime token re-minted" : "runtime token renewed");
445
442
  this.publishStatus();
@@ -450,6 +447,7 @@ export class AgentRunner {
450
447
  },
451
448
  logRunnerDiagnostic: (message) => this.logRunnerDiagnostic(message),
452
449
  reportBackgroundRejection: (scope, status, title, error) => this.reportBackgroundRejection(scope, status, title, error),
450
+ onTerminalAuthFailure: (reason) => { logger.error("runner", `exiting: ${reason}`); this.options.onProviderExit?.(1, reason); },
453
451
  });
454
452
  const token = this.credentials.current();
455
453
  if (token) publishRuntimeTokenForCli(token, this.credentials.expiresAt, (message) => this.logRunnerDiagnostic(message));
@@ -470,14 +468,14 @@ export class AgentRunner {
470
468
  // #2514 — the per-provider config env carries a dedicated-home provider's (claude-b's) own
471
469
  // credential, which the quota harvest needs to key its reports under the B subscription.
472
470
  providerConfigEnv: () => this.options.providerConfig.env,
473
- http: () => this.http,
474
471
  });
475
- this.httpLiveness = new HttpLiveness({
476
- http: this.http, agentId: this.agentId, instanceId: options.instanceId, logIntervalMs: HTTP_LIVENESS_LOG_INTERVAL_MS,
472
+ this.liveness = new LivenessExchange({
473
+ http: this.http, bus: () => this.bus, agentId: this.agentId, instanceId: options.instanceId, logIntervalMs: HTTP_LIVENESS_LOG_INTERVAL_MS,
477
474
  isStopped: () => this.lifecycle.stopped, isTerminalStatusLatched: () => this.lifecycle.terminalStatusLatched,
478
475
  currentStatus: () => this.claims.currentStatus(),
479
- publishQuota: () => this.providerQuotaHarvest.publish(),
480
- bootstrapUnreadMessages: () => this.bootstrapUnreadMessages(), triggerEffort: () => this.effort.trigger(),
476
+ sampleQuota: () => this.providerQuotaHarvest.sample(),
477
+ acceptQuota: (report, result) => this.providerQuotaHarvest.accept(report, result),
478
+ enqueueMessage: (message) => this.enqueueMessage(message), triggerEffort: () => this.effort.trigger(),
481
479
  recoverRuntimeTokenAfterAuthFailure: (source) => this.credentials.recoverRuntimeTokenAfterAuthFailure(source),
482
480
  logRunnerDiagnostic: (message) => this.logRunnerDiagnostic(message),
483
481
  });
@@ -733,16 +731,17 @@ export class AgentRunner {
733
731
  this.ensureScratch();
734
732
  void sweepRunnerStaleScratch({ listAgents: () => this.http.listAgents(), agentId: this.agentId, cwd: this.options.cwd, fallbackBaseDir: orchestratorBaseDirFromEnv(), log: (message) => this.logRunnerDiagnostic(message) });
735
733
  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();
734
+ await attemptProviderStartup(async () => {
735
+ this.adoptProcess(await this.spawnProvider());
736
+ this.registration.writeRunnerInfoFile();
737
+ await this.promptCommands.startLaunchSeededTurn();
738
+ this.publishStatus();
739
+ await this.promptCommands.deliverInitialPrompt();
740
+ }, (status) => this.setProviderStatus(status));
741
741
  await this.bootstrapUnreadMessages();
742
742
  this.taskClaims.start();
743
- this.startHttpLiveness();
743
+ this.startLiveness();
744
744
  this.testRunDedupSweep.start();
745
- this.gateRunLeaseProbe.start();
746
745
  this.credentials.scheduleRuntimeTokenRenewal();
747
746
  this.scheduleDrain();
748
747
  }
@@ -790,6 +789,7 @@ export class AgentRunner {
790
789
  }
791
790
 
792
791
  private handleBusRegistered(): void {
792
+ if (this.process) this.publishStatus();
793
793
  void this.nativeSelfResume.reconcile((id) => this.http.request("GET", `/api/commands/${encodeURIComponent(id)}`)).catch((error) => this.sessionLog(`compact settlement reconcile: ${errMessage(error)}`));
794
794
  // #2973 — a re-register follows a relay restart (a deploy): re-list so changed tools reach the agent.
795
795
  void this.proxy?.refreshTools().catch(() => {}); this.effort.trigger();
@@ -941,14 +941,7 @@ export class AgentRunner {
941
941
  this.scheduleDrain();
942
942
  }
943
943
 
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
- }
944
+ private bootstrapUnreadMessages = () => bootstrapUnreadMessages(this.http, this.agentId, (message) => this.enqueueMessage(message));
952
945
 
953
946
  private scheduleDrain(delayMs = 0): void {
954
947
  if (this.lifecycle.stopped || this.drainTimer || this.deliverySubmitHold || this.submitResendInFlight) return;
@@ -1363,7 +1356,8 @@ export class AgentRunner {
1363
1356
  private async drainMessages(): Promise<void> {
1364
1357
  if (this.lifecycle.stopped || this.delivering || this.deliverySubmitHold || this.submitResendInFlight || this.pendingMessages.size === 0) return;
1365
1358
  if (!this.process) {
1366
- this.scheduleDrain(500);
1359
+ if (this.terminalFailure) await reportUnavailableMessages(this.http, this.agentId, this.pendingMessages, this.settledMessageIds, this.terminalFailure.message);
1360
+ if (this.pendingMessages.size) this.scheduleDrain(500);
1367
1361
  return;
1368
1362
  }
1369
1363
  this.delivering = true;
@@ -2822,7 +2816,7 @@ export class AgentRunner {
2822
2816
  ?? (activeWork.length === 0 ? this.emptyTurns.providerState() ?? null : null);
2823
2817
  const published = this.publishRunnerStatus({
2824
2818
  agentStatus,
2825
- ready: agentStatus !== "offline" && !this.lifecycle.stopped && !teardownLifecycle && !this.startupPaneDialog && !this.deliverySubmitHold,
2819
+ ready: Boolean(this.process) && agentStatus !== "offline" && !this.lifecycle.stopped && !teardownLifecycle && !this.startupPaneDialog && !this.deliverySubmitHold,
2826
2820
  meta: {
2827
2821
  ...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
2822
  lifecycleAction: this.lifecycle.lifecycleAction ?? null,
@@ -2866,7 +2860,7 @@ export class AgentRunner {
2866
2860
  if (published && this.pendingTimelineEvent === timelineEvent) this.pendingTimelineEvent = undefined;
2867
2861
  }
2868
2862
 
2869
- private startHttpLiveness(): void { this.httpLiveness.start(); }
2863
+ private startLiveness(): void { this.liveness.start(); }
2870
2864
 
2871
2865
  private logRunnerDiagnostic(message: string): void { this.context.logRunnerDiagnostic(message); }
2872
2866
  private sessionLog(message: string): void { this.context.sessionLog(message); }
@@ -133,14 +133,6 @@ export function csvTags(raw: string | undefined): string[] {
133
133
  return (raw || "").split(",").map((tag) => tag.trim()).filter(Boolean);
134
134
  }
135
135
 
136
- export function relayBusUrl(relayUrl: string): string {
137
- const url = new URL(relayUrl);
138
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
139
- url.pathname = `${url.pathname.replace(/\/+$/, "")}/bus`;
140
- url.search = "";
141
- return url.toString();
142
- }
143
-
144
136
  export function runnerAgentStatus(status: "idle" | "busy" | "offline" | "error"): "idle" | "busy" | "offline" {
145
137
  return status === "error" ? "offline" : status;
146
138
  }
@@ -194,9 +194,13 @@ export function reapSessionScratch(target: SessionScratchTarget): void {
194
194
  // scratch dir reap to avoid leaking one file per spawn.
195
195
  export function reapSessionLauncher(target: SessionLauncherTarget): string[] {
196
196
  const removed: string[] = [];
197
- for (const path of dedupePaths([
197
+ const launchers = dedupePaths([
198
198
  target.launcherScript,
199
199
  target.tmuxSession ? launcherScriptPathForSession(target.tmuxSession, target.launcherDir) : undefined,
200
+ ]);
201
+ for (const path of dedupePaths([
202
+ ...launchers,
203
+ ...launchers.flatMap(path => [`${path}.exit-code`, `${path}.exit-code.stderr`]),
200
204
  target.tmuxSession ? launcherGatePathForSession(target.tmuxSession, target.launcherDir) : undefined,
201
205
  ])) {
202
206
  try {
@@ -286,7 +290,7 @@ export async function sweepStaleLaunchersAsync(opts: LauncherSweepOptions): Prom
286
290
  try {
287
291
  const current = await stat(scriptPath);
288
292
  if (!current.isFile() || opts.now - current.mtimeMs < minAge) continue;
289
- const agentId = await launcherAgentId(scriptPath);
293
+ const agentId = await launcherAgentId(scriptPath.replace(/\.exit-code(?:\.stderr)?$/, ""));
290
294
  if (agentId && opts.keepAgentIds.has(agentId)) continue;
291
295
  await rm(scriptPath, { force: true });
292
296
  removed.push(scriptPath);
@@ -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
  }
@@ -1,41 +1,14 @@
1
1
  import { relayEnv } from "@humwick/sdk/env";
2
- // #2142 — per-agent test-run dedup ("latest wins").
3
- //
4
- // Workers run `bun run test:affected` after every file change. When a change touches something
5
- // broad, a single run takes 10-15 minutes — and the agent starts the NEXT run long before the
6
- // previous one finishes. The stacked runs saturate the host: each one holds a gate slot, forks
7
- // its own shard processes, and the oldest run's result is thrown away the moment it lands.
8
- // Instructions don't fix this (an agent rationalizes around them), so the bound has to be
9
- // structural and live BELOW the agent: the runner watches its own session's process tree and,
10
- // whenever it sees more than one independent test run, kills every run except the newest.
11
- //
12
- // Scoping is per-agent BY CONSTRUCTION: the sweep only ever looks at processes it can PROVE belong
13
- // to this agent — descendants of this session's provider process (the tmux pane pid / direct child
14
- // pid the adapter already resolves for busy-liveness), plus processes carrying this agent's
15
- // AGENT_RELAY_ID in their environment. A sibling agent's runs satisfy neither test, so this can
16
- // never fratricide the fleet the way a host-wide `pkill -f` would (#753). It is also
17
- // provider-agnostic — nothing here knows whether the tree belongs to Claude or Codex.
18
- //
19
- // #2461 — the env-tag half of that ownership test is new, and it is why this file exists at all
20
- // after the lease landed. Ancestry ALONE made this a no-op for the case the issue reports: a
21
- // `relay_run` gate run executes under the ORCHESTRATOR daemon, a tree that shares no ancestor with
22
- // the provider pane, so a descendant-scoped sweep could never see it. The orchestrator now stamps
23
- // AGENT_RELAY_ID onto those children, which is what makes them attributable here.
24
- //
25
- // This sweep is now the BACKSTOP, not the primary mechanism. Kill-replace happens at LAUNCH, in
26
- // sdk/src/gate-run-lease.ts, entered from `scripts/gate-slot` and the orchestrator. What is left
27
- // for the sweep is the residual shape neither front door sees: a gate run started straight from the
28
- // provider shell in a repo that does not route through `gate.sh`. It is 15s-late by construction —
29
- // that is the documented coverage gap, tracked for a launch-exact PATH shim in the follow-up issue.
30
- // When a live lease holder exists it is AUTHORITATIVE: the sweep keeps it and kills the rest, so
31
- // the two mechanisms can never disagree about which run survives.
2
+ // #3754 — the lease owns launch-time replacement. The sweep only deduplicates
3
+ // unleased runs with the same captured checkout/revision; ancestry proves ownership,
4
+ // but cannot prove two commands tested the same tree. Live leases protect whole runs.
32
5
  import { execFile } from "node:child_process";
33
6
  import { readFileSync, readdirSync } from "node:fs";
34
7
  import { promisify } from "node:util";
35
8
  import { errMessage } from "@humwick/sdk";
36
9
  import { killPid, waitForPidsExit } from "@humwick/sdk/process-utils";
37
10
  import { classifyGateRun, isTestRunCommand, type GateRunCommands } from "@humwick/sdk/gate-run-classify";
38
- import { GATE_RUN_AGENT_ENV } from "@humwick/sdk/gate-run-lease";
11
+ import { GATE_RUN_AGENT_ENV, GATE_RUN_CONTEXT_ENV, GATE_RUN_HELD_ENV, readLiveGateRunHolderPids } from "@humwick/sdk/gate-run-lease";
39
12
  import type { ManagedProcess } from "./adapter";
40
13
 
41
14
  // Re-exported so the sweep and both lease front doors share ONE definition of "is this a test run".
@@ -215,6 +188,9 @@ export interface TestRunDedupScope {
215
188
  /** Classifier override. Defaults to the argv-shape heuristic; the sweep passes a classifier bound
216
189
  * to the repo's CONFIGURED gate commands so a foreign runner (`pnpm tier:run …`) is matched too. */
217
190
  isGateRun?: (args: string) => boolean;
191
+ /** Launch-time checkout/revision identity. Missing identity cannot prove a duplicate. */
192
+ contextByPid?: ReadonlyMap<number, string>;
193
+ leasedPids?: readonly number[];
218
194
  }
219
195
 
220
196
  export function planTestRunDedup(
@@ -278,29 +254,34 @@ export function planTestRunDedup(
278
254
  }
279
255
  if (runRoots.length < 2) return EMPTY_PLAN;
280
256
 
281
- // Lower elapsed = started later = the run the agent actually wants. `ps etime` only has
282
- // second precision, though, and a Codex agent can launch two commands within that second.
283
- // Pids are allocated in start order within the host pid namespace, so the higher pid is the
284
- // newer root for this otherwise-unresolvable tie. Without this deterministic tie-breaker, two
285
- // same-second delivery runs remain tied forever and #2142 fails to bound the exact burst it
286
- // was added for. The scope is still this session's process tree only.
287
- // A live lease holder wins outright — it is the run the launch-time mechanism already chose. Only
288
- // when there is none (an ungated run in a repo without gate.sh) does newest-wins decide.
289
- const leaseKeep = scope.keepPid !== undefined && runRoots.includes(scope.keepPid) ? scope.keepPid : undefined;
290
- const keptPid = leaseKeep ?? runRoots.reduce((newest, pid) => {
291
- const candidate = byPid.get(pid)!;
292
- const current = byPid.get(newest)!;
293
- return candidate.elapsedSeconds < current.elapsedSeconds
294
- || (candidate.elapsedSeconds === current.elapsedSeconds && pid > newest)
295
- ? pid
296
- : newest;
297
- });
298
- const newestElapsed = byPid.get(keptPid)!.elapsedSeconds;
299
- const staleRoots = runRoots
300
- // With a lease holder the age comparison is meaningless — the holder may be the OLDER run, and
301
- // it still wins. Without one, keep the original guard so a mis-ranked row is never killed.
302
- .filter((pid) => pid !== keptPid && (leaseKeep !== undefined || byPid.get(pid)!.elapsedSeconds >= newestElapsed))
303
- .sort((a, b) => a - b);
257
+ // A lease may belong to a child of the outer shell. Spare the WHOLE run, including
258
+ // ancestors and descendants, rather than signalling its owner indirectly (#3754).
259
+ const leased = new Set(scope.leasedPids ?? []);
260
+ const groups = new Map<string, number[]>();
261
+ const leasedRoots = new Set<number>();
262
+ for (const pid of runRoots) {
263
+ if (subtree(pid).some((child) => leased.has(child))) leasedRoots.add(pid);
264
+ const context = scope.contextByPid ? scope.contextByPid.get(pid) : "legacy";
265
+ if (!context) continue;
266
+ const group = groups.get(context) ?? [];
267
+ group.push(pid);
268
+ groups.set(context, group);
269
+ }
270
+ const staleRoots: number[] = [];
271
+ let keptPid: number | undefined;
272
+ for (const group of groups.values()) {
273
+ if (group.length < 2) continue;
274
+ const leaseKeep = scope.keepPid !== undefined && group.includes(scope.keepPid) ? scope.keepPid : undefined;
275
+ const newest = group.find((pid) => leasedRoots.has(pid)) ?? leaseKeep ?? group.reduce((current, pid) => {
276
+ const candidate = byPid.get(pid)!;
277
+ const prior = byPid.get(current)!;
278
+ return candidate.elapsedSeconds < prior.elapsedSeconds
279
+ || (candidate.elapsedSeconds === prior.elapsedSeconds && pid > current) ? pid : current;
280
+ });
281
+ keptPid = newest;
282
+ staleRoots.push(...group.filter((pid) => pid !== newest && !leasedRoots.has(pid)));
283
+ }
284
+ staleRoots.sort((a, b) => a - b);
304
285
  if (staleRoots.length === 0) return EMPTY_PLAN;
305
286
 
306
287
  const protectedSet = new Set([rootPid, ...protectedPids]);
@@ -334,6 +315,17 @@ export async function readProcessTable(timeoutMs: number = PROCESS_TABLE_TIMEOUT
334
315
  return stdout;
335
316
  }
336
317
 
318
+ /** Only launch-time metadata can distinguish revisions; today's HEAD cannot (#3754). */
319
+ export function readTestRunContext(pid: number): { context?: string; leased?: boolean } {
320
+ try {
321
+ const env = Object.fromEntries(readFileSync(`/proc/${pid}/environ`, "utf8").split("\0").map((entry) => {
322
+ const equals = entry.indexOf("=");
323
+ return [entry.slice(0, equals), entry.slice(equals + 1)];
324
+ }));
325
+ return { context: relayEnv(env, GATE_RUN_CONTEXT_ENV), leased: !!relayEnv(env, GATE_RUN_HELD_ENV) };
326
+ } catch { return {}; }
327
+ }
328
+
337
329
  export interface TestRunDedupDeps {
338
330
  /** Resolve this session's provider pid. undefined ⇒ ancestry proves nothing; the env tag may. */
339
331
  rootPid: () => number | undefined;
@@ -344,6 +336,8 @@ export interface TestRunDedupDeps {
344
336
  gateCommands?: () => GateRunCommands;
345
337
  /** The live lease holder pid, when one exists — authoritative over newest-wins. */
346
338
  leaseHolderPid?: () => number | undefined;
339
+ readRunContext?: (pid: number) => { context?: string; leased?: boolean };
340
+ liveLeasePids?: () => Promise<number[]>;
347
341
  collectOwnedPids?: (agentId: string) => Promise<number[]>;
348
342
  readProcessTable?: () => Promise<string>;
349
343
  kill?: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
@@ -374,8 +368,17 @@ export async function runTestRunDedupSweep(deps: TestRunDedupDeps): Promise<numb
374
368
  }
375
369
 
376
370
  const gateCommands = deps.gateCommands?.() ?? {};
377
- const plan = planTestRunDedup(parseProcessTable(table), rootPid, deps.protectedPids?.() ?? [], {
378
- ownedPids,
371
+ const rows = parseProcessTable(table);
372
+ const contextByPid = new Map<number, string>();
373
+ const leasedPids = await (deps.liveLeasePids ?? (() => agentId ? readLiveGateRunHolderPids(agentId) : Promise.resolve([])))();
374
+ for (const row of rows) {
375
+ if (classifyGateRun(row.args, gateCommands) === null) continue;
376
+ const context = (deps.readRunContext ?? readTestRunContext)(row.pid);
377
+ if (context.context) contextByPid.set(row.pid, context.context);
378
+ if (context.leased) leasedPids.push(row.pid);
379
+ }
380
+ const plan = planTestRunDedup(rows, rootPid, deps.protectedPids?.() ?? [], {
381
+ ownedPids, contextByPid, leasedPids,
379
382
  keepPid: deps.leaseHolderPid?.(),
380
383
  isGateRun: (args) => classifyGateRun(args, gateCommands) !== null,
381
384
  });
@@ -1,167 +0,0 @@
1
- # PolyForm Shield License 1.0.0
2
-
3
- <https://polyformproject.org/licenses/shield/1.0.0>
4
-
5
- ## Acceptance
6
-
7
- In order to get any license under these terms, you must agree
8
- to them as both strict obligations and conditions to all
9
- your licenses.
10
-
11
- ## Copyright License
12
-
13
- The licensor grants you a copyright license for the
14
- software to do everything you might do with the software
15
- that would otherwise infringe the licensor's copyright
16
- in it for any permitted purpose. However, you may
17
- only distribute the software according to [Distribution
18
- License](#distribution-license) and make changes or new works
19
- based on the software according to [Changes and New Works
20
- License](#changes-and-new-works-license).
21
-
22
- ## Distribution License
23
-
24
- The licensor grants you an additional copyright license
25
- to distribute copies of the software. Your license
26
- to distribute covers distributing the software with
27
- changes and new works permitted by [Changes and New Works
28
- License](#changes-and-new-works-license).
29
-
30
- ## Notices
31
-
32
- You must ensure that anyone who gets a copy of any part of
33
- the software from you also gets a copy of these terms or the
34
- URL for them above, as well as copies of any plain-text lines
35
- beginning with `Required Notice:` that the licensor provided
36
- with the software. For example:
37
-
38
- > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
39
-
40
- ## Changes and New Works License
41
-
42
- The licensor grants you an additional copyright license to
43
- make changes and new works based on the software for any
44
- permitted purpose.
45
-
46
- ## Patent License
47
-
48
- The licensor grants you a patent license for the software that
49
- covers patent claims the licensor can license, or becomes able
50
- to license, that you would infringe by using the software.
51
-
52
- ## Noncompete
53
-
54
- Any purpose is a permitted purpose, except for providing any
55
- product that competes with the software or any product the
56
- licensor or any of its affiliates provides using the software.
57
-
58
- ## Competition
59
-
60
- Goods and services compete even when they provide functionality
61
- through different kinds of interfaces or for different technical
62
- platforms. Applications can compete with services, libraries
63
- with plugins, frameworks with development tools, and so on,
64
- even if they're written in different programming languages
65
- or for different computer architectures. Goods and services
66
- compete even when provided free of charge. If you market a
67
- product as a practical substitute for the software or another
68
- product, it definitely competes.
69
-
70
- ## New Products
71
-
72
- If you are using the software to provide a product that does
73
- not compete, but the licensor or any of its affiliates brings
74
- your product into competition by providing a new version of
75
- the software or another product using the software, you may
76
- continue using versions of the software available under these
77
- terms beforehand to provide your competing product, but not
78
- any later versions.
79
-
80
- ## Discontinued Products
81
-
82
- You may begin using the software to compete with a product
83
- or service that the licensor or any of its affiliates has
84
- stopped providing, unless the licensor includes a plain-text
85
- line beginning with `Licensor Line of Business:` with the
86
- software that mentions that line of business. For example:
87
-
88
- > Licensor Line of Business: YoyodyneCMS Content Management
89
- System (http://example.com/cms)
90
-
91
- ## Sales of Business
92
-
93
- If the licensor or any of its affiliates sells a line of
94
- business developing the software or using the software
95
- to provide a product, the buyer can also enforce
96
- [Noncompete](#noncompete) for that product.
97
-
98
- ## Fair Use
99
-
100
- You may have "fair use" rights for the software under the
101
- law. These terms do not limit them.
102
-
103
- ## No Other Rights
104
-
105
- These terms do not allow you to sublicense or transfer any of
106
- your licenses to anyone else, or prevent the licensor from
107
- granting licenses to anyone else. These terms do not imply
108
- any other licenses.
109
-
110
- ## Patent Defense
111
-
112
- If you make any written claim that the software infringes or
113
- contributes to infringement of any patent, your patent license
114
- for the software granted under these terms ends immediately. If
115
- your company makes such a claim, your patent license ends
116
- immediately for work on behalf of your company.
117
-
118
- ## Violations
119
-
120
- The first time you are notified in writing that you have
121
- violated any of these terms, or done anything with the software
122
- not covered by your licenses, your licenses can nonetheless
123
- continue if you come into full compliance with these terms,
124
- and take practical steps to correct past violations, within
125
- 32 days of receiving notice. Otherwise, all your licenses
126
- end immediately.
127
-
128
- ## No Liability
129
-
130
- ***As far as the law allows, the software comes as is, without
131
- any warranty or condition, and the licensor will not be liable
132
- to you for any damages arising out of these terms or the use
133
- or nature of the software, under any kind of legal claim.***
134
-
135
- ## Definitions
136
-
137
- The **licensor** is the individual or entity offering these
138
- terms, and the **software** is the software the licensor makes
139
- available under these terms.
140
-
141
- A **product** can be a good or service, or a combination
142
- of them.
143
-
144
- **You** refers to the individual or entity agreeing to these
145
- terms.
146
-
147
- **Your company** is any legal entity, sole proprietorship,
148
- or other kind of organization that you work for, plus all
149
- its affiliates.
150
-
151
- **Affiliates** means the other organizations than an
152
- organization has control over, is under the control of, or is
153
- under common control with.
154
-
155
- **Control** means ownership of substantially all the assets of
156
- an entity, or the power to direct its management and policies
157
- by vote, contract, or otherwise. Control can be direct or
158
- indirect.
159
-
160
- **Your licenses** are all the licenses granted to you for the
161
- software under these terms.
162
-
163
- **Use** means anything you do with the software requiring one
164
- of your licenses.
165
-
166
- Required Notice: Copyright 2026 Exelerus AB
167
- Licensor Line of Business: provider-independent runtimes and coordination software for teams of AI agents
@@ -1,57 +0,0 @@
1
- # @humwick/offload
2
-
3
- Bounded execution primitives for CPU workers, cooperative async I/O, and subprocesses.
4
- All runners use `OffloadEnvelope` for timeout, abort, structured logging, labels, typed
5
- `OffloadError`, and injected escalation.
6
-
7
- ## Maintenance adoption
8
-
9
- `WorkerPool` and `SubprocessRunner` expose `offThread: true`; this is an intentional
10
- marker, not an automatic scheduler integration. A maintenance job that hands its heavy
11
- work to either runner must explicitly wire the existing #1757 seam immediately after
12
- starting it:
13
-
14
- ```ts
15
- const work = runner.run("heavy-job", input, { signal: context.signal });
16
- if (runner.offThread) context.markOffThread?.();
17
- await work;
18
- ```
19
-
20
- `AsyncRunner` is bounded but stays on the process event loop, so it does not advertise
21
- `offThread`.
22
-
23
- ## Shutdown
24
-
25
- Every runner has a teardown that cancels rather than waits: `WorkerPool.destroy()`,
26
- `AsyncRunner.destroy()` and `SubprocessRunner.destroy()` reject outstanding work with an
27
- `aborted` `OffloadError`, kill live subprocess groups, and refuse new work. `drain()` is
28
- the opposite — it waits for accepted work to finish and cancels nothing.
29
-
30
- Because subprocess children run in their own process group, they are reparented rather
31
- than reaped when the host dies; the runner installs a single process `exit` hook that
32
- SIGKILLs any group still alive. A host killed with SIGKILL cannot run that hook, so a
33
- supervisor is still the only guarantee there.
34
-
35
- ## Bounds worth knowing
36
-
37
- - `timeoutMs` (and abort) are hard for subprocesses: SIGTERM, then SIGKILL to the process
38
- group after `killGraceMs` (default 2s), so a child that traps SIGTERM cannot outlive
39
- its deadline. The concurrency slot is held until the child is actually reaped, so
40
- `stats` never reports a slot as free while its work is still running.
41
- - A non-zero exit is a failure by default (it rejects and takes an escalation strike). Probe
42
- callers whose answer *is* the exit code — `tmux has-session`, `which`, `git rev-parse`, a
43
- land gate — must declare it with `okExitCodes` (`[0, 1]`, or `"any"` when the failing codes
44
- are not enumerable). Without it the escalation signal inverts: the sink pages on a healthy
45
- fleet and then latches, so the real outage goes unreported.
46
- **`"any"` also accepts signal deaths** — 137 (SIGKILL, typically the OOM killer) and 139
47
- (SIGSEGV) come back as successes and re-arm the strike counter. That is deliberate for a
48
- land gate, where an OOM-killed gate is just a failed gate, but it means an infrastructure
49
- death reads as a healthy answer and never escalates. Prefer an explicit code list unless
50
- the failing codes genuinely cannot be enumerated; timeout and abort still throw either way.
51
- - Subprocess output is captured up to `maxOutputBytes` per stream (default 8 MiB) and the
52
- result carries `truncated`. The stream is still drained past the cap so a chatty child
53
- never blocks on a full pipe.
54
- - `AsyncRunner` work that ignores its signal keeps its slot until it returns: the cap
55
- degrades toward serial rather than over-admitting. `destroy()` still returns promptly.
56
- - A worker round-trip costs ~110µs, so tasks below roughly 1ms of CPU are not worth
57
- offloading. `run()` has no backpressure: the queue is unbounded by design.