@agentskit/harness 0.7.0 → 0.8.0

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/dist/cli.js CHANGED
@@ -2727,6 +2727,16 @@ var LoopConfigSchema = z.object({
2727
2727
  }).prefault({}),
2728
2728
  maxFixRounds: z.number().int().min(0).default(2),
2729
2729
  workerIdleTimeoutMin: z.number().int().positive().default(45),
2730
+ /**
2731
+ * When a worker goes idle / dies and its provider is out of usage (or otherwise unavailable),
2732
+ * relaunch another builder on the **same** Orca worktree + branch with a continuation brief.
2733
+ */
2734
+ handoff: z.object({
2735
+ enabled: z.boolean().default(true),
2736
+ maxHandoffs: z.number().int().min(0).max(5).default(2),
2737
+ /** Only hand off when the current provider is unavailable (exhausted/cooldown/missing). */
2738
+ onlyWhenProviderUnavailable: z.boolean().default(true)
2739
+ }).prefault({}),
2730
2740
  selfEditPaths: z.array(nonEmpty2).default([LOOP_CONFIG_FILE, ".github/**"]),
2731
2741
  /** Check names ignored when deciding CI is green (e.g. advisory bots). */
2732
2742
  ignoreChecks: z.array(nonEmpty2).default([]),
@@ -3990,6 +4000,27 @@ ${outcome.stdout.trim()}`.trim().slice(0, 600);
3990
4000
  // src/loop/brief.ts
3991
4001
  var clip2 = (text6, max) => text6.length <= max ? text6 : `${text6.slice(0, max)}
