@bybrawe/opencode-loop 0.5.34 → 0.5.35

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
@@ -1033,13 +1033,55 @@ var CONTINUATION_SHORTHANDS = new Set([
1033
1033
  "devam et.",
1034
1034
  "devam et bakal\u0131m"
1035
1035
  ]);
1036
+ var COMPLETION_BOUNDED_PATTERNS = [
1037
+ /\bbitene kadar\b/i,
1038
+ /\b(?:tamamen|komple) projeyi bitir\b/i,
1039
+ /\bi\u015Fi bitir\b/i,
1040
+ /\buntil (?:it(?:'s| is) )?(?:done|complete|completed|finished)\b/i,
1041
+ /\bfinish (?:the )?(?:project|task|work)\b/i,
1042
+ /\bkeep going until\b/i
1043
+ ];
1044
+ var TERMINAL_COMPLETION_PATTERNS = [
1045
+ /proje tamamland[\u0131i](?=\s|[.,;:!\u2014-]|$)/i,
1046
+ /\bproject (?:is )?(?:complete|completed|finished|done)\b/i,
1047
+ /\b(?:task|work) (?:is )?(?:complete|completed|finished|done)\b/i
1048
+ ];
1049
+ var TERMINAL_NO_WORK_PATTERNS = [
1050
+ /yap[\u0131i]lacak (?:ba\u015Fka )?i\u015F yok(?=\s|[.,;:!\u2014-]|$)/i,
1051
+ /ba\u015Fka (?:bir )?i\u015F (?:kalmad[\u0131i]|yok)(?=\s|[.,;:!\u2014-]|$)/i,
1052
+ /\bnothing (?:else )?left to do\b/i,
1053
+ /\bno (?:more|remaining) work\b/i,
1054
+ /\bno known (?:bugs|issues)\b/i,
1055
+ /\bzero known (?:bugs|issues)\b/i
1056
+ ];
1057
+ var NEXT_WORK_PATTERNS = [
1058
+ /\bnext(?: step| task)?\b/i,
1059
+ /s[\u0131i]radaki(?=\s|[.,;:!\u2014-]|$)/i,
1060
+ /sonraki(?=\s|[.,;:!\u2014-]|$)/i,
1061
+ /kalan (?:i\u015F|i\u015Fler|todo|ad\u0131m)(?=\s|[.,;:!\u2014-]|$)/i,
1062
+ /\bremaining (?:work|task|todo|step)/i,
1063
+ /devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
1064
+ ];
1036
1065
  function isContinuationShorthand(value) {
1037
1066
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1038
1067
  }
1068
+ function isCompletionBoundedContinuation(value) {
1069
+ const text = String(value || "").trim();
1070
+ return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
1071
+ }
1072
+ function isTerminalNoWorkReply(value) {
1073
+ const text = String(value || "").trim();
1074
+ if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
1075
+ return false;
1076
+ const completed = TERMINAL_COMPLETION_PATTERNS.some((pattern) => pattern.test(text));
1077
+ const noWork = TERMINAL_NO_WORK_PATTERNS.some((pattern) => pattern.test(text));
1078
+ return completed && noWork;
1079
+ }
1039
1080
  function continuationProjectInstruction(value) {
1040
- if (!isContinuationShorthand(value))
1081
+ if (!isContinuationShorthand(value) && !isCompletionBoundedContinuation(value))
1041
1082
  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.";
1083
+ 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." : "";
1084
+ 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
1085
  }
1044
1086
 
1045
1087
  // src/source/core/jobs.js
@@ -2865,7 +2907,9 @@ function createSessionStatusRuntime(options = {}) {
2865
2907
  if (directory)
2866
2908
  argsList.push({ query: { directory } }, { directory }, { workspace: directory });
2867
2909
  argsList.push({});
2910
+ let attempted = false;
2868
2911
  for (const args of argsList) {
2912
+ attempted = true;
2869
2913
  try {
2870
2914
  const result = await statusMethod.call(client.session, args);
2871
2915
  const error = sdkError(result);
@@ -2895,7 +2939,7 @@ function createSessionStatusRuntime(options = {}) {
2895
2939
  return { type: "idle", source: "sdk" };
2896
2940
  } catch {}
2897
2941
  }
2898
- return;
2942
+ return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
2899
2943
  }
2900
2944
  async function canFinalizeActiveRun(directory, client, sessionID, active, options2 = {}) {
2901
2945
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
@@ -2907,24 +2951,28 @@ function createSessionStatusRuntime(options = {}) {
2907
2951
  return true;
2908
2952
  if (!options2.requireIdle)
2909
2953
  return completion === "unknown" && staleActiveRun(sessionID);
2954
+ const cached = sessionStatuses.get(sessionID);
2955
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2956
+ const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0);
2910
2957
  const live = await readLiveSessionStatus(client, sessionID, directory);
2911
2958
  if (live?.type === "idle")
2912
2959
  return true;
2960
+ if (live?.type === "unknown" && cachedIdleAfterRun) {
2961
+ return true;
2962
+ }
2913
2963
  if (live?.type) {
2914
- if ((live.type === "busy" || live.type === "retry") && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2964
+ if (live.type === "busy" && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2915
2965
  return true;
2916
2966
  return false;
2917
2967
  }
2918
2968
  if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2919
2969
  return true;
2920
- const cached = sessionStatuses.get(sessionID);
2921
- const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2922
- return cached === "idle" && seenAt > (active.startedAt || 0);
2970
+ return cachedIdleAfterRun;
2923
2971
  }
2924
2972
  async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
2925
2973
  if (activeRuns.has(sessionID))
2926
2974
  return false;
2927
- if (liveType !== "busy" && liveType !== "retry")
2975
+ if (liveType !== "busy")
2928
2976
  return false;
2929
2977
  if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
2930
2978
  return false;
@@ -2952,6 +3000,11 @@ function createSessionStatusRuntime(options = {}) {
2952
3000
  if (cached && now2() - seenAt < sessionStatusCacheMs)
2953
3001
  return cached;
2954
3002
  const live = await readLiveSessionStatus(client, sessionID, directory);
3003
+ if (live?.type === "unknown") {
3004
+ const conservative = cached === "retry" ? "retry" : "busy";
3005
+ markSessionStatus(sessionID, conservative);
3006
+ return conservative;
3007
+ }
2955
3008
  if (live?.type) {
2956
3009
  if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
2957
3010
  return "idle";
@@ -2959,13 +3012,15 @@ function createSessionStatusRuntime(options = {}) {
2959
3012
  const active = activeRuns.get(sessionID);
2960
3013
  if (active) {
2961
3014
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
2962
- if (completion === "completed" || completion === "unknown" && staleActiveRun(sessionID)) {
3015
+ if (completion === "completed" || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
2963
3016
  markSessionStatus(sessionID, "idle");
2964
- await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
3017
+ const logDetails = {
2965
3018
  sessionID,
2966
3019
  job: active.job?.name || active.jobId,
2967
- startedAt: active.startedAt
2968
- });
3020
+ startedAt: active.startedAt,
3021
+ ...completion === "completed" ? {} : { staleStatus: live.type }
3022
+ };
3023
+ await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", logDetails);
2969
3024
  return "idle";
2970
3025
  }
2971
3026
  }
@@ -3181,6 +3236,58 @@ ${prompt}`;
3181
3236
  return { fireAction };
3182
3237
  }
3183
3238
 
3239
+ // src/source/runtime/terminal-guard.js
3240
+ function messageText(message) {
3241
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
3242
+ const fromParts = parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join(`
3243
+ `).trim();
3244
+ if (fromParts)
3245
+ return fromParts;
3246
+ const info = message?.info || message || {};
3247
+ for (const value of [info.text, info.content, info.summary]) {
3248
+ if (typeof value === "string" && value.trim())
3249
+ return value.trim();
3250
+ }
3251
+ return "";
3252
+ }
3253
+ async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
3254
+ if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
3255
+ return { job, terminal: false, pausedNow: false };
3256
+ }
3257
+ const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
3258
+ if (!messages)
3259
+ return { job, terminal: false, pausedNow: false };
3260
+ const tail = orderedSessionMessages(messages).at(-1);
3261
+ const info = tail?.info || tail || {};
3262
+ if (info.role !== "assistant")
3263
+ return { job, terminal: false, pausedNow: false };
3264
+ const completed = Number(info?.time?.completed || 0);
3265
+ const created = Number(info?.time?.created || 0);
3266
+ const runStarted = Number(job?.lastRunAt || 0);
3267
+ if (!Number.isFinite(completed) || completed <= 0)
3268
+ return { job, terminal: false, pausedNow: false };
3269
+ if (runStarted > 0 && completed < runStarted && (!Number.isFinite(created) || created < runStarted)) {
3270
+ return { job, terminal: false, pausedNow: false };
3271
+ }
3272
+ const text = messageText(tail);
3273
+ const terminal = isTerminalNoWorkReply(text);
3274
+ if (!terminal) {
3275
+ if (job.terminalNoWorkCount)
3276
+ job.terminalNoWorkCount = 0;
3277
+ return { job, terminal: false, pausedNow: false, text };
3278
+ }
3279
+ job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
3280
+ job.lastTerminalNoWorkAt = Date.now();
3281
+ job.lastTerminalNoWorkSummary = text.slice(0, 1000);
3282
+ const threshold = Math.max(2, Number(options.threshold) || 2);
3283
+ const pausedNow = job.terminalNoWorkCount >= threshold && !job.paused;
3284
+ if (pausedNow) {
3285
+ job.paused = true;
3286
+ job.lastFailureReason = "terminal_no_work";
3287
+ }
3288
+ return { job, terminal: true, pausedNow, text };
3289
+ }
3290
+
3184
3291
  // src/source/runtime/run-finalization.js
3185
3292
  function requireFunction8(value, label) {
3186
3293
  if (typeof value !== "function")
@@ -3200,7 +3307,14 @@ function createRunFinalizationRuntime(options = {}) {
3200
3307
  const toast2 = typeof options.toast === "function" ? options.toast : toast;
3201
3308
  const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
3202
3309
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3310
+ const applyTerminalContinuationGuard2 = typeof options.applyTerminalContinuationGuard === "function" ? options.applyTerminalContinuationGuard : applyTerminalContinuationGuard;
3203
3311
  async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
3312
+ if (job.infrastructureFailureCount) {
3313
+ job.infrastructureFailureCount = 0;
3314
+ job.lastInfrastructureFailure = "";
3315
+ job.lastInfrastructureError = "";
3316
+ job.lastInfrastructureFailureAt = 0;
3317
+ }
3204
3318
  if (job.verifyCommand) {
3205
3319
  const verify = await runShellCommand2(job.verifyCommand, directory, job.timeoutMs || 300000);
3206
3320
  job.lastVerifyAt = now2();
@@ -3263,6 +3377,18 @@ exit=` + postrun.code + `
3263
3377
  job = await runGoalChecks(directory, sessionID, job, client);
3264
3378
  job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob);
3265
3379
  }
3380
+ const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
3381
+ job = terminal.job;
3382
+ if (terminal.pausedNow) {
3383
+ await appendLoopLog2(directory, "terminal-no-work", {
3384
+ sessionID,
3385
+ job: job.name || job.id,
3386
+ count: job.terminalNoWorkCount,
3387
+ summary: String(terminal.text || "").slice(0, 1000)
3388
+ });
3389
+ await notifyJob2(directory, job, "terminal_no_work");
3390
+ await toast2(client, "Loop paused: completion-bounded task reported complete with no work remaining twice.", "success");
3391
+ }
3266
3392
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
3267
3393
  await writeState2(directory, sessionID, state);
3268
3394
  if (isGoalJob(job))
@@ -3359,9 +3485,58 @@ exit=` + preflight.code + `
3359
3485
  return { dueJobs: dueJobs2, admitJob };
3360
3486
  }
3361
3487
 
3488
+ // src/source/runtime/network-recovery.js
3489
+ var TRANSIENT_NETWORK_PATTERNS = [
3490
+ /\b(?:408|425|429|500|502|503|504|524)\b/i,
3491
+ /rate[\s_-]?limit|too many requests|overloaded|service[\s_-]?unavailable|provider[_ -]?unavailable/i,
3492
+ /terminated|fetch failed|failed to fetch|network[\s_-]?error|network connection lost/i,
3493
+ /connection (?:error|refused|lost)|socket (?:hang up|connection was closed)|reset before headers/i,
3494
+ /\b(?:enotfound|eai_again|econnrefused|econnreset|etimedout|ehostunreach|enetunreach|epipe)\b/i,
3495
+ /\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
3496
+ /\btimeout(?:error)?\b/i
3497
+ ];
3498
+ function errorText(value) {
3499
+ if (value instanceof Error)
3500
+ return `${value.name}: ${value.message}`;
3501
+ if (typeof value === "string")
3502
+ return value;
3503
+ try {
3504
+ return JSON.stringify(value);
3505
+ } catch {
3506
+ return String(value);
3507
+ }
3508
+ }
3509
+ function isTransientNetworkError(value) {
3510
+ const text = errorText(value);
3511
+ return TRANSIENT_NETWORK_PATTERNS.some((pattern) => pattern.test(text));
3512
+ }
3513
+ function networkRetryDelayMs(attempt, baseMs = 5000, maxMs = 60000) {
3514
+ const safeAttempt = Math.max(1, Math.floor(Number(attempt) || 1));
3515
+ const safeBase = Math.max(1, Math.floor(Number(baseMs) || 5000));
3516
+ const safeMax = Math.max(safeBase, Math.floor(Number(maxMs) || 60000));
3517
+ return Math.min(safeMax, safeBase * 2 ** Math.min(8, safeAttempt - 1));
3518
+ }
3519
+ function refundInfrastructureRun(job, snapshot = {}, input = {}) {
3520
+ const chargedCount = Number(snapshot.runCount ?? job.runCount ?? 0);
3521
+ const currentCount = Number(job.runCount || 0);
3522
+ if (chargedCount > 0 && currentCount >= chargedCount)
3523
+ job.runCount = Math.max(0, currentCount - 1);
3524
+ if (Number.isFinite(Number(snapshot.previousLastRunAt)))
3525
+ job.lastRunAt = Number(snapshot.previousLastRunAt);
3526
+ if (snapshot.disabledByMaxRuns && job.maxRuns > 0 && job.runCount < job.maxRuns)
3527
+ job.enabled = true;
3528
+ job.infrastructureFailureCount = (job.infrastructureFailureCount || 0) + 1;
3529
+ job.lastInfrastructureFailure = String(input.reason || "transient_network_failure").slice(0, 120);
3530
+ job.lastInfrastructureError = errorText(input.error).slice(0, 4000);
3531
+ job.lastInfrastructureFailureAt = Number(input.now || Date.now());
3532
+ return job;
3533
+ }
3534
+
3362
3535
  // src/source/runtime/executor.js
3363
3536
  var DEFAULT_ACTIVE_GUARD_MS = 45000;
3364
3537
  var DEFAULT_BUSY_RETRY_MS2 = 5000;
3538
+ var DEFAULT_PROVIDER_RETRY_WATCHDOG_MS = 2 * 60000;
3539
+ var DEFAULT_NETWORK_RETRY_MAX_MS = 60000;
3365
3540
  function requireFunction10(value, label) {
3366
3541
  if (typeof value !== "function")
3367
3542
  throw new TypeError(`createLoopExecutor requires ${label}`);
@@ -3393,8 +3568,11 @@ function createLoopExecutor(options = {}) {
3393
3568
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3394
3569
  const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
3395
3570
  const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
3571
+ const providerRetryWatchdogMs = Number.isFinite(Number(options.providerRetryWatchdogMs)) && Number(options.providerRetryWatchdogMs) > 0 ? Number(options.providerRetryWatchdogMs) : DEFAULT_PROVIDER_RETRY_WATCHDOG_MS;
3572
+ const networkRetryMaxMs = Number.isFinite(Number(options.networkRetryMaxMs)) && Number(options.networkRetryMaxMs) > 0 ? Number(options.networkRetryMaxMs) : DEFAULT_NETWORK_RETRY_MAX_MS;
3396
3573
  const activeRuns = new Map;
3397
3574
  const runLocks = new Map;
3575
+ const retryRuns = new Map;
3398
3576
  const statusRuntime = createSessionStatusRuntime({
3399
3577
  activeRuns,
3400
3578
  appendLoopLog: appendLoopLog2,
@@ -3407,6 +3585,7 @@ function createLoopExecutor(options = {}) {
3407
3585
  updateSessionStatusFromEvent,
3408
3586
  staleActiveRun,
3409
3587
  canFinalizeActiveRun,
3588
+ sessionStatusType,
3410
3589
  sessionIsIdle,
3411
3590
  markSessionStatus,
3412
3591
  clearSessionStatus
@@ -3465,6 +3644,7 @@ function createLoopExecutor(options = {}) {
3465
3644
  clearTimeout(active.timer);
3466
3645
  compactionRuntime.clearForActiveRun(sessionID, active);
3467
3646
  activeRuns.delete(sessionID);
3647
+ retryRuns.delete(sessionID);
3468
3648
  }
3469
3649
  function disposeSession(sessionID) {
3470
3650
  clearActiveRun(sessionID);
@@ -3472,30 +3652,106 @@ function createLoopExecutor(options = {}) {
3472
3652
  compactionRuntime.clear(sessionID);
3473
3653
  clearSessionStatus(sessionID);
3474
3654
  }
3655
+ async function persistInfrastructureRefund(directory, sessionID, active, input = {}) {
3656
+ const state = await readState2(directory, sessionID);
3657
+ const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
3658
+ if (!job)
3659
+ return { job: undefined, delayMs: busyRetryMs };
3660
+ refundInfrastructureRun(job, {
3661
+ runCount: active.job?.runCount,
3662
+ previousLastRunAt: active.previousLastRunAt,
3663
+ disabledByMaxRuns: active.disabledByMaxRuns
3664
+ }, {
3665
+ reason: input.reason,
3666
+ error: input.error,
3667
+ now: now2()
3668
+ });
3669
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3670
+ await writeState2(directory, sessionID, state);
3671
+ const delayMs = networkRetryDelayMs(job.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
3672
+ return { job, delayMs };
3673
+ }
3475
3674
  async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
3476
3675
  const active = activeRuns.get(sessionID);
3477
3676
  if (!active || active.jobId !== jobId || active.runToken !== runToken)
3478
3677
  return false;
3678
+ const transient = isTransientNetworkError(error);
3679
+ const snapshot = active;
3479
3680
  clearActiveRun(sessionID);
3480
3681
  clearSessionStatus(sessionID);
3481
3682
  const message = errorMessage(error);
3482
- const state = await readState2(directory, sessionID);
3483
- const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3683
+ let state = await readState2(directory, sessionID);
3684
+ let job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3685
+ let retryDelay = busyRetryMs;
3484
3686
  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");
3687
+ if (transient) {
3688
+ const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
3689
+ reason: "dispatch_failed_transient",
3690
+ error
3691
+ });
3692
+ job = refunded.job;
3693
+ retryDelay = refunded.delayMs;
3694
+ state = await readState2(directory, sessionID);
3695
+ } else {
3696
+ job.failureCount = (job.failureCount || 0) + 1;
3697
+ job.lastFailureReason = "dispatch_failed";
3698
+ job.lastDispatchFailure = message.slice(0, 4000);
3699
+ job.lastDispatchFailureAt = now2();
3700
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
3701
+ job.paused = true;
3702
+ await notifyJob2(directory, job, "dispatch_failed");
3703
+ }
3704
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3705
+ await writeState2(directory, sessionID, state);
3492
3706
  }
3493
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3494
- await writeState2(directory, sessionID, state);
3495
3707
  }
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);
3708
+ await appendLoopLog2(directory, transient ? "network-dispatch-error" : "dispatch-error", {
3709
+ sessionID,
3710
+ job: job?.name || jobId,
3711
+ error: message,
3712
+ ...transient ? { retryInMs: retryDelay } : {}
3713
+ });
3714
+ if (transient) {
3715
+ await toast2(client, `Loop network dispatch failed; retrying when the session recovers: ${message}`, "warning");
3716
+ } else {
3717
+ await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
3718
+ }
3719
+ await scheduleDueWork(directory, client, sessionID, retryDelay);
3720
+ return true;
3721
+ }
3722
+ async function recoverStuckProviderRetry(directory, client, sessionID) {
3723
+ const active = activeRuns.get(sessionID);
3724
+ if (!active || active.compactionOnly) {
3725
+ retryRuns.delete(sessionID);
3726
+ return false;
3727
+ }
3728
+ const current = retryRuns.get(sessionID);
3729
+ if (!current || current.runToken !== active.runToken) {
3730
+ retryRuns.set(sessionID, { runToken: active.runToken, since: now2() });
3731
+ return false;
3732
+ }
3733
+ if (now2() - current.since < providerRetryWatchdogMs)
3734
+ return false;
3735
+ const snapshot = active;
3736
+ try {
3737
+ if (client?.session?.abort) {
3738
+ await fireSdk2(client, "session.abort provider retry watchdog", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
3739
+ }
3740
+ } catch {}
3741
+ clearActiveRun(sessionID);
3742
+ clearSessionStatus(sessionID);
3743
+ const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
3744
+ reason: "provider_retry_watchdog",
3745
+ error: `OpenCode session.status stayed retry for ${providerRetryWatchdogMs}ms`
3746
+ });
3747
+ await appendLoopLog2(directory, "provider-retry-recovery", {
3748
+ sessionID,
3749
+ job: refunded.job?.name || snapshot.jobId,
3750
+ retryForMs: now2() - current.since,
3751
+ retryInMs: refunded.delayMs
3752
+ });
3753
+ await toast2(client, "Loop provider retry exceeded the watchdog; the Loop-owned turn was released and will retry with backoff.", "warning");
3754
+ await scheduleDueWork(directory, client, sessionID, refunded.delayMs);
3499
3755
  return true;
3500
3756
  }
3501
3757
  async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
@@ -3546,14 +3802,24 @@ function createLoopExecutor(options = {}) {
3546
3802
  }
3547
3803
  runLocks.set(sessionID, now2());
3548
3804
  let job;
3805
+ let previousLastRunAt = 0;
3806
+ let disabledByMaxRuns = false;
3549
3807
  try {
3550
3808
  await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
3551
- if (!await sessionIsIdle(client, sessionID, directory)) {
3809
+ const statusType = await sessionStatusType(client, sessionID, directory);
3810
+ if (statusType !== "idle") {
3811
+ if (statusType === "retry") {
3812
+ if (await recoverStuckProviderRetry(directory, client, sessionID))
3813
+ return;
3814
+ } else {
3815
+ retryRuns.delete(sessionID);
3816
+ }
3552
3817
  if (runOptions.force)
3553
3818
  await toast2(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
3554
3819
  await reschedule(busyRetryMs);
3555
3820
  return;
3556
3821
  }
3822
+ retryRuns.delete(sessionID);
3557
3823
  const active = activeRuns.get(sessionID);
3558
3824
  const activeAge = active ? now2() - (active.startedAt || 0) : 0;
3559
3825
  const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || activeGuardMs;
@@ -3607,9 +3873,11 @@ function createLoopExecutor(options = {}) {
3607
3873
  if (runNowRequested)
3608
3874
  delete job.runNowRequestedAt;
3609
3875
  job.watchTriggered = false;
3876
+ previousLastRunAt = Number(job.lastRunAt || 0);
3610
3877
  job.lastRunAt = now2();
3611
3878
  job.runCount = (job.runCount || 0) + 1;
3612
- if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
3879
+ disabledByMaxRuns = job.maxRuns > 0 && job.runCount >= job.maxRuns;
3880
+ if (disabledByMaxRuns) {
3613
3881
  job.enabled = false;
3614
3882
  await notifyJob2(directory, job, "max_runs_reached");
3615
3883
  }
@@ -3648,7 +3916,9 @@ function createLoopExecutor(options = {}) {
3648
3916
  startedAt: now2(),
3649
3917
  timer,
3650
3918
  runToken,
3651
- compactionAction: result.compaction === true
3919
+ compactionAction: result.compaction === true,
3920
+ previousLastRunAt,
3921
+ disabledByMaxRuns
3652
3922
  });
3653
3923
  if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
3654
3924
  await compactionRuntime.finalize(directory, client, sessionID);
@@ -3663,6 +3933,24 @@ function createLoopExecutor(options = {}) {
3663
3933
  await reschedule(busyRetryMs);
3664
3934
  } catch (error) {
3665
3935
  clearActiveRun(sessionID);
3936
+ if (isTransientNetworkError(error) && job) {
3937
+ const stateAfterFailure = await readState2(directory, sessionID);
3938
+ const persisted = (stateAfterFailure.jobs || []).find((candidate) => candidate.id === job.id);
3939
+ if (persisted) {
3940
+ refundInfrastructureRun(persisted, {
3941
+ runCount: job.runCount,
3942
+ previousLastRunAt,
3943
+ disabledByMaxRuns
3944
+ }, { reason: "action_dispatch_transient", error, now: now2() });
3945
+ stateAfterFailure.jobs = (stateAfterFailure.jobs || []).map((candidate) => candidate.id === persisted.id ? persisted : candidate);
3946
+ await writeState2(directory, sessionID, stateAfterFailure);
3947
+ const delayMs = networkRetryDelayMs(persisted.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
3948
+ await appendLoopLog2(directory, "network-action-error", { sessionID, job: persisted.name || persisted.id, error: errorMessage(error), retryInMs: delayMs });
3949
+ await toast2(client, "Loop action hit a transient network failure; the logical run was refunded and will retry.", "warning");
3950
+ await reschedule(delayMs);
3951
+ return;
3952
+ }
3953
+ }
3666
3954
  await toast2(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3667
3955
  await appendLoopLog2(directory, "error", {
3668
3956
  sessionID,
@@ -3680,9 +3968,11 @@ function createLoopExecutor(options = {}) {
3680
3968
  clearActiveRun,
3681
3969
  disposeSession,
3682
3970
  recoverActiveDispatchFailure,
3971
+ recoverStuckProviderRetry,
3683
3972
  finalizeActiveRun,
3684
3973
  fireAction,
3685
3974
  maybeRunDueJobs,
3975
+ sessionStatusType,
3686
3976
  sessionIsIdle,
3687
3977
  updateSessionStatusFromEvent,
3688
3978
  markSessionStatus,
@@ -10,11 +10,62 @@ const CONTINUATION_SHORTHANDS = new Set([
10
10
  "devam et bakalım",
11
11
  ])
12
12
 
13
+ const COMPLETION_BOUNDED_PATTERNS = [
14
+ /\bbitene kadar\b/i,
15
+ /\b(?:tamamen|komple) projeyi bitir\b/i,
16
+ /\bişi bitir\b/i,
17
+ /\buntil (?:it(?:'s| is) )?(?:done|complete|completed|finished)\b/i,
18
+ /\bfinish (?:the )?(?:project|task|work)\b/i,
19
+ /\bkeep going until\b/i,
20
+ ]
21
+
22
+ const TERMINAL_COMPLETION_PATTERNS = [
23
+ // JS \b is ASCII-oriented and does not see Turkish dotless ı as a word
24
+ // character. Use an explicit following delimiter for Turkish terminal forms.
25
+ /proje tamamland[ıi](?=\s|[.,;:!—-]|$)/i,
26
+ /\bproject (?:is )?(?:complete|completed|finished|done)\b/i,
27
+ /\b(?:task|work) (?:is )?(?:complete|completed|finished|done)\b/i,
28
+ ]
29
+
30
+ const TERMINAL_NO_WORK_PATTERNS = [
31
+ /yap[ıi]lacak (?:başka )?iş yok(?=\s|[.,;:!—-]|$)/i,
32
+ /başka (?:bir )?iş (?:kalmad[ıi]|yok)(?=\s|[.,;:!—-]|$)/i,
33
+ /\bnothing (?:else )?left to do\b/i,
34
+ /\bno (?:more|remaining) work\b/i,
35
+ /\bno known (?:bugs|issues)\b/i,
36
+ /\bzero known (?:bugs|issues)\b/i,
37
+ ]
38
+
39
+ const NEXT_WORK_PATTERNS = [
40
+ /\bnext(?: step| task)?\b/i,
41
+ /s[ıi]radaki(?=\s|[.,;:!—-]|$)/i,
42
+ /sonraki(?=\s|[.,;:!—-]|$)/i,
43
+ /kalan (?:iş|işler|todo|adım)(?=\s|[.,;:!—-]|$)/i,
44
+ /\bremaining (?:work|task|todo|step)/i,
45
+ /devam (?:edeceğim|ediyorum|etmek gerek)(?=\s|[.,;:!—-]|$)/i,
46
+ ]
47
+
13
48
  export function isContinuationShorthand(value) {
14
49
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "))
15
50
  }
16
51
 
52
+ export function isCompletionBoundedContinuation(value) {
53
+ const text = String(value || "").trim()
54
+ return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text))
55
+ }
56
+
57
+ export function isTerminalNoWorkReply(value) {
58
+ const text = String(value || "").trim()
59
+ if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text))) return false
60
+ const completed = TERMINAL_COMPLETION_PATTERNS.some((pattern) => pattern.test(text))
61
+ const noWork = TERMINAL_NO_WORK_PATTERNS.some((pattern) => pattern.test(text))
62
+ return completed && noWork
63
+ }
64
+
17
65
  export function continuationProjectInstruction(value) {
18
- if (!isContinuationShorthand(value)) return ""
19
- 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."
66
+ if (!isContinuationShorthand(value) && !isCompletionBoundedContinuation(value)) return ""
67
+ const finish = isCompletionBoundedContinuation(value)
68
+ ? " 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."
69
+ : ""
70
+ 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}`
20
71
  }