@amistio/cli 0.1.52 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +336 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -95,6 +95,8 @@ Approved implementation work uses Git as the handoff boundary. During worktree p
95
95
 
96
96
  Failed or stale work can be requeued from the web Tasks panel. Requeue creates a new linked work attempt and preserves the original terminal attempt for audit history; Requeue safe sends one backend batch that recomputes safe candidates, reports already-active and skipped rows, and still uses linked attempts. Requeue is blocked while equivalent work is already active, when the paired runner does not advertise the needed work kind, or when the latest linked attempt repeats the same sanitized blocker fingerprint. Repeated runner setup, handoff, policy, verification, and worktree blockers require root-cause repair before another linked attempt. Completed implementation status is separate from proof: queue `implementationVerification` from Tasks when a plan needs source-aware evidence before cleanup or implementation status decisions.
97
97
 
98
+ Patch-context failures are typed recovery states. If a local tool reports a stale `apply_patch` context miss, such as an expected-lines mismatch, the runner marks the work with patch-drift recovery metadata and safe next actions instead of uploading raw patch output or leaving a generic failed row. Retryable API failures during implementation finalization are staged in the user-level Amistio finalization outbox and replayed before the runner claims more work, so a transient `500` does not require rerunning the local AI tool. When completion queues a required implementation Test gate, the source-work finalization remains pending locally, compatible runners claim the gate before unrelated implementation work, and finalization replays after the gate passes.
99
+
98
100
  Runner setup and local-tool execution use bounded failure controls. During Git worktree preflight, `amistio run --watch` repairs safe stale Git registrations when the target worktree directory is missing and Git marks the registration prunable; dirty, present, or ambiguous worktrees are preserved. Other Git worktree preflight failures are retried by releasing the claim for another attempt, then fail 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.
99
101
 
100
102
  The environment doctor blocks work before local AI/tool execution when `git`, `node`, Corepack, the package manager, required package scripts, dependencies, setup allowlist, Docker, or the requested execution profile is not ready. Foreground, background, and startup-service runners accept `--execution-profile`; use `--setup-package-manager-install` with `hostWorktreeWithSetup` when the runner may run the fixed package-manager install step in the execution worktree. Work and Runner surfaces receive sanitized profile/readiness metadata only; raw host paths, command lines, environment values, and secrets are not uploaded. Watch mode also performs a Git PATH preflight before auto-sync or work claiming. If the runner reports that Git is not available to the runner PATH, install Git or restart the foreground, background, or startup-service runner from an environment where `git --version` works. On macOS, service and GUI-launched runner environments may not inherit the same PATH as an interactive shell, so restart or reinstall the service after changing PATH.
