@amistio/cli 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@ amistio --help
11
11
 
12
12
  The package install only installs the `amistio` command. Repository cloning, project pairing, credential storage, and runner execution happen only when the user explicitly runs commands such as `amistio bootstrap`, `amistio pair`, or `amistio run --watch`.
13
13
 
14
+ Runner lifecycle controls in the web app, such as update, restart, and remove, apply only to the runner paired by that user unless the active organization role is an admin role. The runner API binds command polling, command status, logs, activity, and tool sessions to the local runner credential that produced them.
15
+
14
16
  After pairing, confirm that at least one local AI tool is available:
15
17
 
16
18
  ```sh
@@ -25,8 +27,12 @@ amistio run --watch --background --tool opencode
25
27
  amistio runner status
26
28
  ```
27
29
 
30
+ When `--tool copilot` uses the GitHub Copilot SDK, Amistio approves read-only permission requests by default and denies mutating, network, MCP, hook, memory, and shell requests. Set `AMISTIO_COPILOT_APPROVE_ALL=1` only on a local machine where broad Copilot SDK approval is intentional.
31
+
28
32
  `amistio runner status` reports local background runner state, latest heartbeat, and bounded resource usage when available. Resource usage is latest-sample runner process memory/CPU plus safe aggregate system memory/load signals; it does not include source files, environment variables, command lines, process lists, credentials, or arbitrary local paths.
29
33
 
34
+ Runner setup and local-tool execution use bounded failure controls. `amistio run --watch` retries Git worktree preflight failures by releasing the claim for another attempt, then fails the work item after `--max-preflight-attempts` attempts, defaulting to 3. Active local-tool runs renew the work lease, and `--tool-timeout-seconds` caps tool execution, defaulting to 1800 seconds.
35
+
30
36
  For headless startup after login on supported user-level service managers:
31
37
 
