@prevalentware/opencode-goal-plugin 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -34,6 +34,15 @@ var CheckpointSchema = Schema.Struct({
34
34
  summary: Schema.String,
35
35
  timestamp: Schema.Number
36
36
  });
37
+ var PendingAttemptSchema = Schema.Struct({
38
+ id: Schema.String,
39
+ reservedAt: Schema.Number,
40
+ started: Schema.Boolean,
41
+ delivered: Schema.Boolean,
42
+ committed: Schema.Boolean,
43
+ armNoProgress: Schema.Boolean,
44
+ previousLastContinuationAt: Schema.NullOr(Schema.Number)
45
+ });
37
46
  var GoalSchema = Schema.Struct({
38
47
  sessionID: Schema.String,
39
48
  objective: Schema.String,
@@ -50,6 +59,7 @@ var GoalSchema = Schema.Struct({
50
59
  autoTurns: Schema.Number,
51
60
  lastContinuationAt: NullableNumber,
52
61
  continuationFailures: Schema.optionalWith(Schema.Number, { default: () => 0 }),
62
+ pendingAttempt: Schema.optionalWith(Schema.NullOr(PendingAttemptSchema), { default: () => null }),
53
63
  lastStatus: Schema.optionalWith(NullableString, { default: () => null }),
54
64
  maxAutoTurns: Schema.optionalWith(NullableNumber, { default: () => null }),
55
65
  maxDurationSeconds: Schema.optionalWith(NullableNumber, { default: () => null }),
@@ -172,6 +182,8 @@ function normalizeGoal(goal) {
172
182
  goal.lastAssistantMessageID ??= "";
173
183
  goal.lastPromptAgent ??= null;
174
184
  goal.awaitingContinuationProgress = goal.awaitingContinuationProgress === true;
185
+ goal.lastContinuationAt = typeof goal.lastContinuationAt === "number" && Number.isFinite(goal.lastContinuationAt) ? Math.floor(goal.lastContinuationAt >= 1000000000000 ? goal.lastContinuationAt / 1000 : goal.lastContinuationAt) : null;
186
+ goal.pendingAttempt = normalizePendingAttempt(goal.pendingAttempt);
175
187
  goal.continuationBaselineMessageID ??= "";
176
188
  goal.continuationBaselineSummary ??= "";
177
189
  goal.noProgressTurns = nonNegativeInteger(goal.noProgressTurns, 0);
@@ -184,6 +196,22 @@ function normalizeGoal(goal) {
184
196
  goal.stopReason ??= null;
185
197
  return goal;
186
198
  }
199
+ function normalizePendingAttempt(attempt) {
200
+ if (!attempt || typeof attempt !== "object")
201
+ return null;
202
+ return {
203
+ id: typeof attempt.id === "string" && attempt.id ? attempt.id : randomId(),
204
+ reservedAt: typeof attempt.reservedAt === "number" && Number.isFinite(attempt.reservedAt) ? attempt.reservedAt : Date.now(),
205
+ started: attempt.started === true,
206
+ delivered: attempt.delivered === true,
207
+ committed: attempt.committed === true,
208
+ armNoProgress: attempt.armNoProgress !== false,
209
+ previousLastContinuationAt: typeof attempt.previousLastContinuationAt === "number" && Number.isFinite(attempt.previousLastContinuationAt) ? attempt.previousLastContinuationAt : null
210
+ };
211
+ }
212
+ function randomId() {
213
+ return `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
214
+ }
187
215
  function normalizeCreateOptions(input) {
188
216
  if (typeof input === "number" || input === null) {
189
217
  return {
@@ -262,11 +290,19 @@ function snapshot(goal) {
262
290
  sampledAt
263
291
  };
264
292
  }
293
+ function snapshotInternal(goal) {
294
+ return { ...snapshot(goal), pendingAttempt: goal.pendingAttempt };
295
+ }
265
296
  async function getGoal(sessionID) {
266
297
  const state = await readState();
267
298
  const goal = state.goals[sessionID];
268
299
  return goal ? snapshot(goal) : null;
269
300
  }
301
+ async function getGoalInternal(sessionID) {
302
+ const state = await readState();
303
+ const goal = state.goals[sessionID];
304
+ return goal ? snapshotInternal(goal) : null;
305
+ }
270
306
  async function createGoal(sessionID, objective, options) {
271
307
  const value = validateObjective(objective);
272
308
  const normalizedOptions = normalizeCreateOptions(options);
@@ -293,6 +329,7 @@ async function createGoal(sessionID, objective, options) {
293
329
  autoTurns: 0,
294
330
  lastContinuationAt: null,
295
331
  continuationFailures: 0,
332
+ pendingAttempt: null,
296
333
  lastStatus: paused ? "Goal recorded from Plan mode; execution paused until resumed from Build mode." : "Goal set.",
297
334
  maxAutoTurns: normalizedOptions.maxAutoTurns,
298
335
  maxDurationSeconds: normalizedOptions.maxDurationSeconds,
@@ -336,6 +373,11 @@ async function updateGoalObjective(sessionID, objective, status = "active", opti
336
373
  goal.closedAt = null;
337
374
  goal.stopReason = planModePause ? PLAN_MODE_STOP_REASON : null;
338
375
  goal.budgetWrapupSent = false;
376
+ if (goal.status === "active") {
377
+ goal.continuationFailures = 0;
378
+ goal.pendingAttempt = null;
379
+ goal.awaitingContinuationProgress = false;
380
+ }
339
381
  if (agent)
340
382
  goal.lastPromptAgent = agent;
341
383
  goal.lastStatus = planModePause ? "Goal objective updated; execution paused while the session is in Plan mode." : goal.status === "active" ? "Goal objective updated and resumed." : "Goal objective updated and paused.";
@@ -387,6 +429,7 @@ async function setGoalStatus(sessionID, status, agent) {
387
429
  goal.updatedAt = nowSeconds();
388
430
  goal.lastAccountedAt = status === "active" ? goal.updatedAt : null;
389
431
  goal.continuationFailures = status === "active" ? 0 : goal.continuationFailures;
432
+ goal.pendingAttempt = status === "active" ? null : goal.pendingAttempt;
390
433
  goal.noProgressTurns = status === "active" ? 0 : goal.noProgressTurns;
391
434
  goal.stopReason = status === "active" ? null : "paused";
392
435
  goal.budgetWrapupSent = status === "active" ? false : goal.budgetWrapupSent;
@@ -462,6 +505,7 @@ async function recordAssistantProgress(sessionID, input) {
462
505
  const threshold = positiveIntegerOrNull(input.noProgressTokenThreshold) ?? goal.noProgressTokenThreshold;
463
506
  const maxNoProgressTurns = positiveIntegerOrNull(input.maxNoProgressTurns) ?? goal.maxNoProgressTurns;
464
507
  const summary = summarizeText(text);
508
+ const substantive = /[\p{L}\p{N}]/u.test(text);
465
509
  const previousSummary = summarizeText(goal.lastAssistantText);
466
510
  const repeatedMessage = Boolean(messageID && messageID === goal.lastAssistantMessageID);
467
511
  const changed = Boolean(summary && summary !== previousSummary);
@@ -471,9 +515,18 @@ async function recordAssistantProgress(sessionID, input) {
471
515
  goal.lastAssistantText = text;
472
516
  if (messageID)
473
517
  goal.lastAssistantMessageID = messageID;
474
- const continuationTurnCompleted = input.evaluateContinuation === true && goal.awaitingContinuationProgress && Boolean(messageID) && messageID !== goal.continuationBaselineMessageID;
518
+ if (substantive && summary && (!repeatedMessage || changed)) {
519
+ const attempt = goal.pendingAttempt;
520
+ if (attempt == null || input.completedAt == null || input.completedAt >= attempt.reservedAt) {
521
+ goal.continuationFailures = 0;
522
+ goal.pendingAttempt = null;
523
+ }
524
+ }
525
+ const attemptForCompletion = goal.pendingAttempt;
526
+ const continuationTurnCompleted = input.evaluateContinuation === true && goal.awaitingContinuationProgress && Boolean(messageID) && messageID !== goal.continuationBaselineMessageID && (input.completedAt == null || attemptForCompletion == null || input.completedAt >= attemptForCompletion.reservedAt);
475
527
  if (continuationTurnCompleted) {
476
528
  goal.awaitingContinuationProgress = false;
529
+ goal.pendingAttempt = null;
477
530
  const lowOutput = outputTokens > 0 && outputTokens < (threshold ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD);
478
531
  const changedSinceContinuation = Boolean(summary && summary !== goal.continuationBaselineSummary);
479
532
  if (lowOutput && !changedSinceContinuation) {
@@ -514,32 +567,84 @@ async function reserveContinuation(sessionID, maxAutoTurns, minIntervalSeconds)
514
567
  if (goal.lastContinuationAt && now - goal.lastContinuationAt < minIntervalSeconds)
515
568
  return null;
516
569
  goal.autoTurns += 1;
570
+ const previousLastContinuationAt = goal.lastContinuationAt;
517
571
  goal.lastContinuationAt = now;
518
572
  goal.continuationBaselineMessageID = goal.lastAssistantMessageID;
519
573
  goal.continuationBaselineSummary = summarizeText(goal.lastAssistantText);
574
+ goal.pendingAttempt = {
575
+ id: randomId(),
576
+ reservedAt: Date.now(),
577
+ started: false,
578
+ delivered: false,
579
+ committed: true,
580
+ armNoProgress: true,
581
+ previousLastContinuationAt
582
+ };
583
+ goal.awaitingContinuationProgress = false;
520
584
  goal.lastStatus = `Auto-continue ${goal.autoTurns} reserved.`;
521
585
  pushHistory(goal, "autoContinue", goal.lastStatus);
522
586
  goal.updatedAt = now;
523
- return snapshot(goal);
587
+ return snapshotInternal(goal);
588
+ });
589
+ }
590
+ async function rollbackContinuationAttempt(sessionID) {
591
+ return mutate((state) => {
592
+ const goal = state.goals[sessionID];
593
+ if (!goal)
594
+ return false;
595
+ const attempt = goal.pendingAttempt;
596
+ if (!attempt || attempt.delivered || !attempt.committed) {
597
+ if (attempt && !attempt.delivered)
598
+ goal.pendingAttempt = null;
599
+ return false;
600
+ }
601
+ goal.autoTurns = Math.max(0, goal.autoTurns - 1);
602
+ goal.lastContinuationAt = attempt.previousLastContinuationAt;
603
+ goal.pendingAttempt = null;
604
+ goal.awaitingContinuationProgress = false;
605
+ goal.lastStatus = "Auto-continue attempt canceled before delivery.";
606
+ goal.updatedAt = nowSeconds();
607
+ return true;
524
608
  });
525
609
  }
526
- async function recordContinuationResult(sessionID, result, maxFailures) {
610
+ async function recordContinuationResult(sessionID, result, maxFailures, options) {
527
611
  return mutate((state) => {
528
612
  const goal = state.goals[sessionID];
529
613
  if (!goal || isClosed(goal.status))
530
- return goal ? snapshot(goal) : null;
614
+ return goal ? snapshotInternal(goal) : null;
531
615
  const now = nowSeconds();
532
616
  goal.updatedAt = now;
533
617
  if (result === "success") {
534
- goal.continuationFailures = 0;
535
618
  if (goal.status === "active") {
619
+ const attempt = goal.pendingAttempt;
620
+ if (attempt) {
621
+ attempt.delivered = true;
622
+ attempt.started = attempt.started || options?.started === true;
623
+ attempt.armNoProgress = options?.armNoProgress ?? attempt.armNoProgress;
624
+ if (attempt.armNoProgress)
625
+ goal.awaitingContinuationProgress = true;
626
+ } else {
627
+ goal.pendingAttempt = {
628
+ id: randomId(),
629
+ reservedAt: Date.now(),
630
+ started: options?.started === true,
631
+ delivered: true,
632
+ committed: false,
633
+ armNoProgress: options?.armNoProgress !== false,
634
+ previousLastContinuationAt: goal.lastContinuationAt
635
+ };
636
+ if (goal.pendingAttempt.armNoProgress)
637
+ goal.awaitingContinuationProgress = true;
638
+ }
536
639
  goal.lastStatus = "Auto-continue prompt sent.";
537
- goal.awaitingContinuationProgress = true;
538
640
  }
539
- return snapshot(goal);
641
+ return snapshotInternal(goal);
540
642
  }
643
+ if (options?.requirePending && goal.pendingAttempt == null)
644
+ return null;
541
645
  goal.continuationFailures += 1;
542
646
  goal.awaitingContinuationProgress = false;
647
+ goal.pendingAttempt = null;
543
648
  goal.lastStatus = `Auto-continue failed ${goal.continuationFailures} time(s).`;
544
649
  pushHistory(goal, "error", goal.lastStatus);
545
650
  if (goal.continuationFailures >= maxFailures) {
@@ -551,7 +656,44 @@ async function recordContinuationResult(sessionID, result, maxFailures) {
551
656
  goal.blocker = "Auto-continue prompt failed repeatedly. Resume the goal to retry.";
552
657
  pushHistory(goal, "paused", goal.lastStatus);
553
658
  }
554
- return snapshot(goal);
659
+ return snapshotInternal(goal);
660
+ });
661
+ }
662
+ async function markPendingContinuationStarted(sessionID) {
663
+ const state = await readState();
664
+ const current = state.goals[sessionID];
665
+ if (!current || current.status !== "active")
666
+ return current ? snapshotInternal(current) : null;
667
+ if (current.pendingAttempt == null || current.pendingAttempt.started)
668
+ return snapshotInternal(current);
669
+ return mutate((state2) => {
670
+ const goal = state2.goals[sessionID];
671
+ if (!goal || goal.status !== "active")
672
+ return goal ? snapshotInternal(goal) : null;
673
+ if (goal.pendingAttempt == null || goal.pendingAttempt.started)
674
+ return snapshotInternal(goal);
675
+ goal.pendingAttempt.started = true;
676
+ goal.updatedAt = nowSeconds();
677
+ return snapshotInternal(goal);
678
+ });
679
+ }
680
+ async function recordToolProgress(sessionID, text, expectedAttemptID) {
681
+ return mutate((state) => {
682
+ const goal = state.goals[sessionID];
683
+ if (!goal || goal.status !== "active")
684
+ return goal ? snapshotInternal(goal) : null;
685
+ const value = text?.trim() ?? "";
686
+ if (!value)
687
+ return snapshotInternal(goal);
688
+ if (goal.continuationFailures === 0 && goal.pendingAttempt == null)
689
+ return snapshotInternal(goal);
690
+ if (goal.pendingAttempt != null && expectedAttemptID !== undefined && expectedAttemptID !== goal.pendingAttempt.id) {
691
+ return snapshotInternal(goal);
692
+ }
693
+ goal.continuationFailures = 0;
694
+ goal.pendingAttempt = null;
695
+ goal.updatedAt = nowSeconds();
696
+ return snapshotInternal(goal);
555
697
  });
556
698
  }
557
699
  function reserveWrapup(goal) {
@@ -560,7 +702,7 @@ function reserveWrapup(goal) {
560
702
  goal.budgetWrapupSent = true;
561
703
  goal.updatedAt = nowSeconds();
562
704
  pushHistory(goal, "limited", `${goal.status}: ${goal.stopReason ?? "goal limit reached"}; requested final handoff.`);
563
- return snapshot(goal);
705
+ return snapshotInternal(goal);
564
706
  }
565
707
  function maybeStopForBudget(goal) {
566
708
  if (goal.status !== "active")
@@ -783,6 +925,11 @@ var DEFAULT_RESTRICTED_AGENTS = ["plan"];
783
925
  var TASK_SETTLE_DELAY_MS = 25;
784
926
  var SNAPSHOT_IDLE_HOLD_MS = 250;
785
927
  var MAX_TIMER_DELAY_MS = 2147483647;
928
+ var STALE_PENDING_MS = 30000;
929
+ var RETRY_SETTLE_MS = 25;
930
+ var TRANSPORT_ERROR_PATTERN = /\b(?:network|fetch|socket|connect|connection|timeout|timed out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|transport|stream|websocket|offline|internet|request failed|proxy)\b/i;
931
+ var NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/i;
932
+ var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history"]);
786
933
  var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
787
934
  var PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.';
788
935
  var LIMITED_GOAL_NOTICE = "Safety limit reached. Do not start or continue substantive work for this goal. Summarize useful progress, remaining work, and blockers, then wait for the user to resume or edit the goal.";
@@ -825,6 +972,9 @@ function commandNameFromOptions(options) {
825
972
  function positiveIntegerOrNull2(value) {
826
973
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
827
974
  }
975
+ function nonNegativeIntegerOrNull(value) {
976
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
977
+ }
828
978
  function timeoutMillisecondsFromSeconds(value) {
829
979
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
830
980
  return null;
@@ -994,6 +1144,103 @@ function isIdleEvent(event) {
994
1144
  const status = event.properties?.status;
995
1145
  return event.type === "session.status" && typeof status === "object" && status !== null && status.type === "idle";
996
1146
  }
1147
+ function isTransportError(error) {
1148
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
1149
+ if (!message || NON_TRANSPORT_TERMINAL_PATTERN.test(message))
1150
+ return false;
1151
+ if (TRANSPORT_ERROR_PATTERN.test(message))
1152
+ return true;
1153
+ return false;
1154
+ }
1155
+ function transportErrorMessageFromEvent(props) {
1156
+ for (const candidate of [props.error, props.message, props.reason]) {
1157
+ if (typeof candidate === "string" && candidate.trim())
1158
+ return candidate.trim();
1159
+ if (isRecord(candidate)) {
1160
+ for (const key of ["message", "error", "reason", "description"]) {
1161
+ const value = candidate[key];
1162
+ if (typeof value === "string" && value.trim())
1163
+ return value.trim();
1164
+ }
1165
+ }
1166
+ }
1167
+ return "";
1168
+ }
1169
+ function continuationRetryDelayMs(minIntervalSeconds, attemptAt, now = Date.now()) {
1170
+ return Math.max(0, attemptAt + minIntervalSeconds * 1000 - now) + RETRY_SETTLE_MS;
1171
+ }
1172
+ function continuationDelayFromSnapshot(minIntervalSeconds, lastContinuationAt, now = Date.now()) {
1173
+ if (lastContinuationAt == null)
1174
+ return RETRY_SETTLE_MS;
1175
+ return Math.max(0, (lastContinuationAt + minIntervalSeconds + 1) * 1000 - now) + RETRY_SETTLE_MS;
1176
+ }
1177
+ function pendingAttemptOf(goal) {
1178
+ return goal?.pendingAttempt ?? null;
1179
+ }
1180
+ function pendingReadyForFailure(attempt, deliveredLocally, now = Date.now()) {
1181
+ if (!attempt)
1182
+ return false;
1183
+ if (attempt.started)
1184
+ return true;
1185
+ if (deliveredLocally)
1186
+ return false;
1187
+ return now - attempt.reservedAt >= STALE_PENDING_MS;
1188
+ }
1189
+ async function reconcileLocalMarkerAfterProgress(locallyDelivered, sessionID, goal) {
1190
+ if (!goal || goal.continuationFailures !== 0)
1191
+ return;
1192
+ const internal = await getGoalInternal(sessionID);
1193
+ if (internal && internal.pendingAttempt == null)
1194
+ locallyDelivered.delete(sessionID);
1195
+ }
1196
+ var TOOL_FAILURE_STATES = new Set([
1197
+ "failed",
1198
+ "failure",
1199
+ "error",
1200
+ "cancelled",
1201
+ "canceled",
1202
+ "aborted",
1203
+ "abort",
1204
+ "interrupted",
1205
+ "running",
1206
+ "pending",
1207
+ "in_progress",
1208
+ "in-progress",
1209
+ "incomplete",
1210
+ "partial",
1211
+ "timeout",
1212
+ "timed_out"
1213
+ ]);
1214
+ function toolOutputFailed(output) {
1215
+ if (!isRecord(output))
1216
+ return true;
1217
+ if (typeof output.error === "string" && output.error.trim())
1218
+ return true;
1219
+ if (output.success === false)
1220
+ return true;
1221
+ const text = typeof output.output === "string" ? output.output.trim() : "";
1222
+ const state = output.state ?? output.status;
1223
+ if (typeof state === "string") {
1224
+ const normalized = state.trim().toLowerCase();
1225
+ if (TOOL_FAILURE_STATES.has(normalized))
1226
+ return true;
1227
+ if (["completed", "complete", "success", "succeeded", "ok", "done"].includes(normalized))
1228
+ return false;
1229
+ }
1230
+ if (isRecord(output.metadata)) {
1231
+ const metaState = output.metadata.state ?? output.metadata.status;
1232
+ if (typeof metaState === "string" && TOOL_FAILURE_STATES.has(metaState.trim().toLowerCase()))
1233
+ return true;
1234
+ }
1235
+ const taskState = parseTaskState(text);
1236
+ if (taskState)
1237
+ return taskState !== "completed";
1238
+ if (/^state:\s*(failed|failure|error|cancelled|canceled|aborted|abort|interrupted|running|pending|incomplete|partial|timeout|timed_out)\b/im.test(text))
1239
+ return true;
1240
+ if (/^<error>/i.test(text) || /^<tool-error>/i.test(text) || /^error:/i.test(text))
1241
+ return true;
1242
+ return false;
1243
+ }
997
1244
  function sessionIDFromEvent(event) {
998
1245
  const direct = event.properties?.sessionID;
999
1246
  if (typeof direct === "string")
@@ -1277,15 +1524,21 @@ class TaskTracker {
1277
1524
  }
1278
1525
  async function recordAssistantMessage(sessionID, message, options, evaluateContinuation = false) {
1279
1526
  if (!message)
1280
- return;
1281
- await recordAssistantProgress(sessionID, {
1282
- messageID: messageID(message),
1283
- text: textFromMessage(message),
1527
+ return { goal: null, progressed: false };
1528
+ const before = await getGoal(sessionID);
1529
+ const id = messageID(message) ?? "";
1530
+ const text = textFromMessage(message);
1531
+ const progressed = Boolean(/[\p{L}\p{N}]/u.test(text) && (id !== (before?.lastAssistantMessageID ?? "") || text !== (before?.lastAssistantText ?? "")));
1532
+ const goal = await recordAssistantProgress(sessionID, {
1533
+ messageID: id,
1534
+ text,
1284
1535
  outputTokens: outputTokensFromMessage(message) ?? null,
1285
1536
  noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
1286
1537
  maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
1287
- evaluateContinuation
1538
+ evaluateContinuation,
1539
+ completedAt: messageCompletedAt(message)
1288
1540
  });
1541
+ return { goal, progressed };
1289
1542
  }
1290
1543
  function mergeSystemReminder(output, reminder) {
1291
1544
  if (!reminder.trim())
@@ -1367,11 +1620,20 @@ function textFromToolResult(result) {
1367
1620
  }
1368
1621
  return;
1369
1622
  }
1623
+ function toolAttemptKey(sessionID, callID) {
1624
+ return `${sessionID}\x00${callID}`;
1625
+ }
1626
+ function clearToolAttemptsForSession(attempts, sessionID) {
1627
+ for (const key of [...attempts.keys()]) {
1628
+ if (key.startsWith(`${sessionID}\x00`))
1629
+ attempts.delete(key);
1630
+ }
1631
+ }
1370
1632
  var server = async ({ client }, options) => {
1371
1633
  const autoContinue = options?.auto_continue ?? true;
1372
1634
  const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
1373
1635
  const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
1374
- const minInterval = positiveIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
1636
+ const minInterval = nonNegativeIntegerOrNull(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
1375
1637
  const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time);
1376
1638
  const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
1377
1639
  const registerCommand = options?.register_command ?? true;
@@ -1381,9 +1643,14 @@ var server = async ({ client }, options) => {
1381
1643
  const scheduledContinuations = new Map;
1382
1644
  const turnWatchdogs = new Map;
1383
1645
  const busySessions = new Set;
1646
+ const nativeRetrySessions = new Set;
1647
+ const locallyDeliveredPendingSessions = new Set;
1648
+ const toolAttempts = new Map;
1649
+ const watchdogRescuedSessions = new Set;
1384
1650
  const planAgents = restrictedAgentSet(options);
1385
1651
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
1386
1652
  const goalServices = { options: options ?? {}, isPlanAgent };
1653
+ let disposed = false;
1387
1654
  async function taskBlockStatus(sessionID) {
1388
1655
  if (!deferWhileTasksActive)
1389
1656
  return false;
@@ -1403,6 +1670,8 @@ var server = async ({ client }, options) => {
1403
1670
  function armTurnWatchdog(sessionID) {
1404
1671
  if (maxTurnTimeMs == null)
1405
1672
  return;
1673
+ if (watchdogRescuedSessions.has(sessionID))
1674
+ return;
1406
1675
  clearTurnWatchdog(sessionID);
1407
1676
  const watchdog = {
1408
1677
  timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs)
@@ -1415,7 +1684,9 @@ var server = async ({ client }, options) => {
1415
1684
  async function runTurnWatchdog(sessionID, watchdog) {
1416
1685
  let claimedContinuation = false;
1417
1686
  try {
1418
- if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1687
+ if (disposed)
1688
+ return;
1689
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID) || watchdogRescuedSessions.has(sessionID))
1419
1690
  return;
1420
1691
  const goal = await getGoal(sessionID);
1421
1692
  if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
@@ -1428,6 +1699,8 @@ var server = async ({ client }, options) => {
1428
1699
  const latestTurnAgent = agentFromMessage(latestAssistant);
1429
1700
  if (isPlanAgent(latestTurnAgent))
1430
1701
  return;
1702
+ const observedBeforeRescue = await recordAssistantMessage(sessionID, latestAssistant, options ?? {});
1703
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observedBeforeRescue.goal);
1431
1704
  const taskStatus = await taskBlockStatus(sessionID);
1432
1705
  if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1433
1706
  return;
@@ -1441,9 +1714,16 @@ var server = async ({ client }, options) => {
1441
1714
  turnWatchdogs.delete(sessionID);
1442
1715
  activeContinuations.add(sessionID);
1443
1716
  claimedContinuation = true;
1717
+ watchdogRescuedSessions.add(sessionID);
1444
1718
  await sendContinuation(client, sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestTurnAgent ?? null);
1719
+ await recordContinuationResult(sessionID, "success", maxPromptFailures, { armNoProgress: false, started: true });
1720
+ locallyDeliveredPendingSessions.add(sessionID);
1721
+ clearTurnWatchdog(sessionID);
1445
1722
  } catch (error) {
1446
1723
  try {
1724
+ if (claimedContinuation && isTransportError(error)) {
1725
+ await recordContinuationResult(sessionID, "failure", maxPromptFailures);
1726
+ }
1447
1727
  await client.app?.log?.({
1448
1728
  body: {
1449
1729
  service: "opencode-goal-plugin",
@@ -1462,38 +1742,74 @@ var server = async ({ client }, options) => {
1462
1742
  turnWatchdogs.delete(sessionID);
1463
1743
  }
1464
1744
  }
1465
- function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
1466
- if (scheduledContinuations.has(sessionID))
1745
+ function cancelScheduledContinuation(sessionID) {
1746
+ const scheduled = scheduledContinuations.get(sessionID);
1747
+ if (scheduled)
1748
+ clearTimeout(scheduled.timer);
1749
+ scheduledContinuations.delete(sessionID);
1750
+ }
1751
+ function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS, replace = false, purpose = "settle") {
1752
+ if (disposed)
1467
1753
  return;
1468
- const timer = setTimeout(() => {
1469
- scheduledContinuations.delete(sessionID);
1470
- runAutoContinue(sessionID, true);
1754
+ if (!replace && scheduledContinuations.has(sessionID))
1755
+ return;
1756
+ if (replace)
1757
+ cancelScheduledContinuation(sessionID);
1758
+ const scheduled = {};
1759
+ const timer = setTimeout(async () => {
1760
+ try {
1761
+ if (scheduledContinuations.get(sessionID) !== scheduled || nativeRetrySessions.has(sessionID))
1762
+ return;
1763
+ if (purpose === "retry") {
1764
+ const goal = await getGoalInternal(sessionID);
1765
+ if (!goal || goal.continuationFailures === 0 && goal.pendingAttempt == null)
1766
+ return;
1767
+ }
1768
+ if (scheduledContinuations.get(sessionID) !== scheduled || nativeRetrySessions.has(sessionID))
1769
+ return;
1770
+ await runAutoContinue(sessionID, true, scheduled);
1771
+ } finally {
1772
+ if (scheduledContinuations.get(sessionID) === scheduled)
1773
+ scheduledContinuations.delete(sessionID);
1774
+ }
1471
1775
  }, Math.max(0, delayMs));
1776
+ scheduled.timer = timer;
1777
+ scheduled.purpose = purpose;
1472
1778
  const maybeUnref = timer;
1473
1779
  if (typeof maybeUnref.unref === "function")
1474
1780
  maybeUnref.unref();
1475
- scheduledContinuations.set(sessionID, timer);
1781
+ scheduledContinuations.set(sessionID, scheduled);
1476
1782
  }
1477
- async function runAutoContinue(sessionID, fromTaskDeferral = false) {
1783
+ async function runAutoContinue(sessionID, fromTaskDeferral = false, scheduled) {
1784
+ if (disposed)
1785
+ return;
1478
1786
  if (busySessions.has(sessionID))
1479
1787
  return;
1480
1788
  if (activeContinuations.has(sessionID))
1481
1789
  return;
1482
1790
  activeContinuations.add(sessionID);
1791
+ let attemptReservedAt = Date.now();
1483
1792
  try {
1484
1793
  const latestAssistant = await fetchLatestAssistant(client, sessionID);
1485
1794
  taskTracker.observeAssistantMessage(sessionID, latestAssistant);
1486
1795
  const taskStatus = await taskBlockStatus(sessionID);
1487
1796
  if (taskStatus && taskStatus.blocked) {
1488
1797
  taskDeferredSessions.add(sessionID);
1489
- if (taskStatus.retryAt != null)
1490
- scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now());
1798
+ if (taskStatus.retryAt != null) {
1799
+ scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null);
1800
+ }
1491
1801
  return;
1492
1802
  }
1493
1803
  if (busySessions.has(sessionID))
1494
1804
  return;
1495
- await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
1496
- const current = await getGoal(sessionID);
1805
+ const observed = await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
1806
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
1807
+ const queued = scheduledContinuations.get(sessionID);
1808
+ if (observed.progressed && queued?.purpose !== "settle")
1809
+ cancelScheduledContinuation(sessionID);
1810
+ if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
1811
+ return;
1812
+ const current = await getGoalInternal(sessionID);
1497
1813
  if (!current)
1498
1814
  return;
1499
1815
  const latestTurnAgent = agentFromMessage(latestAssistant);
@@ -1509,13 +1825,64 @@ var server = async ({ client }, options) => {
1509
1825
  return;
1510
1826
  }
1511
1827
  taskDeferredSessions.delete(sessionID);
1828
+ const attempt = pendingAttemptOf(current);
1829
+ if (current.status === "active" && attempt != null) {
1830
+ const deliveredLocally = locallyDeliveredPendingSessions.has(sessionID);
1831
+ if (!pendingReadyForFailure(attempt, deliveredLocally)) {
1832
+ return;
1833
+ }
1834
+ const afterFailure = await recordContinuationResult(sessionID, "failure", maxPromptFailures, {
1835
+ requirePending: true
1836
+ });
1837
+ if (afterFailure)
1838
+ locallyDeliveredPendingSessions.delete(sessionID);
1839
+ if (autoContinue && afterFailure?.status === "active") {
1840
+ scheduleSettledContinuation(sessionID, continuationRetryDelayMs(minInterval, attempt.reservedAt), true, "retry");
1841
+ }
1842
+ return;
1843
+ }
1844
+ const queuedBeforeReserve = scheduledContinuations.get(sessionID);
1845
+ if (queuedBeforeReserve && queuedBeforeReserve !== scheduled)
1846
+ return;
1847
+ if (!autoContinue)
1848
+ return;
1849
+ if (nativeRetrySessions.has(sessionID))
1850
+ return;
1512
1851
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
1513
1852
  if (!goal)
1514
1853
  return;
1854
+ attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
1855
+ if (nativeRetrySessions.has(sessionID)) {
1856
+ await rollbackContinuationAttempt(sessionID);
1857
+ return;
1858
+ }
1859
+ if (scheduled && scheduledContinuations.get(sessionID) !== scheduled) {
1860
+ await rollbackContinuationAttempt(sessionID);
1861
+ return;
1862
+ }
1515
1863
  await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
1516
- await recordContinuationResult(sessionID, "success", maxPromptFailures);
1864
+ if (disposed) {
1865
+ await rollbackContinuationAttempt(sessionID);
1866
+ return;
1867
+ }
1868
+ const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures);
1869
+ locallyDeliveredPendingSessions.add(sessionID);
1870
+ if (!delivered?.pendingAttempt?.delivered) {
1871
+ await rollbackContinuationAttempt(sessionID);
1872
+ }
1517
1873
  } catch (error) {
1518
- await recordContinuationResult(sessionID, "failure", maxPromptFailures);
1874
+ if (disposed) {
1875
+ await rollbackContinuationAttempt(sessionID);
1876
+ return;
1877
+ }
1878
+ if (isTransportError(error)) {
1879
+ const afterFailure = await recordContinuationResult(sessionID, "failure", maxPromptFailures);
1880
+ if (autoContinue && afterFailure?.status === "active") {
1881
+ scheduleSettledContinuation(sessionID, continuationRetryDelayMs(minInterval, attemptReservedAt), true, "retry");
1882
+ }
1883
+ } else {
1884
+ await rollbackContinuationAttempt(sessionID);
1885
+ }
1519
1886
  await client.app?.log?.({
1520
1887
  body: {
1521
1888
  service: "opencode-goal-plugin",
@@ -1530,12 +1897,17 @@ var server = async ({ client }, options) => {
1530
1897
  }
1531
1898
  return {
1532
1899
  async dispose() {
1533
- for (const timer of scheduledContinuations.values())
1534
- clearTimeout(timer);
1900
+ disposed = true;
1901
+ for (const scheduled of scheduledContinuations.values())
1902
+ clearTimeout(scheduled.timer);
1535
1903
  scheduledContinuations.clear();
1536
1904
  for (const watchdog of turnWatchdogs.values())
1537
1905
  clearTimeout(watchdog.timer);
1538
1906
  turnWatchdogs.clear();
1907
+ watchdogRescuedSessions.clear();
1908
+ locallyDeliveredPendingSessions.clear();
1909
+ nativeRetrySessions.clear();
1910
+ toolAttempts.clear();
1539
1911
  },
1540
1912
  async config(config) {
1541
1913
  if (!registerCommand)
@@ -1622,9 +1994,41 @@ var server = async ({ client }, options) => {
1622
1994
  },
1623
1995
  async "tool.execute.before"(input) {
1624
1996
  taskTracker.noteTaskCall(input);
1997
+ const sessionID = typeof input?.sessionID === "string" ? input.sessionID : undefined;
1998
+ const callID = typeof input?.callID === "string" ? input.callID : undefined;
1999
+ if (sessionID && callID) {
2000
+ const goal = await getGoalInternal(sessionID);
2001
+ toolAttempts.set(toolAttemptKey(sessionID, callID), goal?.pendingAttempt?.id ?? null);
2002
+ }
1625
2003
  },
1626
2004
  async "tool.execute.after"(input, output) {
1627
2005
  taskTracker.noteTaskOutput(input, output);
2006
+ const sessionID = typeof input?.sessionID === "string" ? input.sessionID : undefined;
2007
+ const callID = typeof input?.callID === "string" ? input.callID : undefined;
2008
+ const attemptKey = sessionID && callID ? toolAttemptKey(sessionID, callID) : undefined;
2009
+ const expectedAttemptID = attemptKey ? toolAttempts.get(attemptKey) : undefined;
2010
+ if (attemptKey)
2011
+ toolAttempts.delete(attemptKey);
2012
+ if (!sessionID)
2013
+ return;
2014
+ if (typeof input?.tool === "string" && NON_PROGRESS_TOOLS.has(input.tool.toLowerCase()))
2015
+ return;
2016
+ const toolResult = output;
2017
+ if (toolOutputFailed(toolResult))
2018
+ return;
2019
+ const text = typeof toolResult.output === "string" ? toolResult.output : undefined;
2020
+ if (!text)
2021
+ return;
2022
+ const before = await getGoalInternal(sessionID);
2023
+ const scheduled = scheduledContinuations.get(sessionID);
2024
+ const hasFailureEpisode = Boolean(before && (before.continuationFailures > 0 || before.pendingAttempt != null));
2025
+ if (!before || !hasFailureEpisode && scheduled?.purpose !== "recovery")
2026
+ return;
2027
+ const progressed = await recordToolProgress(sessionID, text, expectedAttemptID);
2028
+ if (progressed?.continuationFailures === 0 && progressed.pendingAttempt == null) {
2029
+ locallyDeliveredPendingSessions.delete(sessionID);
2030
+ cancelScheduledContinuation(sessionID);
2031
+ }
1628
2032
  },
1629
2033
  async "chat.message"(input, output) {
1630
2034
  const sessionID = typeof input?.sessionID === "string" ? input.sessionID : output.message?.sessionID;
@@ -1639,7 +2043,11 @@ var server = async ({ client }, options) => {
1639
2043
  if (!sessionID)
1640
2044
  return;
1641
2045
  await accountUsage(sessionID, tokensFromMessages(output.messages));
1642
- await recordAssistantMessage(sessionID, latestAssistantMessage(output.messages), options ?? {});
2046
+ const observed = await recordAssistantMessage(sessionID, latestAssistantMessage(output.messages), options ?? {});
2047
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
2048
+ const scheduled = scheduledContinuations.get(sessionID);
2049
+ if (observed.progressed && scheduled?.purpose !== "settle")
2050
+ cancelScheduledContinuation(sessionID);
1643
2051
  },
1644
2052
  async "experimental.chat.system.transform"(input, output) {
1645
2053
  if (typeof input.sessionID !== "string")
@@ -1666,44 +2074,91 @@ var server = async ({ client }, options) => {
1666
2074
  if (sessionID && eventType === "session.status") {
1667
2075
  const status = event.properties?.status;
1668
2076
  if (isRecord(status) && typeof status.type === "string") {
1669
- if (status.type === "busy")
2077
+ if (status.type === "busy") {
1670
2078
  busySessions.add(sessionID);
2079
+ nativeRetrySessions.delete(sessionID);
2080
+ }
1671
2081
  if (status.type === "busy")
1672
2082
  armTurnWatchdog(sessionID);
2083
+ if (status.type === "busy")
2084
+ await markPendingContinuationStarted(sessionID);
1673
2085
  if (status.type === "idle") {
1674
2086
  busySessions.delete(sessionID);
2087
+ nativeRetrySessions.delete(sessionID);
1675
2088
  clearTurnWatchdog(sessionID);
2089
+ watchdogRescuedSessions.delete(sessionID);
1676
2090
  }
1677
- if (status.type === "retry")
2091
+ if (status.type === "retry") {
2092
+ nativeRetrySessions.add(sessionID);
1678
2093
  clearTurnWatchdog(sessionID);
2094
+ cancelScheduledContinuation(sessionID);
2095
+ }
1679
2096
  taskTracker.observeSessionStatus(sessionID, status.type);
1680
2097
  }
1681
2098
  }
1682
2099
  if (sessionID && eventType === "session.idle") {
1683
2100
  busySessions.delete(sessionID);
2101
+ nativeRetrySessions.delete(sessionID);
1684
2102
  clearTurnWatchdog(sessionID);
2103
+ watchdogRescuedSessions.delete(sessionID);
1685
2104
  taskTracker.observeSessionStatus(sessionID, "idle");
1686
2105
  }
2106
+ if (sessionID && eventType === "session.error") {
2107
+ const inNativeRetry = nativeRetrySessions.has(sessionID);
2108
+ busySessions.delete(sessionID);
2109
+ clearTurnWatchdog(sessionID);
2110
+ if (inNativeRetry)
2111
+ return;
2112
+ nativeRetrySessions.delete(sessionID);
2113
+ watchdogRescuedSessions.delete(sessionID);
2114
+ const props = event.properties ?? {};
2115
+ const errorMessage = transportErrorMessageFromEvent(props);
2116
+ if (errorMessage && isTransportError(errorMessage)) {
2117
+ const goal = await getGoalInternal(sessionID);
2118
+ if (goal?.status === "active") {
2119
+ const attempt = pendingAttemptOf(goal);
2120
+ if (attempt != null) {
2121
+ const afterFailure = await recordContinuationResult(sessionID, "failure", maxPromptFailures, {
2122
+ requirePending: true
2123
+ });
2124
+ if (afterFailure)
2125
+ locallyDeliveredPendingSessions.delete(sessionID);
2126
+ if (autoContinue && afterFailure?.status === "active") {
2127
+ scheduleSettledContinuation(sessionID, continuationRetryDelayMs(minInterval, attempt.reservedAt), true, "retry");
2128
+ }
2129
+ } else if (autoContinue) {
2130
+ scheduleSettledContinuation(sessionID, continuationDelayFromSnapshot(minInterval, goal.lastContinuationAt), false, "recovery");
2131
+ }
2132
+ }
2133
+ }
2134
+ }
1687
2135
  if (sessionID && eventType === "session.deleted") {
1688
2136
  busySessions.delete(sessionID);
1689
2137
  clearTurnWatchdog(sessionID);
1690
- const scheduled = scheduledContinuations.get(sessionID);
1691
- if (scheduled)
1692
- clearTimeout(scheduled);
1693
- scheduledContinuations.delete(sessionID);
2138
+ watchdogRescuedSessions.delete(sessionID);
2139
+ locallyDeliveredPendingSessions.delete(sessionID);
2140
+ nativeRetrySessions.delete(sessionID);
2141
+ cancelScheduledContinuation(sessionID);
1694
2142
  taskDeferredSessions.delete(sessionID);
2143
+ clearToolAttemptsForSession(toolAttempts, sessionID);
1695
2144
  taskTracker.observeSessionDeleted(sessionID);
1696
2145
  }
1697
2146
  if (sessionID && event.type === "message.updated") {
1698
2147
  const props = event.properties ?? {};
1699
2148
  const message = [props.info, props.message].find((value) => value && typeof value === "object");
1700
2149
  taskTracker.observeAssistantMessage(sessionID, message);
1701
- await recordAssistantMessage(sessionID, message, options ?? {});
2150
+ const observed = await recordAssistantMessage(sessionID, message, options ?? {});
2151
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
2152
+ const scheduled = scheduledContinuations.get(sessionID);
2153
+ if (observed.progressed && scheduled?.purpose !== "settle")
2154
+ cancelScheduledContinuation(sessionID);
1702
2155
  }
1703
- if (!autoContinue || !isIdleEvent(event))
2156
+ if (!isIdleEvent(event))
1704
2157
  return;
1705
2158
  if (!sessionID)
1706
2159
  return;
2160
+ if (!autoContinue && (await getGoalInternal(sessionID))?.pendingAttempt == null)
2161
+ return;
1707
2162
  await runAutoContinue(sessionID);
1708
2163
  }
1709
2164
  };
@@ -1718,7 +2173,7 @@ async function setupV2(context) {
1718
2173
  const autoContinue = options.auto_continue ?? true;
1719
2174
  const deferWhileTasksActive = options.defer_while_tasks_active ?? true;
1720
2175
  const maxAutoTurns = positiveIntegerOrNull2(options.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
1721
- const minInterval = positiveIntegerOrNull2(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
2176
+ const minInterval = nonNegativeIntegerOrNull(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
1722
2177
  const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options.max_turn_time);
1723
2178
  const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
1724
2179
  const registerCommand = options.register_command ?? true;
@@ -1728,6 +2183,10 @@ async function setupV2(context) {
1728
2183
  const scheduledContinuations = new Map;
1729
2184
  const turnWatchdogs = new Map;
1730
2185
  const busySessions = new Set;
2186
+ const nativeRetrySessions = new Set;
2187
+ const locallyDeliveredPendingSessions = new Set;
2188
+ const watchdogRescuedSessions = new Set;
2189
+ const toolAttempts = new Map;
1731
2190
  const planAgents = restrictedAgentSet(options);
1732
2191
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
1733
2192
  const goalServices = { options, isPlanAgent };
@@ -1736,6 +2195,7 @@ async function setupV2(context) {
1736
2195
  const stepTextBuffers = new Map;
1737
2196
  const stepTokenSums = new Map;
1738
2197
  const registrations = [];
2198
+ let disposed = false;
1739
2199
  function stepKey(sessionID, messageID2) {
1740
2200
  return `${sessionID}\x00${messageID2}`;
1741
2201
  }
@@ -1764,6 +2224,8 @@ async function setupV2(context) {
1764
2224
  function armTurnWatchdog(sessionID) {
1765
2225
  if (maxTurnTimeMs == null)
1766
2226
  return;
2227
+ if (watchdogRescuedSessions.has(sessionID))
2228
+ return;
1767
2229
  clearTurnWatchdog(sessionID);
1768
2230
  const watchdog = {
1769
2231
  timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs)
@@ -1776,7 +2238,9 @@ async function setupV2(context) {
1776
2238
  async function runTurnWatchdog(sessionID, watchdog) {
1777
2239
  let claimedContinuation = false;
1778
2240
  try {
1779
- if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
2241
+ if (disposed)
2242
+ return;
2243
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID) || watchdogRescuedSessions.has(sessionID))
1780
2244
  return;
1781
2245
  const goal = await getGoal(sessionID);
1782
2246
  if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
@@ -1791,7 +2255,7 @@ async function setupV2(context) {
1791
2255
  return;
1792
2256
  if (taskStatus && taskStatus.blocked)
1793
2257
  return;
1794
- const current = await getGoal(sessionID);
2258
+ const current = await getGoalInternal(sessionID);
1795
2259
  if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1796
2260
  return;
1797
2261
  if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuationsV2.has(sessionID))
@@ -1799,9 +2263,20 @@ async function setupV2(context) {
1799
2263
  turnWatchdogs.delete(sessionID);
1800
2264
  activeContinuationsV2.add(sessionID);
1801
2265
  claimedContinuation = true;
2266
+ watchdogRescuedSessions.add(sessionID);
1802
2267
  await sendContinuation2(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
2268
+ await recordContinuationResult(sessionID, "success", maxPromptFailures, { armNoProgress: false, started: true });
2269
+ locallyDeliveredPendingSessions.add(sessionID);
2270
+ clearTurnWatchdog(sessionID);
1803
2271
  } catch (error) {
1804
- v2ErrorLog("Turn watchdog retry failed", error);
2272
+ try {
2273
+ if (claimedContinuation && isTransportError(error)) {
2274
+ await recordContinuationResult(sessionID, "failure", maxPromptFailures);
2275
+ }
2276
+ v2ErrorLog("Turn watchdog retry failed", error);
2277
+ } catch {
2278
+ return;
2279
+ }
1805
2280
  } finally {
1806
2281
  if (claimedContinuation)
1807
2282
  activeContinuationsV2.delete(sessionID);
@@ -1809,24 +2284,53 @@ async function setupV2(context) {
1809
2284
  turnWatchdogs.delete(sessionID);
1810
2285
  }
1811
2286
  }
1812
- function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
1813
- if (scheduledContinuations.has(sessionID))
2287
+ function cancelScheduledContinuation(sessionID) {
2288
+ const scheduled = scheduledContinuations.get(sessionID);
2289
+ if (scheduled)
2290
+ clearTimeout(scheduled.timer);
2291
+ scheduledContinuations.delete(sessionID);
2292
+ }
2293
+ function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS, replace = false, purpose = "settle") {
2294
+ if (disposed)
1814
2295
  return;
1815
- const timer = setTimeout(() => {
1816
- scheduledContinuations.delete(sessionID);
1817
- runAutoContinue(sessionID, true);
2296
+ if (!replace && scheduledContinuations.has(sessionID))
2297
+ return;
2298
+ if (replace)
2299
+ cancelScheduledContinuation(sessionID);
2300
+ const scheduled = {};
2301
+ const timer = setTimeout(async () => {
2302
+ try {
2303
+ if (scheduledContinuations.get(sessionID) !== scheduled || nativeRetrySessions.has(sessionID))
2304
+ return;
2305
+ if (purpose === "retry") {
2306
+ const goal = await getGoalInternal(sessionID);
2307
+ if (!goal || goal.continuationFailures === 0 && goal.pendingAttempt == null)
2308
+ return;
2309
+ }
2310
+ if (scheduledContinuations.get(sessionID) !== scheduled || nativeRetrySessions.has(sessionID))
2311
+ return;
2312
+ await runAutoContinue(sessionID, true, scheduled);
2313
+ } finally {
2314
+ if (scheduledContinuations.get(sessionID) === scheduled)
2315
+ scheduledContinuations.delete(sessionID);
2316
+ }
1818
2317
  }, Math.max(0, delayMs));
2318
+ scheduled.timer = timer;
2319
+ scheduled.purpose = purpose;
1819
2320
  const maybeUnref = timer;
1820
2321
  if (typeof maybeUnref.unref === "function")
1821
2322
  maybeUnref.unref();
1822
- scheduledContinuations.set(sessionID, timer);
2323
+ scheduledContinuations.set(sessionID, scheduled);
1823
2324
  }
1824
- async function runAutoContinue(sessionID, fromTaskDeferral = false) {
2325
+ async function runAutoContinue(sessionID, fromTaskDeferral = false, scheduled) {
2326
+ if (disposed)
2327
+ return;
1825
2328
  if (busySessions.has(sessionID))
1826
2329
  return;
1827
2330
  if (activeContinuationsV2.has(sessionID))
1828
2331
  return;
1829
2332
  activeContinuationsV2.add(sessionID);
2333
+ let attemptReservedAt = Date.now();
1830
2334
  try {
1831
2335
  const latestStep = latestStepBySession.get(sessionID);
1832
2336
  if (latestStep?.messageID) {
@@ -1835,23 +2339,33 @@ async function setupV2(context) {
1835
2339
  const taskStatus = taskBlockStatus(sessionID);
1836
2340
  if (taskStatus && taskStatus.blocked) {
1837
2341
  taskDeferredSessions.add(sessionID);
1838
- if (taskStatus.retryAt != null)
1839
- scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now());
2342
+ if (taskStatus.retryAt != null) {
2343
+ scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null);
2344
+ }
1840
2345
  return;
1841
2346
  }
1842
2347
  if (busySessions.has(sessionID))
1843
2348
  return;
1844
2349
  if (latestStep) {
1845
- await recordAssistantProgress(sessionID, {
2350
+ const beforeProgress = await getGoalInternal(sessionID);
2351
+ const after = await recordAssistantProgress(sessionID, {
1846
2352
  messageID: latestStep.messageID,
1847
2353
  text: latestStep.text,
1848
2354
  outputTokens: latestStep.outputTokens,
1849
2355
  noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
1850
2356
  maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
1851
- evaluateContinuation: true
2357
+ evaluateContinuation: true,
2358
+ completedAt: latestStep.completedAt
1852
2359
  });
2360
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, after);
2361
+ const progressed = Boolean(after && (after.lastAssistantMessageID !== (beforeProgress?.lastAssistantMessageID ?? "") || after.lastAssistantText !== (beforeProgress?.lastAssistantText ?? "")));
2362
+ const queuedAfterProgress = scheduledContinuations.get(sessionID);
2363
+ if (progressed && queuedAfterProgress?.purpose !== "settle")
2364
+ cancelScheduledContinuation(sessionID);
1853
2365
  }
1854
- const current = await getGoal(sessionID);
2366
+ if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
2367
+ return;
2368
+ const current = await getGoalInternal(sessionID);
1855
2369
  if (!current)
1856
2370
  return;
1857
2371
  const latestTurnAgent = latestStep?.agent;
@@ -1867,13 +2381,64 @@ async function setupV2(context) {
1867
2381
  return;
1868
2382
  }
1869
2383
  taskDeferredSessions.delete(sessionID);
2384
+ const attempt = pendingAttemptOf(current);
2385
+ if (current.status === "active" && attempt != null) {
2386
+ const deliveredLocally = locallyDeliveredPendingSessions.has(sessionID);
2387
+ if (!pendingReadyForFailure(attempt, deliveredLocally)) {
2388
+ return;
2389
+ }
2390
+ const afterFailure = await recordContinuationResult(sessionID, "failure", maxPromptFailures, {
2391
+ requirePending: true
2392
+ });
2393
+ if (afterFailure)
2394
+ locallyDeliveredPendingSessions.delete(sessionID);
2395
+ if (autoContinue && afterFailure?.status === "active") {
2396
+ scheduleSettledContinuation(sessionID, continuationRetryDelayMs(minInterval, attempt.reservedAt), true, "retry");
2397
+ }
2398
+ return;
2399
+ }
2400
+ const queuedBeforeReserve = scheduledContinuations.get(sessionID);
2401
+ if (queuedBeforeReserve && queuedBeforeReserve !== scheduled)
2402
+ return;
2403
+ if (!autoContinue)
2404
+ return;
2405
+ if (nativeRetrySessions.has(sessionID))
2406
+ return;
1870
2407
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
1871
2408
  if (!goal)
1872
2409
  return;
2410
+ attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
2411
+ if (nativeRetrySessions.has(sessionID)) {
2412
+ await rollbackContinuationAttempt(sessionID);
2413
+ return;
2414
+ }
2415
+ if (scheduled && scheduledContinuations.get(sessionID) !== scheduled) {
2416
+ await rollbackContinuationAttempt(sessionID);
2417
+ return;
2418
+ }
1873
2419
  await sendContinuation2(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
1874
- await recordContinuationResult(sessionID, "success", maxPromptFailures);
2420
+ if (disposed) {
2421
+ await rollbackContinuationAttempt(sessionID);
2422
+ return;
2423
+ }
2424
+ const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures);
2425
+ locallyDeliveredPendingSessions.add(sessionID);
2426
+ if (!delivered?.pendingAttempt?.delivered) {
2427
+ await rollbackContinuationAttempt(sessionID);
2428
+ }
1875
2429
  } catch (error) {
1876
- await recordContinuationResult(sessionID, "failure", maxPromptFailures);
2430
+ if (disposed) {
2431
+ await rollbackContinuationAttempt(sessionID);
2432
+ return;
2433
+ }
2434
+ if (isTransportError(error)) {
2435
+ const afterFailure = await recordContinuationResult(sessionID, "failure", maxPromptFailures);
2436
+ if (autoContinue && afterFailure?.status === "active") {
2437
+ scheduleSettledContinuation(sessionID, continuationRetryDelayMs(minInterval, attemptReservedAt), true, "retry");
2438
+ }
2439
+ } else {
2440
+ await rollbackContinuationAttempt(sessionID);
2441
+ }
1877
2442
  v2ErrorLog("Auto-continue failed", error);
1878
2443
  } finally {
1879
2444
  activeContinuationsV2.delete(sessionID);
@@ -1893,31 +2458,76 @@ async function setupV2(context) {
1893
2458
  case "session.status": {
1894
2459
  const status = data.status;
1895
2460
  if (sessionID && isRecord(status) && typeof status.type === "string") {
1896
- if (status.type === "busy")
2461
+ if (status.type === "busy") {
1897
2462
  busySessions.add(sessionID);
1898
- if (status.type === "busy")
2463
+ nativeRetrySessions.delete(sessionID);
1899
2464
  armTurnWatchdog(sessionID);
2465
+ await markPendingContinuationStarted(sessionID);
2466
+ }
1900
2467
  if (status.type === "idle") {
1901
2468
  busySessions.delete(sessionID);
2469
+ nativeRetrySessions.delete(sessionID);
1902
2470
  clearTurnWatchdog(sessionID);
2471
+ watchdogRescuedSessions.delete(sessionID);
1903
2472
  }
1904
- if (status.type === "retry")
2473
+ if (status.type === "retry") {
2474
+ nativeRetrySessions.add(sessionID);
1905
2475
  clearTurnWatchdog(sessionID);
2476
+ cancelScheduledContinuation(sessionID);
2477
+ }
1906
2478
  taskTracker.observeSessionStatus(sessionID, status.type);
1907
- }
1908
- if (autoContinue && sessionID && isRecord(status) && status.type === "idle") {
1909
- await runAutoContinue(sessionID);
2479
+ if (status.type === "idle") {
2480
+ const goal = await getGoalInternal(sessionID);
2481
+ if (autoContinue || goal?.pendingAttempt != null)
2482
+ await runAutoContinue(sessionID);
2483
+ }
1910
2484
  }
1911
2485
  return;
1912
2486
  }
1913
2487
  case "session.idle": {
1914
2488
  if (sessionID) {
1915
2489
  busySessions.delete(sessionID);
2490
+ nativeRetrySessions.delete(sessionID);
1916
2491
  clearTurnWatchdog(sessionID);
2492
+ watchdogRescuedSessions.delete(sessionID);
1917
2493
  taskTracker.observeSessionStatus(sessionID, "idle");
1918
2494
  }
1919
- if (autoContinue && sessionID)
1920
- await runAutoContinue(sessionID);
2495
+ if (sessionID) {
2496
+ const goal = await getGoalInternal(sessionID);
2497
+ if (autoContinue || goal?.pendingAttempt != null)
2498
+ await runAutoContinue(sessionID);
2499
+ }
2500
+ return;
2501
+ }
2502
+ case "session.error": {
2503
+ if (!sessionID)
2504
+ return;
2505
+ const inNativeRetry = nativeRetrySessions.has(sessionID);
2506
+ busySessions.delete(sessionID);
2507
+ clearTurnWatchdog(sessionID);
2508
+ if (inNativeRetry)
2509
+ return;
2510
+ nativeRetrySessions.delete(sessionID);
2511
+ watchdogRescuedSessions.delete(sessionID);
2512
+ const errorMessage = transportErrorMessageFromEvent(data);
2513
+ if (errorMessage && isTransportError(errorMessage)) {
2514
+ const goal = await getGoalInternal(sessionID);
2515
+ if (goal?.status === "active") {
2516
+ const attempt = pendingAttemptOf(goal);
2517
+ if (attempt != null) {
2518
+ const afterFailure = await recordContinuationResult(sessionID, "failure", maxPromptFailures, {
2519
+ requirePending: true
2520
+ });
2521
+ if (afterFailure)
2522
+ locallyDeliveredPendingSessions.delete(sessionID);
2523
+ if (autoContinue && afterFailure?.status === "active") {
2524
+ scheduleSettledContinuation(sessionID, continuationRetryDelayMs(minInterval, attempt.reservedAt), true, "retry");
2525
+ }
2526
+ } else if (autoContinue) {
2527
+ scheduleSettledContinuation(sessionID, continuationDelayFromSnapshot(minInterval, goal.lastContinuationAt), false, "recovery");
2528
+ }
2529
+ }
2530
+ }
1921
2531
  return;
1922
2532
  }
1923
2533
  case "session.deleted": {
@@ -1925,11 +2535,15 @@ async function setupV2(context) {
1925
2535
  return;
1926
2536
  busySessions.delete(sessionID);
1927
2537
  clearTurnWatchdog(sessionID);
2538
+ watchdogRescuedSessions.delete(sessionID);
2539
+ locallyDeliveredPendingSessions.delete(sessionID);
2540
+ nativeRetrySessions.delete(sessionID);
1928
2541
  const scheduled = scheduledContinuations.get(sessionID);
1929
2542
  if (scheduled)
1930
- clearTimeout(scheduled);
2543
+ clearTimeout(scheduled.timer);
1931
2544
  scheduledContinuations.delete(sessionID);
1932
2545
  taskDeferredSessions.delete(sessionID);
2546
+ clearToolAttemptsForSession(toolAttempts, sessionID);
1933
2547
  taskTracker.observeSessionDeleted(sessionID);
1934
2548
  latestStepBySession.delete(sessionID);
1935
2549
  stepTokenSums.delete(sessionID);
@@ -1956,7 +2570,7 @@ async function setupV2(context) {
1956
2570
  });
1957
2571
  if (!stepTextBuffers.has(stepKey(sessionID, messageID2)))
1958
2572
  stepTextBuffers.set(stepKey(sessionID, messageID2), "");
1959
- latestStepBySession.set(sessionID, { messageID: messageID2, agent, text: "", outputTokens: null });
2573
+ latestStepBySession.set(sessionID, { messageID: messageID2, agent, text: "", outputTokens: null, completedAt: event.created });
1960
2574
  return;
1961
2575
  }
1962
2576
  case "session.text.delta": {
@@ -1985,18 +2599,26 @@ async function setupV2(context) {
1985
2599
  const text = stepTextBuffers.get(stepKey(sessionID, messageID2)) ?? "";
1986
2600
  stepTextBuffers.delete(stepKey(sessionID, messageID2));
1987
2601
  const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
1988
- await recordAssistantProgress(sessionID, {
2602
+ const afterStep = await recordAssistantProgress(sessionID, {
1989
2603
  messageID: messageID2,
1990
2604
  text,
1991
2605
  outputTokens,
1992
2606
  noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
1993
- maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns)
2607
+ maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
2608
+ completedAt: event.created
1994
2609
  });
2610
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, afterStep);
2611
+ if (/[\p{L}\p{N}]/u.test(text)) {
2612
+ const scheduled = scheduledContinuations.get(sessionID);
2613
+ if (scheduled?.purpose === "recovery")
2614
+ cancelScheduledContinuation(sessionID);
2615
+ }
1995
2616
  latestStepBySession.set(sessionID, {
1996
2617
  messageID: messageID2,
1997
2618
  agent: latestStepBySession.get(sessionID)?.agent,
1998
2619
  text,
1999
- outputTokens
2620
+ outputTokens,
2621
+ completedAt: event.created
2000
2622
  });
2001
2623
  return;
2002
2624
  }
@@ -2013,18 +2635,26 @@ async function setupV2(context) {
2013
2635
  const text = stepTextBuffers.get(stepKey(sessionID, messageID2)) ?? "";
2014
2636
  stepTextBuffers.delete(stepKey(sessionID, messageID2));
2015
2637
  const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
2016
- await recordAssistantProgress(sessionID, {
2638
+ const afterStep = await recordAssistantProgress(sessionID, {
2017
2639
  messageID: messageID2,
2018
2640
  text,
2019
2641
  outputTokens,
2020
2642
  noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
2021
- maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns)
2643
+ maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
2644
+ completedAt: event.created
2022
2645
  });
2646
+ await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, afterStep);
2647
+ if (/[\p{L}\p{N}]/u.test(text)) {
2648
+ const scheduled = scheduledContinuations.get(sessionID);
2649
+ if (scheduled?.purpose === "recovery")
2650
+ cancelScheduledContinuation(sessionID);
2651
+ }
2023
2652
  latestStepBySession.set(sessionID, {
2024
2653
  messageID: messageID2,
2025
2654
  agent: latestStepBySession.get(sessionID)?.agent,
2026
2655
  text,
2027
- outputTokens
2656
+ outputTokens,
2657
+ completedAt: event.created
2028
2658
  });
2029
2659
  return;
2030
2660
  }
@@ -2052,13 +2682,44 @@ async function setupV2(context) {
2052
2682
  for (const tool of goalToolsV2(goalServices))
2053
2683
  draft.add(tool);
2054
2684
  }));
2055
- registrations.push(await context.tool.hook("execute.before", (input) => {
2685
+ registrations.push(await context.tool.hook("execute.before", async (input) => {
2056
2686
  taskTracker.noteTaskCall({ tool: input.tool, sessionID: input.sessionID, callID: input.id });
2687
+ const sessionID = typeof input.sessionID === "string" ? input.sessionID : undefined;
2688
+ const callID = typeof input.id === "string" ? input.id : undefined;
2689
+ if (sessionID && callID) {
2690
+ const goal = await getGoalInternal(sessionID);
2691
+ toolAttempts.set(toolAttemptKey(sessionID, callID), goal?.pendingAttempt?.id ?? null);
2692
+ }
2057
2693
  }));
2058
- registrations.push(await context.tool.hook("execute.after", (input) => {
2694
+ registrations.push(await context.tool.hook("execute.after", async (input) => {
2695
+ const sessionID = typeof input.sessionID === "string" ? input.sessionID : undefined;
2696
+ const callID = typeof input.id === "string" ? input.id : undefined;
2697
+ const attemptKey = sessionID && callID ? toolAttemptKey(sessionID, callID) : undefined;
2698
+ const expectedAttemptID = attemptKey ? toolAttempts.get(attemptKey) : undefined;
2699
+ if (attemptKey)
2700
+ toolAttempts.delete(attemptKey);
2059
2701
  if (input.status !== "completed")
2060
2702
  return;
2703
+ const text = textFromToolResult(input.result);
2061
2704
  taskTracker.noteTaskOutput({ tool: input.tool, sessionID: input.sessionID, callID: input.id }, { output: textFromToolResult(input.result) });
2705
+ if (!sessionID || typeof input.tool !== "string")
2706
+ return;
2707
+ if (NON_PROGRESS_TOOLS.has(input.tool.toLowerCase()))
2708
+ return;
2709
+ if (toolOutputFailed(input.result))
2710
+ return;
2711
+ if (!text)
2712
+ return;
2713
+ const before = await getGoalInternal(sessionID);
2714
+ const scheduled = scheduledContinuations.get(sessionID);
2715
+ const hasFailureEpisode = Boolean(before && (before.continuationFailures > 0 || before.pendingAttempt != null));
2716
+ if (!before || !hasFailureEpisode && scheduled?.purpose !== "recovery")
2717
+ return;
2718
+ const progressed = await recordToolProgress(sessionID, text, expectedAttemptID);
2719
+ if (progressed?.continuationFailures === 0 && progressed.pendingAttempt == null) {
2720
+ locallyDeliveredPendingSessions.delete(sessionID);
2721
+ cancelScheduledContinuation(sessionID);
2722
+ }
2062
2723
  }));
2063
2724
  registrations.push(await context.session.hook("context", (sessionContext) => {
2064
2725
  const reminder = systemReminder();
@@ -2085,14 +2746,19 @@ async function setupV2(context) {
2085
2746
  }
2086
2747
  })();
2087
2748
  return async () => {
2749
+ disposed = true;
2088
2750
  abortController.abort();
2089
- for (const timer of scheduledContinuations.values())
2090
- clearTimeout(timer);
2751
+ for (const scheduled of scheduledContinuations.values())
2752
+ clearTimeout(scheduled.timer);
2091
2753
  scheduledContinuations.clear();
2092
2754
  for (const watchdog of turnWatchdogs.values())
2093
2755
  clearTimeout(watchdog.timer);
2094
2756
  turnWatchdogs.clear();
2095
2757
  activeContinuationsV2.clear();
2758
+ nativeRetrySessions.clear();
2759
+ locallyDeliveredPendingSessions.clear();
2760
+ watchdogRescuedSessions.clear();
2761
+ toolAttempts.clear();
2096
2762
  for (const registration of registrations)
2097
2763
  await registration.dispose();
2098
2764
  const termination = Promise.allSettled([consumer, eventIterator?.return?.()]);