@saadnvd1/agent-os 0.3.591 → 0.3.594
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/app/api/bus/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/app/globals.css +11 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/ChatPanel.tsx +45 -11
- package/components/Chat/Messages.test.ts +40 -1
- package/components/Chat/Messages.tsx +96 -26
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/Chat/PlanCard.tsx +4 -2
- package/components/Chat/Undo.tsx +3 -0
- package/components/Chat/idle.ts +9 -0
- package/components/SessionList/RowSubtitle.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/data/chat/useChat.ts +83 -13
- package/data/chat/usePending.ts +70 -0
- package/data/done/index.ts +17 -0
- package/lib/chat/client-send.ts +8 -1
- package/lib/chat/demo.ts +22 -3
- package/lib/chat/drivers/claude.ts +15 -2
- package/lib/chat/drivers/opencode.ts +1 -0
- package/lib/chat/events.ts +7 -2
- package/lib/chat/pending.ts +169 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/socket.ts +12 -1
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ChatImage } from "./events";
|
|
2
|
+
|
|
3
|
+
// A message the composer sent that isn't in the chat or its queue yet. It
|
|
4
|
+
// shows as a bubble at once, and stays (as failed, with Retry) if it never
|
|
5
|
+
// arrives, so a long paste is never lost to a dead socket.
|
|
6
|
+
export interface PendingSend {
|
|
7
|
+
id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
images?: ChatImage[];
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
// "sending": not taken in yet. "accepted": the server has it and is
|
|
12
|
+
// starting the agent.
|
|
13
|
+
status: "sending" | "accepted" | "failed";
|
|
14
|
+
error?: string;
|
|
15
|
+
// When it was last sent or accepted, for the timeouts below.
|
|
16
|
+
at: number;
|
|
17
|
+
// Sends so far; each reconnect resends once, up to MAX_SENDS.
|
|
18
|
+
sends: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PendingAction =
|
|
22
|
+
| {
|
|
23
|
+
type: "add";
|
|
24
|
+
send: Omit<PendingSend, "status" | "at" | "sends">;
|
|
25
|
+
now: number;
|
|
26
|
+
}
|
|
27
|
+
// Sent again on a new socket, or by Retry.
|
|
28
|
+
| { type: "resent"; id: string; now: number }
|
|
29
|
+
| { type: "accepted"; id: string; now: number }
|
|
30
|
+
| { type: "failed"; id: string; error: string }
|
|
31
|
+
// Ids now in the chat or its queue: those sends are done.
|
|
32
|
+
| { type: "resolved"; ids: Iterable<string> }
|
|
33
|
+
| { type: "drop"; id: string }
|
|
34
|
+
| { type: "tick"; now: number };
|
|
35
|
+
|
|
36
|
+
// No answer at all: the socket is likely dead though not yet closed.
|
|
37
|
+
export const SEND_TIMEOUT_MS = 10_000;
|
|
38
|
+
// Taken in but never shown: a new agent takes seconds, not a minute.
|
|
39
|
+
export const ACCEPTED_TIMEOUT_MS = 60_000;
|
|
40
|
+
export const MAX_SENDS = 3;
|
|
41
|
+
|
|
42
|
+
export const NOT_SENT = "connection lost";
|
|
43
|
+
const NO_ANSWER = "no answer from AgentOS";
|
|
44
|
+
const NEVER_ARRIVED = "the agent didn't take it";
|
|
45
|
+
|
|
46
|
+
export function pendingReducer(
|
|
47
|
+
state: PendingSend[],
|
|
48
|
+
action: PendingAction
|
|
49
|
+
): PendingSend[] {
|
|
50
|
+
const update = (id: string, fn: (p: PendingSend) => PendingSend | null) => {
|
|
51
|
+
const i = state.findIndex((p) => p.id === id);
|
|
52
|
+
if (i === -1) return state;
|
|
53
|
+
const next = fn(state[i]);
|
|
54
|
+
if (next === state[i]) return state;
|
|
55
|
+
const out = state.slice();
|
|
56
|
+
if (next) out[i] = next;
|
|
57
|
+
else out.splice(i, 1);
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
switch (action.type) {
|
|
61
|
+
case "add":
|
|
62
|
+
if (state.some((p) => p.id === action.send.id)) return state;
|
|
63
|
+
return [
|
|
64
|
+
...state,
|
|
65
|
+
{ ...action.send, status: "sending", at: action.now, sends: 1 },
|
|
66
|
+
];
|
|
67
|
+
case "resent":
|
|
68
|
+
return update(action.id, (p) => ({
|
|
69
|
+
...p,
|
|
70
|
+
status: "sending",
|
|
71
|
+
error: undefined,
|
|
72
|
+
at: action.now,
|
|
73
|
+
sends: p.sends + 1,
|
|
74
|
+
}));
|
|
75
|
+
case "accepted":
|
|
76
|
+
return update(action.id, (p) =>
|
|
77
|
+
p.status === "sending"
|
|
78
|
+
? { ...p, status: "accepted", at: action.now }
|
|
79
|
+
: p
|
|
80
|
+
);
|
|
81
|
+
case "failed":
|
|
82
|
+
return update(action.id, (p) => ({
|
|
83
|
+
...p,
|
|
84
|
+
status: "failed",
|
|
85
|
+
error: action.error,
|
|
86
|
+
}));
|
|
87
|
+
case "resolved": {
|
|
88
|
+
const ids = new Set(action.ids);
|
|
89
|
+
const kept = state.filter((p) => !ids.has(p.id));
|
|
90
|
+
return kept.length === state.length ? state : kept;
|
|
91
|
+
}
|
|
92
|
+
case "drop":
|
|
93
|
+
return update(action.id, () => null);
|
|
94
|
+
case "tick": {
|
|
95
|
+
let changed = false;
|
|
96
|
+
const next = state.map((p) => {
|
|
97
|
+
const limit =
|
|
98
|
+
p.status === "sending"
|
|
99
|
+
? SEND_TIMEOUT_MS
|
|
100
|
+
: p.status === "accepted"
|
|
101
|
+
? ACCEPTED_TIMEOUT_MS
|
|
102
|
+
: Infinity;
|
|
103
|
+
if (action.now - p.at < limit) return p;
|
|
104
|
+
changed = true;
|
|
105
|
+
return {
|
|
106
|
+
...p,
|
|
107
|
+
status: "failed" as const,
|
|
108
|
+
error: p.status === "sending" ? NO_ANSWER : NEVER_ARRIVED,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return changed ? next : state;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// What a new socket sends again: anything not failed and not over its sends.
|
|
117
|
+
// Safe to repeat: the server drops an id already in the chat or its queue.
|
|
118
|
+
export const toResend = (state: PendingSend[]) =>
|
|
119
|
+
state.filter((p) => p.status !== "failed" && p.sends < MAX_SENDS);
|
|
120
|
+
|
|
121
|
+
// A new socket that can't send again what's still pending marks it failed.
|
|
122
|
+
export const overSends = (state: PendingSend[]) =>
|
|
123
|
+
state.filter((p) => p.status !== "failed" && p.sends >= MAX_SENDS);
|
|
124
|
+
|
|
125
|
+
// getRandomValues, not randomUUID: a phone on plain http over the tailnet
|
|
126
|
+
// isn't a secure context.
|
|
127
|
+
export const clientId = () =>
|
|
128
|
+
`user-c-${Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) =>
|
|
129
|
+
b.toString(16).padStart(2, "0")
|
|
130
|
+
).join("")}`;
|
|
131
|
+
|
|
132
|
+
const keyFor = (sessionId: string) => `agentos:pending:${sessionId}`;
|
|
133
|
+
|
|
134
|
+
// Kept in this browser so a reload doesn't lose a failed article. Over
|
|
135
|
+
// quota, images go first and the words stay.
|
|
136
|
+
export function savePending(sessionId: string, state: PendingSend[]): void {
|
|
137
|
+
try {
|
|
138
|
+
const key = keyFor(sessionId);
|
|
139
|
+
if (!state.length) return localStorage.removeItem(key);
|
|
140
|
+
for (const kept of [
|
|
141
|
+
state,
|
|
142
|
+
state.map((p) => ({ ...p, images: undefined })),
|
|
143
|
+
]) {
|
|
144
|
+
try {
|
|
145
|
+
localStorage.setItem(key, JSON.stringify(kept));
|
|
146
|
+
return;
|
|
147
|
+
} catch {
|
|
148
|
+
// Too big; try without images.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Storage unavailable.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function loadPending(sessionId: string): PendingSend[] {
|
|
157
|
+
try {
|
|
158
|
+
const raw = localStorage.getItem(keyFor(sessionId));
|
|
159
|
+
const list = raw ? (JSON.parse(raw) as PendingSend[]) : [];
|
|
160
|
+
// From an earlier page: each is sent once more on connecting.
|
|
161
|
+
return Array.isArray(list)
|
|
162
|
+
? list.filter(
|
|
163
|
+
(p) => typeof p?.id === "string" && typeof p.text === "string"
|
|
164
|
+
)
|
|
165
|
+
: [];
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
package/lib/chat/runner.ts
CHANGED
|
@@ -384,6 +384,10 @@ const sentBy = ({ from, peer, origin }: SentBy): SentBy => ({
|
|
|
384
384
|
origin,
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
// Already a message in the chat or its queue.
|
|
388
|
+
const taken = (sessionId: string, id: string) =>
|
|
389
|
+
hasItem(sessionId, id) || listQueue(sessionId).some((q) => q.id === id);
|
|
390
|
+
|
|
387
391
|
export async function sendChat(
|
|
388
392
|
sessionId: string,
|
|
389
393
|
input: {
|
|
@@ -393,16 +397,20 @@ export async function sendChat(
|
|
|
393
397
|
queue?: boolean;
|
|
394
398
|
// Compact a stale chat first (lib/chat/stale).
|
|
395
399
|
compact?: CompactFirst;
|
|
400
|
+
// The client's own id for it, so a resend after a reconnect is a no-op.
|
|
401
|
+
id?: string;
|
|
396
402
|
} & SentBy
|
|
397
403
|
): Promise<void> {
|
|
398
404
|
const text = input.text.trim();
|
|
399
405
|
if (!text && !input.images?.length) return;
|
|
406
|
+
if (input.id && taken(sessionId, input.id)) return;
|
|
400
407
|
refuseAway(sessionId);
|
|
408
|
+
const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
|
|
401
409
|
// Its worktree is still being set up, its task hasn't launched (held by
|
|
402
410
|
// Pause, say), or it's moving or landing: the message waits its turn.
|
|
403
411
|
if (settingUp(sessionId) || launchPending(sessionId) || chatHold(sessionId)) {
|
|
404
412
|
enqueue(sessionId, {
|
|
405
|
-
id
|
|
413
|
+
id,
|
|
406
414
|
text,
|
|
407
415
|
images: input.images,
|
|
408
416
|
...sentBy(input),
|
|
@@ -411,7 +419,8 @@ export async function sendChat(
|
|
|
411
419
|
return;
|
|
412
420
|
}
|
|
413
421
|
const live = await ensureLive(sessionId);
|
|
414
|
-
|
|
422
|
+
// Sent again while the worker was starting.
|
|
423
|
+
if (input.id && taken(sessionId, id)) return;
|
|
415
424
|
if (queuedIfHeld(sessionId, { ...input, id, text })) return;
|
|
416
425
|
live.worker.command({
|
|
417
426
|
type: "send",
|
package/lib/chat/socket.ts
CHANGED
|
@@ -145,7 +145,18 @@ function onChatMessage(
|
|
|
145
145
|
void Promise.resolve(p).catch(fail);
|
|
146
146
|
if (msg.type === "send") {
|
|
147
147
|
const send = clientSend(msg);
|
|
148
|
-
|
|
148
|
+
const id = send.id;
|
|
149
|
+
// Taken in now: a new worker can take seconds to start.
|
|
150
|
+
if (id) reply({ type: "accepted", id });
|
|
151
|
+
void sendChat(sessionId, { ...send, queue: true }).catch((err) => {
|
|
152
|
+
if (id)
|
|
153
|
+
reply({
|
|
154
|
+
type: "send_failed",
|
|
155
|
+
id,
|
|
156
|
+
message: err instanceof Error ? err.message : String(err),
|
|
157
|
+
});
|
|
158
|
+
fail(err);
|
|
159
|
+
});
|
|
149
160
|
// "Session 4" is named after its first message.
|
|
150
161
|
run(titleChatFromMessage(sessionId, send.text));
|
|
151
162
|
} else if (msg.type === "queue_edit" && typeof msg.text === "string")
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2298,6 +2298,17 @@ export const migrations: Migration[] = [
|
|
|
2298
2298
|
`);
|
|
2299
2299
|
},
|
|
2300
2300
|
},
|
|
2301
|
+
{
|
|
2302
|
+
key: "20261014T1900_pause_note",
|
|
2303
|
+
up: (db) => {
|
|
2304
|
+
// Why a resume that was asked for hasn't happened yet (waiting for a
|
|
2305
|
+
// slot, held by load), shown on the task row. Additive.
|
|
2306
|
+
const has = (
|
|
2307
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
2308
|
+
).some((c) => c.name === "pause_note");
|
|
2309
|
+
if (!has) db.exec(`ALTER TABLE sessions ADD COLUMN pause_note TEXT`);
|
|
2310
|
+
},
|
|
2311
|
+
},
|
|
2301
2312
|
{
|
|
2302
2313
|
key: "20261014T2000_chat_bookmarks",
|
|
2303
2314
|
up: (db) => {
|
package/lib/db/types.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface Session {
|
|
|
92
92
|
pause_state?: "pausing" | "paused" | "resuming" | null;
|
|
93
93
|
paused_until?: string | null;
|
|
94
94
|
pause_id?: number | null;
|
|
95
|
+
// Why a requested resume hasn't happened yet.
|
|
96
|
+
pause_note?: string | null;
|
|
95
97
|
placed_note?: string | null;
|
|
96
98
|
// Set by done: hidden from the sidebar and the orchestrator, never deleted.
|
|
97
99
|
archived_at: string | null;
|
package/lib/done/worktree.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getProject } from "../projects";
|
|
|
13
13
|
import { deleteWorktree, isAgentOSWorktree } from "../worktrees";
|
|
14
14
|
import { removalRunning, removalSettled } from "../worktree-removal";
|
|
15
15
|
import { describeChanges, unsavedChanges } from "../worktree-placed";
|
|
16
|
+
import { dropSnapshot, snapshotWorktree } from "../resurrect/snapshot";
|
|
16
17
|
|
|
17
18
|
export type WorktreeFate =
|
|
18
19
|
| { action: "none" }
|
|
@@ -100,7 +101,7 @@ export type FatePreview =
|
|
|
100
101
|
| { action: "remove"; why: string; repo: string };
|
|
101
102
|
|
|
102
103
|
type Judged = Pick<Session, "worktree_path" | "base_branch"> &
|
|
103
|
-
Partial<Pick<Session, "project_id">>;
|
|
104
|
+
Partial<Pick<Session, "project_id" | "id">>;
|
|
104
105
|
|
|
105
106
|
// Gone, or still going: removed by whatever else got to it first.
|
|
106
107
|
const gone = (merged: MergedAs | null, still = false) => ({
|
|
@@ -215,8 +216,23 @@ export async function settleWorktree(
|
|
|
215
216
|
merged: MergedAs | null,
|
|
216
217
|
movedAway = false
|
|
217
218
|
): Promise<WorktreeFate> {
|
|
219
|
+
// Unmerged work is saved for a restore while the fate is judged, and kept
|
|
220
|
+
// only if the worktree is really removed.
|
|
221
|
+
const snapshot =
|
|
222
|
+
!merged && !movedAway && session.id && session.worktree_path
|
|
223
|
+
? snapshotWorktree(session.worktree_path, session.id)
|
|
224
|
+
: null;
|
|
218
225
|
const fate = await worktreeFate(session, merged, undefined, movedAway);
|
|
219
|
-
if (fate.action !== "remove")
|
|
226
|
+
if (fate.action !== "remove") {
|
|
227
|
+
if (snapshot && fate.action === "kept") {
|
|
228
|
+
const { path } = fate;
|
|
229
|
+
void snapshot.then((made) => {
|
|
230
|
+
if (made) void dropSnapshot(path, session.id!);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return fate;
|
|
234
|
+
}
|
|
235
|
+
await snapshot;
|
|
220
236
|
const path = session.worktree_path!;
|
|
221
237
|
await deleteWorktree(path, fate.repo, true).catch(() => {});
|
|
222
238
|
if (fs.existsSync(path))
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -36,7 +36,9 @@ import type { StackItemView } from "../stacks/types";
|
|
|
36
36
|
import { doneSession } from "../done";
|
|
37
37
|
import { archiveChecked } from "../done/archive";
|
|
38
38
|
import { amendBrief, amendmentsOf } from "./amendments";
|
|
39
|
-
import type
|
|
39
|
+
import { db, type Session } from "../db";
|
|
40
|
+
import { resurrectSession } from "../resurrect";
|
|
41
|
+
import { resolveEnded } from "../resurrect/find";
|
|
40
42
|
import { approvePlanNow } from "../models/plan-step";
|
|
41
43
|
import { braked } from "./brakes";
|
|
42
44
|
import { getOrchestrator } from "./home";
|
|
@@ -54,7 +56,7 @@ import {
|
|
|
54
56
|
PauseRefused,
|
|
55
57
|
activePause,
|
|
56
58
|
requestPause,
|
|
57
|
-
|
|
59
|
+
resumeNow,
|
|
58
60
|
} from "../tasks/pause";
|
|
59
61
|
import { pauseUntil, type PausePreset } from "../sidebar/snooze";
|
|
60
62
|
import type { Priority } from "../tasks/schedule-types";
|
|
@@ -411,6 +413,30 @@ export async function archive(
|
|
|
411
413
|
return `Archived ${s.name}: out of the sidebar, in Archived.`;
|
|
412
414
|
}
|
|
413
415
|
|
|
416
|
+
// Brings a dropped, done or archived session back, same id and conversation.
|
|
417
|
+
// A task takes a running slot again, so it goes through the brakes like a
|
|
418
|
+
// start; an unarchive or reopen starts nothing.
|
|
419
|
+
export async function resurrect(
|
|
420
|
+
workspaceId: string,
|
|
421
|
+
ref: string,
|
|
422
|
+
reason: string
|
|
423
|
+
): Promise<string> {
|
|
424
|
+
if (!reason.trim()) throw new Error("Say why it's restored");
|
|
425
|
+
const id = resolveEnded(ref, { workspaceId });
|
|
426
|
+
const row = db
|
|
427
|
+
.prepare(`SELECT task_status, archived_at FROM sessions WHERE id = ?`)
|
|
428
|
+
.get(id) as { task_status: string | null; archived_at: string | null };
|
|
429
|
+
const task =
|
|
430
|
+
row.task_status === "dropped" ||
|
|
431
|
+
(row.task_status === "done" && !row.archived_at);
|
|
432
|
+
const go = () => resurrectSession(id);
|
|
433
|
+
const out = task
|
|
434
|
+
? await braked(workspaceId, "task", go, (o) => o.id)
|
|
435
|
+
: await go();
|
|
436
|
+
addNote(workspaceId, `Restored ${out.name}: ${reason.trim()}`);
|
|
437
|
+
return out.text;
|
|
438
|
+
}
|
|
439
|
+
|
|
414
440
|
// Stops the agent and keeps everything else: a chat's turn is interrupted,
|
|
415
441
|
// a terminal's tmux session ends. Its worktree and branch stay.
|
|
416
442
|
export async function stop(workspaceId: string, ref: string): Promise<string> {
|
|
@@ -507,14 +533,27 @@ export function pauseTask(
|
|
|
507
533
|
return `Pausing ${task.name} ${now ? "now" : "at the end of its step"}. Its slot frees once it has stopped; an event says when.`;
|
|
508
534
|
}
|
|
509
535
|
|
|
510
|
-
export function resumeTask(
|
|
536
|
+
export async function resumeTask(
|
|
537
|
+
workspaceId: string,
|
|
538
|
+
ref: string
|
|
539
|
+
): Promise<string> {
|
|
511
540
|
const task = workspaceTask(workspaceId, ref);
|
|
512
541
|
if (!activePause(task.id)) throw new Error(`${task.name} isn't paused`);
|
|
542
|
+
const slots = task.project_id ? slotState(task.project_id) : null;
|
|
543
|
+
let out;
|
|
513
544
|
try {
|
|
514
|
-
|
|
545
|
+
out = await resumeNow(task.id, "orchestrator");
|
|
515
546
|
} catch (error) {
|
|
516
547
|
if (error instanceof PauseRefused) throw new Error(error.message);
|
|
517
548
|
throw error;
|
|
518
549
|
}
|
|
519
|
-
|
|
550
|
+
if (out.outcome === "held")
|
|
551
|
+
return `${task.name} is ${out.note}. It resumes by itself when that clears.`;
|
|
552
|
+
if (out.outcome === "cancelled")
|
|
553
|
+
return `${task.name} keeps running: its pause was cancelled.`;
|
|
554
|
+
const past = slots?.full
|
|
555
|
+
? ` past the running-task limit (${slots.running} of ${slots.limit} running)`
|
|
556
|
+
: "";
|
|
557
|
+
addNote(workspaceId, `Resumed ${task.name} now${past}.`);
|
|
558
|
+
return `Resumed ${task.name} now${past}. It carries on with what was held for it.`;
|
|
520
559
|
}
|
|
@@ -137,10 +137,11 @@ Acting:
|
|
|
137
137
|
- \`${TOOL_NAMES.set_priority}\` (task, priority): re-rank a queued task (urgent, blocking, normal, background), or tag a running one. Urgent is a bug Saad hit; background starts last.
|
|
138
138
|
- \`${TOOL_NAMES.start_now}\` (task, reason): start a queued task now, past the running-task limit and its after. Still refused while paused or braked, and a Mac-only task stays queued. Use it sparingly, when Saad asks or it unblocks others; the reason is logged for Saad.
|
|
139
139
|
- \`${TOOL_NAMES.pause_task}\` (task, for, now?, reason): pause a running task for 1h, 2h, until tomorrow 9 AM or until resumed. Its agent ends at the end of its step (now interrupts the step); its worktree and conversation stay and its slot frees for the next queued task. It resumes by itself, ahead of new arrivals, with what was held (CI and review notes, messages) in one prompt. Use it to make room for urgent work or to wait out a loaded machine. Refused for a task that is merging, moving, setting up or waiting on Saad.
|
|
140
|
-
- \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now. A pause Saad set is his to lift.
|
|
140
|
+
- \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now, past the running-task limit unless the machine load holds it. A pause Saad set is his to lift.
|
|
141
141
|
- \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
|
|
142
142
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
|
|
143
143
|
- \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
|
|
144
|
+
- \`${TOOL_NAMES.resurrect}\` (session, reason): bring a dropped, done or archived session back with its conversation and worktree, in the state it had (a plan-only task waits for approval again). Use it when a drop or done was a mistake, or when Saad asks; refused for a merged PR, a missing conversation or a removed project. A restored task counts against the running limit and the brakes like a start.
|
|
144
145
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
145
146
|
- \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
|
|
146
147
|
- \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
|
|
@@ -58,6 +58,7 @@ interface TaskRow {
|
|
|
58
58
|
armed_at: string | null;
|
|
59
59
|
pause_state: string | null;
|
|
60
60
|
paused_until: string | null;
|
|
61
|
+
pause_note: string | null;
|
|
61
62
|
placed_note: string | null;
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -165,7 +166,7 @@ function tasksSection(
|
|
|
165
166
|
const tasks = db
|
|
166
167
|
.prepare(
|
|
167
168
|
`SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
|
|
168
|
-
s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.placed_note
|
|
169
|
+
s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.pause_note, s.placed_note
|
|
169
170
|
FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
170
171
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
171
172
|
AND s.task_status = 'running'
|
|
@@ -181,7 +182,7 @@ function tasksSection(
|
|
|
181
182
|
const gates = gateState(checks.get(t.id), failures.get(t.id));
|
|
182
183
|
const arm = t.armed_at ? " | armed to merge" : "";
|
|
183
184
|
const paused = t.pause_state
|
|
184
|
-
? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}`
|
|
185
|
+
? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}${t.pause_note ? ` (${t.pause_note})` : ""}`
|
|
185
186
|
: "";
|
|
186
187
|
const placed = t.placed_note ? ` | ${t.placed_note}` : "";
|
|
187
188
|
return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${paused}${placed}${gates ? ` | ${gates}` : ""}`;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
archive,
|
|
7
7
|
done,
|
|
8
8
|
drop,
|
|
9
|
+
resurrect,
|
|
9
10
|
moveTaskTo,
|
|
10
11
|
send,
|
|
11
12
|
stack,
|
|
@@ -64,6 +65,7 @@ const ACTING = new Set<ToolName>([
|
|
|
64
65
|
"stack",
|
|
65
66
|
"land",
|
|
66
67
|
"drop",
|
|
68
|
+
"resurrect",
|
|
67
69
|
"move",
|
|
68
70
|
"approve_plan",
|
|
69
71
|
"plan_pieces",
|
|
@@ -219,6 +221,10 @@ export async function runTool(
|
|
|
219
221
|
return done(w, p(tool, raw).session);
|
|
220
222
|
case "archive":
|
|
221
223
|
return archive(w, p(tool, raw).session);
|
|
224
|
+
case "resurrect": {
|
|
225
|
+
const a = p(tool, raw);
|
|
226
|
+
return resurrect(w, a.session, a.reason);
|
|
227
|
+
}
|
|
222
228
|
case "add_project": {
|
|
223
229
|
const a = p(tool, raw);
|
|
224
230
|
return addProject(w, a.path, a.name);
|
|
@@ -211,7 +211,7 @@ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
|
|
|
211
211
|
|
|
212
212
|
const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
|
|
213
213
|
|
|
214
|
-
// aos move, done, archive, stop and drop reach a session by name, past the tools'
|
|
214
|
+
// aos move, done, archive, resurrect, stop and drop reach a session by name, past the tools'
|
|
215
215
|
// Pause check and logging; the tools do the same work.
|
|
216
216
|
function aosRefusal(args: string[]): string | null {
|
|
217
217
|
const [sub] = positionals(args);
|
|
@@ -219,6 +219,7 @@ function aosRefusal(args: string[]): string | null {
|
|
|
219
219
|
move: T.move,
|
|
220
220
|
done: T.done,
|
|
221
221
|
archive: T.archive,
|
|
222
|
+
resurrect: T.resurrect,
|
|
222
223
|
stop: T.stop,
|
|
223
224
|
};
|
|
224
225
|
if (sub && tool[sub])
|
|
@@ -291,6 +291,12 @@ export const TOOL_SHAPES = {
|
|
|
291
291
|
stop: { session },
|
|
292
292
|
done: { session },
|
|
293
293
|
archive: { session },
|
|
294
|
+
resurrect: {
|
|
295
|
+
session: ref(
|
|
296
|
+
"A dropped, done or archived session: its name, project/name or id (from the decision log, or as Saad named it)"
|
|
297
|
+
),
|
|
298
|
+
reason: z.string().trim().min(1).max(2000),
|
|
299
|
+
},
|
|
294
300
|
add_project: {
|
|
295
301
|
path: z
|
|
296
302
|
.string()
|
|
@@ -88,11 +88,13 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
88
88
|
pause_task:
|
|
89
89
|
"Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on Saad. Give a reason; it is shown with the pause.",
|
|
90
90
|
resume_task:
|
|
91
|
-
"Resume a task you paused (or the scheduler did) now. A pause Saad set is his to lift: this refuses and says so.",
|
|
91
|
+
"Resume a task you paused (or the scheduler did) now: it starts at once, past the running-task limit, unless the machine load holds it (the answer says so). A pause Saad set is his to lift: this refuses and says so.",
|
|
92
92
|
stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
|
|
93
93
|
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it lands on the sidebar's Done shelf; it no longer archives (use archive for that). Not a rejection.",
|
|
94
94
|
archive:
|
|
95
95
|
"Put a finished session away, out of the sidebar (it stays in Archived, and Saad can unarchive it). Nothing is merged or cleaned up: refused while the session is working or has an open PR; done first for finished work.",
|
|
96
|
+
resurrect:
|
|
97
|
+
"Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
|
|
96
98
|
add_project:
|
|
97
99
|
"Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
|
|
98
100
|
note: "Add a line to this workspace's decision log; it shows in your chat.",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Sessions that ended (dropped, done, archived) and can be asked for back. */
|
|
2
|
+
|
|
3
|
+
import { db } from "../db";
|
|
4
|
+
import { resolveRef } from "../bus/resolve";
|
|
5
|
+
|
|
6
|
+
export type EndedKind = "dropped" | "done" | "archived" | "settled";
|
|
7
|
+
|
|
8
|
+
export interface EndedSession {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
projectId: string | null;
|
|
12
|
+
projectName: string | null;
|
|
13
|
+
kind: EndedKind;
|
|
14
|
+
taskStatus: string | null;
|
|
15
|
+
// A plan-only task whose plan was written and never approved.
|
|
16
|
+
planWaiting: boolean;
|
|
17
|
+
endedAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A workspace's, or one project's, or everyone's (a person's own shell).
|
|
21
|
+
export type EndedScope = { workspaceId: string } | { projectId: string } | null;
|
|
22
|
+
|
|
23
|
+
interface Row {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
tmux_name: string;
|
|
27
|
+
project_id: string | null;
|
|
28
|
+
project_name: string | null;
|
|
29
|
+
task_status: string | null;
|
|
30
|
+
archived_at: string | null;
|
|
31
|
+
plan_phase: string | null;
|
|
32
|
+
ended_at: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function kindOf(r: Row): EndedKind {
|
|
36
|
+
if (r.task_status === "dropped") return "dropped";
|
|
37
|
+
if (r.archived_at) return "archived";
|
|
38
|
+
return r.task_status === "done" ? "done" : "settled";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rows(scope: EndedScope, limit: number, kind?: EndedKind): Row[] {
|
|
42
|
+
const where = [
|
|
43
|
+
"s.role IS NULL",
|
|
44
|
+
"s.peer_mirror = 0",
|
|
45
|
+
"s.held_by_device IS NULL",
|
|
46
|
+
];
|
|
47
|
+
const args: (string | number)[] = [];
|
|
48
|
+
if (scope && "workspaceId" in scope) {
|
|
49
|
+
where.push("p.workspace_id = ?");
|
|
50
|
+
args.push(scope.workspaceId);
|
|
51
|
+
} else if (scope) {
|
|
52
|
+
where.push("s.project_id = ?");
|
|
53
|
+
args.push(scope.projectId);
|
|
54
|
+
}
|
|
55
|
+
where.push(
|
|
56
|
+
`(s.archived_at IS NOT NULL OR s.task_status IN ('dropped', 'done')
|
|
57
|
+
OR (s.task_status IS NULL AND s.settled_at IS NOT NULL))`
|
|
58
|
+
);
|
|
59
|
+
if (kind === "dropped") where.push("s.task_status = 'dropped'");
|
|
60
|
+
if (kind === "archived") where.push("s.archived_at IS NOT NULL");
|
|
61
|
+
if (kind === "done")
|
|
62
|
+
where.push("s.task_status = 'done' AND s.archived_at IS NULL");
|
|
63
|
+
return db
|
|
64
|
+
.prepare(
|
|
65
|
+
`SELECT s.id, s.name, s.tmux_name, s.project_id, p.name AS project_name,
|
|
66
|
+
s.task_status, s.archived_at, s.plan_phase,
|
|
67
|
+
COALESCE(s.task_ended_at, s.archived_at, s.settled_at, s.updated_at) AS ended_at
|
|
68
|
+
FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
|
|
69
|
+
WHERE ${where.join(" AND ")}
|
|
70
|
+
ORDER BY ended_at DESC LIMIT ?`
|
|
71
|
+
)
|
|
72
|
+
.all(...args, limit) as Row[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function endedSessions(
|
|
76
|
+
scope: EndedScope,
|
|
77
|
+
opts: { limit?: number; kind?: EndedKind } = {}
|
|
78
|
+
): EndedSession[] {
|
|
79
|
+
return rows(scope, opts.limit ?? 50, opts.kind).map((r) => ({
|
|
80
|
+
id: r.id,
|
|
81
|
+
name: r.name,
|
|
82
|
+
projectId: r.project_id,
|
|
83
|
+
projectName: r.project_name,
|
|
84
|
+
kind: kindOf(r),
|
|
85
|
+
taskStatus: r.task_status,
|
|
86
|
+
planWaiting: r.plan_phase === "awaiting",
|
|
87
|
+
endedAt: r.ended_at,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** An ended session by name, project/name, id or id prefix, within the scope. */
|
|
92
|
+
export function resolveEnded(ref: string, scope: EndedScope): string {
|
|
93
|
+
const all = rows(scope, 500);
|
|
94
|
+
const r = resolveRef(
|
|
95
|
+
ref,
|
|
96
|
+
all.map((s) => ({
|
|
97
|
+
id: s.id,
|
|
98
|
+
name: s.name,
|
|
99
|
+
projectName: s.project_name,
|
|
100
|
+
tmuxName: s.tmux_name,
|
|
101
|
+
previousNames: [],
|
|
102
|
+
}))
|
|
103
|
+
);
|
|
104
|
+
if (!r.ok)
|
|
105
|
+
throw new Error(
|
|
106
|
+
r.reason === "none"
|
|
107
|
+
? `No dropped, done or archived session "${ref}"${scope ? " here" : ""}.`
|
|
108
|
+
: r.error
|
|
109
|
+
);
|
|
110
|
+
return r.id;
|
|
111
|
+
}
|