paseo-follow-up 1.3.0-beta.2 → 1.3.0-beta.3
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.tsx +28 -6
- package/client/settings.tsx +7 -2
- package/index.server.ts +35 -8
- package/package.json +1 -1
- package/server/agent-load.ts +35 -0
- package/server/daemon.ts +38 -16
- package/server/delivery.ts +3 -0
- package/server/discovery.ts +12 -1
- package/server/engine.ts +32 -5
- package/server/history-cache.ts +53 -0
- package/server/inbox-directory.ts +4 -0
- package/server/recovery-handoff.ts +6 -0
- package/shared/agent-load.ts +20 -0
- package/shared/contracts.ts +4 -1
- package/shared/model.ts +8 -0
- package/shared/settings.ts +18 -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.3
|
|
120
120
|
# or, for a fresh install:
|
|
121
121
|
paseo plugin install npm:paseo-follow-up@beta
|
|
122
122
|
```
|
package/client/home.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { needsYou } from "../shared/attention";
|
|
|
8
8
|
import { briefPane, followUpLog, lastWorkdayEnd, nextNeeds, paneButtons, sinceLabel, tailSteps, type HomeGroupId, type HomeModel, type LogLine, type TailStep, type Workstream } from "../shared/home";
|
|
9
9
|
import { age, snoozeUntil, type InboxRow } from "../shared/inbox";
|
|
10
10
|
import { errorText } from "../shared/errors";
|
|
11
|
+
import { isLoaded, type LoadSignals } from "../shared/agent-load";
|
|
11
12
|
import { BriefCard, type BriefDeps } from "./brief";
|
|
12
13
|
import { Button, IconButton, text, type Theme } from "./kit";
|
|
13
14
|
import { NeedsYouList } from "./needs-you";
|
|
@@ -77,7 +78,9 @@ export function FollowHome(props: PluginScreenProps & { deps: HomeDeps }) {
|
|
|
77
78
|
const list = <HomeList model={model} theme={theme} dense={compact} touch={compact} selectedKey={selected} onSelect={onSelect} briefLine={briefLine}
|
|
78
79
|
results={omnibox.results} activeResult={omnibox.active} onResult={item => setSelected(item.key)} filter={filter}
|
|
79
80
|
top={<ListTop theme={theme} dense={compact} title={<ListHeader theme={theme} compact={compact} onSettings={deps.openSettings} />}>
|
|
80
|
-
|
|
81
|
+
{/* zIndex: on web every View is its own stacking context, so the sort menu only paints and hit-tests above the
|
|
82
|
+
strip and meters below if this row sits above them. */}
|
|
83
|
+
<View style={{ zIndex: 2, flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: compact ? 12 : 16 }}>
|
|
81
84
|
<SearchField theme={theme} omnibox={omnibox} dense={compact} onEnter={item => setSelected(item.key)} />
|
|
82
85
|
<SortMenu theme={theme} mode={model.sort} onChange={deps.sources.extras.setSort} rankedAt={model.rankedAt} touch={compact} />
|
|
83
86
|
</View>
|
|
@@ -125,21 +128,36 @@ function useAgentLive(agentId: string | null) {
|
|
|
125
128
|
return { handle, permissions: (handle?.pendingPermissions ?? []) as unknown as Permission[], status: handle?.status ?? null };
|
|
126
129
|
}
|
|
127
130
|
|
|
128
|
-
/**
|
|
131
|
+
/** The last steps Home read per agent this session, shown while the agent is unloaded. */
|
|
132
|
+
const seenSteps = new Map<string, TailStep[]>();
|
|
133
|
+
/**
|
|
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 server/agent-load.ts), so an unloaded agent
|
|
136
|
+
* shows the steps Home saw last and waits for an explicit "Load recent steps".
|
|
137
|
+
*/
|
|
129
138
|
function useTail(handle: ReturnType<typeof useAgentLive>["handle"], key: string) {
|
|
130
139
|
const [steps, setSteps] = useState<TailStep[] | null>(null);
|
|
131
140
|
const [error, setError] = useState<string | null>(null);
|
|
141
|
+
const [wake, setWake] = useState(0);
|
|
142
|
+
const [asleep, setAsleep] = useState(false);
|
|
143
|
+
useEffect(() => { setWake(0); }, [handle]);
|
|
132
144
|
useEffect(() => {
|
|
133
145
|
if (!handle) { setSteps([]); return; }
|
|
146
|
+
const current = handle.current();
|
|
147
|
+
if (!current) return;
|
|
148
|
+
const loaded = isLoaded(current as unknown as LoadSignals);
|
|
149
|
+
setAsleep(!loaded);
|
|
150
|
+
if (!loaded && !wake) { setSteps(seenSteps.get(handle.id) ?? []); setError(null); return; }
|
|
134
151
|
let live = true;
|
|
135
152
|
handle.timeline.refetch({ direction: "tail", limit: 40 }).then(page => {
|
|
136
153
|
if (!live) return;
|
|
137
154
|
const entries = [...(page as unknown as { entries: { seqStart: number; item: Parameters<typeof tailSteps>[0][number] }[] }).entries].sort((a, b) => a.seqStart - b.seqStart);
|
|
138
|
-
|
|
155
|
+
const next = tailSteps(entries.map(e => e.item), 6);
|
|
156
|
+
seenSteps.set(handle.id, next); setSteps(next); setError(null);
|
|
139
157
|
}).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } });
|
|
140
158
|
return () => { live = false; };
|
|
141
|
-
}, [handle, key]);
|
|
142
|
-
return { steps, error };
|
|
159
|
+
}, [handle, key, wake]);
|
|
160
|
+
return { steps, error, asleep: asleep && !wake, load: () => setWake(n => n + 1) };
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onBack }: {
|
|
@@ -214,8 +232,12 @@ function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onB
|
|
|
214
232
|
{s.agentId && <View style={{ gap: 8 }}>
|
|
215
233
|
<Text style={[t.small, { fontWeight: "600", letterSpacing: 0.5, textTransform: "uppercase" }]}>Recent · last {tail.steps?.length || 6} steps</Text>
|
|
216
234
|
{tail.steps === null ? <Spinner color={c.foregroundMuted} size={12} label="Loading the agent's steps" />
|
|
217
|
-
: tail.steps.length === 0 ? <Text style={t.meta}>{tail.error ? `Couldn't load the agent's steps: ${tail.error}` : "No steps yet."}</Text>
|
|
235
|
+
: tail.steps.length === 0 ? <Text style={t.meta}>{tail.error ? `Couldn't load the agent's steps: ${tail.error}` : tail.asleep ? "This agent is asleep, so its steps aren't loaded." : "No steps yet."}</Text>
|
|
218
236
|
: 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" icon="RefreshCw" title="Load recent steps" accessibilityHint="Wakes the agent to read its steps. It stays loaded for about an hour." onPress={tail.load} />
|
|
239
|
+
<Text style={[t.small, { flex: 1, minWidth: 160 }]}>Wakes the agent, which keeps it in memory for about an hour.</Text>
|
|
240
|
+
</View>}
|
|
219
241
|
<Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>
|
|
220
242
|
</View>}
|
|
221
243
|
</ScrollView>
|
package/client/settings.tsx
CHANGED
|
@@ -50,7 +50,8 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
|
50
50
|
});
|
|
51
51
|
const data = query.data;
|
|
52
52
|
const chosen = data?.workspaces.find(w => w.id === picked) ?? null;
|
|
53
|
-
const exceptions = data?.workspaces.filter(w => w.optedOut || (w.enable_tracking && w.enable_auto_sends !== data.enable_auto_sends_default)) ?? [];
|
|
53
|
+
const exceptions = data?.workspaces.filter(w => w.optedOut || (w.enable_tracking && !w.parked && w.enable_auto_sends !== data.enable_auto_sends_default)) ?? [];
|
|
54
|
+
const windowDays = data?.activeWindowDays ?? 3;
|
|
54
55
|
return <>
|
|
55
56
|
<SettingsSection title="Following" info="Follow-up follows every workspace's PR stack, tasks and repairs as soon as the workspace has an agent. Turn Follow every workspace off to follow only the projects switched on below. Follow-up never merges, requests reviewers or archives on its own.">
|
|
56
57
|
{query.isPending && <ActivityIndicator accessibilityLabel="Loading following settings" color={c.foregroundMuted} style={{ alignSelf: "flex-start", marginVertical: 8 }} />}
|
|
@@ -60,6 +61,10 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
|
60
61
|
onValueChange={value => change.mutate({ enable_follow_every: value })} />
|
|
61
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}
|
|
62
63
|
onValueChange={value => change.mutate({ enable_auto_sends_default: value })} />
|
|
64
|
+
<SettingsSelect label="Watch closely for" hint="A workspace with no message from you in this long, no loaded agent and no open PR rests. It stays listed but isn't checked, and nothing is sent to it, until a turn starts there."
|
|
65
|
+
value={String(windowDays)} disabled={change.isPending}
|
|
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
|
+
onValueChange={value => change.mutate({ activeWindowDays: Number(value) })} />
|
|
63
68
|
</SettingsCard>}
|
|
64
69
|
</SettingsSection>
|
|
65
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.">
|
|
@@ -67,7 +72,7 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
|
67
72
|
<SettingsSelect label="Workspace" value={picked} options={[{ value: "", label: "Choose a workspace" }, ...data.workspaces.map(w => ({ value: w.id, label: w.title }))]} onValueChange={setPicked} />
|
|
68
73
|
{chosen && <SettingsSwitch label="Follow this workspace" value={!chosen.optedOut && chosen.enable_tracking} disabled={change.isPending}
|
|
69
74
|
onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, follow: value } }, { onSuccess: () => toast.show(value ? `Following ${chosen.title}` : `Stopped following ${chosen.title}`, { variant: "success" }) })} />}
|
|
70
|
-
{chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" value={chosen.enable_auto_sends} disabled={change.isPending}
|
|
75
|
+
{chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" hint={chosen.parked ? "Resting. Turns back on by itself when the workspace is active again." : chosen.dormant ? "Resting. Nothing is sent until a turn starts there." : undefined} value={chosen.enable_auto_sends} disabled={change.isPending}
|
|
71
76
|
onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, enable_auto_sends: value } })} />}
|
|
72
77
|
</SettingsCard>
|
|
73
78
|
{exceptions.length > 0 && <SettingsCard>
|
package/index.server.ts
CHANGED
|
@@ -33,7 +33,9 @@ import { daemonDirectory, guardPath } from "./server/inbox-directory";
|
|
|
33
33
|
import { fileSources } from "./server/inbox-classifier";
|
|
34
34
|
import { onBeforeSend } from "./server/daemon";
|
|
35
35
|
import { copyFile, readFile, rename } from "node:fs/promises";
|
|
36
|
-
import {
|
|
36
|
+
import { HistoryCache } from "./server/history-cache";
|
|
37
|
+
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";
|
|
37
39
|
import { BRIEF_KIND, BRIEF_VERSION, prState, validateNeedsYou, type Brief, type NeedsYouRecord } from "./shared/brief";
|
|
38
40
|
|
|
39
41
|
export default function contribute(server: PluginServerContext) {
|
|
@@ -45,7 +47,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
45
47
|
const bridge = join(directory, "mcp.mjs");
|
|
46
48
|
const store = new Store(directory);
|
|
47
49
|
const agentHooks = new AgentHooks(directory, clientEntry());
|
|
48
|
-
const gateway = new PaseoGateway();
|
|
50
|
+
const gateway = new PaseoGateway(undefined, undefined, undefined, HistoryCache.at(directory));
|
|
49
51
|
const graphite = new Graphite();
|
|
50
52
|
const github = new Github(undefined, graphite, new Deployments());
|
|
51
53
|
const browser = new BrowserBridge();
|
|
@@ -141,12 +143,18 @@ export default function contribute(server: PluginServerContext) {
|
|
|
141
143
|
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; });
|
|
142
144
|
await store.mutate(state => { migrateFollowEvery(state); });
|
|
143
145
|
};
|
|
146
|
+
/** 1.3.0-beta.3: back up once before the active-scope migration (applied by the engine after its first discovery). */
|
|
147
|
+
const backupActiveScope = async () => {
|
|
148
|
+
if ((await store.snapshot()).migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return;
|
|
149
|
+
await copyFile(join(directory, "state.json"), join(directory, "state.before-active-scope.json.bak"), 1).catch(error => { if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; });
|
|
150
|
+
};
|
|
144
151
|
const ready = (async () => {
|
|
145
152
|
unlock = await store.lock();
|
|
146
153
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
147
154
|
await writeFile(bridge, mcpSource, { mode: 0o600 });
|
|
148
155
|
await engine.recover();
|
|
149
156
|
await migrateTracking();
|
|
157
|
+
await backupActiveScope();
|
|
150
158
|
hooksStarted = true;
|
|
151
159
|
await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; following still works."; });
|
|
152
160
|
hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
|
|
@@ -202,20 +210,25 @@ export default function contribute(server: PluginServerContext) {
|
|
|
202
210
|
});
|
|
203
211
|
const followSettings = async () => {
|
|
204
212
|
const state = await store.snapshot();
|
|
205
|
-
return { enable_follow_every: followsEvery(state), enable_auto_sends_default: autoSendsByDefault(state),
|
|
206
|
-
workspaces: state.workspaces.filter(w => !w.archived).map(w => ({ id: w.id, title: w.title, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, optedOut: optedOut(state, w.id) }))
|
|
213
|
+
return { enable_follow_every: followsEvery(state), enable_auto_sends_default: autoSendsByDefault(state), activeWindowDays: state.activeWindowDays ?? DEFAULT_ACTIVE_WINDOW_DAYS,
|
|
214
|
+
workspaces: state.workspaces.filter(w => !w.archived).map(w => ({ id: w.id, title: w.title, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, optedOut: optedOut(state, w.id), dormant: !!w.dormant, parked: !!state.autoSendsParked?.includes(w.id) }))
|
|
207
215
|
.sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)) };
|
|
208
216
|
};
|
|
209
217
|
server.handle(rpc.followSettingsRpc, async () => { await requireReady(); return followSettings(); });
|
|
210
|
-
server.handle(rpc.setFollowSettingsRpc, async ({ enable_follow_every, enable_auto_sends_default, workspace }) => {
|
|
218
|
+
server.handle(rpc.setFollowSettingsRpc, async ({ enable_follow_every, enable_auto_sends_default, activeWindowDays, workspace }) => {
|
|
211
219
|
await requireReady();
|
|
212
220
|
await store.mutate(state => {
|
|
221
|
+
if (activeWindowDays !== undefined) state.activeWindowDays = activeWindowDays;
|
|
213
222
|
if (enable_follow_every !== undefined) state.enable_follow_every = enable_follow_every;
|
|
214
223
|
if (enable_auto_sends_default !== undefined) state.enable_auto_sends_default = enable_auto_sends_default;
|
|
215
224
|
if (!workspace) return;
|
|
216
225
|
const w = state.workspaces.find(w => w.id === workspace.workspaceId && !w.archived);
|
|
217
226
|
if (!w) throw new Error("This workspace is no longer available. Refresh settings.");
|
|
218
|
-
if (workspace.enable_auto_sends !== undefined)
|
|
227
|
+
if (workspace.enable_auto_sends !== undefined) {
|
|
228
|
+
w.enable_auto_sends = workspace.enable_auto_sends;
|
|
229
|
+
// An explicit choice replaces the migration's parking.
|
|
230
|
+
if (state.autoSendsParked) { state.autoSendsParked = state.autoSendsParked.filter(id => id !== w.id); if (!state.autoSendsParked.length) delete state.autoSendsParked; }
|
|
231
|
+
}
|
|
219
232
|
// Opting out stops following (discovery skips it); following again ends the opt-out below.
|
|
220
233
|
if (workspace.follow === false) { state.workspaceOptOuts = [...new Set([...state.workspaceOptOuts ?? [], w.id])]; w.enable_tracking = false; }
|
|
221
234
|
if (workspace.follow === true) state.workspaceOptOuts = (state.workspaceOptOuts ?? []).filter(id => id !== w.id);
|
|
@@ -504,7 +517,18 @@ export default function contribute(server: PluginServerContext) {
|
|
|
504
517
|
server.on("workspace.archived", async event => inboxHook(() => inbox.workspaceArchived(event.workspace.id))),
|
|
505
518
|
server.on("agent.turn_started", async event => {
|
|
506
519
|
await ready;
|
|
507
|
-
if (
|
|
520
|
+
if (stopped || loadError) return;
|
|
521
|
+
// A turn in a dormant workspace makes it active again; discovery reads the now-loaded agent on the next poll.
|
|
522
|
+
const saved = await store.snapshot();
|
|
523
|
+
const owned = saved.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
|
|
524
|
+
const waking = saved.workspaces.find(w => w.dormant && w.id === event.agent.workspaceId);
|
|
525
|
+
if (!owned && !waking) return;
|
|
526
|
+
if (owned) gateway.markDirty(event.agent.id);
|
|
527
|
+
await store.mutate(state => {
|
|
528
|
+
const w = owned && state.workspaces.find(w => w.id === owned.id); if (w) w.completion = null;
|
|
529
|
+
const d = waking && state.workspaces.find(w => w.id === waking.id); if (d) delete d.dormant;
|
|
530
|
+
});
|
|
531
|
+
if (waking) void engine.tick();
|
|
508
532
|
}),
|
|
509
533
|
server.before("agent.session_open", async ({ request }, { paseo }) => {
|
|
510
534
|
await ready;
|
|
@@ -564,7 +588,10 @@ export default function contribute(server: PluginServerContext) {
|
|
|
564
588
|
const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
|
|
565
589
|
if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => {
|
|
566
590
|
const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
|
|
567
|
-
if (w
|
|
591
|
+
if (!w || w.mainAgentId !== event.agent.id || !interruption) return;
|
|
592
|
+
// The provider lost the conversation: every later send fails the same way, so stop sending instead of holding.
|
|
593
|
+
if (event.outcome.kind === "failed" && conversationGone(event.outcome.error.message)) { w.goneAgent = { agentId: event.agent.id, at: interruption.at, reason: event.outcome.error.message }; w.completion = null; return; }
|
|
594
|
+
w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null;
|
|
568
595
|
});
|
|
569
596
|
void engine.tick();
|
|
570
597
|
}),
|
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.3",
|
|
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": [
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { settled, type Workspace } from "../shared/model";
|
|
2
|
+
|
|
3
|
+
export { isLoaded, LIVE_STATUSES, type LoadSignals } from "../shared/agent-load";
|
|
4
|
+
import { isLoaded, type LoadSignals } from "../shared/agent-load";
|
|
5
|
+
|
|
6
|
+
export const DAY_MS = 86_400_000;
|
|
7
|
+
export const DEFAULT_ACTIVE_WINDOW_DAYS = 3;
|
|
8
|
+
export const activeWindowMs = (state: { activeWindowDays?: number }): number => (state.activeWindowDays ?? DEFAULT_ACTIVE_WINDOW_DAYS) * DAY_MS;
|
|
9
|
+
|
|
10
|
+
/** A user message within the active window. */
|
|
11
|
+
export function recentlyUsed(agent: { lastUserMessageAt: string | null }, windowMs: number, now: number): boolean {
|
|
12
|
+
if (!agent.lastUserMessageAt) return false;
|
|
13
|
+
const at = Date.parse(agent.lastUserMessageAt);
|
|
14
|
+
return Number.isFinite(at) && now - at < windowMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A PR this workspace owns that isn't merged or closed. An unobserved PR counts as open until it's seen. */
|
|
18
|
+
export function ownsOpenPr(w: Pick<Workspace, "prs"> | undefined): boolean {
|
|
19
|
+
return !!w?.prs.some(p => !settled(p) && (p.snapshot ? p.snapshot.state === "OPEN" : true));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The active scope: Follow-up polls a followed workspace only while it has a loaded agent, a user message in
|
|
24
|
+
* the active window, or an open PR it owns. Everything else stays listed and costs nothing until a turn starts
|
|
25
|
+
* there (the turn hooks mark it dirty and the next discovery sees the loaded agent).
|
|
26
|
+
*/
|
|
27
|
+
export function workspaceActive(agents: (LoadSignals & { lastUserMessageAt: string | null })[], saved: Pick<Workspace, "prs"> | undefined, windowMs: number, now: number): boolean {
|
|
28
|
+
return agents.some(a => isLoaded(a) || recentlyUsed(a, windowMs, now)) || ownsOpenPr(saved);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The provider no longer has this conversation, so every send to it fails. */
|
|
32
|
+
export function conversationGone(error: unknown): boolean {
|
|
33
|
+
const text = typeof error === "string" ? error : error instanceof Error ? error.message : error && typeof error === "object" && "message" in error ? String((error as { message: unknown }).message) : "";
|
|
34
|
+
return /no conversation found|conversation (?:not found|no longer exists)|session (?:id )?(?:not found|does not exist|no longer exists)|no (?:such )?session/i.test(text);
|
|
35
|
+
}
|
package/server/daemon.ts
CHANGED
|
@@ -15,11 +15,14 @@ import { extractHistory, type History, type DiscoveredWorkspace } from "./discov
|
|
|
15
15
|
import type { PluginBeforeRequests } from "@getpaseo/plugin/server";
|
|
16
16
|
import { isWorkspaceRoot, type WorkspaceAgent } from "./workspace-agent";
|
|
17
17
|
import { SessionGuard } from "./session-guard";
|
|
18
|
-
import { DeliveryDeferred } from "./delivery";
|
|
18
|
+
import { DeliveryDeferred, ConversationGone } from "./delivery";
|
|
19
|
+
export { ConversationGone } from "./delivery";
|
|
20
|
+
import { activeWindowMs, isLoaded, recentlyUsed, workspaceActive, conversationGone, DEFAULT_ACTIVE_WINDOW_DAYS, DAY_MS } from "./agent-load";
|
|
21
|
+
import { HistoryCache } from "./history-cache";
|
|
19
22
|
type RecoveryAgentOptions = Partial<PluginBeforeRequests["agent.create"]["config"]> & { workspaceId: string; labels: Record<string, string>; idempotencyKey: string; env: Record<string, string> };
|
|
20
23
|
|
|
21
24
|
export const paseoHome = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
22
|
-
export type Agent = { id: string; provider: string; cwd: string; model?: string | null; currentModeId?: string | null; thinkingOptionId?: string | null; features?: { id: string; value: unknown }[]; persistence?: { sessionId: string; nativeHandle?: string } | null; title?: string | null; createdAt: string; workspaceId?: string; status: string; archivedAt?: string | null; labels: Record<string, string>; lastUserMessageAt: string | null; pendingPermissions: unknown[] };
|
|
25
|
+
export type Agent = { id: string; provider: string; cwd: string; model?: string | null; currentModeId?: string | null; thinkingOptionId?: string | null; features?: { id: string; value: unknown }[]; persistence?: { sessionId: string; nativeHandle?: string } | null; title?: string | null; createdAt: string; workspaceId?: string; status: string; archivedAt?: string | null; labels: Record<string, string>; lastUserMessageAt: string | null; pendingPermissions: unknown[]; activeTurn?: unknown; lastError?: string | null };
|
|
23
26
|
type WorkspaceRow = { id: string; name: string; projectId?: string | null; labels?: string[]; archivingAt?: string | null };
|
|
24
27
|
export interface Daemon {
|
|
25
28
|
connect(): Promise<void>; close(): Promise<void>;
|
|
@@ -97,25 +100,32 @@ export function historyRetryDelay(failures: number): number {
|
|
|
97
100
|
return failures <= 0 ? HISTORY_RETRY_MS : Math.min(HISTORY_RETRY_MS * 2 ** (failures - 1), HISTORY_RETRY_MAX_MS);
|
|
98
101
|
}
|
|
99
102
|
/**
|
|
100
|
-
*
|
|
101
|
-
* daemon anyway resumes the session from persistence (spawning its provider process)
|
|
102
|
-
*
|
|
103
|
+
* An unloaded agent has no live runtime, so it cannot have running provider subagents. Asking the
|
|
104
|
+
* daemon anyway resumes the session from persistence (spawning its provider process) and keeps it
|
|
105
|
+
* loaded for an hour, so each poll would wake it. See isLoaded for how "unloaded" is told apart.
|
|
103
106
|
*/
|
|
104
107
|
async function subagentsRunning(client: Daemon, agent: Agent, failure: string): Promise<boolean> {
|
|
105
|
-
if (agent
|
|
108
|
+
if (!isLoaded(agent)) return false;
|
|
106
109
|
const children = await client.listProviderSubagents(agent.id);
|
|
107
110
|
if (children.error) throw new Error(failure);
|
|
108
111
|
return children.subagents.some(child => child.status === "running");
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export class PaseoGateway implements Gateway {
|
|
112
|
-
constructor(private connect: typeof withDaemon = withDaemon, private guard = new SessionGuard(join(paseoHome, "plugin-data/session-guard")), private compacting = compactionPending) {}
|
|
115
|
+
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) {}
|
|
113
116
|
private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
|
|
114
117
|
private dirty = new Set<string>();
|
|
115
118
|
markDirty(agentId: string): void { this.dirty.add(agentId); }
|
|
116
119
|
// `failures` counts consecutive unreadable reads of one transcript; `retryAt` backs them off.
|
|
117
|
-
|
|
120
|
+
// Persisted (successful reads only) so restarts don't rescan; see HistoryCache.
|
|
121
|
+
/** Timeline reads discovery has made, for tests and the scale check. */
|
|
122
|
+
timelineReads = 0;
|
|
123
|
+
/** The active window from the last discovery's state. */
|
|
124
|
+
private windowMs = DEFAULT_ACTIVE_WINDOW_DAYS * DAY_MS;
|
|
118
125
|
async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
|
|
126
|
+
await this.histories.load();
|
|
127
|
+
const now = this.now();
|
|
128
|
+
const windowMs = this.windowMs = activeWindowMs(state);
|
|
119
129
|
return this.connect(async client => {
|
|
120
130
|
const [agents, workspaces, catalog] = await Promise.all([
|
|
121
131
|
pages(cursor => client.fetchAgents({ page: { limit: 100, ...(cursor ? { cursor } : {}) } })),
|
|
@@ -146,9 +156,13 @@ export class PaseoGateway implements Gateway {
|
|
|
146
156
|
// sessions can still contribute their PRs without acquiring completion authority.
|
|
147
157
|
const project = catalog.projects.find(p => p.projectId === workspace.projectId);
|
|
148
158
|
const repo = project?.projectKind === "git" ? /^remote:github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(project.projectKey)?.[1] ?? null : null;
|
|
149
|
-
|
|
159
|
+
// Active scope: a dormant workspace stays listed but is never scanned. Within an active one only
|
|
160
|
+
// loaded agents are read, because a timeline read loads the agent (see isLoaded).
|
|
161
|
+
row.active = workspaceActive(roots, existing, windowMs, now);
|
|
162
|
+
if (row.active) for (const agent of roots) {
|
|
163
|
+
if (!isLoaded(agent)) continue;
|
|
150
164
|
const cached = this.histories.get(agent.id);
|
|
151
|
-
if (!cached || cached.messageAt !== agent.lastUserMessageAt || this.dirty.has(agent.id) || (cached.history.error &&
|
|
165
|
+
if (!cached || cached.messageAt !== agent.lastUserMessageAt || this.dirty.has(agent.id) || (cached.history.error && now >= cached.retryAt) || (["running", "initializing"].includes(agent.status) && now - cached.at >= 15_000)) scans.push({ row, agent, repo });
|
|
152
166
|
}
|
|
153
167
|
discovered.push(row);
|
|
154
168
|
}
|
|
@@ -157,6 +171,7 @@ export class PaseoGateway implements Gateway {
|
|
|
157
171
|
// Session opening and Track need only ownership metadata. Read transcripts in the poll.
|
|
158
172
|
for (const { agent, repo } of scans.slice(0, workspaceId ? 0 : 4)) {
|
|
159
173
|
let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = [];
|
|
174
|
+
this.timelineReads++;
|
|
160
175
|
try {
|
|
161
176
|
const entries: { item: unknown; seqStart: number; timestamp?: string }[] = [];
|
|
162
177
|
let cursor: { epoch: string; seq: number } | null = null;
|
|
@@ -175,12 +190,12 @@ export class PaseoGateway implements Gateway {
|
|
|
175
190
|
history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
|
|
176
191
|
submissions = submittedPrs(items, agent.id, repo);
|
|
177
192
|
} catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
|
|
178
|
-
//
|
|
179
|
-
//
|
|
193
|
+
// An unreadable timeline is retried with backoff instead of every poll. New user messages and
|
|
194
|
+
// live turns retry immediately.
|
|
180
195
|
const previous = this.histories.get(agent.id);
|
|
181
196
|
const live = ["running", "initializing"].includes(agent.status);
|
|
182
197
|
const failures = !history.error ? 0 : previous?.history.error && previous.messageAt === agent.lastUserMessageAt && !live ? previous.failures + 1 : 1;
|
|
183
|
-
const at =
|
|
198
|
+
const at = this.now();
|
|
184
199
|
this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at, failures, retryAt: at + historyRetryDelay(failures), history, submissions, artifacts });
|
|
185
200
|
this.dirty.delete(agent.id);
|
|
186
201
|
}
|
|
@@ -195,6 +210,7 @@ export class PaseoGateway implements Gateway {
|
|
|
195
210
|
}
|
|
196
211
|
const known = new Set(agents.map(a => a.agent.id));
|
|
197
212
|
for (const id of this.histories.keys()) if (!known.has(id)) this.histories.delete(id);
|
|
213
|
+
await this.histories.save();
|
|
198
214
|
return discovered;
|
|
199
215
|
});
|
|
200
216
|
}
|
|
@@ -230,6 +246,8 @@ export class PaseoGateway implements Gateway {
|
|
|
230
246
|
const context = { guardedInterruptionId };
|
|
231
247
|
if (guarded || !pauses && workspace.labels?.some(label => /^(Paused|Paused by Session Guard)$/i.test(label))) return { ...context, guarded: true, busy: false, reason: "Waiting for Session Guard to resume this workspace", archived: false };
|
|
232
248
|
if (compacting) return { busy: true, reason: "Smart Session is compacting the main agent", archived: false };
|
|
249
|
+
const mainLoaded = isLoaded(a), mainRecent = recentlyUsed(a, this.windowMs, this.now());
|
|
250
|
+
if (conversationGone(a.lastError)) return { busy: false, reason: a.lastError ?? "Conversation not found", archived: false, gone: a.lastError ?? "Conversation not found", mainLoaded, mainRecent };
|
|
233
251
|
if (a.pendingPermissions.length || a.status === "error") return { busy: false, reason: a.status === "error" ? "Main agent needs recovery" : "Main agent is waiting for a permission or answer", archived: false };
|
|
234
252
|
let busy = false; let backgroundBusy = false;
|
|
235
253
|
for (const { agent } of agents.filter(row => row.agent.workspaceId === w.id)) {
|
|
@@ -237,7 +255,7 @@ export class PaseoGateway implements Gateway {
|
|
|
237
255
|
if (["running", "initializing"].includes(agent.status)) { busy = true; if (agent.id !== w.mainAgentId) backgroundBusy = true; }
|
|
238
256
|
if (await subagentsRunning(client, agent, "Subagent observation unavailable")) { busy = true; backgroundBusy = true; }
|
|
239
257
|
}
|
|
240
|
-
return { ...context, busy, reason: null, archived: false, backgroundBusy };
|
|
258
|
+
return { ...context, busy, reason: null, archived: false, backgroundBusy, mainLoaded, mainRecent };
|
|
241
259
|
});
|
|
242
260
|
}
|
|
243
261
|
async recoveryRuntime(w: Workspace, batch: Batch): Promise<{ busy: boolean; reason: string | null }> {
|
|
@@ -271,8 +289,10 @@ export class PaseoGateway implements Gateway {
|
|
|
271
289
|
}
|
|
272
290
|
catch { throw new DeliveryDeferred("Session Guard state is unavailable; follow-up will wait and retry."); }
|
|
273
291
|
if (guarded) throw new DeliveryDeferred("Waiting for Session Guard to resume the assigned session.");
|
|
292
|
+
if (conversationGone(a.lastError)) throw new ConversationGone(recipient, a.lastError!);
|
|
274
293
|
await beforeSend(recipient, batch.messageId).catch(() => undefined);
|
|
275
|
-
await client.sendMessage(recipient, text, { messageId: batch.messageId, activeTurnBehavior: "steer" });
|
|
294
|
+
try { await client.sendMessage(recipient, text, { messageId: batch.messageId, activeTurnBehavior: "steer" }); }
|
|
295
|
+
catch (error) { if (conversationGone(error)) throw new ConversationGone(recipient, error instanceof Error ? error.message : String(error)); throw error; }
|
|
276
296
|
});
|
|
277
297
|
}
|
|
278
298
|
/**
|
|
@@ -324,7 +344,9 @@ export class PaseoGateway implements Gateway {
|
|
|
324
344
|
const entries: { item: unknown; seqStart: number }[] = [];
|
|
325
345
|
let cursor: { epoch: string; seq: number } | null = null;
|
|
326
346
|
let historyError: string | null = "History exceeds the recovery window; inspect local transcripts for older work";
|
|
327
|
-
|
|
347
|
+
// Reading an unloaded main agent's timeline would wake it; the recovery agent reads local transcripts instead.
|
|
348
|
+
if (!isLoaded(main)) historyError = "The main agent isn't loaded, so its timeline wasn't read (that would wake it). Use its local provider transcript and git history.";
|
|
349
|
+
else try {
|
|
328
350
|
for (let i = 0; i < 100; i++) {
|
|
329
351
|
const page = await client.fetchAgentTimeline(main.id, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
|
|
330
352
|
if (page.error || page.gap || page.staleCursor) throw new Error("History changed or is unavailable");
|
package/server/delivery.ts
CHANGED
|
@@ -1 +1,4 @@
|
|
|
1
1
|
export class DeliveryDeferred extends Error {}
|
|
2
|
+
|
|
3
|
+
/** The provider no longer has the agent's conversation: every send fails, so Follow-up stops sending to it. */
|
|
4
|
+
export class ConversationGone extends Error { constructor(readonly agentId: string, reason: string) { super(reason); this.name = "ConversationGone"; } }
|
package/server/discovery.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { State, Workspace } from "../shared/model";
|
|
|
5
5
|
import { autoSendsByDefault, followsByDefault } from "../shared/settings";
|
|
6
6
|
|
|
7
7
|
export type History = { candidates: string[]; goal: string | null; complete: boolean; error: string | null };
|
|
8
|
-
export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; /** Set when mainAgentId is a live successor to this closed or gone main agent. */ replacesMainAgentId?: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[] };
|
|
8
|
+
export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; /** Set when mainAgentId is a live successor to this closed or gone main agent. */ replacesMainAgentId?: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[]; /** In the active scope (see workspaceActive). Undefined means unknown, treated as active. */ active?: boolean };
|
|
9
9
|
|
|
10
10
|
export function extractHistory(items: readonly unknown[], complete = true, error: string | null = null): History {
|
|
11
11
|
const candidates = new Set<string>();
|
|
@@ -48,6 +48,15 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
|
|
|
48
48
|
state.workspaces.push(w);
|
|
49
49
|
}
|
|
50
50
|
w.title = row.title;
|
|
51
|
+
if (row.active !== undefined) {
|
|
52
|
+
if (row.active) delete w.dormant; else w.dormant = true;
|
|
53
|
+
// Auto-send parked by the active-scope migration comes back once the workspace is active again.
|
|
54
|
+
if (row.active && state.autoSendsParked?.includes(w.id)) {
|
|
55
|
+
if (w.enable_tracking && !w.archived) w.enable_auto_sends = true;
|
|
56
|
+
state.autoSendsParked = state.autoSendsParked.filter(id => id !== w.id);
|
|
57
|
+
if (!state.autoSendsParked.length) delete state.autoSendsParked;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
51
60
|
// A closed or gone main agent hands the workspace to its live successor (discovery decides; see
|
|
52
61
|
// successorMainAgent). Any other mismatch keeps the recorded owner.
|
|
53
62
|
if (row.replacesMainAgentId && row.replacesMainAgentId === w.mainAgentId && !w.archived) {
|
|
@@ -55,6 +64,8 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
|
|
|
55
64
|
for (const b of w.batches) if (b.state === "pending" && b.recipientAgentId === row.replacesMainAgentId) b.recipientAgentId = null;
|
|
56
65
|
}
|
|
57
66
|
if (w.mainAgentId !== row.mainAgentId || w.archived) continue;
|
|
67
|
+
// A new main agent, or a new message to the gone one, means its conversation can be tried again.
|
|
68
|
+
if (w.goneAgent && (w.goneAgent.agentId !== w.mainAgentId || (row.messageAt && row.messageAt > w.goneAgent.at))) w.goneAgent = null;
|
|
58
69
|
if (row.contributorAgentIds) w.contributorAgentIds = row.contributorAgentIds;
|
|
59
70
|
if (!w.enable_tracking && followsByDefault(state, row)) { w.enable_auto_sends = startsWithAutoSend(state, w); w.enable_tracking = true; }
|
|
60
71
|
if (!w.inventory) {
|
package/server/engine.ts
CHANGED
|
@@ -7,15 +7,24 @@ import { answerRecipients, developerAnswer, syncTaskQuestions } from "./question
|
|
|
7
7
|
import { answerMessage, isOpen as isOpenQuestion, type Question } from "../shared/questions";
|
|
8
8
|
import { reconcileSubmittedStacks } from "./submissions";
|
|
9
9
|
import { applyDiscovery, startsWithAutoSend, type DiscoveredWorkspace } from "./discovery";
|
|
10
|
-
import { followsByDefault } from "../shared/settings";
|
|
10
|
+
import { followsByDefault, migrateActiveScope } from "../shared/settings";
|
|
11
11
|
import { replyRecorded } from "../shared/communication";
|
|
12
|
-
import { DeliveryDeferred } from "./delivery";
|
|
12
|
+
import { DeliveryDeferred, ConversationGone } from "./delivery";
|
|
13
13
|
import { recordSnapshot } from "./deployment-anchor";
|
|
14
14
|
|
|
15
15
|
export interface Runtime {
|
|
16
16
|
busy: boolean; reason: string | null; archived: boolean; backgroundBusy?: boolean;
|
|
17
17
|
guarded?: boolean; guardedInterruptionId?: string;
|
|
18
|
+
/** The main agent's runtime is loaded (see isLoaded). Undefined means unknown. */
|
|
19
|
+
mainLoaded?: boolean;
|
|
20
|
+
/** The main agent had a user message within the active window. Undefined means unknown. */
|
|
21
|
+
mainRecent?: boolean;
|
|
22
|
+
/** The main agent's provider conversation no longer exists. */
|
|
23
|
+
gone?: string | null;
|
|
18
24
|
}
|
|
25
|
+
const GONE_REASON = "The main agent's conversation no longer exists, so Follow-up stopped sending to it. Start a new agent in this workspace to continue.";
|
|
26
|
+
/** A batch that asks the agent to verify it's finished and declare Done. These never go out on their own. */
|
|
27
|
+
export const completionBatch = (b: Batch): boolean => b.tasks.some(t => t.kind === "completion");
|
|
19
28
|
export const interruptionHold = (kind: "canceled" | "failed") => kind === "canceled" ? "You stopped the main agent. Resume follow-up when ready." : "The main agent's turn failed. Check it before resuming follow-up.";
|
|
20
29
|
export interface Gateway {
|
|
21
30
|
discover?(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]>;
|
|
@@ -65,9 +74,13 @@ export class Engine {
|
|
|
65
74
|
if (this.stopped) return;
|
|
66
75
|
if (this.gateway.discover) {
|
|
67
76
|
const discovered = await this.gateway.discover(saved);
|
|
68
|
-
await this.store.mutate(state =>
|
|
77
|
+
await this.store.mutate(state => {
|
|
78
|
+
applyDiscovery(state, discovered.filter(row => followsByDefault(state, row) || saved.workspaces.some(w => w.id === row.id && w.enable_tracking && !w.archived)), this.now());
|
|
79
|
+
migrateActiveScope(state, new Set(discovered.filter(row => row.active !== false).map(row => row.id)));
|
|
80
|
+
});
|
|
69
81
|
}
|
|
70
|
-
|
|
82
|
+
// Dormant workspaces stay listed but cost nothing: no PR or agent observation until they're active again.
|
|
83
|
+
const active = (await this.store.snapshot()).workspaces.filter(w => w.enable_tracking && !w.archived && !w.dormant);
|
|
71
84
|
if (!active.length) { this.error = null; return; }
|
|
72
85
|
const observations = await this.github.observe(active.flatMap(w => w.prs));
|
|
73
86
|
for (const original of active) {
|
|
@@ -77,6 +90,8 @@ export class Engine {
|
|
|
77
90
|
const w = findWorkspace(state, original.id);
|
|
78
91
|
w.runtimeBusy = runtime.busy; w.runtimeReason = runtime.reason;
|
|
79
92
|
w.backgroundBusy = runtime.backgroundBusy ?? false;
|
|
93
|
+
if (runtime.gone && w.goneAgent?.agentId !== w.mainAgentId) w.goneAgent = { agentId: w.mainAgentId, at: new Date(this.now()).toISOString(), reason: runtime.gone };
|
|
94
|
+
if (w.goneAgent?.agentId === w.mainAgentId) w.runtimeReason = GONE_REASON;
|
|
80
95
|
if (runtime.guardedInterruptionId && w.interruption && w.hold === interruptionHold(w.interruption.kind)) {
|
|
81
96
|
w.hold = null; w.interruption.guardPauseId = runtime.guardedInterruptionId;
|
|
82
97
|
}
|
|
@@ -114,7 +129,10 @@ export class Engine {
|
|
|
114
129
|
for (const q of findWorkspace(await this.store.snapshot(), original.id).questions.filter(q => q.answer?.delivery.state === "queued")) await this.deliverAnswer(original.id, q.id).catch(() => undefined);
|
|
115
130
|
let current = findWorkspace(await this.store.snapshot(), original.id);
|
|
116
131
|
const pending = current.batches.find(b => b.state === "pending" && !isRecovery(b));
|
|
117
|
-
|
|
132
|
+
// Auto-send only reaches an agent that is loaded and recently used: sending to a dormant one would wake it.
|
|
133
|
+
// Completion checks never go out on their own; they wait for Tom or the agent's next live turn.
|
|
134
|
+
if (current.enable_auto_sends && !current.archived && !current.hold && pending && !current.dormant && !current.goneAgent &&
|
|
135
|
+
!completionBatch(pending) && runtime.mainLoaded !== false && runtime.mainRecent !== false &&
|
|
118
136
|
!runtime.busy && !runtime.backgroundBusy && !runtime.reason &&
|
|
119
137
|
!current.batches.some(b => !isRecovery(b) && ["sending", "sent", "blocked", "uncertain"].includes(b.state)) &&
|
|
120
138
|
this.now() - Date.parse(pending.createdAt) >= this.settleMs &&
|
|
@@ -197,6 +215,7 @@ export class Engine {
|
|
|
197
215
|
const batch = current.batches.find(b => b.id === batchId && b.state === "pending");
|
|
198
216
|
if (batch && isRecovery(batch)) throw new Error("Recovery requires its own explicit action.");
|
|
199
217
|
if (!batch || !current.enable_tracking || current.archived || current.hold || this.stopped || (automatic && !current.enable_auto_sends)) throw new Error("Workspace or queue is no longer ready to send");
|
|
218
|
+
if (current.goneAgent?.agentId === current.mainAgentId) throw new Error(GONE_REASON);
|
|
200
219
|
if (needsPrObservations(current, batch) && current.prs.some(p => p.error || !p.snapshot?.complete)) throw new Error("PR status is incomplete; refresh before sending repairs");
|
|
201
220
|
const runtime = await this.gateway.observe(current, true);
|
|
202
221
|
if (runtime.busy || runtime.backgroundBusy || runtime.reason || runtime.archived) throw new Error(runtime.reason ?? "The workspace has active agent work. Send when it is idle.");
|
|
@@ -256,6 +275,14 @@ export class Engine {
|
|
|
256
275
|
} catch (error) {
|
|
257
276
|
await this.store.mutate(state => {
|
|
258
277
|
const b = findWorkspace(state, workspaceId).batches.find(b => b.id === batchId)!;
|
|
278
|
+
if (b.state === "sending" && error instanceof ConversationGone) {
|
|
279
|
+
// Nothing reached the agent. Keep the batch for a new agent and stop retrying this one.
|
|
280
|
+
const w = findWorkspace(state, workspaceId);
|
|
281
|
+
b.state = "pending"; b.result = ""; b.recipientAgentId = null;
|
|
282
|
+
w.goneAgent = { agentId: error.agentId, at: new Date(this.now()).toISOString(), reason: error.message };
|
|
283
|
+
w.runtimeReason = GONE_REASON;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
259
286
|
if (b.state === "sending" && error instanceof DeliveryDeferred) {
|
|
260
287
|
b.state = "pending"; b.result = "";
|
|
261
288
|
if (!isRecovery(b)) findWorkspace(state, workspaceId).runtimeReason = error.message;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { History } from "./discovery";
|
|
5
|
+
import type { Submission } from "./submissions";
|
|
6
|
+
import type { Artifact } from "../shared/spec";
|
|
7
|
+
|
|
8
|
+
/** What discovery keeps per agent: the distilled history, never raw timeline items. */
|
|
9
|
+
export type HistoryEntry = { messageAt: string | null; at: number; failures: number; retryAt: number; history: History; submissions: Submission[]; artifacts: Artifact[] };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Discovery's per-agent history, persisted so a worker or daemon restart doesn't rescan every timeline.
|
|
13
|
+
* Keyed by agent id and valid while the agent's lastUserMessageAt is unchanged. Only successful reads are
|
|
14
|
+
* saved; failures stay in memory with their backoff. A missing or unreadable file means an empty cache.
|
|
15
|
+
*/
|
|
16
|
+
export class HistoryCache {
|
|
17
|
+
private entries = new Map<string, HistoryEntry>();
|
|
18
|
+
private loaded: Promise<void> | null = null;
|
|
19
|
+
private changed = false;
|
|
20
|
+
constructor(private path: string | null) {}
|
|
21
|
+
static at(directory: string): HistoryCache { return new HistoryCache(join(directory, "history-cache.json")); }
|
|
22
|
+
load(): Promise<void> {
|
|
23
|
+
this.loaded ??= (async () => {
|
|
24
|
+
if (!this.path) return;
|
|
25
|
+
try {
|
|
26
|
+
const file = JSON.parse(await readFile(this.path, "utf8"));
|
|
27
|
+
if (file?.version !== 1 || !file.agents || typeof file.agents !== "object") return;
|
|
28
|
+
for (const [id, value] of Object.entries(file.agents as Record<string, Partial<HistoryEntry>>)) {
|
|
29
|
+
if (!value || typeof value !== "object" || !value.history || !Array.isArray(value.history.candidates)) continue;
|
|
30
|
+
this.entries.set(id, { messageAt: value.messageAt ?? null, at: Number(value.at) || 0, failures: 0, retryAt: 0, history: value.history as History, submissions: Array.isArray(value.submissions) ? value.submissions : [], artifacts: Array.isArray(value.artifacts) ? value.artifacts : [] });
|
|
31
|
+
}
|
|
32
|
+
} catch { /* absent or unreadable: start empty */ }
|
|
33
|
+
})();
|
|
34
|
+
return this.loaded;
|
|
35
|
+
}
|
|
36
|
+
get(id: string): HistoryEntry | undefined { return this.entries.get(id); }
|
|
37
|
+
set(id: string, entry: HistoryEntry): void { this.entries.set(id, entry); if (!entry.history.error) this.changed = true; }
|
|
38
|
+
delete(id: string): void { if (this.entries.delete(id)) this.changed = true; }
|
|
39
|
+
keys(): string[] { return [...this.entries.keys()]; }
|
|
40
|
+
async save(): Promise<void> {
|
|
41
|
+
if (!this.path || !this.changed) return;
|
|
42
|
+
this.changed = false;
|
|
43
|
+
const agents: Record<string, Omit<HistoryEntry, "failures" | "retryAt">> = {};
|
|
44
|
+
for (const [id, e] of this.entries) if (!e.history.error) agents[id] = { messageAt: e.messageAt, at: e.at, history: e.history, submissions: e.submissions, artifacts: e.artifacts };
|
|
45
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(join(this.path, ".."), { recursive: true, mode: 0o700 });
|
|
48
|
+
await writeFile(temporary, JSON.stringify({ version: 1, agents }), { mode: 0o600 });
|
|
49
|
+
await rename(temporary, this.path);
|
|
50
|
+
} catch { this.changed = true; }
|
|
51
|
+
finally { await unlink(temporary).catch(() => undefined); }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { pages, withDaemon, type Agent } from "./daemon";
|
|
3
3
|
import type { AgentInfo, Directory } from "./inbox";
|
|
4
|
+
import { isLoaded } from "./agent-load";
|
|
4
5
|
|
|
5
6
|
type Snapshot = Agent & { updatedAt?: string; parentAgentId?: string | null; requiresAttention?: boolean; attentionReason?: string | null; attentionTimestamp?: string | null };
|
|
6
7
|
const info = (a: Snapshot): AgentInfo => ({
|
|
@@ -36,6 +37,9 @@ export function daemonDirectory(connect: typeof withDaemon = withDaemon): Direct
|
|
|
36
37
|
},
|
|
37
38
|
async tail(id, limit) {
|
|
38
39
|
return connect(async client => {
|
|
40
|
+
// Called from turn hooks, so the agent is normally live. Never wake one that isn't.
|
|
41
|
+
const row = await client.fetchAgent(id);
|
|
42
|
+
if (!isLoaded(row?.agent)) return [];
|
|
39
43
|
const page = await client.fetchAgentTimeline(id, { direction: "tail", limit, projection: "canonical", timeout: 10_000 });
|
|
40
44
|
return [...page.entries].sort((a, b) => a.seqStart - b.seqStart).map(e => e.item);
|
|
41
45
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isWorkspaceRoot } from "./workspace-agent";
|
|
2
2
|
import type { Workspace } from "../shared/model";
|
|
3
3
|
import type { Agent, Daemon } from "./daemon";
|
|
4
|
+
import { isLoaded } from "./agent-load";
|
|
4
5
|
|
|
5
6
|
async function userMessages(client: Daemon, agentId: string): Promise<string[]> {
|
|
6
7
|
const entries: { item: unknown; seqStart: number }[] = [];
|
|
@@ -23,6 +24,8 @@ export async function recoveryHandoff(client: Daemon, w: Workspace, caller: Agen
|
|
|
23
24
|
if (!w.enable_tracking || w.archived || caller.archivedAt || caller.workspaceId !== w.id || caller.id === w.mainAgentId || !await isWorkspaceRoot(caller, async id => (await client.fetchAgent(id))?.agent)) return null;
|
|
24
25
|
const candidates = w.batches.filter(b => b.recipientAgentId && b.recipientAgentId !== w.mainAgentId && b.recipientAgentId !== caller.id && b.tasks.every(t => t.kind === "inventory") && ["sending", "sent", "uncertain", "blocked"].includes(b.state) && !b.settledAt);
|
|
25
26
|
if (!candidates.length) return null;
|
|
27
|
+
// The caller is the agent making this tool call, so it's loaded and reading it wakes nothing.
|
|
28
|
+
if (!isLoaded(caller)) return null;
|
|
26
29
|
const initialTask = (await userMessages(client, caller.id))[0];
|
|
27
30
|
if (!initialTask) return null;
|
|
28
31
|
for (const b of candidates) {
|
|
@@ -30,6 +33,9 @@ export async function recoveryHandoff(client: Daemon, w: Workspace, caller: Agen
|
|
|
30
33
|
const prior = (await client.fetchAgent(b.recipientAgentId!))?.agent;
|
|
31
34
|
if (!prior?.archivedAt || prior.workspaceId !== w.id || Date.parse(caller.createdAt) < Date.parse(b.sentAt ?? b.createdAt)) continue;
|
|
32
35
|
// An archived recipient plus the exact initial user task proves a handoff, not a historical mention.
|
|
36
|
+
// Reading the prior's timeline would load it (even archived), so an unloaded prior is proven by the
|
|
37
|
+
// caller's first message carrying this batch's unguessable id, sent after the batch went out.
|
|
38
|
+
if (!isLoaded(prior)) return { batchId: b.id, fromAgentId: prior.id };
|
|
33
39
|
if ((await userMessages(client, prior.id)).includes(initialTask)) return { batchId: b.id, fromAgentId: prior.id };
|
|
34
40
|
}
|
|
35
41
|
return null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether Paseo has this agent's runtime in memory right now.
|
|
3
|
+
*
|
|
4
|
+
* Reading an agent's timeline (fetch_agent_timeline) or its provider subagents goes through the daemon's
|
|
5
|
+
* ensureAgentLoaded, which resumes an unloaded agent from persistence (provider CLI plus every MCP server,
|
|
6
|
+
* about 650 MB) and keeps it loaded for an hour. Follow-up must never do that on its own.
|
|
7
|
+
*
|
|
8
|
+
* An unloaded agent's snapshot is projected from its stored record and reports the last persisted
|
|
9
|
+
* status (usually "idle", rarely "closed"), so status alone can't tell. A loaded agent's snapshot always
|
|
10
|
+
* carries `activeTurn` (null between turns); the stored projection never does. Running, initializing and
|
|
11
|
+
* permission-waiting agents are live by definition. Anything else is treated as unloaded: when in doubt,
|
|
12
|
+
* don't wake it.
|
|
13
|
+
*/
|
|
14
|
+
export type LoadSignals = { status: string; activeTurn?: unknown; pendingPermissions?: unknown[] | null };
|
|
15
|
+
export const LIVE_STATUSES = ["running", "initializing"];
|
|
16
|
+
export function isLoaded(agent: LoadSignals | null | undefined): boolean {
|
|
17
|
+
if (!agent) return false;
|
|
18
|
+
if (LIVE_STATUSES.includes(agent.status) || !!agent.pendingPermissions?.length) return true;
|
|
19
|
+
return agent.status !== "closed" && Object.prototype.hasOwnProperty.call(agent, "activeTurn") && agent.activeTurn !== undefined;
|
|
20
|
+
}
|
package/shared/contracts.ts
CHANGED
|
@@ -49,11 +49,14 @@ export const setProjectTrackingRpc = defineRpc({ name: "follow.set-project-track
|
|
|
49
49
|
/** Following (1.3.0-beta.2): the two global switches and the per-workspace exceptions, for Settings only. */
|
|
50
50
|
const followSettingsSchema = z.object({
|
|
51
51
|
enable_follow_every: z.boolean(), enable_auto_sends_default: z.boolean(),
|
|
52
|
-
|
|
52
|
+
/** Days without a user message before a followed workspace goes dormant (1.3.0-beta.3). */
|
|
53
|
+
activeWindowDays: z.number().int().optional(),
|
|
54
|
+
workspaces: z.array(z.object({ id: idSchema, title: z.string(), enable_tracking: z.boolean(), enable_auto_sends: z.boolean(), optedOut: z.boolean(), dormant: z.boolean().optional(), parked: z.boolean().optional() })),
|
|
53
55
|
});
|
|
54
56
|
export const followSettingsRpc = defineRpc({ name: "follow.follow-settings", input: z.object({}), output: followSettingsSchema });
|
|
55
57
|
export const setFollowSettingsRpc = defineRpc({ name: "follow.set-follow-settings", input: z.object({
|
|
56
58
|
enable_follow_every: z.boolean().optional(), enable_auto_sends_default: z.boolean().optional(),
|
|
59
|
+
activeWindowDays: z.number().int().min(1).max(60).optional(),
|
|
57
60
|
workspace: z.object({ workspaceId: idSchema, follow: z.boolean().optional(), enable_auto_sends: z.boolean().optional() }).optional(),
|
|
58
61
|
}), output: followSettingsSchema });
|
|
59
62
|
/** External priorities for Home's Priority sort (written by the set_priorities agent tool). */
|
package/shared/model.ts
CHANGED
|
@@ -78,6 +78,10 @@ export const workspaceSchema = z.object({
|
|
|
78
78
|
candidates: z.array(idSchema), historyComplete: z.boolean(), historyError: z.string().nullable(),
|
|
79
79
|
scannedMessageAt: z.string().nullable(),
|
|
80
80
|
}).nullable().default(null),
|
|
81
|
+
/** 1.3.0-beta.3: outside the active scope (no loaded agent, no recent message, no open PR). Absent means active. */
|
|
82
|
+
dormant: z.boolean().optional(),
|
|
83
|
+
/** The main agent's provider conversation no longer exists; Follow-up stops sending to it. */
|
|
84
|
+
goneAgent: z.object({ agentId: idSchema, at: z.string(), reason: z.string() }).nullable().optional(),
|
|
81
85
|
});
|
|
82
86
|
export const stateSchema = z.object({
|
|
83
87
|
version: z.literal(1), workspaces: z.array(workspaceSchema),
|
|
@@ -91,6 +95,10 @@ export const stateSchema = z.object({
|
|
|
91
95
|
workspaceOptOuts: z.array(idSchema).optional(),
|
|
92
96
|
/** One-time migrations already applied, so they never run twice. */
|
|
93
97
|
migrations: z.array(z.string()).optional(),
|
|
98
|
+
/** Days without a user message before a followed workspace goes dormant. Absent means 3. */
|
|
99
|
+
activeWindowDays: z.number().int().min(1).max(60).optional(),
|
|
100
|
+
/** Workspaces whose auto-send the active-scope migration turned off; it turns back on when they become active. */
|
|
101
|
+
autoSendsParked: z.array(idSchema).optional(),
|
|
94
102
|
});
|
|
95
103
|
export type Snapshot = z.infer<typeof snapshotSchema>;
|
|
96
104
|
export type Deployment = z.infer<typeof deploymentSchema>;
|
package/shared/settings.ts
CHANGED
|
@@ -43,3 +43,21 @@ export function migrateFollowEvery(state: { workspaces: { id: string; archived:
|
|
|
43
43
|
}
|
|
44
44
|
return { tracked, autoSends };
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
export const ACTIVE_SCOPE_MIGRATION = "active-scope-1";
|
|
48
|
+
/**
|
|
49
|
+
* 1.3.0-beta.3: undo the beta.2 blast radius. Once, turn auto-send off for followed workspaces outside the active
|
|
50
|
+
* scope and remember them; each turns back on when its workspace becomes active (see applyDiscovery).
|
|
51
|
+
* `active` holds the workspace ids discovery found active. Returns how many were parked, or null if already applied.
|
|
52
|
+
*/
|
|
53
|
+
export function migrateActiveScope(state: { workspaces: { id: string; archived: boolean; enable_tracking: boolean; enable_auto_sends: boolean }[]; migrations?: string[]; autoSendsParked?: string[] }, active: Set<string>): number | null {
|
|
54
|
+
if (state.migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return null;
|
|
55
|
+
state.migrations = [...state.migrations ?? [], ACTIVE_SCOPE_MIGRATION];
|
|
56
|
+
const parked = new Set(state.autoSendsParked ?? []);
|
|
57
|
+
for (const w of state.workspaces) {
|
|
58
|
+
if (w.archived || !w.enable_tracking || !w.enable_auto_sends || active.has(w.id)) continue;
|
|
59
|
+
w.enable_auto_sends = false; parked.add(w.id);
|
|
60
|
+
}
|
|
61
|
+
if (parked.size) state.autoSendsParked = [...parked];
|
|
62
|
+
return parked.size;
|
|
63
|
+
}
|