32
38
  ```sh
package/dist/index.js CHANGED
@@ -410,6 +410,7 @@ var runnerCredentialItemSchema = baseItemSchema.extend({
410
410
  projectId: z.string().min(1),
411
411
  runnerCredentialId: z.string().min(1),
412
412
  repositoryLinkId: z.string().min(1),
413
+ runnerId: z.string().min(1).optional(),
413
414
  pairedByUserId: z.string().min(1).optional(),
414
415
  machineId: z.string().min(1).optional(),
415
416
  tokenHash: z.string().min(32),
@@ -719,6 +720,8 @@ var pairingSessionItemSchema = baseItemSchema.extend({
719
720
  projectId: z.string().min(1),
720
721
  createdByUserId: z.string().min(1),
721
722
  expiresAt: isoDateTimeSchema,
723
+ failedAttemptCount: z.number().int().nonnegative().optional(),
724
+ lastFailedAttemptAt: isoDateTimeSchema.optional(),
722
725
  status: z.enum(["pending", "confirmed", "expired", "revoked"])
723
726
  });
724
727
  var projectItemUnionSchema = z.discriminatedUnion("type", [
@@ -1865,7 +1868,7 @@ async function runLocalTool(options) {
1865
1868
  promptFilePath,
1866
1869
  streamOutput: Boolean(options.streamOutput),
1867
1870
  ...options.session ? { session: options.session } : {}
1868
- });
1871
+ }, options.timeoutMs);
1869
1872
  return {
1870
1873
  toolName: runner2.toolName,
1871
1874
  displayCommand: runner2.kind === "sdk" ? runner2.displayCommand : runner2.invocation.displayCommand,
@@ -1950,16 +1953,16 @@ async function createToolRunner(options) {
1950
1953
  }
1951
1954
  throw new Error(`The ${adapter.name} SDK or executable was not found. Install the SDK/runtime or pass --tool-command.`);
1952
1955
  }
1953
- async function executeToolRunner(runner2, input) {
1956
+ async function executeToolRunner(runner2, input, timeoutMs) {
1954
1957
  if (runner2.kind === "command") {
1955
- return executeToolInvocation(runner2.invocation, input.rootDir, input.streamOutput);
1958
+ return executeToolInvocation(runner2.invocation, input.rootDir, input.streamOutput, timeoutMs);
1956
1959
  }
1957
1960
  try {
1958
- return await runner2.adapter.runWithSdk(input);
1961
+ return await withTimeout(runner2.adapter.runWithSdk(input), timeoutMs, runner2.displayCommand);
1959
1962
  } catch (error) {
1960
1963
  if (runner2.allowCommandFallback && runner2.adapter.buildInvocation && runner2.adapter.executable && await commandExists(runner2.adapter.executable)) {
1961
1964
  const fallback = runner2.adapter.buildInvocation(input);
1962
- const result = await executeToolInvocation(fallback, input.rootDir, input.streamOutput);
1965
+ const result = await executeToolInvocation(fallback, input.rootDir, input.streamOutput, timeoutMs);
1963
1966
  const sdkFailure = `SDK execution for ${runner2.adapter.name} failed, fell back to ${fallback.displayCommand}: ${errorMessage(error)}`;
1964
1967
  return {
1965
1968
  ...result,
@@ -2048,7 +2051,7 @@ async function commandExists(command) {
2048
2051
  lookup.on("close", (exitCode) => resolve(exitCode === 0));
2049
2052
  });
2050
2053
  }
2051
- async function executeToolInvocation(invocation, rootDir, streamOutput) {
2054
+ async function executeToolInvocation(invocation, rootDir, streamOutput, timeoutMs) {
2052
2055
  return new Promise((resolve, reject) => {
2053
2056
  const child = spawn(invocation.command, invocation.args, {
2054
2057
  cwd: rootDir,
@@ -2058,7 +2061,33 @@ async function executeToolInvocation(invocation, rootDir, streamOutput) {
2058
2061
  });
2059
2062
  let stdout = "";
2060
2063
  let stderr = "";
2061
- child.on("error", reject);
2064
+ let settled = false;
2065
+ let forceKillTimer;
2066
+ const timeout = timeoutMs && timeoutMs > 0 ? setTimeout(() => {
2067
+ if (settled) return;
2068
+ stderr += `${toolTimeoutMessage(invocation.displayCommand, timeoutMs)}
2069
+ `;
2070
+ child.kill("SIGTERM");
2071
+ forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
2072
+ forceKillTimer.unref?.();
2073
+ rejectOnce(new Error(toolTimeoutMessage(invocation.displayCommand, timeoutMs)));
2074
+ }, timeoutMs) : void 0;
2075
+ timeout?.unref?.();
2076
+ const resolveOnce = (value) => {
2077
+ if (settled) return;
2078
+ settled = true;
2079
+ if (timeout) clearTimeout(timeout);
2080
+ if (forceKillTimer) clearTimeout(forceKillTimer);
2081
+ resolve(value);
2082
+ };
2083
+ const rejectOnce = (error) => {
2084
+ if (settled) return;
2085
+ settled = true;
2086
+ if (timeout) clearTimeout(timeout);
2087
+ if (forceKillTimer) clearTimeout(forceKillTimer);
2088
+ reject(error);
2089
+ };
2090
+ child.on("error", rejectOnce);
2062
2091
  child.stdout.setEncoding("utf8");
2063
2092
  child.stderr.setEncoding("utf8");
2064
2093
  child.stdout.on("data", (chunk) => {
@@ -2079,10 +2108,40 @@ async function executeToolInvocation(invocation, rootDir, streamOutput) {
2079
2108
  }
2080
2109
  child.stdin.end();
2081
2110
  child.on("close", (exitCode) => {
2082
- resolve({ exitCode: exitCode ?? 1, stdout, stderr });
2111
+ if (forceKillTimer) clearTimeout(forceKillTimer);
2112
+ resolveOnce({ exitCode: exitCode ?? 1, stdout, stderr });
2083
2113
  });
2084
2114
  });
2085
2115
  }
2116
+ async function withTimeout(promise, timeoutMs, displayCommand) {
2117
+ if (!timeoutMs || timeoutMs <= 0) {
2118
+ return promise;
2119
+ }
2120
+ return new Promise((resolve, reject) => {
2121
+ const timeout = setTimeout(() => reject(new Error(toolTimeoutMessage(displayCommand, timeoutMs))), timeoutMs);
2122
+ timeout.unref?.();
2123
+ promise.then(
2124
+ (value) => {
2125
+ clearTimeout(timeout);
2126
+ resolve(value);
2127
+ },
2128
+ (error) => {
2129
+ clearTimeout(timeout);
2130
+ reject(error);
2131
+ }
2132
+ );
2133
+ });
2134
+ }
2135
+ function toolTimeoutMessage(displayCommand, timeoutMs) {
2136
+ return `Local tool timed out after ${formatTimeoutDuration(timeoutMs)}: ${displayCommand}`;
2137
+ }
2138
+ function formatTimeoutDuration(timeoutMs) {
2139
+ if (timeoutMs < 1e3) {
2140
+ return `${timeoutMs}ms`;
2141
+ }
2142
+ const seconds = timeoutMs / 1e3;
2143
+ return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`;
2144
+ }
2086
2145
  async function runOpencodeSdk(input) {
2087
2146
  const { createOpencode } = await import("@opencode-ai/sdk");
2088
2147
  const previousDirectory = process.cwd();
@@ -2170,7 +2229,7 @@ async function runCodexSdk(input) {
2170
2229
  return { exitCode: 0, stdout: result.finalResponse, stderr: "" };
2171
2230
  }
2172
2231
  async function runCopilotSdk(input) {
2173
- const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
2232
+ const { CopilotClient } = await import("@github/copilot-sdk");
2174
2233
  const client = new CopilotClient({
2175
2234
  cwd: input.rootDir,
2176
2235
  logLevel: "error"
@@ -2183,7 +2242,7 @@ async function runCopilotSdk(input) {
2183
2242
  workingDirectory: input.rootDir,
2184
2243
  enableConfigDiscovery: true,
2185
2244
  streaming: input.streamOutput,
2186
- onPermissionRequest: approveAll
2245
+ onPermissionRequest: createCopilotPermissionHandler()
2187
2246
  });
2188
2247
  try {
2189
2248
  let streamedOutput = "";
@@ -2205,6 +2264,18 @@ async function runCopilotSdk(input) {
2205
2264
  await client.stop();
2206
2265
  }
2207
2266
  }
2267
+ function createCopilotPermissionHandler(env = process.env) {
2268
+ const allowAllPermissions = isCopilotApproveAllEnabled(env);
2269
+ return (request) => {
2270
+ if (allowAllPermissions || request.kind === "read") {
2271
+ return { kind: "approve-once" };
2272
+ }
2273
+ return { kind: "reject" };
2274
+ };
2275
+ }
2276
+ function isCopilotApproveAllEnabled(env = process.env) {
2277
+ return /^(1|true|yes)$/i.test(env.AMISTIO_COPILOT_APPROVE_ALL ?? "");
2278
+ }
2208
2279
  function extractTextParts(parts) {
2209
2280
  if (!Array.isArray(parts)) {
2210
2281
  return "";
@@ -3813,6 +3884,8 @@ function buildBackgroundRunnerArgs(options) {
3813
3884
  if (options.maxIterations !== void 0) {
3814
3885
  args.push("--max-iterations", String(options.maxIterations));
3815
3886
  }
3887
+ args.push("--max-preflight-attempts", String(options.maxPreflightAttempts));
3888
+ args.push("--tool-timeout-seconds", String(options.toolTimeoutSeconds));
3816
3889
  if (!options.stream) {
3817
3890
  args.push("--no-stream");
3818
3891
  }
@@ -3960,6 +4033,10 @@ var CLI_VERSION = readCliPackageVersion();
3960
4033
  var program = new Command();
3961
4034
  var defaultRoot = process.env.INIT_CWD ?? process.cwd();
3962
4035
  var apiUrlOptionDescription = `Amistio API URL override (or ${AMISTIO_API_URL_ENV})`;
4036
+ var DEFAULT_MAX_PREFLIGHT_ATTEMPTS = 3;
4037
+ var DEFAULT_TOOL_TIMEOUT_SECONDS = 30 * 60;
4038
+ var RUNNER_WORK_LEASE_SECONDS = 300;
4039
+ var RUNNER_WORK_LEASE_RENEWAL_MS = 12e4;
3963
4040
  program.name("amistio").description("Amistio project brain CLI").version(CLI_VERSION);
3964
4041
  program.command("init").description("Create Amistio control-plane folders for a new project").option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
3965
4042
  const created = await initControlPlane(options.root);
@@ -3986,7 +4063,8 @@ program.command("bootstrap").description("Clone a linked repository locally, pre
3986
4063
  repositoryLinkId: options.repositoryLink,
3987
4064
  repoName: parsedRepoUrl.repoName,
3988
4065
  repoFingerprint: createRepoFingerprint(options.account, options.project, options.repositoryLink),
3989
- defaultBranch: options.defaultBranch
4066
+ defaultBranch: options.defaultBranch,
4067
+ machineId: runnerMachineId()
3990
4068
  });
3991
4069
  const filePath = await writeProjectLink(checkout.targetDir, {
3992
4070
  amistioAccountId: options.account,
@@ -4038,6 +4116,7 @@ program.command("import").description("Pair an existing checkout and import lega
4038
4116
  repoName: repository.repoName,
4039
4117
  repoFingerprint: repository.repoFingerprint,
4040
4118
  defaultBranch: repository.defaultBranch,
4119
+ machineId: runnerMachineId(),
4041
4120
  ...parsedCloneUrl ? { cloneUrl: parsedCloneUrl.cloneUrl } : {},
4042
4121
  ...parsedCloneUrl?.provider ? { provider: parsedCloneUrl.provider } : {},
4043
4122
  ...parsedCloneUrl?.repoOwner ? { repoOwner: parsedCloneUrl.repoOwner } : {},
@@ -4084,7 +4163,8 @@ program.command("pair").description("Pair this repository with an Amistio web pr
4084
4163
  repositoryLinkId,
4085
4164
  repoName: inferRepoName(pairingRoot),
4086
4165
  repoFingerprint: createRepoFingerprint(options.account, options.project, repositoryLinkId),
4087
- defaultBranch: options.defaultBranch
4166
+ defaultBranch: options.defaultBranch,
4167
+ machineId: runnerMachineId()
4088
4168
  });
4089
4169
  repositoryLinkId = pairing.repositoryLink.repositoryLinkId;
4090
4170
  credential = credential ?? pairing.token;
@@ -4265,7 +4345,7 @@ program.command("orchestrate").description("Update the Amistio control plane thr
4265
4345
  process.exitCode = result.exitCode;
4266
4346
  }
4267
4347
  });
4268
- program.command("run").description("Claim and run approved Amistio work locally").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--runner-id <runnerId>", "Stable runner ID").option("--root <path>", "Repository root", defaultRoot).option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent").option("--invocation-channel <channel>", "Local invocation channel: auto, sdk, or command", parseInvocationChannel).option("--model <model>", "Model to request when the selected local tool supports model selection").option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--dry-run", "Claim work and print the generated execution prompt without running a tool").option("--watch", "Keep polling for approved work until stopped").option("--background", "Start a detached background runner that watches for approved work").option("--interval-seconds <seconds>", "Polling interval for --watch", parsePositiveInteger, 10).option("--max-iterations <count>", "Stop watch mode after this many polling attempts", parsePositiveInteger).option("--no-stream", "Capture local tool output instead of streaming it").option("--verbose", "Print detailed runner errors while watching").action(async (options, command) => {
4348
+ program.command("run").description("Claim and run approved Amistio work locally").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--runner-id <runnerId>", "Stable runner ID").option("--root <path>", "Repository root", defaultRoot).option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent").option("--invocation-channel <channel>", "Local invocation channel: auto, sdk, or command", parseInvocationChannel).option("--model <model>", "Model to request when the selected local tool supports model selection").option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--dry-run", "Claim work and print the generated execution prompt without running a tool").option("--watch", "Keep polling for approved work until stopped").option("--background", "Start a detached background runner that watches for approved work").option("--interval-seconds <seconds>", "Polling interval for --watch", parsePositiveInteger, 10).option("--max-iterations <count>", "Stop watch mode after this many polling attempts", parsePositiveInteger).option("--max-preflight-attempts <count>", "Fail setup/preflight failures after this many claimed attempts", parsePositiveInteger, DEFAULT_MAX_PREFLIGHT_ATTEMPTS).option("--tool-timeout-seconds <seconds>", "Fail local tool execution after this many seconds", parsePositiveInteger, DEFAULT_TOOL_TIMEOUT_SECONDS).option("--no-stream", "Capture local tool output instead of streaming it").option("--verbose", "Print detailed runner errors while watching").action(async (options, command) => {
4269
4349
  const context = await loadPairedApiContext(options.root, options.apiUrl);
4270
4350
  if (!context) {
4271
4351
  console.log("Repository is not paired. Run `amistio pair` first.");
@@ -4437,7 +4517,7 @@ runner.command("stop").description("Stop a background runner for the paired repo
4437
4517
  console.log(stopResult === "stopped" ? `Stopped background runner ${record.runnerId}.` : `Marked background runner ${record.runnerId} stopped; process was not running.`);
4438
4518
  });
4439
4519
  var runnerService = runner.command("service").description("Manage a user-level startup service for the paired runner");
4440
- runnerService.command("install").description("Install a user-level startup service for this paired repository runner").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).option("--runner-id <runnerId>", "Stable runner ID").option("--tool <name>", "Local tool to use: auto, opencode, claude, codex, copilot, gemini, aider, cursor-agent").option("--invocation-channel <channel>", "Local invocation channel: auto, sdk, or command", parseInvocationChannel).option("--model <model>", "Model to request when the selected local tool supports model selection").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--interval-seconds <seconds>", "Polling interval for the service runner", parsePositiveInteger, 10).option("--no-stream", "Capture local tool output instead of streaming it").option("--verbose", "Print detailed runner errors").option("--dry-run", "Print the startup service descriptor without installing it").action(async (options) => {
4520
+ runnerService.command("install").description("Install a user-level startup service for this paired repository runner").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).option("--runner-id <runnerId>", "Stable runner ID").option("--tool <name>", "Local tool to use: auto, opencode, claude, codex, copilot, gemini, aider, cursor-agent").option("--invocation-channel <channel>", "Local invocation channel: auto, sdk, or command", parseInvocationChannel).option("--model <model>", "Model to request when the selected local tool supports model selection").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--interval-seconds <seconds>", "Polling interval for the service runner", parsePositiveInteger, 10).option("--max-preflight-attempts <count>", "Fail setup/preflight failures after this many claimed attempts", parsePositiveInteger, DEFAULT_MAX_PREFLIGHT_ATTEMPTS).option("--tool-timeout-seconds <seconds>", "Fail local tool execution after this many seconds", parsePositiveInteger, DEFAULT_TOOL_TIMEOUT_SECONDS).option("--no-stream", "Capture local tool output instead of streaming it").option("--verbose", "Print detailed runner errors").option("--dry-run", "Print the startup service descriptor without installing it").action(async (options) => {
4441
4521
  const context = await loadPairedApiContext(options.root, options.apiUrl);
4442
4522
  if (!context) {
4443
4523
  console.log("Repository is not paired. Run `amistio pair` first.");
@@ -4550,6 +4630,8 @@ async function runWatchIteration({ command, context, options, runnerId }) {
4550
4630
  runnerId
4551
4631
  },
4552
4632
  suppressIdleOutput: Boolean(options.watch),
4633
+ maxPreflightAttempts: options.maxPreflightAttempts,
4634
+ toolTimeoutMs: options.toolTimeoutSeconds * 1e3,
4553
4635
  verbose: Boolean(options.verbose)
4554
4636
  });
