@kynver-app/runtime 0.1.22 → 0.1.24

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
@@ -680,6 +680,64 @@ function classifyExitFailure(errorText) {
680
680
  return null;
681
681
  }
682
682
 
683
+ // src/exited-salvage.ts
684
+ function trimOrNull(value) {
685
+ if (typeof value !== "string") return null;
686
+ const trimmed = value.trim();
687
+ return trimmed.length ? trimmed : null;
688
+ }
689
+ function hasFinalResult(value) {
690
+ if (value === void 0 || value === null) return false;
691
+ if (typeof value === "string") return value.trim().length > 0;
692
+ if (typeof value === "boolean") return value;
693
+ if (Array.isArray(value)) return value.length > 0;
694
+ if (typeof value === "object") return Object.keys(value).length > 0;
695
+ return true;
696
+ }
697
+ function committedHeadFromAncestry(ancestry) {
698
+ if (!ancestry?.checked) return null;
699
+ if (ancestry.headIsAncestorOfBase !== false) return null;
700
+ return trimOrNull(ancestry.head);
701
+ }
702
+ function buildAttentionReason(kind, uncommittedCount, headCommit) {
703
+ const parts = ["exited_with_changes_salvage"];
704
+ if (kind === "uncommitted" || kind === "both") {
705
+ parts.push(
706
+ `${uncommittedCount} uncommitted change${uncommittedCount === 1 ? "" : "s"} with no final result`
707
+ );
708
+ }
709
+ if ((kind === "committed_ahead" || kind === "both") && headCommit) {
710
+ const sha = headCommit.length > 12 ? headCommit.slice(0, 12) : headCommit;
711
+ parts.push(`commit ${sha} ahead of base with no final result`);
712
+ }
713
+ parts.push("review worktree \u2014 commit, open a PR, or run a salvage worker before discarding");
714
+ return parts.join(": ");
715
+ }
716
+ function assessExitedWorkerSalvage(input) {
717
+ if (input.alive || hasFinalResult(input.finalResult)) return null;
718
+ const uncommittedCount = (input.changedFiles ?? []).filter((line) => line.trim()).length;
719
+ const headCommit = trimOrNull(input.headCommit) ?? committedHeadFromAncestry(input.gitAncestry);
720
+ const hasUncommitted = uncommittedCount > 0;
721
+ const hasCommittedAhead = Boolean(headCommit);
722
+ if (!hasUncommitted && !hasCommittedAhead) {
723
+ return {
724
+ kind: "none",
725
+ salvageable: false,
726
+ uncommittedCount: 0,
727
+ headCommit: null,
728
+ attentionReason: "process exited without a final result"
729
+ };
730
+ }
731
+ const kind = hasUncommitted && hasCommittedAhead ? "both" : hasUncommitted ? "uncommitted" : "committed_ahead";
732
+ return {
733
+ kind,
734
+ salvageable: true,
735
+ uncommittedCount,
736
+ headCommit,
737
+ attentionReason: buildAttentionReason(kind, uncommittedCount, headCommit)
738
+ };
739
+ }
740
+
683
741
  // src/git.ts
684
742
  import { spawnSync } from "node:child_process";
