agent-relay-server 0.39.0 → 0.41.0
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/docs/openapi.json +29 -1
- package/package.json +2 -2
- package/public/assets/{activity-b8kUF4hR.js → activity-DAz3DcDA.js} +2 -2
- package/public/assets/{activity-b8kUF4hR.js.map → activity-DAz3DcDA.js.map} +1 -1
- package/public/assets/{agents-C_ZSRXRa.js → agents-BJ7qRWxt.js} +2 -2
- package/public/assets/{agents-C_ZSRXRa.js.map → agents-BJ7qRWxt.js.map} +1 -1
- package/public/assets/{analytics-3JbZ6M41.js → analytics-MNCS2qnT.js} +2 -2
- package/public/assets/{analytics-3JbZ6M41.js.map → analytics-MNCS2qnT.js.map} +1 -1
- package/public/assets/{automation-CVu2C3sk.js → automation-COp2Kb3h.js} +2 -2
- package/public/assets/{automation-CVu2C3sk.js.map → automation-COp2Kb3h.js.map} +1 -1
- package/public/assets/chat-D_O8VqMR.js +2 -0
- package/public/assets/chat-D_O8VqMR.js.map +1 -0
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-CSAGn8zM.js → formatted-body-impl-BbMHqkCy.js} +2 -2
- package/public/assets/{formatted-body-impl-CSAGn8zM.js.map → formatted-body-impl-BbMHqkCy.js.map} +1 -1
- package/public/assets/{index-n5wxGWSq.js → index-DgJOEApM.js} +5 -5
- package/public/assets/{index-n5wxGWSq.js.map → index-DgJOEApM.js.map} +1 -1
- package/public/assets/{index-Bins8N_5.css → index-Dop-uXiy.css} +1 -1
- package/public/assets/{maintenance-CCcvsBbm.js → maintenance-BHv90kXZ.js} +2 -2
- package/public/assets/{maintenance-CCcvsBbm.js.map → maintenance-BHv90kXZ.js.map} +1 -1
- package/public/assets/{managed-agents-DHfO5gVD.js → managed-agents-BAlvkxfo.js} +2 -2
- package/public/assets/{managed-agents-DHfO5gVD.js.map → managed-agents-BAlvkxfo.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C1jNXAEL.js → markdown-preview-impl-6k_FWjmd.js} +2 -2
- package/public/assets/{markdown-preview-impl-C1jNXAEL.js.map → markdown-preview-impl-6k_FWjmd.js.map} +1 -1
- package/public/assets/{memory-VmjJ-YZ8.js → memory-DBjqdVpg.js} +2 -2
- package/public/assets/{memory-VmjJ-YZ8.js.map → memory-DBjqdVpg.js.map} +1 -1
- package/public/assets/{messages-DXN6Rkjt.js → messages-axoaJY2N.js} +2 -2
- package/public/assets/{messages-DXN6Rkjt.js.map → messages-axoaJY2N.js.map} +1 -1
- package/public/assets/{orchestrators-CLyYvfn1.js → orchestrators-DMfHZ44H.js} +2 -2
- package/public/assets/{orchestrators-CLyYvfn1.js.map → orchestrators-DMfHZ44H.js.map} +1 -1
- package/public/assets/{overview-DZmVU2Fh.js → overview-DqxYWpUT.js} +2 -2
- package/public/assets/{overview-DZmVU2Fh.js.map → overview-DqxYWpUT.js.map} +1 -1
- package/public/assets/{pairs-BeEORfZF.js → pairs-D3wnzC1V.js} +2 -2
- package/public/assets/{pairs-BeEORfZF.js.map → pairs-D3wnzC1V.js.map} +1 -1
- package/public/assets/{security-DIe-a7rD.js → security-KWrb7PKj.js} +2 -2
- package/public/assets/{security-DIe-a7rD.js.map → security-KWrb7PKj.js.map} +1 -1
- package/public/assets/{settings-BeFPCFUZ.js → settings-PXMEzNAm.js} +2 -2
- package/public/assets/{settings-BeFPCFUZ.js.map → settings-PXMEzNAm.js.map} +1 -1
- package/public/assets/{tasks-BVXiBzUI.js → tasks-CFYShBCz.js} +2 -2
- package/public/assets/{tasks-BVXiBzUI.js.map → tasks-CFYShBCz.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CN311GDZ.js → terminal-viewer-impl-BwPYZlWI.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CN311GDZ.js.map → terminal-viewer-impl-BwPYZlWI.js.map} +1 -1
- package/public/assets/{work-queue-BnQftDAS.js → work-queue-VQ_6QDJc.js} +2 -2
- package/public/assets/{work-queue-BnQftDAS.js.map → work-queue-VQ_6QDJc.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/src/adapter.ts +12 -3
- package/src/bus.ts +2 -0
- package/src/config-store.ts +20 -8
- package/src/context-advisory.ts +110 -0
- package/src/context-threshold-config.ts +40 -0
- package/src/db/agents.ts +21 -5
- package/src/db/continuation-archives.ts +179 -0
- package/src/db/continuations.ts +151 -0
- package/src/db/index.ts +2 -0
- package/src/db/messages.ts +36 -2
- package/src/db/migrations.ts +34 -0
- package/src/db/schema.ts +24 -0
- package/src/lifecycle-manager.ts +2 -2
- package/src/maintenance.ts +11 -17
- package/src/mcp.ts +77 -0
- package/src/routes/commands.ts +24 -0
- package/src/routes/continuation-archives.ts +37 -0
- package/src/routes/index.ts +2 -0
- package/src/security.ts +2 -0
- package/src/self-resume-config.ts +44 -0
- package/src/self-resume.ts +232 -0
- package/src/services/send-message.ts +4 -0
- package/src/workspace-pr-completion.ts +121 -0
- package/public/assets/chat-BiCWbMPO.js +0 -2
- package/public/assets/chat-BiCWbMPO.js.map +0 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { MAX_BODY_BYTES } from "../config";
|
|
2
|
+
import { getContinuationEnvelope } from "./continuations.ts";
|
|
3
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
4
|
+
|
|
5
|
+
export interface ContinuationArchive {
|
|
6
|
+
id: number;
|
|
7
|
+
ref: string;
|
|
8
|
+
agentId: string;
|
|
9
|
+
generation: number;
|
|
10
|
+
segment: string;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ContinuationArchiveSearchResult extends ContinuationArchive {
|
|
15
|
+
score: number | null;
|
|
16
|
+
searchMode: "fts5" | "like";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ContinuationArchiveRow {
|
|
20
|
+
id: number;
|
|
21
|
+
agent_id: string;
|
|
22
|
+
generation: number;
|
|
23
|
+
segment: string;
|
|
24
|
+
created_at: number;
|
|
25
|
+
score?: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_RECALL_LIMIT = 20;
|
|
29
|
+
|
|
30
|
+
export function ensureContinuationArchiveSearchSchema(): void {
|
|
31
|
+
if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return;
|
|
32
|
+
try {
|
|
33
|
+
getDb().query(`
|
|
34
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS continuation_archive_segments
|
|
35
|
+
USING fts5(segment, content='continuation_archives', content_rowid='id')
|
|
36
|
+
`).run();
|
|
37
|
+
getDb().query(`
|
|
38
|
+
CREATE TRIGGER IF NOT EXISTS continuation_archives_ai AFTER INSERT ON continuation_archives BEGIN
|
|
39
|
+
INSERT INTO continuation_archive_segments(rowid, segment) VALUES (new.id, new.segment);
|
|
40
|
+
END
|
|
41
|
+
`).run();
|
|
42
|
+
getDb().query(`
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS continuation_archives_ad AFTER DELETE ON continuation_archives BEGIN
|
|
44
|
+
INSERT INTO continuation_archive_segments(continuation_archive_segments, rowid, segment)
|
|
45
|
+
VALUES('delete', old.id, old.segment);
|
|
46
|
+
END
|
|
47
|
+
`).run();
|
|
48
|
+
getDb().query(`
|
|
49
|
+
CREATE TRIGGER IF NOT EXISTS continuation_archives_au AFTER UPDATE ON continuation_archives BEGIN
|
|
50
|
+
INSERT INTO continuation_archive_segments(continuation_archive_segments, rowid, segment)
|
|
51
|
+
VALUES('delete', old.id, old.segment);
|
|
52
|
+
INSERT INTO continuation_archive_segments(rowid, segment) VALUES (new.id, new.segment);
|
|
53
|
+
END
|
|
54
|
+
`).run();
|
|
55
|
+
getDb().query("INSERT INTO continuation_archive_segments(continuation_archive_segments) VALUES('rebuild')").run();
|
|
56
|
+
} catch {
|
|
57
|
+
// FTS5 is expected in bun:sqlite, but recall stays available through LIKE when
|
|
58
|
+
// a custom SQLite build omits it.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function archiveContinuationSegment(input: {
|
|
63
|
+
agentId: string;
|
|
64
|
+
segment: string;
|
|
65
|
+
generation?: number;
|
|
66
|
+
now?: number;
|
|
67
|
+
}): ContinuationArchive {
|
|
68
|
+
const agentId = input.agentId.trim();
|
|
69
|
+
if (!agentId) throw new ValidationError("agentId required");
|
|
70
|
+
const segment = input.segment.trim();
|
|
71
|
+
if (!segment) throw new ValidationError("segment required");
|
|
72
|
+
if (new TextEncoder().encode(segment).byteLength > MAX_BODY_BYTES) throw new ValidationError(`segment exceeds ${MAX_BODY_BYTES} bytes`);
|
|
73
|
+
const generation = input.generation ?? nextContinuationArchiveGeneration(agentId);
|
|
74
|
+
if (!Number.isSafeInteger(generation) || generation < 0) throw new ValidationError("generation must be a non-negative integer");
|
|
75
|
+
const createdAt = input.now ?? Date.now();
|
|
76
|
+
const info = getDb().query(`
|
|
77
|
+
INSERT INTO continuation_archives (agent_id, generation, segment, created_at)
|
|
78
|
+
VALUES (?, ?, ?, ?)
|
|
79
|
+
`).run(agentId, generation, segment, createdAt);
|
|
80
|
+
return getContinuationArchive(Number(info.lastInsertRowid))!;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getContinuationArchive(id: number): ContinuationArchive | null {
|
|
84
|
+
const row = getDb().query("SELECT * FROM continuation_archives WHERE id = ?").get(id) as ContinuationArchiveRow | undefined;
|
|
85
|
+
return row ? rowToArchive(row) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function searchContinuationArchives(input: {
|
|
89
|
+
agentId: string;
|
|
90
|
+
query: string;
|
|
91
|
+
limit?: number;
|
|
92
|
+
}): ContinuationArchiveSearchResult[] {
|
|
93
|
+
const agentId = input.agentId.trim();
|
|
94
|
+
if (!agentId) throw new ValidationError("agentId required");
|
|
95
|
+
const query = input.query.trim();
|
|
96
|
+
if (!query) throw new ValidationError("query required");
|
|
97
|
+
const limit = clampLimit(input.limit);
|
|
98
|
+
const fts = ftsQuery(query);
|
|
99
|
+
if (continuationArchiveSearchMode() === "fts5" && fts) {
|
|
100
|
+
try {
|
|
101
|
+
const rows = getDb().query(`
|
|
102
|
+
SELECT a.*, bm25(continuation_archive_segments) AS score
|
|
103
|
+
FROM continuation_archive_segments
|
|
104
|
+
JOIN continuation_archives a ON a.id = continuation_archive_segments.rowid
|
|
105
|
+
WHERE continuation_archive_segments MATCH ? AND a.agent_id = ?
|
|
106
|
+
ORDER BY score ASC, a.created_at DESC, a.id DESC
|
|
107
|
+
LIMIT ?
|
|
108
|
+
`).all(fts, agentId, limit) as ContinuationArchiveRow[];
|
|
109
|
+
return rows.map((row) => ({ ...rowToArchive(row), score: row.score ?? null, searchMode: "fts5" as const }));
|
|
110
|
+
} catch {
|
|
111
|
+
// Bad MATCH syntax should not make recall fail; the LIKE fallback is less
|
|
112
|
+
// precise but deterministic.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return searchContinuationArchivesLike(agentId, query, limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function continuationArchiveSearchMode(): "fts5" | "like" {
|
|
119
|
+
if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return "like";
|
|
120
|
+
const row = getDb().query(`
|
|
121
|
+
SELECT 1 FROM sqlite_master
|
|
122
|
+
WHERE type = 'table' AND name = 'continuation_archive_segments'
|
|
123
|
+
`).get();
|
|
124
|
+
return row ? "fts5" : "like";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nextContinuationArchiveGeneration(agentId: string): number {
|
|
128
|
+
const row = getDb().query(`
|
|
129
|
+
SELECT max(generation) AS generation FROM continuation_archives WHERE agent_id = ?
|
|
130
|
+
`).get(agentId) as { generation: number | null } | undefined;
|
|
131
|
+
const archiveGeneration = typeof row?.generation === "number" ? row.generation : 0;
|
|
132
|
+
const envelopeGeneration = getContinuationEnvelope(agentId)?.generation ?? 0;
|
|
133
|
+
return Math.max(archiveGeneration, envelopeGeneration) + 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function searchContinuationArchivesLike(agentId: string, query: string, limit: number): ContinuationArchiveSearchResult[] {
|
|
137
|
+
const terms = likeTerms(query);
|
|
138
|
+
if (terms.length === 0) return [];
|
|
139
|
+
const clauses = terms.map(() => "lower(segment) LIKE ? ESCAPE '\\'").join(" AND ");
|
|
140
|
+
const rows = getDb().query(`
|
|
141
|
+
SELECT *, NULL AS score FROM continuation_archives
|
|
142
|
+
WHERE agent_id = ? AND ${clauses}
|
|
143
|
+
ORDER BY created_at DESC, id DESC
|
|
144
|
+
LIMIT ?
|
|
145
|
+
`).all(agentId, ...terms.map((term) => `%${escapeLike(term)}%`), limit) as ContinuationArchiveRow[];
|
|
146
|
+
return rows.map((row) => ({ ...rowToArchive(row), score: null, searchMode: "like" as const }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clampLimit(value: number | undefined): number {
|
|
150
|
+
if (value === undefined) return 5;
|
|
151
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new ValidationError("limit must be a positive integer");
|
|
152
|
+
return Math.min(value, MAX_RECALL_LIMIT);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ftsQuery(query: string): string {
|
|
156
|
+
return likeTerms(query)
|
|
157
|
+
.slice(0, 16)
|
|
158
|
+
.map((term) => `"${term.replace(/"/g, '""')}"`)
|
|
159
|
+
.join(" ");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function likeTerms(query: string): string[] {
|
|
163
|
+
return [...new Set((query.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? []).filter(Boolean))];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function escapeLike(value: string): string {
|
|
167
|
+
return value.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rowToArchive(row: ContinuationArchiveRow): ContinuationArchive {
|
|
171
|
+
return {
|
|
172
|
+
id: row.id,
|
|
173
|
+
ref: String(row.id),
|
|
174
|
+
agentId: row.agent_id,
|
|
175
|
+
generation: row.generation,
|
|
176
|
+
segment: row.segment,
|
|
177
|
+
createdAt: row.created_at,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { parseJson } from "../utils";
|
|
3
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
4
|
+
import type { ContinuationBudget, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
interface ContinuationRow {
|
|
7
|
+
agent_id: string;
|
|
8
|
+
objective: string;
|
|
9
|
+
generation: number;
|
|
10
|
+
ledger: string;
|
|
11
|
+
working_state: string;
|
|
12
|
+
archive_refs: string;
|
|
13
|
+
budget: string;
|
|
14
|
+
created_at: number;
|
|
15
|
+
updated_at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getContinuationEnvelope(agentId: string): ContinuationEnvelope | null {
|
|
19
|
+
const row = getDb().query("SELECT * FROM agent_continuations WHERE agent_id = ?").get(agentId) as ContinuationRow | undefined;
|
|
20
|
+
return row ? rowToEnvelope(row) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ensureContinuationEnvelope(input: {
|
|
24
|
+
agentId: string;
|
|
25
|
+
objective?: string;
|
|
26
|
+
config: SelfResumeConfig;
|
|
27
|
+
now?: number;
|
|
28
|
+
}): ContinuationEnvelope {
|
|
29
|
+
const existing = getContinuationEnvelope(input.agentId);
|
|
30
|
+
if (existing) return existing;
|
|
31
|
+
const objective = input.objective;
|
|
32
|
+
if (!objective?.trim()) throw new ValidationError("objective required for first self-resume when no spawn prompt was recorded");
|
|
33
|
+
const now = input.now ?? Date.now();
|
|
34
|
+
const budget: ContinuationBudget = {
|
|
35
|
+
...input.config.budget,
|
|
36
|
+
spent: { generations: 0, wallClockMs: 0, tokens: 0 },
|
|
37
|
+
};
|
|
38
|
+
getDb().query(`
|
|
39
|
+
INSERT INTO agent_continuations (agent_id, objective, generation, ledger, working_state, archive_refs, budget, created_at, updated_at)
|
|
40
|
+
VALUES (?, ?, 0, '[]', '', '[]', ?, ?, ?)
|
|
41
|
+
`).run(input.agentId, objective, JSON.stringify(budget), now, now);
|
|
42
|
+
return getContinuationEnvelope(input.agentId)!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function seedContinuationObjectiveFromSpawnPrompt(agentId: string, spawnRequestId: string | undefined, config: SelfResumeConfig, now = Date.now()): ContinuationEnvelope | null {
|
|
46
|
+
if (!spawnRequestId || getContinuationEnvelope(agentId)) return getContinuationEnvelope(agentId);
|
|
47
|
+
const row = getDb().query(`
|
|
48
|
+
SELECT params FROM commands
|
|
49
|
+
WHERE type = 'agent.spawn' AND correlation_id = ?
|
|
50
|
+
ORDER BY created_at DESC
|
|
51
|
+
LIMIT 1
|
|
52
|
+
`).get(spawnRequestId) as { params: string } | undefined;
|
|
53
|
+
const params = row ? parseJson<unknown>(row.params, {}) : {};
|
|
54
|
+
if (!isRecord(params) || typeof params.prompt !== "string" || !params.prompt.trim()) return null;
|
|
55
|
+
return ensureContinuationEnvelope({ agentId, objective: params.prompt, config, now });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateContinuationAfterCompact(input: {
|
|
59
|
+
agentId: string;
|
|
60
|
+
generation: number;
|
|
61
|
+
ledgerEntries: ContinuationLedgerEntry[];
|
|
62
|
+
workingState: string;
|
|
63
|
+
archiveRefs?: string[];
|
|
64
|
+
spentTokens: number;
|
|
65
|
+
now?: number;
|
|
66
|
+
}): ContinuationEnvelope {
|
|
67
|
+
const existing = getContinuationEnvelope(input.agentId);
|
|
68
|
+
if (!existing) throw new ValidationError("continuation envelope not found");
|
|
69
|
+
const now = input.now ?? Date.now();
|
|
70
|
+
const nextBudget: ContinuationBudget = {
|
|
71
|
+
...existing.budget,
|
|
72
|
+
spent: {
|
|
73
|
+
generations: input.generation,
|
|
74
|
+
wallClockMs: Math.max(0, now - existing.createdAt),
|
|
75
|
+
tokens: existing.budget.spent.tokens + Math.max(0, input.spentTokens),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
getDb().query(`
|
|
79
|
+
UPDATE agent_continuations
|
|
80
|
+
SET generation = ?, ledger = ?, working_state = ?, archive_refs = ?, budget = ?, updated_at = ?
|
|
81
|
+
WHERE agent_id = ?
|
|
82
|
+
`).run(
|
|
83
|
+
input.generation,
|
|
84
|
+
JSON.stringify([...existing.ledger, ...input.ledgerEntries]),
|
|
85
|
+
input.workingState,
|
|
86
|
+
JSON.stringify([...existing.archiveRefs, ...(input.archiveRefs ?? [])]),
|
|
87
|
+
JSON.stringify(nextBudget),
|
|
88
|
+
now,
|
|
89
|
+
input.agentId,
|
|
90
|
+
);
|
|
91
|
+
return getContinuationEnvelope(input.agentId)!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function appendContinuationArchiveRefs(agentId: string, archiveRefs: string[]): ContinuationEnvelope | null {
|
|
95
|
+
if (archiveRefs.length === 0) return getContinuationEnvelope(agentId);
|
|
96
|
+
const existing = getContinuationEnvelope(agentId);
|
|
97
|
+
if (!existing) return null;
|
|
98
|
+
const refs = [...existing.archiveRefs];
|
|
99
|
+
for (const ref of archiveRefs) {
|
|
100
|
+
if (!refs.includes(ref)) refs.push(ref);
|
|
101
|
+
}
|
|
102
|
+
getDb().query(`
|
|
103
|
+
UPDATE agent_continuations
|
|
104
|
+
SET archive_refs = ?, updated_at = ?
|
|
105
|
+
WHERE agent_id = ?
|
|
106
|
+
`).run(JSON.stringify(refs), Date.now(), agentId);
|
|
107
|
+
return getContinuationEnvelope(agentId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rowToEnvelope(row: ContinuationRow): ContinuationEnvelope {
|
|
111
|
+
return {
|
|
112
|
+
agentId: row.agent_id,
|
|
113
|
+
objective: row.objective,
|
|
114
|
+
generation: row.generation,
|
|
115
|
+
ledger: parseJson<ContinuationLedgerEntry[]>(row.ledger, []).filter(isLedgerEntry),
|
|
116
|
+
workingState: row.working_state,
|
|
117
|
+
archiveRefs: parseJson<string[]>(row.archive_refs, []).filter((value): value is string => typeof value === "string"),
|
|
118
|
+
budget: parseBudget(row.budget),
|
|
119
|
+
createdAt: row.created_at,
|
|
120
|
+
updatedAt: row.updated_at,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseBudget(raw: string): ContinuationBudget {
|
|
125
|
+
const parsed = parseJson<unknown>(raw, null);
|
|
126
|
+
if (!isRecord(parsed) || !isRecord(parsed.spent)) {
|
|
127
|
+
return { maxGenerations: 1, maxWallClockMs: 1, maxTokens: 1, spent: { generations: 0, wallClockMs: 0, tokens: 0 } };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
maxGenerations: numberValue(parsed.maxGenerations),
|
|
131
|
+
maxWallClockMs: numberValue(parsed.maxWallClockMs),
|
|
132
|
+
maxTokens: numberValue(parsed.maxTokens),
|
|
133
|
+
spent: {
|
|
134
|
+
generations: numberValue(parsed.spent.generations),
|
|
135
|
+
wallClockMs: numberValue(parsed.spent.wallClockMs),
|
|
136
|
+
tokens: numberValue(parsed.spent.tokens),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isLedgerEntry(value: unknown): value is ContinuationLedgerEntry {
|
|
142
|
+
return isRecord(value)
|
|
143
|
+
&& typeof value.gen === "number"
|
|
144
|
+
&& typeof value.ruledOut === "string"
|
|
145
|
+
&& typeof value.why === "string"
|
|
146
|
+
&& typeof value.at === "number";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function numberValue(value: unknown): number {
|
|
150
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
151
|
+
}
|
package/src/db/index.ts
CHANGED
package/src/db/messages.ts
CHANGED
|
@@ -90,6 +90,34 @@ export function findMessageByIdempotencyKey(from: string, key: string): Message
|
|
|
90
90
|
return row ? rowToMessage(row) : null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function sessionStepId(payload: Record<string, unknown> | undefined): string | undefined {
|
|
94
|
+
const session = isRecord(payload?.session) ? payload.session : undefined;
|
|
95
|
+
return stringValue(session?.stepId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Codex session-mirror upsert (#parity): a provider step that emits more than once — a
|
|
100
|
+
* tool's running→completed lifecycle, a reasoning/response row that streams — carries a
|
|
101
|
+
* stable `payload.session.stepId`. Instead of appending a second row (which renders as a
|
|
102
|
+
* duplicate on dashboard reload), update the EXISTING row in place: same DB id, so the
|
|
103
|
+
* dashboard's id-keyed merge replaces it. Gated on stepId presence + matching step, so the
|
|
104
|
+
* Claude transcript path (no stepId) is byte-identical. Newer-event-wins via occurredAt so a
|
|
105
|
+
* late retry of an earlier lifecycle phase can't clobber a later one.
|
|
106
|
+
*/
|
|
107
|
+
function upsertSessionStepInPlace(existing: Message, input: SendMessageInput, now: number): Message | null {
|
|
108
|
+
const incomingStep = sessionStepId(input.payload);
|
|
109
|
+
if (!incomingStep) return null;
|
|
110
|
+
if (existing.kind !== "session") return null;
|
|
111
|
+
if (sessionStepId(existing.payload) !== incomingStep) return null;
|
|
112
|
+
const incomingAt = sanitizeOccurredAt(input.occurredAt, now) ?? now;
|
|
113
|
+
const existingAt = existing.occurredAt ?? existing.createdAt;
|
|
114
|
+
if (incomingAt < existingAt) return null; // stale lifecycle phase — keep the newer row, no re-emit
|
|
115
|
+
getDb()
|
|
116
|
+
.query("UPDATE messages SET body = ?, payload = ?, occurred_at = ? WHERE id = ?")
|
|
117
|
+
.run(input.body, JSON.stringify(input.payload ?? {}), incomingAt, existing.id);
|
|
118
|
+
return getMessage(existing.id);
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
export function policyNameFromTarget(target: string): string | null {
|
|
94
122
|
if (!target.startsWith("policy:")) return null;
|
|
95
123
|
const name = target.slice("policy:".length).trim();
|
|
@@ -169,7 +197,7 @@ export function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: n
|
|
|
169
197
|
return Math.floor(occurredAt);
|
|
170
198
|
}
|
|
171
199
|
|
|
172
|
-
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
|
|
200
|
+
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean; updated?: boolean } {
|
|
173
201
|
const now = Date.now();
|
|
174
202
|
const payload = input.payload ?? {};
|
|
175
203
|
const attachmentRefs = cleanAttachmentRefs(payload);
|
|
@@ -181,7 +209,13 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
181
209
|
|
|
182
210
|
if (input.idempotencyKey) {
|
|
183
211
|
const existing = findMessageByIdempotencyKey(input.from, input.idempotencyKey);
|
|
184
|
-
if (existing)
|
|
212
|
+
if (existing) {
|
|
213
|
+
// Session-step upsert: a re-emitted provider step (tool running→completed, streamed
|
|
214
|
+
// reasoning/response) updates its row in place instead of being dropped as a dup retry.
|
|
215
|
+
const upserted = upsertSessionStepInPlace(existing, input, now);
|
|
216
|
+
if (upserted) return { message: upserted, created: false, updated: true };
|
|
217
|
+
return { message: existing, created: false };
|
|
218
|
+
}
|
|
185
219
|
}
|
|
186
220
|
|
|
187
221
|
// Resolve thread: if replying, inherit from parent; reject unknown replyTo
|
package/src/db/migrations.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { getDb } from "./connection.ts";
|
|
18
|
+
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
18
19
|
import { normalizeReactionEmoji } from "./mappers.ts";
|
|
19
20
|
import type {
|
|
20
21
|
AgentCard,
|
|
@@ -176,6 +177,34 @@ export function applyMigrations(): void {
|
|
|
176
177
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
|
|
177
178
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
|
|
178
179
|
|
|
180
|
+
getDb().run(`
|
|
181
|
+
CREATE TABLE IF NOT EXISTS agent_continuations (
|
|
182
|
+
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
183
|
+
objective TEXT NOT NULL,
|
|
184
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
ledger TEXT NOT NULL DEFAULT '[]',
|
|
186
|
+
working_state TEXT NOT NULL DEFAULT '',
|
|
187
|
+
archive_refs TEXT NOT NULL DEFAULT '[]',
|
|
188
|
+
budget TEXT NOT NULL,
|
|
189
|
+
created_at INTEGER NOT NULL,
|
|
190
|
+
updated_at INTEGER NOT NULL
|
|
191
|
+
)
|
|
192
|
+
`);
|
|
193
|
+
getDb().run(`
|
|
194
|
+
CREATE TABLE IF NOT EXISTS continuation_archives (
|
|
195
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
196
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
197
|
+
generation INTEGER NOT NULL,
|
|
198
|
+
segment TEXT NOT NULL,
|
|
199
|
+
created_at INTEGER NOT NULL
|
|
200
|
+
)
|
|
201
|
+
`);
|
|
202
|
+
getDb().run(`
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_continuation_archives_agent_generation
|
|
204
|
+
ON continuation_archives(agent_id, generation, created_at DESC)
|
|
205
|
+
`);
|
|
206
|
+
ensureContinuationArchiveSearchSchema();
|
|
207
|
+
|
|
179
208
|
const tokenCols = getDb().query("PRAGMA table_info(tokens)").all() as any[];
|
|
180
209
|
const tokenColNames = tokenCols.map((c: any) => c.name);
|
|
181
210
|
if (!tokenColNames.includes("constraints")) {
|
|
@@ -245,6 +274,11 @@ export function applyMigrations(): void {
|
|
|
245
274
|
}
|
|
246
275
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
|
|
247
276
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_from_kind_id ON messages(from_agent, kind, id)");
|
|
277
|
+
// (from_agent, idempotency_key) powers findMessageByIdempotencyKey — the retry-dedup lookup
|
|
278
|
+
// AND the session-step in-place upsert (Codex tool running→completed / streamed reasoning),
|
|
279
|
+
// which runs per persisted session step. Added here (not the base schema) because the column
|
|
280
|
+
// is migration-added for pre-hardening DBs. Without it that lookup full-scans messages.
|
|
281
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key)");
|
|
248
282
|
|
|
249
283
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
250
284
|
getDb().run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
package/src/db/schema.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { applyConnectionPragmas, getDb, setDb } from "./connection.ts";
|
|
18
|
+
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
18
19
|
import { applyMigrations } from "./migrations.ts";
|
|
19
20
|
import type {
|
|
20
21
|
AgentCard,
|
|
@@ -174,6 +175,28 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
174
175
|
);
|
|
175
176
|
CREATE INDEX IF NOT EXISTS idx_context_snapshots_agent_time ON context_snapshots(agent_id, captured_at DESC);
|
|
176
177
|
|
|
178
|
+
CREATE TABLE IF NOT EXISTS agent_continuations (
|
|
179
|
+
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
180
|
+
objective TEXT NOT NULL,
|
|
181
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
182
|
+
ledger TEXT NOT NULL DEFAULT '[]',
|
|
183
|
+
working_state TEXT NOT NULL DEFAULT '',
|
|
184
|
+
archive_refs TEXT NOT NULL DEFAULT '[]',
|
|
185
|
+
budget TEXT NOT NULL,
|
|
186
|
+
created_at INTEGER NOT NULL,
|
|
187
|
+
updated_at INTEGER NOT NULL
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
CREATE TABLE IF NOT EXISTS continuation_archives (
|
|
191
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
192
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
193
|
+
generation INTEGER NOT NULL,
|
|
194
|
+
segment TEXT NOT NULL,
|
|
195
|
+
created_at INTEGER NOT NULL
|
|
196
|
+
);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_continuation_archives_agent_generation
|
|
198
|
+
ON continuation_archives(agent_id, generation, created_at DESC);
|
|
199
|
+
|
|
177
200
|
CREATE TABLE IF NOT EXISTS memories (
|
|
178
201
|
id TEXT PRIMARY KEY,
|
|
179
202
|
type TEXT NOT NULL,
|
|
@@ -743,6 +766,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
743
766
|
CREATE INDEX IF NOT EXISTS idx_insights_obs_session ON insights_observations(session_id);
|
|
744
767
|
`);
|
|
745
768
|
|
|
769
|
+
ensureContinuationArchiveSearchSchema();
|
|
746
770
|
applyMigrations();
|
|
747
771
|
|
|
748
772
|
// Bootstrap planner statistics on first run (sqlite_stat1 absent means ANALYZE
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
upsertManagedAgentState,
|
|
10
10
|
} from "./config-store";
|
|
11
11
|
import { emitRelayEvent } from "./events";
|
|
12
|
+
import { handleContextPressure } from "./context-advisory";
|
|
12
13
|
import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
|
|
13
14
|
import { authContextFromSystem } from "./services/auth-context";
|
|
14
15
|
import { emitCommandEvent } from "./command-events";
|
|
@@ -25,7 +26,6 @@ const STOP_TIMEOUT_MS = 60_000;
|
|
|
25
26
|
const FAST_FAIL_RUNTIME_MS = 30_000;
|
|
26
27
|
const MAX_FAST_FAILURES = 3;
|
|
27
28
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
28
|
-
const DEFAULT_COMPACT_THRESHOLD = 0.75;
|
|
29
29
|
const DEFAULT_COMPACT_TARGET = 0.3;
|
|
30
30
|
const DEFAULT_MAX_TASKS_BEFORE_COMPACT = 10;
|
|
31
31
|
const DEFAULT_COMPACT_COOLDOWN_MS = 60_000;
|
|
@@ -354,6 +354,7 @@ export class LifecycleManager {
|
|
|
354
354
|
this.stopAgent(policy, true, "idle-timeout");
|
|
355
355
|
return;
|
|
356
356
|
}
|
|
357
|
+
if (handleContextPressure({ agent, now: this.now, hasActiveCommand: (target, type) => this.hasActiveCommand(target, type), compact: (reason) => this.compactAgent(policy, agent.id, reason, alwaysReloadTags(agent.tags)) })) return;
|
|
357
358
|
if (this.shouldCompact(agent)) {
|
|
358
359
|
this.compactAgent(policy, agent.id, "context-threshold", alwaysReloadTags(agent.tags));
|
|
359
360
|
return;
|
|
@@ -524,7 +525,6 @@ export class LifecycleManager {
|
|
|
524
525
|
if (agent.providerCapabilities?.context?.compact !== true) return false;
|
|
525
526
|
if (this.hasActiveCommand(agent.id, "agent.compact")) return false;
|
|
526
527
|
|
|
527
|
-
if (context.utilization > DEFAULT_COMPACT_THRESHOLD) return true;
|
|
528
528
|
if (context.tasksSinceCompact > DEFAULT_MAX_TASKS_BEFORE_COMPACT) return true;
|
|
529
529
|
return context.lifecycleState === "cooling" &&
|
|
530
530
|
this.now() - context.lastUpdatedAt >= DEFAULT_COMPACT_COOLDOWN_MS &&
|
package/src/maintenance.ts
CHANGED
|
@@ -34,7 +34,8 @@ import {
|
|
|
34
34
|
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
35
35
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
36
36
|
import { reconcileLandedWorkspace } from "./branch-landed";
|
|
37
|
-
import {
|
|
37
|
+
import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
|
|
38
|
+
import { preparePrCompletionScan } from "./workspace-pr-completion";
|
|
38
39
|
import { notifyAgentOffline } from "./agent-lifecycle-events";
|
|
39
40
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
40
41
|
import { reapOrphanedWorktrees } from "./workspace-orphans";
|
|
@@ -578,12 +579,8 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
578
579
|
const candidates = listWorkspaces().filter(
|
|
579
580
|
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
|
|
580
581
|
);
|
|
581
|
-
const flagged: string[] = [];
|
|
582
|
-
const
|
|
583
|
-
const merged: string[] = [];
|
|
584
|
-
const notifiedStewards: string[] = [];
|
|
585
|
-
const reviewerArmed: string[] = [];
|
|
586
|
-
const reviewerChangesRequested: string[] = [];
|
|
582
|
+
const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
|
|
583
|
+
const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
|
|
587
584
|
|
|
588
585
|
for (const ws of candidates) {
|
|
589
586
|
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
@@ -621,15 +618,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
621
618
|
continue;
|
|
622
619
|
}
|
|
623
620
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
if (action.notifiedOwner) reviewerChangesRequested.push(ws.id);
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
621
|
+
const prAction = preparePrCompletionScan(ws, p, orch.agentId);
|
|
622
|
+
if (prAction.armCommand) { emitCommand(prAction.armCommand); reviewerArmed.push(ws.id); continue; }
|
|
623
|
+
if (prAction.notifiedOwner) { reviewerChangesRequested.push(ws.id); continue; }
|
|
624
|
+
if (prAction.mergeCommand) { emitCommand(prAction.mergeCommand); relayPrMerged.push(ws.id); continue; }
|
|
625
|
+
if (prAction.waitReason) relayPrWaiting.push(`${ws.id}:${prAction.waitReason}`);
|
|
626
|
+
if (prAction.stop) continue;
|
|
633
627
|
|
|
634
628
|
// Past here we act on the conflict signal — skip when the host couldn't assess it
|
|
635
629
|
// (undefined): never flag/clear a conflict on incomplete data.
|
|
@@ -684,7 +678,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
684
678
|
}
|
|
685
679
|
}
|
|
686
680
|
|
|
687
|
-
return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards, reviewerArmed, reviewerChangesRequested };
|
|
681
|
+
return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
|
|
688
682
|
}
|
|
689
683
|
|
|
690
684
|
// Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready
|