4555
4637
  } catch (error) {
@@ -4564,7 +4646,7 @@ ${detail}`);
4564
4646
  } else {
4565
4647
  console.error(`${message} Run with --verbose for details.`);
4566
4648
  }
4567
- await Promise.allSettled([
4649
+ const settlements = await Promise.allSettled([
4568
4650
  context.client.sendRunnerHeartbeat(context.metadata.amistioProjectId, runnerId, context.metadata.repositoryLinkId, "blocked", { ...runnerHeartbeatMetadata(), preferenceMessage: message }),
4569
4651
  context.client.recordRunnerLog(context.metadata.amistioProjectId, {
4570
4652
  runnerId,
@@ -4575,6 +4657,7 @@ ${detail}`);
4575
4657
  machineId: runnerMachineId()
4576
4658
  })
4577
4659
  ]);
4660
+ logRejectedSettlements("record watch error", settlements);
4578
4661
  return { status: "failed", exitCode: 1, message };
4579
4662
  }
4580
4663
  }
@@ -4590,9 +4673,11 @@ async function runNextWorkItem({
4590
4673
  explicitModel,
4591
4674
  explicitInvocationChannel,
4592
4675
  explicitTool,
4676
+ maxPreflightAttempts,
4593
4677
  toolCommand,
4594
4678
  commandContext,
4595
4679
  suppressIdleOutput,
4680
+ toolTimeoutMs,
4596
4681
  verbose
4597
4682
  }) {
4598
4683
  const toolConfig = await resolveRunnerToolConfig({
@@ -4615,7 +4700,7 @@ async function runNextWorkItem({
4615
4700
  console.log(toolConfig.message);
4616
4701
  return { status: "blocked", exitCode: 1 };
4617
4702
  }
4618
- const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId, 300, runnerIsolationCapabilityMetadata());
4703
+ const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId, RUNNER_WORK_LEASE_SECONDS, runnerIsolationCapabilityMetadata());
4619
4704
  if (!result.workItem) {
4620
4705
  const nextAction = await loadProjectNextAction(apiClient, projectId, repositoryLinkId, root);
4621
4706
  const message = formatProjectNextAction(nextAction);
@@ -4625,14 +4710,20 @@ async function runNextWorkItem({
4625
4710
  return { status: "idle", exitCode: 0, nextAction, message };
4626
4711
  }
4627
4712
  const prompt = await createRunnerWorkPrompt(apiClient, projectId, result.workItem);
4713
+ await recordRunnerMilestone(apiClient, projectId, result.workItem, runnerId, repositoryLinkId, {
4714
+ status: "running",
4715
+ summary: "Prepared local runner execution prompt.",
4716
+ idempotencyKey: `runner_milestone_prompt_${result.workItem.workItemId}_${result.workItem.attempt}`,
4717
+ metadata: { workKind: result.workItem.workKind ?? "implementation", attempt: result.workItem.attempt }
4718
+ });
4628
4719
  if (dryRun || toolConfig.tool === "none") {
4629
4720
  console.log(prompt);
4630
4721
  await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
4631
4722
  return { status: "preview", exitCode: 0 };
4632
4723
  }
4633
- const worktreeIsolation = await prepareWorktreeForClaimedItem({ apiClient, projectId, repositoryLinkId, root, runnerId, toolConfig, workItem: result.workItem });
4634
- if (worktreeIsolation.status === "blocked") {
4635
- return { status: "blocked", exitCode: 1, message: worktreeIsolation.message };
4724
+ const worktreeIsolation = await prepareWorktreeForClaimedItem({ apiClient, maxPreflightAttempts, projectId, repositoryLinkId, root, runnerId, toolConfig, workItem: result.workItem });
4725
+ if (worktreeIsolation.status !== "ready") {
4726
+ return { status: worktreeIsolation.status === "failed" ? "failed" : "blocked", exitCode: 1, message: worktreeIsolation.message };
4636
4727
  }
4637
4728
  const executionRoot = worktreeIsolation.isolation?.worktreePath ?? root;
4638
4729
  const isolationTelemetry = workItemIsolationTelemetry(result.workItem, worktreeIsolation.isolation);
@@ -4669,6 +4760,7 @@ async function runNextWorkItem({
4669
4760
  const providerSessionStore = new LocalToolSessionStore();
4670
4761
  const providerSessionId = sessionContext.toolSession ? await providerSessionStore.getProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName) : void 0;
4671
4762
  let toolResult;
4763
+ const stopLeaseRenewal = startWorkLeaseRenewal({ apiClient, projectId, repositoryLinkId, runnerId, toolConfig, workItem: result.workItem, telemetry: isolationTelemetry });
4672
4764
  try {
4673
4765
  toolResult = await runLocalTool({
4674
4766
  rootDir: executionRoot,
@@ -4678,6 +4770,7 @@ async function runNextWorkItem({
4678
4770
  ...toolCommand ? { toolCommand } : {},
4679
4771
  ...toolConfig.model ? { model: toolConfig.model } : {},
4680
4772
  streamOutput: stream,
4773
+ timeoutMs: toolTimeoutMs,
4681
4774
  ...sessionContext.toolSession ? {
4682
4775
  session: {
4683
4776
  toolSessionId: sessionContext.toolSession.toolSessionId,
@@ -4688,10 +4781,11 @@ async function runNextWorkItem({
4688
4781
  } : {}
4689
4782
  });
4690
4783
  } catch (error) {
4784
+ stopLeaseRenewal();
4691
4785
  const detail = truncateLogExcerpt(errorDetail(error));
4692
4786
  const durationMs2 = Date.now() - startedAt;
4693
4787
  const message = `${preview.toolName} failed before returning a result.`;
4694
- await Promise.allSettled([
4788
+ const settlements = await Promise.allSettled([
4695
4789
  apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig)),
4696
4790
  markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage3(error)),
4697
4791
  apiClient.updateWorkStatus(projectId, result.workItem.workItemId, "failed", `run_failed_${result.workItem.workItemId}_${result.workItem.attempt}_${runnerId}`, runnerId, {
@@ -4713,11 +4807,13 @@ async function runNextWorkItem({
4713
4807
  metadata: { tool: preview.toolName, error: detail }
4714
4808
  })
4715
4809
  ]);
4810
+ logRejectedSettlements("record local tool failure", settlements);
4716
4811
  if (verbose || !stream) {
4717
4812
  console.error(detail);
4718
4813
  }
4719
4814
  return { status: "failed", exitCode: 1, message };
4720
4815
  }
4816
+ stopLeaseRenewal();
4721
4817
  if (sessionContext.toolSession && toolResult.providerSessionId) {
4722
4818
  await providerSessionStore.setProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName, toolResult.providerSessionId);
4723
4819
  }
@@ -4728,47 +4824,59 @@ async function runNextWorkItem({
4728
4824
  console.error(toolResult.stderr.trim());
4729
4825
  }
4730
4826
  if (result.workItem.workKind === "brainGeneration" || result.workItem.workKind === "planRevision") {
4731
- return finalizeBrainGenerationWork({
4732
- apiClient,
4733
- durationMs: Date.now() - startedAt,
4734
- projectId,
4735
- repositoryLinkId,
4736
- runnerId,
4737
- sessionContext,
4738
- toolConfig,
4739
- toolName: preview.toolName,
4740
- toolResult,
4741
- workItem: result.workItem
4742
- });
4827
+ try {
4828
+ return await finalizeBrainGenerationWork({
4829
+ apiClient,
4830
+ durationMs: Date.now() - startedAt,
4831
+ projectId,
4832
+ repositoryLinkId,
4833
+ runnerId,
4834
+ sessionContext,
4835
+ toolConfig,
4836
+ toolName: preview.toolName,
4837
+ toolResult,
4838
+ workItem: result.workItem
4839
+ });
4840
+ } catch (error) {
4841
+ return recordFinalizationFailure({ apiClient, error, isolationTelemetry, projectId, repositoryLinkId, runnerId, sessionContext, toolConfig, toolName: preview.toolName, workItem: result.workItem, durationMs: Date.now() - startedAt });
4842
+ }
4743
4843
  }
4744
4844
  if (result.workItem.workKind === "assistantQuestion") {
4745
- return finalizeAssistantQuestionWork({
4746
- apiClient,
4747
- durationMs: Date.now() - startedAt,
4748
- projectId,
4749
- repositoryLinkId,
4750
- runnerId,
4751
- sessionContext,
4752
- toolConfig,
4753
- toolName: preview.toolName,
4754
- toolResult,
4755
- workItem: result.workItem
4756
- });
4845
+ try {
4846
+ return await finalizeAssistantQuestionWork({
4847
+ apiClient,
4848
+ durationMs: Date.now() - startedAt,
4849
+ projectId,
4850
+ repositoryLinkId,
4851
+ runnerId,
4852
+ sessionContext,
4853
+ toolConfig,
4854
+ toolName: preview.toolName,
4855
+ toolResult,
4856
+ workItem: result.workItem
4857
+ });
4858
+ } catch (error) {
4859
+ return recordFinalizationFailure({ apiClient, error, isolationTelemetry, projectId, repositoryLinkId, runnerId, sessionContext, toolConfig, toolName: preview.toolName, workItem: result.workItem, durationMs: Date.now() - startedAt });
4860
+ }
4757
4861
  }
4758
4862
  if (result.workItem.workKind === "impactPreview") {
4759
- return finalizeImpactPreviewWork({
4760
- apiClient,
4761
- durationMs: Date.now() - startedAt,
4762
- projectId,
4763
- repositoryLinkId,
4764
- root,
4765
- runnerId,
4766
- sessionContext,
4767
- toolConfig,
4768
- toolName: preview.toolName,
4769
- toolResult,
4770
- workItem: result.workItem
4771
- });
4863
+ try {
4864
+ return await finalizeImpactPreviewWork({
4865
+ apiClient,
4866
+ durationMs: Date.now() - startedAt,
4867
+ projectId,
4868
+ repositoryLinkId,
4869
+ root,
4870
+ runnerId,
4871
+ sessionContext,
4872
+ toolConfig,
4873
+ toolName: preview.toolName,
4874
+ toolResult,
4875
+ workItem: result.workItem
4876
+ });
4877
+ } catch (error) {
4878
+ return recordFinalizationFailure({ apiClient, error, isolationTelemetry, projectId, repositoryLinkId, runnerId, sessionContext, toolConfig, toolName: preview.toolName, workItem: result.workItem, durationMs: Date.now() - startedAt });
4879
+ }
4772
4880
  }
4773
4881
  const finalStatus = toolResult.exitCode === 0 ? "completed" : "failed";
4774
4882
  const durationMs = Date.now() - startedAt;
@@ -4820,11 +4928,17 @@ async function runNextWorkItem({
4820
4928
  console.log(`Marked ${statusResult.workItem.workItemId} ${statusResult.workItem.status} after ${durationSeconds}s.`);
4821
4929
  return { status: finalStatus, exitCode: toolResult.exitCode };
4822
4930
  }
4823
- async function prepareWorktreeForClaimedItem({ apiClient, projectId, repositoryLinkId, root, runnerId, toolConfig, workItem }) {
4931
+ async function prepareWorktreeForClaimedItem({ apiClient, maxPreflightAttempts, projectId, repositoryLinkId, root, runnerId, toolConfig, workItem }) {
4824
4932
  if (!needsGitWorktreeIsolation(workItem)) {
4825
4933
  return { status: "ready" };
4826
4934
  }
4827
4935
  const identity = resolveWorktreeIdentity(workItem);
4936
+ await recordRunnerMilestone(apiClient, projectId, workItem, runnerId, repositoryLinkId, {
4937
+ status: "running",
4938
+ summary: `Checking Git worktree isolation for attempt ${workItem.attempt}/${maxPreflightAttempts}.`,
4939
+ idempotencyKey: `runner_milestone_worktree_preflight_${workItem.workItemId}_${workItem.attempt}`,
4940
+ metadata: { executionWorktreeKey: identity.worktreeKey, executionBranch: identity.branch, implementationScopeId: identity.implementationScopeId, attempt: workItem.attempt, maxAttempts: maxPreflightAttempts }
4941
+ });
4828
4942
  try {
4829
4943
  const isolation = await prepareGitWorktreeIsolation(root, workItem);
4830
4944
  await recordRunnerMilestone(apiClient, projectId, workItem, runnerId, repositoryLinkId, {
@@ -4837,29 +4951,111 @@ async function prepareWorktreeForClaimedItem({ apiClient, projectId, repositoryL
4837
4951
  } catch (error) {
4838
4952
  const message = errorMessage3(error);
4839
4953
  const telemetry = workItemIsolationTelemetry(workItem, { ...identity, baseRevision: workItem.baseRevision ?? "unknown", worktreePath: "" });
4840
- const statusResult = await apiClient.updateWorkStatus(projectId, workItem.workItemId, "blocked", `worktree_${workItem.workItemId}_${randomUUID()}`, runnerId, {
4954
+ const finalAttempt = workItem.attempt >= maxPreflightAttempts;
4955
+ const statusMessage = finalAttempt ? `Git worktree preflight failed after ${workItem.attempt}/${maxPreflightAttempts} attempts. ${message}` : `Git worktree preflight attempt ${workItem.attempt}/${maxPreflightAttempts} failed. Requeueing for retry. ${message}`;
4956
+ const statusResult = await apiClient.updateWorkStatus(projectId, workItem.workItemId, finalAttempt ? "failed" : "approved", `worktree_${finalAttempt ? "failed" : "retry"}_${workItem.workItemId}_${workItem.attempt}_${randomUUID()}`, runnerId, {
4841
4957
  ...telemetry,
4842
- message,
4843
- blockerReason: message,
4958
+ message: statusMessage,
4959
+ ...finalAttempt ? { blockerReason: message } : { releaseClaim: true },
4844
4960
  error: message
4845
4961
  });
4846
4962
  await recordRunnerMilestone(apiClient, projectId, statusResult.workItem, runnerId, repositoryLinkId, {
4847
- status: "blocked",
4848
- summary: message,
4849
- idempotencyKey: `runner_milestone_worktree_blocked_${workItem.workItemId}_${statusResult.workItem.idempotencyKey}`,
4850
- metadata: { executionWorktreeKey: telemetry.executionWorktreeKey ?? "", executionBranch: telemetry.executionBranch ?? "", implementationScopeId: telemetry.implementationScopeId ?? "" }
4963
+ status: finalAttempt ? "failed" : "warning",
4964
+ summary: statusMessage,
4965
+ idempotencyKey: `runner_milestone_worktree_${finalAttempt ? "failed" : "retry"}_${workItem.workItemId}_${statusResult.workItem.idempotencyKey}`,
4966
+ metadata: { executionWorktreeKey: telemetry.executionWorktreeKey ?? "", executionBranch: telemetry.executionBranch ?? "", implementationScopeId: telemetry.implementationScopeId ?? "", attempt: workItem.attempt, maxAttempts: maxPreflightAttempts, error: message }
4851
4967
  });
4852
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", {
4968
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", {
4853
4969
  ...runnerHeartbeatMetadata(toolConfig),
4854
- currentWorkItemId: workItem.workItemId,
4855
- ...telemetry.implementationScopeId ? { currentImplementationScopeId: telemetry.implementationScopeId } : {},
4856
- ...telemetry.executionWorktreeKey ? { currentWorktreeKey: telemetry.executionWorktreeKey } : {},
4857
- ...telemetry.executionBranch ? { currentBranch: telemetry.executionBranch } : {}
4970
+ preferenceMessage: statusMessage
4858
4971
  });
4859
- console.error(message);
4860
- return { status: "blocked", message };
4972
+ console.error(statusMessage);
4973
+ return { status: finalAttempt ? "failed" : "retrying", message: statusMessage };
4861
4974
  }
4862
4975
  }
4976
+ function startWorkLeaseRenewal({ apiClient, projectId, repositoryLinkId, runnerId, toolConfig, workItem, telemetry }) {
4977
+ let stopped = false;
4978
+ const renew = async () => {
4979
+ if (stopped) return;
4980
+ const leaseExpiresAt = new Date(Date.now() + RUNNER_WORK_LEASE_SECONDS * 1e3).toISOString();
4981
+ try {
4982
+ await apiClient.updateWorkStatus(projectId, workItem.workItemId, "running", `lease_renewal_${workItem.workItemId}_${workItem.attempt}_${Date.now()}`, runnerId, {
4983
+ ...telemetry,
4984
+ leaseExpiresAt
4985
+ });
4986
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "running", {
4987
+ ...runnerHeartbeatMetadata(toolConfig),
4988
+ currentWorkItemId: workItem.workItemId,
4989
+ ...telemetry.implementationScopeId ? { currentImplementationScopeId: telemetry.implementationScopeId } : {},
4990
+ ...telemetry.executionWorktreeKey ? { currentWorktreeKey: telemetry.executionWorktreeKey } : {},
4991
+ ...telemetry.executionBranch ? { currentBranch: telemetry.executionBranch } : {}
4992
+ });
4993
+ } catch (error) {
4994
+ const detail = truncateLogExcerpt(errorDetail(error));
4995
+ console.error(`Could not renew Amistio work lease for ${workItem.workItemId}: ${detail}`);
4996
+ await apiClient.recordRunnerLog(projectId, {
4997
+ runnerId,
4998
+ repositoryLinkId,
4999
+ status: "failed",
5000
+ workItemId: workItem.workItemId,
5001
+ workTitle: workItem.title,
5002
+ ...workItem.workKind ? { workKind: workItem.workKind } : {},
5003
+ message: "Runner could not renew the active work lease.",
5004
+ error: detail,
5005
+ machineId: runnerMachineId()
5006
+ }).catch(() => void 0);
5007
+ }
5008
+ };
5009
+ const timer = setInterval(() => {
5010
+ void renew();
5011
+ }, RUNNER_WORK_LEASE_RENEWAL_MS);
5012
+ timer.unref?.();
5013
+ return () => {
5014
+ stopped = true;
5015
+ clearInterval(timer);
5016
+ };
5017
+ }
5018
+ async function recordFinalizationFailure({ apiClient, durationMs, error, isolationTelemetry, projectId, repositoryLinkId, runnerId, sessionContext, toolConfig, toolName, workItem }) {
5019
+ const detail = truncateLogExcerpt(errorDetail(error));
5020
+ const message = `${toolName} completed, but Amistio could not finalize the result.`;
5021
+ const settlements = await Promise.allSettled([
5022
+ apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig)),
5023
+ markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage3(error)),
5024
+ apiClient.updateWorkStatus(projectId, workItem.workItemId, "failed", `finalize_failed_${workItem.workItemId}_${workItem.attempt}_${randomUUID()}`, runnerId, {
5025
+ ...isolationTelemetry,
5026
+ tool: toolName,
5027
+ durationMs,
5028
+ message,
5029
+ error: detail,
5030
+ ...sessionContext.toolSession ? { toolSessionId: sessionContext.toolSession.toolSessionId } : {},
5031
+ sessionPolicy: sessionContext.policy,
5032
+ sessionDecision: sessionContext.decision,
5033
+ sessionDecisionReason: sessionContext.reason
5034
+ }),
5035
+ apiClient.recordRunnerLog(projectId, {
5036
+ runnerId,
5037
+ repositoryLinkId,
5038
+ status: "failed",
5039
+ workItemId: workItem.workItemId,
5040
+ workTitle: workItem.title,
5041
+ ...workItem.workKind ? { workKind: workItem.workKind } : {},
5042
+ tool: toolName,
5043
+ durationMs,
5044
+ message,
5045
+ error: detail,
5046
+ machineId: runnerMachineId()
5047
+ }),
5048
+ recordRunnerMilestone(apiClient, projectId, workItem, runnerId, repositoryLinkId, {
5049
+ status: "failed",
5050
+ summary: message,
5051
+ idempotencyKey: `runner_milestone_finalization_failed_${workItem.workItemId}_${workItem.attempt}`,
5052
+ metadata: { tool: toolName, durationMs, error: detail }
5053
+ })
5054
+ ]);
5055
+ logRejectedSettlements("record finalization failure", settlements);
5056
+ console.error(detail);
5057
+ return { status: "failed", exitCode: 1, message };
5058
+ }
4863
5059
  function workItemIsolationTelemetry(workItem, isolation) {
4864
5060
  const implementationScopeId = isolation?.implementationScopeId ?? workItem.implementationScopeId;
4865
5061
  const executionBranch = isolation?.branch ?? workItem.executionBranch;
@@ -4888,6 +5084,13 @@ async function recordRunnerMilestone(apiClient, projectId, workItem, runnerId, r
4888
5084
  ...input
4889
5085
  }).catch(() => void 0);
4890
5086
  }
5087
+ function logRejectedSettlements(action, settlements) {
5088
+ for (const settlement of settlements) {
5089
+ if (settlement.status === "rejected") {
5090
+ console.error(`${action} failed: ${errorMessage3(settlement.reason)}`);
5091
+ }
5092
+ }
5093
+ }
4891
5094
  async function runPendingRunnerCommand(apiClient, context, heartbeatMetadata) {
4892
5095
  const { commands } = await apiClient.listRunnerCommands(context.projectId, context.runnerId, context.repositoryLinkId).catch(() => ({ commands: [] }));
4893
5096
  const command = commands.filter((item) => item.status === "pending" || item.status === "acknowledged" || item.status === "running").sort((first, second) => Date.parse(first.createdAt) - Date.parse(second.createdAt))[0];