@songsid/agend 2.1.7-beta.5 → 2.1.7-beta.6
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/dist/backend/codex.d.ts +26 -9
- package/dist/backend/codex.js +91 -33
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.js +51 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/cli.js +79 -132
- package/dist/cli.js.map +1 -1
- package/dist/completion-install.d.ts +32 -0
- package/dist/completion-install.js +125 -1
- package/dist/completion-install.js.map +1 -1
- package/dist/config-validator.js +4 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +15 -6
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +109 -0
- package/dist/delivery-outbox.js +306 -10
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +8 -0
- package/dist/fleet-manager.js +48 -0
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +10 -0
- package/dist/instance-lifecycle.js +20 -11
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +6 -0
- package/dist/instructions.js +8 -0
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +2 -0
- package/dist/locale.js.map +1 -1
- package/dist/ls-rows.d.ts +50 -0
- package/dist/ls-rows.js +152 -0
- package/dist/ls-rows.js.map +1 -0
- package/dist/outbound-handlers.d.ts +1 -1
- package/dist/outbound-handlers.js +6 -7
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +3 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/quickstart.js +9 -3
- package/dist/quickstart.js.map +1 -1
- package/dist/remote-credential-check.d.ts +26 -7
- package/dist/remote-credential-check.js +157 -10
- package/dist/remote-credential-check.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/ui/dashboard.html +7 -5
- package/dist/ui/view.html +138 -45
- package/dist/usage/usage-api.js +30 -0
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
|
@@ -47,21 +47,50 @@ export type DeliveryStatusSelector = {
|
|
|
47
47
|
deliveryId: string;
|
|
48
48
|
operationId?: never;
|
|
49
49
|
correlationId?: never;
|
|
50
|
+
messageId?: never;
|
|
50
51
|
limit?: never;
|
|
51
52
|
cursor?: never;
|
|
52
53
|
} | {
|
|
53
54
|
operationId: string;
|
|
54
55
|
deliveryId?: never;
|
|
55
56
|
correlationId?: never;
|
|
57
|
+
messageId?: never;
|
|
56
58
|
limit?: number;
|
|
57
59
|
cursor?: string;
|
|
58
60
|
} | {
|
|
59
61
|
correlationId: string;
|
|
60
62
|
deliveryId?: never;
|
|
61
63
|
operationId?: never;
|
|
64
|
+
messageId?: never;
|
|
65
|
+
limit?: number;
|
|
66
|
+
cursor?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* #856: the `message_id` a receiver sees in the envelope header. Lets an
|
|
70
|
+
* agent check that a peer message it is about to act on was really
|
|
71
|
+
* delivered by the fleet — a model can "see" one nobody sent.
|
|
72
|
+
*/
|
|
73
|
+
| {
|
|
74
|
+
messageId: string;
|
|
75
|
+
deliveryId?: never;
|
|
76
|
+
operationId?: never;
|
|
77
|
+
correlationId?: never;
|
|
62
78
|
limit?: number;
|
|
63
79
|
cursor?: string;
|
|
64
80
|
};
|
|
81
|
+
/** The one mapping from validated tool/CLI arguments to a selector. */
|
|
82
|
+
export declare function deliveryStatusSelector(args: {
|
|
83
|
+
delivery_id?: string;
|
|
84
|
+
operation_id?: string;
|
|
85
|
+
correlation_id?: string;
|
|
86
|
+
message_id?: string;
|
|
87
|
+
limit?: number;
|
|
88
|
+
cursor?: string;
|
|
89
|
+
}): DeliveryStatusSelector;
|
|
90
|
+
/** sha256 of a delivered text, as hex. The same function hashes admission and paste. */
|
|
91
|
+
export declare function deliveryContentDigest(text: string): string;
|
|
92
|
+
/** #926 reminders and overdue notices; never themselves the subject of a failure notice. */
|
|
93
|
+
export declare const REPLY_OBLIGATION_NOTICE_KIND = "reply_obligation_notice";
|
|
65
94
|
export interface DeliveryStatusItem {
|
|
66
95
|
delivery_id: string;
|
|
67
96
|
operation_id: string;
|
|
@@ -76,6 +105,44 @@ export interface DeliveryStatusItem {
|
|
|
76
105
|
status_summary: string;
|
|
77
106
|
error_summary: string | null;
|
|
78
107
|
safe_to_retry: boolean;
|
|
108
|
+
/** Fleet message ID of the delivered envelope, when it had one. */
|
|
109
|
+
message_id: string | null;
|
|
110
|
+
/** sha256 of the delivered text as admitted (#856). */
|
|
111
|
+
content_sha256: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* The text as admitted — only on a `message_id` query by the row's own
|
|
114
|
+
* source or target: the caller already sent or received it, and verifying
|
|
115
|
+
* a value (a SHA, a PR number) needs the text, not just its digest. Every
|
|
116
|
+
* other query, and operator reads, stay redacted as before (#982).
|
|
117
|
+
*/
|
|
118
|
+
content?: string | null;
|
|
119
|
+
/** #926: the reply this request is owed, when it asked for one. */
|
|
120
|
+
reply_obligation?: {
|
|
121
|
+
state: "open" | "answered";
|
|
122
|
+
opened_at: string;
|
|
123
|
+
last_asked_at: string;
|
|
124
|
+
nudged_at: string | null;
|
|
125
|
+
overdue_notified_at: string | null;
|
|
126
|
+
answered_at: string | null;
|
|
127
|
+
} | null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* #926: a peer asked with `requires_reply` and the owner has not answered it
|
|
131
|
+
* through the fleet yet. Terminal text is not an answer: only a message the
|
|
132
|
+
* owner sends back to the requester with the same correlation_id closes it.
|
|
133
|
+
*/
|
|
134
|
+
export interface ReplyObligation {
|
|
135
|
+
correlationId: string;
|
|
136
|
+
requesterInstance: string;
|
|
137
|
+
ownerInstance: string;
|
|
138
|
+
requestDeliveryId: string;
|
|
139
|
+
openedAt: string;
|
|
140
|
+
lastAskedAt: string;
|
|
141
|
+
state: "open" | "answered";
|
|
142
|
+
nudgedAt: string | null;
|
|
143
|
+
overdueNotifiedAt: string | null;
|
|
144
|
+
answeredAt: string | null;
|
|
145
|
+
answeredDeliveryId: string | null;
|
|
79
146
|
}
|
|
80
147
|
export interface DeliveryStatusPage {
|
|
81
148
|
items: DeliveryStatusItem[];
|
|
@@ -92,6 +159,10 @@ export interface DeliveryAttemptEvidence {
|
|
|
92
159
|
transcriptSessionId: string | null;
|
|
93
160
|
submissionMode: DurableSubmissionMode;
|
|
94
161
|
queueResumePolicy?: QueueResumePolicy;
|
|
162
|
+
/** sha256 of the text the target daemon received, before formatting (#856). */
|
|
163
|
+
pastedContentSha256?: string | null;
|
|
164
|
+
/** sha256 of the exact string handed to the pane write (#856). */
|
|
165
|
+
pastedBytesSha256?: string | null;
|
|
95
166
|
}
|
|
96
167
|
export interface DeliveryReconciliationCandidate extends OutboxDelivery {
|
|
97
168
|
attempt: {
|
|
@@ -116,6 +187,44 @@ export declare class DeliveryOutbox extends EventEmitter {
|
|
|
116
187
|
private readonly db;
|
|
117
188
|
readonly managerBootId: string;
|
|
118
189
|
constructor(dbPath: string, managerBootId?: string);
|
|
190
|
+
/**
|
|
191
|
+
* #926: a delivered `requires_reply` request opens (or, asked again,
|
|
192
|
+
* re-opens) the owner's obligation. Runs inside the delivered transition.
|
|
193
|
+
* An answer admitted before the request finished its submission proof —
|
|
194
|
+
* an owner can reply while the daemon is still confirming — counts.
|
|
195
|
+
*/
|
|
196
|
+
private openReplyObligation;
|
|
197
|
+
/** The owner's message back to the requester on the same correlation_id answers it — at admission. */
|
|
198
|
+
private answerReplyObligation;
|
|
199
|
+
/**
|
|
200
|
+
* #926 safety net 1: remind an owner that has not answered. `turn-ended`
|
|
201
|
+
* reminds once per ask, after the grace (a reply seconds after the ask is
|
|
202
|
+
* normal); `restart` reminds once per restart, because the interrupted turn
|
|
203
|
+
* does not resume on its own. Marked in the same transaction as the notice.
|
|
204
|
+
*/
|
|
205
|
+
remindReplyObligations(ownerInstance: string, opts: {
|
|
206
|
+
now?: Date;
|
|
207
|
+
graceMs: number;
|
|
208
|
+
reason: "turn-ended" | "restart";
|
|
209
|
+
since?: Date;
|
|
210
|
+
}): number;
|
|
211
|
+
/**
|
|
212
|
+
* #926 safety net 2 (poll-on-timeout, in code): tell the requester once when
|
|
213
|
+
* the owner is not working and nothing has come back for `overdueMs` since
|
|
214
|
+
* the last ask or reminder. A still-working owner is never reported.
|
|
215
|
+
*/
|
|
216
|
+
notifyOverdueReplyObligations(opts: {
|
|
217
|
+
now?: Date;
|
|
218
|
+
overdueMs: number;
|
|
219
|
+
ownerIdle: (owner: string) => boolean;
|
|
220
|
+
}): number;
|
|
221
|
+
/** A system notice as a durable row, like #929's outcome notices; the key makes it idempotent. */
|
|
222
|
+
private insertReplyObligationNotice;
|
|
223
|
+
/** The owner's open obligations, oldest first (#926). */
|
|
224
|
+
openReplyObligations(ownerInstance: string): ReplyObligation[];
|
|
225
|
+
getReplyObligation(correlationId: string, requesterInstance: string, ownerInstance: string): ReplyObligation | undefined;
|
|
226
|
+
/** Rows admitted before #856 get the same message_id / digest a new row would. */
|
|
227
|
+
private backfillMessageEvidence;
|
|
119
228
|
private ensureColumn;
|
|
120
229
|
/** Commit admission before returning success. Duplicate source keys return their original row. */
|
|
121
230
|
admit(input: NewOutboxDelivery): {
|
package/dist/delivery-outbox.js
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
4
|
import { chmodSync, mkdirSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
export const DURABLE_DELIVERY_MAX_ATTEMPTS = 8;
|
|
7
7
|
export const DURABLE_DELIVERY_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
8
8
|
export const DURABLE_DELIVERY_ABORT_BACKOFF_MS = 5_000;
|
|
9
|
+
/** The one mapping from validated tool/CLI arguments to a selector. */
|
|
10
|
+
export function deliveryStatusSelector(args) {
|
|
11
|
+
const page = { ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.cursor ? { cursor: args.cursor } : {}) };
|
|
12
|
+
if (args.delivery_id)
|
|
13
|
+
return { deliveryId: args.delivery_id };
|
|
14
|
+
if (args.operation_id)
|
|
15
|
+
return { operationId: args.operation_id, ...page };
|
|
16
|
+
if (args.message_id)
|
|
17
|
+
return { messageId: args.message_id, ...page };
|
|
18
|
+
return { correlationId: args.correlation_id, ...page };
|
|
19
|
+
}
|
|
20
|
+
/** sha256 of a delivered text, as hex. The same function hashes admission and paste. */
|
|
21
|
+
export function deliveryContentDigest(text) {
|
|
22
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
23
|
+
}
|
|
24
|
+
/** The text a durable row delivers, for kinds whose payload carries one. */
|
|
25
|
+
function payloadContent(payload) {
|
|
26
|
+
const content = payload && typeof payload === "object" ? payload.content : undefined;
|
|
27
|
+
return typeof content === "string" ? content : null;
|
|
28
|
+
}
|
|
29
|
+
/** #926 reminders and overdue notices; never themselves the subject of a failure notice. */
|
|
30
|
+
export const REPLY_OBLIGATION_NOTICE_KIND = "reply_obligation_notice";
|
|
31
|
+
/** Kinds that carry a peer request or its answer. Broadcasts are one-to-many and deferred (#926 §6). */
|
|
32
|
+
const REPLY_OBLIGATION_KINDS = ["fleet_inbound", "steer"];
|
|
33
|
+
function payloadRequiresReply(payload) {
|
|
34
|
+
const meta = payload && typeof payload === "object" ? payload.meta : undefined;
|
|
35
|
+
return !!meta && typeof meta === "object" && meta.requires_reply === "true";
|
|
36
|
+
}
|
|
37
|
+
function payloadMessageId(payload) {
|
|
38
|
+
const meta = payload && typeof payload === "object" ? payload.meta : undefined;
|
|
39
|
+
const id = meta && typeof meta === "object" ? meta.message_id : undefined;
|
|
40
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
41
|
+
}
|
|
9
42
|
function mapRow(row) {
|
|
10
43
|
return {
|
|
11
44
|
deliveryId: row.delivery_id,
|
|
@@ -60,9 +93,42 @@ function statusSummary(state) {
|
|
|
60
93
|
function safeToRetry(state) {
|
|
61
94
|
return state === "queued" || state === "delivering" || state === "retry_wait" || state === "failed";
|
|
62
95
|
}
|
|
96
|
+
function hasTable(db, table) {
|
|
97
|
+
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
|
98
|
+
}
|
|
99
|
+
/** The obligation a request row opened, if any; absent on databases from before #926. */
|
|
100
|
+
function obligationFor(db, correlationId, requester, owner) {
|
|
101
|
+
if (!correlationId || !hasTable(db, "reply_obligations"))
|
|
102
|
+
return null;
|
|
103
|
+
const row = db.prepare(`
|
|
104
|
+
SELECT state, opened_at, last_asked_at, nudged_at, overdue_notified_at, answered_at FROM reply_obligations
|
|
105
|
+
WHERE correlation_id=? AND requester_instance=? AND owner_instance=?
|
|
106
|
+
`).get(correlationId, requester, owner);
|
|
107
|
+
return row ?? null;
|
|
108
|
+
}
|
|
109
|
+
function hasColumn(db, table, column) {
|
|
110
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === column);
|
|
111
|
+
}
|
|
112
|
+
/** Derived from the stored payload, so it is the same for pre-#856 rows. */
|
|
113
|
+
function rowContentEvidence(payloadJson, withContent) {
|
|
114
|
+
let payload = null;
|
|
115
|
+
try {
|
|
116
|
+
payload = JSON.parse(payloadJson);
|
|
117
|
+
}
|
|
118
|
+
catch { /* corrupt row: report no evidence */ }
|
|
119
|
+
const content = payloadContent(payload);
|
|
120
|
+
return {
|
|
121
|
+
message_id: payloadMessageId(payload),
|
|
122
|
+
content_sha256: content === null ? null : deliveryContentDigest(content),
|
|
123
|
+
...(withContent ? { content } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
63
126
|
function queryStatusPage(db, selector, callerInstance) {
|
|
64
127
|
const where = [];
|
|
65
128
|
const params = [];
|
|
129
|
+
// An operator may read a database written before the column existed.
|
|
130
|
+
const messageIdColumn = hasColumn(db, "deliveries", "message_id")
|
|
131
|
+
? "d.message_id" : "json_extract(d.payload_json,'$.meta.message_id')";
|
|
66
132
|
if (selector.deliveryId) {
|
|
67
133
|
where.push("d.delivery_id = ?");
|
|
68
134
|
params.push(selector.deliveryId);
|
|
@@ -71,6 +137,10 @@ function queryStatusPage(db, selector, callerInstance) {
|
|
|
71
137
|
where.push("d.operation_id = ?");
|
|
72
138
|
params.push(selector.operationId);
|
|
73
139
|
}
|
|
140
|
+
else if (selector.messageId) {
|
|
141
|
+
where.push(`${messageIdColumn} = ?`);
|
|
142
|
+
params.push(selector.messageId);
|
|
143
|
+
}
|
|
74
144
|
else {
|
|
75
145
|
where.push("d.correlation_id = ?");
|
|
76
146
|
params.push(selector.correlationId);
|
|
@@ -86,6 +156,10 @@ function queryStatusPage(db, selector, callerInstance) {
|
|
|
86
156
|
cursorScope.push("c.operation_id = ?");
|
|
87
157
|
cursorParams.push(selector.operationId);
|
|
88
158
|
}
|
|
159
|
+
else if (selector.messageId) {
|
|
160
|
+
cursorScope.push(`${messageIdColumn.replace(/\bd\./g, "c.")} = ?`);
|
|
161
|
+
cursorParams.push(selector.messageId);
|
|
162
|
+
}
|
|
89
163
|
else {
|
|
90
164
|
cursorScope.push("c.correlation_id = ?");
|
|
91
165
|
cursorParams.push(selector.correlationId);
|
|
@@ -103,7 +177,7 @@ function queryStatusPage(db, selector, callerInstance) {
|
|
|
103
177
|
d.kind,
|
|
104
178
|
CASE WHEN d.state='submission_started' AND d.reconciliation_pending=1
|
|
105
179
|
THEN 'reconciliation_pending' ELSE d.state END AS state,
|
|
106
|
-
d.attempt_no,d.created_at,d.updated_at,d.last_error
|
|
180
|
+
d.attempt_no,d.created_at,d.updated_at,d.last_error,d.payload_json
|
|
107
181
|
FROM deliveries d
|
|
108
182
|
WHERE ${where.join(" AND ")}
|
|
109
183
|
ORDER BY d.created_seq
|
|
@@ -126,10 +200,22 @@ function queryStatusPage(db, selector, callerInstance) {
|
|
|
126
200
|
status_summary: statusSummary(row.state),
|
|
127
201
|
error_summary: safeErrorSummary(row.last_error),
|
|
128
202
|
safe_to_retry: safeToRetry(row.state),
|
|
203
|
+
// Text only for the explicit verification query (#856), and only to the
|
|
204
|
+
// row's own source/target; every other query keeps #982's redaction.
|
|
205
|
+
...rowContentEvidence(row.payload_json, callerInstance !== null && !!selector.messageId),
|
|
206
|
+
reply_obligation: obligationFor(db, row.correlation_id, row.source_instance, row.target_instance),
|
|
129
207
|
})),
|
|
130
208
|
next_cursor: hasMore ? visible.at(-1)?.delivery_id ?? null : null,
|
|
131
209
|
};
|
|
132
210
|
}
|
|
211
|
+
function mapObligation(row) {
|
|
212
|
+
return {
|
|
213
|
+
correlationId: row.correlation_id, requesterInstance: row.requester_instance, ownerInstance: row.owner_instance,
|
|
214
|
+
requestDeliveryId: row.request_delivery_id, openedAt: row.opened_at, lastAskedAt: row.last_asked_at, state: row.state,
|
|
215
|
+
nudgedAt: row.nudged_at, overdueNotifiedAt: row.overdue_notified_at, answeredAt: row.answered_at,
|
|
216
|
+
answeredDeliveryId: row.answered_delivery_id,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
133
219
|
/** Durable admission and fenced state transitions for the fleet delivery queue. */
|
|
134
220
|
export class DeliveryOutbox extends EventEmitter {
|
|
135
221
|
db;
|
|
@@ -215,6 +301,21 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
215
301
|
created_at TEXT NOT NULL,
|
|
216
302
|
PRIMARY KEY (source_instance, source_daemon_boot_id, operation_id, target_instance)
|
|
217
303
|
);
|
|
304
|
+
CREATE TABLE IF NOT EXISTS reply_obligations (
|
|
305
|
+
correlation_id TEXT NOT NULL,
|
|
306
|
+
requester_instance TEXT NOT NULL,
|
|
307
|
+
owner_instance TEXT NOT NULL,
|
|
308
|
+
request_delivery_id TEXT NOT NULL,
|
|
309
|
+
opened_at TEXT NOT NULL,
|
|
310
|
+
last_asked_at TEXT NOT NULL,
|
|
311
|
+
state TEXT NOT NULL CHECK (state IN ('open','answered')),
|
|
312
|
+
nudged_at TEXT,
|
|
313
|
+
overdue_notified_at TEXT,
|
|
314
|
+
answered_at TEXT,
|
|
315
|
+
answered_delivery_id TEXT,
|
|
316
|
+
PRIMARY KEY (correlation_id, requester_instance, owner_instance)
|
|
317
|
+
);
|
|
318
|
+
CREATE INDEX IF NOT EXISTS idx_reply_obligation_owner_state ON reply_obligations(owner_instance, state);
|
|
218
319
|
CREATE TABLE IF NOT EXISTS failure_notices (
|
|
219
320
|
parent_delivery_id TEXT PRIMARY KEY,
|
|
220
321
|
notice_delivery_id TEXT NOT NULL UNIQUE,
|
|
@@ -233,7 +334,186 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
233
334
|
this.ensureColumn("delivery_attempts", "backend_version", "TEXT");
|
|
234
335
|
this.ensureColumn("delivery_attempts", "queue_resume_policy", "TEXT");
|
|
235
336
|
this.ensureColumn("delivery_attempts", "enter_started_at", "TEXT");
|
|
236
|
-
|
|
337
|
+
// #856: a receiver can look a message up by the ID in its envelope, and
|
|
338
|
+
// admission vs pane-write digests tell transport rewrites apart from a
|
|
339
|
+
// receiver acting on a message nobody sent.
|
|
340
|
+
this.ensureColumn("deliveries", "message_id", "TEXT");
|
|
341
|
+
this.ensureColumn("deliveries", "content_sha256", "TEXT");
|
|
342
|
+
this.ensureColumn("delivery_attempts", "pasted_content_sha256", "TEXT");
|
|
343
|
+
this.ensureColumn("delivery_attempts", "pasted_bytes_sha256", "TEXT");
|
|
344
|
+
this.ensureColumn("delivery_attempts", "content_digest_mismatch", "INTEGER NOT NULL DEFAULT 0");
|
|
345
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_message_id ON deliveries(message_id)");
|
|
346
|
+
this.backfillMessageEvidence();
|
|
347
|
+
this.db.pragma("user_version = 4");
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* #926: a delivered `requires_reply` request opens (or, asked again,
|
|
351
|
+
* re-opens) the owner's obligation. Runs inside the delivered transition.
|
|
352
|
+
* An answer admitted before the request finished its submission proof —
|
|
353
|
+
* an owner can reply while the daemon is still confirming — counts.
|
|
354
|
+
*/
|
|
355
|
+
openReplyObligation(request, now) {
|
|
356
|
+
if (!REPLY_OBLIGATION_KINDS.includes(request.kind) || !request.correlation_id)
|
|
357
|
+
return;
|
|
358
|
+
let payload = null;
|
|
359
|
+
try {
|
|
360
|
+
payload = JSON.parse(request.payload_json);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!payloadRequiresReply(payload))
|
|
366
|
+
return;
|
|
367
|
+
const earlyAnswer = this.db.prepare(`
|
|
368
|
+
SELECT delivery_id FROM deliveries
|
|
369
|
+
WHERE correlation_id=? AND source_instance=? AND target_instance=? AND created_seq > ?
|
|
370
|
+
AND kind IN (${REPLY_OBLIGATION_KINDS.map(() => "?").join(",")})
|
|
371
|
+
ORDER BY created_seq LIMIT 1
|
|
372
|
+
`).get(request.correlation_id, request.target_instance, request.source_instance, request.created_seq, ...REPLY_OBLIGATION_KINDS);
|
|
373
|
+
this.db.prepare(`
|
|
374
|
+
INSERT INTO reply_obligations(correlation_id, requester_instance, owner_instance, request_delivery_id,
|
|
375
|
+
opened_at, last_asked_at, state, answered_at, answered_delivery_id)
|
|
376
|
+
VALUES (@cid, @requester, @owner, @request, @now, @now, @state, @answeredAt, @answeredId)
|
|
377
|
+
ON CONFLICT(correlation_id, requester_instance, owner_instance) DO UPDATE SET
|
|
378
|
+
request_delivery_id=excluded.request_delivery_id, last_asked_at=excluded.last_asked_at,
|
|
379
|
+
state=excluded.state, nudged_at=NULL, overdue_notified_at=NULL,
|
|
380
|
+
answered_at=excluded.answered_at, answered_delivery_id=excluded.answered_delivery_id
|
|
381
|
+
`).run({
|
|
382
|
+
cid: request.correlation_id, requester: request.source_instance, owner: request.target_instance,
|
|
383
|
+
request: request.delivery_id, now,
|
|
384
|
+
state: earlyAnswer ? "answered" : "open",
|
|
385
|
+
answeredAt: earlyAnswer ? now : null, answeredId: earlyAnswer?.delivery_id ?? null,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** The owner's message back to the requester on the same correlation_id answers it — at admission. */
|
|
389
|
+
answerReplyObligation(answer, now) {
|
|
390
|
+
if (!REPLY_OBLIGATION_KINDS.includes(answer.kind) || !answer.correlation_id)
|
|
391
|
+
return;
|
|
392
|
+
this.db.prepare(`
|
|
393
|
+
UPDATE reply_obligations SET state='answered', answered_at=?, answered_delivery_id=?
|
|
394
|
+
WHERE correlation_id=? AND owner_instance=? AND requester_instance=? AND state='open'
|
|
395
|
+
`).run(now, answer.delivery_id, answer.correlation_id, answer.source_instance, answer.target_instance);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* #926 safety net 1: remind an owner that has not answered. `turn-ended`
|
|
399
|
+
* reminds once per ask, after the grace (a reply seconds after the ask is
|
|
400
|
+
* normal); `restart` reminds once per restart, because the interrupted turn
|
|
401
|
+
* does not resume on its own. Marked in the same transaction as the notice.
|
|
402
|
+
*/
|
|
403
|
+
remindReplyObligations(ownerInstance, opts) {
|
|
404
|
+
const now = opts.now ?? new Date();
|
|
405
|
+
const nowIso = now.toISOString();
|
|
406
|
+
const graceCutoff = new Date(now.getTime() - opts.graceMs).toISOString();
|
|
407
|
+
const sinceIso = (opts.since ?? now).toISOString();
|
|
408
|
+
const inserted = this.db.transaction(() => {
|
|
409
|
+
const due = this.db.prepare(opts.reason === "turn-ended"
|
|
410
|
+
? "SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' AND nudged_at IS NULL AND last_asked_at <= ?"
|
|
411
|
+
: "SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' AND (nudged_at IS NULL OR nudged_at < ?)").all(ownerInstance, opts.reason === "turn-ended" ? graceCutoff : sinceIso);
|
|
412
|
+
let count = 0;
|
|
413
|
+
for (const row of due) {
|
|
414
|
+
const content = opts.reason === "turn-ended"
|
|
415
|
+
? `[system:reply-pending] Your turn ended without answering ${row.requester_instance} (correlation_id ${row.correlation_id}, asked ${row.last_asked_at}). Send your conclusion with report_result (correlation_id ${row.correlation_id}) now — text you write in the terminal does not reach them.`
|
|
416
|
+
: `[system:reply-pending] A restart interrupted your work on correlation_id ${row.correlation_id} from ${row.requester_instance} (asked ${row.last_asked_at}). Resume it and answer with report_result (correlation_id ${row.correlation_id}); terminal text does not reach them.`;
|
|
417
|
+
const key = `reply-pending:${opts.reason}:${row.correlation_id}:${row.requester_instance}:${row.owner_instance}:${opts.reason === "turn-ended" ? row.last_asked_at : sinceIso}`;
|
|
418
|
+
if (this.insertReplyObligationNotice(row.owner_instance, row.correlation_id, key, content, nowIso))
|
|
419
|
+
count++;
|
|
420
|
+
this.db.prepare(`
|
|
421
|
+
UPDATE reply_obligations SET nudged_at=? WHERE correlation_id=? AND requester_instance=? AND owner_instance=? AND state='open'
|
|
422
|
+
`).run(nowIso, row.correlation_id, row.requester_instance, row.owner_instance);
|
|
423
|
+
}
|
|
424
|
+
return count;
|
|
425
|
+
})();
|
|
426
|
+
if (inserted > 0)
|
|
427
|
+
this.emit("admitted");
|
|
428
|
+
return inserted;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* #926 safety net 2 (poll-on-timeout, in code): tell the requester once when
|
|
432
|
+
* the owner is not working and nothing has come back for `overdueMs` since
|
|
433
|
+
* the last ask or reminder. A still-working owner is never reported.
|
|
434
|
+
*/
|
|
435
|
+
notifyOverdueReplyObligations(opts) {
|
|
436
|
+
if (!(opts.overdueMs > 0))
|
|
437
|
+
return 0;
|
|
438
|
+
const now = opts.now ?? new Date();
|
|
439
|
+
const nowIso = now.toISOString();
|
|
440
|
+
const inserted = this.db.transaction(() => {
|
|
441
|
+
const open = this.db.prepare(`
|
|
442
|
+
SELECT * FROM reply_obligations WHERE state='open' AND overdue_notified_at IS NULL ORDER BY opened_at
|
|
443
|
+
`).all();
|
|
444
|
+
let count = 0;
|
|
445
|
+
for (const row of open) {
|
|
446
|
+
const quietSince = Math.max(Date.parse(row.last_asked_at), row.nudged_at ? Date.parse(row.nudged_at) : 0);
|
|
447
|
+
if (now.getTime() - quietSince < opts.overdueMs || !opts.ownerIdle(row.owner_instance))
|
|
448
|
+
continue;
|
|
449
|
+
const content = `[system:reply-overdue] ${row.owner_instance} has not answered correlation_id ${row.correlation_id} (asked ${row.last_asked_at}${row.nudged_at ? `, reminded ${row.nudged_at}` : ""}) and is not working on anything now. Check with describe_instance or delivery_status (correlation_id ${row.correlation_id}), or ask again.`;
|
|
450
|
+
const key = `reply-overdue:${row.correlation_id}:${row.requester_instance}:${row.owner_instance}:${row.last_asked_at}`;
|
|
451
|
+
if (this.insertReplyObligationNotice(row.requester_instance, row.correlation_id, key, content, nowIso))
|
|
452
|
+
count++;
|
|
453
|
+
this.db.prepare(`
|
|
454
|
+
UPDATE reply_obligations SET overdue_notified_at=? WHERE correlation_id=? AND requester_instance=? AND owner_instance=? AND state='open'
|
|
455
|
+
`).run(nowIso, row.correlation_id, row.requester_instance, row.owner_instance);
|
|
456
|
+
}
|
|
457
|
+
return count;
|
|
458
|
+
})();
|
|
459
|
+
if (inserted > 0)
|
|
460
|
+
this.emit("admitted");
|
|
461
|
+
return inserted;
|
|
462
|
+
}
|
|
463
|
+
/** A system notice as a durable row, like #929's outcome notices; the key makes it idempotent. */
|
|
464
|
+
insertReplyObligationNotice(target, correlationId, sourceKey, content, now) {
|
|
465
|
+
const noticeId = randomUUID();
|
|
466
|
+
const payload = {
|
|
467
|
+
type: "fleet_inbound",
|
|
468
|
+
content,
|
|
469
|
+
meta: {
|
|
470
|
+
user: "AgEnD delivery outbox", user_id: "agend-system", message_id: `reply-obligation-${noticeId}`,
|
|
471
|
+
chat_id: "", thread_id: "", source: "delivery-outbox",
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
return this.db.prepare(`
|
|
475
|
+
INSERT OR IGNORE INTO deliveries (
|
|
476
|
+
delivery_id,operation_id,source_key,source_instance,source_daemon_boot_id,
|
|
477
|
+
target_instance,target_session,target_daemon_boot_id,kind,correlation_id,payload_json,message_id,content_sha256,state,
|
|
478
|
+
attempt_no,created_seq,manager_boot_id,created_at,updated_at,accepted_at
|
|
479
|
+
) VALUES (?,?,?,'agend-system',?,?,NULL,NULL,?,?,?,?,?,'queued',0,
|
|
480
|
+
(SELECT COALESCE(MAX(created_seq),0)+1 FROM deliveries),NULL,?,?,?)
|
|
481
|
+
`).run(noticeId, `notice:${sourceKey}`, sourceKey, this.managerBootId, target, REPLY_OBLIGATION_NOTICE_KIND, correlationId, JSON.stringify(payload), payload.meta.message_id, deliveryContentDigest(content), now, now, now).changes === 1;
|
|
482
|
+
}
|
|
483
|
+
/** The owner's open obligations, oldest first (#926). */
|
|
484
|
+
openReplyObligations(ownerInstance) {
|
|
485
|
+
const rows = this.db.prepare(`
|
|
486
|
+
SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' ORDER BY opened_at, correlation_id
|
|
487
|
+
`).all(ownerInstance);
|
|
488
|
+
return rows.map(mapObligation);
|
|
489
|
+
}
|
|
490
|
+
getReplyObligation(correlationId, requesterInstance, ownerInstance) {
|
|
491
|
+
const row = this.db.prepare(`
|
|
492
|
+
SELECT * FROM reply_obligations WHERE correlation_id=? AND requester_instance=? AND owner_instance=?
|
|
493
|
+
`).get(correlationId, requesterInstance, ownerInstance);
|
|
494
|
+
return row ? mapObligation(row) : undefined;
|
|
495
|
+
}
|
|
496
|
+
/** Rows admitted before #856 get the same message_id / digest a new row would. */
|
|
497
|
+
backfillMessageEvidence() {
|
|
498
|
+
const rows = this.db.prepare(`
|
|
499
|
+
SELECT delivery_id, payload_json FROM deliveries WHERE message_id IS NULL AND content_sha256 IS NULL
|
|
500
|
+
`).all();
|
|
501
|
+
if (rows.length === 0)
|
|
502
|
+
return;
|
|
503
|
+
const update = this.db.prepare("UPDATE deliveries SET message_id=?, content_sha256=? WHERE delivery_id=?");
|
|
504
|
+
this.db.transaction(() => {
|
|
505
|
+
for (const row of rows) {
|
|
506
|
+
let payload = null;
|
|
507
|
+
try {
|
|
508
|
+
payload = JSON.parse(row.payload_json);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const content = payloadContent(payload);
|
|
514
|
+
update.run(payloadMessageId(payload), content === null ? null : deliveryContentDigest(content), row.delivery_id);
|
|
515
|
+
}
|
|
516
|
+
})();
|
|
237
517
|
}
|
|
238
518
|
ensureColumn(table, column, definition) {
|
|
239
519
|
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
@@ -248,11 +528,11 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
248
528
|
const insert = this.db.prepare(`
|
|
249
529
|
INSERT OR IGNORE INTO deliveries (
|
|
250
530
|
delivery_id, operation_id, source_key, source_instance, source_daemon_boot_id,
|
|
251
|
-
target_instance, target_session, kind, correlation_id, payload_json, state,
|
|
531
|
+
target_instance, target_session, kind, correlation_id, payload_json, message_id, content_sha256, state,
|
|
252
532
|
attempt_no, created_seq, manager_boot_id, created_at, updated_at, accepted_at
|
|
253
533
|
) VALUES (
|
|
254
534
|
@delivery_id, @operation_id, @source_key, @source_instance, @source_daemon_boot_id,
|
|
255
|
-
@target_instance, @target_session, @kind, @correlation_id, @payload_json, 'queued',
|
|
535
|
+
@target_instance, @target_session, @kind, @correlation_id, @payload_json, @message_id, @content_sha256, 'queued',
|
|
256
536
|
0, (SELECT COALESCE(MAX(created_seq), 0) + 1 FROM deliveries), NULL,
|
|
257
537
|
@created_at, @updated_at, @accepted_at
|
|
258
538
|
)
|
|
@@ -270,6 +550,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
270
550
|
kind: input.kind,
|
|
271
551
|
correlation_id: input.correlationId ?? null,
|
|
272
552
|
payload_json: JSON.stringify(input.payload),
|
|
553
|
+
message_id: payloadMessageId(input.payload),
|
|
554
|
+
content_sha256: (content => content === null ? null : deliveryContentDigest(content))(payloadContent(input.payload)),
|
|
273
555
|
created_at: now,
|
|
274
556
|
updated_at: now,
|
|
275
557
|
accepted_at: now,
|
|
@@ -277,6 +559,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
277
559
|
const row = select.get(input.sourceKey);
|
|
278
560
|
if (!row)
|
|
279
561
|
throw new Error("outbox admission committed without a readable row");
|
|
562
|
+
if (result.changes === 1)
|
|
563
|
+
this.answerReplyObligation(row, now);
|
|
280
564
|
return { delivery: mapRow(row), inserted: result.changes === 1 };
|
|
281
565
|
});
|
|
282
566
|
return transaction();
|
|
@@ -390,6 +674,10 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
390
674
|
const started = this.db.prepare(`
|
|
391
675
|
SELECT COUNT(*) AS count FROM delivery_attempts WHERE delivery_id=?
|
|
392
676
|
`).get(deliveryId);
|
|
677
|
+
// Only a digest on BOTH sides can disagree: a pre-#856 row or a caller
|
|
678
|
+
// that could not hash is "unknown", never a mismatch.
|
|
679
|
+
const mismatch = !!row.content_sha256 && !!evidence?.pastedContentSha256
|
|
680
|
+
&& row.content_sha256 !== evidence.pastedContentSha256;
|
|
393
681
|
if (started.count >= DURABLE_DELIVERY_MAX_ATTEMPTS) {
|
|
394
682
|
const update = this.db.prepare(`
|
|
395
683
|
UPDATE deliveries SET state='failed',updated_at=?,finished_at=?,last_error='delivery submission attempt limit reached'
|
|
@@ -404,16 +692,19 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
404
692
|
INSERT INTO delivery_attempts(
|
|
405
693
|
delivery_id,target_daemon_boot_id,attempt_no,state,begin_ack_at,
|
|
406
694
|
backend,window_id,transcript_path,transcript_offset,transcript_session_id,submission_mode,
|
|
407
|
-
backend_version,queue_resume_policy
|
|
408
|
-
) VALUES (?,?,?,'begun'
|
|
409
|
-
`).run(deliveryId, targetBootId, attemptNo, now, evidence?.backend ?? null, evidence?.windowId ?? null, evidence?.transcriptPath ?? null, evidence?.transcriptOffset ?? null, evidence?.transcriptSessionId ?? null, evidence?.submissionMode ?? null, evidence?.backendVersion ?? null, evidence?.queueResumePolicy ?? null);
|
|
695
|
+
backend_version,queue_resume_policy,pasted_content_sha256,pasted_bytes_sha256,content_digest_mismatch
|
|
696
|
+
) VALUES (?,?,?,'begun',?,?,?,?,?,?,?,?,?,?,?,?)
|
|
697
|
+
`).run(deliveryId, targetBootId, attemptNo, now, evidence?.backend ?? null, evidence?.windowId ?? null, evidence?.transcriptPath ?? null, evidence?.transcriptOffset ?? null, evidence?.transcriptSessionId ?? null, evidence?.submissionMode ?? null, evidence?.backendVersion ?? null, evidence?.queueResumePolicy ?? null, evidence?.pastedContentSha256 ?? null, evidence?.pastedBytesSha256 ?? null, mismatch ? 1 : 0);
|
|
410
698
|
this.db.prepare(`
|
|
411
699
|
UPDATE deliveries SET state='submission_started', submitted_at=?, updated_at=?
|
|
412
700
|
WHERE delivery_id=? AND state='delivering'
|
|
413
701
|
`).run(now, now, deliveryId);
|
|
414
|
-
return { outcome: "begun", failed: false };
|
|
702
|
+
return { outcome: "begun", failed: false, mismatch };
|
|
415
703
|
});
|
|
416
704
|
const result = transaction();
|
|
705
|
+
if (result.mismatch) {
|
|
706
|
+
this.emit("content_digest_mismatch", { deliveryId, targetBootId, attemptNo, targetInstance: this.get(deliveryId)?.targetInstance });
|
|
707
|
+
}
|
|
417
708
|
if (result.outcome === "begun")
|
|
418
709
|
this.emit("state", { deliveryId, state: "submission_started" });
|
|
419
710
|
else if (result.failed)
|
|
@@ -490,6 +781,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
490
781
|
if (parent && (outcome === "failed" || outcome === "uncertain")) {
|
|
491
782
|
this.insertFailureNotice(parent, outcome, evidence ?? outcome, now);
|
|
492
783
|
}
|
|
784
|
+
if (parent && outcome === "delivered")
|
|
785
|
+
this.openReplyObligation(parent, now);
|
|
493
786
|
return { changed: true, accepted: true };
|
|
494
787
|
});
|
|
495
788
|
const result = transaction();
|
|
@@ -767,6 +1060,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
767
1060
|
`).run(attemptState, outcome === "retry_wait" ? now : null, terminal ? now : null, evidence.slice(0, 300), deliveryId, targetBootId, attemptNo);
|
|
768
1061
|
if (outcome === "uncertain")
|
|
769
1062
|
this.insertFailureNotice(parent, "uncertain", evidence, now);
|
|
1063
|
+
if (outcome === "delivered")
|
|
1064
|
+
this.openReplyObligation(parent, now);
|
|
770
1065
|
return true;
|
|
771
1066
|
});
|
|
772
1067
|
const changed = transaction();
|
|
@@ -790,7 +1085,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
790
1085
|
}
|
|
791
1086
|
/** Must be called inside the same SQLite transaction as the terminal transition. */
|
|
792
1087
|
insertFailureNotice(parent, outcome, reason, now) {
|
|
793
|
-
if (parent.kind === "delivery_outcome_notice" || parent.kind === "post_restart_outcome_notice"
|
|
1088
|
+
if (parent.kind === "delivery_outcome_notice" || parent.kind === "post_restart_outcome_notice"
|
|
1089
|
+
|| parent.kind === REPLY_OBLIGATION_NOTICE_KIND)
|
|
794
1090
|
return;
|
|
795
1091
|
const noticeId = randomUUID();
|
|
796
1092
|
const operationId = `notice:${parent.operation_id}:${parent.delivery_id}`;
|