@humwick/runner 0.0.0-stage → 0.130.1
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/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +42 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { errMessage, type Message } from "@humwick/sdk";
|
|
2
|
+
import { providerAttachmentLocalPaths, providerAttachmentText, PromptAcceptedError, type ManagedProcess, type PromptOfferOptions, type ProviderInputObserver, type ProviderPermissionDecision, type ProviderPermissionDecisionInput } from "../../adapter";
|
|
3
|
+
import { ProviderSubmitFailure } from "../../provider-submit-failure";
|
|
4
|
+
import type { EmptyTurnArm } from "../../empty-turn";
|
|
5
|
+
import type { ProviderInputTicket } from "../../provider-input-queue";
|
|
6
|
+
import { isTurnInProgressDeliveryError } from "../../runner-helpers";
|
|
7
|
+
import { logger } from "../../logger";
|
|
8
|
+
import type { RunnerTimelineEvent } from "../../turn-reconcile-event";
|
|
9
|
+
import type { RunnerLaunchOptions } from "../runner-context";
|
|
10
|
+
|
|
11
|
+
interface PromptCommandsDeps {
|
|
12
|
+
options: RunnerLaunchOptions;
|
|
13
|
+
agentId: string;
|
|
14
|
+
process(): ManagedProcess | undefined;
|
|
15
|
+
isStopped(): boolean;
|
|
16
|
+
armInput(now: number, mode?: "ladder", initial?: boolean): EmptyTurnArm;
|
|
17
|
+
disarmInput(arm: EmptyTurnArm): void;
|
|
18
|
+
inputObserver(arm: EmptyTurnArm): ProviderInputObserver;
|
|
19
|
+
recordInjectedPrompt(prompt: string, imagePaths?: string[]): void;
|
|
20
|
+
offerPromptOrPark(prompt: string, options: PromptOfferOptions, ticket?: ProviderInputTicket, onSubmitHeld?: () => void, heldFor?: string): Promise<"accepted" | "parked" | "not-durable" | "submit-held">;
|
|
21
|
+
offerProviderInput<T>(run: (observer?: ProviderInputObserver) => Promise<T>, offeredNothing?: (result: T) => boolean): Promise<T>;
|
|
22
|
+
terminalFailure(): Readonly<{ message: string }> | undefined;
|
|
23
|
+
sessionLog(message: string): void;
|
|
24
|
+
publishRunnerTimelineEvent(event: RunnerTimelineEvent): void;
|
|
25
|
+
holdFailedProviderSubmit(detail: string, held: { resend: () => Promise<unknown> }): void;
|
|
26
|
+
resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean | undefined;
|
|
27
|
+
reservePark(): ProviderInputTicket;
|
|
28
|
+
notePendingPrompt(messageId: number): void;
|
|
29
|
+
cacheAttachments(messages: Message[]): Promise<Message[]>;
|
|
30
|
+
parkPromptBehindSubmitHold(params: Record<string, unknown>, key: string, turn: ProviderInputTicket): Promise<Record<string, unknown>>;
|
|
31
|
+
waitForProviderTurnClear(): Promise<void>;
|
|
32
|
+
pendingQuestionApprovalId(): string | undefined;
|
|
33
|
+
clearPendingQuestionHold(): void;
|
|
34
|
+
publishStatus(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// #329: a cold isolated profile (first-run trust/onboarding/install gates) can take far longer
|
|
38
|
+
// than the warm-restart window to render an input-ready TUI, so the first prompt delivery gets a
|
|
39
|
+
// generous timeout. If it still misses, the prompt is re-attempted on the next provider ready
|
|
40
|
+
// signal (short timeout, since it just went idle), up to a bounded number of attempts.
|
|
41
|
+
const INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS = 30_000;
|
|
42
|
+
const INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS = 10_000;
|
|
43
|
+
const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
|
|
44
|
+
const PROMPT_NOT_DURABLE = "prompt refused under a provider hold and could not be parked durably; not delivered";
|
|
45
|
+
/** Who a prompt.inject came from (every producer stamps its session message's sender); parked copies keep it (#2664). */
|
|
46
|
+
export const promptOrigin = (params: Record<string, unknown>): string => [params.from, params.requestedBy].find((id): id is string => typeof id === "string" && id !== "") ?? "user";
|
|
47
|
+
|
|
48
|
+
export class PromptCommands {
|
|
49
|
+
// #329: a spawn-time initial prompt whose first delivery timed out before the TUI was
|
|
50
|
+
// ready. Held (not dropped) for a ready-signal-driven re-attempt; cleared on success.
|
|
51
|
+
private pendingInitialPrompt?: string;
|
|
52
|
+
private deliveringInitialPrompt = false;
|
|
53
|
+
private initialPromptAttempts = 0;
|
|
54
|
+
|
|
55
|
+
constructor(private readonly deps: PromptCommandsDeps) {}
|
|
56
|
+
|
|
57
|
+
noteLaunchSeededPrompt(opts: { resumeId?: string; suppressPrompt?: boolean }): string | undefined {
|
|
58
|
+
const launchSeededPrompt = this.deps.options.adapter.seedsInitialPromptAtLaunch && !opts.resumeId && !opts.suppressPrompt ? this.deps.options.prompt?.trim() : ""; if (launchSeededPrompt) { this.deps.recordInjectedPrompt(launchSeededPrompt); this.deps.armInput(Date.now()); }
|
|
59
|
+
return launchSeededPrompt;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async startLaunchSeededTurn(): Promise<void> {
|
|
63
|
+
if (this.deps.process()?.meta?.launchSeededTurn !== true) return;
|
|
64
|
+
try {
|
|
65
|
+
await this.deps.options.adapter.startLaunchSeededTurn?.(this.deps.process()!);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
this.deps.sessionLog(`launch-seeded turn watch failed: ${errMessage(error)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async deliverInitialPrompt(): Promise<void> {
|
|
72
|
+
const prompt = this.deps.options.prompt?.trim();
|
|
73
|
+
if (!prompt || this.deps.options.adapter.seedsInitialPromptAtLaunch) return;
|
|
74
|
+
await this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Deliver the spawn-time first prompt, surviving a cold-start TUI that isn't input-ready yet
|
|
78
|
+
// (#329). Timeout stashes it for ready-signal retry and surfaces the stall on the timeline.
|
|
79
|
+
private async attemptInitialPromptDelivery(prompt: string, readyTimeoutMs: number): Promise<void> {
|
|
80
|
+
if (this.deliveringInitialPrompt || this.deps.isStopped() || !this.deps.process() || !this.deps.options.adapter.deliverInitialPrompt) return;
|
|
81
|
+
this.deliveringInitialPrompt = true;
|
|
82
|
+
const attempt = (this.initialPromptAttempts += 1);
|
|
83
|
+
let arm: EmptyTurnArm | undefined;
|
|
84
|
+
try {
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
// #2389 — armed BEFORE the delivery is awaited. On OpenCode `deliverInitialPrompt` awaits the
|
|
87
|
+
// synchronous /message POST, which returns only when the assistant message is complete; an arm
|
|
88
|
+
// taken on resolution lands at or after that turn's own idle edge and misses it entirely.
|
|
89
|
+
arm = this.deps.armInput(Date.now(), "ladder", true);
|
|
90
|
+
this.deps.recordInjectedPrompt(prompt);
|
|
91
|
+
const outcome = await this.deps.offerPromptOrPark(prompt, { readyTimeoutMs, isInitial: true, inputObserver: this.deps.inputObserver(arm) });
|
|
92
|
+
// #2619 — a refused launch prompt that could not be made durable stays pending (re-offered
|
|
93
|
+
// on the next ready edge); a durably parked one redelivers from the inbox.
|
|
94
|
+
this.pendingInitialPrompt = outcome === "not-durable" ? prompt : undefined;
|
|
95
|
+
if (outcome !== "accepted") {
|
|
96
|
+
this.deps.sessionLog(`initial prompt attempt ${attempt} refused under a provider hold (${prompt.length} chars); ${outcome === "parked" ? "parked for redelivery" : "not durable, kept pending"}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// #2397 round 3 review (LOW-5r) — a synchronous-endpoint adapter (OpenCode) can resolve this
|
|
100
|
+
// call NORMALLY after running its own bounded retry+reroll loop into a TERMINAL provider
|
|
101
|
+
// failure (see failTurn) rather than throwing — throwing here would route into this method's
|
|
102
|
+
// own retry-and-give-up path, re-sending the same prompt on top of the budget the adapter
|
|
103
|
+
// already exhausted. `this.deps.terminalFailure()` is ground truth for that outcome: it is set by
|
|
104
|
+
// the adapter's own `error` status and cleared by the `busy` edge THIS delivery started with
|
|
105
|
+
// (setProviderStatus, ~line 1841), so a set value here can only have been raised during this
|
|
106
|
+
// very call, never stale. Without this check, a freshly spawned agent's terminally-failed
|
|
107
|
+
// first turn — the exact incident #2397 was filed on — was still logged "delivered".
|
|
108
|
+
if (this.deps.terminalFailure()) {
|
|
109
|
+
this.deps.sessionLog(`initial prompt attempt ${attempt} ended in a terminal provider failure (${prompt.length} chars, ${Date.now() - startedAt}ms): ${this.deps.terminalFailure()!.message}`);
|
|
110
|
+
} else {
|
|
111
|
+
// #2389 — delivery used to be logged ONLY on exception, so a spawn that idled forever left no
|
|
112
|
+
// evidence of whether its first prompt ever reached the provider; establishing that took
|
|
113
|
+
// SQLite archaeology on retained provider state. Say so on success too.
|
|
114
|
+
this.deps.sessionLog(`initial prompt delivered on attempt ${attempt} (${prompt.length} chars, ${Date.now() - startedAt}ms)`);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// Nothing reached the provider, so nothing is owed — unless a turn started meanwhile, which
|
|
118
|
+
// disarmInput checks: a synchronous-endpoint transport can throw after the model already ran.
|
|
119
|
+
if (arm) this.deps.disarmInput(arm);
|
|
120
|
+
// #2534 — the adapter proved the provider ACCEPTED and persisted this prompt (matched to the
|
|
121
|
+
// outbound request identity) before its completion await failed (e.g. the #2425 whole-turn
|
|
122
|
+
// abort on a long first turn). The provider is already working on it, so requeueing would
|
|
123
|
+
// run the same assignment a second time — and with the acceptance-keyed markers retained,
|
|
124
|
+
// the retry would also no longer carry the launch briefing. Drop it like a normal success.
|
|
125
|
+
if (error instanceof PromptAcceptedError) {
|
|
126
|
+
this.pendingInitialPrompt = undefined;
|
|
127
|
+
this.deps.sessionLog(`initial prompt attempt ${attempt} was accepted by the provider before its completion await failed (${prompt.length} chars); kept as delivered, not requeued: ${errMessage(error)}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const giveUp = (error instanceof ProviderSubmitFailure && !error.retrySafe) || attempt >= MAX_INITIAL_PROMPT_ATTEMPTS;
|
|
131
|
+
this.pendingInitialPrompt = giveUp ? undefined : prompt;
|
|
132
|
+
logger.error("runner", `initial prompt attempt ${attempt} failed${giveUp ? " (giving up)" : "; will retry on next ready signal"}: ${errMessage(error)}`);
|
|
133
|
+
this.deps.publishRunnerTimelineEvent({ status: "prompt_failed", timestamp: Date.now(), title: "Initial prompt not delivered", body: errMessage(error), metadata: { attempts: attempt, terminal: giveUp } });
|
|
134
|
+
if (error instanceof ProviderSubmitFailure && giveUp) this.deps.holdFailedProviderSubmit(`Initial provider prompt submission unconfirmed after ${attempt} attempt(s): ${error.message}`, { resend: () => this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS) });
|
|
135
|
+
} finally {
|
|
136
|
+
this.deliveringInitialPrompt = false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async respondToPermissionDecision(params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
141
|
+
const approvalId = typeof params.approvalId === "string" ? params.approvalId : "";
|
|
142
|
+
const decision = typeof params.decision === "string" ? params.decision : "";
|
|
143
|
+
if (!approvalId) throw new Error("approvalId required");
|
|
144
|
+
if (decision !== "approve" && decision !== "approve-session" && decision !== "deny" && decision !== "abort" && decision !== "answer") {
|
|
145
|
+
throw new Error("decision must be approve, approve-session, deny, abort, or answer");
|
|
146
|
+
}
|
|
147
|
+
const answersRaw = params.answers;
|
|
148
|
+
const answers = answersRaw && typeof answersRaw === "object" && !Array.isArray(answersRaw)
|
|
149
|
+
? Object.fromEntries(
|
|
150
|
+
Object.entries(answersRaw as Record<string, unknown>).filter(([, v]) => typeof v === "string") as [string, string][],
|
|
151
|
+
)
|
|
152
|
+
: undefined;
|
|
153
|
+
if (decision === "answer" && (!answers || Object.keys(answers).length === 0)) {
|
|
154
|
+
throw new Error("answers required for answer decision");
|
|
155
|
+
}
|
|
156
|
+
const input: ProviderPermissionDecisionInput = {
|
|
157
|
+
approvalId,
|
|
158
|
+
decision: decision as ProviderPermissionDecision,
|
|
159
|
+
...(typeof params.reason === "string" ? { reason: params.reason } : {}),
|
|
160
|
+
...(answers ? { answers } : {}),
|
|
161
|
+
};
|
|
162
|
+
if (this.deps.resolvePermissionDecision(input)) {
|
|
163
|
+
return { approvalId, decision, provider: this.deps.options.provider };
|
|
164
|
+
}
|
|
165
|
+
if (!this.deps.process()) throw new Error("provider process is unavailable");
|
|
166
|
+
if (!this.deps.options.adapter.respondToPermissionDecision) throw new Error("provider does not support permission decisions");
|
|
167
|
+
const result = await this.deps.options.adapter.respondToPermissionDecision(this.deps.process()!, input);
|
|
168
|
+
return { approvalId, decision, ...(result ? { providerResult: result } : {}) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async injectPrompt(params: Record<string, unknown>, ticket?: ProviderInputTicket, commandId?: string): Promise<Record<string, unknown>> {
|
|
172
|
+
const body = typeof params.body === "string" ? params.body : "";
|
|
173
|
+
if (!body) throw new Error("body required");
|
|
174
|
+
if (!this.deps.process()) throw new Error("provider process is unavailable");
|
|
175
|
+
if (!this.deps.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
|
|
176
|
+
const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
|
|
177
|
+
// A prompt that raced the hold's publication (the server 409s once it sees the hold) parks behind the held item,
|
|
178
|
+
// decided in its FIFO slot only, so parking follows the input order.
|
|
179
|
+
const parkKey = `submit-hold-prompt:${messageId ?? commandId ?? crypto.randomUUID()}`;
|
|
180
|
+
let parkTurn: ProviderInputTicket | undefined;
|
|
181
|
+
const reservePark = () => { parkTurn = this.deps.reservePark(); };
|
|
182
|
+
const attachments = Array.isArray(params.attachments)
|
|
183
|
+
? params.attachments.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item))
|
|
184
|
+
: [];
|
|
185
|
+
if (messageId) this.deps.notePendingPrompt(messageId);
|
|
186
|
+
const { prompt, imagePaths } = await this.promptInjectionText(body, messageId, attachments);
|
|
187
|
+
// Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
|
|
188
|
+
// prompt already created its own session message shown in the dashboard.
|
|
189
|
+
this.deps.recordInjectedPrompt(prompt.trim(), imagePaths);
|
|
190
|
+
// #1228: prompt injection is a manual dashboard "continue"/chat-box send, not the
|
|
191
|
+
// agent's genuine first turn — never label it as the initial prompt.
|
|
192
|
+
// #2389/#2539 — offer through the ONE provider-input seam: arm the empty-turn watch at
|
|
193
|
+
// hand-off, serialize through the FIFO behind any reserved ticket, disarm on rejection.
|
|
194
|
+
// #2543 — a busy recipient (the adapter answered with the shared turn-in-progress signal
|
|
195
|
+
// instead of an unguarded start) DEFERS: bounded wait for the provider turn to clear, then
|
|
196
|
+
// one re-offer on the idle path through a fresh FIFO slot — the same shape as the #2539
|
|
197
|
+
// context-injection defer. A timed-out wait (or any other failure) throws so the command
|
|
198
|
+
// fails visibly — never a silent drop.
|
|
199
|
+
const submitHeld = (outcome: string) => outcome === "submit-held";
|
|
200
|
+
try {
|
|
201
|
+
const outcome = await this.deps.offerProviderInput((inputObserver) => this.deps.offerPromptOrPark(prompt, { isInitial: false, imagePaths, inputObserver }, ticket, reservePark, promptOrigin(params)), submitHeld);
|
|
202
|
+
if (outcome === "not-durable") throw new Error(PROMPT_NOT_DURABLE);
|
|
203
|
+
if (outcome === "submit-held") return this.deps.parkPromptBehindSubmitHold(params, parkKey, parkTurn!);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!isTurnInProgressDeliveryError(error)) {
|
|
206
|
+
if (error instanceof ProviderSubmitFailure && !error.retrySafe) this.deps.holdFailedProviderSubmit(`Dashboard prompt submission unconfirmed: ${error.message}`, { resend: () => this.injectPrompt(params) });
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
this.deps.sessionLog("prompt injection deferred behind the active provider turn; delivering when it ends");
|
|
210
|
+
await this.deps.waitForProviderTurnClear();
|
|
211
|
+
this.deps.recordInjectedPrompt(prompt.trim(), imagePaths); // refresh the echo-dedup window past the wait
|
|
212
|
+
const outcome = await this.deps.offerProviderInput((inputObserver) => this.deps.offerPromptOrPark(prompt, { isInitial: false, imagePaths, inputObserver }, undefined, reservePark, promptOrigin(params)), submitHeld);
|
|
213
|
+
if (outcome === "not-durable") throw new Error(PROMPT_NOT_DURABLE);
|
|
214
|
+
if (outcome === "submit-held") return this.deps.parkPromptBehindSubmitHold(params, parkKey, parkTurn!);
|
|
215
|
+
}
|
|
216
|
+
return { injected: true, messageId, attachmentCount: attachments.length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// #1173: the resolution side of the deny-then-hold AskUserQuestion flow (see control-server.ts's
|
|
220
|
+
// handlePermissionRequest). By the time this fires, the original PreToolUse hook has long since
|
|
221
|
+
// been answered with `deny` — there's no live HTTP request to respond to — so the human's answer
|
|
222
|
+
// (or dismissal) is delivered by clearing the pending-question hold and injecting a normal
|
|
223
|
+
// user prompt via the same path the dashboard chat box uses.
|
|
224
|
+
async injectAnswerPrompt(input: { approvalId: string; text?: string }): Promise<void> {
|
|
225
|
+
const heldApprovalId = this.deps.pendingQuestionApprovalId();
|
|
226
|
+
if (heldApprovalId && heldApprovalId !== input.approvalId) return;
|
|
227
|
+
if (heldApprovalId === input.approvalId) {
|
|
228
|
+
this.deps.clearPendingQuestionHold();
|
|
229
|
+
this.deps.publishStatus();
|
|
230
|
+
}
|
|
231
|
+
const text = input.text ?? "The question was dismissed or timed out before the user answered. Proceed on your best judgment, or re-ask if you still need a decision.";
|
|
232
|
+
if (!this.deps.process() || !this.deps.options.adapter.deliverInitialPrompt) {
|
|
233
|
+
this.deps.sessionLog(`could not inject AskUserQuestion answer for ${input.approvalId}: provider process/prompt-injection unavailable`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.deps.recordInjectedPrompt(text.trim());
|
|
237
|
+
// #1228: this resolves a held AskUserQuestion mid-session — never the initial prompt.
|
|
238
|
+
// #2389/#2539 — the answer unblocks a turn that is waiting on it, so it arms.
|
|
239
|
+
// #2543 — offered through the same seam with the same busy policy: the adapter's shared
|
|
240
|
+
// turn-in-progress refusal defers (bounded turn-clear wait + one re-offer) instead of an
|
|
241
|
+
// unguarded start; any other failure stays best-effort logged, exactly as before.
|
|
242
|
+
const deliver = async (inputObserver?: ProviderInputObserver) => {
|
|
243
|
+
if (await this.deps.offerPromptOrPark(text, { isInitial: false, inputObserver }, undefined, undefined, input.text !== undefined ? "user" : undefined) === "not-durable") throw new Error(PROMPT_NOT_DURABLE);
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
await this.deps.offerProviderInput(deliver);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (!isTurnInProgressDeliveryError(error)) {
|
|
249
|
+
this.deps.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
this.deps.sessionLog(`AskUserQuestion answer for ${input.approvalId} deferred behind the active provider turn; delivering when it ends`);
|
|
254
|
+
await this.deps.waitForProviderTurnClear();
|
|
255
|
+
this.deps.recordInjectedPrompt(text.trim()); // refresh the echo-dedup window past the wait
|
|
256
|
+
await this.deps.offerProviderInput(deliver);
|
|
257
|
+
} catch (retryError) {
|
|
258
|
+
this.deps.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(retryError)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async promptInjectionText(body: string, messageId: number | undefined, attachments: Record<string, unknown>[]): Promise<{ prompt: string; imagePaths: string[] }> {
|
|
264
|
+
if (!attachments.length) return { prompt: body, imagePaths: [] };
|
|
265
|
+
const messages = await this.deps.cacheAttachments([{
|
|
266
|
+
id: messageId ?? 0,
|
|
267
|
+
from: "user",
|
|
268
|
+
to: this.deps.agentId,
|
|
269
|
+
kind: "session",
|
|
270
|
+
body,
|
|
271
|
+
payload: { attachments },
|
|
272
|
+
readBy: [],
|
|
273
|
+
createdAt: Date.now(),
|
|
274
|
+
}]);
|
|
275
|
+
const attachmentText = providerAttachmentText(messages[0]!);
|
|
276
|
+
return { prompt: attachmentText ? `${body}\n\n${attachmentText}` : body, imagePaths: providerAttachmentLocalPaths(messages) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
retryInitialPrompt(): void {
|
|
280
|
+
if (this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
|
|
281
|
+
void this.attemptInitialPromptDelivery(this.pendingInitialPrompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { HumwickHttpClient } from "@humwick/sdk";
|
|
2
|
+
import { orchestratorUrlFromEnv } from "../config";
|
|
3
|
+
import { httpErrorKey, runtimeTokenRenewDelayMs, runtimeTokenRenewTimerSchedule } from "../runner-helpers";
|
|
4
|
+
import { rederiveRuntimeToken, TokenRotation } from "../runtime-token-remint";
|
|
5
|
+
|
|
6
|
+
type RenewedTokenRecord = Awaited<ReturnType<HumwickHttpClient["renewRuntimeToken"]>>["record"];
|
|
7
|
+
type RotationStatus = "runtime-token-renewed" | "runtime-token-reminted";
|
|
8
|
+
interface CredentialsDeps {
|
|
9
|
+
isStopped(): boolean;
|
|
10
|
+
renewRuntimeToken: HumwickHttpClient["renewRuntimeToken"];
|
|
11
|
+
remintRuntimeToken: HumwickHttpClient["remintRuntimeToken"];
|
|
12
|
+
onTokenChanged(token: string, record: RenewedTokenRecord): void;
|
|
13
|
+
resumeLivenessAndPublishRotation(status: RotationStatus, jti: string): void;
|
|
14
|
+
publishRenewalFailure(): void;
|
|
15
|
+
logRunnerDiagnostic(message: string): void;
|
|
16
|
+
reportBackgroundRejection(scope: string, status: string, title: string, error: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TOKEN_RENEW_LOG_INTERVAL_MS = 5 * 60 * 1000;
|
|
20
|
+
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;
|
|
24
|
+
|
|
25
|
+
export class Credentials {
|
|
26
|
+
private currentToken?: string;
|
|
27
|
+
private currentTokenJti?: string;
|
|
28
|
+
private currentTokenProfileId?: string;
|
|
29
|
+
private currentTokenExpiresAt?: number;
|
|
30
|
+
private tokenRenewTimer?: Timer;
|
|
31
|
+
private readonly tokenRotation = new TokenRotation();
|
|
32
|
+
private tokenRenewLastLog?: { key: string; at: number };
|
|
33
|
+
private reactiveTokenRecoveryAt?: number;
|
|
34
|
+
|
|
35
|
+
constructor(options: { token?: string; tokenJti?: string; tokenProfileId?: string; tokenExpiresAt?: number }, private readonly deps: CredentialsDeps) {
|
|
36
|
+
this.currentToken = options.token;
|
|
37
|
+
this.currentTokenJti = options.tokenJti;
|
|
38
|
+
this.currentTokenProfileId = options.tokenProfileId;
|
|
39
|
+
this.currentTokenExpiresAt = options.tokenExpiresAt;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
current(): string | undefined { return this.currentToken; }
|
|
43
|
+
get jti(): string | undefined { return this.currentTokenJti; }
|
|
44
|
+
get profileId(): string | undefined { return this.currentTokenProfileId; }
|
|
45
|
+
get expiresAt(): number | undefined { return this.currentTokenExpiresAt; }
|
|
46
|
+
|
|
47
|
+
stop(): void {
|
|
48
|
+
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
49
|
+
this.tokenRenewTimer = undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Timer/edge callers cannot await renewal; report an escaping rejection so it cannot
|
|
53
|
+
// silently strand the runner off the bus (#1617).
|
|
54
|
+
private renewRuntimeTokenInBackground(): void {
|
|
55
|
+
void this.renewRuntimeToken().catch((error) => this.deps.reportBackgroundRejection("runtime token renewal", "runtime-token-renewal-failed", "Runtime token renewal aborted", error));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The TTL timer cannot detect revocation: recover on auth failure, coalescing a burst.
|
|
59
|
+
// Orchestrator re-mint heals stale/expired tokens but preserves hard revocation (#484).
|
|
60
|
+
recoverRuntimeTokenAfterAuthFailure(source: string): void {
|
|
61
|
+
if (this.deps.isStopped() || this.tokenRotation.inFlight) return;
|
|
62
|
+
if (!this.isRuntimeTokenRenewable() && !this.canRemintViaOrchestrator()) return;
|
|
63
|
+
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();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
scheduleRuntimeTokenRenewal(delayMs?: number): void {
|
|
71
|
+
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
72
|
+
this.tokenRenewTimer = undefined;
|
|
73
|
+
if (this.deps.isStopped()) return;
|
|
74
|
+
const canSelfRenew = this.isRuntimeTokenRenewable();
|
|
75
|
+
const canRemint = this.canRemintViaOrchestrator();
|
|
76
|
+
// Keep the renewal clock ticking as long as the session can recover its token
|
|
77
|
+
// by EITHER path. Without the re-mint fallback an expired token would stop the
|
|
78
|
+
// timer forever (the old deadlock that stranded live agents off the bus).
|
|
79
|
+
if (!canSelfRenew && !canRemint) return;
|
|
80
|
+
let computedDelay = delayMs;
|
|
81
|
+
if (computedDelay === undefined) {
|
|
82
|
+
computedDelay = canSelfRenew
|
|
83
|
+
? runtimeTokenRenewDelayMs(this.currentTokenExpiresAt!, Date.now())
|
|
84
|
+
: TOKEN_RENEW_RETRY_MS; // expired but re-mintable → retry via orchestrator soon
|
|
85
|
+
if (computedDelay === undefined) computedDelay = TOKEN_RENEW_RETRY_MS;
|
|
86
|
+
}
|
|
87
|
+
const schedule = runtimeTokenRenewTimerSchedule(computedDelay);
|
|
88
|
+
if (!schedule) return;
|
|
89
|
+
this.tokenRenewTimer = setTimeout(() => {
|
|
90
|
+
this.tokenRenewTimer = undefined;
|
|
91
|
+
if (!schedule.renew) {
|
|
92
|
+
this.scheduleRuntimeTokenRenewal();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.renewRuntimeTokenInBackground();
|
|
96
|
+
}, schedule.delayMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Can the runner self-renew right now? Requires a non-expired runner-profile token
|
|
100
|
+
// (the relay rejects renewal of an expired token).
|
|
101
|
+
private isRuntimeTokenRenewable(): boolean {
|
|
102
|
+
return Boolean(
|
|
103
|
+
this.currentToken &&
|
|
104
|
+
this.currentTokenExpiresAt &&
|
|
105
|
+
this.currentTokenExpiresAt * 1000 > Date.now() &&
|
|
106
|
+
(this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Can the runner recover its token via the orchestrator? Works even when the token
|
|
111
|
+
// is already expired — the orchestrator's standing credential is the authority.
|
|
112
|
+
private canRemintViaOrchestrator(): boolean {
|
|
113
|
+
return Boolean(
|
|
114
|
+
orchestratorUrlFromEnv() &&
|
|
115
|
+
this.currentToken &&
|
|
116
|
+
(this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async renewRuntimeToken(): Promise<void> {
|
|
121
|
+
if (this.deps.isStopped() || this.tokenRotation.inFlight || !this.currentToken) return;
|
|
122
|
+
await this.tokenRotation.run(async () => {
|
|
123
|
+
// Preferred path: self-renew directly against the relay while the token is
|
|
124
|
+
// still valid. Cheapest and needs no orchestrator round-trip.
|
|
125
|
+
if (this.isRuntimeTokenRenewable()) {
|
|
126
|
+
try {
|
|
127
|
+
const renewed = await this.deps.renewRuntimeToken();
|
|
128
|
+
this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-renewed");
|
|
129
|
+
return;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.logRuntimeTokenRenewalFailure(error);
|
|
132
|
+
// Relay unreachable or token rejected — fall through to orchestrator re-mint.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Recovery path: token expired, or self-renew failed. Ask the orchestrator —
|
|
136
|
+
// it holds a long-lived credential and can mint a fresh runner token, so a
|
|
137
|
+
// live session heals instead of being stranded off the bus.
|
|
138
|
+
if (this.canRemintViaOrchestrator() && await this.remintViaOrchestrator()) return;
|
|
139
|
+
this.deps.publishRenewalFailure();
|
|
140
|
+
this.scheduleRuntimeTokenRenewal(TOKEN_RENEW_RETRY_MS);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Install state before core fan-out, then resume liveness and renew on the same clock.
|
|
145
|
+
private applyRenewedToken(
|
|
146
|
+
token: string,
|
|
147
|
+
record: RenewedTokenRecord,
|
|
148
|
+
status: RotationStatus,
|
|
149
|
+
): void {
|
|
150
|
+
this.currentToken = token;
|
|
151
|
+
this.currentTokenJti = record.jti;
|
|
152
|
+
this.currentTokenProfileId = record.profileId ?? this.currentTokenProfileId;
|
|
153
|
+
this.currentTokenExpiresAt = record.expiresAt;
|
|
154
|
+
this.deps.onTokenChanged(token, record);
|
|
155
|
+
this.reactiveTokenRecoveryAt = undefined;
|
|
156
|
+
this.deps.resumeLivenessAndPublishRotation(status, record.jti);
|
|
157
|
+
this.scheduleRuntimeTokenRenewal();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Recover the runtime token through the orchestrator. The runner proxies its own
|
|
161
|
+
// (possibly expired) token; the orchestrator re-mints it via the relay using its
|
|
162
|
+
// standing credential. Returns true on success.
|
|
163
|
+
private async remintViaOrchestrator(): Promise<boolean> {
|
|
164
|
+
const orchUrl = orchestratorUrlFromEnv();
|
|
165
|
+
if (!orchUrl || !this.currentToken) return false;
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(`${orchUrl.replace(/\/+$/, "")}/api/runtime-tokens/runner-renew`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: JSON.stringify({ token: this.currentToken }),
|
|
171
|
+
signal: AbortSignal.timeout(10_000),
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) return false;
|
|
174
|
+
const renewed = await res.json() as { token?: string; record?: { jti: string; profileId?: string; expiresAt?: number } };
|
|
175
|
+
if (!renewed?.token || !renewed.record) return false;
|
|
176
|
+
this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-reminted");
|
|
177
|
+
this.deps.logRunnerDiagnostic(`[runner] runtime token re-minted via orchestrator (jti ${renewed.record.jti})`);
|
|
178
|
+
return true;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.logRuntimeTokenRenewalFailure(error);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// #1245 — a newly allowed tool group or scope reaches the next generation without a restart.
|
|
186
|
+
async remintRuntimeTokenAtBoundary(reason: "clear" | "compact"): Promise<Record<string, unknown>> {
|
|
187
|
+
try {
|
|
188
|
+
// Queued behind a renewal in flight, so it re-derives from the token that renewal installs.
|
|
189
|
+
return await this.tokenRotation.runNext(async () => {
|
|
190
|
+
if (this.deps.isStopped() || !this.currentToken || this.currentTokenProfileId !== "provider-agent") return { status: "skipped", reason: "no spawned runner token" };
|
|
191
|
+
return rederiveRuntimeToken({ remintRuntimeToken: this.deps.remintRuntimeToken }, reason, (token, record) => this.applyRenewedToken(token, record, "runtime-token-reminted"), (message) => this.deps.logRunnerDiagnostic(message));
|
|
192
|
+
});
|
|
193
|
+
} finally {
|
|
194
|
+
this.scheduleRuntimeTokenRenewal(); // a renewal timer that fired meanwhile returned without re-arming
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private logRuntimeTokenRenewalFailure(error: unknown): void {
|
|
199
|
+
const key = httpErrorKey(error);
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
if (
|
|
202
|
+
this.tokenRenewLastLog?.key === key &&
|
|
203
|
+
now - this.tokenRenewLastLog.at < TOKEN_RENEW_LOG_INTERVAL_MS
|
|
204
|
+
) return;
|
|
205
|
+
this.tokenRenewLastLog = { key, at: now };
|
|
206
|
+
this.deps.logRunnerDiagnostic(`[runner] runtime token renewal failed: ${String(error)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { errMessage, type HumwickHttpClient } from "@humwick/sdk";
|
|
2
|
+
import { httpErrorKey, isHttpAuthError, runnerAgentStatus } from "../runner-helpers";
|
|
3
|
+
import type { ClaimTracker } from "../claim-tracker";
|
|
4
|
+
|
|
5
|
+
const HTTP_LIVENESS_INTERVAL_MS = 20_000;
|
|
6
|
+
|
|
7
|
+
interface HttpLivenessDeps {
|
|
8
|
+
http: Pick<HumwickHttpClient, "heartbeat">;
|
|
9
|
+
agentId: string;
|
|
10
|
+
instanceId: string;
|
|
11
|
+
logIntervalMs: number;
|
|
12
|
+
isStopped(): boolean;
|
|
13
|
+
isTerminalStatusLatched(): boolean;
|
|
14
|
+
currentStatus: ClaimTracker["currentStatus"];
|
|
15
|
+
publishQuota(): Promise<void>;
|
|
16
|
+
bootstrapUnreadMessages(): Promise<void>;
|
|
17
|
+
triggerEffort(): void;
|
|
18
|
+
recoverRuntimeTokenAfterAuthFailure(source: string): void;
|
|
19
|
+
logRunnerDiagnostic(message: string): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class HttpLiveness {
|
|
23
|
+
private httpLivenessTimer?: Timer;
|
|
24
|
+
private httpLivenessInFlight = false;
|
|
25
|
+
private httpLivenessAuthFailed = false;
|
|
26
|
+
private httpLivenessLastLog?: { key: string; at: number };
|
|
27
|
+
|
|
28
|
+
constructor(private readonly deps: HttpLivenessDeps) {}
|
|
29
|
+
|
|
30
|
+
stop(): void {
|
|
31
|
+
if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
|
|
32
|
+
this.httpLivenessTimer = undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resetAuthFailure(): void { this.httpLivenessAuthFailed = false; }
|
|
36
|
+
|
|
37
|
+
start(): void {
|
|
38
|
+
if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
|
|
39
|
+
void this.publishHttpLiveness();
|
|
40
|
+
this.httpLivenessTimer = setInterval(() => {
|
|
41
|
+
void this.publishHttpLiveness();
|
|
42
|
+
}, HTTP_LIVENESS_INTERVAL_MS);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async publishHttpLiveness(): Promise<void> {
|
|
46
|
+
if (this.deps.isStopped() || this.deps.isTerminalStatusLatched() || this.httpLivenessInFlight || this.httpLivenessAuthFailed) return;
|
|
47
|
+
this.httpLivenessInFlight = true;
|
|
48
|
+
const status = this.deps.currentStatus();
|
|
49
|
+
const agentStatus = runnerAgentStatus(status);
|
|
50
|
+
try {
|
|
51
|
+
// #3606 — one call; the hub writes and emits only when status or readiness actually changed.
|
|
52
|
+
await this.deps.http.heartbeat(this.deps.agentId, this.deps.instanceId, { status: agentStatus, ready: agentStatus !== "offline" });
|
|
53
|
+
await this.deps.publishQuota().catch((error) => this.deps.logRunnerDiagnostic(`passive provider quota publish failed: ${errMessage(error)}`));
|
|
54
|
+
await this.deps.bootstrapUnreadMessages(); this.deps.triggerEffort();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!this.deps.isStopped()) this.handleHttpLivenessFailure(error);
|
|
57
|
+
} finally {
|
|
58
|
+
this.httpLivenessInFlight = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private handleHttpLivenessFailure(error: unknown): void {
|
|
63
|
+
const authFailed = isHttpAuthError(error);
|
|
64
|
+
this.logHttpLivenessFailure(error, authFailed);
|
|
65
|
+
if (!authFailed) return;
|
|
66
|
+
this.httpLivenessAuthFailed = true;
|
|
67
|
+
if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
|
|
68
|
+
this.httpLivenessTimer = undefined;
|
|
69
|
+
// A 401/403 here is the only timely signal that the token died — stopping the
|
|
70
|
+
// liveness timer means there is no second chance, so recover from THIS failure.
|
|
71
|
+
this.deps.recoverRuntimeTokenAfterAuthFailure("http-liveness");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private logHttpLivenessFailure(error: unknown, authFailed: boolean): void {
|
|
75
|
+
const key = httpErrorKey(error);
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
if (
|
|
78
|
+
this.httpLivenessLastLog?.key === key &&
|
|
79
|
+
now - this.httpLivenessLastLog.at < this.deps.logIntervalMs
|
|
80
|
+
) return;
|
|
81
|
+
this.httpLivenessLastLog = { key, at: now };
|
|
82
|
+
const suffix = authFailed ? "auth failed; stopping HTTP liveness retries until restart" : String(error);
|
|
83
|
+
this.deps.logRunnerDiagnostic(`[runner] HTTP liveness update failed: ${suffix}`);
|
|
84
|
+
}
|
|
85
|
+
}
|