Repository navigation
Expand file tree
/
Copy pathtask_queue.js
More file actions
321 lines (301 loc) · 16.5 KB
/
Copy pathtask_queue.js
File metadata and controls
321 lines (301 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
// LLM-owned task queue. The bot's model creates / completes / cancels tasks
// itself via !addTask / !finishTask / !cancelTask / !showQueue commands; the
// queue is rendered into the system prompt every turn via $TASKQUEUE so the
// model always sees what it's tracking.
//
// Tasks persist to bots/<name>/tasks.json across restarts.
import fs from 'fs';
import path from 'path';
const STATUS = {PENDING: 'pending', IN_PROGRESS: 'in_progress', DONE: 'done'};
const LOG_PATH = path.resolve('./queue.log');
// End-factor phrases that aren't verifiable. Rejecting them at add-time forces
// the LLM to write a concrete criterion, which is what Phase H's verify gate
// needs in order to catch honor-system finishes. The exact words come from
// observed failures (queue.log, diamond-armor session 2026-05-19) where the
// LLM declared tasks done because "enough X for Y" is unfalsifiable.
const VAGUE_END_FACTOR_PATTERNS = [
/\benough\b/i,
/\b(?:if|as|when)\s+(?:needed|necessary|possible|appropriate)\b/i,
/\bsufficient\b/i,
/\bsome\s+(?:more|of)\b/i,
];
// On (re)start the queue is pruned Claude-Code style: a queue left IDLE (no
// in_progress task) is stale leftovers and must not auto-resume → wipe it.
// But an in_progress task means a crash/disconnect interrupted an ACTIVE
// mission, and restarts here are mostly involuntary and frequent — so we keep
// the in_progress task AND its pending downstream steps. Dropping the pending
// steps truncated multi-step goals: "make an iron pickaxe" decomposes into
// mine → smelt → craft, and a restart while smelting kept #269 (smelt) but
// wiped the pending #270 (craft), so the bot "finished" after smelting and
// never crafted the pickaxe (2026-06-18). DONE tasks are always dropped.
// nextId stays monotonic so IDs never collide across sessions in queue.log
// (the eval system of record).
export function pruneQueueOnStart(rawData, { keepInProgress = true } = {}) {
let nextId = 1;
let tasks = [];
try {
const data = typeof rawData === 'string' ? JSON.parse(rawData) : (rawData || {});
nextId = data.nextId || 1;
if (keepInProgress && Array.isArray(data.tasks)) {
const hasActive = data.tasks.some(t => t && String(t.status) === 'in_progress');
// Active mission → keep in_progress + pending (drop done) so the whole
// plan resumes. Idle queue → wipe all (anti-stale-resume, unchanged).
if (hasActive) tasks = data.tasks.filter(t => t && String(t.status) !== 'done');
}
} catch { /* unreadable queue → fresh start */ }
return { nextId, tasks };
}
export class TaskQueue {
constructor(agentName, onChange = null, isPaused = null, options = {}) {
this.agentName = agentName;
this.path = path.resolve(`./bots/${agentName}/tasks.json`);
this.tasks = [];
this._nextId = 1;
this.onChange = onChange; // (kind, task) => void; kind in {add, start, finish, cancel, clearDone, auto-start}
// () => boolean. When truthy, addTask + finishTask skip auto-promotion
// so plan-mode honors "no work until the player approves." The queue
// can still add/cancel/finish; only the implicit start is gated.
this.isPaused = isPaused;
// v2 Step 6: child task queues (subagent context isolation) are
// ephemeral — no disk persistence, no queue.log appends. When
// {persist: false} is set, _load skips disk read and _persist /
// _log become no-ops. Used by src/agent/subagent_v2.js per
// dispatch.
this.persist = options?.persist !== false;
// Anti-thrash: track recent cancellations so we can reject the
// immediate re-add. Observed 2026-05-20 03:50: model cancelled +
// re-added the same two descriptions every ~1.5s for 5 minutes,
// burning 132 task IDs. Window matches the heartbeat cadence —
// anything inside it that re-adds a cancelled phrase is thrash.
this._recentCancels = []; // [{description (lowercase), ts}]
this._thrashWindowMs = options?.thrashWindowMs ?? 15000;
if (this.persist) this._load();
}
_paused() {
if (typeof this.isPaused !== 'function') return false;
try { return !!this.isPaused(); } catch (e) {
console.warn('TaskQueue.isPaused threw:', e?.message || e);
return false;
}
}
_fire(kind, task) {
if (this.onChange) {
try { this.onChange(kind, task); } catch (e) { console.warn('TaskQueue.onChange failed:', e?.message || e); }
}
}
_log(action, task) {
if (!this.persist) return; // ephemeral child queues skip queue.log
try {
const ts = new Date().toISOString();
// ef= trails the description so the eval referee can read the task's
// completion criterion from the log (verb/#id/status prefixes that
// metrics.mjs and loop.sh grep on are untouched).
const ef = task && task.endFactor ? ` ef=${JSON.stringify(task.endFactor)}` : '';
const line = task
? `${ts} [${this.agentName}] ${action} #${task.id} ${task.status} ${JSON.stringify(task.description)}${ef}\n`
: `${ts} [${this.agentName}] ${action}\n`;
fs.appendFileSync(LOG_PATH, line);
} catch (e) {
console.warn('queue log append failed:', e?.message || e);
}
}
_load() {
try {
if (!fs.existsSync(this.path)) return;
const data = JSON.parse(fs.readFileSync(this.path, 'utf8'));
this.tasks = data.tasks || [];
this._nextId = data.nextId || (this.tasks.reduce((m, t) => Math.max(m, t.id), 0) + 1);
} catch (e) {
console.warn('TaskQueue load failed:', e?.message || e);
}
}
_persist() {
if (!this.persist) return; // ephemeral child queues skip disk
try {
fs.mkdirSync(path.dirname(this.path), {recursive: true});
fs.writeFileSync(this.path, JSON.stringify({nextId: this._nextId, tasks: this.tasks}, null, 2));
} catch (e) {
console.warn('TaskQueue persist failed:', e?.message || e);
}
}
addTask(description, endFactor = null) {
description = String(description || '').trim();
endFactor = String(endFactor || '').trim() || null;
if (!description) return {ok: false, message: 'Task description was empty — rejected.'};
if (description.length < 4) return {ok: false, message: `Task description "${description}" too short (need at least 4 chars). Be specific — rejected.`};
if (!endFactor) return {ok: false, message: `Task "${description}" missing end_factor — rejected. Every task needs an explicit completion criterion (e.g. "3 iron_ore in inventory", "player picked up the pickaxe", "bot at coords 100,64,-50").`};
const vague = VAGUE_END_FACTOR_PATTERNS.find(re => re.test(endFactor));
if (vague) {
const hit = endFactor.match(vague)[0];
return {ok: false, message: `Task "${description}" has a vague end_factor ("${endFactor}") — rejected. The phrase "${hit}" is not verifiable. Use a concrete, measurable criterion (e.g. "3 iron_ingot in inventory", "iron_pickaxe in inventory", "bot at coords 100,64,-50"). Pick a specific number and item.`};
}
// Reject duplicates among live (non-done) tasks. Compare case-insensitive
// exact match — fuzzy match would risk false rejects on similar-but-
// distinct tasks (e.g. "mine 3 iron_ore" vs "mine 5 iron_ore").
const dupe = this.tasks.find(t => t.status !== STATUS.DONE && t.description.toLowerCase() === description.toLowerCase());
if (dupe) return {ok: false, message: `Duplicate of task #${dupe.id} (${dupe.status}): "${dupe.description}" — rejected. Use the existing task.`};
// Anti-thrash: if this same description was cancelled within the
// thrash window, the model is re-adding what it just removed.
// Reject and tell it to commit to the existing plan or take a
// concrete next step instead of churning the queue.
const descLc = description.toLowerCase();
const cutoff = Date.now() - this._thrashWindowMs;
this._recentCancels = this._recentCancels.filter(c => c.ts >= cutoff);
const thrash = this._recentCancels.find(c => c.description === descLc);
if (thrash) {
return {ok: false, message: `Task "${description}" was cancelled ${Math.round((Date.now()-thrash.ts)/1000)}s ago — re-adding it would just thrash the queue. Either execute the current in_progress task with concrete commands, or pick a genuinely different next step.`};
}
const task = {id: this._nextId++, description, endFactor, status: STATUS.PENDING, createdAt: Date.now()};
this.tasks.push(task);
// Auto-advance: if nothing is in progress, promote this one immediately
// so the model never has to chain !addTask + !startTask manually.
// Plan-mode pause suppresses this — the LLM is supposed to lay out the
// full plan and wait for player approval before any task starts.
const hasActive = this.tasks.some(x => x.status === STATUS.IN_PROGRESS);
const endHint = ` Done when: ${endFactor}.`;
if (!hasActive && !this._paused()) {
task.status = STATUS.IN_PROGRESS;
task.startedAt = Date.now();
this._persist();
this._log('add+start', task);
this._fire('add', task);
this._fire('start', task);
return {ok: true, message: `Task #${task.id} added and started: ${description}.${endHint} Begin executing it now.`, task};
}
this._persist();
this._log('add', task);
this._fire('add', task);
return {ok: true, message: `Task #${task.id} queued: ${description}.${endHint}`, task};
}
// Mark a task as in_progress. If no id, picks the first pending one.
// Mostly used to re-order; auto-start covers the common case.
startTask(id = null) {
const t = id != null ? this._find(id) : this.tasks.find(x => x.status === STATUS.PENDING);
if (!t) return {ok: false, message: id != null ? `No task #${id}.` : 'No pending tasks.'};
if (t.status === STATUS.DONE) return {ok: false, message: `Task #${t.id} is already done.`};
for (const o of this.tasks) if (o.status === STATUS.IN_PROGRESS && o.id !== t.id) o.status = STATUS.PENDING;
t.status = STATUS.IN_PROGRESS;
if (!t.startedAt) t.startedAt = Date.now();
this._persist();
this._log('start', t);
this._fire('start', t);
return {ok: true, message: `Started task #${t.id}: ${t.description}`, task: t};
}
// Mark a task done. If no id, picks the in_progress one. Auto-advances to
// the next pending task so the model can immediately execute it.
finishTask(id = null) {
const t = id != null ? this._find(id) : this.tasks.find(x => x.status === STATUS.IN_PROGRESS);
if (!t) return {ok: false, message: id != null ? `No task #${id}.` : 'No task in progress.'};
if (t.status === STATUS.DONE) return {ok: false, message: `Task #${t.id} was already done.`};
t.status = STATUS.DONE;
t.finishedAt = Date.now();
this._log('finish', t);
// The onChange contract (constructor comment) always promised a
// 'finish' kind but nothing fired it — the followup-drain and
// subagent-finalize listeners in agent._onQueueChange were dead.
this._fire('finish', t);
// Plan-mode pause skips the implicit advance — if a task somehow ran
// into plan mode, finishing it shouldn't slide the next pending into
// in_progress behind the player's back.
const next = this._paused() ? null : this.tasks.find(x => x.status === STATUS.PENDING);
if (next) {
next.status = STATUS.IN_PROGRESS;
next.startedAt = Date.now();
this._persist();
this._log('auto-start', next);
this._fire('start', next);
return {
ok: true,
message: `Finished task #${t.id}: ${t.description}. Auto-started #${next.id}: ${next.description}. Begin executing it now.`,
task: t,
};
}
this._persist();
return {
ok: true,
message: `Finished task #${t.id}: ${t.description}. Queue is empty — all done. Tell the player you're done.`,
task: t,
};
}
cancelTask(id) {
const idx = this.tasks.findIndex(x => x.id === Number(id));
if (idx === -1) return {ok: false, message: `No task #${id}.`};
const [t] = this.tasks.splice(idx, 1);
this._recentCancels.push({description: t.description.toLowerCase(), ts: Date.now()});
this._persist();
this._log('cancel', t);
this._fire('cancel', t); // same contract gap as finishTask above
return {ok: true, message: `Cancelled task #${t.id}: ${t.description}`, task: t};
}
// Park the active task without cancelling it (soft player pause: "wait",
// "hold on"). A pending task is dormant — the drive loop only nudges
// in_progress — until the player re-directs (!startTask or a new ask).
demoteActive() {
const t = this.tasks.find(x => x.status === STATUS.IN_PROGRESS);
if (!t) return null;
t.status = STATUS.PENDING;
this._persist();
this._log('demote', t);
return t;
}
// Wipe everything not yet finished. Called by !stop so the player's mental
// model ("stop = stop everything") matches reality. Done tasks stay so they
// remain visible in history. Fires a 'cancel' event for each so any
// listeners (chat surface, persist hooks) react consistently.
cancelAllPending() {
const cancelled = this.tasks.filter(x => x.status !== STATUS.DONE);
if (cancelled.length === 0) return {ok: true, count: 0, message: 'No pending tasks to cancel.'};
const now = Date.now();
cancelled.forEach(t => this._recentCancels.push({description: t.description.toLowerCase(), ts: now}));
this.tasks = this.tasks.filter(x => x.status === STATUS.DONE);
this._persist();
this._log(`cancelAllPending (removed ${cancelled.length})`);
cancelled.forEach(t => this._fire('cancel', t));
return {ok: true, count: cancelled.length, message: `Cancelled ${cancelled.length} pending task(s).`};
}
clearDone() {
const before = this.tasks.length;
this.tasks = this.tasks.filter(x => x.status !== STATUS.DONE);
this._persist();
this._log(`clearDone (removed ${before - this.tasks.length})`);
return {ok: true, message: `Cleared ${before - this.tasks.length} done task(s).`};
}
_find(id) {
return this.tasks.find(x => x.id === Number(id));
}
// What the LLM sees in $TASKQUEUE every turn. Done tasks are hidden so the
// list stays focused on what's left to do; cleared via !clearDone. Each
// line carries its end factor so the model self-checks against the same
// criterion it set at add-time.
serialize({ planMode = false } = {}) {
const live = this.tasks.filter(t => t.status !== STATUS.DONE);
if (live.length === 0) {
return planMode
? 'Your task queue is empty — propose a plan via !addTask, then post it to chat for approval.'
: 'Your task queue is empty.';
}
const header = planMode
? 'Your task queue (PLAN MODE — awaiting player approval):'
: 'Your task queue:';
const lines = live.map(t => {
const tag = t.status === STATUS.IN_PROGRESS ? 'IN PROGRESS' : 'pending';
const end = t.endFactor ? ` (done when: ${t.endFactor})` : '';
return ` #${t.id} [${tag}] ${t.description}${end}`;
});
return header + '\n' + lines.join('\n');
}
// Human-facing chat dump for !showQueue.
formatForChat({ planMode = false } = {}) {
const live = this.tasks.filter(t => t.status !== STATUS.DONE);
if (live.length === 0) {
return planMode ? '[planning] no plan yet — add steps with !addTask' : 'No tasks queued.';
}
const prefix = planMode ? '[planning] Plan: ' : '[executing] ';
const body = live.map(t => {
const tag = t.status === STATUS.IN_PROGRESS ? '▶' : '○';
const end = t.endFactor ? ` (done: ${t.endFactor})` : '';
return `${tag} #${t.id} ${t.description}${end}`;
}).join(' | ');
return prefix + body + (planMode ? ' (say ok to start)' : '');
}
}