package/dist/index.js CHANGED
@@ -157,7 +157,7 @@ var autopilotGuardCheckSchema = z.object({
157
157
  var implementationExpectedOutcomeSchema = z.enum(["sourceImplementation", "docsOnly", "analysisOnly", "verificationOnly"]);
158
158
  var implementationHandoffStatusSchema = z.enum(["notStarted", "noChanges", "prReady", "blocked", "failed", "noImplementationProduced"]);
159
159
  var implementationHandoffCleanupStatusSchema = z.enum(["notApplicable", "pending", "completed", "failed"]);
160
- var implementationHandoffRecoveryCategorySchema = z.enum(["noChangeCleaned", "rebaseConflict", "dirtyWorktree", "unresolvedConflicts", "providerBlocked", "cleanupRetryAvailable", "manualReview", "artifactBlocked", "nonGithubProvider"]);
160
+ var implementationHandoffRecoveryCategorySchema = z.enum(["noChangeCleaned", "rebaseConflict", "dirtyWorktree", "unresolvedConflicts", "providerBlocked", "cleanupRetryAvailable", "manualReview", "artifactBlocked", "nonGithubProvider", "patchDrift"]);
161
161
  var implementationHandoffRecoveryActionSchema = z.enum(["retryHandoff", "requeueFreshAttempt", "keepForManualRepair", "cleanNoChangeWorktree", "markSupersededNoChanges", "exportHandoffDetails", "retryCleanup"]);
162
162
  var implementationHandoffRebaseAbortStatusSchema = z.enum(["notAttempted", "succeeded", "failed", "notApplicable"]);
163
163
  var safeRepoPathSchema = z.string().trim().min(1).max(300).refine((value) => {
@@ -3538,6 +3538,47 @@ var durableResultFinalizationEntrySchema = z4.object({
3538
3538
  updatedAt: z4.string().min(1),
3539
3539
  lastError: z4.string().optional()
3540
3540
  }).and(durableResultMutationSchema);
3541
+ var implementationFinalizationTelemetrySchema = z4.object({
3542
+ message: z4.string().trim().min(1).max(1e3).optional(),
3543
+ tool: z4.string().trim().min(1).max(120).optional(),
3544
+ durationMs: z4.number().int().nonnegative().optional(),
3545
+ sessionPolicy: sessionPolicySchema.optional(),
3546
+ sessionGroupKey: z4.string().trim().min(1).max(200).optional(),
3547
+ toolSessionId: z4.string().trim().min(1).max(200).optional(),
3548
+ sessionDecision: sessionDecisionSchema.optional(),
3549
+ sessionDecisionReason: z4.string().trim().min(1).max(600).optional(),
3550
+ claimLaneId: z4.string().trim().min(1).max(80).optional(),
3551
+ claimLeaseId: z4.string().trim().min(1).max(200).optional(),
3552
+ controllingAdrId: z4.string().trim().min(1).max(200).optional(),
3553
+ implementationScopeId: z4.string().trim().min(1).max(200).optional(),
3554
+ executionBranch: z4.string().trim().min(1).max(200).optional(),
3555
+ executionWorktreeKey: z4.string().trim().min(1).max(200).optional(),
3556
+ isolationMode: z4.enum(["none", "gitWorktree"]).optional(),
3557
+ machineId: z4.string().trim().min(1).max(200).optional(),
3558
+ repositoryLockId: z4.string().trim().min(1).max(200).optional(),
3559
+ blockerReason: z4.string().trim().min(1).max(1e3).optional(),
3560
+ error: z4.string().trim().min(1).max(1200).optional(),
3561
+ implementationHandoff: implementationHandoffSchema.optional()
3562
+ }).strict();
3563
+ var implementationFinalizationEntrySchema = z4.object({
3564
+ schemaVersion: z4.literal(1),
3565
+ kind: z4.literal("implementationFinalization"),
3566
+ status: z4.enum(["pending", "terminal"]),
3567
+ accountId: z4.string().min(1),
3568
+ projectId: z4.string().min(1),
3569
+ repositoryLinkId: z4.string().min(1),
3570
+ runnerId: z4.string().min(1),
3571
+ workItemId: z4.string().min(1),
3572
+ workKind: z4.enum(["implementation", "promptBatch"]),
3573
+ attempt: z4.number().int().nonnegative(),
3574
+ idempotencyKey: z4.string().min(1),
3575
+ finalStatus: workStatusSchema,
3576
+ telemetry: implementationFinalizationTelemetrySchema,
3577
+ retryCount: z4.number().int().nonnegative(),
3578
+ createdAt: z4.string().min(1),
3579
+ updatedAt: z4.string().min(1),
3580
+ lastError: z4.string().optional()
3581
+ });
3541
3582
  function defaultResultFinalizationOutboxDir() {
3542
3583
  return path5.join(os2.homedir(), ".config", "amistio", "result-finalizations");
3543
3584
  }
@@ -3580,6 +3621,26 @@ function createDurableResultFinalizationEntry(input, now = (/* @__PURE__ */ new
3580
3621
  updatedAt: now
3581
3622
  });
3582
3623
  }
3624
+ function createImplementationFinalizationEntry(input, now = (/* @__PURE__ */ new Date()).toISOString()) {
3625
+ return implementationFinalizationEntrySchema.parse({
3626
+ schemaVersion: 1,
3627
+ kind: "implementationFinalization",
3628
+ status: "pending",
3629
+ accountId: input.accountId,
3630
+ projectId: input.projectId,
3631
+ repositoryLinkId: input.repositoryLinkId,
3632
+ runnerId: input.runnerId,
3633
+ workItemId: input.workItemId,
3634
+ workKind: input.workKind,
3635
+ attempt: input.attempt,
3636
+ idempotencyKey: input.idempotencyKey,
3637
+ finalStatus: input.finalStatus,
3638
+ telemetry: input.telemetry,
3639
+ retryCount: 0,
3640
+ createdAt: now,
3641
+ updatedAt: now
3642
+ });
3643
+ }
3583
3644
  async function upsertBrainGenerationFinalizationEntry(entry, outboxDir = defaultResultFinalizationOutboxDir()) {
3584
3645
  await mkdir5(outboxDir, { recursive: true });
3585
3646
  const filePath = brainGenerationFinalizationEntryPath(entry, outboxDir);
@@ -3620,6 +3681,14 @@ async function upsertDurableResultFinalizationEntry(entry, outboxDir = defaultRe
3620
3681
  `, "utf8");
3621
3682
  await rename(tempPath, filePath);
3622
3683
  }
3684
+ async function upsertImplementationFinalizationEntry(entry, outboxDir = defaultResultFinalizationOutboxDir()) {
3685
+ await mkdir5(outboxDir, { recursive: true });
3686
+ const filePath = implementationFinalizationEntryPath(entry, outboxDir);
3687
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3688
+ await writeFile4(tempPath, `${JSON.stringify(entry, null, 2)}
3689
+ `, "utf8");
3690
+ await rename(tempPath, filePath);
3691
+ }
3623
3692
  async function listPendingDurableResultFinalizations(scope, outboxDir = defaultResultFinalizationOutboxDir()) {
3624
3693
  const entries = await readdir2(outboxDir, { withFileTypes: true }).catch((error) => {
3625
3694
  if (error.code === "ENOENT") return [];
@@ -3644,6 +3713,30 @@ async function listPendingDurableResultFinalizations(scope, outboxDir = defaultR
3644
3713
  }
3645
3714
  return pending.sort((first, second) => Date.parse(first.createdAt) - Date.parse(second.createdAt));
3646
3715
  }
3716
+ async function listPendingImplementationFinalizations(scope, outboxDir = defaultResultFinalizationOutboxDir()) {
3717
+ const entries = await readdir2(outboxDir, { withFileTypes: true }).catch((error) => {
3718
+ if (error.code === "ENOENT") return [];
3719
+ throw error;
3720
+ });
3721
+ const pending = [];
3722
+ for (const entry of entries) {
3723
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
3724
+ const filePath = path5.join(outboxDir, entry.name);
3725
+ let raw;
3726
+ try {
3727
+ raw = JSON.parse(await readFile3(filePath, "utf8"));
3728
+ } catch {
3729
+ continue;
3730
+ }
3731
+ const parsed = implementationFinalizationEntrySchema.safeParse(raw);
3732
+ if (!parsed.success) continue;
3733
+ const value = parsed.data;
3734
+ if (value.status !== "pending") continue;
3735
+ if (value.accountId !== scope.accountId || value.projectId !== scope.projectId || value.repositoryLinkId !== scope.repositoryLinkId || value.runnerId !== scope.runnerId) continue;
3736
+ pending.push(value);
3737
+ }
3738
+ return pending.sort((first, second) => Date.parse(first.createdAt) - Date.parse(second.createdAt));
3739
+ }
3647
3740
  async function markBrainGenerationFinalizationRetry(entry, error, outboxDir = defaultResultFinalizationOutboxDir()) {
3648
3741
  const updated = brainGenerationFinalizationEntrySchema.parse({
3649
3742
  ...entry,
@@ -3689,9 +3782,33 @@ async function markDurableResultFinalizationTerminal(entry, error, outboxDir = d
3689
3782
  await upsertDurableResultFinalizationEntry(updated, outboxDir);
3690
3783
  return updated;
3691
3784
  }
3785
+ async function markImplementationFinalizationRetry(entry, error, outboxDir = defaultResultFinalizationOutboxDir()) {
3786
+ const updated = implementationFinalizationEntrySchema.parse({
3787
+ ...entry,
3788
+ status: "pending",
3789
+ retryCount: entry.retryCount + 1,
3790
+ lastError: truncateLocalError(error),
3791
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3792
+ });
3793
+ await upsertImplementationFinalizationEntry(updated, outboxDir);
3794
+ return updated;
3795
+ }
3796
+ async function markImplementationFinalizationTerminal(entry, error, outboxDir = defaultResultFinalizationOutboxDir()) {
3797
+ const updated = implementationFinalizationEntrySchema.parse({
3798
+ ...entry,
3799
+ status: "terminal",
3800
+ lastError: truncateLocalError(error),
3801
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3802
+ });
3803
+ await upsertImplementationFinalizationEntry(updated, outboxDir);
3804
+ return updated;
3805
+ }
3692
3806
  async function deleteDurableResultFinalizationEntry(entry, outboxDir = defaultResultFinalizationOutboxDir()) {
3693
3807
  await rm(durableResultFinalizationEntryPath(entry, outboxDir), { force: true });
3694
3808
  }
3809
+ async function deleteImplementationFinalizationEntry(entry, outboxDir = defaultResultFinalizationOutboxDir()) {
3810
+ await rm(implementationFinalizationEntryPath(entry, outboxDir), { force: true });
3811
+ }
3695
3812
  function brainGenerationFinalizationEntryPath(entry, outboxDir) {
3696
3813
  return path5.join(outboxDir, `${brainGenerationFinalizationEntryKey(entry)}.json`);
3697
3814
  }
@@ -3706,6 +3823,13 @@ function durableResultFinalizationEntryKey(entry) {
3706
3823
  const hash = createHash2("sha256").update([entry.accountId, entry.projectId, entry.repositoryLinkId, entry.runnerId, entry.workItemId, String(entry.attempt), entry.idempotencyKey, entry.resultKind].join("\0")).digest("hex").slice(0, 32);
3707
3824
  return `runner-result-${hash}`;
3708
3825
  }
3826
+ function implementationFinalizationEntryPath(entry, outboxDir) {
3827
+ return path5.join(outboxDir, `${implementationFinalizationEntryKey(entry)}.json`);
3828
+ }
3829
+ function implementationFinalizationEntryKey(entry) {
3830
+ const hash = createHash2("sha256").update([entry.accountId, entry.projectId, entry.repositoryLinkId, entry.runnerId, entry.workItemId, String(entry.attempt), entry.idempotencyKey].join("\0")).digest("hex").slice(0, 32);
3831
+ return `implementation-finalization-${hash}`;
3832
+ }
3709
3833
  function truncateLocalError(error) {
3710
3834
  const trimmed = error.trim();
3711
3835
  return trimmed.length > 600 ? `${trimmed.slice(0, 600)}...` : trimmed;
@@ -5099,11 +5223,20 @@ async function detectRunnerProviderCatalog(adapter) {
5099
5223
  return mergeProviderCatalogs(adapter.providerCatalog, localOpencodeConfigCatalog);
5100
5224
  }
5101
5225
  function localOpencodeProviderConfigPaths() {
5102
- return [
5226
+ const configPaths = [
5103
5227
  path7.join(os3.homedir(), ".config", "opencode", "opencode.json"),
5104
- path7.join(os3.homedir(), ".config", "opencode", "config.json"),
5105
- path7.join(process.cwd(), "opencode.json")
5228
+ path7.join(os3.homedir(), ".config", "opencode", "config.json")
5106
5229
  ];
5230
+ const cwd = safeProcessCwd();
5231
+ if (cwd) configPaths.push(path7.join(cwd, "opencode.json"));
5232
+ return configPaths;
5233
+ }
5234
+ function safeProcessCwd() {
5235
+ try {
5236
+ return process.cwd();
5237
+ } catch {
5238
+ return void 0;
5239
+ }
5107
5240
  }
5108
5241
  async function loadLocalOpencodeProviderConfigCatalog(configPaths = localOpencodeProviderConfigPaths()) {
5109
5242
  for (const configPath of configPaths) {
@@ -6112,6 +6245,30 @@ async function checkRunnerWatchGitPreflight(options) {
6112
6245
  return { status: "blocked", exitCode: 1, message, readiness: readiness2 };
6113
6246
  }
6114
6247
 
6248
+ // src/runner-patch-drift.ts
6249
+ var patchDriftPatterns = [
6250
+ { signature: "applyPatchVerificationFailed", pattern: /apply_patch\s+verification\s+failed/i },
6251
+ { signature: "expectedLinesMissing", pattern: /failed\s+to\s+find\s+expected\s+lines/i },
6252
+ { signature: "patchContextMismatch", pattern: /patch\s+context\s+(?:mismatch|drift)|stale\s+patch\s+context/i }
6253
+ ];
6254
+ function classifyPatchDriftToolResult(result) {
6255
+ if (result.exitCode === 0) return void 0;
6256
+ return classifyPatchDriftText(`${result.stderr}
6257
+ ${result.stdout}`);
6258
+ }
6259
+ function classifyPatchDriftText(output) {
6260
+ for (const candidate of patchDriftPatterns) {
6261
+ if (candidate.pattern.test(output)) {
6262
+ return {
6263
+ signature: candidate.signature,
6264
+ message: "Patch context drift was detected; refresh context or queue a fresh linked attempt before retrying this implementation.",
6265
+ summary: `Local patch application failed with ${candidate.signature}. The runner preserved bounded recovery metadata instead of storing raw patch output.`
6266
+ };
6267
+ }
6268
+ }
6269
+ return void 0;
6270
+ }
6271
+
6115
6272
  // src/runner-watch-errors.ts
6116
6273
  var forgottenRunnerWatchMessage = "This runner was forgotten by the server. Start or pair a new runner from the Runner panel; this runner ID cannot heartbeat or claim work anymore.";
6117
6274
  async function handleRunnerWatchError(options) {
@@ -12270,6 +12427,25 @@ async function runNextWorkItem({
12270
12427
  let implementationHandoff;
12271
12428
  let finalMessage = `${preview.toolName} exited with code ${toolResult.exitCode}.`;
12272
12429
  let finalError = failureExcerpt ?? promptBatchFailureReason(promptBatchResult);
12430
+ const patchDrift = finalStatus !== "completed" ? classifyPatchDriftToolResult(toolResult) : void 0;
12431
+ if (patchDrift) {
12432
+ finalStatus = "blocked";
12433
+ finalMessage = patchDrift.message;
12434
+ finalError = patchDrift.summary;
12435
+ implementationHandoff = {
12436
+ status: "blocked",
12437
+ cleanupStatus: "pending",
12438
+ message: patchDrift.message,
12439
+ error: patchDrift.summary,
12440
+ recovery: {
12441
+ category: "patchDrift",
12442
+ availableActions: ["requeueFreshAttempt", "exportHandoffDetails"],
12443
+ conflictFiles: [],
12444
+ summary: "The local patch did not match the current worktree context. Refresh context or queue a fresh linked attempt before retrying.",
12445
+ ...isolationTelemetry.executionWorktreeKey ? { worktreeKey: isolationTelemetry.executionWorktreeKey } : {}
12446
+ }
12447
+ };
12448
+ }
12273
12449
  if (promptBatchResult && finalStatus !== "completed" && toolResult.exitCode === 0) {
12274
12450
  finalMessage = "Prompt batch reported a failed or blocked child prompt.";
12275
12451
  }
@@ -12320,33 +12496,59 @@ async function runNextWorkItem({
12320
12496
  ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {}
12321
12497
  });
12322
12498
  let statusResult;
12499
+ const finalizationIdempotencyKey = `run_${result.workItem.workItemId}_${randomUUID3()}`;
12500
+ const finalizationTelemetry = {
12501
+ tool: preview.toolName,
12502
+ ...toolResult.model ? { model: toolResult.model } : {},
12503
+ durationMs,
12504
+ message: finalMessage,
12505
+ ...isolationTelemetry,
12506
+ ...finalStatus === "blocked" ? { blockerReason: finalMessage } : {},
12507
+ sessionPolicy: sessionContext.policy,
12508
+ sessionDecision: sessionContext.decision,
12509
+ sessionDecisionReason: sessionContext.reason,
12510
+ ...updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {},
12511
+ ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {},
12512
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
12513
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
12514
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {},
12515
+ ...implementationHandoff ? { implementationHandoff } : {},
12516
+ ...result.workItem.workKind === "promptBatch" ? { promptBatch: finalizePromptBatchMetadata(result.workItem, finalStatus, finalError, promptBatchResult) } : {},
12517
+ ...finalError ? { error: finalError } : {}
12518
+ };
12323
12519
  try {
12324
- statusResult = await apiClient.updateWorkStatus(
12325
- projectId,
12326
- result.workItem.workItemId,
12520
+ const implementationFinalization = await submitStagedImplementationFinalization(apiClient, {
12521
+ accountId: commandContext.accountId,
12522
+ attempt: result.workItem.attempt,
12327
12523
  finalStatus,
12328
- `run_${result.workItem.workItemId}_${randomUUID3()}`,
12524
+ idempotencyKey: finalizationIdempotencyKey,
12525
+ projectId,
12526
+ repositoryLinkId,
12329
12527
  runnerId,
12330
- {
12331
- tool: preview.toolName,
12332
- ...toolResult.model ? { model: toolResult.model } : {},
12333
- durationMs,
12334
- message: finalMessage,
12335
- ...isolationTelemetry,
12336
- ...finalStatus === "blocked" ? { blockerReason: finalMessage } : {},
12337
- sessionPolicy: sessionContext.policy,
12338
- sessionDecision: sessionContext.decision,
12339
- sessionDecisionReason: sessionContext.reason,
12340
- ...updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {},
12341
- ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {},
12342
- ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
12343
- ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
12344
- ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {},
12345
- ...implementationHandoff ? { implementationHandoff } : {},
12346
- ...result.workItem.workKind === "promptBatch" ? { promptBatch: finalizePromptBatchMetadata(result.workItem, finalStatus, finalError, promptBatchResult) } : {},
12347
- ...finalError ? { error: finalError } : {}
12528
+ telemetry: implementationFinalizationTelemetry(finalizationTelemetry),
12529
+ workItemId: result.workItem.workItemId,
12530
+ workKind: result.workItem.workKind === "promptBatch" ? "promptBatch" : "implementation"
12531
+ });
12532
+ if (implementationFinalization.status === "failed") {
12533
+ if (implementationFinalization.retryable) {
12534
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig, currentRunnerMode(), heartbeatConcurrency)).catch(() => void 0);
12535
+ return { status: "blocked", exitCode: 0, message: implementationFinalization.message };
12348
12536
  }
12349
- );
12537
+ throw new Error(implementationFinalization.message);
12538
+ }
12539
+ if (implementationFinalization.status === "deferred") {
12540
+ const gateMessage = "Implementation test gate was queued and must pass before completion or PR handoff is finalized.";
12541
+ await recordRunnerMilestone(apiClient, projectId, result.workItem, runnerId, repositoryLinkId, {
12542
+ status: "queued",
12543
+ summary: gateMessage,
12544
+ idempotencyKey: `runner_milestone_test_gate_required_${result.workItem.workItemId}_${result.workItem.attempt}`,
12545
+ metadata: { tool: preview.toolName, durationMs, executionWorktreeKey: isolationTelemetry.executionWorktreeKey ?? "", executionBranch: isolationTelemetry.executionBranch ?? "" }
12546
+ });
12547
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig, currentRunnerMode(), heartbeatConcurrency));
12548
+ console.log(gateMessage);
12549
+ return { status: "blocked", exitCode: 0, message: gateMessage };
12550
+ }
12551
+ statusResult = { workItem: implementationFinalization.workItem };
12350
12552
  } catch (error) {
12351
12553
  if (error instanceof AmistioApiError && error.status === 409 && error.detail.includes("implementation_test_gate_required")) {
12352
12554
  const gateMessage = "Implementation test gate was queued and must pass before completion or PR handoff is finalized.";
@@ -12895,10 +13097,12 @@ async function replayPendingResultFinalizations({ accountId, apiClient, projectI
12895
13097
  return brainReplay;
12896
13098
  }
12897
13099
  const pendingEntries = await listPendingDurableResultFinalizations({ accountId, projectId, repositoryLinkId, runnerId });
12898
- if (!pendingEntries.length) {
13100
+ const pendingImplementationEntries = await listPendingImplementationFinalizations({ accountId, projectId, repositoryLinkId, runnerId });
13101
+ if (!pendingEntries.length && !pendingImplementationEntries.length) {
12899
13102
  return void 0;
12900
13103
  }
12901
13104
  let completedCount = 0;
13105
+ let deferredCount = 0;
12902
13106
  for (const entry of pendingEntries) {
12903
13107
  const replay = await submitDurableResultFinalizationEntry(apiClient, entry, { recordReplayTelemetry: true });
12904
13108
  if (replay.status === "completed") {
@@ -12907,7 +13111,24 @@ async function replayPendingResultFinalizations({ accountId, apiClient, projectI
12907
13111
  }
12908
13112
  return { status: "failed", exitCode: 1, message: replay.message };
12909
13113
  }
12910
- const message = `Replayed ${completedCount} pending runner result finalization${completedCount === 1 ? "" : "s"}.`;
13114
+ for (const entry of pendingImplementationEntries) {
13115
+ const replay = await submitImplementationFinalizationEntry(apiClient, entry, { recordReplayTelemetry: true });
13116
+ if (replay.status === "completed") {
13117
+ completedCount += 1;
13118
+ continue;
13119
+ }
13120
+ if (replay.status === "deferred") {
13121
+ deferredCount += 1;
13122
+ continue;
13123
+ }
13124
+ return { status: "failed", exitCode: 1, message: replay.message };
13125
+ }
13126
+ if (!completedCount && deferredCount) {
13127
+ const message2 = `Deferred ${deferredCount} pending implementation finalization${deferredCount === 1 ? "" : "s"} until required implementation test gates pass.`;
13128
+ console.log(message2);
13129
+ return void 0;
13130
+ }
13131
+ const message = `Replayed ${completedCount} pending runner finalization${completedCount === 1 ? "" : "s"}.`;
12911
13132
  console.log(message);
12912
13133
  return { status: "completed", exitCode: 0, message };
12913
13134
  }
@@ -12980,6 +13201,59 @@ async function submitDurableResultFinalizationEntry(apiClient, entry, options =
12980
13201
  }
12981
13202
  return { status: "completed", workItem, response };
12982
13203
  }
13204
+ async function submitImplementationFinalizationEntry(apiClient, entry, options = {}) {
13205
+ let response;
13206
+ try {
13207
+ response = await apiClient.updateWorkStatus(entry.projectId, entry.workItemId, entry.finalStatus, entry.idempotencyKey, entry.runnerId, entry.telemetry);
13208
+ } catch (error) {
13209
+ if (isImplementationTestGateRequiredError(error)) {
13210
+ await markImplementationFinalizationRetry(entry, "implementation_test_gate_required");
13211
+ const message2 = `Pending implementation finalization ${entry.workItemId} is waiting for the required implementation test gate to pass.`;
13212
+ await apiClient.updateWorkStatus(entry.projectId, entry.workItemId, "blocked", `${entry.idempotencyKey}_gate_pending`, entry.runnerId, {
13213
+ ...entry.telemetry,
13214
+ message: message2,
13215
+ blockerReason: message2,
13216
+ releaseClaim: true
13217
+ }).catch((releaseError) => {
13218
+ console.error(`Could not release implementation work ${entry.workItemId} while waiting for its test gate: ${truncateLogExcerpt(errorMessage7(releaseError))}`);
13219
+ });
13220
+ console.log(message2);
13221
+ return { status: "deferred", message: message2, retryable: true };
13222
+ }
13223
+ const detail = truncateLogExcerpt(errorMessage7(error));
13224
+ if (isRetryableApiError(error)) {
13225
+ const updated = await markImplementationFinalizationRetry(entry, detail);
13226
+ const message2 = `Pending implementation finalization ${entry.workItemId} could not be replayed yet (${updated.retryCount} attempt${updated.retryCount === 1 ? "" : "s"}): ${detail}`;
13227
+ console.error(message2);
13228
+ return { status: "failed", message: message2, retryable: true };
13229
+ }
13230
+ await markImplementationFinalizationTerminal(entry, detail);
13231
+ const message = `Pending implementation finalization ${entry.workItemId} reached a terminal API failure: ${detail}`;
13232
+ console.error(message);
13233
+ return { status: "failed", message, retryable: false };
13234
+ }
13235
+ await deleteImplementationFinalizationEntry(entry).catch((error) => {
13236
+ console.error(`delete pending implementation finalization ${entry.workItemId} failed: ${errorMessage7(error)}`);
13237
+ });
13238
+ if (options.recordReplayTelemetry) {
13239
+ await recordRunnerMilestone(apiClient, entry.projectId, response.workItem, entry.runnerId, entry.repositoryLinkId, {
13240
+ status: runnerMilestoneStatusFromWorkStatus(entry.finalStatus),
13241
+ summary: `Replayed pending implementation finalization as ${entry.finalStatus}.`,
13242
+ idempotencyKey: `runner_milestone_implementation_replayed_${entry.workItemId}_${response.workItem.idempotencyKey}`,
13243
+ metadata: { replayedFinalization: true, workKind: entry.workKind }
13244
+ });
13245
+ await apiClient.sendRunnerHeartbeat(entry.projectId, entry.runnerId, entry.repositoryLinkId, "online", {
13246
+ ...runnerHeartbeatMetadata(),
13247
+ preferenceMessage: "Pending implementation finalization replayed."
13248
+ }).catch(() => void 0);
13249
+ }
13250
+ return { status: "completed", workItem: response.workItem };
13251
+ }
13252
+ function runnerMilestoneStatusFromWorkStatus(status) {
13253
+ if (status === "completed" || status === "failed" || status === "running" || status === "blocked") return status;
13254
+ if (status === "approved" || status === "drafted") return "queued";
13255
+ return "info";
13256
+ }
12983
13257
  async function submitDurableResultMutation(apiClient, entry) {
12984
13258
  if (entry.resultKind === "assistantResult") {
12985
13259
  return apiClient.submitAssistantResult(entry.projectId, entry.workItemId, entry.result);
@@ -13033,6 +13307,39 @@ async function submitStagedDurableResultFinalization(apiClient, input) {
13033
13307
  await upsertDurableResultFinalizationEntry(entry);
13034
13308
  return submitDurableResultFinalizationEntry(apiClient, entry);
13035
13309
  }
13310
+ async function submitStagedImplementationFinalization(apiClient, input) {
13311
+ const entry = createImplementationFinalizationEntry(input);
13312
+ await upsertImplementationFinalizationEntry(entry);
13313
+ return submitImplementationFinalizationEntry(apiClient, entry);
13314
+ }
13315
+ function implementationFinalizationTelemetry(telemetry) {
13316
+ const isolationMode = telemetry.isolationMode === "none" || telemetry.isolationMode === "gitWorktree" ? telemetry.isolationMode : void 0;
13317
+ return {
13318
+ ...telemetry.message ? { message: telemetry.message } : {},
13319
+ ...telemetry.tool ? { tool: telemetry.tool } : {},
13320
+ ...telemetry.durationMs !== void 0 ? { durationMs: telemetry.durationMs } : {},
13321
+ ...telemetry.sessionPolicy ? { sessionPolicy: telemetry.sessionPolicy } : {},
13322
+ ...telemetry.sessionGroupKey ? { sessionGroupKey: telemetry.sessionGroupKey } : {},
13323
+ ...telemetry.toolSessionId ? { toolSessionId: telemetry.toolSessionId } : {},
13324
+ ...telemetry.sessionDecision ? { sessionDecision: telemetry.sessionDecision } : {},
13325
+ ...telemetry.sessionDecisionReason ? { sessionDecisionReason: telemetry.sessionDecisionReason } : {},
13326
+ ...telemetry.claimLaneId ? { claimLaneId: telemetry.claimLaneId } : {},
13327
+ ...telemetry.claimLeaseId ? { claimLeaseId: telemetry.claimLeaseId } : {},
13328
+ ...telemetry.controllingAdrId ? { controllingAdrId: telemetry.controllingAdrId } : {},
13329
+ ...telemetry.implementationScopeId ? { implementationScopeId: telemetry.implementationScopeId } : {},
13330
+ ...telemetry.executionBranch ? { executionBranch: telemetry.executionBranch } : {},
13331
+ ...telemetry.executionWorktreeKey ? { executionWorktreeKey: telemetry.executionWorktreeKey } : {},
13332
+ ...isolationMode ? { isolationMode } : {},
13333
+ ...telemetry.machineId ? { machineId: telemetry.machineId } : {},
13334
+ ...telemetry.repositoryLockId ? { repositoryLockId: telemetry.repositoryLockId } : {},
13335
+ ...telemetry.blockerReason ? { blockerReason: telemetry.blockerReason } : {},
13336
+ ...telemetry.error ? { error: telemetry.error } : {},
13337
+ ...telemetry.implementationHandoff ? { implementationHandoff: telemetry.implementationHandoff } : {}
13338
+ };
13339
+ }
13340
+ function isImplementationTestGateRequiredError(error) {
13341
+ return error instanceof AmistioApiError && error.status === 409 && error.detail.includes("implementation_test_gate_required");
13342
+ }
13036
13343
  async function submitPrimaryRunnerResult(apiClient, input) {
13037
13344
  const replay = await submitStagedDurableResultFinalization(apiClient, input);
13038
13345
  if (replay.status === "failed") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amistio/cli",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",