@saadnvd1/agent-os 0.3.464 → 0.3.466
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/README.md +20 -0
- package/app/api/fresh-start/route.ts +5 -3
- package/app/api/git/commit/route.ts +4 -3
- package/app/api/git/file-content/route.ts +4 -15
- package/app/api/git/history/[hash]/diff/route.ts +1 -1
- package/app/api/git/history/[hash]/route.ts +1 -1
- package/app/api/git/history/route.ts +1 -1
- package/app/api/git/push/route.ts +2 -2
- package/app/api/sessions/[id]/pr/route.ts +2 -2
- package/components/Chat/ChatPanel.tsx +23 -1
- package/components/Chat/Messages.tsx +2 -3
- package/components/Chat/StaleChip.tsx +55 -0
- package/components/Workspaces/FreshStartForm.tsx +29 -6
- package/data/chat/useChat.ts +10 -2
- package/lib/away/deploy.ts +3 -7
- package/lib/away/run.ts +6 -5
- package/lib/build.ts +2 -6
- package/lib/bus/index.ts +3 -0
- package/lib/chat/client-send.ts +11 -3
- package/lib/chat/drivers/claude-mapper.ts +11 -1
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/events.ts +14 -1
- package/lib/chat/files.ts +31 -9
- package/lib/chat/refresh.ts +14 -2
- package/lib/chat/runner.ts +8 -1
- package/lib/chat/stale.ts +95 -0
- package/lib/chat/worker/host.ts +133 -4
- package/lib/chat/worker/protocol.ts +3 -0
- package/lib/done/remote.ts +5 -6
- package/lib/done/testing.ts +2 -7
- package/lib/done/worktree.ts +2 -2
- package/lib/git-exec.ts +334 -0
- package/lib/git-history.ts +29 -41
- package/lib/git-status.ts +45 -124
- package/lib/git.ts +44 -159
- package/lib/lumifyhub/publish.ts +2 -2
- package/lib/orchestrator/deliver.ts +9 -1
- package/lib/orchestrator/diff.ts +15 -13
- package/lib/orchestrator/rebase.ts +20 -24
- package/lib/orchestrator/repo-slug.ts +4 -7
- package/lib/orchestrator/repo.ts +24 -35
- package/lib/pr-generation.ts +2 -1
- package/lib/pr.ts +16 -57
- package/lib/schedules/start.ts +1 -0
- package/lib/sessions/worktree-setup.ts +17 -13
- package/lib/stacks/start.ts +7 -11
- package/lib/tasks/branch-worktree.ts +3 -2
- package/lib/tasks/finish.ts +5 -6
- package/lib/tasks/gh.ts +5 -0
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/move.ts +3 -2
- package/lib/tasks/project-ref.ts +12 -19
- package/lib/tasks/session.ts +5 -5
- package/lib/worktree-hooks.ts +3 -7
- package/lib/worktree-placed.ts +3 -10
- package/lib/worktrees.ts +4 -42
- package/package.json +1 -1
package/lib/chat/files.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
|
+
import { git } from "../git-exec";
|
|
3
4
|
import type { FileSuggestion } from "./events";
|
|
4
5
|
import {
|
|
5
6
|
indexPaths,
|
|
@@ -33,11 +34,36 @@ export function sweepFileIndexes(now = Date.now()): void {
|
|
|
33
34
|
|
|
34
35
|
export const fileIndexCount = () => cache.size;
|
|
35
36
|
|
|
37
|
+
const LIST_TIMEOUT_MS = 5000;
|
|
38
|
+
|
|
39
|
+
export interface Lister {
|
|
40
|
+
// The program it needs.
|
|
41
|
+
cmd: string;
|
|
42
|
+
list: (cwd: string) => Promise<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
// Ways to list a folder's files with ignored ones left out: ripgrep, or
|
|
37
46
|
// git where ripgrep isn't installed.
|
|
38
|
-
export const LISTERS: [
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
export const LISTERS: Lister[] = [
|
|
48
|
+
{
|
|
49
|
+
cmd: "rg",
|
|
50
|
+
list: async (cwd) =>
|
|
51
|
+
(
|
|
52
|
+
await execFileAsync("rg", ["--files", "--hidden", "-g", "!.git"], {
|
|
53
|
+
cwd,
|
|
54
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
55
|
+
timeout: LIST_TIMEOUT_MS,
|
|
56
|
+
})
|
|
57
|
+
).stdout,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
cmd: "git",
|
|
61
|
+
list: (cwd) =>
|
|
62
|
+
git(cwd, ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
|
63
|
+
timeout: LIST_TIMEOUT_MS,
|
|
64
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
41
67
|
];
|
|
42
68
|
|
|
43
69
|
// The folder's files, for @mentions when no agent is running to match them
|
|
@@ -46,13 +72,9 @@ export async function listFiles(
|
|
|
46
72
|
cwd: string,
|
|
47
73
|
listers = LISTERS
|
|
48
74
|
): Promise<IndexedPath[]> {
|
|
49
|
-
for (const
|
|
75
|
+
for (const { list } of listers) {
|
|
50
76
|
try {
|
|
51
|
-
const
|
|
52
|
-
cwd,
|
|
53
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
54
|
-
timeout: 5000,
|
|
55
|
-
});
|
|
77
|
+
const stdout = await list(cwd);
|
|
56
78
|
return indexPaths(
|
|
57
79
|
withFolders(stdout.split("\n").filter(Boolean).slice(0, MAX_FILES))
|
|
58
80
|
);
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* default: its state is in the database and its brief carries it
|
|
7
7
|
* (lib/orchestrator/digest). A task keeps its progress only in its
|
|
8
8
|
* conversation and its branch, so for tasks it's off unless switched on.
|
|
9
|
-
* App-wide, in Settings → Workspaces
|
|
9
|
+
* App-wide, in Settings → Workspaces, with the size past which an idle
|
|
10
|
+
* chat is compacted before a message goes in (lib/chat/stale).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { db } from "../db";
|
|
@@ -14,13 +15,18 @@ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
|
|
|
14
15
|
|
|
15
16
|
export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
|
|
16
17
|
export const TASK_KEY = "chat.task_refresh_tokens";
|
|
18
|
+
export const STALE_COMPACT_KEY = "chat.stale_compact_tokens";
|
|
17
19
|
export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
|
|
20
|
+
export const DEFAULT_STALE_COMPACT_TOKENS = 150_000;
|
|
18
21
|
export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
|
|
19
22
|
|
|
20
23
|
export interface RefreshSettings {
|
|
21
24
|
// Context tokens at which a conversation starts over; 0 is off.
|
|
22
25
|
orchestratorTokens: number;
|
|
23
26
|
taskTokens: number;
|
|
27
|
+
// Context tokens above which a chat whose prompt cache has expired is
|
|
28
|
+
// compacted before the next message; 0 is off.
|
|
29
|
+
staleCompactTokens: number;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function read(key: string, fallback: number): number {
|
|
@@ -35,6 +41,7 @@ export function refreshSettings(): RefreshSettings {
|
|
|
35
41
|
return {
|
|
36
42
|
orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
|
|
37
43
|
taskTokens: read(TASK_KEY, 0),
|
|
44
|
+
staleCompactTokens: read(STALE_COMPACT_KEY, DEFAULT_STALE_COMPACT_TOKENS),
|
|
38
45
|
};
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -49,7 +56,7 @@ export function checkRefreshTokens(value: unknown): number {
|
|
|
49
56
|
value > MAX_REFRESH_TOKENS
|
|
50
57
|
)
|
|
51
58
|
throw new Error(
|
|
52
|
-
`A
|
|
59
|
+
`A context threshold is off or between ${MIN_REFRESH_TOKENS / 1000}k and ${MAX_REFRESH_TOKENS / 1000}k tokens`
|
|
53
60
|
);
|
|
54
61
|
return value;
|
|
55
62
|
}
|
|
@@ -67,6 +74,11 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
|
|
|
67
74
|
]);
|
|
68
75
|
if (input.taskTokens !== undefined)
|
|
69
76
|
checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
|
|
77
|
+
if (input.staleCompactTokens !== undefined)
|
|
78
|
+
checked.push([
|
|
79
|
+
STALE_COMPACT_KEY,
|
|
80
|
+
checkRefreshTokens(input.staleCompactTokens),
|
|
81
|
+
]);
|
|
70
82
|
db.transaction(() => {
|
|
71
83
|
for (const [key, n] of checked) put.run(key, String(n));
|
|
72
84
|
})();
|
package/lib/chat/runner.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { randomUUID } from "crypto";
|
|
9
9
|
import os from "os";
|
|
10
10
|
import { db, type Session } from "../db";
|
|
11
|
+
import type { CompactFirst } from "./stale";
|
|
11
12
|
import type {
|
|
12
13
|
ApprovalDecision,
|
|
13
14
|
ChatContext,
|
|
@@ -329,6 +330,8 @@ export async function sendChat(
|
|
|
329
330
|
images?: ChatImage[];
|
|
330
331
|
// Typed in the composer: waits in the queue while a turn runs.
|
|
331
332
|
queue?: boolean;
|
|
333
|
+
// Compact a stale chat first (lib/chat/stale).
|
|
334
|
+
compact?: CompactFirst;
|
|
332
335
|
} & SentBy
|
|
333
336
|
): Promise<void> {
|
|
334
337
|
const text = input.text.trim();
|
|
@@ -356,6 +359,7 @@ export async function sendChat(
|
|
|
356
359
|
images: input.images,
|
|
357
360
|
...sentBy(input),
|
|
358
361
|
queue: input.queue,
|
|
362
|
+
compact: input.compact,
|
|
359
363
|
});
|
|
360
364
|
}
|
|
361
365
|
|
|
@@ -486,6 +490,8 @@ async function resumeCutOff(sessionId: string): Promise<void> {
|
|
|
486
490
|
text: RESUME_NOTE,
|
|
487
491
|
from: "agentos",
|
|
488
492
|
origin: { kind: "event", label: "AgentOS" },
|
|
493
|
+
// Down past its cache, a long chat is compacted before it carries on.
|
|
494
|
+
compact: "auto",
|
|
489
495
|
});
|
|
490
496
|
if (task)
|
|
491
497
|
queueEvent(
|
|
@@ -552,7 +558,7 @@ export async function chatFileSuggestions(
|
|
|
552
558
|
// only then has it accepted it. "queued" when a turn was already running.
|
|
553
559
|
export async function sendChatConfirmed(
|
|
554
560
|
sessionId: string,
|
|
555
|
-
input: { text: string; id?: string } & SentBy,
|
|
561
|
+
input: { text: string; id?: string; compact?: CompactFirst } & SentBy,
|
|
556
562
|
timeoutMs = 10_000
|
|
557
563
|
): Promise<"delivered" | "queued"> {
|
|
558
564
|
const text = input.text.trim();
|
|
@@ -607,6 +613,7 @@ export async function sendChatConfirmed(
|
|
|
607
613
|
id,
|
|
608
614
|
text,
|
|
609
615
|
...sentBy(input),
|
|
616
|
+
compact: input.compact,
|
|
610
617
|
});
|
|
611
618
|
await accepted;
|
|
612
619
|
} finally {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale chats: a message sent to a conversation whose prompt cache has
|
|
3
|
+
* expired re-reads the whole history at the cache-write price. When it is
|
|
4
|
+
* also long, compacting first is far cheaper than carrying it (one summary
|
|
5
|
+
* turn, then a small cache write instead of a huge one). The one check every
|
|
6
|
+
* send path uses: the composer to offer it (components/Chat/StaleChip), the
|
|
7
|
+
* chat worker to do it for automated sends (lib/chat/worker/host).
|
|
8
|
+
*
|
|
9
|
+
* Pure, so the browser and the worker read the same rule.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ChatContext, ChatItem, ChatState } from "./events";
|
|
13
|
+
import { formatTokens } from "./context";
|
|
14
|
+
|
|
15
|
+
// How a send treats a stale chat: "auto" compacts first only when it is
|
|
16
|
+
// stale (automated sends); "yes" compacts first because the reader chose
|
|
17
|
+
// to (the composer's chip). Absent, it never does.
|
|
18
|
+
export type CompactFirst = "auto" | "yes";
|
|
19
|
+
|
|
20
|
+
// The prompt-cache lifetime a turn wrote with, read from its usage: the
|
|
21
|
+
// session runs with one or the other, and only a write says which. A turn
|
|
22
|
+
// that only read the cache says nothing.
|
|
23
|
+
export function cacheTtlMs(
|
|
24
|
+
cacheCreation:
|
|
25
|
+
| {
|
|
26
|
+
ephemeral_5m_input_tokens?: number;
|
|
27
|
+
ephemeral_1h_input_tokens?: number;
|
|
28
|
+
}
|
|
29
|
+
| null
|
|
30
|
+
| undefined
|
|
31
|
+
): number | undefined {
|
|
32
|
+
if (!cacheCreation) return undefined;
|
|
33
|
+
if ((cacheCreation.ephemeral_1h_input_tokens ?? 0) > 0) return 60 * 60_000;
|
|
34
|
+
if ((cacheCreation.ephemeral_5m_input_tokens ?? 0) > 0) return 5 * 60_000;
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Staleness {
|
|
39
|
+
idleMs: number;
|
|
40
|
+
tokens: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Stale: no turn running, the last one ended longer ago than the cache it
|
|
44
|
+
// wrote lives, and the context is above the threshold (0 is off). Null when
|
|
45
|
+
// it isn't, or when the chat never said which cache it uses.
|
|
46
|
+
export function staleness(
|
|
47
|
+
context: ChatContext | null | undefined,
|
|
48
|
+
opts: { state: ChatState; thresholdTokens: number; now?: number }
|
|
49
|
+
): Staleness | null {
|
|
50
|
+
if (opts.state === "running" || opts.state === "waiting") return null;
|
|
51
|
+
if (!context?.cacheTtlMs || opts.thresholdTokens <= 0) return null;
|
|
52
|
+
const idleMs = (opts.now ?? Date.now()) - context.at;
|
|
53
|
+
if (idleMs <= context.cacheTtlMs) return null;
|
|
54
|
+
if (context.usedTokens <= opts.thresholdTokens) return null;
|
|
55
|
+
return { idleMs, tokens: context.usedTokens };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatIdle(ms: number): string {
|
|
59
|
+
const m = Math.floor(ms / 60_000);
|
|
60
|
+
if (m < 120) return `${m}m`;
|
|
61
|
+
const h = Math.floor(m / 60);
|
|
62
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// "Idle 47m · 412k context"
|
|
66
|
+
export function staleLabel(s: Staleness): string {
|
|
67
|
+
return `Idle ${formatIdle(s.idleMs)} · ${formatTokens(s.tokens)} context`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whether the composer's send compacts first: the chip was showing and the
|
|
71
|
+
// reader didn't pick "Send as-is". A slash command goes as typed.
|
|
72
|
+
export function composerCompacts(
|
|
73
|
+
text: string,
|
|
74
|
+
context: ChatContext | null | undefined,
|
|
75
|
+
opts: { state: ChatState; thresholdTokens: number; asIs: boolean }
|
|
76
|
+
): boolean {
|
|
77
|
+
if (opts.asIs || text.trimStart().startsWith("/")) return false;
|
|
78
|
+
return !!staleness(context, opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The chat's line for a compaction: "Compacted before sending · idle 47m ·
|
|
82
|
+
// 412k → 38k tokens" when it ran ahead of a message to a stale chat.
|
|
83
|
+
export function compactedLine(
|
|
84
|
+
item: Extract<ChatItem, { kind: "compacted" }>
|
|
85
|
+
): string {
|
|
86
|
+
const head =
|
|
87
|
+
item.idleMs !== undefined
|
|
88
|
+
? `Compacted before sending · idle ${formatIdle(item.idleMs)}`
|
|
89
|
+
: item.trigger === "auto"
|
|
90
|
+
? "Context compacted automatically"
|
|
91
|
+
: "Context compacted";
|
|
92
|
+
if (item.before === undefined) return head;
|
|
93
|
+
const after = item.after === undefined ? "…" : formatTokens(item.after);
|
|
94
|
+
return `${head} · ${formatTokens(item.before)} → ${after} tokens`;
|
|
95
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveModelForAgent } from "../../model-catalog";
|
|
|
13
13
|
import { chatDriverFor } from "../drivers";
|
|
14
14
|
import type { ChatConversation } from "../driver";
|
|
15
15
|
import type {
|
|
16
|
+
ChatContext,
|
|
16
17
|
ChatImage,
|
|
17
18
|
ChatItem,
|
|
18
19
|
ChatState,
|
|
@@ -35,7 +36,8 @@ import {
|
|
|
35
36
|
visualsTools,
|
|
36
37
|
} from "../../artifacts/tools";
|
|
37
38
|
import type { RoleExtras } from "./extras";
|
|
38
|
-
import { refreshedNote } from "../refresh";
|
|
39
|
+
import { refreshSettings, refreshedNote } from "../refresh";
|
|
40
|
+
import { staleLabel, staleness, type Staleness } from "../stale";
|
|
39
41
|
import type { WorkerCommand, WorkerEvent } from "./protocol";
|
|
40
42
|
|
|
41
43
|
// How soon after a stopped turn ends a turn the agent starts on its own
|
|
@@ -123,6 +125,19 @@ export class ChatHost {
|
|
|
123
125
|
// size can't end every conversation after one turn.
|
|
124
126
|
private freshConversation: boolean;
|
|
125
127
|
private contextBase: number | null = null;
|
|
128
|
+
// How full the conversation was at its last turn's end (lib/chat/stale).
|
|
129
|
+
private context: ChatContext | null = null;
|
|
130
|
+
// A compaction run ahead of a message to a stale chat: the message waits
|
|
131
|
+
// at the front of the queue until it ends (compactFirst).
|
|
132
|
+
private compacting: {
|
|
133
|
+
idleMs: number;
|
|
134
|
+
before: number;
|
|
135
|
+
done?: boolean;
|
|
136
|
+
} | null = null;
|
|
137
|
+
// Its line, until the agent's next measure gives the size after.
|
|
138
|
+
private compactedLine: Extract<ChatItem, { kind: "compacted" }> | null = null;
|
|
139
|
+
// An orchestrator gone stale starts over rather than compacting.
|
|
140
|
+
private staleRefresh: Staleness | null = null;
|
|
126
141
|
readonly done: Promise<void>;
|
|
127
142
|
|
|
128
143
|
constructor(
|
|
@@ -182,11 +197,19 @@ export class ChatHost {
|
|
|
182
197
|
permissionMode: extras.permissionMode,
|
|
183
198
|
});
|
|
184
199
|
this.suggestion = session.chat_suggestion ?? null;
|
|
200
|
+
try {
|
|
201
|
+
this.context = session.chat_context
|
|
202
|
+
? (JSON.parse(session.chat_context) as ChatContext)
|
|
203
|
+
: null;
|
|
204
|
+
} catch {
|
|
205
|
+
this.context = null;
|
|
206
|
+
}
|
|
185
207
|
this.refresh = extras.refresh;
|
|
186
208
|
this.freshConversation = !session.claude_session_id;
|
|
187
209
|
if (session.chat_refreshed)
|
|
188
210
|
this.refreshedNote = refreshedNote(
|
|
189
|
-
extras.refresh?.role ??
|
|
211
|
+
extras.refresh?.role ??
|
|
212
|
+
(session.role === "orchestrator" ? "orchestrator" : "task"),
|
|
190
213
|
session.chat_refreshed
|
|
191
214
|
);
|
|
192
215
|
// Told only an orchestrator whose tools changed since its last worker:
|
|
@@ -222,6 +245,12 @@ export class ChatHost {
|
|
|
222
245
|
try {
|
|
223
246
|
for await (const e of this.conversation.events) {
|
|
224
247
|
if (e.type === "item") {
|
|
248
|
+
// A compaction run for a stale chat shows as its one line; what
|
|
249
|
+
// a failed one printed still shows.
|
|
250
|
+
if (this.compacting?.done && e.item.kind === "command_output")
|
|
251
|
+
continue;
|
|
252
|
+
if (this.compacting && e.item.kind === "compacted")
|
|
253
|
+
e.item = this.compactedItem(e.item);
|
|
225
254
|
if ("streaming" in e.item && e.item.streaming)
|
|
226
255
|
this.streaming.set(e.item.id, e.item);
|
|
227
256
|
else this.streaming.delete(e.item.id);
|
|
@@ -259,7 +288,9 @@ export class ChatHost {
|
|
|
259
288
|
JSON.stringify(e.context),
|
|
260
289
|
this.session.id
|
|
261
290
|
);
|
|
291
|
+
this.context = e.context;
|
|
262
292
|
this.emit(e);
|
|
293
|
+
this.settleCompactedLine(e.context.usedTokens);
|
|
263
294
|
this.refreshIfFull(e.context.usedTokens);
|
|
264
295
|
} else if (e.type === "turn_start") {
|
|
265
296
|
this.turnsStarted++;
|
|
@@ -303,6 +334,8 @@ export class ChatHost {
|
|
|
303
334
|
// A send waiting on the same change may have started a turn
|
|
304
335
|
// meanwhile: that turn is running, not idle.
|
|
305
336
|
if (this.busy()) continue;
|
|
337
|
+
// The compaction ran (or failed): its message is first in line.
|
|
338
|
+
this.compacting = null;
|
|
306
339
|
if (this.sendQueued()) continue;
|
|
307
340
|
}
|
|
308
341
|
// A stopped turn can still settle an approval after it ended.
|
|
@@ -503,7 +536,13 @@ export class ChatHost {
|
|
|
503
536
|
this.sendUser(cmd, true);
|
|
504
537
|
return;
|
|
505
538
|
}
|
|
506
|
-
if (
|
|
539
|
+
if (this.compactFirst(cmd)) return;
|
|
540
|
+
// A compaction run for an earlier message takes nothing in: what's
|
|
541
|
+
// sent meanwhile goes behind that message.
|
|
542
|
+
if (
|
|
543
|
+
(cmd.queue || this.compacting) &&
|
|
544
|
+
(this.busy() || listQueue(this.session.id).length)
|
|
545
|
+
) {
|
|
507
546
|
// Behind the running turn, and behind anything queued before it.
|
|
508
547
|
enqueue(this.session.id, cmd);
|
|
509
548
|
this.emit({ type: "queue" });
|
|
@@ -620,6 +659,88 @@ export class ChatHost {
|
|
|
620
659
|
this.close();
|
|
621
660
|
}
|
|
622
661
|
|
|
662
|
+
// A stale chat (lib/chat/stale) is compacted before the message goes in,
|
|
663
|
+
// only between turns: the message waits at the front of the queue, and
|
|
664
|
+
// the compaction's end sends it. "yes" is the reader's choice, made on
|
|
665
|
+
// what the composer showed; "auto" checks for itself.
|
|
666
|
+
private compactFirst(cmd: Extract<WorkerCommand, { type: "send" }>): boolean {
|
|
667
|
+
if (!cmd.compact || this.busy() || this.closed || this.handingOver())
|
|
668
|
+
return false;
|
|
669
|
+
const stale = this.staleNow(cmd.compact);
|
|
670
|
+
if (!stale) return false;
|
|
671
|
+
try {
|
|
672
|
+
enqueue(this.session.id, cmd);
|
|
673
|
+
} catch {
|
|
674
|
+
// A full queue: it goes in as it is.
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
moveToFront(this.session.id, cmd.id);
|
|
678
|
+
this.emit({ type: "queue" });
|
|
679
|
+
// An orchestrator isn't compacted: a summary would carry what it read
|
|
680
|
+
// from tasks and CI without its untrusted fence. It starts a fresh
|
|
681
|
+
// conversation from its brief and the database instead, as at its
|
|
682
|
+
// threshold, and the next worker sends the message.
|
|
683
|
+
if (this.session.role === "orchestrator") {
|
|
684
|
+
this.staleRefresh = stale;
|
|
685
|
+
this.refreshAt = stale.tokens;
|
|
686
|
+
this.retiring = true;
|
|
687
|
+
this.retireWaited = true;
|
|
688
|
+
this.retireIfDone();
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
this.compacting = { idleMs: stale.idleMs, before: stale.tokens };
|
|
692
|
+
this.currentTurn = null;
|
|
693
|
+
this.stopping = null;
|
|
694
|
+
this.conversation.send("/compact");
|
|
695
|
+
this.setState("running");
|
|
696
|
+
console.error(
|
|
697
|
+
`${new Date().toISOString()} compacting ${this.session.id} before a message: idle ${Math.round(stale.idleMs / 60_000)}m, ${stale.tokens} tokens`
|
|
698
|
+
);
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private staleNow(compact: "auto" | "yes"): Staleness | null {
|
|
703
|
+
const context = this.context;
|
|
704
|
+
// Only an agent that says which cache it uses has one to lose.
|
|
705
|
+
if (!context?.cacheTtlMs) return null;
|
|
706
|
+
if (compact === "yes")
|
|
707
|
+
return { idleMs: Date.now() - context.at, tokens: context.usedTokens };
|
|
708
|
+
return staleness(context, {
|
|
709
|
+
state: this.state,
|
|
710
|
+
thresholdTokens: refreshSettings().staleCompactTokens,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private compactedItem(
|
|
715
|
+
item: Extract<ChatItem, { kind: "compacted" }>
|
|
716
|
+
): ChatItem {
|
|
717
|
+
if (this.compacting) this.compacting.done = true;
|
|
718
|
+
const line = {
|
|
719
|
+
...item,
|
|
720
|
+
idleMs: this.compacting?.idleMs,
|
|
721
|
+
before: item.before ?? this.compacting?.before,
|
|
722
|
+
};
|
|
723
|
+
this.compactedLine = line.after === undefined ? line : null;
|
|
724
|
+
if (line.after !== undefined) this.logCompacted(line);
|
|
725
|
+
return line;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The size after, from the measure that follows the compaction's result.
|
|
729
|
+
private settleCompactedLine(usedTokens: number): void {
|
|
730
|
+
const line = this.compactedLine;
|
|
731
|
+
if (!line) return;
|
|
732
|
+
this.compactedLine = null;
|
|
733
|
+
const settled = { ...line, after: usedTokens };
|
|
734
|
+
this.record(settled);
|
|
735
|
+
this.logCompacted(settled);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private logCompacted(line: { before?: number; after?: number }): void {
|
|
739
|
+
console.error(
|
|
740
|
+
`${new Date().toISOString()} compacted ${this.session.id}: ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
623
744
|
// Past its threshold, once: it retires at the next turn boundary, as for
|
|
624
745
|
// a deploy, without waiting for the agent's guess at the next message
|
|
625
746
|
// (the new conversation never saw what it guessed from).
|
|
@@ -643,13 +764,21 @@ export class ChatHost {
|
|
|
643
764
|
chat_suggestion = NULL, chat_context = NULL, chat_restarted = 0,
|
|
644
765
|
chat_refreshed = ? WHERE id = ?`
|
|
645
766
|
).run(this.refreshAt, this.session.id);
|
|
767
|
+
const brief = `its brief${this.session.role === "orchestrator" ? " and the current state from AgentOS" : ""}`;
|
|
768
|
+
const stale = this.staleRefresh;
|
|
646
769
|
this.record({
|
|
647
770
|
id: `note-refresh-${Date.now()}`,
|
|
648
771
|
kind: "note",
|
|
649
772
|
tone: "note",
|
|
650
|
-
text:
|
|
773
|
+
text: stale
|
|
774
|
+
? `${staleLabel(stale)}, past its prompt cache: the agent starts a fresh conversation with ${brief} before the next message.`
|
|
775
|
+
: `Context reached ${Math.round(this.refreshAt / 1000)}k tokens: the agent starts a fresh conversation with ${brief} at its next message.`,
|
|
651
776
|
createdAt: Date.now(),
|
|
652
777
|
});
|
|
778
|
+
if (stale)
|
|
779
|
+
console.error(
|
|
780
|
+
`${new Date().toISOString()} fresh start for stale ${this.session.id}: ${stale.tokens} tokens, idle ${Math.round(stale.idleMs / 60_000)}m`
|
|
781
|
+
);
|
|
653
782
|
}
|
|
654
783
|
|
|
655
784
|
// Retiring, and nothing in the background keeps it: messages wait in the
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
PeerMessage,
|
|
19
19
|
} from "../events";
|
|
20
20
|
import type { UndoResult } from "../driver";
|
|
21
|
+
import type { CompactFirst } from "../stale";
|
|
21
22
|
|
|
22
23
|
export const PROTOCOL_VERSION = 1;
|
|
23
24
|
|
|
@@ -35,6 +36,8 @@ export type WorkerCommand =
|
|
|
35
36
|
// From the composer: waits its turn behind a running one, in the
|
|
36
37
|
// queue, rather than joining it.
|
|
37
38
|
queue?: boolean;
|
|
39
|
+
// Compact a stale conversation before this goes in (lib/chat/stale).
|
|
40
|
+
compact?: CompactFirst;
|
|
38
41
|
}
|
|
39
42
|
// A queued message goes next: the running turn stops for it.
|
|
40
43
|
// `during`: the user message whose turn the reader saw running; only that
|
package/lib/done/remote.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Session } from "../db";
|
|
6
6
|
import type { TaskPR } from "../tasks";
|
|
7
|
-
import {
|
|
7
|
+
import { git, gitPush } from "../git-exec";
|
|
8
8
|
import { projectPathFor } from "../tasks/session";
|
|
9
9
|
import { PROTECTED_BRANCHES } from "../tasks/finish";
|
|
10
10
|
import { liveChildren } from "../stacks/guard";
|
|
@@ -23,15 +23,14 @@ export async function deleteMergedRemote(
|
|
|
23
23
|
liveChildren(s.id).length
|
|
24
24
|
)
|
|
25
25
|
return false;
|
|
26
|
-
const line = await
|
|
27
|
-
"git",
|
|
28
|
-
["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
|
|
26
|
+
const line = await git(
|
|
29
27
|
repo,
|
|
30
|
-
|
|
28
|
+
["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
|
|
29
|
+
{ timeout: 30000 }
|
|
31
30
|
).catch(() => "");
|
|
32
31
|
const tip = line.trim().split(/\s+/)[0];
|
|
33
32
|
if (!tip || tip !== pr.head) return false;
|
|
34
|
-
return
|
|
33
|
+
return gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 })
|
|
35
34
|
.then(() => true)
|
|
36
35
|
.catch(() => false);
|
|
37
36
|
}
|
package/lib/done/testing.ts
CHANGED
|
@@ -4,18 +4,13 @@
|
|
|
4
4
|
import fs from "fs";
|
|
5
5
|
import os from "os";
|
|
6
6
|
import path from "path";
|
|
7
|
-
import {
|
|
7
|
+
import { gitSync } from "../git-exec";
|
|
8
8
|
|
|
9
9
|
// Old enough that CI counts as settled on every commit.
|
|
10
10
|
const env = { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" };
|
|
11
11
|
|
|
12
12
|
export const git = (cwd: string, ...args: string[]) =>
|
|
13
|
-
|
|
14
|
-
cwd,
|
|
15
|
-
encoding: "utf8",
|
|
16
|
-
stdio: "pipe",
|
|
17
|
-
env,
|
|
18
|
-
}).trim();
|
|
13
|
+
gitSync(cwd, args, { env }).trim();
|
|
19
14
|
|
|
20
15
|
export function commitFile(cwd: string, file: string, text: string): string {
|
|
21
16
|
fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
package/lib/done/worktree.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import type { Session } from "../db";
|
|
9
9
|
import { getDefaultBranch } from "../git";
|
|
10
|
-
import {
|
|
10
|
+
import { git as runGit } from "../git-exec";
|
|
11
11
|
import { mergePolicy } from "../tasks/merge-policy";
|
|
12
12
|
import { getProject } from "../projects";
|
|
13
13
|
import {
|
|
@@ -24,7 +24,7 @@ export type WorktreeFate =
|
|
|
24
24
|
| { action: "kept"; why: string; path: string };
|
|
25
25
|
|
|
26
26
|
const git = (cwd: string, ...args: string[]) =>
|
|
27
|
-
|
|
27
|
+
runGit(cwd, args, { timeout: 30000 }).then((out) => out.trim());
|
|
28
28
|
|
|
29
29
|
// The main checkout a worktree belongs to.
|
|
30
30
|
async function repoOf(worktree: string): Promise<string> {
|