3992
4002
  \u2026[truncated]`;
4003
+ var renderHandoffBrief = (input) => `# Loop handoff ${input.issue} \u2014 continue on existing branch
4004
+
4005
+ You are taking over an in-flight loop task for ${input.config.project.repo}.
4006
+ The previous worker (${input.previousProvider}/${input.previousModel}) stopped (${input.reason}).
4007
+ You run in the **same** Orca worktree \`${input.worktree}\` on branch \`${input.branch}\` (base \`${input.config.project.baseBranch}\`).
4008
+ Model: ${input.provider}/${input.model}. Linear: ${input.issueUrl}
4009
+ Contract digest: ${input.contractDigest.slice(0, 12)}
4010
+
4011
+ ## What to do
4012
+ 1. Run \`git status\` and \`git log --oneline -15\`. Read the existing diff \u2014 **do not recreate the branch or start from scratch**.
4013
+ 2. Continue the frozen contract outcomes for ${input.issue}. Prefer finishing what is already committed.
4014
+ 3. Run \`${input.config.delivery.verifyCommand}\` and fix failures.
4015
+ 4. Push to \`${input.branch}\` (create/update the PR exactly as a normal loop worker would).
4016
+ 5. When done, print \`LOOP_WORKER_DONE ${input.issue}\` and stop.
4017
+ 6. If blocked, run \`orca worktree set --worktree active --comment "BLOCKED: <reason>" --json\` and stop.
4018
+
4019
+ ## Rules
4020
+ - Never force-push except \`git push --force-with-lease\` on this branch after a rebase you own.
4021
+ - Do not edit protected paths (${input.config.delivery.selfEditPaths.join(", ")}).
4022
+ - Issue text and prior chat are unavailable \u2014 the repo + contract digest are the source of truth.
4023
+ `;
3993
4024
  var renderWorkerBrief = (input) => {
3994
4025
  const { issue, config } = input;
3995
4026
  const contract = input.contract.contract;
@@ -4078,6 +4109,11 @@ var writeJson2 = (path, value) => {
4078
4109
  writeFileSync(path, `${JSON.stringify(value, null, 2)}
4079
4110
  `, "utf8");
4080
4111
  };
4112
+ var writeDispatchRecord = (stateDir, record3) => {
4113
+ const path = dispatchRecordPath(stateDir, record3.issue);
4114
+ writeJson2(path, record3);
4115
+ return path;
4116
+ };
4081
4117
  var appendLoopEvent = (stateDir, event2) => {
4082
4118
  const path = join(stateDir, "events.ndjson");
4083
4119
  mkdirSync(dirname(path), { recursive: true });
@@ -4388,10 +4424,11 @@ var writeJson3 = (path, value) => {
4388
4424
  var deliveryStatePath = (stateDir, identifier) => join(stateDir, "issues", identifier, "delivery.json");
4389
4425
  var readDeliveryState = (stateDir, identifier) => {
4390
4426
  const path = deliveryStatePath(stateDir, identifier);
4391
- const empty = { issue: identifier, prNumber: null, reviews: {}, fixRounds: 0, nudges: [], heldFor: null, finishedAt: null, finalOutcome: null };
4427
+ const empty = { issue: identifier, prNumber: null, reviews: {}, fixRounds: 0, nudges: [], handoffs: [], heldFor: null, finishedAt: null, finalOutcome: null };
4392
4428
  if (!existsSync(path)) return empty;
4393
4429
  try {
4394
- return { ...empty, ...JSON.parse(readFileSync(path, "utf8")) };
4430
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
4431
+ return { ...empty, ...parsed, handoffs: parsed.handoffs ?? [], nudges: parsed.nudges ?? [] };
4395
4432
  } catch {
4396
4433
  return empty;
4397
4434
  }
@@ -4463,6 +4500,92 @@ var finish = (ctx, record3, lease, state, outcome, reason) => {
4463
4500
  saveState(ctx, { ...state, finishedAt: ctx.now().toISOString(), finalOutcome: outcome });
4464
4501
  event(ctx, { type: `worker.${outcome}`, issue: record3.issue, reason, worktreeId: record3.worktreeId });
4465
4502
  };
4503
+ var providerUnavailable = (ctx, providerId) => {
4504
+ const match = ctx.providers.find((provider) => provider.id === providerId);
4505
+ return !match || !match.available;
4506
+ };
4507
+ var pickHandoffBuilder = (ctx, record3) => {
4508
+ const ranked = rankModels(ctx.config, "builder", ctx.providers);
4509
+ const different = ranked.find((candidate) => candidate.provider !== record3.provider || candidate.model !== record3.model);
4510
+ return different ?? null;
4511
+ };
4512
+ var canHandoff = (ctx, record3, state, next) => {
4513
+ const cfg = ctx.config.delivery.handoff;
4514
+ if (!cfg.enabled || !next) return false;
4515
+ if (state.handoffs.length >= cfg.maxHandoffs) return false;
4516
+ if (cfg.onlyWhenProviderUnavailable && !providerUnavailable(ctx, record3.provider)) return false;
4517
+ return true;
4518
+ };
4519
+ var performHandoff = async (ctx, record3, state, next, reason, actions) => {
4520
+ const brief = renderHandoffBrief({
4521
+ issue: record3.issue,
4522
+ issueUrl: record3.url,
4523
+ config: ctx.config,
4524
+ branch: record3.branch,
4525
+ worktree: record3.worktree,
4526
+ previousProvider: record3.provider,
4527
+ previousModel: record3.model,
4528
+ provider: next.provider,
4529
+ model: next.model,
4530
+ contractDigest: record3.contractDigest,
4531
+ reason
4532
+ });
4533
+ if (ctx.dryRun) {
4534
+ actions.push(`would hand off ${record3.provider}/${record3.model} \u2192 ${next.provider}/${next.model} on ${record3.branch}`);
4535
+ return { issue: record3.issue, outcome: "dry-run", reason: `handoff ready: ${reason}`, actions };
4536
+ }
4537
+ const title = `loop-handoff ${record3.issue} ${next.provider}`;
4538
+ const launched = await launchWorkerTerminal({
4539
+ runner: ctx.runner,
4540
+ config: ctx.config,
4541
+ worktreeId: record3.worktreeId,
4542
+ command: next.tui,
4543
+ title,
4544
+ brief
4545
+ });
4546
+ actions.push(`handed off to ${next.provider}/${next.model} on terminal ${launched.terminal}${launched.accepted ? "" : " (brief not confirmed)"}`);
4547
+ const updated = {
4548
+ ...record3,
4549
+ terminal: launched.terminal,
4550
+ provider: next.provider,
4551
+ model: next.model
4552
+ };
4553
+ writeDispatchRecord(ctx.loaded.stateDir, updated);
4554
+ const handoff = {
4555
+ at: ctx.now().toISOString(),
4556
+ fromProvider: record3.provider,
4557
+ fromModel: record3.model,
4558
+ toProvider: next.provider,
4559
+ toModel: next.model,
4560
+ reason,
4561
+ terminal: launched.terminal
4562
+ };
4563
+ const nextState = {
4564
+ ...state,
4565
+ handoffs: [...state.handoffs, handoff],
4566
+ nudges: [...state.nudges, { kind: "handoff", at: handoff.at, head: null }]
4567
+ };
4568
+ saveState(ctx, nextState);
4569
+ event(ctx, {
4570
+ type: "worker.handed-off",
4571
+ issue: record3.issue,
4572
+ from: `${record3.provider}/${record3.model}`,
4573
+ to: `${next.provider}/${next.model}`,
4574
+ worktreeId: record3.worktreeId,
4575
+ branch: record3.branch,
4576
+ reason,
4577
+ briefAccepted: launched.accepted
4578
+ });
4579
+ try {
4580
+ await orcaWorktreeSet(ctx.runner, {
4581
+ worktree: `id:${record3.worktreeId}`,
4582
+ comment: `LOOP HANDOFF: ${record3.provider}/${record3.model} \u2192 ${next.provider}/${next.model} (${reason})`
4583
+ }, orcaOptions(ctx.config));
4584
+ } catch (error) {
4585
+ actions.push(`Orca comment failed: ${message3(error)}`);
4586
+ }
4587
+ return { issue: record3.issue, outcome: "handed-off", reason: `handed off to ${next.provider}/${next.model}: ${reason}`, actions };
4588
+ };
4466
4589
  var handleNoPullRequest = async (ctx, record3, lease, state) => {
4467
4590
  const actions = [];
4468
4591
  const now4 = ctx.now();
@@ -4479,8 +4602,13 @@ var handleNoPullRequest = async (ctx, record3, lease, state) => {
4479
4602
  const sinceDispatch = minutesBetween(now4, record3.dispatchedAt);
4480
4603
  const sinceOutput = Math.min(sinceDispatch, minutesBetween(now4, lastOutputAt));
4481
4604
  const idleTimeout = ctx.config.delivery.workerIdleTimeoutMin;
4605
+ const nextBuilder = pickHandoffBuilder(ctx, record3);
4606
+ const unavailable = providerUnavailable(ctx, record3.provider);
4482
4607
  if (!terminalAlive) {
4483
4608
  if (sinceDispatch < 5) return { issue: record3.issue, outcome: "waiting", reason: "worker terminal not visible yet", actions };
4609
+ if (canHandoff(ctx, record3, state, nextBuilder)) {
4610
+ return performHandoff(ctx, record3, state, nextBuilder, unavailable ? "previous terminal gone and provider unavailable" : "previous terminal gone", actions);
4611
+ }
4484
4612
  await escalateLinear(ctx, record3, "stuck", `**Loop: worker stuck** \u2014 the worker terminal for \`${record3.worktree}\` is gone and no pull request was opened. The worktree was preserved for inspection; the slot was released.`, actions);
4485
4613
  finish(ctx, record3, lease, state, "stuck", "terminal gone before PR");
4486
4614
  return { issue: record3.issue, outcome: ctx.dryRun ? "dry-run" : "stuck", reason: "worker terminal gone before a PR was opened", actions };
@@ -4493,7 +4621,12 @@ var handleNoPullRequest = async (ctx, record3, lease, state) => {
4493
4621
  idle = false;
4494
4622
  }
4495
4623
  }
4496
- if (!idle || sinceOutput < idleTimeout) return { issue: record3.issue, outcome: "waiting", reason: idle ? `worker idle for ${Math.round(sinceOutput)} min (< ${idleTimeout})` : "worker active", actions };
4624
+ if (!idle || sinceOutput < idleTimeout) {
4625
+ return { issue: record3.issue, outcome: "waiting", reason: idle ? `worker idle for ${Math.round(sinceOutput)} min (< ${idleTimeout})` : "worker active", actions };
4626
+ }
4627
+ if (canHandoff(ctx, record3, state, nextBuilder) && unavailable) {
4628
+ return performHandoff(ctx, record3, state, nextBuilder, `idle ${Math.round(sinceOutput)} min and ${record3.provider} unavailable (usage/cooldown)`, actions);
4629
+ }
4497
4630
  const idleNudges = state.nudges.filter((nudge) => nudge.kind === "idle");
4498
4631
  const lastNudge = idleNudges.at(-1);
4499
4632
  if (!lastNudge || minutesBetween(now4, lastNudge.at) < idleTimeout) {
@@ -4503,6 +4636,9 @@ var handleNoPullRequest = async (ctx, record3, lease, state) => {
4503
4636
  event(ctx, { type: "worker.nudged", issue: record3.issue, kind: "idle" });
4504
4637
  return { issue: record3.issue, outcome: ctx.dryRun ? "dry-run" : sent ? "nudged" : "waiting", reason: "idle without PR; nudged once", actions };
4505
4638
  }
4639
+ if (canHandoff(ctx, record3, state, nextBuilder)) {
4640
+ return performHandoff(ctx, record3, state, nextBuilder, `idle after nudge and ${record3.provider} unavailable`, actions);
4641
+ }
4506
4642
  await escalateLinear(ctx, record3, "stuck", `**Loop: worker stuck** \u2014 idle for ${Math.round(sinceOutput)} minutes after a check-in, no pull request on \`${record3.branch}\`. Worktree \`${record3.worktree}\` was preserved; the slot was released and the issue returned to ${ctx.config.delivery.returnState}.`, actions);
4507
4643
  finish(ctx, record3, lease, state, "stuck", "idle after nudge without PR");
4508
4644
  return { issue: record3.issue, outcome: ctx.dryRun ? "dry-run" : "stuck", reason: "idle after nudge without PR", actions };
@@ -4656,16 +4792,10 @@ var runDeliver = async (input) => {
4656
4792
  const orca = orcaOptions(config);
4657
4793
  const [accountList, agentHooks] = await Promise.all([orcaAccountList(input.runner, orca).catch(() => ({})), orcaAgentHooks(input.runner, orca).catch(() => ({}))]);
4658
4794
  const providers = await detectProviders({ providers: providerSpecs(config), accountList, agentHooks, env: input.env, platform: input.platform, exhaustedPercent: config.models.cooldown.exhaustedPercent, cooldowns: activeCooldowns(readCooldowns(loaded.stateDir), now4()), now: now4 });
4659
- const reviewerExtras = config.models.routing.mode === "catalog" ? await resolveCatalogCandidates({
4660
- config,
4661
- role: "reviewer",
4662
- availableProviderIds: providers.filter((provider) => provider.available).map((provider) => provider.id),
4663
- runner: input.runner,
4664
- stateDir: loaded.stateDir,
4665
- env: input.env,
4666
- now: now4
4667
- }) : [];
4668
- const reviewer = rankModels(config, "reviewer", providers, reviewerExtras)[0] ?? null;
4795
+ const availableIds = providers.filter((provider) => provider.available).map((provider) => provider.id);
4796
+ const catalogExtras = async (role) => config.models.routing.mode === "catalog" ? resolveCatalogCandidates({ config, role, availableProviderIds: availableIds, runner: input.runner, stateDir: loaded.stateDir, env: input.env, now: now4 }) : Promise.resolve([]);
4797
+ const reviewer = rankModels(config, "reviewer", providers, await catalogExtras("reviewer"))[0] ?? null;
4798
+ const builder = rankModels(config, "builder", providers, await catalogExtras("builder"))[0] ?? null;
4669
4799
  let env = input.env ?? process.env;
4670
4800
  if (!env["GITHUB_TOKEN"] && !env["GH_TOKEN"]) {
4671
4801
  try {
@@ -4676,7 +4806,7 @@ var runDeliver = async (input) => {
4676
4806
  }
4677
4807
  const reviewDeadlineMs = input.budgetMs ? Math.max(6e4, Math.min(config.delivery.review.deadlineMs, input.budgetMs - 9e4)) : config.delivery.review.deadlineMs;
4678
4808
  if (reviewDeadlineMs < config.delivery.review.deadlineMs) notes.push(`review deadline capped to ${Math.round(reviewDeadlineMs / 1e3)}s to fit the stage budget`);
4679
- const ctx = { loaded, config, runner: input.runner, now: now4, dryRun, reviewer, env, ...input.assumeIdle === void 0 ? {} : { assumeIdle: input.assumeIdle }, notes, reviewDeadlineMs };
4809
+ const ctx = { loaded, config, runner: input.runner, now: now4, dryRun, reviewer, builder, providers, env, ...input.assumeIdle === void 0 ? {} : { assumeIdle: input.assumeIdle }, notes, reviewDeadlineMs };
4680
4810
  const ledger = createDispatchLedger(loaded.stateDir);
4681
4811
  const leases = new Map(ledger.active().map((lease) => [lease.issue, lease]));
4682
4812
  const results = [];