@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/index.js CHANGED
@@ -397,12 +397,12 @@ var DEFAULT_CRITICAL_FREE_BYTES = 15 * 1024 * 1024 * 1024;
397
397
  var DEFAULT_MAX_USED_PERCENT = 80;
398
398
  var DEFAULT_HARD_MAX_USED_PERCENT = 90;
399
399
  function observeRunnerDiskGate(input = {}) {
400
- const path17 = input.diskPath?.trim() || "/";
400
+ const path18 = input.diskPath?.trim() || "/";
401
401
  const warnBelowBytes = input.diskFreeWarnBytes ?? DEFAULT_WARN_FREE_BYTES;
402
402
  const criticalBelowBytes = input.diskFreeCriticalBytes ?? DEFAULT_CRITICAL_FREE_BYTES;
403
403
  const maxUsedPercent = input.diskMaxUsedPercent ?? DEFAULT_MAX_USED_PERCENT;
404
404
  const hardMaxUsedPercent = input.diskHardMaxUsedPercent ?? DEFAULT_HARD_MAX_USED_PERCENT;
405
- const stats = statfsSync(path17);
405
+ const stats = statfsSync(path18);
406
406
  const freeBytes = Number(stats.bavail) * Number(stats.bsize);
407
407
  const totalBytes = Number(stats.blocks) * Number(stats.bsize);
408
408
  const usedPercent = totalBytes > 0 ? (totalBytes - freeBytes) / totalBytes * 100 : 100;
@@ -422,7 +422,7 @@ function observeRunnerDiskGate(input = {}) {
422
422
  }
423
423
  return {
424
424
  ok,
425
- path: path17,
425
+ path: path18,
426
426
  freeBytes,
427
427
  totalBytes,
428
428
  usedPercent,
@@ -696,22 +696,36 @@ function gitIsAncestor(cwd, ancestor, descendant) {
696
696
  if (res.status === 1) return { isAncestor: false, error: null };
697
697
  return { isAncestor: null, error: res.error || res.stderr || res.stdout || `git exited ${res.status}` };
698
698
  }
699
- function computeGitAncestry(worktreePath, base = "origin/main") {
699
+ function computeGitAncestry(worktreePath, baseOrOptions = "origin/main") {
700
+ const options = typeof baseOrOptions === "string" ? { base: baseOrOptions } : baseOrOptions;
701
+ const baseLabel = options.baseCommit?.trim() || options.base?.trim() || "origin/main";
702
+ const pinnedBaseCommit = options.baseCommit?.trim() || null;
700
703
  if (!worktreePath) {
701
- return unknownAncestry(base, "missing worktree path");
704
+ return unknownAncestry(baseLabel, "missing worktree path");
702
705
  }
703
706
  const head = gitCapture(worktreePath, ["rev-parse", "HEAD"]);
704
- if (head.status !== 0) return unknownAncestry(base, head.error || head.stderr || head.stdout || "failed to resolve HEAD");
705
- const baseHead = gitCapture(worktreePath, ["rev-parse", base]);
706
- if (baseHead.status !== 0) {
707
- return unknownAncestry(base, baseHead.error || baseHead.stderr || baseHead.stdout || `failed to resolve ${base}`, head.stdout.trim());
707
+ if (head.status !== 0) {
708
+ return unknownAncestry(baseLabel, head.error || head.stderr || head.stdout || "failed to resolve HEAD");
709
+ }
710
+ let baseSha;
711
+ if (pinnedBaseCommit) {
712
+ baseSha = pinnedBaseCommit;
713
+ } else {
714
+ const baseHead = gitCapture(worktreePath, ["rev-parse", baseLabel]);
715
+ if (baseHead.status !== 0) {
716
+ return unknownAncestry(
717
+ baseLabel,
718
+ baseHead.error || baseHead.stderr || baseHead.stdout || `failed to resolve ${baseLabel}`,
719
+ head.stdout.trim()
720
+ );
721
+ }
722
+ baseSha = baseHead.stdout.trim();
708
723
  }
709
724
  const headSha = head.stdout.trim();
710
- const baseSha = baseHead.stdout.trim();
711
725
  if (headSha === baseSha) {
712
726
  return {
713
727
  checked: true,
714
- base,
728
+ base: baseLabel,
715
729
  head: headSha,
716
730
  baseHead: baseSha,
717
731
  baseIsAncestorOfHead: true,
@@ -725,7 +739,7 @@ function computeGitAncestry(worktreePath, base = "origin/main") {
725
739
  if (baseIsAncestorOfHead.isAncestor == null || headIsAncestorOfBase.isAncestor == null) {
726
740
  return {
727
741
  checked: false,
728
- base,
742
+ base: baseLabel,
729
743
  head: headSha,
730
744
  baseHead: baseSha,
731
745
  baseIsAncestorOfHead: baseIsAncestorOfHead.isAncestor,
@@ -737,7 +751,7 @@ function computeGitAncestry(worktreePath, base = "origin/main") {
737
751
  const relation = baseIsAncestorOfHead.isAncestor ? "ahead" : headIsAncestorOfBase.isAncestor ? "merged" : "diverged";
738
752
  return {
739
753
  checked: true,
740
- base,
754
+ base: baseLabel,
741
755
  head: headSha,
742
756
  baseHead: baseSha,
743
757
  baseIsAncestorOfHead: baseIsAncestorOfHead.isAncestor,
@@ -764,12 +778,74 @@ function scrubClaudeEnv(env) {
764
778
  return next;
765
779
  }
766
780
 
781
+ // src/landing-gate.ts
782
+ function trimOrNull(value) {
783
+ if (typeof value !== "string") return null;
784
+ const trimmed = value.trim();
785
+ return trimmed.length ? trimmed : null;
786
+ }
787
+ function hasFinalResult(value) {
788
+ if (value === void 0 || value === null) return false;
789
+ if (typeof value === "string") return value.trim().length > 0;
790
+ if (typeof value === "boolean") return value;
791
+ if (Array.isArray(value)) return value.length > 0;
792
+ if (typeof value === "object") return Object.keys(value).length > 0;
793
+ return true;
794
+ }
795
+ function hasCommittedLandingRef(snapshot) {
796
+ if (trimOrNull(snapshot.headCommit)) return true;
797
+ if (trimOrNull(snapshot.prUrl)) return true;
798
+ if (trimOrNull(snapshot.artifactBundlePath)) return true;
799
+ if (trimOrNull(snapshot.patchPath)) return true;
800
+ const ancestry = snapshot.gitAncestry;
801
+ if (ancestry?.checked && ancestry.headIsAncestorOfBase === false && trimOrNull(ancestry.head)) {
802
+ return true;
803
+ }
804
+ return false;
805
+ }
806
+ function assessWorkerLanding(snapshot) {
807
+ if (!hasFinalResult(snapshot.finalResult)) return { blocked: false };
808
+ if (snapshot.changedFiles.length === 0) return { blocked: false };
809
+ if (!hasCommittedLandingRef(snapshot)) {
810
+ return {
811
+ blocked: true,
812
+ reason: "dirty_worktree_no_pr",
813
+ detail: `Worktree has ${snapshot.changedFiles.length} uncommitted change(s) with no commit or PR; commit, open a PR, or discard before landing`
814
+ };
815
+ }
816
+ return {
817
+ blocked: true,
818
+ detail: `Worktree has ${snapshot.changedFiles.length} uncommitted change(s); commit or discard before landing`
819
+ };
820
+ }
821
+ function landingAttentionReason(verdict) {
822
+ if (!verdict.blocked) return void 0;
823
+ return verdict.detail ?? verdict.reason ?? "dirty_worktree_no_pr";
824
+ }
825
+
767
826
  // src/status.ts
768
827
  var NO_START_MS = 18e4;
769
828
  var STALE_MS = 6e5;
770
829
  function computeAttention(input) {
771
830
  const now = Date.now();
772
- if (input.finalResult) return { state: "done", reason: "final result recorded" };
831
+ if (input.completionBlocker) {
832
+ return { state: "blocked", reason: input.completionBlocker };
833
+ }
834
+ if (input.finalResult) {
835
+ const landing = assessWorkerLanding({
836
+ finalResult: input.finalResult,
837
+ changedFiles: input.changedFiles ?? [],
838
+ gitAncestry: input.gitAncestry ?? null
839
+ });
840
+ if (landing.blocked) {
841
+ const detail = landingAttentionReason(landing);
842
+ return {
843
+ state: "needs_attention",
844
+ reason: landing.reason ? `landing blocked (${landing.reason}): ${detail}` : `landing blocked: ${detail}`
845
+ };
846
+ }
847
+ return { state: "done", reason: "final result recorded" };
848
+ }
773
849
  if (!input.alive) {
774
850
  const classified = classifyExitFailure(input.error);
775
851
  if (classified) return { state: "blocked", reason: classified.reason };
@@ -800,7 +876,10 @@ function computeWorkerStatus(worker, options = {}) {
800
876
  const stderrBytes = fileSize(worker.stderrPath);
801
877
  const heartbeatBytes = fileSize(worker.heartbeatPath);
802
878
  const changedFiles = gitStatusShort(worker.worktreePath);
803
- const gitAncestry = computeGitAncestry(worker.worktreePath, options.base);
879
+ const gitAncestry = computeGitAncestry(worker.worktreePath, {
880
+ base: options.base,
881
+ baseCommit: options.baseCommit
882
+ });
804
883
  const lastActivityAt = latestIso([
805
884
  parsed.lastEventAt,
806
885
  heartbeat.lastHeartbeatAt,
@@ -809,6 +888,7 @@ function computeWorkerStatus(worker, options = {}) {
809
888
  fileMtime(worker.heartbeatPath)
810
889
  ]);
811
890
  const error = parsed.error || (!alive && !parsed.finalResult ? tailFile(worker.stderrPath, 10).trim() || void 0 : void 0);
891
+ const completionBlocker = typeof worker.completionBlocker === "string" && worker.completionBlocker.trim() ? worker.completionBlocker.trim() : null;
812
892
  const attention = computeAttention({
813
893
  alive,
814
894
  finalResult: parsed.finalResult,
@@ -818,14 +898,18 @@ function computeWorkerStatus(worker, options = {}) {
818
898
  lastActivityAt,
819
899
  heartbeatBlocker: heartbeat.heartbeatBlocker,
820
900
  startedAt: worker.startedAt,
821
- error
901
+ error,
902
+ changedFiles,
903
+ gitAncestry,
904
+ completionBlocker
822
905
  });
906
+ const workerStatusLabel = completionBlocker || attention.state === "blocked" ? "blocked" : attention.state === "done" ? "done" : parsed.finalResult ? "exited" : alive ? "running" : "exited";
823
907
  return {
824
908
  runId: worker.runId,
825
909
  worker: worker.name,
826
910
  pid: worker.pid,
827
911
  alive,
828
- status: parsed.finalResult ? "done" : alive ? "running" : "exited",
912
+ status: workerStatusLabel,
829
913
  attention,
830
914
  branch: worker.branch,
831
915
  worktreePath: worker.worktreePath,
@@ -854,6 +938,10 @@ function isFinishedWorkerStatus(status) {
854
938
  if (status.status === "exited" || status.status === "done") return true;
855
939
  return false;
856
940
  }
941
+ function isLandingBlockedWorkerStatus(status) {
942
+ if (!status.finalResult) return false;
943
+ return status.attention.state === "needs_attention" || status.attention.state === "blocked";
944
+ }
857
945
  function deriveRunStatus(fallback, workers) {
858
946
  if (workers.length === 0) return fallback;
859
947
  if (workers.some((w) => w.attention === "needs_attention" || w.attention === "stale" || w.attention === "blocked")) {
@@ -904,6 +992,10 @@ function readAvailableMemBytes() {
904
992
  }
905
993
  return os.freemem();
906
994
  }
995
+ function isActiveHarnessWorker(worker) {
996
+ const status = computeWorkerStatus(worker);
997
+ return status.alive && !status.finalResult && status.attention.state !== "done";
998
+ }
907
999
  function countActiveWorkersForRun(run) {
908
1000
  let active = 0;
909
1001
  for (const name of Object.keys(run.workers || {})) {
@@ -911,11 +1003,8 @@ function countActiveWorkersForRun(run) {
911
1003
  path5.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
912
1004
  void 0
913
1005
  );
914
- if (!worker) continue;
915
- const status = computeWorkerStatus(worker);
916
- if (status.alive && !status.finalResult && status.attention.state !== "done") {
917
- active++;
918
- }
1006
+ if (!worker || !isActiveHarnessWorker(worker)) continue;
1007
+ active++;
919
1008
  }
920
1009
  return active;
921
1010
  }
@@ -940,7 +1029,7 @@ function observeRunnerResourceGate(input) {
940
1029
  const maxConcurrentWorkers = Math.max(0, Math.min(targetCap, capacityFromTotal));
941
1030
  const slotsByCapacity = Math.max(0, maxConcurrentWorkers - activeWorkers);
942
1031
  const slotsByFreeMem = capacityFromFree;
943
- const slotsAvailable = Math.min(slotsByCapacity, slotsByFreeMem);
1032
+ let slotsAvailable = Math.min(slotsByCapacity, slotsByFreeMem);
944
1033
  let reason = null;
945
1034
  if (slotsAvailable <= 0) {
946
1035
  if (activeWorkers >= maxConcurrentWorkers) {
@@ -967,39 +1056,6 @@ function observeRunnerResourceGate(input) {
967
1056
  };
968
1057
  }
969
1058
 
970
- // src/supervisor.ts
971
- import { existsSync as existsSync10, mkdirSync as mkdirSync3 } from "node:fs";
972
- import path10 from "node:path";
973
-
974
- // src/prompt.ts
975
- function buildPrompt(input) {
976
- 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.";
977
- const progressLines = [
978
- "Structured plan progress (required when planId is set):",
979
- "- 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).",
980
- "- 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.",
981
- "- 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`).",
982
- "- 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>`.",
983
- "- Before the completion report: mark completion-report rows partial with evidence; do not skip report review.",
984
- "- After implementation: wait for report_reviewer then deep_reviewer confirmation (via MCP/session agents) before follow-up rows close.",
985
- 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."
986
- ];
987
- return [
988
- "You are running under the Kynver AgentOS runtime.",
989
- "Immediately state your plan before editing.",
990
- ownership,
991
- `Worktree: ${input.worktreePath}`,
992
- `Progress heartbeat file: ${input.heartbeatPath}`,
993
- "After each major step, append one JSON line to the heartbeat file with fields: ts, phase, summary, changedFiles, blocker.",
994
- "Final response must include files changed, verification commands, and unresolved risks.",
995
- "",
996
- ...progressLines,
997
- "",
998
- "Task:",
999
- input.task
1000
- ].join("\n");
1001
- }
1002
-
1003
1059
  // src/providers/claude.ts
1004
1060
  import { closeSync, openSync } from "node:fs";
1005
1061
  import { spawn } from "node:child_process";
@@ -1058,7 +1114,7 @@ function preflightCursorModel(model, defaultModel) {
1058
1114
  }
1059
1115
 
1060
1116
  // src/providers/claude.ts
1061
- var CLAUDE_DEFAULT_MODEL = "claude-opus-4-7";
1117
+ var CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
1062
1118
  var claudeProvider = {
1063
1119
  name: "claude",
1064
1120
  defaultModel: CLAUDE_DEFAULT_MODEL,
@@ -1104,6 +1160,159 @@ var claudeProvider = {
1104
1160
  }
1105
1161
  };
1106
1162
 
1163
+ // src/model-routing.ts
1164
+ var GLOBAL_DEFAULT_MODEL = "claude-sonnet-4-6";
1165
+ function taskString(task, key) {
1166
+ const v = task[key];
1167
+ return typeof v === "string" ? v.trim() : "";
1168
+ }
1169
+ function normalizeRef(ref) {
1170
+ return ref.toLowerCase();
1171
+ }
1172
+ function resolveGlobalDefaultModel(config = loadUserConfig()) {
1173
+ const fromConfig = config.defaultModel?.trim();
1174
+ if (fromConfig) return fromConfig;
1175
+ const fromEnv = process.env.KYNVER_DEFAULT_MODEL?.trim();
1176
+ if (fromEnv) return fromEnv;
1177
+ return GLOBAL_DEFAULT_MODEL;
1178
+ }
1179
+ function inferProviderFromModel(model) {
1180
+ const m = (model ?? "").toLowerCase();
1181
+ if (!m) return "claude";
1182
+ if (m.includes("composer") || m.includes("cursor") || m.includes("codex") || m.startsWith("gpt-") || m.startsWith("gpt5")) {
1183
+ return "cursor";
1184
+ }
1185
+ return "claude";
1186
+ }
1187
+ function isOpusLane(ref, title) {
1188
+ if (ref.includes("deep") && ref.includes("review")) return true;
1189
+ if (ref.includes("security")) return true;
1190
+ if (ref.includes("plan_author") || ref.includes("plan-author")) return true;
1191
+ if (title.includes("deep review") || title.includes("security review")) return true;
1192
+ if (ref.includes("plan") && !ref.includes("review") && (ref.includes("author") || ref.includes("strategy"))) {
1193
+ return true;
1194
+ }
1195
+ return false;
1196
+ }
1197
+ function inferModelRoutingFromTask(task) {
1198
+ const ref = normalizeRef(taskString(task, "executorRef"));
1199
+ const title = taskString(task, "title").toLowerCase();
1200
+ const priority = taskString(task, "priority") || "normal";
1201
+ const roleLane = normalizeRef(taskString(task, "roleLane"));
1202
+ if (ref.includes("cursor") || ref.includes("codex") || ref.includes("composer") || ref.includes("copilot") || roleLane === "implementer" || roleLane === "repair_implementer") {
1203
+ return { provider: "cursor", rule: "lane:implementation" };
1204
+ }
1205
+ if (ref.includes("landing") || title.startsWith("land:") || title.includes(" merge")) {
1206
+ return {
1207
+ model: "claude-haiku-4-5-20251001",
1208
+ provider: "claude",
1209
+ rule: "lane:landing"
1210
+ };
1211
+ }
1212
+ if (ref.includes("review") || title.startsWith("review ") || roleLane.includes("review")) {
1213
+ if (isOpusLane(ref, title) || roleLane === "deep_reviewer") {
1214
+ return { model: "claude-opus-4-7", provider: "claude", rule: "lane:deep_review" };
1215
+ }
1216
+ return { model: "claude-sonnet-4-6", provider: "claude", rule: "lane:review" };
1217
+ }
1218
+ if (isOpusLane(ref, title) || roleLane === "plan_author") {
1219
+ return { model: "claude-opus-4-7", provider: "claude", rule: "lane:planning" };
1220
+ }
1221
+ if (priority === "critical") {
1222
+ return { model: "claude-opus-4-7", provider: "claude", rule: "priority:critical" };
1223
+ }
1224
+ if (priority === "low") {
1225
+ return {
1226
+ model: "claude-haiku-4-5-20251001",
1227
+ provider: "claude",
1228
+ rule: "priority:low"
1229
+ };
1230
+ }
1231
+ return {
1232
+ model: resolveGlobalDefaultModel(),
1233
+ provider: "claude",
1234
+ rule: "default:sonnet"
1235
+ };
1236
+ }
1237
+ function resolveWorkerLaunch(input) {
1238
+ if (input.explicitModel?.trim()) {
1239
+ const model2 = input.explicitModel.trim();
1240
+ return {
1241
+ model: model2,
1242
+ provider: input.explicitProvider?.trim() || inferProviderFromModel(model2),
1243
+ rule: "explicit:cli",
1244
+ requestedModel: model2
1245
+ };
1246
+ }
1247
+ if (input.task && Object.keys(input.task).length > 0) {
1248
+ const inferred = inferModelRoutingFromTask(input.task);
1249
+ return {
1250
+ ...inferred,
1251
+ requestedModel: inferred.model
1252
+ };
1253
+ }
1254
+ const model = resolveGlobalDefaultModel();
1255
+ return {
1256
+ model,
1257
+ provider: input.explicitProvider?.trim() || inferProviderFromModel(model),
1258
+ rule: "default:global",
1259
+ requestedModel: model
1260
+ };
1261
+ }
1262
+ function resolveModelFallback(startedModel, launchModel, providerDefault) {
1263
+ return startedModel || launchModel || providerDefault || resolveGlobalDefaultModel() || CLAUDE_DEFAULT_MODEL;
1264
+ }
1265
+
1266
+ // src/retry-limits.ts
1267
+ function positiveInt2(value, fallback) {
1268
+ const n = Number(value);
1269
+ if (!Number.isFinite(n) || n <= 0) return fallback;
1270
+ return Math.floor(n);
1271
+ }
1272
+ function readHarnessRetryLimits() {
1273
+ return {
1274
+ maxTaskAttempts: positiveInt2(process.env.KYNVER_MAX_TASK_ATTEMPTS, 3),
1275
+ dispatchCooldownMs: positiveInt2(process.env.KYNVER_DISPATCH_COOLDOWN_MS, 5e3)
1276
+ };
1277
+ }
1278
+
1279
+ // src/supervisor.ts
1280
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3 } from "node:fs";
1281
+ import path10 from "node:path";
1282
+
1283
+ // src/prompt.ts
1284
+ function buildPrompt(input) {
1285
+ 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.";
1286
+ const compact = Boolean(input.model?.toLowerCase().includes("haiku"));
1287
+ const progressLines = compact ? [
1288
+ "Plan progress: when planId is set, use `kynver plan progress` for running|partial|blocked only; row `done` is MCP/session only.",
1289
+ input.planId ? `Active planId: ${input.planId}` : "No planId on this worker."
1290
+ ] : [
1291
+ "Structured plan progress (required when planId is set):",
1292
+ "- 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).",
1293
+ "- 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.",
1294
+ "- 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`).",
1295
+ "- 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>`.",
1296
+ "- Before the completion report: mark completion-report rows partial with evidence; do not skip report review.",
1297
+ "- After implementation: wait for report_reviewer then deep_reviewer confirmation (via MCP/session agents) before follow-up rows close.",
1298
+ 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."
1299
+ ];
1300
+ return [
1301
+ "You are running under the Kynver AgentOS runtime.",
1302
+ "Immediately state your plan before editing.",
1303
+ ownership,
1304
+ `Worktree: ${input.worktreePath}`,
1305
+ `Progress heartbeat file: ${input.heartbeatPath}`,
1306
+ "After each major step, append one JSON line to the heartbeat file with fields: ts, phase, summary, changedFiles, blocker.",
1307
+ "Final response must include files changed, verification commands, and unresolved risks.",
1308
+ "",
1309
+ ...progressLines,
1310
+ "",
1311
+ "Task:",
1312
+ input.task
1313
+ ].join("\n");
1314
+ }
1315
+
1107
1316
  // src/providers/cursor.ts
1108
1317
  import { closeSync as closeSync2, existsSync as existsSync8, openSync as openSync2 } from "node:fs";
1109
1318
  import { spawn as spawn2 } from "node:child_process";
@@ -1307,9 +1516,13 @@ function persistCompletionBlocker(worker, reason) {
1307
1516
  else delete worker.completionBlocker;
1308
1517
  saveWorker(worker.runId, worker);
1309
1518
  }
1519
+ function workerStatusOptions(run) {
1520
+ return run ? { base: run.base, baseCommit: run.baseCommit } : {};
1521
+ }
1310
1522
  async function tryCompleteWorker(args) {
1311
1523
  const worker = loadWorker(String(args.run), String(args.name));
1312
- const status = computeWorkerStatus(worker);
1524
+ const run = loadRun(worker.runId);
1525
+ const status = computeWorkerStatus(worker, workerStatusOptions(run));
1313
1526
  const agentOsId = (args.agentOsId ? String(args.agentOsId) : worker.agentOsId) || "";
1314
1527
  const taskId = (args.taskId ? String(args.taskId) : worker.taskId) || null;
1315
1528
  if (!agentOsId) {
@@ -1353,7 +1566,8 @@ async function tryCompleteWorker(args) {
1353
1566
  async function completeWorker(args) {
1354
1567
  try {
1355
1568
  const worker = loadWorker(String(args.run), String(args.name));
1356
- const status = computeWorkerStatus(worker);
1569
+ const run = loadRun(worker.runId);
1570
+ const status = computeWorkerStatus(worker, workerStatusOptions(run));
1357
1571
  const agentOsId = (args.agentOsId ? String(args.agentOsId) : worker.agentOsId) || "";
1358
1572
  const taskId = (args.taskId ? String(args.taskId) : worker.taskId) || null;
1359
1573
  if (!agentOsId) {
@@ -1400,7 +1614,8 @@ async function completeWorker(args) {
1400
1614
  }
1401
1615
  function workerStatus(args) {
1402
1616
  const worker = loadWorker(String(args.run), String(args.name));
1403
- const status = computeWorkerStatus(worker);
1617
+ const run = loadRun(worker.runId);
1618
+ const status = computeWorkerStatus(worker, workerStatusOptions(run));
1404
1619
  writeJson(path8.join(worker.workerDir, "last-status.json"), status);
1405
1620
  console.log(JSON.stringify(status, null, 2));
1406
1621
  }
@@ -1415,14 +1630,21 @@ function runStatus(args) {
1415
1630
  if (!worker) {
1416
1631
  return { worker: name, status: "missing", attention: "needs_attention", attentionReason: "worker.json not found" };
1417
1632
  }
1418
- const status = computeWorkerStatus(worker, { base: run.base });
1633
+ const status = computeWorkerStatus(worker, {
1634
+ base: run.base,
1635
+ baseCommit: run.baseCommit
1636
+ });
1637
+ const headCommit = status.gitAncestry.headIsAncestorOfBase === false && status.gitAncestry.head ? status.gitAncestry.head : void 0;
1419
1638
  const rawBlocker = worker.completionBlocker;
1420
1639
  const completionBlocker = typeof rawBlocker === "string" && rawBlocker ? rawBlocker : void 0;
1640
+ const boardStatus = completionBlocker ? "blocked" : status.status;
1641
+ const boardAttention = completionBlocker ? "blocked" : status.attention.state;
1421
1642
  return {
1422
1643
  worker: status.worker,
1423
- status: completionBlocker ? "blocked" : status.status,
1424
- attention: completionBlocker ? "blocked" : status.attention.state,
1644
+ status: boardStatus,
1645
+ attention: boardAttention,
1425
1646
  attentionReason: completionBlocker ?? status.attention.reason,
1647
+ landingBlocked: status.finalResult ? boardAttention === "needs_attention" || boardAttention === "blocked" : false,
1426
1648
  pid: status.pid,
1427
1649
  alive: status.alive,
1428
1650
  currentTool: status.currentTool,
@@ -1431,7 +1653,14 @@ function runStatus(args) {
1431
1653
  lastHeartbeatSummary: status.lastHeartbeatSummary,
1432
1654
  heartbeatBlocker: status.heartbeatBlocker,
1433
1655
  changedFileCount: status.changedFiles.length,
1656
+ changedFiles: status.changedFiles,
1434
1657
  branch: status.branch,
1658
+ model: typeof worker.model === "string" ? worker.model : void 0,
1659
+ routingRule: typeof worker.routingRule === "string" ? worker.routingRule : void 0,
1660
+ requestedModel: typeof worker.requestedModel === "string" ? worker.requestedModel : void 0,
1661
+ headCommit,
1662
+ gitAncestry: status.gitAncestry,
1663
+ finalResult: status.finalResult,
1435
1664
  ancestry: status.gitAncestry.relation,
1436
1665
  ancestryChecked: status.gitAncestry.checked
1437
1666
  };
@@ -1647,8 +1876,17 @@ function spawnWorkerProcess(run, opts) {
1647
1876
  const name = safeSlug(rawName);
1648
1877
  if (run.workers?.[name]) throw new Error(`worker already exists in run ${run.id}: ${name}`);
1649
1878
  if (!opts.task) throw new Error(`missing task text for worker ${name}`);
1650
- const provider = resolveWorkerProvider(opts.provider);
1651
- let launchModel = opts.model;
1879
+ const routing = opts.routingRule || opts.requestedModel ? {
1880
+ provider: opts.provider || "claude",
1881
+ model: opts.model,
1882
+ rule: opts.routingRule || "explicit:spawn",
1883
+ requestedModel: opts.requestedModel ?? opts.model
1884
+ } : resolveWorkerLaunch({
1885
+ explicitModel: opts.model,
1886
+ explicitProvider: opts.provider
1887
+ });
1888
+ const provider = resolveWorkerProvider(routing.provider);
1889
+ let launchModel = routing.model;
1652
1890
  if (provider.preflightModel) {
1653
1891
  const preflight = provider.preflightModel(opts.model);
1654
1892
  if (!preflight.ok) {
@@ -1676,7 +1914,10 @@ function spawnWorkerProcess(run, opts) {
1676
1914
  task: opts.task,
1677
1915
  ownedPaths: opts.ownedPaths || [],
1678
1916
  worktreePath,
1679
- heartbeatPath
1917
+ heartbeatPath,
1918
+ planId: opts.planId,
1919
+ taskId: opts.taskId,
1920
+ model: launchModel
1680
1921
  });
1681
1922
  let started;
1682
1923
  try {
@@ -1698,7 +1939,7 @@ function spawnWorkerProcess(run, opts) {
1698
1939
  git(run.repo, ["branch", "-D", branch], { allowFailure: true });
1699
1940
  throw error;
1700
1941
  }
1701
- const model = started.model || launchModel || provider.defaultModel || "claude-opus-4-7";
1942
+ const model = resolveModelFallback(started.model, launchModel, provider.defaultModel);
1702
1943
  const worker = {
1703
1944
  name,
1704
1945
  runId: run.id,
@@ -1717,6 +1958,8 @@ function spawnWorkerProcess(run, opts) {
1717
1958
  ...opts.planId ? { planId: String(opts.planId) } : {},
1718
1959
  ...opts.leaseOwner ? { leaseOwner: String(opts.leaseOwner) } : {},
1719
1960
  ...opts.dispatched ? { dispatched: true } : {},
1961
+ routingRule: routing.rule,
1962
+ ...routing.requestedModel ? { requestedModel: routing.requestedModel } : {},
1720
1963
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
1721
1964
  };
1722
1965
  saveWorker(run.id, worker);
@@ -1724,14 +1967,23 @@ function spawnWorkerProcess(run, opts) {
1724
1967
  run.status = "running";
1725
1968
  saveRun(run);
1726
1969
  if (worker.agentOsId && worker.taskId) {
1970
+ let sidecarSpawned;
1727
1971
  try {
1728
- spawnCompletionSidecar({
1972
+ sidecarSpawned = spawnCompletionSidecar({
1729
1973
  runId: run.id,
1730
1974
  workerName: name,
1731
1975
  workerDir,
1732
1976
  agentOsId: worker.agentOsId
1733
1977
  });
1734
- } catch {
1978
+ } catch (error) {
1979
+ const reason = `completion sidecar failed to spawn: ${error.message}`;
1980
+ worker.completionBlocker = reason;
1981
+ saveWorker(run.id, worker);
1982
+ }
1983
+ if (!sidecarSpawned) {
1984
+ const reason = "completion sidecar failed to spawn (CLI not found or spawn error)";
1985
+ worker.completionBlocker = reason;
1986
+ saveWorker(run.id, worker);
1735
1987
  }
1736
1988
  }
1737
1989
  return worker;
@@ -1866,17 +2118,34 @@ async function dispatchRun(args) {
1866
2118
  console.log(JSON.stringify(summary2, null, 2));
1867
2119
  return;
1868
2120
  }
2121
+ const retryLimits = readHarnessRetryLimits();
1869
2122
  const outcomes = [];
1870
2123
  for (const decision of result.started) {
1871
2124
  const task = decision.task;
2125
+ const attempt = Number(task.attempt) || 1;
2126
+ if (attempt > retryLimits.maxTaskAttempts) {
2127
+ outcomes.push({
2128
+ taskId: task.id,
2129
+ started: false,
2130
+ error: `task attempt ${attempt} exceeds KYNVER_MAX_TASK_ATTEMPTS (${retryLimits.maxTaskAttempts})`
2131
+ });
2132
+ continue;
2133
+ }
1872
2134
  const name = safeSlug(`t-${task.id}-a${task.attempt}`);
2135
+ const routing = resolveWorkerLaunch({
2136
+ explicitModel: args.model ? String(args.model) : void 0,
2137
+ task
2138
+ });
1873
2139
  try {
1874
2140
  const planId = task.planId ? String(task.planId) : void 0;
1875
2141
  const worker = spawnWorkerProcess(run, {
1876
2142
  name,
1877
2143
  task: buildDispatchTaskText(task, agentOsId),
1878
2144
  ownedPaths: args.owned ? String(args.owned).split(",").map((s) => s.trim()).filter(Boolean) : [],
1879
- model: args.model ? String(args.model) : void 0,
2145
+ model: routing.model,
2146
+ provider: routing.provider,
2147
+ routingRule: routing.rule,
2148
+ requestedModel: routing.requestedModel,
1880
2149
  agentOsId,
1881
2150
  taskId: String(task.id),
1882
2151
  planId,
@@ -1888,7 +2157,10 @@ async function dispatchRun(args) {
1888
2157
  started: true,
1889
2158
  worker: worker.name,
1890
2159
  pid: worker.pid,
1891
- branch: worker.branch
2160
+ branch: worker.branch,
2161
+ model: worker.model,
2162
+ provider: routing.provider,
2163
+ routingRule: routing.rule
1892
2164
  });
1893
2165
  } catch (error) {
1894
2166
  const releaseUrl = `${base}/api/agent-os/by-id/${encodeURIComponent(agentOsId)}/tasks/${encodeURIComponent(String(task.id))}/release`;
@@ -2077,7 +2349,10 @@ import { mkdirSync as mkdirSync5, realpathSync } from "node:fs";
2077
2349
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2078
2350
 
2079
2351
  // src/pipeline-tick.ts
2080
- import path16 from "node:path";
2352
+ import path17 from "node:path";
2353
+
2354
+ // src/stale-reconcile.ts
2355
+ import path15 from "node:path";
2081
2356
 
2082
2357
  // src/finalize.ts
2083
2358
  import path14 from "node:path";
@@ -2088,13 +2363,17 @@ function terminalStatusFor(run) {
2088
2363
  let anyAlive = false;
2089
2364
  let anyResult = false;
2090
2365
  let anyCompletionBlocked = false;
2366
+ let anyLandingBlocked = false;
2091
2367
  for (const name of names) {
2092
2368
  const worker = readJson(
2093
2369
  path14.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2094
2370
  void 0
2095
2371
  );
2096
2372
  if (!worker) continue;
2097
- const status = computeWorkerStatus(worker);
2373
+ const status = computeWorkerStatus(worker, {
2374
+ base: run.base,
2375
+ baseCommit: run.baseCommit
2376
+ });
2098
2377
  if (status.alive && !status.finalResult) {
2099
2378
  anyAlive = true;
2100
2379
  break;
@@ -2102,10 +2381,14 @@ function terminalStatusFor(run) {
2102
2381
  if (typeof worker.completionBlocker === "string" && worker.completionBlocker) {
2103
2382
  anyCompletionBlocked = true;
2104
2383
  }
2105
- if (status.finalResult) anyResult = true;
2384
+ if (isLandingBlockedWorkerStatus(status)) {
2385
+ anyLandingBlocked = true;
2386
+ }
2387
+ if (status.finalResult && status.attention.state === "done") anyResult = true;
2106
2388
  }
2107
2389
  if (anyAlive) return null;
2108
2390
  if (anyCompletionBlocked) return null;
2391
+ if (anyLandingBlocked) return null;
2109
2392
  return anyResult ? "completed" : "failed";
2110
2393
  }
2111
2394
  function finalizeStaleRuns() {
@@ -2122,8 +2405,82 @@ function finalizeStaleRuns() {
2122
2405
  return finalized;
2123
2406
  }
2124
2407
 
2408
+ // src/stale-reconcile.ts
2409
+ var STALE_RECONCILE_HEARTBEAT_MS = 15 * 60 * 1e3;
2410
+ function staleReconcileDisabled() {
2411
+ return process.env.KYNVER_NO_STALE_CLEANUP === "1";
2412
+ }
2413
+ function reconcileStaleWorkers() {
2414
+ if (staleReconcileDisabled()) {
2415
+ return { workers: [], finalizedRuns: finalizeStaleRuns() };
2416
+ }
2417
+ const outcomes = [];
2418
+ const now = Date.now();
2419
+ for (const run of listRunRecords()) {
2420
+ for (const name of Object.keys(run.workers || {})) {
2421
+ const workerPath = path15.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json");
2422
+ const worker = readJson(workerPath, void 0);
2423
+ if (!worker || worker.status !== "running") {
2424
+ outcomes.push({
2425
+ runId: run.id,
2426
+ worker: name,
2427
+ action: "skipped",
2428
+ reason: worker ? `worker status is ${worker.status}` : "worker.json missing"
2429
+ });
2430
+ continue;
2431
+ }
2432
+ const status = computeWorkerStatus(worker, { base: run.base, baseCommit: run.baseCommit });
2433
+ if (status.finalResult) {
2434
+ outcomes.push({ runId: run.id, worker: name, action: "skipped", reason: "final result present" });
2435
+ continue;
2436
+ }
2437
+ if (!status.alive) {
2438
+ const nextStatus = status.attention.state === "blocked" ? "blocked" : status.status === "done" ? "done" : "exited";
2439
+ worker.status = nextStatus;
2440
+ worker.reconciledAt = (/* @__PURE__ */ new Date()).toISOString();
2441
+ worker.reconcileReason = status.attention.reason;
2442
+ saveWorker(run.id, worker);
2443
+ outcomes.push({
2444
+ runId: run.id,
2445
+ worker: name,
2446
+ action: "marked_exited",
2447
+ reason: status.attention.reason
2448
+ });
2449
+ continue;
2450
+ }
2451
+ if (status.attention.state === "stale" && worker.pid && isPidAlive(worker.pid)) {
2452
+ const hbMs = status.lastHeartbeatAt ? Date.parse(status.lastHeartbeatAt) : NaN;
2453
+ const actMs = status.lastActivityAt ? Date.parse(status.lastActivityAt) : NaN;
2454
+ const hbStale = !Number.isFinite(hbMs) || now - hbMs > STALE_RECONCILE_HEARTBEAT_MS;
2455
+ const actStale = Number.isFinite(actMs) && now - actMs > STALE_MS;
2456
+ if (hbStale && actStale) {
2457
+ killWorkerProcess(worker.pid, "SIGTERM");
2458
+ worker.status = "exited";
2459
+ worker.reconciledAt = (/* @__PURE__ */ new Date()).toISOString();
2460
+ worker.reconcileReason = `reconciled stale worker: ${status.attention.reason}`;
2461
+ saveWorker(run.id, worker);
2462
+ outcomes.push({
2463
+ runId: run.id,
2464
+ worker: name,
2465
+ action: "killed_stale",
2466
+ reason: status.attention.reason
2467
+ });
2468
+ continue;
2469
+ }
2470
+ }
2471
+ outcomes.push({
2472
+ runId: run.id,
2473
+ worker: name,
2474
+ action: "skipped",
2475
+ reason: status.attention.reason
2476
+ });
2477
+ }
2478
+ }
2479
+ return { workers: outcomes, finalizedRuns: finalizeStaleRuns() };
2480
+ }
2481
+
2125
2482
  // src/plan-progress-daemon-sync.ts
2126
- import path15 from "node:path";
2483
+ import path16 from "node:path";
2127
2484
 
2128
2485
  // src/plan-progress-sync.ts
2129
2486
  async function syncPlanProgress(args) {
@@ -2147,7 +2504,7 @@ async function syncActiveWorkerPlanProgress(runId, args) {
2147
2504
  const outcomes = [];
2148
2505
  for (const name of Object.keys(run.workers || {})) {
2149
2506
  const worker = readJson(
2150
- path15.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2507
+ path16.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2151
2508
  void 0
2152
2509
  );
2153
2510
  if (!worker?.dispatched || !worker.taskId) continue;
@@ -2201,7 +2558,7 @@ async function completeFinishedWorkers(runId, args) {
2201
2558
  const outcomes = [];
2202
2559
  for (const name of Object.keys(run.workers || {})) {
2203
2560
  const worker = readJson(
2204
- path16.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2561
+ path17.join(runDirectory(run.id), "workers", safeSlug(name), "worker.json"),
2205
2562
  void 0
2206
2563
  );
2207
2564
  if (!worker?.taskId) continue;
@@ -2236,7 +2593,7 @@ async function runPipelineTick(args) {
2236
2593
  const execute = args.execute !== false && args.execute !== "false";
2237
2594
  runStatus({ run: runId });
2238
2595
  const completedWorkers = await completeFinishedWorkers(runId, args);
2239
- const finalizedStaleRuns = finalizeStaleRuns();
2596
+ const staleReconcile = reconcileStaleWorkers();
2240
2597
  const planProgressSync = await syncActiveWorkerPlanProgress(runId, args);
2241
2598
  const workspacePrefs = await fetchWorkspaceRuntimePreferences(agentOsId, args);
2242
2599
  const resourceGate = observeRunnerResourceGate({
@@ -2277,7 +2634,7 @@ async function runPipelineTick(args) {
2277
2634
  execute,
2278
2635
  resourceGate,
2279
2636
  completedWorkers,
2280
- finalizedStaleRuns,
2637
+ staleReconcile,
2281
2638
  planProgressSync,
2282
2639
  operatorTick,
2283
2640
  sweep,
@@ -2495,6 +2852,7 @@ if (isCliEntry) {
2495
2852
  }
2496
2853
  export {
2497
2854
  DEFAULT_DISPATCH_LEASE_MS,
2855
+ assessWorkerLanding,
2498
2856
  autoCompleteWorker,
2499
2857
  autoCompleteWorkerCli,
2500
2858
  buildDispatchTaskText,
@@ -2507,6 +2865,7 @@ export {
2507
2865
  dispatchRun,
2508
2866
  getHarnessPaths,
2509
2867
  isFinishedWorkerStatus,
2868
+ isLandingBlockedWorkerStatus,
2510
2869
  listRuns,
2511
2870
  loadUserConfig,
2512
2871
  main,