@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/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/scripts/network-recovery-test.mjs +218 -0
- package/scripts/session-status-test.mjs +18 -4
- package/src/index.js +319 -29
- package/src/source/core/continuation.js +53 -2
- package/src/source/runtime/executor.js +145 -16
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-finalization.js +28 -0
- package/src/source/runtime/session-status.js +29 -30
- package/src/source/runtime/terminal-guard.js +56 -0
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
3483
|
-
|
|
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
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
job
|
|
3491
|
-
|
|
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"
|
|
3497
|
-
|
|
3498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|