@saadnvd1/agent-os 0.3.529 → 0.3.531
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/lib/chat/runner.ts +26 -3
- package/lib/models/plan-step.ts +144 -13
- package/lib/notify/copy.ts +4 -4
- package/lib/sessions/end.ts +2 -0
- package/lib/sessions/stop.ts +2 -0
- package/package.json +1 -1
package/lib/chat/runner.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { pushPlanReady } from "../notify/push/events";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
handoffAfterPlan,
|
|
11
|
+
planTurnStarted,
|
|
12
|
+
recheckPlanning,
|
|
13
|
+
type PlanHandoffEnv,
|
|
14
|
+
} from "../models/plan-step";
|
|
10
15
|
import { randomUUID } from "crypto";
|
|
11
16
|
import os from "os";
|
|
12
17
|
import { db, type Session } from "../db";
|
|
@@ -149,6 +154,18 @@ function retireIfStale(sessionId: string, live: Live): void {
|
|
|
149
154
|
);
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
// What the plan step needs to know of a chat, read when it asks.
|
|
158
|
+
function planEnv(sessionId: string): PlanHandoffEnv {
|
|
159
|
+
return {
|
|
160
|
+
backgroundRunning: () =>
|
|
161
|
+
(registry.live.get(sessionId)?.activity.tasks.size ?? 0) > 0,
|
|
162
|
+
turnRunning: () => {
|
|
163
|
+
const state = registry.live.get(sessionId)?.state;
|
|
164
|
+
return state === "running" || state === "waiting";
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
152
169
|
function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
153
170
|
track(live, e);
|
|
154
171
|
if (e.type === "item" && e.item.kind === "task")
|
|
@@ -169,6 +186,7 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
169
186
|
emit(sessionId, e);
|
|
170
187
|
} else if (e.type === "state") {
|
|
171
188
|
live.state = e.state;
|
|
189
|
+
if (e.state === "running") planTurnStarted(sessionId);
|
|
172
190
|
// Whatever ended the turn ended the compaction too.
|
|
173
191
|
if (e.state === "idle" || e.state === "error")
|
|
174
192
|
setCompacting(sessionId, live, null);
|
|
@@ -177,8 +195,12 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
177
195
|
live.retiring = undefined;
|
|
178
196
|
retireIfStale(sessionId, live);
|
|
179
197
|
if (e.state === "idle")
|
|
180
|
-
void handoffAfterPlan(
|
|
181
|
-
|
|
198
|
+
void handoffAfterPlan(
|
|
199
|
+
sessionId,
|
|
200
|
+
(id, m) => sendQueuedNow(id, m),
|
|
201
|
+
planEnv(sessionId)
|
|
202
|
+
).catch((err) =>
|
|
203
|
+
console.error(`Plan handoff for ${sessionId} failed:`, err)
|
|
182
204
|
);
|
|
183
205
|
} else if (e.type === "suggestion") {
|
|
184
206
|
emit(sessionId, e);
|
|
@@ -940,6 +962,7 @@ export async function reattachChats(): Promise<void> {
|
|
|
940
962
|
if (!live.has(id)) await resumeInterrupted(id);
|
|
941
963
|
for (const id of queuedSessions(Date.now() - RESUME_WITHIN_MS))
|
|
942
964
|
if (!live.has(id)) await resumeQueue(id);
|
|
965
|
+
await recheckPlanning((id, m) => sendQueuedNow(id, m), planEnv);
|
|
943
966
|
}
|
|
944
967
|
|
|
945
968
|
// Asks a chat's worker to end at its next turn boundary, so the next message
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { db, type Session } from "../db";
|
|
12
|
-
import { enqueue } from "../chat/queued";
|
|
12
|
+
import { deleteQueued, enqueue } from "../chat/queued";
|
|
13
13
|
import { applySessionModel } from "./session-model";
|
|
14
14
|
import {
|
|
15
15
|
resolveRole,
|
|
@@ -31,9 +31,17 @@ export function planPrompt(task: string, only = false): string {
|
|
|
31
31
|
return `${task}
|
|
32
32
|
|
|
33
33
|
---
|
|
34
|
-
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. End the turn once the plan is written: ${after}.`;
|
|
34
|
+
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}.`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export const PLAN_RETRY_PROMPT = `Write the plan to \`${PLAN_FILE}\` now, plan only.`;
|
|
38
|
+
export const planRetryMessageId = (sessionId: string) =>
|
|
39
|
+
`plan-retry-${sessionId}`;
|
|
40
|
+
|
|
41
|
+
// How long a planning turn may sit ended on background work before it's
|
|
42
|
+
// treated as having nothing running and re-prompted.
|
|
43
|
+
export const PLAN_BACKGROUND_WAIT_MS = 20 * 60 * 1000;
|
|
44
|
+
|
|
37
45
|
export function implementPrompt(planned: boolean, amendment?: string): string {
|
|
38
46
|
const base = planned
|
|
39
47
|
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.`
|
|
@@ -80,7 +88,7 @@ export function planSummary(text: string, max = 5): string[] {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
// Tells the orchestrator that started a task its plan is written, once.
|
|
83
|
-
function announcePlan(s: Session, ref: string | null): void {
|
|
91
|
+
function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
84
92
|
if (!s.started_by && !s.held_by_device) return;
|
|
85
93
|
const workspaceId = sessionWorkspace(s);
|
|
86
94
|
// A task held for another machine's orchestrator can't reach it: the line
|
|
@@ -91,7 +99,8 @@ function announcePlan(s: Session, ref: string | null): void {
|
|
|
91
99
|
const next = ref
|
|
92
100
|
? `implementing on ${ref}`
|
|
93
101
|
: "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
|
|
94
|
-
|
|
102
|
+
const again = retried ? " (asked once more for it first)" : "";
|
|
103
|
+
let head = `Task "${s.name}": no plan file was written${again}; ${next}${ref ? " anyway" : ""}.`;
|
|
95
104
|
try {
|
|
96
105
|
const summary = planSummary(fs.readFileSync(file, "utf8"));
|
|
97
106
|
head = `Task "${s.name}": plan written to ${file}; ${next}.`;
|
|
@@ -128,7 +137,8 @@ async function startImplementing(
|
|
|
128
137
|
s: Session,
|
|
129
138
|
from: "planning" | "awaiting",
|
|
130
139
|
send: Send,
|
|
131
|
-
amendment?: string
|
|
140
|
+
amendment?: string,
|
|
141
|
+
retried = false
|
|
132
142
|
): Promise<string | null> {
|
|
133
143
|
const sessionId = s.id;
|
|
134
144
|
const routed = resolveRole("implement", {
|
|
@@ -158,7 +168,7 @@ async function startImplementing(
|
|
|
158
168
|
if (!claimed) return null;
|
|
159
169
|
if (from === "planning") {
|
|
160
170
|
try {
|
|
161
|
-
announcePlan(s, routed.ref);
|
|
171
|
+
announcePlan(s, routed.ref, retried);
|
|
162
172
|
} catch (e) {
|
|
163
173
|
console.error("plan-done event failed:", e);
|
|
164
174
|
}
|
|
@@ -168,12 +178,57 @@ async function startImplementing(
|
|
|
168
178
|
return routed.ref;
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
// Per planning task, kept in memory: when its turn first ended on background
|
|
182
|
+
// work, and whether it has been asked once more for the plan. A restart
|
|
183
|
+
// forgets both, which costs at most one more re-prompt.
|
|
184
|
+
const waiting = new Map<
|
|
185
|
+
string,
|
|
186
|
+
{ since: number; retried: boolean; timer?: ReturnType<typeof setTimeout> }
|
|
187
|
+
>();
|
|
188
|
+
|
|
189
|
+
export interface PlanHandoffEnv {
|
|
190
|
+
// Whether the session's agent still runs subagents or background shells.
|
|
191
|
+
backgroundRunning?: () => boolean;
|
|
192
|
+
// Whether the chat is in the middle of a turn right now.
|
|
193
|
+
turnRunning?: () => boolean;
|
|
194
|
+
waitMs?: number;
|
|
195
|
+
now?: () => number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stateOf(sessionId: string) {
|
|
199
|
+
let w = waiting.get(sessionId);
|
|
200
|
+
if (!w) waiting.set(sessionId, (w = { since: 0, retried: false }));
|
|
201
|
+
return w;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function forget(sessionId: string): void {
|
|
205
|
+
clearTimeout(waiting.get(sessionId)?.timer);
|
|
206
|
+
waiting.delete(sessionId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A task dropped, stopped or done: nothing left to wait for.
|
|
210
|
+
export const forgetPlanWait = forget;
|
|
211
|
+
|
|
212
|
+
// A new turn began (the agent resumed with its background results): whatever
|
|
213
|
+
// the wait was counting is stale, and the deadline restarts at the next idle.
|
|
214
|
+
export function planTurnStarted(sessionId: string): void {
|
|
215
|
+
const w = waiting.get(sessionId);
|
|
216
|
+
if (!w) return;
|
|
217
|
+
clearTimeout(w.timer);
|
|
218
|
+
w.timer = undefined;
|
|
219
|
+
w.since = 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
171
222
|
// Called when a chat goes idle. A task whose plan turn has ended (it has a
|
|
172
223
|
// recorded plan turn) moves to implementing, once; a plan-only task instead
|
|
173
|
-
// waits for approval (approvePlan), holding no agent process.
|
|
224
|
+
// waits for approval (approvePlan), holding no agent process. A turn that
|
|
225
|
+
// ended with no plan file isn't the end of planning while its background
|
|
226
|
+
// work runs (the agent resumes with the results): it waits, up to a limit,
|
|
227
|
+
// then asks once for the plan before falling back to today's behaviour.
|
|
174
228
|
export async function handoffAfterPlan(
|
|
175
229
|
sessionId: string,
|
|
176
|
-
send: Send
|
|
230
|
+
send: Send,
|
|
231
|
+
env: PlanHandoffEnv = {}
|
|
177
232
|
): Promise<boolean> {
|
|
178
233
|
const planned = db
|
|
179
234
|
.prepare(
|
|
@@ -182,18 +237,74 @@ export async function handoffAfterPlan(
|
|
|
182
237
|
.get(sessionId);
|
|
183
238
|
if (!planned) return false;
|
|
184
239
|
const s = getSession(sessionId);
|
|
185
|
-
if (s?.plan_phase !== "planning" || !s.worktree_path)
|
|
240
|
+
if (s?.plan_phase !== "planning" || !s.worktree_path) {
|
|
241
|
+
forget(sessionId);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (s.task_status !== "running" || s.archived_at) {
|
|
245
|
+
forget(sessionId);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
// Mid-turn, the turn's own end decides.
|
|
249
|
+
if (env.turnRunning?.()) return false;
|
|
250
|
+
if (!fs.existsSync(path.join(s.worktree_path, PLAN_FILE))) {
|
|
251
|
+
const w = stateOf(sessionId);
|
|
252
|
+
const now = (env.now ?? Date.now)();
|
|
253
|
+
const limit = env.waitMs ?? PLAN_BACKGROUND_WAIT_MS;
|
|
254
|
+
if (env.backgroundRunning?.()) {
|
|
255
|
+
if (!w.since) w.since = now;
|
|
256
|
+
if (now - w.since < limit) {
|
|
257
|
+
clearTimeout(w.timer);
|
|
258
|
+
// No further turn may come to re-check this; the timer does.
|
|
259
|
+
w.timer = setTimeout(
|
|
260
|
+
() =>
|
|
261
|
+
void handoffAfterPlan(sessionId, send, env).catch((e) =>
|
|
262
|
+
console.error(`Plan handoff for ${sessionId} failed:`, e)
|
|
263
|
+
),
|
|
264
|
+
Math.max(limit - (now - w.since), 1) + 1
|
|
265
|
+
);
|
|
266
|
+
w.timer.unref?.();
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!w.retried) {
|
|
271
|
+
w.retried = true;
|
|
272
|
+
w.since = 0;
|
|
273
|
+
clearTimeout(w.timer);
|
|
274
|
+
const id = planRetryMessageId(sessionId);
|
|
275
|
+
enqueue(sessionId, {
|
|
276
|
+
id,
|
|
277
|
+
text: PLAN_RETRY_PROMPT,
|
|
278
|
+
origin: { kind: "decision", label: "Plan step" },
|
|
279
|
+
});
|
|
280
|
+
try {
|
|
281
|
+
await send(sessionId, id);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// Not left queued behind a turn that may write the plan itself.
|
|
284
|
+
deleteQueued(sessionId, id);
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// A re-prompt still queued is stale now.
|
|
291
|
+
deleteQueued(sessionId, planRetryMessageId(sessionId));
|
|
292
|
+
const retried = waiting.get(sessionId)?.retried ?? false;
|
|
293
|
+
forget(sessionId);
|
|
186
294
|
if (!s.plan_only)
|
|
187
|
-
return (
|
|
188
|
-
|
|
295
|
+
return (
|
|
296
|
+
(await startImplementing(s, "planning", send, undefined, retried)) !==
|
|
297
|
+
null
|
|
298
|
+
);
|
|
299
|
+
const awaiting = db
|
|
189
300
|
.prepare(
|
|
190
301
|
`UPDATE sessions SET plan_phase = 'awaiting'
|
|
191
302
|
WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
|
|
192
303
|
)
|
|
193
304
|
.run(sessionId).changes;
|
|
194
|
-
if (!
|
|
305
|
+
if (!awaiting) return false;
|
|
195
306
|
try {
|
|
196
|
-
announcePlan(s, null);
|
|
307
|
+
announcePlan(s, null, retried);
|
|
197
308
|
} catch (e) {
|
|
198
309
|
console.error("plan-done event failed:", e);
|
|
199
310
|
}
|
|
@@ -207,6 +318,26 @@ export async function handoffAfterPlan(
|
|
|
207
318
|
return true;
|
|
208
319
|
}
|
|
209
320
|
|
|
321
|
+
// On server start: tasks still planning get the same check an idle chat
|
|
322
|
+
// does, since the idle that would have run it may have come and gone while
|
|
323
|
+
// the server was down. `envFor` reads the live chat's state.
|
|
324
|
+
export async function recheckPlanning(
|
|
325
|
+
send: Send,
|
|
326
|
+
envFor: (sessionId: string) => PlanHandoffEnv
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const rows = db
|
|
329
|
+
.prepare(
|
|
330
|
+
`SELECT id FROM sessions
|
|
331
|
+
WHERE plan_phase = 'planning' AND task_status = 'running'
|
|
332
|
+
AND archived_at IS NULL AND worktree_path IS NOT NULL`
|
|
333
|
+
)
|
|
334
|
+
.all() as { id: string }[];
|
|
335
|
+
for (const { id } of rows)
|
|
336
|
+
await handoffAfterPlan(id, send, envFor(id)).catch((e) =>
|
|
337
|
+
console.error(`Plan re-check for ${id} failed:`, e)
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
210
341
|
// approvePlan, sending through the chat runner (imported when needed: it
|
|
211
342
|
// imports this file).
|
|
212
343
|
export async function approvePlanNow(sessionId: string, message?: string) {
|
package/lib/notify/copy.ts
CHANGED
|
@@ -55,21 +55,21 @@ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
|
|
|
55
55
|
level: "active",
|
|
56
56
|
defaultOn: true,
|
|
57
57
|
label: "CI and review problems",
|
|
58
|
-
word: "
|
|
58
|
+
word: "CI or review failed",
|
|
59
59
|
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
60
60
|
},
|
|
61
61
|
merged: {
|
|
62
62
|
level: "passive",
|
|
63
63
|
defaultOn: false,
|
|
64
64
|
label: "Merged",
|
|
65
|
-
word: "
|
|
65
|
+
word: "Merged",
|
|
66
66
|
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
67
67
|
},
|
|
68
68
|
agents: {
|
|
69
69
|
level: "active",
|
|
70
70
|
defaultOn: true,
|
|
71
71
|
label: "Messages from agents",
|
|
72
|
-
word: "
|
|
72
|
+
word: "Agent message",
|
|
73
73
|
link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
|
|
74
74
|
},
|
|
75
75
|
digest: {
|
|
@@ -83,7 +83,7 @@ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
|
|
|
83
83
|
level: "active",
|
|
84
84
|
defaultOn: true,
|
|
85
85
|
label: "Machine alerts",
|
|
86
|
-
word: "
|
|
86
|
+
word: "Machine alert",
|
|
87
87
|
link: () => "/machines",
|
|
88
88
|
},
|
|
89
89
|
};
|
package/lib/sessions/end.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { stopChat } from "../chat/runner";
|
|
|
3
3
|
import { waitForExit } from "../chat/worker/client";
|
|
4
4
|
import { endTmuxSession } from "../tmux/end";
|
|
5
5
|
import { releaseOnEnd } from "../simulator";
|
|
6
|
+
import { forgetPlanWait } from "../models/plan-step";
|
|
6
7
|
|
|
7
8
|
type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
8
9
|
|
|
@@ -10,6 +11,7 @@ type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
|
10
11
|
// chat session can still own one). The one call that delete, done, drop and
|
|
11
12
|
// finish share.
|
|
12
13
|
export async function endSessionAgent(s: Agent): Promise<void> {
|
|
14
|
+
forgetPlanWait(s.id);
|
|
13
15
|
stopChat(s.id);
|
|
14
16
|
// The task's simulator goes with it; no row, no work.
|
|
15
17
|
releaseOnEnd(s.id, { remove: true });
|
package/lib/sessions/stop.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { stopChatTurn } from "../chat/stop";
|
|
|
4
4
|
import type { Session } from "../db";
|
|
5
5
|
import { stopHeldTask } from "../tasks/held-remote";
|
|
6
6
|
import { releaseOnEnd } from "../simulator";
|
|
7
|
+
import { forgetPlanWait } from "../models/plan-step";
|
|
7
8
|
|
|
8
9
|
// Stops a session's agent and keeps everything else, the same for every
|
|
9
10
|
// caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
|
|
@@ -31,6 +32,7 @@ export async function stopSession(
|
|
|
31
32
|
throw new Error(
|
|
32
33
|
`Couldn't stop ${s.name}: it runs on another machine; stop it there.`
|
|
33
34
|
);
|
|
35
|
+
forgetPlanWait(s.id);
|
|
34
36
|
const out = await stopChatTurn(s.id);
|
|
35
37
|
if (!out.stopped)
|
|
36
38
|
throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
|