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.
Files changed (70) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-b8kUF4hR.js → activity-DAz3DcDA.js} +2 -2
  4. package/public/assets/{activity-b8kUF4hR.js.map → activity-DAz3DcDA.js.map} +1 -1
  5. package/public/assets/{agents-C_ZSRXRa.js → agents-BJ7qRWxt.js} +2 -2
  6. package/public/assets/{agents-C_ZSRXRa.js.map → agents-BJ7qRWxt.js.map} +1 -1
  7. package/public/assets/{analytics-3JbZ6M41.js → analytics-MNCS2qnT.js} +2 -2
  8. package/public/assets/{analytics-3JbZ6M41.js.map → analytics-MNCS2qnT.js.map} +1 -1
  9. package/public/assets/{automation-CVu2C3sk.js → automation-COp2Kb3h.js} +2 -2
  10. package/public/assets/{automation-CVu2C3sk.js.map → automation-COp2Kb3h.js.map} +1 -1
  11. package/public/assets/chat-D_O8VqMR.js +2 -0
  12. package/public/assets/chat-D_O8VqMR.js.map +1 -0
  13. package/public/assets/display-ConJ9cJB.js.map +1 -1
  14. package/public/assets/{formatted-body-impl-CSAGn8zM.js → formatted-body-impl-BbMHqkCy.js} +2 -2
  15. package/public/assets/{formatted-body-impl-CSAGn8zM.js.map → formatted-body-impl-BbMHqkCy.js.map} +1 -1
  16. package/public/assets/{index-n5wxGWSq.js → index-DgJOEApM.js} +5 -5
  17. package/public/assets/{index-n5wxGWSq.js.map → index-DgJOEApM.js.map} +1 -1
  18. package/public/assets/{index-Bins8N_5.css → index-Dop-uXiy.css} +1 -1
  19. package/public/assets/{maintenance-CCcvsBbm.js → maintenance-BHv90kXZ.js} +2 -2
  20. package/public/assets/{maintenance-CCcvsBbm.js.map → maintenance-BHv90kXZ.js.map} +1 -1
  21. package/public/assets/{managed-agents-DHfO5gVD.js → managed-agents-BAlvkxfo.js} +2 -2
  22. package/public/assets/{managed-agents-DHfO5gVD.js.map → managed-agents-BAlvkxfo.js.map} +1 -1
  23. package/public/assets/{markdown-preview-impl-C1jNXAEL.js → markdown-preview-impl-6k_FWjmd.js} +2 -2
  24. package/public/assets/{markdown-preview-impl-C1jNXAEL.js.map → markdown-preview-impl-6k_FWjmd.js.map} +1 -1
  25. package/public/assets/{memory-VmjJ-YZ8.js → memory-DBjqdVpg.js} +2 -2
  26. package/public/assets/{memory-VmjJ-YZ8.js.map → memory-DBjqdVpg.js.map} +1 -1
  27. package/public/assets/{messages-DXN6Rkjt.js → messages-axoaJY2N.js} +2 -2
  28. package/public/assets/{messages-DXN6Rkjt.js.map → messages-axoaJY2N.js.map} +1 -1
  29. package/public/assets/{orchestrators-CLyYvfn1.js → orchestrators-DMfHZ44H.js} +2 -2
  30. package/public/assets/{orchestrators-CLyYvfn1.js.map → orchestrators-DMfHZ44H.js.map} +1 -1
  31. package/public/assets/{overview-DZmVU2Fh.js → overview-DqxYWpUT.js} +2 -2
  32. package/public/assets/{overview-DZmVU2Fh.js.map → overview-DqxYWpUT.js.map} +1 -1
  33. package/public/assets/{pairs-BeEORfZF.js → pairs-D3wnzC1V.js} +2 -2
  34. package/public/assets/{pairs-BeEORfZF.js.map → pairs-D3wnzC1V.js.map} +1 -1
  35. package/public/assets/{security-DIe-a7rD.js → security-KWrb7PKj.js} +2 -2
  36. package/public/assets/{security-DIe-a7rD.js.map → security-KWrb7PKj.js.map} +1 -1
  37. package/public/assets/{settings-BeFPCFUZ.js → settings-PXMEzNAm.js} +2 -2
  38. package/public/assets/{settings-BeFPCFUZ.js.map → settings-PXMEzNAm.js.map} +1 -1
  39. package/public/assets/{tasks-BVXiBzUI.js → tasks-CFYShBCz.js} +2 -2
  40. package/public/assets/{tasks-BVXiBzUI.js.map → tasks-CFYShBCz.js.map} +1 -1
  41. package/public/assets/{terminal-viewer-impl-CN311GDZ.js → terminal-viewer-impl-BwPYZlWI.js} +2 -2
  42. package/public/assets/{terminal-viewer-impl-CN311GDZ.js.map → terminal-viewer-impl-BwPYZlWI.js.map} +1 -1
  43. package/public/assets/{work-queue-BnQftDAS.js → work-queue-VQ_6QDJc.js} +2 -2
  44. package/public/assets/{work-queue-BnQftDAS.js.map → work-queue-VQ_6QDJc.js.map} +1 -1
  45. package/public/index.html +2 -2
  46. package/runner/src/adapter.ts +12 -3
  47. package/src/bus.ts +2 -0
  48. package/src/config-store.ts +20 -8
  49. package/src/context-advisory.ts +110 -0
  50. package/src/context-threshold-config.ts +40 -0
  51. package/src/db/agents.ts +21 -5
  52. package/src/db/continuation-archives.ts +179 -0
  53. package/src/db/continuations.ts +151 -0
  54. package/src/db/index.ts +2 -0
  55. package/src/db/messages.ts +36 -2
  56. package/src/db/migrations.ts +34 -0
  57. package/src/db/schema.ts +24 -0
  58. package/src/lifecycle-manager.ts +2 -2
  59. package/src/maintenance.ts +11 -17
  60. package/src/mcp.ts +77 -0
  61. package/src/routes/commands.ts +24 -0
  62. package/src/routes/continuation-archives.ts +37 -0
  63. package/src/routes/index.ts +2 -0
  64. package/src/security.ts +2 -0
  65. package/src/self-resume-config.ts +44 -0
  66. package/src/self-resume.ts +232 -0
  67. package/src/services/send-message.ts +4 -0
  68. package/src/workspace-pr-completion.ts +121 -0
  69. package/public/assets/chat-BiCWbMPO.js +0 -2
  70. 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
@@ -21,3 +21,5 @@ export * from "./stats.ts";
21
21
  export * from "./orchestrators.ts";
22
22
  export * from "./workspaces.ts";
23
23
  export * from "./merge-lease.ts";
24
+ export * from "./continuations.ts";
25
+ export * from "./continuation-archives.ts";
@@ -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) return { message: existing, created: false };
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
@@ -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
@@ -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 &&
@@ -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 { handleReviewerPipelineScan, isAwaitingReviewerApproval } from "./reviewer-pipeline";
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 cleared: string[] = [];
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
- if (isAwaitingReviewerApproval(ws)) {
625
- const action = handleReviewerPipelineScan(ws, p, orch.agentId);
626
- if (action.armCommand) {
627
- emitCommand(action.armCommand);
628
- reviewerArmed.push(ws.id);
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