@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/CHANGELOG.md +4 -0
- package/capabilities/public-surface.json +80 -77
- package/dist/cli.js +144 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +37 -3
- package/dist/index.js +145 -15
- package/dist/index.js.map +1 -1
- package/docs/LOOP.md +19 -0
- package/loop.config.example.yaml +1 -0
- package/package.json +1 -1
- package/release/manifest.json +1 -1
- package/release/notes.md +4 -0
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
|
-
|
|
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)
|
|
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
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
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 = [];
|