@bybrawe/opencode-loop 0.5.34 → 0.5.36

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/src/index.js CHANGED
@@ -801,11 +801,25 @@ function orderedSessionMessages(messages) {
801
801
  return { message, index, created: Number.isFinite(created) ? created : 0 };
802
802
  }).sort((a, b) => a.created - b.created || a.index - b.index).map((entry) => entry.message);
803
803
  }
804
+ function assistantMessageHasMeaningfulActivity(message) {
805
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
806
+ for (const part of parts) {
807
+ if (!part || typeof part !== "object")
808
+ continue;
809
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim())
810
+ return true;
811
+ if (["tool", "file", "patch", "artifact"].includes(String(part.type || "")))
812
+ return true;
813
+ }
814
+ const info = message?.info || message || {};
815
+ return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim());
816
+ }
804
817
  async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
805
818
  const messages = await readRecentSessionMessages(client, sessionID, directory);
806
819
  if (!messages)
807
820
  return "unknown";
808
- const tail = orderedSessionMessages(messages).at(-1);
821
+ const ordered = orderedSessionMessages(messages);
822
+ const tail = ordered.at(-1);
809
823
  const info = tail?.info || tail;
810
824
  if (!info || info.role !== "assistant")
811
825
  return "incomplete";
@@ -816,7 +830,17 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act
816
830
  const startedAt = Number(active?.startedAt || 0);
817
831
  if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt))
818
832
  return "incomplete";
819
- return "completed";
833
+ const relevant = ordered.filter((message) => {
834
+ const candidate = message?.info || message || {};
835
+ if (candidate.role !== "assistant")
836
+ return false;
837
+ if (startedAt <= 0)
838
+ return true;
839
+ const candidateCreated = Number(candidate?.time?.created || 0);
840
+ const candidateCompleted = Number(candidate?.time?.completed || 0);
841
+ return candidateCreated >= startedAt || candidateCompleted >= startedAt;
842
+ });
843
+ return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty";
820
844
  }
821
845
  async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
822
846
  const preferred = normalizedModelRef(preferredModel);
@@ -1033,13 +1057,55 @@ var CONTINUATION_SHORTHANDS = new Set([
1033
1057
  "devam et.",
1034
1058
  "devam et bakal\u0131m"
1035
1059
  ]);
