@kynver-app/runtime 0.1.19 → 0.1.21

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
@@ -396,12 +396,12 @@ var DEFAULT_CRITICAL_FREE_BYTES = 15 * 1024 * 1024 * 1024;
396
396
  var DEFAULT_MAX_USED_PERCENT = 80;
397
397
  var DEFAULT_HARD_MAX_USED_PERCENT = 90;
398
398
  function observeRunnerDiskGate(input = {}) {
399
- const path17 = input.diskPath?.trim() || "/";
399
+ const path18 = input.diskPath?.trim() || "/";
400
400
  const warnBelowBytes = input.diskFreeWarnBytes ?? DEFAULT_WARN_FREE_BYTES;
401
401
  const criticalBelowBytes = input.diskFreeCriticalBytes ?? DEFAULT_CRITICAL_FREE_BYTES;
402
402
  const maxUsedPercent = input.diskMaxUsedPercent ?? DEFAULT_MAX_USED_PERCENT;
403
403
  const hardMaxUsedPercent = input.diskHardMaxUsedPercent ?? DEFAULT_HARD_MAX_USED_PERCENT;
404
- const stats = statfsSync(path17);
404
+ const stats = statfsSync(path18);
405
405
  const freeBytes = Number(stats.bavail) * Number(stats.bsize);
406
406
  const totalBytes = Number(stats.blocks) * Number(stats.bsize);
407
407
  const usedPercent = totalBytes > 0 ? (totalBytes - freeBytes) / totalBytes * 100 : 100;
@@ -421,7 +421,7 @@ function observeRunnerDiskGate(input = {}) {
421
421
  }
422
422
  return {
423
423
  ok,
424
- path: path17,
424
+ path: path18,
425
425
  freeBytes,
426
426
  totalBytes,
427
427
  usedPercent,
@@ -695,22 +695,36 @@ function gitIsAncestor(cwd, ancestor, descendant) {
695
695
  if (res.status === 1) return { isAncestor: false, error: null };
696
696
  return { isAncestor: null, error: res.error || res.stderr || res.stdout || `git exited ${res.status}` };
697
697
  }
698
- function computeGitAncestry(worktreePath, base = "origin/main") {
698
+ function computeGitAncestry(worktreePath, baseOrOptions = "origin/main") {
699
+ const options = typeof baseOrOptions === "string" ? { base: baseOrOptions } : baseOrOptions;
700
+ const baseLabel = options.baseCommit?.trim() || options.base?.trim() || "origin/main";
701
+ const pinnedBaseCommit = options.baseCommit?.trim() || null;
699
702
  if (!worktreePath) {
700
- return unknownAncestry(base, "missing worktree path");
703
+ return unknownAncestry(baseLabel, "missing worktree path");
701
704
  }
702
705
  const head = gitCapture(worktreePath, ["rev-parse", "HEAD"]);
703
- if (head.status !== 0) return unknownAncestry(base, head.error || head.stderr || head.stdout || "failed to resolve HEAD");
704
- const baseHead = gitCapture(worktreePath, ["rev-parse", base]);
705
- if (baseHead.status !== 0) {
706
- return unknownAncestry(base, baseHead.error || baseHead.stderr || baseHead.stdout || `failed to resolve ${base}`, head.stdout.trim());
706
+ if (head.status !== 0) {
707
+ return unknownAncestry(baseLabel, head.error || head.stderr || head.stdout || "failed to resolve HEAD");
708
+ }
709
+ let baseSha;
710
+ if (pinnedBaseCommit) {
711
+ baseSha = pinnedBaseCommit;
712
+ } else {
713
+ const baseHead = gitCapture(worktreePath, ["rev-parse", baseLabel]);
714
+ if (baseHead.status !== 0) {
715
+ return unknownAncestry(
716
+ baseLabel,
717
+ baseHead.error || baseHead.stderr || baseHead.stdout || `failed to resolve ${baseLabel}`,
718
+ head.stdout.trim()
719
+ );
720
+ }
721
+ baseSha = baseHead.stdout.trim();
707
722
  }
708
723
  const headSha = head.stdout.trim();
709
- const baseSha = baseHead.stdout.trim();
710
724
  if (headSha === baseSha) {
711
725
  return {
712
726
  checked: true,
713
- base,
727
+ base: baseLabel,
714
728
  head: headSha,
715
729
  baseHead: baseSha,
716
730
  baseIsAncestorOfHead: true,
@@ -724,7 +738,7 @@ function computeGitAncestry(worktreePath, base = "origin/main") {
724
738
  if (baseIsAncestorOfHead.isAncestor == null || headIsAncestorOfBase.isAncestor == null) {
725
739
  return {
726
740
  checked: false,
727
- base,
741
+ base: baseLabel,
728
742
  head: headSha,
729
743
  baseHead: baseSha,
730
744
  baseIsAncestorOfHead: baseIsAncestorOfHead.isAncestor,
@@ -736,7 +750,7 @@ function computeGitAncestry(worktreePath, base = "origin/main") {
736
750
  const relation = baseIsAncestorOfHead.isAncestor ? "ahead" : headIsAncestorOfBase.isAncestor ? "merged" : "diverged";
737
751
  return {
738
752
  checked: true,
739
- base,
753
+ base: baseLabel,
740
754
  head: headSha,
741
755
  baseHead: baseSha,
742
756
  baseIsAncestorOfHead: baseIsAncestorOfHead.isAncestor,
@@ -763,12 +777,74 @@ function scrubClaudeEnv(env) {
763
777
  return next;
764
778
  }
765
779
 
780
+ // src/landing-gate.ts
781
+ function trimOrNull(value) {
782
+ if (typeof value !== "string") return null;
783
+ const trimmed = value.trim();
784
+ return trimmed.length ? trimmed : null;
785
+ }
786
+ function hasFinalResult(value) {
787
+ if (value === void 0 || value === null) return false;
788
+ if (typeof value === "string") return value.trim().length > 0;
789
+ if (typeof value === "boolean") return value;
790
+ if (Array.isArray(value)) return value.length > 0;
791
+ if (typeof value === "object") return Object.keys(value).length > 0;
792
+ return true;
793
+ }
794
+ function hasCommittedLandingRef(snapshot) {
795
+ if (trimOrNull(snapshot.headCommit)) return true;
796
+ if (trimOrNull(snapshot.prUrl)) return true;
797
+ if (trimOrNull(snapshot.artifactBundlePath)) return true;
798
+ if (trimOrNull(snapshot.patchPath)) return true;
799
+ const ancestry = snapshot.gitAncestry;
800
+ if (ancestry?.checked && ancestry.headIsAncestorOfBase === false && trimOrNull(ancestry.head)) {
801
+ return true;
802
+ }
803
+ return false;
804
+ }
805
+ function assessWorkerLanding(snapshot) {
806
+ if (!hasFinalResult(snapshot.finalResult)) return { blocked: false };
807
+ if (snapshot.changedFiles.length === 0) return { blocked: false };
808
+ if (!hasCommittedLandingRef(snapshot)) {
809
+ return {
810
+ blocked: true,
811
+ reason: "dirty_worktree_no_pr",
812
+ detail: `Worktree has ${snapshot.changedFiles.length} uncommitted change(s) with no commit or PR; commit, open a PR, or discard before landing`
813
+ };
814
+ }
815
+ return {
816
+ blocked: true,
817
+ detail: `Worktree has ${snapshot.changedFiles.length} uncommitted change(s); commit or discard before landing`
818
+ };
819
+ }
820
+ function landingAttentionReason(verdict) {
821
+ if (!verdict.blocked) return void 0;
822
+ return verdict.detail ?? verdict.reason ?? "dirty_worktree_no_pr";
823
+ }
824
+
766
825
  // src/status.ts
767
826
  var NO_START_MS = 18e4;
768
827
  var STALE_MS = 6e5;
769
828
  function computeAttention(input) {
770
829
  const now = Date.now();
771
- if (input.finalResult) return { state: "done", reason: "final result recorded" };
830
+ if (input.completionBlocker) {
831
+ return { state: "blocked", reason: input.completionBlocker };
832
+ }
833
+ if (input.finalResult) {
834
+ const landing = assessWorkerLanding({
835
+ finalResult: input.finalResult,
836
+ changedFiles: input.changedFiles ?? [],
837
+ gitAncestry: input.gitAncestry ?? null
838
+ });
839
+ if (landing.blocked) {
840
+ const detail = landingAttentionReason(landing);
841
+ return {
842
+ state: "needs_attention",
843
+ reason: landing.reason ? `landing blocked (${landing.reason}): ${detail}` : `landing blocked: ${detail}`
844
+ };
845
+ }
846
+ return { state: "done", reason: "final result recorded" };
847
+ }
772
848
  if (!input.alive) {
773
849
  const classified = classifyExitFailure(input.error);
774
850
  if (classified) return { state: "blocked", reason: classified.reason };
@@ -799,7 +875,10 @@ function computeWorkerStatus(worker, options = {}) {
799
875
  const stderrBytes = fileSize(worker.stderrPath);
800
876
  const heartbeatBytes = fileSize(worker.heartbeatPath);
801
877
  const changedFiles = gitStatusShort(worker.worktreePath);
802
- const gitAncestry = computeGitAncestry(worker.worktreePath, options.base);
878
+ const gitAncestry = computeGitAncestry(worker.worktreePath, {
879
+ base: options.base,
880
+ baseCommit: options.baseCommit
881
+ });
803
882
  const lastActivityAt = latestIso([
804
883
  parsed.lastEventAt,
805
884
  heartbeat.lastHeartbeatAt,
@@ -808,6 +887,7 @@ function computeWorkerStatus(worker, options = {}) {
808
887
  fileMtime(worker.heartbeatPath)
809
888
  ]);
810
889
  const error = parsed.error || (!alive && !parsed.finalResult ? tailFile(worker.stderrPath, 10).trim() || void 0 : void 0);
890
+ const completionBlocker = typeof worker.completionBlocker === "string" && worker.completionBlocker.trim() ? worker.completionBlocker.trim() : null;
811
891
  const attention = computeAttention({
812
892
  alive,
813
893
  finalResult: parsed.finalResult,
@@ -817,14 +897,18 @@ function computeWorkerStatus(worker, options = {}) {
817
897
  lastActivityAt,
818
898
  heartbeatBlocker: heartbeat.heartbeatBlocker,
819
899
  startedAt: worker.startedAt,
820
- error
900
+ error,
901
+ changedFiles,
902
+ gitAncestry,
903
+ completionBlocker
821
904
  });
905
+ const workerStatusLabel = completionBlocker || attention.state === "blocked" ? "blocked" : attention.state === "done" ? "done" : parsed.finalResult ? "exited" : alive ? "running" : "exited";
822
906
  return {
823
907
  runId: worker.runId,
824
908
  worker: worker.name,
825
909
  pid: worker.pid,
826
910
  alive,
827
- status: parsed.finalResult ? "done" : alive ? "running" : "exited",
911
+ status: workerStatusLabel,
828
912
  attention,
829
913
  branch: worker.branch,
830
914
  worktreePath: worker.worktreePath,
@@ -853,6 +937,10 @@ function isFinishedWorkerStatus(status) {
853
937
  if (status.status === "exited" || status.status === "done") return true;
854
938
  return false;
855
939
  }
940
+ function isLandingBlockedWorkerStatus(status) {
941
+ if (!status.finalResult) return false;
942
+ return status.attention.state === "needs_attention" || status.attention.state === "blocked";
943
+ }
856
944
  function deriveRunStatus(fallback, workers) {
857
945
  if (workers.length === 0) return fallback;
858
946
  if (workers.some((w) => w.attention === "needs_attention" || w.attention === "stale" || w.attention === "blocked")) {
@@ -903,6 +991,10 @@ function readAvailableMemBytes() {
903
991
  }
904
992
  return os.freemem();
905
993
  }
994
+ function isActiveHarnessWorker(worker) {
995
+ const status = computeWorkerStatus(worker);
996
+ return status.alive && !status.finalResult && status.attention.state !== "done";
997
+ }
906
998
  function countActiveWorkersForRun(run) {
907
999
  let active = 0;
908
1000
  for (const name of Object.keys(run.workers || {})) {
@@ -910,11 +1002,8 @@ function countActiveWorkersForRun(run) {
910
1002
  path5.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
911
1003
  void 0
912
1004
  );
913
- if (!worker) continue;
914
- const status = computeWorkerStatus(worker);
915
- if (status.alive && !status.finalResult && status.attention.state !== "done") {
916
- active++;
917
- }
1005
+ if (!worker || !isActiveHarnessWorker(worker)) continue;
1006
+ active++;
918
1007
  }
919
1008
  return active;
920
1009
  }
@@ -939,7 +1028,7 @@ function observeRunnerResourceGate(input) {
939
1028
  const maxConcurrentWorkers = Math.max(0, Math.min(targetCap, capacityFromTotal));
940
1029
  const slotsByCapacity = Math.max(0, maxConcurrentWorkers - activeWorkers);
941
1030
  const slotsByFreeMem = capacityFromFree;
942
- const slotsAvailable = Math.min(slotsByCapacity, slotsByFreeMem);
1031
+ let slotsAvailable = Math.min(slotsByCapacity, slotsByFreeMem);
943
1032
  let reason = null;
944
1033
  if (slotsAvailable <= 0) {
945
1034
  if (activeWorkers >= maxConcurrentWorkers) {
@@ -966,39 +1055,6 @@ function observeRunnerResourceGate(input) {
966
1055
  };
967
1056
  }
968
1057
 
969
- // src/supervisor.ts
970
- import { existsSync as existsSync10, mkdirSync as mkdirSync3 } from "node:fs";
971
- import path10 from "node:path";
972
-
973
- // src/prompt.ts
974
- function buildPrompt(input) {
975
- const ownership = input.ownedPaths.length ? `Owned paths: ${input.ownedPaths.join(", ")}. Do not edit outside these paths without stopping and reporting why.` : "Owned paths: unrestricted for this worker, but keep edits tightly scoped.";
976
- const progressLines = [
977
- "Structured plan progress (required when planId is set):",
978
- "- Harness checkpoints only: `kynver plan progress --plan <planId> --row <rowKey> --role implementer --status running|partial|blocked` (the by-id harness route rejects `done` and confirm events).",
979
- "- When a slice is finished, emit `partial` with evidence (`--evidence pr:<url>`, `--evidence path:<file>`, or `--evidence command:<cmd>`). Do not propose or confirm row `done` from the worker CLI.",
980
- "- Propose/confirm row `done` is MCP/session only: chat agents use `agent_os_plan_progress_event_append` on the slug route (implementer proposes with `proposed: true`; report_reviewer/deep_reviewer confirm with `proposed: false`).",
981
- "- When blocked on operator/Ghost/runtime review, create a linked review task (MCP `agent_os_plan_review_task_create` or API) and pass `--review-task <taskId>`.",
982
- "- Before the completion report: mark completion-report rows partial with evidence; do not skip report review.",
983
- "- After implementation: wait for report_reviewer then deep_reviewer confirmation (via MCP/session agents) before follow-up rows close.",
984
- input.planId ? `Active planId: ${input.planId}${input.taskId ? ` \xB7 taskId: ${input.taskId}` : ""}` : "No planId on this worker \u2014 still emit progress when you touch plan-scoped work."
985
- ];
986
- return [
987
- "You are running under the Kynver AgentOS runtime.",
988
- "Immediately state your plan before editing.",
989
- ownership,
990
- `Worktree: ${input.worktreePath}`,
991
- `Progress heartbeat file: ${input.heartbeatPath}`,
992
- "After each major step, append one JSON line to the heartbeat file with fields: ts, phase, summary, changedFiles, blocker.",
993
- "Final response must include files changed, verification commands, and unresolved risks.",
994
- "",
995
- ...progressLines,
996
- "",
997
- "Task:",
998
- input.task
999
- ].join("\n");
1000
- }
1001
-
1002
1058
  // src/providers/claude.ts
1003
1059
  import { closeSync, openSync } from "node:fs";
1004
1060
  import { spawn } from "node:child_process";
@@ -1057,7 +1113,7 @@ function preflightCursorModel(model, defaultModel) {
1057
1113
  }
1058
1114
 
1059
1115
  // src/providers/claude.ts
1060
- var CLAUDE_DEFAULT_MODEL = "claude-opus-4-7";
1116
+ var CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
1061
1117
  var claudeProvider = {
1062
1118
  name: "claude",
1063
1119
  defaultModel: CLAUDE_DEFAULT_MODEL,
@@ -1103,6 +1159,159 @@ var claudeProvider = {
1103
1159
  }
1104
1160
  };
1105
1161
 
1162
+ // src/model-routing.ts
1163
+ var GLOBAL_DEFAULT_MODEL = "claude-sonnet-4-6";
1164
+ function taskString(task, key) {
1165
+ const v = task[key];
1166
+ return typeof v === "string" ? v.trim() : "";
1167
+ }
1168
+ function normalizeRef(ref) {
1169
+ return ref.toLowerCase();
1170
+ }
1171
+ function resolveGlobalDefaultModel(config = loadUserConfig()) {
1172
+ const fromConfig = config.defaultModel?.trim();
1173
+ if (fromConfig) return fromConfig;
1174
+ const fromEnv = process.env.KYNVER_DEFAULT_MODEL?.trim();
1175
+ if (fromEnv) return fromEnv;
1176
+ return GLOBAL_DEFAULT_MODEL;
1177
+ }
1178
+ function inferProviderFromModel(model) {
1179
+ const m = (model ?? "").toLowerCase();
1180
+ if (!m) return "claude";
1181
+ if (m.includes("composer") || m.includes("cursor") || m.includes("codex") || m.startsWith("gpt-") || m.startsWith("gpt5")) {
1182
+ return "cursor";
1183
+ }
1184
+ return "claude";
1185
+ }
1186
+ function isOpusLane(ref, title) {
1187
+ if (ref.includes("deep") && ref.includes("review")) return true;
1188
+ if (ref.includes("security")) return true;
1189
+ if (ref.includes("plan_author") || ref.includes("plan-author")) return true;
1190
+ if (title.includes("deep review") || title.includes("security review")) return true;
1191
+ if (ref.includes("plan") && !ref.includes("review") && (ref.includes("author") || ref.includes("strategy"))) {
1192
+ return true;
1193
+ }
1194
+ return false;
1195
+ }
1196
+ function inferModelRoutingFromTask(task) {
1197
+ const ref = normalizeRef(taskString(task, "executorRef"));
1198
+ const title = taskString(task, "title").toLowerCase();
1199
+ const priority = taskString(task, "priority") || "normal";
1200
+ const roleLane = normalizeRef(taskString(task, "roleLane"));
1201
+ if (ref.includes("cursor") || ref.includes("codex") || ref.includes("composer") || ref.includes("copilot") || roleLane === "implementer" || roleLane === "repair_implementer") {
1202
+ return { provider: "cursor", rule: "lane:implementation" };
1203
+ }
1204
+ if (ref.includes("landing") || title.startsWith("land:") || title.includes(" merge")) {
1205
+ return {
1206
+ model: "claude-haiku-4-5-20251001",
1207
+ provider: "claude",
1208
+ rule: "lane:landing"
1209
+ };
1210
+ }
1211
+ if (ref.includes("review") || title.startsWith("review ") || roleLane.includes("review")) {
1212
+ if (isOpusLane(ref, title) || roleLane === "deep_reviewer") {
1213
+ return { model: "claude-opus-4-7", provider: "claude", rule: "lane:deep_review" };
1214
+ }
1215
+ return { model: "claude-sonnet-4-6", provider: "claude", rule: "lane:review" };
1216
+ }
1217
+ if (isOpusLane(ref, title) || roleLane === "plan_author") {
1218
+ return { model: "claude-opus-4-7", provider: "claude", rule: "lane:planning" };
1219
+ }
1220
+ if (priority === "critical") {
1221
+ return { model: "claude-opus-4-7", provider: "claude", rule: "priority:critical" };
1222
+ }
1223
+ if (priority === "low") {
1224
+ return {
1225
+ model: "claude-haiku-4-5-20251001",
1226
+ provider: "claude",
1227
+ rule: "priority:low"
1228
+ };
1229
+ }
1230
+ return {
1231
+ model: resolveGlobalDefaultModel(),
1232
+ provider: "claude",
1233
+ rule: "default:sonnet"
1234
+ };
1235
+ }
1236
+ function resolveWorkerLaunch(input) {
1237
+ if (input.explicitModel?.trim()) {
1238
+ const model2 = input.explicitModel.trim();
1239
+ return {
1240
+ model: model2,
1241
+ provider: input.explicitProvider?.trim() || inferProviderFromModel(model2),
1242
+ rule: "explicit:cli",
1243
+ requestedModel: model2
1244
+ };
1245
+ }
1246
+ if (input.task && Object.keys(input.task).length > 0) {
1247
+ const inferred = inferModelRoutingFromTask(input.task);
1248
+ return {
1249
+ ...inferred,
1250
+ requestedModel: inferred.model
1251
+ };
1252
+ }
1253
+ const model = resolveGlobalDefaultModel();
1254
+ return {
1255
+ model,
1256
+ provider: input.explicitProvider?.trim() || inferProviderFromModel(model),
1257
+ rule: "default:global",
1258
+ requestedModel: model
1259
+ };
1260
+ }
1261
+ function resolveModelFallback(startedModel, launchModel, providerDefault) {
1262
+ return startedModel || launchModel || providerDefault || resolveGlobalDefaultModel() || CLAUDE_DEFAULT_MODEL;
1263
+ }
1264
+
1265
+ // src/retry-limits.ts
1266
+ function positiveInt2(value, fallback) {
1267
+ const n = Number(value);
1268
+ if (!Number.isFinite(n) || n <= 0) return fallback;
1269
+ return Math.floor(n);
1270
+ }
1271
+ function readHarnessRetryLimits() {
1272
+ return {
1273
+ maxTaskAttempts: positiveInt2(process.env.KYNVER_MAX_TASK_ATTEMPTS, 3),
1274
+ dispatchCooldownMs: positiveInt2(process.env.KYNVER_DISPATCH_COOLDOWN_MS, 5e3)
1275
+ };
1276
+ }
1277
+
1278
+ // src/supervisor.ts
1279
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3 } from "node:fs";
1280
+ import path10 from "node:path";
1281
+
1282
+ // src/prompt.ts
1283
+ function buildPrompt(input) {
1284
+ const ownership = input.ownedPaths.length ? `Owned paths: ${input.ownedPaths.join(", ")}. Do not edit outside these paths without stopping and reporting why.` : "Owned paths: unrestricted for this worker, but keep edits tightly scoped.";
1285
+ const compact = Boolean(input.model?.toLowerCase().includes("haiku"));
1286
+ const progressLines = compact ? [
1287
+ "Plan progress: when planId is set, use `kynver plan progress` for running|partial|blocked only; row `done` is MCP/session only.",
1288
+ input.planId ? `Active planId: ${input.planId}` : "No planId on this worker."
1289
+ ] : [
1290
+ "Structured plan progress (required when planId is set):",
1291
+ "- Harness checkpoints only: `kynver plan progress --plan <planId> --row <rowKey> --role implementer --status running|partial|blocked` (the by-id harness route rejects `done` and confirm events).",
1292
+ "- When a slice is finished, emit `partial` with evidence (`--evidence pr:<url>`, `--evidence path:<file>`, or `--evidence command:<cmd>`). Do not propose or confirm row `done` from the worker CLI.",
1293
+ "- Propose/confirm row `done` is MCP/session only: chat agents use `agent_os_plan_progress_event_append` on the slug route (implementer proposes with `proposed: true`; report_reviewer/deep_reviewer confirm with `proposed: false`).",
1294
+ "- When blocked on operator/Ghost/runtime review, create a linked review task (MCP `agent_os_plan_review_task_create` or API) and pass `--review-task <taskId>`.",
1295
+ "- Before the completion report: mark completion-report rows partial with evidence; do not skip report review.",
1296
+ "- After implementation: wait for report_reviewer then deep_reviewer confirmation (via MCP/session agents) before follow-up rows close.",
1297
+ input.planId ? `Active planId: ${input.planId}${input.taskId ? ` \xB7 taskId: ${input.taskId}` : ""}` : "No planId on this worker \u2014 still emit progress when you touch plan-scoped work."
1298
+ ];
1299
+ return [
1300
+ "You are running under the Kynver AgentOS runtime.",
1301
+ "Immediately state your plan before editing.",
1302
+ ownership,
1303
+ `Worktree: ${input.worktreePath}`,
1304
+ `Progress heartbeat file: ${input.heartbeatPath}`,
1305
+ "After each major step, append one JSON line to the heartbeat file with fields: ts, phase, summary, changedFiles, blocker.",
1306
+ "Final response must include files changed, verification commands, and unresolved risks.",
1307
+ "",
1308
+ ...progressLines,
1309
+ "",
1310
+ "Task:",
1311
+ input.task
1312
+ ].join("\n");
1313
+ }
1314
+
1106
1315
  // src/providers/cursor.ts
1107
1316
  import { closeSync as closeSync2, existsSync as existsSync8, openSync as openSync2 } from "node:fs";
1108
1317
  import { spawn as spawn2 } from "node:child_process";
@@ -1306,9 +1515,13 @@ function persistCompletionBlocker(worker, reason) {
1306
1515
  else delete worker.completionBlocker;
1307
1516
  saveWorker(worker.runId, worker);
1308
1517
  }
1518
+ function workerStatusOptions(run) {
1519
+ return run ? { base: run.base, baseCommit: run.baseCommit } : {};
1520
+ }
1309
1521
  async function tryCompleteWorker(args) {
1310
1522
  const worker = loadWorker(String(args.run), String(args.name));
1311
- const status = computeWorkerStatus(worker);
1523
+ const run = loadRun(worker.runId);
1524
+ const status = computeWorkerStatus(worker, workerStatusOptions(run));
1312
1525
  const agentOsId = (args.agentOsId ? String(args.agentOsId) : worker.agentOsId) || "";
1313
1526
  const taskId = (args.taskId ? String(args.taskId) : worker.taskId) || null;
1314
1527
  if (!agentOsId) {
@@ -1352,7 +1565,8 @@ async function tryCompleteWorker(args) {
1352
1565
  async function completeWorker(args) {
1353
1566
  try {
1354
1567
  const worker = loadWorker(String(args.run), String(args.name));
1355
- const status = computeWorkerStatus(worker);
1568
+ const run = loadRun(worker.runId);
1569
+ const status = computeWorkerStatus(worker, workerStatusOptions(run));
1356
1570
  const agentOsId = (args.agentOsId ? String(args.agentOsId) : worker.agentOsId) || "";
1357
1571
  const taskId = (args.taskId ? String(args.taskId) : worker.taskId) || null;
1358
1572
  if (!agentOsId) {
@@ -1399,7 +1613,8 @@ async function completeWorker(args) {
1399
1613
  }
1400
1614
  function workerStatus(args) {
1401
1615
  const worker = loadWorker(String(args.run), String(args.name));
1402
- const status = computeWorkerStatus(worker);
1616
+ const run = loadRun(worker.runId);
1617
+ const status = computeWorkerStatus(worker, workerStatusOptions(run));
1403
1618
  writeJson(path8.join(worker.workerDir, "last-status.json"), status);
1404
1619
  console.log(JSON.stringify(status, null, 2));
1405
1620
  }
@@ -1414,14 +1629,21 @@ function runStatus(args) {
1414
1629
  if (!worker) {
1415
1630
  return { worker: name, status: "missing", attention: "needs_attention", attentionReason: "worker.json not found" };
1416
1631
  }
1417
- const status = computeWorkerStatus(worker, { base: run.base });
1632
+ const status = computeWorkerStatus(worker, {
1633
+ base: run.base,
1634
+ baseCommit: run.baseCommit
1635
+ });
1636
+ const headCommit = status.gitAncestry.headIsAncestorOfBase === false && status.gitAncestry.head ? status.gitAncestry.head : void 0;
1418
1637
  const rawBlocker = worker.completionBlocker;
1419
1638
  const completionBlocker = typeof rawBlocker === "string" && rawBlocker ? rawBlocker : void 0;
1639
+ const boardStatus = completionBlocker ? "blocked" : status.status;
1640
+ const boardAttention = completionBlocker ? "blocked" : status.attention.state;
1420
1641
  return {
1421
1642
  worker: status.worker,
1422
- status: completionBlocker ? "blocked" : status.status,
1423
- attention: completionBlocker ? "blocked" : status.attention.state,
1643
+ status: boardStatus,
1644
+ attention: boardAttention,
1424
1645
  attentionReason: completionBlocker ?? status.attention.reason,
1646
+ landingBlocked: status.finalResult ? boardAttention === "needs_attention" || boardAttention === "blocked" : false,
1425
1647
  pid: status.pid,
1426
1648
  alive: status.alive,
1427
1649
  currentTool: status.currentTool,
@@ -1430,7 +1652,14 @@ function runStatus(args) {
1430
1652
  lastHeartbeatSummary: status.lastHeartbeatSummary,
1431
1653
  heartbeatBlocker: status.heartbeatBlocker,
1432
1654
  changedFileCount: status.changedFiles.length,
1655
+ changedFiles: status.changedFiles,
1433
1656
  branch: status.branch,
1657
+ model: typeof worker.model === "string" ? worker.model : void 0,
1658
+ routingRule: typeof worker.routingRule === "string" ? worker.routingRule : void 0,
1659
+ requestedModel: typeof worker.requestedModel === "string" ? worker.requestedModel : void 0,
1660
+ headCommit,
1661
+ gitAncestry: status.gitAncestry,
1662
+ finalResult: status.finalResult,
1434
1663
  ancestry: status.gitAncestry.relation,
1435
1664
  ancestryChecked: status.gitAncestry.checked
1436
1665
  };
@@ -1646,8 +1875,17 @@ function spawnWorkerProcess(run, opts) {
1646
1875
  const name = safeSlug(rawName);
1647
1876
  if (run.workers?.[name]) throw new Error(`worker already exists in run ${run.id}: ${name}`);
1648
1877
  if (!opts.task) throw new Error(`missing task text for worker ${name}`);
1649
- const provider = resolveWorkerProvider(opts.provider);
1650
- let launchModel = opts.model;
1878
+ const routing = opts.routingRule || opts.requestedModel ? {
1879
+ provider: opts.provider || "claude",
1880
+ model: opts.model,
1881
+ rule: opts.routingRule || "explicit:spawn",
1882
+ requestedModel: opts.requestedModel ?? opts.model
1883
+ } : resolveWorkerLaunch({
1884
+ explicitModel: opts.model,
1885
+ explicitProvider: opts.provider
1886
+ });
1887
+ const provider = resolveWorkerProvider(routing.provider);
1888
+ let launchModel = routing.model;
1651
1889
  if (provider.preflightModel) {
1652
1890
  const preflight = provider.preflightModel(opts.model);
1653
1891
  if (!preflight.ok) {
@@ -1675,7 +1913,10 @@ function spawnWorkerProcess(run, opts) {
1675
1913
  task: opts.task,
1676
1914
  ownedPaths: opts.ownedPaths || [],
1677
1915
  worktreePath,
1678
- heartbeatPath
1916
+ heartbeatPath,
1917
+ planId: opts.planId,
1918
+ taskId: opts.taskId,
1919
+ model: launchModel
1679
1920
  });
1680
1921
  let started;
1681
1922
  try {
@@ -1697,7 +1938,7 @@ function spawnWorkerProcess(run, opts) {
1697
1938
  git(run.repo, ["branch", "-D", branch], { allowFailure: true });
1698
1939
  throw error;
1699
1940
  }
1700
- const model = started.model || launchModel || provider.defaultModel || "claude-opus-4-7";
1941
+ const model = resolveModelFallback(started.model, launchModel, provider.defaultModel);
1701
1942
  const worker = {
1702
1943
  name,
1703
1944
  runId: run.id,
@@ -1716,6 +1957,8 @@ function spawnWorkerProcess(run, opts) {
1716
1957
  ...opts.planId ? { planId: String(opts.planId) } : {},
1717
1958
  ...opts.leaseOwner ? { leaseOwner: String(opts.leaseOwner) } : {},
1718
1959
  ...opts.dispatched ? { dispatched: true } : {},
1960
+ routingRule: routing.rule,
1961
+ ...routing.requestedModel ? { requestedModel: routing.requestedModel } : {},
1719
1962
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
1720
1963
  };
1721
1964
  saveWorker(run.id, worker);
@@ -1723,14 +1966,23 @@ function spawnWorkerProcess(run, opts) {
1723
1966
  run.status = "running";
1724
1967
  saveRun(run);
1725
1968
  if (worker.agentOsId && worker.taskId) {
1969
+ let sidecarSpawned;
1726
1970
  try {
1727
- spawnCompletionSidecar({
1971
+ sidecarSpawned = spawnCompletionSidecar({
1728
1972
  runId: run.id,
1729
1973
  workerName: name,
1730
1974
  workerDir,
1731
1975
  agentOsId: worker.agentOsId
1732
1976
  });
1733
- } catch {
1977
+ } catch (error) {
1978
+ const reason = `completion sidecar failed to spawn: ${error.message}`;
1979
+ worker.completionBlocker = reason;
1980
+ saveWorker(run.id, worker);
1981
+ }
1982
+ if (!sidecarSpawned) {
1983
+ const reason = "completion sidecar failed to spawn (CLI not found or spawn error)";
1984
+ worker.completionBlocker = reason;
1985
+ saveWorker(run.id, worker);
1734
1986
  }
1735
1987
  }
1736
1988
  return worker;
@@ -1865,17 +2117,34 @@ async function dispatchRun(args) {
1865
2117
  console.log(JSON.stringify(summary2, null, 2));
1866
2118
  return;
1867
2119
  }
2120
+ const retryLimits = readHarnessRetryLimits();
1868
2121
  const outcomes = [];
1869
2122
  for (const decision of result.started) {
1870
2123
  const task = decision.task;
2124
+ const attempt = Number(task.attempt) || 1;
2125
+ if (attempt > retryLimits.maxTaskAttempts) {
2126
+ outcomes.push({
2127
+ taskId: task.id,
2128
+ started: false,
2129
+ error: `task attempt ${attempt} exceeds KYNVER_MAX_TASK_ATTEMPTS (${retryLimits.maxTaskAttempts})`
2130
+ });
2131
+ continue;
2132
+ }
1871
2133
  const name = safeSlug(`t-${task.id}-a${task.attempt}`);
2134
+ const routing = resolveWorkerLaunch({
2135
+ explicitModel: args.model ? String(args.model) : void 0,
2136
+ task
2137
+ });
1872
2138
  try {
1873
2139
  const planId = task.planId ? String(task.planId) : void 0;
1874
2140
  const worker = spawnWorkerProcess(run, {
1875
2141
  name,
1876
2142
  task: buildDispatchTaskText(task, agentOsId),
1877
2143
  ownedPaths: args.owned ? String(args.owned).split(",").map((s) => s.trim()).filter(Boolean) : [],
1878
- model: args.model ? String(args.model) : void 0,
2144
+ model: routing.model,
2145
+ provider: routing.provider,
2146
+ routingRule: routing.rule,
2147
+ requestedModel: routing.requestedModel,
1879
2148
  agentOsId,
1880
2149
  taskId: String(task.id),
1881
2150
  planId,
@@ -1887,7 +2156,10 @@ async function dispatchRun(args) {
1887
2156
  started: true,
1888
2157
  worker: worker.name,
1889
2158
  pid: worker.pid,
1890
- branch: worker.branch
2159
+ branch: worker.branch,
2160
+ model: worker.model,
2161
+ provider: routing.provider,
2162
+ routingRule: routing.rule
1891
2163
  });
1892
2164
  } catch (error) {
1893
2165
  const releaseUrl = `${base}/api/agent-os/by-id/${encodeURIComponent(agentOsId)}/tasks/${encodeURIComponent(String(task.id))}/release`;
@@ -2045,7 +2317,10 @@ function failExists(message) {
2045
2317
  }
2046
2318
 
2047
2319
  // src/pipeline-tick.ts
2048
- import path16 from "node:path";
2320
+ import path17 from "node:path";
2321
+
2322
+ // src/stale-reconcile.ts
2323
+ import path15 from "node:path";
2049
2324
 
2050
2325
  // src/finalize.ts
2051
2326
  import path14 from "node:path";
@@ -2056,13 +2331,17 @@ function terminalStatusFor(run) {
2056
2331
  let anyAlive = false;
2057
2332
  let anyResult = false;
2058
2333
  let anyCompletionBlocked = false;
2334
+ let anyLandingBlocked = false;
2059
2335
  for (const name of names) {
2060
2336
  const worker = readJson(
2061
2337
  path14.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2062
2338
  void 0
2063
2339
  );
2064
2340
  if (!worker) continue;
2065
- const status = computeWorkerStatus(worker);
2341
+ const status = computeWorkerStatus(worker, {
2342
+ base: run.base,
2343
+ baseCommit: run.baseCommit
2344
+ });
2066
2345
  if (status.alive && !status.finalResult) {
2067
2346
  anyAlive = true;
2068
2347
  break;
@@ -2070,10 +2349,14 @@ function terminalStatusFor(run) {
2070
2349
  if (typeof worker.completionBlocker === "string" && worker.completionBlocker) {
2071
2350
  anyCompletionBlocked = true;
2072
2351
  }
2073
- if (status.finalResult) anyResult = true;
2352
+ if (isLandingBlockedWorkerStatus(status)) {
2353
+ anyLandingBlocked = true;
2354
+ }
2355
+ if (status.finalResult && status.attention.state === "done") anyResult = true;
2074
2356
  }
2075
2357
  if (anyAlive) return null;
2076
2358
  if (anyCompletionBlocked) return null;
2359
+ if (anyLandingBlocked) return null;
2077
2360
  return anyResult ? "completed" : "failed";
2078
2361
  }
2079
2362
  function finalizeStaleRuns() {
@@ -2090,8 +2373,82 @@ function finalizeStaleRuns() {
2090
2373
  return finalized;
2091
2374
  }
2092
2375
 
2376
+ // src/stale-reconcile.ts
2377
+ var STALE_RECONCILE_HEARTBEAT_MS = 15 * 60 * 1e3;
2378
+ function staleReconcileDisabled() {
2379
+ return process.env.KYNVER_NO_STALE_CLEANUP === "1";
2380
+ }
2381
+ function reconcileStaleWorkers() {
2382
+ if (staleReconcileDisabled()) {
2383
+ return { workers: [], finalizedRuns: finalizeStaleRuns() };
2384
+ }
2385
+ const outcomes = [];
2386
+ const now = Date.now();
2387
+ for (const run of listRunRecords()) {
2388
+ for (const name of Object.keys(run.workers || {})) {
2389
+ const workerPath = path15.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json");
2390
+ const worker = readJson(workerPath, void 0);
2391
+ if (!worker || worker.status !== "running") {
2392
+ outcomes.push({
2393
+ runId: run.id,
2394
+ worker: name,
2395
+ action: "skipped",
2396
+ reason: worker ? `worker status is ${worker.status}` : "worker.json missing"
2397
+ });
2398
+ continue;
2399
+ }
2400
+ const status = computeWorkerStatus(worker, { base: run.base, baseCommit: run.baseCommit });
2401
+ if (status.finalResult) {
2402
+ outcomes.push({ runId: run.id, worker: name, action: "skipped", reason: "final result present" });
2403
+ continue;
2404
+ }
2405
+ if (!status.alive) {
2406
+ const nextStatus = status.attention.state === "blocked" ? "blocked" : status.status === "done" ? "done" : "exited";
2407
+ worker.status = nextStatus;
2408
+ worker.reconciledAt = (/* @__PURE__ */ new Date()).toISOString();
2409
+ worker.reconcileReason = status.attention.reason;
2410
+ saveWorker(run.id, worker);
2411
+ outcomes.push({
2412
+ runId: run.id,
2413
+ worker: name,
2414
+ action: "marked_exited",
2415
+ reason: status.attention.reason
2416
+ });
2417
+ continue;
2418
+ }
2419
+ if (status.attention.state === "stale" && worker.pid && isPidAlive(worker.pid)) {
2420
+ const hbMs = status.lastHeartbeatAt ? Date.parse(status.lastHeartbeatAt) : NaN;
2421
+ const actMs = status.lastActivityAt ? Date.parse(status.lastActivityAt) : NaN;
2422
+ const hbStale = !Number.isFinite(hbMs) || now - hbMs > STALE_RECONCILE_HEARTBEAT_MS;
2423
+ const actStale = Number.isFinite(actMs) && now - actMs > STALE_MS;
2424
+ if (hbStale && actStale) {
2425
+ killWorkerProcess(worker.pid, "SIGTERM");
2426
+ worker.status = "exited";
2427
+ worker.reconciledAt = (/* @__PURE__ */ new Date()).toISOString();
2428
+ worker.reconcileReason = `reconciled stale worker: ${status.attention.reason}`;
2429
+ saveWorker(run.id, worker);
2430
+ outcomes.push({
2431
+ runId: run.id,
2432
+ worker: name,
2433
+ action: "killed_stale",
2434
+ reason: status.attention.reason
2435
+ });
2436
+ continue;
2437
+ }
2438
+ }
2439
+ outcomes.push({
2440
+ runId: run.id,
2441
+ worker: name,
2442
+ action: "skipped",
2443
+ reason: status.attention.reason
2444
+ });
2445
+ }
2446
+ }
2447
+ return { workers: outcomes, finalizedRuns: finalizeStaleRuns() };
2448
+ }
2449
+
2093
2450
  // src/plan-progress-daemon-sync.ts
2094
- import path15 from "node:path";
2451
+ import path16 from "node:path";
2095
2452
 
2096
2453
  // src/plan-progress-sync.ts
2097
2454
  async function syncPlanProgress(args) {
@@ -2115,7 +2472,7 @@ async function syncActiveWorkerPlanProgress(runId, args) {
2115
2472
  const outcomes = [];
2116
2473
  for (const name of Object.keys(run.workers || {})) {
2117
2474
  const worker = readJson(
2118
- path15.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2475
+ path16.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2119
2476
  void 0
2120
2477
  );
2121
2478
  if (!worker?.dispatched || !worker.taskId) continue;
@@ -2169,7 +2526,7 @@ async function completeFinishedWorkers(runId, args) {
2169
2526
  const outcomes = [];
2170
2527
  for (const name of Object.keys(run.workers || {})) {
2171
2528
  const worker = readJson(
2172
- path16.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2529
+ path17.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2173
2530
  void 0
2174
2531
  );
2175
2532
  if (!worker?.taskId) continue;
@@ -2204,7 +2561,7 @@ async function runPipelineTick(args) {
2204
2561
  const execute = args.execute !== false && args.execute !== "false";
2205
2562
  runStatus({ run: runId });
2206
2563
  const completedWorkers = await completeFinishedWorkers(runId, args);
2207
- const finalizedStaleRuns = finalizeStaleRuns();
2564
+ const staleReconcile = reconcileStaleWorkers();
2208
2565
  const planProgressSync = await syncActiveWorkerPlanProgress(runId, args);
2209
2566
  const workspacePrefs = await fetchWorkspaceRuntimePreferences(agentOsId, args);
2210
2567
  const resourceGate = observeRunnerResourceGate({
@@ -2245,7 +2602,7 @@ async function runPipelineTick(args) {
2245
2602
  execute,
2246
2603
  resourceGate,
2247
2604
  completedWorkers,
2248
- finalizedStaleRuns,
2605
+ staleReconcile,
2249
2606
  planProgressSync,
2250
2607
  operatorTick,
2251
2608
  sweep,