paseo-follow-up 1.3.0-beta.3 → 1.3.0-beta.4
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 +1 -1
- package/client/home-chrome.tsx +19 -1
- package/client/home.tsx +30 -20
- package/client/inbox-store.ts +6 -2
- package/client/live.tsx +18 -5
- package/client/settings.tsx +4 -4
- package/client/visibility.ts +24 -0
- package/index.server.ts +34 -6
- package/package.json +4 -3
- package/server/agent-hook.mjs +9 -3
- package/server/batch-archive.ts +29 -0
- package/server/daemon.ts +54 -19
- package/server/engine.ts +11 -0
- package/server/governor.ts +96 -0
- package/server/history-cache.ts +4 -3
- package/server/hook-source.ts +1 -1
- package/server/list-cache.ts +48 -0
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +25 -8
- package/server/scopes.ts +34 -0
- package/server/store.ts +75 -14
- package/shared/contracts.ts +5 -1
- package/shared/resources.ts +71 -0
- package/shared/settings.ts +3 -0
- package/shared/usage.ts +3 -0
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ The Follow-up sidebar row (and ⌘K → **Follow-up: Home**) opens **Home**: eve
|
|
|
116
116
|
Install the beta:
|
|
117
117
|
|
|
118
118
|
```sh
|
|
119
|
-
paseo plugin update follow-through --version 1.3.0-beta.
|
|
119
|
+
paseo plugin update follow-through --version 1.3.0-beta.4
|
|
120
120
|
# or, for a fresh install:
|
|
121
121
|
paseo plugin install npm:paseo-follow-up@beta
|
|
122
122
|
```
|
package/client/home-chrome.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { FIELD_LABEL, excerpt, highlightRuns, homeSearchDocs, indexDocs, omnibox
|
|
|
6
6
|
import { SORT_HINT, SORT_LABEL, SORT_MODES, type SortMode } from "../shared/sort";
|
|
7
7
|
import { priorityNote, PRIORITY_SOURCE_LABEL } from "../shared/priorities";
|
|
8
8
|
import { meters, meterText, type Meter } from "../shared/usage";
|
|
9
|
+
import { resourceMeter, type Resources } from "../shared/resources";
|
|
9
10
|
import type { HomeSources } from "./home-data";
|
|
10
11
|
import type { Workstream } from "../shared/home";
|
|
11
12
|
import { Age, PrChipView } from "./home-list";
|
|
@@ -171,10 +172,12 @@ export function UsageMeters({ theme, sources, dense }: { theme: Theme; sources:
|
|
|
171
172
|
const now = useMinute();
|
|
172
173
|
const list = useMemo(() => meters(usage, Math.max(now, Date.now())), [usage, now]);
|
|
173
174
|
const [open, setOpen] = useState<string | null>(null);
|
|
174
|
-
|
|
175
|
+
const vm = usage.resources && resourceMeter(usage.resources, now) ? usage.resources : null;
|
|
176
|
+
if (!list.length && !vm) return null;
|
|
175
177
|
const detail = list.find(m => m.family === open);
|
|
176
178
|
return <View style={{ paddingHorizontal: dense ? 8 : 16, gap: 4 }}>
|
|
177
179
|
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 12, rowGap: 2 }}>
|
|
180
|
+
{vm && <VmMeter r={vm} c={c} now={Math.max(now, Date.now())} />}
|
|
178
181
|
{list.map(m => <Pressable key={m.family} accessibilityRole="button" accessibilityLabel={`${meterText(m, now)}. ${m.accounts.length > 1 ? `${m.accounts.length} accounts; ` : ""}show detail`}
|
|
179
182
|
aria-expanded={open === m.family} onPress={() => setOpen(o => o === m.family ? null : m.family)}
|
|
180
183
|
style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: 22 }}>
|
|
@@ -191,6 +194,21 @@ export function UsageMeters({ theme, sources, dense }: { theme: Theme; sources:
|
|
|
191
194
|
</View>}
|
|
192
195
|
</View>;
|
|
193
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* "Paseo memory 72% · CPU 40%", the resource governor's view. Text stays muted like the provider meters; the bar
|
|
199
|
+
* and the "· paused" suffix take amber or red when Follow-up has held back its own work. Old readings dim.
|
|
200
|
+
*/
|
|
201
|
+
function VmMeter({ r, c, now }: { r: Resources; c: Colors; now: number }) {
|
|
202
|
+
const m = resourceMeter(r, now);
|
|
203
|
+
if (!m) return null;
|
|
204
|
+
const color = toneColor(m, c);
|
|
205
|
+
return <View accessibilityRole="text" accessibilityLabel={m.label} style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: 22, opacity: m.stale ? 0.6 : 1 }}>
|
|
206
|
+
<Bar pct={m.pct} color={color} c={c} />
|
|
207
|
+
<Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 11, fontVariant: ["tabular-nums"] }}>
|
|
208
|
+
{m.text}{m.suffix ? <Text style={{ color: m.tone === "ok" ? c.foregroundMuted : color, fontWeight: "500" }}>{` ${m.suffix}`}</Text> : null}
|
|
209
|
+
</Text>
|
|
210
|
+
</View>;
|
|
211
|
+
}
|
|
194
212
|
function Bar({ pct, color, c }: { pct: number; color: string; c: Colors }) {
|
|
195
213
|
return <View style={{ width: 28, height: 4, borderRadius: 2, backgroundColor: c.border, overflow: "hidden" }}>
|
|
196
214
|
<View style={{ width: `${Math.min(100, pct)}%`, height: 4, backgroundColor: color === c.foregroundMuted ? withAlpha(c.foreground, 0.45) : color }} />
|
package/client/home.tsx
CHANGED
|
@@ -128,36 +128,45 @@ function useAgentLive(agentId: string | null) {
|
|
|
128
128
|
return { handle, permissions: (handle?.pendingPermissions ?? []) as unknown as Permission[], status: handle?.status ?? null };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/** The last steps Home read per agent this session, shown while the agent
|
|
132
|
-
const seenSteps = new Map<string, TailStep[]>();
|
|
131
|
+
/** The last steps Home read per agent this session, and when, shown while the agent isn't loaded. */
|
|
132
|
+
const seenSteps = new Map<string, { steps: TailStep[]; at: number }>();
|
|
133
133
|
/**
|
|
134
134
|
* "Recent · last N steps", read only for the selected agent, refreshed when its status or flag changes.
|
|
135
|
-
* Reading a timeline loads an unloaded agent for an hour (see
|
|
136
|
-
* shows the steps Home saw last and waits for an explicit "Load recent steps".
|
|
135
|
+
* Reading a timeline loads an unloaded agent for an hour (see shared/agent-load.ts), so an agent that isn't loaded
|
|
136
|
+
* shows the steps Home saw last (marked with when) and waits for an explicit "Load recent steps".
|
|
137
137
|
*/
|
|
138
138
|
function useTail(handle: ReturnType<typeof useAgentLive>["handle"], key: string) {
|
|
139
139
|
const [steps, setSteps] = useState<TailStep[] | null>(null);
|
|
140
140
|
const [error, setError] = useState<string | null>(null);
|
|
141
|
+
const [seenAt, setSeenAt] = useState<number | null>(null);
|
|
141
142
|
const [wake, setWake] = useState(0);
|
|
142
143
|
const [asleep, setAsleep] = useState(false);
|
|
143
|
-
|
|
144
|
+
const [loading, setLoading] = useState(false);
|
|
145
|
+
const [unknown, setUnknown] = useState(false);
|
|
146
|
+
useEffect(() => { setWake(0); setUnknown(false); }, [handle]);
|
|
147
|
+
// A snapshot that never arrives counts as not loaded instead of spinning forever.
|
|
148
|
+
useEffect(() => { if (!handle) return; const id = setTimeout(() => setUnknown(true), 5_000); return () => clearTimeout(id); }, [handle]);
|
|
144
149
|
useEffect(() => {
|
|
145
150
|
if (!handle) { setSteps([]); return; }
|
|
146
151
|
const current = handle.current();
|
|
147
|
-
if (!current) return;
|
|
148
|
-
const loaded = isLoaded(current as unknown as LoadSignals);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
if (!current && !unknown) return;
|
|
153
|
+
const loaded = !!current && isLoaded(current as unknown as LoadSignals);
|
|
154
|
+
if (!loaded && !wake) {
|
|
155
|
+
const seen = seenSteps.get(handle.id);
|
|
156
|
+
setAsleep(true); setSteps(seen?.steps ?? []); setSeenAt(seen?.at ?? null); setError(null); return;
|
|
157
|
+
}
|
|
158
|
+
setAsleep(false);
|
|
159
|
+
let live = true; setLoading(true);
|
|
152
160
|
handle.timeline.refetch({ direction: "tail", limit: 40 }).then(page => {
|
|
153
161
|
if (!live) return;
|
|
154
162
|
const entries = [...(page as unknown as { entries: { seqStart: number; item: Parameters<typeof tailSteps>[0][number] }[] }).entries].sort((a, b) => a.seqStart - b.seqStart);
|
|
155
163
|
const next = tailSteps(entries.map(e => e.item), 6);
|
|
156
|
-
seenSteps.set(handle.id, next); setSteps(next); setError(null);
|
|
157
|
-
}).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } })
|
|
164
|
+
seenSteps.set(handle.id, { steps: next, at: Date.now() }); setSteps(next); setSeenAt(null); setError(null);
|
|
165
|
+
}).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } })
|
|
166
|
+
.finally(() => { if (live) setLoading(false); });
|
|
158
167
|
return () => { live = false; };
|
|
159
|
-
}, [handle, key, wake]);
|
|
160
|
-
return { steps, error, asleep:
|
|
168
|
+
}, [handle, key, wake, unknown]);
|
|
169
|
+
return { steps, error, seenAt, asleep, loading, woken: wake > 0, load: () => setWake(n => n + 1) };
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onBack }: {
|
|
@@ -230,15 +239,16 @@ function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onB
|
|
|
230
239
|
actions={{ openPr: (key, intent) => setSheet({ key, intent }), openQuestion: setQuestion, openAgent: () => openFull(s) }} />}
|
|
231
240
|
{actionBar}
|
|
232
241
|
{s.agentId && <View style={{ gap: 8 }}>
|
|
233
|
-
<Text style={[t.small, { fontWeight: "600", letterSpacing: 0.5, textTransform: "uppercase" }]}>
|
|
242
|
+
<Text style={[t.small, { fontWeight: "600", letterSpacing: 0.5, textTransform: "uppercase" }]}>{tail.asleep && tail.seenAt ? `Last seen · ${age(new Date(tail.seenAt).toISOString(), Date.now()) === "now" ? "just now" : `${age(new Date(tail.seenAt).toISOString(), Date.now())} ago`}` : tail.steps?.length ? `Recent · last ${tail.steps.length} steps` : "Recent steps"}</Text>
|
|
243
|
+
{!!tail.error && <Text style={t.meta}>{`Couldn't load the agent's steps: ${tail.error}`}</Text>}
|
|
234
244
|
{tail.steps === null ? <Spinner color={c.foregroundMuted} size={12} label="Loading the agent's steps" />
|
|
235
|
-
: tail.steps.length === 0 ? <Text style={t.meta}>{tail.
|
|
245
|
+
: tail.steps.length === 0 ? (tail.error ? null : <Text style={t.meta}>{tail.asleep ? "This agent isn't loaded, so its steps aren't shown." : "No steps yet."}</Text>)
|
|
236
246
|
: tail.steps.map((step, i) => <Step key={i} step={step} c={c} last={i === tail.steps!.length - 1} />)}
|
|
237
|
-
{tail.asleep && <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
|
238
|
-
<Button theme={theme} compact={compact} kind="secondary"
|
|
239
|
-
<Text style={[t.small, { flex: 1, minWidth: 160 }]}>
|
|
247
|
+
{(tail.asleep || (tail.woken && tail.loading)) && <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
|
248
|
+
<Button theme={theme} compact={compact} kind="secondary" title={tail.loading ? "Loading…" : "Load recent steps"} loading={tail.loading} disabled={tail.loading} onPress={tail.load} />
|
|
249
|
+
<Text style={[t.small, { flex: 1, minWidth: 160 }]}>Loads the agent into memory for about an hour.</Text>
|
|
240
250
|
</View>}
|
|
241
|
-
<Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>
|
|
251
|
+
{!!tail.steps?.length && <Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>}
|
|
242
252
|
</View>}
|
|
243
253
|
</ScrollView>
|
|
244
254
|
{s.agentId && <ReplyBox theme={theme} compact={compact} agentName={s.title} onSend={async text => {
|
package/client/inbox-store.ts
CHANGED
|
@@ -3,10 +3,13 @@ import { inboxActRpc, inboxListRpc } from "../shared/contracts";
|
|
|
3
3
|
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
4
4
|
import type { InboxRow } from "../shared/inbox";
|
|
5
5
|
import { subscribeDisplay } from "./display";
|
|
6
|
+
import { onVisible, pollDelay } from "./visibility";
|
|
6
7
|
|
|
7
8
|
type List = RpcOutput<typeof inboxListRpc>;
|
|
8
9
|
export type Recent = { agentIds: string[]; label: string; at: number; batch: boolean };
|
|
9
10
|
const POLL_MS = 4_000;
|
|
11
|
+
/** While the app is hidden (1.3.0-beta.4). */
|
|
12
|
+
const HIDDEN_POLL_MS = 30_000;
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* One shared poll of the inbox for every surface on this client. Sign-offs apply optimistically;
|
|
@@ -47,13 +50,14 @@ export function createInboxStore(client: PluginClientContext) {
|
|
|
47
50
|
} catch {
|
|
48
51
|
failures++;
|
|
49
52
|
if (failures >= 2 && offlineSince === null) { offlineSince = Date.now(); emit(); }
|
|
50
|
-
} finally { if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); }
|
|
53
|
+
} finally { if (!stopped) timer = setTimeout(() => void poll(), pollDelay(POLL_MS, HIDDEN_POLL_MS)); }
|
|
51
54
|
};
|
|
52
55
|
const refresh = () => { if (timer) clearTimeout(timer); void poll(); };
|
|
53
56
|
const unsubscribe = subscribeDisplay(values => {
|
|
54
57
|
const on = values.enable_inbox !== false;
|
|
55
58
|
if (on !== enabled) { enabled = on; emit(); if (on) refresh(); }
|
|
56
59
|
});
|
|
60
|
+
const offVisible = onVisible(refresh);
|
|
57
61
|
void poll();
|
|
58
62
|
|
|
59
63
|
const patch = (agentIds: string[], change: (row: InboxRow) => InboxRow) => {
|
|
@@ -91,7 +95,7 @@ export function createInboxStore(client: PluginClientContext) {
|
|
|
91
95
|
recent: () => recent,
|
|
92
96
|
clearRecent() { recent = null; emit(); },
|
|
93
97
|
refresh,
|
|
94
|
-
stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); unsubscribe(); },
|
|
98
|
+
stop() { stopped = true; offVisible(); if (timer) clearTimeout(timer); listeners.clear(); unsubscribe(); },
|
|
95
99
|
|
|
96
100
|
/** Sign off now; queue for replay when the host can't be reached. */
|
|
97
101
|
async signOff(requested: string[], options: { muteTurn?: boolean } = {}) {
|
package/client/live.tsx
CHANGED
|
@@ -8,8 +8,11 @@ import { needsYou } from "../shared/attention";
|
|
|
8
8
|
import { useSidebarViewActive } from "./inbox";
|
|
9
9
|
import { homeCounts } from "../shared/home";
|
|
10
10
|
import { useHomeModel, type HomeSources } from "./home-data";
|
|
11
|
+
import { onVisible, pollDelay } from "./visibility";
|
|
11
12
|
|
|
12
13
|
const POLL_MS = 8_000;
|
|
14
|
+
/** While the app is hidden. */
|
|
15
|
+
const HIDDEN_POLL_MS = 60_000;
|
|
13
16
|
|
|
14
17
|
/** One shared poll of tracked workspaces for the pills, header-button dots and the sidebar count. */
|
|
15
18
|
export function createLiveStore(client: PluginClientContext) {
|
|
@@ -20,17 +23,27 @@ export function createLiveStore(client: PluginClientContext) {
|
|
|
20
23
|
const listeners = new Set<() => void>();
|
|
21
24
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
22
25
|
let stopped = false;
|
|
26
|
+
/** The worker's list revision this store holds; each poll asks only for what changed since (1.3.0-beta.4). */
|
|
27
|
+
let revision: number | undefined;
|
|
28
|
+
let all = new Map<string, Workspace>();
|
|
23
29
|
const emit = () => { version++; for (const l of listeners) l(); };
|
|
24
30
|
const poll = async () => {
|
|
25
31
|
try {
|
|
26
|
-
const result = await client.rpc(listRpc, {});
|
|
32
|
+
const result = await client.rpc(listRpc, revision === undefined ? {} : { since: revision });
|
|
27
33
|
if (stopped) return;
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
const diff = result.full === false;
|
|
35
|
+
if (diff && !result.workspaces.length && !result.removed?.length) return;
|
|
36
|
+
const next = diff ? new Map(all) : new Map<string, Workspace>();
|
|
37
|
+
for (const id of result.removed ?? []) next.delete(id);
|
|
38
|
+
for (const w of result.workspaces) next.set(w.id, w);
|
|
39
|
+
all = next; revision = result.revision;
|
|
40
|
+
workspaces = new Map([...all.values()].filter(w => !w.archived).map(w => [w.id, w]));
|
|
41
|
+
archived = [...all.values()].filter(w => w.archived);
|
|
30
42
|
emit();
|
|
31
43
|
} catch { /* Keep the last answer through restarts and disconnects. */ }
|
|
32
|
-
finally { if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); }
|
|
44
|
+
finally { if (!stopped) timer = setTimeout(() => void poll(), pollDelay(POLL_MS, HIDDEN_POLL_MS)); }
|
|
33
45
|
};
|
|
46
|
+
const offVisible = onVisible(() => { if (timer) clearTimeout(timer); void poll(); });
|
|
34
47
|
void poll();
|
|
35
48
|
return {
|
|
36
49
|
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
@@ -39,7 +52,7 @@ export function createLiveStore(client: PluginClientContext) {
|
|
|
39
52
|
all: () => [...workspaces.values()],
|
|
40
53
|
archived: () => archived,
|
|
41
54
|
refresh: () => { if (timer) clearTimeout(timer); void poll(); },
|
|
42
|
-
stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); },
|
|
55
|
+
stop() { stopped = true; offVisible(); if (timer) clearTimeout(timer); listeners.clear(); },
|
|
43
56
|
};
|
|
44
57
|
}
|
|
45
58
|
export type LiveStore = ReturnType<typeof createLiveStore>;
|
package/client/settings.tsx
CHANGED
|
@@ -61,7 +61,7 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
|
61
61
|
onValueChange={value => change.mutate({ enable_follow_every: value })} />
|
|
62
62
|
<SettingsSwitch label="Send repairs automatically" hint="Review threads and red CI go to the workspace's agent on their own. Applies to workspaces Follow-up starts following from now on." value={data.enable_auto_sends_default} disabled={change.isPending}
|
|
63
63
|
onValueChange={value => change.mutate({ enable_auto_sends_default: value })} />
|
|
64
|
-
<SettingsSelect label="
|
|
64
|
+
<SettingsSelect label="Rest workspaces after" hint="A workspace rests when you haven't messaged it in this long, its agent isn't loaded and it has no open PR. It stays listed, but isn't checked and gets nothing sent until you start a turn there."
|
|
65
65
|
value={String(windowDays)} disabled={change.isPending}
|
|
66
66
|
options={[...new Set([1, 3, 7, 14, windowDays])].sort((a, b) => a - b).map(d => ({ value: String(d), label: d === 1 ? "1 day" : `${d} days` }))}
|
|
67
67
|
onValueChange={value => change.mutate({ activeWindowDays: Number(value) })} />
|
|
@@ -69,14 +69,14 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
|
69
69
|
</SettingsSection>
|
|
70
70
|
{!!data?.workspaces.length && <SettingsSection title="Workspace exceptions" info="Stop following one workspace, or change its auto-send. Following it again picks up its PRs on the next refresh.">
|
|
71
71
|
<SettingsCard>
|
|
72
|
-
<SettingsSelect label="Workspace" value={picked} options={[{ value: "", label: "Choose a workspace" }, ...data.workspaces.map(w => ({ value: w.id, label: w.title }))]} onValueChange={setPicked} />
|
|
72
|
+
<SettingsSelect label="Workspace" value={picked} options={[{ value: "", label: "Choose a workspace" }, ...data.workspaces.map(w => ({ value: w.id, label: w.dormant ? `${w.title} · resting` : w.title }))]} onValueChange={setPicked} />
|
|
73
73
|
{chosen && <SettingsSwitch label="Follow this workspace" value={!chosen.optedOut && chosen.enable_tracking} disabled={change.isPending}
|
|
74
74
|
onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, follow: value } }, { onSuccess: () => toast.show(value ? `Following ${chosen.title}` : `Stopped following ${chosen.title}`, { variant: "success" }) })} />}
|
|
75
|
-
{chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" hint={chosen.parked ? "Resting.
|
|
75
|
+
{chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" hint={chosen.parked ? "Resting. Auto-send was paused and comes back when the workspace is active again. Changing it here makes your choice stick." : chosen.dormant ? "Resting. Nothing is sent until you start a turn there." : undefined} value={chosen.enable_auto_sends} disabled={change.isPending}
|
|
76
76
|
onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, enable_auto_sends: value } })} />}
|
|
77
77
|
</SettingsCard>
|
|
78
78
|
{exceptions.length > 0 && <SettingsCard>
|
|
79
|
-
{exceptions.map(w => <SettingsAction key={w.id} label={w.title} hint={w.optedOut || !w.enable_tracking ? "Not followed" : w.enable_auto_sends ? "Sends repairs automatically" : "Repairs wait for you"}
|
|
79
|
+
{exceptions.map(w => <SettingsAction key={w.id} label={w.title} hint={(w.optedOut || !w.enable_tracking ? "Not followed" : w.enable_auto_sends ? "Sends repairs automatically" : "Repairs wait for you") + (w.dormant && !w.optedOut && w.enable_tracking ? " · resting" : "")}
|
|
80
80
|
actionLabel="Edit" onPress={() => setPicked(w.id)} />)}
|
|
81
81
|
</SettingsCard>}
|
|
82
82
|
{!exceptions.length && <Text style={t.meta}>No exceptions. Every workspace is followed the same way.</Text>}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AppState } from "react-native";
|
|
2
|
+
|
|
3
|
+
type Doc = { visibilityState?: string; addEventListener(type: string, fn: () => void): void; removeEventListener(type: string, fn: () => void): void };
|
|
4
|
+
const doc = (): Doc | undefined => (globalThis as { document?: Doc }).document;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the app is in the foreground (1.3.0-beta.4). Background polls slow down instead of flooding the daemon
|
|
8
|
+
* from a hidden window. Unknown counts as visible.
|
|
9
|
+
*/
|
|
10
|
+
export function appVisible(): boolean {
|
|
11
|
+
try {
|
|
12
|
+
const d = doc(); if (d?.visibilityState) return d.visibilityState === "visible";
|
|
13
|
+
return AppState.currentState !== "background" && AppState.currentState !== "inactive";
|
|
14
|
+
} catch { return true; }
|
|
15
|
+
}
|
|
16
|
+
/** Calls `wake` when the app comes back to the foreground, so a slowed poll refreshes right away. */
|
|
17
|
+
export function onVisible(wake: () => void): () => void {
|
|
18
|
+
const subscription = (() => { try { return AppState.addEventListener("change", state => { if (state === "active") wake(); }); } catch { return null; } })();
|
|
19
|
+
const listener = () => { if (appVisible()) wake(); };
|
|
20
|
+
try { doc()?.addEventListener("visibilitychange", listener); } catch { /* no DOM */ }
|
|
21
|
+
return () => { subscription?.remove(); try { doc()?.removeEventListener("visibilitychange", listener); } catch { /* no DOM */ } };
|
|
22
|
+
}
|
|
23
|
+
/** The poll interval for now: `visibleMs` in the foreground, `hiddenMs` otherwise. */
|
|
24
|
+
export const pollDelay = (visibleMs: number, hiddenMs: number): number => appVisible() ? visibleMs : hiddenMs;
|
package/index.server.ts
CHANGED
|
@@ -34,8 +34,13 @@ import { fileSources } from "./server/inbox-classifier";
|
|
|
34
34
|
import { onBeforeSend } from "./server/daemon";
|
|
35
35
|
import { copyFile, readFile, rename } from "node:fs/promises";
|
|
36
36
|
import { HistoryCache } from "./server/history-cache";
|
|
37
|
+
import { Governor } from "./server/governor";
|
|
38
|
+
import { ListCache } from "./server/list-cache";
|
|
39
|
+
import { scopeWriter } from "./server/scopes";
|
|
40
|
+
import { batchArchive, pruneUnsent } from "./server/batch-archive";
|
|
41
|
+
import { NO_USAGE } from "./shared/usage";
|
|
37
42
|
import { conversationGone, DEFAULT_ACTIVE_WINDOW_DAYS } from "./server/agent-load";
|
|
38
|
-
import { ACTIVE_SCOPE_MIGRATION, autoSendsByDefault, FOLLOW_EVERY_MIGRATION, followsByDefault, followsEvery, migrateFollowEvery, optedOut } from "./shared/settings";
|
|
43
|
+
import { ACTIVE_SCOPE_MIGRATION, PRUNE_MIGRATION, autoSendsByDefault, FOLLOW_EVERY_MIGRATION, followsByDefault, followsEvery, migrateFollowEvery, optedOut } from "./shared/settings";
|
|
39
44
|
import { BRIEF_KIND, BRIEF_VERSION, prState, validateNeedsYou, type Brief, type NeedsYouRecord } from "./shared/brief";
|
|
40
45
|
|
|
41
46
|
export default function contribute(server: PluginServerContext) {
|
|
@@ -45,7 +50,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
45
50
|
const questionMode = async (): Promise<QuestionMode> => { try { const state = await questionSettings.read(); return state.status === "ready" ? state.values.mode : DEFAULT_QUESTION_MODE; } catch { return DEFAULT_QUESTION_MODE; } };
|
|
46
51
|
const directory = join(paseoHome, "plugin-data", "follow-through");
|
|
47
52
|
const bridge = join(directory, "mcp.mjs");
|
|
48
|
-
const store = new Store(directory);
|
|
53
|
+
const store = new Store(directory, 1_000);
|
|
54
|
+
store.onChanged = scopeWriter(directory);
|
|
49
55
|
const agentHooks = new AgentHooks(directory, clientEntry());
|
|
50
56
|
const gateway = new PaseoGateway(undefined, undefined, undefined, HistoryCache.at(directory));
|
|
51
57
|
const graphite = new Graphite();
|
|
@@ -53,6 +59,11 @@ export default function contribute(server: PluginServerContext) {
|
|
|
53
59
|
const browser = new BrowserBridge();
|
|
54
60
|
const actions = new PrActions(store, github, graphite, browser);
|
|
55
61
|
const engine = new Engine(store, github, gateway);
|
|
62
|
+
const governor = new Governor();
|
|
63
|
+
const listCache = new ListCache();
|
|
64
|
+
engine.paused = () => !governor.allowsBackground();
|
|
65
|
+
const archive = batchArchive(directory);
|
|
66
|
+
engine.archive = archive;
|
|
56
67
|
const usage = new UsageReader(paseoHome);
|
|
57
68
|
// Unknown is not off: an unreadable document keeps the inbox on with its defaults.
|
|
58
69
|
const inboxSettings = async () => { try { const state = await displaySettings.read(); if (state.status === "ready") return state.values; } catch {} return displayPreferences.schema.parse({}); };
|
|
@@ -121,6 +132,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
121
132
|
const inboxLoop = async () => {
|
|
122
133
|
await ready;
|
|
123
134
|
if (stopped || loadError) return;
|
|
135
|
+
// Critical memory: the inbox's background reads wait too; hooks still record live events.
|
|
136
|
+
if (!governor.allowsExtras()) { if (!stopped) inboxTimer = setTimeout(() => void inboxLoop(), 15_000); return; }
|
|
124
137
|
try { await writeBriefState().catch(() => undefined); await inbox.tick(); await inbox.observeGuard(); await inbox.observeFollowUp(await store.snapshot()); inboxError = null; }
|
|
125
138
|
catch (error) { inboxError = error instanceof Error ? error.message : "The Needs-you inbox couldn't update."; }
|
|
126
139
|
if (!stopped) inboxTimer = setTimeout(() => void inboxLoop(), 15_000);
|
|
@@ -143,6 +156,17 @@ export default function contribute(server: PluginServerContext) {
|
|
|
143
156
|
await copyFile(join(directory, "state.json"), join(directory, "state.before-follow-every.json.bak"), 1).catch(error => { if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; });
|
|
144
157
|
await store.mutate(state => { migrateFollowEvery(state); });
|
|
145
158
|
};
|
|
159
|
+
/**
|
|
160
|
+
* 1.3.0-beta.4: once, back up state.json and move never-sent cancelled batches beyond the newest few per
|
|
161
|
+
* workspace to the archive (see pruneUnsent). Archived first, so nothing is lost if the write fails.
|
|
162
|
+
*/
|
|
163
|
+
const pruneHistory = async () => {
|
|
164
|
+
if ((await store.snapshot()).migrations?.includes(PRUNE_MIGRATION)) return;
|
|
165
|
+
await copyFile(join(directory, "state.json"), join(directory, "state.before-prune.json.bak"), 1).catch(error => { if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; });
|
|
166
|
+
const preview = await store.read();
|
|
167
|
+
for (const w of preview.workspaces) await archive(w.id, pruneUnsent(w));
|
|
168
|
+
await store.mutate(state => { for (const w of state.workspaces) pruneUnsent(w); state.migrations = [...state.migrations ?? [], PRUNE_MIGRATION]; });
|
|
169
|
+
};
|
|
146
170
|
/** 1.3.0-beta.3: back up once before the active-scope migration (applied by the engine after its first discovery). */
|
|
147
171
|
const backupActiveScope = async () => {
|
|
148
172
|
if ((await store.snapshot()).migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return;
|
|
@@ -152,9 +176,12 @@ export default function contribute(server: PluginServerContext) {
|
|
|
152
176
|
unlock = await store.lock();
|
|
153
177
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
154
178
|
await writeFile(bridge, mcpSource, { mode: 0o600 });
|
|
179
|
+
governor.start();
|
|
155
180
|
await engine.recover();
|
|
156
181
|
await migrateTracking();
|
|
157
182
|
await backupActiveScope();
|
|
183
|
+
await pruneHistory();
|
|
184
|
+
await store.onChanged(await store.snapshot()).catch(() => undefined);
|
|
158
185
|
hooksStarted = true;
|
|
159
186
|
await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; following still works."; });
|
|
160
187
|
hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
|
|
@@ -256,7 +283,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
256
283
|
const view = browserWorkspaces(entries);
|
|
257
284
|
return { tracked: true, ...view, workspaces: view.workspaces.map(w => ({ ...w, title: w.title.slice(0, 200), agentStatus: w.agentStatus.slice(0, 40), state: w.state.slice(0, 200) })) };
|
|
258
285
|
});
|
|
259
|
-
server.handle(rpc.listRpc, async () => { await ready; return {
|
|
286
|
+
server.handle(rpc.listRpc, async ({ since }) => { await ready; return { ...listCache.answer(await store.snapshot(), since), error: loadError ?? hookError ?? engine.error }; });
|
|
260
287
|
server.handle(rpc.getRpc, async ({ workspaceId }) => { await ready; return { workspace: (await store.snapshot()).workspaces.find(w => w.id === workspaceId && w.enable_tracking) ?? null, error: loadError ?? hookError ?? engine.error }; });
|
|
261
288
|
server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
|
|
262
289
|
await requireReady();
|
|
@@ -440,7 +467,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
440
467
|
const priorityStore = new PriorityStore(directory);
|
|
441
468
|
const agentActions = new AgentActions({ store, prActions: actions, engine, inbox, priorities: priorityStore });
|
|
442
469
|
server.handle(rpc.prioritiesRpc, async () => ({ items: await priorityStore.read() }));
|
|
443
|
-
|
|
470
|
+
// Provider limits are non-essential: at critical memory only the VM meter is served.
|
|
471
|
+
server.handle(rpc.usageRpc, async () => ({ ...(governor.allowsExtras() ? await usage.read() : NO_USAGE), resources: governor.current() }));
|
|
444
472
|
server.handle(rpc.agentActRpc, async ({ agentId, tool, args }, { paseo }) => {
|
|
445
473
|
await requireReady(); seen(paseo);
|
|
446
474
|
if (!(AGENT_TOOLS as readonly string[]).includes(tool)) throw new Error(`Unknown Follow-up tool ${tool}.`);
|
|
@@ -598,8 +626,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
598
626
|
server.on("workspace.archived", async event => { await ready; if (!loadError && !stopped) await store.mutate(state => { const w = state.workspaces.find(w => w.id === event.workspace.id); if (w) w.archived = true; }); }),
|
|
599
627
|
];
|
|
600
628
|
return async () => {
|
|
601
|
-
stopped = true; if (timer) clearTimeout(timer); if (inboxTimer) clearTimeout(inboxTimer); inbox.stop(); if (hookTimer) clearInterval(hookTimer); removers.forEach(remove => remove());
|
|
629
|
+
stopped = true; governor.stop(); if (timer) clearTimeout(timer); if (inboxTimer) clearTimeout(inboxTimer); inbox.stop(); if (hookTimer) clearInterval(hookTimer); removers.forEach(remove => remove());
|
|
602
630
|
await ready; if (hooksStarted) await agentHooks.stop().catch(() => {});
|
|
603
|
-
await actions.close(); await closeConnections(); await engine.stop(); await ready; await unlock?.();
|
|
631
|
+
await actions.close(); await closeConnections(); await engine.stop(); await ready; await store.flush().catch(() => undefined); await unlock?.();
|
|
604
632
|
};
|
|
605
633
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-follow-up",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.3.0-beta.
|
|
4
|
+
"version": "1.3.0-beta.4",
|
|
5
5
|
"description": "Follow every workspace PR stack in Paseo, answer agent questions, batch repairs for the main agent, and see what needs you.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"test": "tsx --test tests/*.test.ts",
|
|
27
|
-
"verify": "npm run typecheck && npm test && node --test button-visibility.test.mjs",
|
|
27
|
+
"verify": "npm run typecheck && npm test && node --test button-visibility.test.mjs && npm run test:scale",
|
|
28
28
|
"preview": "node scripts/check-install.mjs --keep",
|
|
29
29
|
"test:install": "node scripts/check-install.mjs",
|
|
30
|
-
"prepare": "node scripts/build.mjs"
|
|
30
|
+
"prepare": "node scripts/build.mjs",
|
|
31
|
+
"test:scale": "node scripts/scale-check.mjs --workspaces 120 --awake 5 --churn 35 --idle 30 --rss-budget 240 --cpu-budget 3"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@getpaseo/client": "0.14.0",
|
package/server/agent-hook.mjs
CHANGED
|
@@ -59,9 +59,15 @@ async function main() {
|
|
|
59
59
|
if (event.agent_id || event.hook_event_name === "SubagentStop") return;
|
|
60
60
|
const lease = await json(join(directory, "hooks-enabled.json"));
|
|
61
61
|
if (lease.expiresAt < Date.now() || !lease.clientEntry) return;
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
62
|
+
// scopes.json is a few KB; this hook runs on every tool call. Older workers don't write it: read state.json.
|
|
63
|
+
const scopes = await json(join(directory, "scopes.json")).catch(() => null);
|
|
64
|
+
let workspace = null; let contributor = false;
|
|
65
|
+
if (scopes?.agents) { const entry = scopes.agents[agentId]; if (entry && entry.role !== "recovery") { workspace = { id: entry.workspaceId }; contributor = entry.role === "contributor"; } }
|
|
66
|
+
else {
|
|
67
|
+
const state = await json(join(directory, "state.json")).catch(() => ({ workspaces: [] }));
|
|
68
|
+
workspace = state.workspaces.find(w => (w.mainAgentId === agentId || w.contributorAgentIds?.includes(agentId)) && w.enable_tracking && !w.archived) ?? null;
|
|
69
|
+
contributor = !!workspace && workspace.mainAgentId !== agentId;
|
|
70
|
+
}
|
|
65
71
|
// The brief nudge applies to root agents launched with FOLLOW_THROUGH_BRIEF, tracked or not.
|
|
66
72
|
const provider = process.env.FOLLOW_THROUGH_BRIEF || null;
|
|
67
73
|
const brief = provider ? await json(join(directory, "brief.json")).catch(() => null) : null;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Batch, Workspace } from "../shared/model";
|
|
4
|
+
|
|
5
|
+
/** Never-sent cancelled batches kept in state.json per workspace; older ones move to the archive. */
|
|
6
|
+
export const KEEP_UNSENT_CANCELLED = 3;
|
|
7
|
+
/**
|
|
8
|
+
* A batch cancelled before it went out ("Conditions resolved before delivery") records nothing about delivery:
|
|
9
|
+
* dedupe and repair limits only look at batches that were sent, and the activity log only at settled ones. The
|
|
10
|
+
* completion check flips with every turn, so these piled up to ~290 per workspace (82% of state.json on the VM).
|
|
11
|
+
* Removes all but the newest few from `w` and returns them for the archive.
|
|
12
|
+
*/
|
|
13
|
+
export function pruneUnsent(w: Pick<Workspace, "batches">, keep = KEEP_UNSENT_CANCELLED): Batch[] {
|
|
14
|
+
const unsent = w.batches.filter(b => b.state === "cancelled" && !b.sentAt && !b.settledAt);
|
|
15
|
+
if (unsent.length <= keep) return [];
|
|
16
|
+
const drop = new Set(unsent.slice(0, unsent.length - keep));
|
|
17
|
+
w.batches = w.batches.filter(b => !drop.has(b));
|
|
18
|
+
return [...drop];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Appends pruned batches to plugin-data/follow-through/archive/<workspace>.jsonl (kept, never read back). */
|
|
22
|
+
export function batchArchive(directory: string) {
|
|
23
|
+
return async (workspaceId: string, batches: Batch[]): Promise<void> => {
|
|
24
|
+
if (!batches.length) return;
|
|
25
|
+
const folder = join(directory, "archive");
|
|
26
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
27
|
+
await appendFile(join(folder, `${workspaceId.replace(/[^\w.-]/g, "_")}.jsonl`), batches.map(b => JSON.stringify(b)).join("\n") + "\n", { mode: 0o600 });
|
|
28
|
+
};
|
|
29
|
+
}
|
package/server/daemon.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface Daemon {
|
|
|
30
30
|
fetchAgent(id: string): Promise<{ agent: Agent } | null>;
|
|
31
31
|
fetchAgents(options?: unknown): Promise<{ entries: { agent: Agent }[]; pageInfo: { hasMore: boolean; nextCursor: string | null } }>;
|
|
32
32
|
fetchWorkspaces(options?: unknown): Promise<{ entries: WorkspaceRow[]; pageInfo: { hasMore: boolean; nextCursor: string | null } }>;
|
|
33
|
-
fetchAgentTimeline(id: string, options: unknown): Promise<{ entries: { item: unknown; seqStart: number; timestamp?: string }[]; startCursor: { epoch: string; seq: number } | null; hasOlder: boolean; error: string | null; gap: boolean; staleCursor: boolean }>;
|
|
33
|
+
fetchAgentTimeline(id: string, options: unknown): Promise<{ entries: { item: unknown; seqStart: number; timestamp?: string }[]; startCursor: { epoch: string; seq: number } | null; endCursor?: { epoch: string; seq: number } | null; epoch?: string; reset?: boolean; hasOlder: boolean; error: string | null; gap: boolean; staleCursor: boolean }>;
|
|
34
34
|
listProviderSubagents(id: string): Promise<{ subagents: { status: string }[]; error?: string | null }>;
|
|
35
35
|
sendMessage(id: string, text: string, options: { messageId: string; activeTurnBehavior: "steer" }): Promise<void>;
|
|
36
36
|
setWorkspaceLabel(input: { workspaceId: string; label: { name: string; color: "emerald" }; assigned: boolean }): Promise<unknown>;
|
|
@@ -111,6 +111,44 @@ async function subagentsRunning(client: Daemon, agent: Agent, failure: string):
|
|
|
111
111
|
return children.subagents.some(child => child.status === "running");
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
type Entry = { item: unknown; seqStart: number; timestamp?: string };
|
|
115
|
+
type Cursor = { epoch: string; seq: number };
|
|
116
|
+
type Read = { entries: Entry[]; complete: boolean; end: Cursor | null; incremental: boolean };
|
|
117
|
+
/** Entries re-read before the last cursor: canonical items (a tool call and its receipt) can update in place. */
|
|
118
|
+
const OVERLAP = 25;
|
|
119
|
+
/** The whole timeline, newest page first, up to 100 pages of 500. */
|
|
120
|
+
async function readAll(client: Daemon, agentId: string): Promise<Read> {
|
|
121
|
+
const entries: Entry[] = []; let cursor: Cursor | null = null; let end: Cursor | null = null; let complete = false;
|
|
122
|
+
for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
|
|
123
|
+
const page = await client.fetchAgentTimeline(agentId, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
|
|
124
|
+
if (page.error || page.gap || page.staleCursor) throw new Error("Conversation history was unavailable or changed during recovery");
|
|
125
|
+
if (pageIndex === 0) end = page.endCursor ?? null;
|
|
126
|
+
entries.push(...page.entries);
|
|
127
|
+
if (!page.hasOlder) { complete = true; break; }
|
|
128
|
+
if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
|
|
129
|
+
cursor = page.startCursor;
|
|
130
|
+
}
|
|
131
|
+
return { entries: entries.sort((a, b) => a.seqStart - b.seqStart), complete, end, incremental: false };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Only what's new since `since` (plus a small overlap), for a live agent whose history is already cached.
|
|
135
|
+
* Null means the cursor no longer applies (new epoch, reset, gap): read everything instead.
|
|
136
|
+
*/
|
|
137
|
+
async function readSince(client: Daemon, agentId: string, since: Cursor): Promise<Read | null> {
|
|
138
|
+
const entries: Entry[] = []; let cursor: Cursor | null = null; let end: Cursor | null = null;
|
|
139
|
+
for (let pageIndex = 0; pageIndex < 20; pageIndex++) {
|
|
140
|
+
const page = await client.fetchAgentTimeline(agentId, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 100, projection: "canonical", timeout: 15_000 });
|
|
141
|
+
if (page.error || page.gap || page.staleCursor || page.reset || (page.epoch && page.epoch !== since.epoch)) return null;
|
|
142
|
+
if (pageIndex === 0) { end = page.endCursor ?? null; if (end && end.epoch !== since.epoch) return null; }
|
|
143
|
+
entries.push(...page.entries);
|
|
144
|
+
const oldest = page.startCursor?.seq ?? page.entries[0]?.seqStart;
|
|
145
|
+
if (!page.hasOlder || (oldest !== undefined && oldest <= since.seq - OVERLAP)) return { entries: entries.filter(e => e.seqStart > since.seq - OVERLAP).sort((a, b) => a.seqStart - b.seqStart), complete: true, end, incremental: true };
|
|
146
|
+
if (!page.startCursor) return null;
|
|
147
|
+
cursor = page.startCursor;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
114
152
|
export class PaseoGateway implements Gateway {
|
|
115
153
|
constructor(private connect: typeof withDaemon = withDaemon, private guard = new SessionGuard(join(paseoHome, "plugin-data/session-guard")), private compacting = compactionPending, private histories = new HistoryCache(null), private now = Date.now) {}
|
|
116
154
|
private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
|
|
@@ -120,6 +158,8 @@ export class PaseoGateway implements Gateway {
|
|
|
120
158
|
// Persisted (successful reads only) so restarts don't rescan; see HistoryCache.
|
|
121
159
|
/** Timeline reads discovery has made, for tests and the scale check. */
|
|
122
160
|
timelineReads = 0;
|
|
161
|
+
/** Of those, reads that fetched only the newest entries. */
|
|
162
|
+
incrementalReads = 0;
|
|
123
163
|
/** The active window from the last discovery's state. */
|
|
124
164
|
private windowMs = DEFAULT_ACTIVE_WINDOW_DAYS * DAY_MS;
|
|
125
165
|
async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
|
|
@@ -170,25 +210,20 @@ export class PaseoGateway implements Gateway {
|
|
|
170
210
|
scans.sort((a, b) => (this.histories.get(a.agent.id)?.at ?? 0) - (this.histories.get(b.agent.id)?.at ?? 0) || (b.agent.lastUserMessageAt ?? "").localeCompare(a.agent.lastUserMessageAt ?? ""));
|
|
171
211
|
// Session opening and Track need only ownership metadata. Read transcripts in the poll.
|
|
172
212
|
for (const { agent, repo } of scans.slice(0, workspaceId ? 0 : 4)) {
|
|
173
|
-
let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = [];
|
|
213
|
+
let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = []; let end: { epoch: string; seq: number } | null = null;
|
|
174
214
|
this.timelineReads++;
|
|
215
|
+
const cached = this.histories.get(agent.id);
|
|
175
216
|
try {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
entries.sort((a, b) => a.seqStart - b.seqStart);
|
|
188
|
-
const items = entries.map(e => e.item);
|
|
189
|
-
artifacts = publishedArtifacts(entries, agent.id);
|
|
190
|
-
history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
|
|
191
|
-
submissions = submittedPrs(items, agent.id, repo);
|
|
217
|
+
const read = (cached?.cursor && cached.history.complete && !cached.history.error ? await readSince(client, agent.id, cached.cursor) : null) ?? await readAll(client, agent.id);
|
|
218
|
+
end = read.end;
|
|
219
|
+
const items = read.entries.map(e => e.item);
|
|
220
|
+
const fresh = { artifacts: publishedArtifacts(read.entries, agent.id), history: extractHistory(items, read.complete, read.complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory"), submissions: submittedPrs(items, agent.id, repo) };
|
|
221
|
+
if (read.incremental && cached) {
|
|
222
|
+
this.incrementalReads++;
|
|
223
|
+
history = { candidates: [...new Set([...cached.history.candidates, ...fresh.history.candidates])].sort(), goal: cached.history.goal ?? fresh.history.goal, complete: true, error: null };
|
|
224
|
+
submissions = [...new Map([...cached.submissions, ...fresh.submissions].map(x => [`${x.repo}#${x.number}`, x])).values()];
|
|
225
|
+
artifacts = [...new Map([...cached.artifacts, ...fresh.artifacts].map(x => [x.url, x])).values()];
|
|
226
|
+
} else ({ history, submissions, artifacts } = fresh);
|
|
192
227
|
} catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
|
|
193
228
|
// An unreadable timeline is retried with backoff instead of every poll. New user messages and
|
|
194
229
|
// live turns retry immediately.
|
|
@@ -196,7 +231,7 @@ export class PaseoGateway implements Gateway {
|
|
|
196
231
|
const live = ["running", "initializing"].includes(agent.status);
|
|
197
232
|
const failures = !history.error ? 0 : previous?.history.error && previous.messageAt === agent.lastUserMessageAt && !live ? previous.failures + 1 : 1;
|
|
198
233
|
const at = this.now();
|
|
199
|
-
this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at, failures, retryAt: at + historyRetryDelay(failures), history, submissions, artifacts });
|
|
234
|
+
this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at, failures, retryAt: at + historyRetryDelay(failures), history, submissions, artifacts, cursor: history.error ? null : end });
|
|
200
235
|
this.dirty.delete(agent.id);
|
|
201
236
|
}
|
|
202
237
|
for (const row of discovered) {
|