1060
+ var COMPLETION_BOUNDED_PATTERNS = [
1061
+ /\bbitene kadar\b/i,
1062
+ /\b(?:tamamen|komple) projeyi bitir\b/i,
1063
+ /\bi\u015Fi bitir\b/i,
1064
+ /\buntil (?:it(?:'s| is) )?(?:done|complete|completed|finished)\b/i,
1065
+ /\bfinish (?:the )?(?:project|task|work)\b/i,
1066
+ /\bkeep going until\b/i
1067
+ ];
1068
+ var TERMINAL_COMPLETION_PATTERNS = [
1069
+ /proje tamamland[\u0131i](?=\s|[.,;:!\u2014-]|$)/i,
1070
+ /\bproject (?:is )?(?:complete|completed|finished|done)\b/i,
1071
+ /\b(?:task|work) (?:is )?(?:complete|completed|finished|done)\b/i
1072
+ ];
1073
+ var TERMINAL_NO_WORK_PATTERNS = [
1074
+ /yap[\u0131i]lacak (?:ba\u015Fka )?i\u015F yok(?=\s|[.,;:!\u2014-]|$)/i,
1075
+ /ba\u015Fka (?:bir )?i\u015F (?:kalmad[\u0131i]|yok)(?=\s|[.,;:!\u2014-]|$)/i,
1076
+ /\bnothing (?:else )?left to do\b/i,
1077
+ /\bno (?:more|remaining) work\b/i,
1078
+ /\bno known (?:bugs|issues)\b/i,
1079
+ /\bzero known (?:bugs|issues)\b/i
1080
+ ];
1081
+ var NEXT_WORK_PATTERNS = [
1082
+ /\bnext(?: step| task)?\b/i,
1083
+ /s[\u0131i]radaki(?=\s|[.,;:!\u2014-]|$)/i,
1084
+ /sonraki(?=\s|[.,;:!\u2014-]|$)/i,
1085
+ /kalan (?:i\u015F|i\u015Fler|todo|ad\u0131m)(?=\s|[.,;:!\u2014-]|$)/i,
1086
+ /\bremaining (?:work|task|todo|step)/i,
1087
+ /devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
1088
+ ];
1036
1089
  function isContinuationShorthand(value) {
1037
1090
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1038
1091
  }
1092
+ function isCompletionBoundedContinuation(value) {
1093
+ const text = String(value || "").trim();
1094
+ return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
1095
+ }
1096
+ function isTerminalNoWorkReply(value) {
1097
+ const text = String(value || "").trim();
1098
+ if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
1099
+ return false;
1100
+ const completed = TERMINAL_COMPLETION_PATTERNS.some((pattern) => pattern.test(text));
1101
+ const noWork = TERMINAL_NO_WORK_PATTERNS.some((pattern) => pattern.test(text));
1102
+ return completed && noWork;
1103
+ }
1039
1104
  function continuationProjectInstruction(value) {
1040
- if (!isContinuationShorthand(value))
1105
+ if (!isContinuationShorthand(value) && !isCompletionBoundedContinuation(value))
1041
1106
  return "";
1042
- return "Treat this as continuation of the current project and conversation, not a fresh task. Inspect the repository state, relevant files, TODO/progress notes, recent changes, and git status as needed to identify the next unfinished step. Continue from existing work, do not redo completed work, and verify meaningful changes when practical.";
1107
+ const finish = isCompletionBoundedContinuation(value) ? " If you believe the project is finished, perform a fresh verification pass before declaring completion; report both that the project is complete and that no work remains only when you have concrete current evidence." : "";
1108
+ return `Treat this as continuation of the current project and conversation, not a fresh task. Inspect the repository state, relevant files, TODO/progress notes, recent changes, and git status as needed to identify the next unfinished step. Continue from existing work, do not redo completed work, and verify meaningful changes when practical.${finish}`;
1043
1109
  }
1044
1110
 
1045
1111
  // src/source/core/jobs.js
@@ -2811,6 +2877,9 @@ function nonNegativeNumber(value, fallback) {
2811
2877
  const number = Number(value);
2812
2878
  return Number.isFinite(number) && number >= 0 ? number : fallback;
2813
2879
  }
2880
+ function settledAssistantCompletion(value) {
2881
+ return value === "completed" || value === "empty";
2882
+ }
2814
2883
  function createSessionStatusRuntime(options = {}) {
2815
2884
  const activeRuns = options.activeRuns;
2816
2885
  if (!(activeRuns instanceof Map))
@@ -2865,7 +2934,9 @@ function createSessionStatusRuntime(options = {}) {
2865
2934
  if (directory)
2866
2935
  argsList.push({ query: { directory } }, { directory }, { workspace: directory });
2867
2936
  argsList.push({});
2937
+ let attempted = false;
2868
2938
  for (const args of argsList) {
2939
+ attempted = true;
2869
2940
  try {
2870
2941
  const result = await statusMethod.call(client.session, args);
2871
2942
  const error = sdkError(result);
@@ -2895,7 +2966,7 @@ function createSessionStatusRuntime(options = {}) {
2895
2966
  return { type: "idle", source: "sdk" };
2896
2967
  } catch {}
2897
2968
  }
2898
- return;
2969
+ return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
2899
2970
  }
2900
2971
  async function canFinalizeActiveRun(directory, client, sessionID, active, options2 = {}) {
2901
2972
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
@@ -2903,33 +2974,37 @@ function createSessionStatusRuntime(options = {}) {
2903
2974
  if (!options2.requireIdle && !options2.forceStale)
2904
2975
  return true;
2905
2976
  const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2906
- if (completion === "completed")
2977
+ if (settledAssistantCompletion(completion))
2907
2978
  return true;
2908
2979
  if (!options2.requireIdle)
2909
2980
  return completion === "unknown" && staleActiveRun(sessionID);
2981
+ const cached = sessionStatuses.get(sessionID);
2982
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2983
+ const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0);
2910
2984
  const live = await readLiveSessionStatus(client, sessionID, directory);
2911
2985
  if (live?.type === "idle")
2912
2986
  return true;
2987
+ if (live?.type === "unknown" && cachedIdleAfterRun) {
2988
+ return true;
2989
+ }
2913
2990
  if (live?.type) {
2914
- if ((live.type === "busy" || live.type === "retry") && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2991
+ if (live.type === "busy" && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2915
2992
  return true;
2916
2993
  return false;
2917
2994
  }
2918
2995
  if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2919
2996
  return true;
2920
- const cached = sessionStatuses.get(sessionID);
2921
- const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2922
- return cached === "idle" && seenAt > (active.startedAt || 0);
2997
+ return cachedIdleAfterRun;
2923
2998
  }
2924
2999
  async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
2925
3000
  if (activeRuns.has(sessionID))
2926
3001
  return false;
2927
- if (liveType !== "busy" && liveType !== "retry")
3002
+ if (liveType !== "busy")
2928
3003
  return false;
2929
3004
  if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
2930
3005
  return false;
2931
3006
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
2932
- if (completion !== "completed")
3007
+ if (!settledAssistantCompletion(completion))
2933
3008
  return false;
2934
3009
  markSessionStatus(sessionID, "idle");
2935
3010
  await appendLoopLog2(directory, "status-message-idle-recovery", {
@@ -2952,6 +3027,11 @@ function createSessionStatusRuntime(options = {}) {
2952
3027
  if (cached && now2() - seenAt < sessionStatusCacheMs)
2953
3028
  return cached;
2954
3029
  const live = await readLiveSessionStatus(client, sessionID, directory);
3030
+ if (live?.type === "unknown") {
3031
+ const conservative = cached === "retry" ? "retry" : "busy";
3032
+ markSessionStatus(sessionID, conservative);
3033
+ return conservative;
3034
+ }
2955
3035
  if (live?.type) {
2956
3036
  if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
2957
3037
  return "idle";
@@ -2959,13 +3039,16 @@ function createSessionStatusRuntime(options = {}) {
2959
3039
  const active = activeRuns.get(sessionID);
2960
3040
  if (active) {
2961
3041
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
2962
- if (completion === "completed" || completion === "unknown" && staleActiveRun(sessionID)) {
3042
+ if (settledAssistantCompletion(completion) || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
2963
3043
  markSessionStatus(sessionID, "idle");
2964
- await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
3044
+ const logDetails = {
2965
3045
  sessionID,
2966
3046
  job: active.job?.name || active.jobId,
2967
- startedAt: active.startedAt
2968
- });
3047
+ startedAt: active.startedAt,
3048
+ ...completion === "completed" ? {} : { staleStatus: live.type }
3049
+ };
3050
+ const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
3051
+ await appendLoopLog2(directory, recoveryEvent, logDetails);
2969
3052
  return "idle";
2970
3053
  }
2971
3054
  }
@@ -2987,6 +3070,7 @@ function createSessionStatusRuntime(options = {}) {
2987
3070
  staleActiveRun,
2988
3071
  canFinalizeActiveRun,
2989
3072
  readLiveSessionStatus,
3073
+ activeRunCompletion: activeRunCompletionFromMessages2,
2990
3074
  sessionStatusType,
2991
3075
  sessionIsIdle
2992
3076
  };
@@ -3181,6 +3265,58 @@ ${prompt}`;
3181
3265
  return { fireAction };
3182
3266
  }
3183
3267
 
3268
+ // src/source/runtime/terminal-guard.js
3269
+ function messageText(message) {
3270
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
3271
+ const fromParts = parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join(`
3272
+ `).trim();
3273
+ if (fromParts)
3274
+ return fromParts;
3275
+ const info = message?.info || message || {};
3276
+ for (const value of [info.text, info.content, info.summary]) {
3277
+ if (typeof value === "string" && value.trim())
3278
+ return value.trim();
3279
+ }
3280
+ return "";
3281
+ }
3282
+ async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
3283
+ if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
3284
+ return { job, terminal: false, pausedNow: false };
3285
+ }
3286
+ const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
3287
+ if (!messages)
3288
+ return { job, terminal: false, pausedNow: false };
3289
+ const tail = orderedSessionMessages(messages).at(-1);
3290
+ const info = tail?.info || tail || {};
3291
+ if (info.role !== "assistant")
3292
+ return { job, terminal: false, pausedNow: false };
3293
+ const completed = Number(info?.time?.completed || 0);
3294
+ const created = Number(info?.time?.created || 0);
3295
+ const runStarted = Number(job?.lastRunAt || 0);
3296
+ if (!Number.isFinite(completed) || completed <= 0)
3297
+ return { job, terminal: false, pausedNow: false };
3298
+ if (runStarted > 0 && completed < runStarted && (!Number.isFinite(created) || created < runStarted)) {
3299
+ return { job, terminal: false, pausedNow: false };
3300
+ }
3301
+ const text = messageText(tail);
3302
+ const terminal = isTerminalNoWorkReply(text);
3303
+ if (!terminal) {
3304
+ if (job.terminalNoWorkCount)
3305
+ job.terminalNoWorkCount = 0;
3306
+ return { job, terminal: false, pausedNow: false, text };
3307
+ }
3308
+ job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
3309
+ job.lastTerminalNoWorkAt = Date.now();
3310
+ job.lastTerminalNoWorkSummary = text.slice(0, 1000);
3311
+ const threshold = Math.max(2, Number(options.threshold) || 2);
3312
+ const pausedNow = job.terminalNoWorkCount >= threshold && !job.paused;
3313
+ if (pausedNow) {
3314
+ job.paused = true;
3315
+ job.lastFailureReason = "terminal_no_work";
3316
+ }
3317
+ return { job, terminal: true, pausedNow, text };
3318
+ }
3319
+
3184
3320
  // src/source/runtime/run-finalization.js
3185
3321
  function requireFunction8(value, label) {
3186
3322
  if (typeof value !== "function")
@@ -3200,7 +3336,14 @@ function createRunFinalizationRuntime(options = {}) {
3200
3336
  const toast2 = typeof options.toast === "function" ? options.toast : toast;
3201
3337
  const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
3202
3338
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3339
+ const applyTerminalContinuationGuard2 = typeof options.applyTerminalContinuationGuard === "function" ? options.applyTerminalContinuationGuard : applyTerminalContinuationGuard;
3203
3340
  async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
3341
+ if (job.infrastructureFailureCount) {
3342
+ job.infrastructureFailureCount = 0;
3343
+ job.lastInfrastructureFailure = "";
3344
+ job.lastInfrastructureError = "";
3345
+ job.lastInfrastructureFailureAt = 0;
3346
+ }
3204
3347
  if (job.verifyCommand) {
3205
3348
  const verify = await runShellCommand2(job.verifyCommand, directory, job.timeoutMs || 300000);
3206
3349
  job.lastVerifyAt = now2();
@@ -3263,6 +3406,18 @@ exit=` + postrun.code + `
3263
3406
  job = await runGoalChecks(directory, sessionID, job, client);
3264
3407
  job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob);
3265
3408
  }
3409
+ const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
3410
+ job = terminal.job;
3411
+ if (terminal.pausedNow) {
3412
+ await appendLoopLog2(directory, "terminal-no-work", {
3413
+ sessionID,
3414
+ job: job.name || job.id,
3415
+ count: job.terminalNoWorkCount,
3416
+ summary: String(terminal.text || "").slice(0, 1000)
3417
+ });
3418
+ await notifyJob2(directory, job, "terminal_no_work");
3419
+ await toast2(client, "Loop paused: completion-bounded task reported complete with no work remaining twice.", "success");
3420
+ }
3266
3421
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
3267
3422
  await writeState2(directory, sessionID, state);
3268
3423
  if (isGoalJob(job))
@@ -3359,9 +3514,102 @@ exit=` + preflight.code + `
3359
3514
  return { dueJobs: dueJobs2, admitJob };
3360
3515
  }
3361
3516
 
3517
+ // src/source/runtime/network-recovery.js
3518
+ var TRANSIENT_NETWORK_PATTERNS = [
3519
+ /\b(?:408|425|429|500|502|503|504|524)\b/i,
3520
+ /rate[\s_-]?limit|too many requests|overloaded|service[\s_-]?unavailable|provider[_ -]?unavailable/i,
3521
+ /terminated|fetch failed|failed to fetch|network[\s_-]?error|network connection lost/i,
3522
+ /connection (?:error|refused|lost)|socket (?:hang up|connection was closed)|reset before headers/i,
3523
+ /\b(?:enotfound|eai_again|econnrefused|econnreset|etimedout|ehostunreach|enetunreach|epipe)\b/i,
3524
+ /\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
3525
+ /\btimeout(?:error)?\b/i
3526
+ ];
3527
+ function errorText(value) {
3528
+ if (value instanceof Error)
3529
+ return `${value.name}: ${value.message}`;
3530
+ if (typeof value === "string")
3531
+ return value;
3532
+ try {
3533
+ return JSON.stringify(value);
3534
+ } catch {
3535
+ return String(value);
3536
+ }
3537
+ }
3538
+ function isTransientNetworkError(value) {
3539
+ const text = errorText(value);
3540
+ return TRANSIENT_NETWORK_PATTERNS.some((pattern) => pattern.test(text));
3541
+ }
3542
+ function networkRetryDelayMs(attempt, baseMs = 5000, maxMs = 60000) {
3543
+ const safeAttempt = Math.max(1, Math.floor(Number(attempt) || 1));
3544
+ const safeBase = Math.max(1, Math.floor(Number(baseMs) || 5000));
3545
+ const safeMax = Math.max(safeBase, Math.floor(Number(maxMs) || 60000));
3546
+ return Math.min(safeMax, safeBase * 2 ** Math.min(8, safeAttempt - 1));
3547
+ }
3548
+ function refundInfrastructureRun(job, snapshot = {}, input = {}) {
3549
+ const chargedCount = Number(snapshot.runCount ?? job.runCount ?? 0);
3550
+ const currentCount = Number(job.runCount || 0);
3551
+ if (chargedCount > 0 && currentCount >= chargedCount)
3552
+ job.runCount = Math.max(0, currentCount - 1);
3553
+ if (Number.isFinite(Number(snapshot.previousLastRunAt)))
3554
+ job.lastRunAt = Number(snapshot.previousLastRunAt);
3555
+ if (snapshot.disabledByMaxRuns && job.maxRuns > 0 && job.runCount < job.maxRuns)
3556
+ job.enabled = true;
3557
+ job.infrastructureFailureCount = (job.infrastructureFailureCount || 0) + 1;
3558
+ job.lastInfrastructureFailure = String(input.reason || "transient_network_failure").slice(0, 120);
3559
+ job.lastInfrastructureError = errorText(input.error).slice(0, 4000);
3560
+ job.lastInfrastructureFailureAt = Number(input.now || Date.now());
3561
+ return job;
3562
+ }
3563
+
3564
+ // src/source/runtime/empty-turn.js
3565
+ var DEFAULT_MAX_EMPTY_TURNS = 2;
3566
+ function guardsEmptyAssistantTurn(job) {
3567
+ const kind = actionKind(job?.action, job || {});
3568
+ return kind === "prompt" || kind === "goal";
3569
+ }
3570
+ function emptyTurnLimit(job) {
3571
+ const configured = Number(job?.maxEmptyTurns || 0);
3572
+ if (Number.isFinite(configured) && configured > 0)
3573
+ return Math.max(1, Math.floor(configured));
3574
+ return DEFAULT_MAX_EMPTY_TURNS;
3575
+ }
3576
+ function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
3577
+ const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0);
3578
+ const currentCount = Number(job?.runCount || 0);
3579
+ if (chargedCount > 0 && currentCount >= chargedCount)
3580
+ job.runCount = Math.max(0, currentCount - 1);
3581
+ if (Number.isFinite(Number(active?.previousLastRunAt)))
3582
+ job.lastRunAt = Number(active.previousLastRunAt);
3583
+ if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
3584
+ job.enabled = true;
3585
+ }
3586
+ job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1;
3587
+ job.lastEmptyTurnAt = Number(timestamp) || Date.now();
3588
+ job.lastFailureReason = "empty_turn";
3589
+ const limit = emptyTurnLimit(job);
3590
+ const paused = job.emptyTurnCount >= limit;
3591
+ if (paused) {
3592
+ job.paused = true;
3593
+ delete job.runNowRequestedAt;
3594
+ } else {
3595
+ job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now());
3596
+ }
3597
+ return { job, paused, count: job.emptyTurnCount, limit };
3598
+ }
3599
+ function clearEmptyAssistantTurnStreak(job) {
3600
+ if (!job)
3601
+ return job;
3602
+ job.emptyTurnCount = 0;
3603
+ if (job.lastFailureReason === "empty_turn")
3604
+ delete job.lastFailureReason;
3605
+ return job;
3606
+ }
3607
+
3362
3608
  // src/source/runtime/executor.js
3363
3609
  var DEFAULT_ACTIVE_GUARD_MS = 45000;
3364
3610
  var DEFAULT_BUSY_RETRY_MS2 = 5000;
3611
+ var DEFAULT_PROVIDER_RETRY_WATCHDOG_MS = 2 * 60000;
3612
+ var DEFAULT_NETWORK_RETRY_MAX_MS = 60000;
3365
3613
  function requireFunction10(value, label) {
3366
3614
  if (typeof value !== "function")
3367
3615
  throw new TypeError(`createLoopExecutor requires ${label}`);
@@ -3393,8 +3641,11 @@ function createLoopExecutor(options = {}) {
3393
3641
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3394
3642
  const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
3395
3643
  const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
3644
+ const providerRetryWatchdogMs = Number.isFinite(Number(options.providerRetryWatchdogMs)) && Number(options.providerRetryWatchdogMs) > 0 ? Number(options.providerRetryWatchdogMs) : DEFAULT_PROVIDER_RETRY_WATCHDOG_MS;
3645
+ const networkRetryMaxMs = Number.isFinite(Number(options.networkRetryMaxMs)) && Number(options.networkRetryMaxMs) > 0 ? Number(options.networkRetryMaxMs) : DEFAULT_NETWORK_RETRY_MAX_MS;
3396
3646
  const activeRuns = new Map;
3397
3647
  const runLocks = new Map;
3648
+ const retryRuns = new Map;
3398
3649
  const statusRuntime = createSessionStatusRuntime({
3399
3650
  activeRuns,
3400
3651
  appendLoopLog: appendLoopLog2,
@@ -3407,6 +3658,8 @@ function createLoopExecutor(options = {}) {
3407
3658
  updateSessionStatusFromEvent,
3408
3659
  staleActiveRun,
3409
3660
  canFinalizeActiveRun,
3661
+ activeRunCompletion,
3662
+ sessionStatusType,
3410
3663
  sessionIsIdle,
3411
3664
  markSessionStatus,
3412
3665
  clearSessionStatus
@@ -3465,6 +3718,7 @@ function createLoopExecutor(options = {}) {
3465
3718
  clearTimeout(active.timer);
3466
3719
  compactionRuntime.clearForActiveRun(sessionID, active);
3467
3720
  activeRuns.delete(sessionID);
3721
+ retryRuns.delete(sessionID);
3468
3722
  }
3469
3723
  function disposeSession(sessionID) {
3470
3724
  clearActiveRun(sessionID);
@@ -3472,30 +3726,106 @@ function createLoopExecutor(options = {}) {
3472
3726
  compactionRuntime.clear(sessionID);
3473
3727
  clearSessionStatus(sessionID);
3474
3728
  }
3729
+ async function persistInfrastructureRefund(directory, sessionID, active, input = {}) {
3730
+ const state = await readState2(directory, sessionID);
3731
+ const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
3732
+ if (!job)
3733
+ return { job: undefined, delayMs: busyRetryMs };
3734
+ refundInfrastructureRun(job, {
3735
+ runCount: active.job?.runCount,
3736
+ previousLastRunAt: active.previousLastRunAt,
3737
+ disabledByMaxRuns: active.disabledByMaxRuns
3738
+ }, {
3739
+ reason: input.reason,
3740
+ error: input.error,
3741
+ now: now2()
3742
+ });
3743
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3744
+ await writeState2(directory, sessionID, state);
3745
+ const delayMs = networkRetryDelayMs(job.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
3746
+ return { job, delayMs };
3747
+ }
3475
3748
  async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
3476
3749
  const active = activeRuns.get(sessionID);
3477
3750
  if (!active || active.jobId !== jobId || active.runToken !== runToken)
3478
3751
  return false;
3752
+ const transient = isTransientNetworkError(error);
3753
+ const snapshot = active;
3479
3754
  clearActiveRun(sessionID);
3480
3755
  clearSessionStatus(sessionID);
3481
3756
  const message = errorMessage(error);
3482
- const state = await readState2(directory, sessionID);
3483
- const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3757
+ let state = await readState2(directory, sessionID);
3758
+ let job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3759
+ let retryDelay = busyRetryMs;
3484
3760
  if (job) {
3485
- job.failureCount = (job.failureCount || 0) + 1;
3486
- job.lastFailureReason = "dispatch_failed";
3487
- job.lastDispatchFailure = message.slice(0, 4000);
3488
- job.lastDispatchFailureAt = now2();
3489
- if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
3490
- job.paused = true;
3491
- await notifyJob2(directory, job, "dispatch_failed");
3761
+ if (transient) {
3762
+ const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
3763
+ reason: "dispatch_failed_transient",
3764
+ error
3765
+ });
3766
+ job = refunded.job;
3767
+ retryDelay = refunded.delayMs;
3768
+ state = await readState2(directory, sessionID);
3769
+ } else {
3770
+ job.failureCount = (job.failureCount || 0) + 1;
3771
+ job.lastFailureReason = "dispatch_failed";
3772
+ job.lastDispatchFailure = message.slice(0, 4000);
3773
+ job.lastDispatchFailureAt = now2();
3774
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
3775
+ job.paused = true;
3776
+ await notifyJob2(directory, job, "dispatch_failed");
3777
+ }
3778
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3779
+ await writeState2(directory, sessionID, state);
3492
3780
  }
3493
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3494
- await writeState2(directory, sessionID, state);
3495
3781
  }
3496
- await appendLoopLog2(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message });
3497
- await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
3498
- await scheduleDueWork(directory, client, sessionID, busyRetryMs);
3782
+ await appendLoopLog2(directory, transient ? "network-dispatch-error" : "dispatch-error", {
3783
+ sessionID,
3784
+ job: job?.name || jobId,
3785
+ error: message,
3786
+ ...transient ? { retryInMs: retryDelay } : {}
3787
+ });
3788
+ if (transient) {
3789
+ await toast2(client, `Loop network dispatch failed; retrying when the session recovers: ${message}`, "warning");
3790
+ } else {
3791
+ await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
3792
+ }
3793
+ await scheduleDueWork(directory, client, sessionID, retryDelay);
3794
+ return true;
3795
+ }
3796
+ async function recoverStuckProviderRetry(directory, client, sessionID) {
3797
+ const active = activeRuns.get(sessionID);
3798
+ if (!active || active.compactionOnly) {
3799
+ retryRuns.delete(sessionID);
3800
+ return false;
3801
+ }
3802
+ const current = retryRuns.get(sessionID);
3803
+ if (!current || current.runToken !== active.runToken) {
3804
+ retryRuns.set(sessionID, { runToken: active.runToken, since: now2() });
3805
+ return false;
3806
+ }
3807
+ if (now2() - current.since < providerRetryWatchdogMs)
3808
+ return false;
3809
+ const snapshot = active;
3810
+ try {
3811
+ if (client?.session?.abort) {
3812
+ await fireSdk2(client, "session.abort provider retry watchdog", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
3813
+ }
3814
+ } catch {}
3815
+ clearActiveRun(sessionID);
3816
+ clearSessionStatus(sessionID);
3817
+ const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
3818
+ reason: "provider_retry_watchdog",
3819
+ error: `OpenCode session.status stayed retry for ${providerRetryWatchdogMs}ms`
3820
+ });
3821
+ await appendLoopLog2(directory, "provider-retry-recovery", {
3822
+ sessionID,
3823
+ job: refunded.job?.name || snapshot.jobId,
3824
+ retryForMs: now2() - current.since,
3825
+ retryInMs: refunded.delayMs
3826
+ });
3827
+ await toast2(client, "Loop provider retry exceeded the watchdog; the Loop-owned turn was released and will retry with backoff.", "warning");
3828
+ await scheduleDueWork(directory, client, sessionID, refunded.delayMs);
3499
3829
  return true;
3500
3830
  }
3501
3831
  async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
@@ -3504,6 +3834,7 @@ function createLoopExecutor(options = {}) {
3504
3834
  return;
3505
3835
  if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
3506
3836
  return false;
3837
+ const completion = await activeRunCompletion(directory, client, sessionID, active);
3507
3838
  const recoveredStale = staleActiveRun(sessionID);
3508
3839
  if (active.compactionOnly) {
3509
3840
  const pending = compactionRuntime.getPending(sessionID);
@@ -3524,6 +3855,30 @@ function createLoopExecutor(options = {}) {
3524
3855
  if (!job)
3525
3856
  return;
3526
3857
  job.lastFinishedAt = now2();
3858
+ if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
3859
+ const empty = refundEmptyAssistantTurn(job, active, now2());
3860
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3861
+ await writeState2(directory, sessionID, state);
3862
+ await appendLoopLog2(directory, "empty-assistant-turn", {
3863
+ sessionID,
3864
+ job: job.name || job.id,
3865
+ count: empty.count,
3866
+ limit: empty.limit,
3867
+ paused: empty.paused,
3868
+ refunded: true
3869
+ });
3870
+ if (empty.paused) {
3871
+ await notifyJob2(directory, job, "empty_turn");
3872
+ await toast2(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning");
3873
+ await scheduleDueWork(directory, client, sessionID);
3874
+ } else {
3875
+ await toast2(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning");
3876
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs);
3877
+ }
3878
+ return true;
3879
+ }
3880
+ if (completion === "completed")
3881
+ clearEmptyAssistantTurnStreak(job);
3527
3882
  if (recoveredStale) {
3528
3883
  await appendLoopLog2(directory, "active-stale-recovery", {
3529
3884
  sessionID,
@@ -3546,14 +3901,24 @@ function createLoopExecutor(options = {}) {
3546
3901
  }
3547
3902
  runLocks.set(sessionID, now2());
3548
3903
  let job;
3904
+ let previousLastRunAt = 0;
3905
+ let disabledByMaxRuns = false;
3549
3906
  try {
3550
3907
  await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
3551
- if (!await sessionIsIdle(client, sessionID, directory)) {
3908
+ const statusType = await sessionStatusType(client, sessionID, directory);
3909
+ if (statusType !== "idle") {
3910
+ if (statusType === "retry") {
3911
+ if (await recoverStuckProviderRetry(directory, client, sessionID))
3912
+ return;
3913
+ } else {
3914
+ retryRuns.delete(sessionID);
3915
+ }
3552
3916
  if (runOptions.force)
3553
3917
  await toast2(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
3554
3918
  await reschedule(busyRetryMs);
3555
3919
  return;
3556
3920
  }
3921
+ retryRuns.delete(sessionID);
3557
3922
  const active = activeRuns.get(sessionID);
3558
3923
  const activeAge = active ? now2() - (active.startedAt || 0) : 0;
3559
3924
  const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || activeGuardMs;
@@ -3607,9 +3972,11 @@ function createLoopExecutor(options = {}) {
3607
3972
  if (runNowRequested)
3608
3973
  delete job.runNowRequestedAt;
3609
3974
  job.watchTriggered = false;
3975
+ previousLastRunAt = Number(job.lastRunAt || 0);
3610
3976
  job.lastRunAt = now2();
3611
3977
  job.runCount = (job.runCount || 0) + 1;
3612
- if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
3978
+ disabledByMaxRuns = job.maxRuns > 0 && job.runCount >= job.maxRuns;
3979
+ if (disabledByMaxRuns) {
3613
3980
  job.enabled = false;
3614
3981
  await notifyJob2(directory, job, "max_runs_reached");
3615
3982
  }
@@ -3648,7 +4015,9 @@ function createLoopExecutor(options = {}) {
3648
4015
  startedAt: now2(),
3649
4016
  timer,
3650
4017
  runToken,
3651
- compactionAction: result.compaction === true
4018
+ compactionAction: result.compaction === true,
4019
+ previousLastRunAt,
4020
+ disabledByMaxRuns
3652
4021
  });
3653
4022
  if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
3654
4023
  await compactionRuntime.finalize(directory, client, sessionID);
@@ -3663,6 +4032,24 @@ function createLoopExecutor(options = {}) {
3663
4032
  await reschedule(busyRetryMs);
3664
4033
  } catch (error) {
3665
4034
  clearActiveRun(sessionID);
4035
+ if (isTransientNetworkError(error) && job) {
4036
+ const stateAfterFailure = await readState2(directory, sessionID);
4037
+ const persisted = (stateAfterFailure.jobs || []).find((candidate) => candidate.id === job.id);
4038
+ if (persisted) {
4039
+ refundInfrastructureRun(persisted, {
4040
+ runCount: job.runCount,
4041
+ previousLastRunAt,
4042
+ disabledByMaxRuns
4043
+ }, { reason: "action_dispatch_transient", error, now: now2() });
4044
+ stateAfterFailure.jobs = (stateAfterFailure.jobs || []).map((candidate) => candidate.id === persisted.id ? persisted : candidate);
4045
+ await writeState2(directory, sessionID, stateAfterFailure);
4046
+ const delayMs = networkRetryDelayMs(persisted.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
4047
+ await appendLoopLog2(directory, "network-action-error", { sessionID, job: persisted.name || persisted.id, error: errorMessage(error), retryInMs: delayMs });
4048
+ await toast2(client, "Loop action hit a transient network failure; the logical run was refunded and will retry.", "warning");
4049
+ await reschedule(delayMs);
4050
+ return;
4051
+ }
4052
+ }
3666
4053
  await toast2(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3667
4054
  await appendLoopLog2(directory, "error", {
3668
4055
  sessionID,
@@ -3680,9 +4067,11 @@ function createLoopExecutor(options = {}) {
3680
4067
  clearActiveRun,
3681
4068
  disposeSession,
3682
4069
  recoverActiveDispatchFailure,
4070
+ recoverStuckProviderRetry,
3683
4071
  finalizeActiveRun,
3684
4072
  fireAction,
3685
4073
  maybeRunDueJobs,
4074
+ sessionStatusType,
3686
4075
  sessionIsIdle,
3687
4076
  updateSessionStatusFromEvent,
3688
4077
  markSessionStatus,