autodev-cli 1.4.181 → 1.4.183

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/out/taskLoop.d.ts CHANGED
@@ -14,6 +14,42 @@ export type LoopState = 'idle' | 'running' | 'stopping' | 'paused';
14
14
  * only a task that TRANSITIONED to blocked during this wait is skipped.
15
15
  */
16
16
  export declare function taskHardFailedDuringWait(blockedBeforeWait: boolean, blockedAfterWait: boolean): boolean;
17
+ /**
18
+ * Default number of times a stranded `[~]` in-progress task (the CLI exited
19
+ * without completing it) is reset to pending `[ ]` and re-attempted before it
20
+ * is finally flagged BLOCKED / needs-attention. Overridable per-agent via
21
+ * `settings.maxStrandRetries`. A crashed / StopFailure'd task is NEVER marked
22
+ * done — correctness over efficiency: re-doing a task the agent actually
23
+ * finished (safe, wasteful) beats reporting a half-done task as complete
24
+ * (unsafe). The user explicitly prefers never-false-done.
25
+ */
26
+ export declare const MAX_STRAND_RETRIES = 3;
27
+ /** Action decided for a single stranded `[~]` task by {@link reconcileStrandedTask}. */
28
+ export type StrandAction = 'reset' | 'block';
29
+ /**
30
+ * PURE, side-effect-contained reconciliation for ONE stranded `[~]` task on a
31
+ * non-clean CLI exit. Advances the in-memory strand counter and decides whether
32
+ * to reset the task to pending (`reset`, retry) or promote it to blocked /
33
+ * needs-attention (`block`).
34
+ *
35
+ * CRITICAL flood-safety contract: this mutates ONLY the in-memory `strandCount`
36
+ * map and (on `block`) the `blocked` set — it does NOT touch TODO.md. The caller
37
+ * MUST invoke this and let the bookkeeping settle BEFORE any TODO.md write, so a
38
+ * locked / unwritable TODO.md can never cause a flood: the per-task counter
39
+ * still climbs and the task still lands in `blocked` after MAX regardless of
40
+ * whether the reset/blocked-marker write throws or silently fails. Because the
41
+ * caller's stranded filter excludes `blocked` tasks, a given task is reconciled
42
+ * at most `max + 1` times total (max resets, then one block) — never per-second.
43
+ *
44
+ * @param key Task identity — `t.id ?? t.text` (mirrors _blockedTasks/_pendingRetry keying).
45
+ * @param strandCount Per-task strand-attempt counter map (mutated).
46
+ * @param blocked Blocked / needs-attention task-key set (mutated on `block`).
47
+ * @param max Retry ceiling (default {@link MAX_STRAND_RETRIES}).
48
+ */
49
+ export declare function reconcileStrandedTask(key: string, strandCount: Map<string, number>, blocked: Set<string>, max?: number): {
50
+ action: StrandAction;
51
+ count: number;
52
+ };
17
53
  export interface LoopCallbacks {
18
54
  /** Send a prompt to the active AI provider. messageFile is the absolute path of the written .md file for CLI providers. */
19
55
  sendToAi: (prompt: string, taskLabel: string, includeProfile?: boolean, messageFile?: string) => Promise<void>;
@@ -109,6 +145,13 @@ export declare class TaskLoopRunner {
109
145
  * stranded-[~] cleanup auto-marking them done). Consumed when the loop
110
146
  * re-picks the task for re-dispatch. */
111
147
  private _pendingRetry;
148
+ /** Per-task STRAND counter (id or text). Incremented each time the CLI exits
149
+ * leaving this task `[~]` in-progress without completing it. While ≤
150
+ * maxStrandRetries the task is reset to pending `[ ]` and re-attempted; past
151
+ * the ceiling it is promoted to _blockedTasks (needs-attention) and NEVER
152
+ * marked done. Advances purely in-memory BEFORE any TODO.md write so a
153
+ * locked/unwritable TODO.md can never flood the stranded sweep. */
154
+ private _strandCount;
112
155
  /** Counts completed tasks since the last auto-compact run. */
113
156
  private _autoCompactCounter;
114
157
  /** Counts completed tasks since the last session reset. */
package/out/taskLoop.js CHANGED
@@ -33,8 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.taskLoopRunner = exports.TaskLoopRunner = void 0;
36
+ exports.taskLoopRunner = exports.TaskLoopRunner = exports.MAX_STRAND_RETRIES = void 0;
37
37
  exports.taskHardFailedDuringWait = taskHardFailedDuringWait;
38
+ exports.reconcileStrandedTask = reconcileStrandedTask;
38
39
  const fs = __importStar(require("fs"));
39
40
  const path = __importStar(require("path"));
40
41
  const os = __importStar(require("os"));
@@ -86,6 +87,46 @@ const version_1 = require("./version");
86
87
  function taskHardFailedDuringWait(blockedBeforeWait, blockedAfterWait) {
87
88
  return blockedAfterWait && !blockedBeforeWait;
88
89
  }
90
+ /**
91
+ * Default number of times a stranded `[~]` in-progress task (the CLI exited
92
+ * without completing it) is reset to pending `[ ]` and re-attempted before it
93
+ * is finally flagged BLOCKED / needs-attention. Overridable per-agent via
94
+ * `settings.maxStrandRetries`. A crashed / StopFailure'd task is NEVER marked
95
+ * done — correctness over efficiency: re-doing a task the agent actually
96
+ * finished (safe, wasteful) beats reporting a half-done task as complete
97
+ * (unsafe). The user explicitly prefers never-false-done.
98
+ */
99
+ exports.MAX_STRAND_RETRIES = 3;
100
+ /**
101
+ * PURE, side-effect-contained reconciliation for ONE stranded `[~]` task on a
102
+ * non-clean CLI exit. Advances the in-memory strand counter and decides whether
103
+ * to reset the task to pending (`reset`, retry) or promote it to blocked /
104
+ * needs-attention (`block`).
105
+ *
106
+ * CRITICAL flood-safety contract: this mutates ONLY the in-memory `strandCount`
107
+ * map and (on `block`) the `blocked` set — it does NOT touch TODO.md. The caller
108
+ * MUST invoke this and let the bookkeeping settle BEFORE any TODO.md write, so a
109
+ * locked / unwritable TODO.md can never cause a flood: the per-task counter
110
+ * still climbs and the task still lands in `blocked` after MAX regardless of
111
+ * whether the reset/blocked-marker write throws or silently fails. Because the
112
+ * caller's stranded filter excludes `blocked` tasks, a given task is reconciled
113
+ * at most `max + 1` times total (max resets, then one block) — never per-second.
114
+ *
115
+ * @param key Task identity — `t.id ?? t.text` (mirrors _blockedTasks/_pendingRetry keying).
116
+ * @param strandCount Per-task strand-attempt counter map (mutated).
117
+ * @param blocked Blocked / needs-attention task-key set (mutated on `block`).
118
+ * @param max Retry ceiling (default {@link MAX_STRAND_RETRIES}).
119
+ */
120
+ function reconcileStrandedTask(key, strandCount, blocked, max = exports.MAX_STRAND_RETRIES) {
121
+ const count = (strandCount.get(key) ?? 0) + 1;
122
+ strandCount.set(key, count);
123
+ if (count > max) {
124
+ blocked.add(key);
125
+ strandCount.delete(key); // done escalating — it is blocked now; free the counter
126
+ return { action: 'block', count };
127
+ }
128
+ return { action: 'reset', count };
129
+ }
89
130
  // ---------------------------------------------------------------------------
90
131
  // Rate-limit + context-length errors
91
132
  // ---------------------------------------------------------------------------
@@ -244,6 +285,13 @@ class TaskLoopRunner {
244
285
  * stranded-[~] cleanup auto-marking them done). Consumed when the loop
245
286
  * re-picks the task for re-dispatch. */
246
287
  _pendingRetry = new Set();
288
+ /** Per-task STRAND counter (id or text). Incremented each time the CLI exits
289
+ * leaving this task `[~]` in-progress without completing it. While ≤
290
+ * maxStrandRetries the task is reset to pending `[ ]` and re-attempted; past
291
+ * the ceiling it is promoted to _blockedTasks (needs-attention) and NEVER
292
+ * marked done. Advances purely in-memory BEFORE any TODO.md write so a
293
+ * locked/unwritable TODO.md can never flood the stranded sweep. */
294
+ _strandCount = new Map();
247
295
  /** Counts completed tasks since the last auto-compact run. */
248
296
  _autoCompactCounter = 0;
249
297
  /** Counts completed tasks since the last session reset. */
@@ -448,6 +496,7 @@ class TaskLoopRunner {
448
496
  this._blockedTasks.clear();
449
497
  this._hardFailAttempts.clear();
450
498
  this._pendingRetry.clear();
499
+ this._strandCount.clear();
451
500
  this._autoCompactCounter = 0;
452
501
  this._resetSessionCounter = 0;
453
502
  this._profileSentCounter = 0;
@@ -1650,27 +1699,73 @@ class TaskLoopRunner {
1650
1699
  this._cb?.log(`⏳ opencode-cli exited cleanly with [~] task — waiting for external response…`);
1651
1700
  }
1652
1701
  else {
1653
- // Exclude tasks flagged as provider hard-failures: those were left
1654
- // [~] ON PURPOSE to signal "blocked / needs attention". Auto-marking
1655
- // them done here would re-introduce the exact false-green we fixed.
1656
- // Also exclude tasks queued for a provider-hard-failure retry:
1657
- // those are re-dispatched (not auto-completed) by the retry hook
1658
- // above once their backoff elapses.
1702
+ // TRUTHFUL stranded-[~] reconciliation. The CLI exited leaving a
1703
+ // task [~] in-progress without completing it (crash, StopFailure,
1704
+ // resume stale-read, any provider hard-exit). We must NEVER mark it
1705
+ // [x] done — that reports a half-done task as SUCCESS (false-green,
1706
+ // the exact data-integrity bug the user hit). Instead: bounded
1707
+ // reset+retry, then BLOCKED / needs-attention.
1708
+ //
1709
+ // Exclusions preserved: tasks already flagged blocked (_blockedTasks
1710
+ // — left [~] on purpose) and tasks queued for a provider-retry
1711
+ // re-dispatch (_pendingRetry, re-sent by the retry hook above).
1712
+ //
1713
+ // FLOOD SAFETY: reconcileStrandedTask advances the in-memory strand
1714
+ // counter (and promotes to _blockedTasks past MAX) BEFORE and
1715
+ // INDEPENDENTLY of the TODO.md write below. So even if every
1716
+ // resetToTodo write silently fails on a locked/unwritable TODO.md
1717
+ // (the Windows file-lock case that produced the ~40×/second flood),
1718
+ // the counter still climbs and the task still lands blocked after
1719
+ // MAX — and once blocked it is excluded from this sweep, so a given
1720
+ // task is reconciled at most MAX+1 times TOTAL, never per-second.
1721
+ // We also do NOT tight-`continue`: we fall through to the
1722
+ // _sleepOrWake(loopInterval) below, so nothing can spin faster than
1723
+ // the loop interval regardless of write success. ≤1 pass/iteration.
1724
+ const maxStrand = settings.maxStrandRetries ?? exports.MAX_STRAND_RETRIES;
1659
1725
  const stranded = tasks.filter(t => t.status === 'in-progress'
1660
1726
  && !this._blockedTasks.has(t.id ?? t.text)
1661
1727
  && !this._pendingRetry.has(t.id ?? t.text));
1662
1728
  for (const t of stranded) {
1663
- // Auto-mark as [x] done rather than resetting to [ ].
1664
- // Resetting to [ ] causes an infinite loop: the agent picks it
1665
- // up, marks [~] again, exits, and we end up here forever.
1666
- // If the CLI exited leaving a [~] task it means the agent
1667
- // already worked on it — treat it as done.
1668
- await todoWriteManager_1.todoWriter.markDone(todoPath, t).catch(() => { });
1669
- }
1670
- if (stranded.length > 0) {
1671
- this._cb?.log(`✅ Auto-marked ${stranded.length} stranded [~] task(s) as done — CLI exited without completing them`);
1672
- continue; // re-pick immediately
1729
+ const key = t.id ?? t.text;
1730
+ // In-memory bookkeeping FIRST (write-independent, flood-proof).
1731
+ const { action, count } = reconcileStrandedTask(key, this._strandCount, this._blockedTasks, maxStrand);
1732
+ if (action === 'reset') {
1733
+ this._cb?.log(`↺ Reset stranded [~] task to pending — retry ${count}/${maxStrand}: ${discordLabel(t.text)}`);
1734
+ // Best-effort reset; a failure here cannot flood (counter already
1735
+ // advanced above, and we fall through to the loopInterval wait).
1736
+ await todoWriteManager_1.todoWriter.resetToTodo(todoPath, t).catch(() => { });
1737
+ }
1738
+ else {
1739
+ // count > MAX → BLOCKED / needs-attention. NEVER done, NEVER
1740
+ // reset again. Surface it the SAME way a provider hard-failure
1741
+ // does (task_fail → FAILED + StopFailure hook), then keep polling
1742
+ // for OTHER work (it's now excluded from the stranded sweep).
1743
+ this._failedCount++;
1744
+ this._cb?.log(`⚠ Task stranded after ${maxStrand} attempts — marking blocked (needs attention), NOT done: ${discordLabel(t.text)}`);
1745
+ const errText = `task stranded (CLI exited without completing it) after ${maxStrand} reset attempts — needs attention`;
1746
+ this._notifyWebhook('task_fail', {
1747
+ iteration: this._iterations,
1748
+ task: { text: t.text, id: t.id },
1749
+ duration: 0,
1750
+ error: errText,
1751
+ reason: 'stranded',
1752
+ workDir: this._workspaceRoot,
1753
+ gitRepo: this._gitRepo,
1754
+ gitBranch: this._gitBranch,
1755
+ });
1756
+ this._notifyDiscord(`⚠ **Task stranded — blocked (not done):**\n${discordLabel(t.text)}`);
1757
+ if (this._workspaceRoot) {
1758
+ (0, liveNarration_1.appendHookEventLine)(this._workspaceRoot, {
1759
+ hook_event_name: 'StopFailure', event_type: 'stop_failure',
1760
+ provider: provider ?? '', cwd: this._workspaceRoot, error: 'stranded',
1761
+ title: 'Task stranded', message: errText, tool_name: '',
1762
+ timestamp: new Date().toISOString(),
1763
+ });
1764
+ }
1765
+ }
1673
1766
  }
1767
+ // NOTE: NO tight-`continue` — fall through to the loopInterval sleep
1768
+ // below (do NOT re-pick instantly). This is the flood guard.
1674
1769
  }
1675
1770
  }
1676
1771
  this._cb?.log(`No pending tasks — waiting ${settings.loopInterval}s…`);