685
743
  function git(cwd, args, options = {}) {
@@ -804,12 +862,12 @@ function scrubClaudeEnv(env) {
804
862
  }
805
863
 
806
864
  // src/landing-gate.ts
807
- function trimOrNull(value) {
865
+ function trimOrNull2(value) {
808
866
  if (typeof value !== "string") return null;
809
867
  const trimmed = value.trim();
810
868
  return trimmed.length ? trimmed : null;
811
869
  }
812
- function hasFinalResult(value) {
870
+ function hasFinalResult2(value) {
813
871
  if (value === void 0 || value === null) return false;
814
872
  if (typeof value === "string") return value.trim().length > 0;
815
873
  if (typeof value === "boolean") return value;
@@ -818,18 +876,18 @@ function hasFinalResult(value) {
818
876
  return true;
819
877
  }
820
878
  function hasCommittedLandingRef(snapshot) {
821
- if (trimOrNull(snapshot.headCommit)) return true;
822
- if (trimOrNull(snapshot.prUrl)) return true;
823
- if (trimOrNull(snapshot.artifactBundlePath)) return true;
824
- if (trimOrNull(snapshot.patchPath)) return true;
879
+ if (trimOrNull2(snapshot.headCommit)) return true;
880
+ if (trimOrNull2(snapshot.prUrl)) return true;
881
+ if (trimOrNull2(snapshot.artifactBundlePath)) return true;
882
+ if (trimOrNull2(snapshot.patchPath)) return true;
825
883
  const ancestry = snapshot.gitAncestry;
826
- if (ancestry?.checked && ancestry.headIsAncestorOfBase === false && trimOrNull(ancestry.head)) {
884
+ if (ancestry?.checked && ancestry.headIsAncestorOfBase === false && trimOrNull2(ancestry.head)) {
827
885
  return true;
828
886
  }
829
887
  return false;
830
888
  }
831
889
  function assessWorkerLanding(snapshot) {
832
- if (!hasFinalResult(snapshot.finalResult)) return { blocked: false };
890
+ if (!hasFinalResult2(snapshot.finalResult)) return { blocked: false };
833
891
  if (snapshot.changedFiles.length === 0) return { blocked: false };
834
892
  if (!hasCommittedLandingRef(snapshot)) {
835
893
  return {
@@ -874,10 +932,23 @@ function computeAttention(input) {
874
932
  if (!input.alive) {
875
933
  const classified = classifyExitFailure(input.error);
876
934
  if (classified) return { state: "blocked", reason: classified.reason };
935
+ const salvage = assessExitedWorkerSalvage({
936
+ alive: false,
937
+ finalResult: null,
938
+ changedFiles: input.changedFiles,
939
+ gitAncestry: input.gitAncestry
940
+ });
941
+ if (salvage?.salvageable) {
942
+ const tail2 = input.error?.trim();
943
+ return {
944
+ state: "needs_attention",
945
+ reason: tail2 ? `${salvage.attentionReason} (${tail2})` : salvage.attentionReason
946
+ };
947
+ }
877
948
  const tail = input.error?.trim();
878
949
  return {
879
950
  state: "needs_attention",
880
- reason: tail ? `process exited without a final result: ${tail}` : "process exited without a final result"
951
+ reason: tail ? `process exited without a final result: ${tail}` : salvage?.attentionReason ?? "process exited without a final result"
881
952
  };
882
953
  }
883
954
  if (input.heartbeatBlocker) {
@@ -1394,6 +1465,8 @@ function buildPrompt(input) {
1394
1465
  `Progress heartbeat file: ${input.heartbeatPath}`,
1395
1466
  "After each major step, append one JSON line to the heartbeat file with fields: ts, phase, summary, changedFiles, blocker.",
1396
1467
  "Final response must include files changed, verification commands, and unresolved risks.",
1468
+ "Completion handoff (required): before you stop, ensure the harness records a final result \u2014 summarize outcome in your last message and append a heartbeat line with phase `complete`. If you leave uncommitted changes, commit or open a PR; exiting with only dirty files and no final result routes to salvage review, not production review.",
1469
+ "Long-running commands: prefer bounded verification (targeted tests/typecheck for touched paths). If a full build is required, note it in heartbeat phase `verify` so a silent exit is not mistaken for success.",
1397
1470
  "",
1398
1471
  ...progressLines,
1399
1472
  "",
@@ -1726,6 +1799,13 @@ function buildRunBoard(runId) {
1726
1799
  baseCommit: run.baseCommit
1727
1800
  });
1728
1801
  const headCommit = status.gitAncestry.headIsAncestorOfBase === false && status.gitAncestry.head ? status.gitAncestry.head : void 0;
1802
+ const exitedSalvage = assessExitedWorkerSalvage({
1803
+ alive: status.alive,
1804
+ finalResult: status.finalResult,
1805
+ changedFiles: status.changedFiles,
1806
+ gitAncestry: status.gitAncestry,
1807
+ headCommit
1808
+ });
1729
1809
  const rawBlocker = worker.completionBlocker;
1730
1810
  const completionBlocker = typeof rawBlocker === "string" && rawBlocker ? rawBlocker : void 0;
1731
1811
  const boardStatus = completionBlocker ? "blocked" : status.status;
@@ -1736,6 +1816,9 @@ function buildRunBoard(runId) {
1736
1816
  attention: boardAttention,
1737
1817
  attentionReason: completionBlocker ?? status.attention.reason,
1738
1818
  landingBlocked: status.finalResult ? boardAttention === "needs_attention" || boardAttention === "blocked" : false,
1819
+ exitedWithoutFinalResult: !status.finalResult && !status.alive,
1820
+ salvageState: exitedSalvage?.salvageable ? "review_needed" : "none",
1821
+ salvageReason: exitedSalvage?.salvageable ? exitedSalvage.attentionReason : void 0,
1739
1822
  pid: status.pid,
1740
1823
  alive: status.alive,
1741
1824
  currentTool: status.currentTool,
@@ -2687,7 +2770,13 @@ async function completeFinishedWorkers(runId, args) {
2687
2770
  if (!worker?.taskId) continue;
2688
2771
  const status = computeWorkerStatus(worker);
2689
2772
  if (!isFinishedWorkerStatus(status)) continue;
2690
- if (!worker.dispatched && !status.finalResult) continue;
2773
+ const exitedSalvage = assessExitedWorkerSalvage({
2774
+ alive: status.alive,
2775
+ finalResult: status.finalResult,
2776
+ changedFiles: status.changedFiles,
2777
+ gitAncestry: status.gitAncestry
2778
+ });
2779
+ if (!worker.dispatched && !status.finalResult && !exitedSalvage?.salvageable) continue;
2691
2780
  const result = await tryCompleteWorker({
2692
2781
  run: runId,
2693
2782
  name,
@@ -2716,15 +2805,15 @@ async function runPipelineTick(args) {
2716
2805
  const agentOsId = String(required(String(args.agentOsId || ""), "--agent-os-id"));
2717
2806
  const execute = args.execute !== false && args.execute !== "false";
2718
2807
  runStatus({ run: runId });
2719
- const completedWorkers = await completeFinishedWorkers(runId, args);
2720
- const staleReconcile = reconcileStaleWorkers();
2721
- const planProgressSync = await syncActiveWorkerPlanProgress(runId, args);
2722
2808
  const workspacePrefs = await fetchWorkspaceRuntimePreferences(agentOsId, args);
2723
2809
  const resourceGate = observeRunnerResourceGate({
2724
2810
  runId,
2725
2811
  configuredMaxWorkersOverride: workspacePrefs?.maxConcurrentWorkers
2726
2812
  });
2727
2813
  const operatorTick = await postOperatorTick(agentOsId, runId, resourceGate, args);
2814
+ const completedWorkers = await completeFinishedWorkers(runId, args);
2815
+ const staleReconcile = reconcileStaleWorkers();
2816
+ const planProgressSync = await syncActiveWorkerPlanProgress(runId, args);
2728
2817
  let maxStarts = resourceGate.slotsAvailable;
2729
2818
  const tickBody = operatorTick;
2730
2819
  const advised = tickBody.response?.dispatch?.recommendedMaxStarts;
@@ -2976,6 +3065,7 @@ if (isCliEntry) {
2976
3065
  }
2977
3066
  export {
2978
3067
  DEFAULT_DISPATCH_LEASE_MS,
3068
+ assessExitedWorkerSalvage,
2979
3069
  assessWorkerLanding,
2980
3070
  autoCompleteWorker,
2981
3071
  autoCompleteWorkerCli,