agent-dealer 1.2.6 → 1.2.8
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/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +4 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +39 -3
- package/bundle/server/dist/coordinator/commands.js +90 -5
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +79 -1
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +17 -1
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +78 -2
- package/bundle/server/dist/coordinator/prompts.test.js +83 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.test.js +14 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/index.js +5 -0
- package/bundle/server/dist/db/schema.sql +3 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +29 -4
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-B6SVCzMR.js +64 -0
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/action-presentation.d.ts +51 -0
- package/bundle/shared/dist/action-presentation.js +280 -0
- package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
- package/bundle/shared/dist/action-presentation.test.js +180 -0
- package/bundle/shared/dist/attempt-waste.d.ts +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +11 -11
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +14 -14
- package/bundle/shared/dist/human-actions.js +9 -0
- package/bundle/shared/dist/index.d.ts +126 -124
- package/bundle/shared/dist/index.js +4 -0
- package/bundle/shared/dist/issues.d.ts +158 -28
- package/bundle/shared/dist/issues.js +8 -0
- package/bundle/shared/dist/issues.test.js +1 -0
- package/bundle/shared/dist/outbound-draft.d.ts +18 -18
- package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
- package/bundle/shared/dist/usage-events.d.ts +2 -2
- package/bundle/shared/dist/worker-sessions.d.ts +16 -6
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +6 -6
- package/bundle/shared/dist/workflow.js +10 -0
- package/bundle/shared/dist/workflow.test.js +2 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +0 -60
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// packages/server/src/coordinator/playbook-feedback.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-305: Dealer-side playbook-use receipts and failure/correction signals to Deck.
|
|
4
|
+
//
|
|
5
|
+
// Product boundary (NOT-162): Dealer owns worker/run correlation, durable workflow
|
|
6
|
+
// evidence, outcomes, review findings, human retry feedback, and the decision that a
|
|
7
|
+
// failure report exists. Deck owns feedback-signal storage, curation, playbook
|
|
8
|
+
// attribution, patch proposals, and accepted playbook content. Dealer never synthesizes
|
|
9
|
+
// a playbook edit and never attributes a failure to one playbook.
|
|
10
|
+
//
|
|
11
|
+
// Two durable artifacts, both idempotent across retries/restarts:
|
|
12
|
+
//
|
|
13
|
+
// - `playbook_use_receipt`: one per terminal worker session, naming exactly the
|
|
14
|
+
// playbooks Deck observed that session fetch (via the NOT-304 correlation seam),
|
|
15
|
+
// plus collection status/error. Never stores prompts, tool arguments/results,
|
|
16
|
+
// repository names, or issue descriptions — only the fields the design names.
|
|
17
|
+
// - `deck_feedback_signal`: one per failure/correction trigger (human retry,
|
|
18
|
+
// attempts exhaustion, recurring blocking finding), sent to Deck as a `signal_only`
|
|
19
|
+
// feedback record with a deterministic source key. Clean runs record receipts only.
|
|
20
|
+
import { parseProfileSnapshot } from "@agent-dealer/shared";
|
|
21
|
+
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
22
|
+
import { callDeckTool } from "../adapters/reflect-authority.js";
|
|
23
|
+
import { getIssue } from "../repository/issues.js";
|
|
24
|
+
import { getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
|
|
25
|
+
import { createIssueArtifact } from "../repository/artifacts.js";
|
|
26
|
+
import { listArtifactsForIssueByKind } from "../repository/artifacts-for-issue.js";
|
|
27
|
+
import { listFindingsForIssue } from "../repository/findings.js";
|
|
28
|
+
import { listHumanActionsForIssue } from "../repository/human-actions.js";
|
|
29
|
+
const CORRELATION_TOOL_TIMEOUT_MS = 15_000;
|
|
30
|
+
const SIGNAL_TOOL_TIMEOUT_MS = 15_000;
|
|
31
|
+
/**
|
|
32
|
+
* Bound, read-only Deck correlation operation (the NOT-304 seam): given the opaque
|
|
33
|
+
* per-session correlation UUID Dealer passed in the launch configuration, returns the
|
|
34
|
+
* playbooks Deck actually observed that session fetch. Dealer calls it; Deck owns the
|
|
35
|
+
* usage stream and the attribution.
|
|
36
|
+
*/
|
|
37
|
+
export const DECK_CORRELATION_TOOL = "list_card_usage_by_correlation";
|
|
38
|
+
export const PLAYBOOK_USE_RECEIPT_KIND = "playbook_use_receipt";
|
|
39
|
+
export const DECK_FEEDBACK_SIGNAL_KIND = "deck_feedback_signal";
|
|
40
|
+
const defaultDeps = {
|
|
41
|
+
checkHealth: checkAgentDeckHealth,
|
|
42
|
+
callTool: callDeckTool,
|
|
43
|
+
};
|
|
44
|
+
const TERMINAL_SESSION = new Set(["done", "failed", "timed_out", "cancelled"]);
|
|
45
|
+
function sessionDeckId(session) {
|
|
46
|
+
return parseProfileSnapshot(session.profileSnapshotJson)?.deckId ?? null;
|
|
47
|
+
}
|
|
48
|
+
function parseReceiptContent(contentJson) {
|
|
49
|
+
if (!contentJson)
|
|
50
|
+
return null;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(contentJson);
|
|
53
|
+
if (typeof parsed.workerSessionId !== "string" || !Array.isArray(parsed.playbookIds))
|
|
54
|
+
return null;
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Latest receipt recorded for one session — readers take the latest; a retry after an
|
|
62
|
+
* earlier `error`/`skipped` row appends a fresh row rather than rewriting history.
|
|
63
|
+
* (`listArtifactsForIssueByKind` orders newest-first, so the first match wins.) */
|
|
64
|
+
export function latestReceiptForSession(issueId, workerSessionId) {
|
|
65
|
+
const rows = listArtifactsForIssueByKind(issueId, PLAYBOOK_USE_RECEIPT_KIND);
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
const receipt = parseReceiptContent(row.contentJson);
|
|
68
|
+
if (receipt?.workerSessionId === workerSessionId)
|
|
69
|
+
return receipt;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Distinct error texts on the latest receipt of each worker session still in
|
|
75
|
+
* `error` — so the issue-level `reflect_status` can name the actual failure (Deck
|
|
76
|
+
* offline vs. malformed correlation response) instead of blaming the network for
|
|
77
|
+
* every receipt error.
|
|
78
|
+
*/
|
|
79
|
+
export function latestReceiptErrors(issueId) {
|
|
80
|
+
const errors = new Set();
|
|
81
|
+
for (const session of listWorkerSessionsForIssue(issueId)) {
|
|
82
|
+
const receipt = latestReceiptForSession(issueId, session.id);
|
|
83
|
+
if (receipt?.status === "error" && receipt.error)
|
|
84
|
+
errors.add(receipt.error);
|
|
85
|
+
}
|
|
86
|
+
return [...errors];
|
|
87
|
+
}
|
|
88
|
+
function recordReceipt(issueId, workerSessionId, receipt) {
|
|
89
|
+
createIssueArtifact({
|
|
90
|
+
issueId,
|
|
91
|
+
workerSessionId,
|
|
92
|
+
kind: PLAYBOOK_USE_RECEIPT_KIND,
|
|
93
|
+
author: "system",
|
|
94
|
+
content: receipt,
|
|
95
|
+
});
|
|
96
|
+
return receipt;
|
|
97
|
+
}
|
|
98
|
+
function asRecord(value) {
|
|
99
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
100
|
+
}
|
|
101
|
+
function asString(value) {
|
|
102
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parses the NOT-304 correlation response tolerantly on envelope naming (Deck owns the
|
|
106
|
+
* field names) but strictly on identity: every fetch must name a non-empty playbook ID
|
|
107
|
+
* string, otherwise the whole response is malformed — guessing IDs would corrupt the
|
|
108
|
+
* receipt. Returns null on malformed input.
|
|
109
|
+
*/
|
|
110
|
+
export function parseCorrelatedFetches(data) {
|
|
111
|
+
const root = asRecord(data);
|
|
112
|
+
if (!root)
|
|
113
|
+
return null;
|
|
114
|
+
const raw = root.fetches ?? root.usage ?? root.cards;
|
|
115
|
+
if (!Array.isArray(raw))
|
|
116
|
+
return null;
|
|
117
|
+
const fetches = [];
|
|
118
|
+
for (const entry of raw) {
|
|
119
|
+
const record = asRecord(entry);
|
|
120
|
+
if (!record)
|
|
121
|
+
return null;
|
|
122
|
+
const playbookId = asString(record.playbook_id) ?? asString(record.playbookId) ?? asString(record.id);
|
|
123
|
+
if (!playbookId)
|
|
124
|
+
return null;
|
|
125
|
+
fetches.push({
|
|
126
|
+
playbookId,
|
|
127
|
+
firstFetchedAt: asString(record.first_fetched_at) ??
|
|
128
|
+
asString(record.firstFetchedAt) ??
|
|
129
|
+
asString(record.fetched_at) ??
|
|
130
|
+
null,
|
|
131
|
+
lastFetchedAt: asString(record.last_fetched_at) ??
|
|
132
|
+
asString(record.lastFetchedAt) ??
|
|
133
|
+
asString(record.fetched_at) ??
|
|
134
|
+
null,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return fetches;
|
|
138
|
+
}
|
|
139
|
+
function summarizeFetches(fetches) {
|
|
140
|
+
const playbookIds = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
let first = null;
|
|
143
|
+
let last = null;
|
|
144
|
+
for (const fetch of fetches) {
|
|
145
|
+
if (!seen.has(fetch.playbookId)) {
|
|
146
|
+
seen.add(fetch.playbookId);
|
|
147
|
+
playbookIds.push(fetch.playbookId);
|
|
148
|
+
}
|
|
149
|
+
for (const stamp of [fetch.firstFetchedAt, fetch.lastFetchedAt]) {
|
|
150
|
+
if (!stamp)
|
|
151
|
+
continue;
|
|
152
|
+
if (first === null || stamp < first)
|
|
153
|
+
first = stamp;
|
|
154
|
+
if (last === null || stamp > last)
|
|
155
|
+
last = stamp;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { playbookIds, firstFetchedAt: first, lastFetchedAt: last };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Same-process guard against concurrent duplicate collection: the session-end probe
|
|
162
|
+
* in worker-loop.ts and the issue-level trigger both collect for the just-finished
|
|
163
|
+
* session, and without this the check-then-write below races into two `collected`
|
|
164
|
+
* rows. Concurrent callers for one session share a single collection. (Cross-process
|
|
165
|
+
* races remain possible — there is no unique constraint on receipt rows — so readers
|
|
166
|
+
* always take the latest row per session.)
|
|
167
|
+
*/
|
|
168
|
+
const receiptInflight = new Map();
|
|
169
|
+
export async function collectPlaybookUseReceipt(workerSessionId, deps = defaultDeps) {
|
|
170
|
+
const inflight = receiptInflight.get(workerSessionId);
|
|
171
|
+
if (inflight)
|
|
172
|
+
return inflight;
|
|
173
|
+
const task = collectPlaybookUseReceiptInner(workerSessionId, deps).finally(() => {
|
|
174
|
+
if (receiptInflight.get(workerSessionId) === task)
|
|
175
|
+
receiptInflight.delete(workerSessionId);
|
|
176
|
+
});
|
|
177
|
+
receiptInflight.set(workerSessionId, task);
|
|
178
|
+
return task;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Records the actual-use receipt for one worker session. Never throws: Deck outages
|
|
182
|
+
* and malformed responses become an `error`-status receipt (a visible record,
|
|
183
|
+
* re-attempted by a later reflection trigger), and non-terminal or unknown sessions
|
|
184
|
+
* return a reason without writing.
|
|
185
|
+
* Idempotent: a non-empty `collected` receipt is returned as-is; an `error`/`skipped`
|
|
186
|
+
* row — or a `collected` row naming zero playbooks — is re-attempted and superseded
|
|
187
|
+
* by a fresh row. An empty list collected at session end is provisional: Deck may
|
|
188
|
+
* ingest usage asynchronously after the probe, so freezing it would permanently hide
|
|
189
|
+
* playbooks fetched moments later; the issue-level trigger re-reads until use shows
|
|
190
|
+
* up (or re-confirms genuinely empty). A later trigger after a restart or Deck
|
|
191
|
+
* recovery heals the gap without duplicating non-empty collected evidence.
|
|
192
|
+
*/
|
|
193
|
+
async function collectPlaybookUseReceiptInner(workerSessionId, deps = defaultDeps) {
|
|
194
|
+
const session = getWorkerSession(workerSessionId);
|
|
195
|
+
if (!session)
|
|
196
|
+
return { collected: false, reason: "unknown worker session" };
|
|
197
|
+
if (!TERMINAL_SESSION.has(session.status)) {
|
|
198
|
+
return { collected: false, reason: "worker session is not terminal" };
|
|
199
|
+
}
|
|
200
|
+
const existing = latestReceiptForSession(session.issueId, workerSessionId);
|
|
201
|
+
if (existing?.status === "collected" && existing.playbookIds.length > 0) {
|
|
202
|
+
return { collected: true, receipt: existing, duplicate: true };
|
|
203
|
+
}
|
|
204
|
+
const deckId = sessionDeckId(session);
|
|
205
|
+
const base = {
|
|
206
|
+
workerSessionId,
|
|
207
|
+
role: session.role,
|
|
208
|
+
deckId,
|
|
209
|
+
correlationId: session.deckCorrelationId,
|
|
210
|
+
runtime: session.runtime,
|
|
211
|
+
model: session.model,
|
|
212
|
+
playbookIds: [],
|
|
213
|
+
firstFetchedAt: null,
|
|
214
|
+
lastFetchedAt: null,
|
|
215
|
+
status: "skipped",
|
|
216
|
+
};
|
|
217
|
+
if (!deckId) {
|
|
218
|
+
return {
|
|
219
|
+
collected: false,
|
|
220
|
+
reason: recordReceipt(session.issueId, workerSessionId, {
|
|
221
|
+
...base,
|
|
222
|
+
reason: "no deck in session profile snapshot — nothing to correlate",
|
|
223
|
+
}).reason,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!session.deckCorrelationId) {
|
|
227
|
+
return {
|
|
228
|
+
collected: false,
|
|
229
|
+
reason: recordReceipt(session.issueId, workerSessionId, {
|
|
230
|
+
...base,
|
|
231
|
+
reason: "no deck correlation id on worker session — nothing to correlate",
|
|
232
|
+
}).reason,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const healthy = await deps.checkHealth().catch(() => false);
|
|
236
|
+
if (!healthy) {
|
|
237
|
+
recordReceipt(session.issueId, workerSessionId, {
|
|
238
|
+
...base,
|
|
239
|
+
status: "error",
|
|
240
|
+
error: "Agent Deck offline — receipt collection resumes the next time issue reflection runs (a later completion trigger or an explicit reflection retry)",
|
|
241
|
+
});
|
|
242
|
+
return { collected: false, reason: "Agent Deck offline" };
|
|
243
|
+
}
|
|
244
|
+
const correlated = await deps.callTool({
|
|
245
|
+
deckId,
|
|
246
|
+
toolName: DECK_CORRELATION_TOOL,
|
|
247
|
+
arguments: { correlation_id: session.deckCorrelationId },
|
|
248
|
+
timeoutMs: CORRELATION_TOOL_TIMEOUT_MS,
|
|
249
|
+
});
|
|
250
|
+
if (!correlated.ok) {
|
|
251
|
+
recordReceipt(session.issueId, workerSessionId, {
|
|
252
|
+
...base,
|
|
253
|
+
status: "error",
|
|
254
|
+
error: correlated.reason,
|
|
255
|
+
});
|
|
256
|
+
return { collected: false, reason: correlated.reason };
|
|
257
|
+
}
|
|
258
|
+
const fetches = parseCorrelatedFetches(correlated.data);
|
|
259
|
+
if (!fetches) {
|
|
260
|
+
recordReceipt(session.issueId, workerSessionId, {
|
|
261
|
+
...base,
|
|
262
|
+
status: "error",
|
|
263
|
+
error: "malformed correlation response — no usable playbook fetch list",
|
|
264
|
+
});
|
|
265
|
+
return { collected: false, reason: "malformed correlation response" };
|
|
266
|
+
}
|
|
267
|
+
const summary = summarizeFetches(fetches);
|
|
268
|
+
const receipt = recordReceipt(session.issueId, workerSessionId, {
|
|
269
|
+
...base,
|
|
270
|
+
...summary,
|
|
271
|
+
status: "collected",
|
|
272
|
+
...(summary.playbookIds.length === 0 ? { reason: "no observed playbook use" } : {}),
|
|
273
|
+
});
|
|
274
|
+
return { collected: true, receipt, duplicate: false };
|
|
275
|
+
}
|
|
276
|
+
/** Collects receipts for every terminal session of the issue still missing one. */
|
|
277
|
+
export async function collectPlaybookUseReceiptsForIssue(issueId, deps = defaultDeps) {
|
|
278
|
+
let collected = 0;
|
|
279
|
+
let pending = 0;
|
|
280
|
+
let errors = 0;
|
|
281
|
+
for (const session of listWorkerSessionsForIssue(issueId)) {
|
|
282
|
+
if (!TERMINAL_SESSION.has(session.status))
|
|
283
|
+
continue;
|
|
284
|
+
const result = await collectPlaybookUseReceipt(session.id, deps);
|
|
285
|
+
if (result.collected && !result.duplicate)
|
|
286
|
+
collected++;
|
|
287
|
+
else if (!result.collected) {
|
|
288
|
+
pending++;
|
|
289
|
+
if (latestReceiptForSession(session.issueId, session.id)?.status === "error")
|
|
290
|
+
errors++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { collected, pending, errors };
|
|
294
|
+
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Failure/correction signals.
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
/** Human choices that correct or retry the work (as opposed to accepting/closing it). */
|
|
299
|
+
const CORRECTION_CHOICES = {
|
|
300
|
+
final_review: ["repair"],
|
|
301
|
+
attempts_exhausted: ["retry"],
|
|
302
|
+
policy_escalation: ["resume", "repair", "retry_merge"],
|
|
303
|
+
product_scope_decision: ["resume"],
|
|
304
|
+
deck_interaction_required: ["resume"],
|
|
305
|
+
};
|
|
306
|
+
function resolutionChoice(resolutionJson) {
|
|
307
|
+
if (!resolutionJson)
|
|
308
|
+
return null;
|
|
309
|
+
try {
|
|
310
|
+
const choice = JSON.parse(resolutionJson).choice;
|
|
311
|
+
return typeof choice === "string" ? choice : null;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function excerpt(text, max = 500) {
|
|
318
|
+
const trimmed = text?.trim();
|
|
319
|
+
if (!trimmed)
|
|
320
|
+
return null;
|
|
321
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Explicit fallback excerpt when no human stated a reason (recurring blocking
|
|
325
|
+
* findings, blank retry reasons). Deck rejects a missing/blank
|
|
326
|
+
* user_feedback_excerpt, so a report must never omit it. Constant text keeps
|
|
327
|
+
* repeated sends byte-identical for crash-window idempotency.
|
|
328
|
+
*/
|
|
329
|
+
export const NO_HUMAN_FEEDBACK_EXCERPT = "no human feedback — Dealer-observed failure (see failure_summary for observed evidence)";
|
|
330
|
+
/**
|
|
331
|
+
* Deck's `propose_playbook_patch` input schema (additionalProperties:false).
|
|
332
|
+
* Dealer may only send these top-level fields; the issue-centric reflection path
|
|
333
|
+
* sends `signal_only` with `kind` + `rationale` + `evidence` and never
|
|
334
|
+
* `playbook_id`/`ops` (no attribution, no synthesized edit).
|
|
335
|
+
*/
|
|
336
|
+
const DECK_SIGNAL_TOP_LEVEL_KEYS = new Set([
|
|
337
|
+
"kind",
|
|
338
|
+
"rationale",
|
|
339
|
+
"evidence",
|
|
340
|
+
"signal_ids",
|
|
341
|
+
"supersedes",
|
|
342
|
+
"ops",
|
|
343
|
+
"playbook_id",
|
|
344
|
+
"new_playbook",
|
|
345
|
+
]);
|
|
346
|
+
/** Returns null when args satisfy Deck's schema; otherwise a human-readable reason. */
|
|
347
|
+
export function validateSignalOnlyArgs(args) {
|
|
348
|
+
for (const key of Object.keys(args)) {
|
|
349
|
+
if (!DECK_SIGNAL_TOP_LEVEL_KEYS.has(key))
|
|
350
|
+
return `unknown top-level field: ${key}`;
|
|
351
|
+
}
|
|
352
|
+
if (args.kind !== "signal_only")
|
|
353
|
+
return `kind must be "signal_only"`;
|
|
354
|
+
if (typeof args.rationale !== "string" || !args.rationale.trim()) {
|
|
355
|
+
return "rationale must be a non-empty string";
|
|
356
|
+
}
|
|
357
|
+
const evidence = asRecord(args.evidence);
|
|
358
|
+
if (!evidence)
|
|
359
|
+
return "evidence must be an object";
|
|
360
|
+
for (const key of Object.keys(evidence)) {
|
|
361
|
+
if (key !== "failure_summary" && key !== "user_feedback_excerpt" && key !== "corrected_output_hint") {
|
|
362
|
+
return `unknown evidence field: ${key}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (typeof evidence.failure_summary !== "string" || !evidence.failure_summary.trim()) {
|
|
366
|
+
return "evidence.failure_summary must be a non-empty string";
|
|
367
|
+
}
|
|
368
|
+
// Deck's schema (shared PatchEvidenceContent) requires a non-empty
|
|
369
|
+
// user_feedback_excerpt on every report — recurring_blocking and blank-reason
|
|
370
|
+
// reports included. corrected_output_hint stays optional.
|
|
371
|
+
if (typeof evidence.user_feedback_excerpt !== "string" || !evidence.user_feedback_excerpt.trim()) {
|
|
372
|
+
return "evidence.user_feedback_excerpt must be a non-empty string";
|
|
373
|
+
}
|
|
374
|
+
if ("corrected_output_hint" in evidence &&
|
|
375
|
+
(typeof evidence.corrected_output_hint !== "string" || !evidence.corrected_output_hint.trim())) {
|
|
376
|
+
return "evidence.corrected_output_hint must be a non-empty string when present";
|
|
377
|
+
}
|
|
378
|
+
if ("playbook_id" in args || "ops" in args)
|
|
379
|
+
return "signal_only must not carry playbook_id or ops";
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Builds the Deck-stored `signal_only` payload. Every Dealer-owned reference the
|
|
384
|
+
* inbox needs — deterministic source key, dealer issue/session refs, observed
|
|
385
|
+
* playbook use (or an explicit `no observed playbook use` line) — lives inside
|
|
386
|
+
* Deck-stored fields (`rationale` + `evidence.failure_summary`), because Deck's
|
|
387
|
+
* schema drops anything else. The source key is embedded verbatim so a repeated
|
|
388
|
+
* send after a crash between the Deck call and the local artifact write is
|
|
389
|
+
* recognizable as the same report.
|
|
390
|
+
*/
|
|
391
|
+
export function buildSignalOnlyArgs(candidate, opts) {
|
|
392
|
+
const baseRationale = candidate.trigger === "human_retry"
|
|
393
|
+
? "Human correction during Dealer review — reported once as supporting evidence, without attributing the failure to any playbook."
|
|
394
|
+
: candidate.trigger === "attempts_exhausted"
|
|
395
|
+
? "Dealer review attempts were exhausted — reported once as supporting evidence, without attributing the failure to any playbook."
|
|
396
|
+
: "A blocking review finding recurred across rounds — reported once as supporting evidence, without attributing the failure to any playbook.";
|
|
397
|
+
const sessionsLine = opts.workerSessionIds.length > 0 ? opts.workerSessionIds.join(", ") : "none";
|
|
398
|
+
const playbooksLine = opts.playbookIds.length > 0 ? opts.playbookIds.join(", ") : "no observed playbook use";
|
|
399
|
+
// Deck requires user_feedback_excerpt on every report. Prefer the human's own
|
|
400
|
+
// words; otherwise the finding's rationale/title excerpt the candidate already
|
|
401
|
+
// selected; otherwise an explicit no-feedback line (never omitted, never blank).
|
|
402
|
+
const userFeedbackExcerpt = candidate.userFeedback?.trim() ? candidate.userFeedback : NO_HUMAN_FEEDBACK_EXCERPT;
|
|
403
|
+
return {
|
|
404
|
+
kind: "signal_only",
|
|
405
|
+
rationale: `${baseRationale} ` +
|
|
406
|
+
`Dealer source ${candidate.sourceKey} | trigger ${candidate.trigger} | ` +
|
|
407
|
+
`dealer issue ${opts.issueId} | worker sessions ${sessionsLine}.`,
|
|
408
|
+
evidence: {
|
|
409
|
+
failure_summary: `${candidate.failure}\n\n` +
|
|
410
|
+
`Dealer issue: ${opts.issueId}\n` +
|
|
411
|
+
`Worker sessions: ${sessionsLine}\n` +
|
|
412
|
+
`Playbooks observed in use: ${playbooksLine}\n` +
|
|
413
|
+
`Source key: ${candidate.sourceKey}`,
|
|
414
|
+
user_feedback_excerpt: userFeedbackExcerpt,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/** Deterministic source key — the cross-restart idempotency identity of one report. */
|
|
419
|
+
export function signalSourceKey(issueId, trigger, key) {
|
|
420
|
+
return `dealer:${issueId}:${trigger}:${key}`;
|
|
421
|
+
}
|
|
422
|
+
function humanRetryCandidates(issueId, alreadyReferencedActionIds) {
|
|
423
|
+
const candidates = [];
|
|
424
|
+
for (const action of listHumanActionsForIssue(issueId)) {
|
|
425
|
+
if (action.status !== "resolved")
|
|
426
|
+
continue;
|
|
427
|
+
// One action produces at most one signal, whichever trigger claims it first — a
|
|
428
|
+
// meaningful failure/correction appears once in Deck's feedback inbox, never once
|
|
429
|
+
// per trigger that describes the same human decision.
|
|
430
|
+
if (alreadyReferencedActionIds.has(action.id))
|
|
431
|
+
continue;
|
|
432
|
+
const choice = resolutionChoice(action.resolutionJson);
|
|
433
|
+
if (!choice)
|
|
434
|
+
continue;
|
|
435
|
+
if (!CORRECTION_CHOICES[action.actionType]?.includes(choice))
|
|
436
|
+
continue;
|
|
437
|
+
const failureParts = [
|
|
438
|
+
`Human ${choice} on ${action.actionType} (action ${action.id}, resolved by ${action.resolvedBy ?? "unknown"}).`,
|
|
439
|
+
];
|
|
440
|
+
const reason = excerpt(action.reason);
|
|
441
|
+
if (reason)
|
|
442
|
+
failureParts.push(`Stated reason: ${reason}`);
|
|
443
|
+
const question = excerpt(action.question);
|
|
444
|
+
if (question)
|
|
445
|
+
failureParts.push(`Asked: ${question}`);
|
|
446
|
+
candidates.push({
|
|
447
|
+
trigger: "human_retry",
|
|
448
|
+
sourceKey: signalSourceKey(issueId, "human_retry", action.id),
|
|
449
|
+
humanActionId: action.id,
|
|
450
|
+
failure: failureParts.join(" "),
|
|
451
|
+
userFeedback: reason,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return candidates;
|
|
455
|
+
}
|
|
456
|
+
function attemptsExhaustedCandidates(issueId, alreadyReferencedActionIds) {
|
|
457
|
+
const candidates = [];
|
|
458
|
+
for (const action of listHumanActionsForIssue(issueId)) {
|
|
459
|
+
if (action.actionType !== "attempts_exhausted")
|
|
460
|
+
continue;
|
|
461
|
+
// A resolved attempts_exhausted:retry is already reported as a human_retry above —
|
|
462
|
+
// one action produces at most one signal, whichever trigger claims it first.
|
|
463
|
+
if (alreadyReferencedActionIds.has(action.id))
|
|
464
|
+
continue;
|
|
465
|
+
const choice = action.status === "resolved" ? resolutionChoice(action.resolutionJson) : null;
|
|
466
|
+
candidates.push({
|
|
467
|
+
trigger: "attempts_exhausted",
|
|
468
|
+
sourceKey: signalSourceKey(issueId, "attempts_exhausted", action.id),
|
|
469
|
+
humanActionId: action.id,
|
|
470
|
+
failure: `Review attempts were exhausted (action ${action.id}, ` +
|
|
471
|
+
`${action.status === "resolved" ? `human chose ${choice ?? "unknown"}` : "awaiting human decision"}). ` +
|
|
472
|
+
`Stated reason: ${excerpt(action.reason) ?? "(none recorded)"}`,
|
|
473
|
+
userFeedback: excerpt(action.reason),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return candidates;
|
|
477
|
+
}
|
|
478
|
+
function recurringBlockingCandidate(issueId) {
|
|
479
|
+
const recurring = listFindingsForIssue(issueId).filter((f) => f.status === "recurring" && f.severity === "blocking");
|
|
480
|
+
if (recurring.length === 0)
|
|
481
|
+
return null;
|
|
482
|
+
const lines = recurring.map((f) => `- ${f.fingerprint} (rounds ${f.firstRound}–${f.lastRound}): ${excerpt(f.title, 200) ?? "(untitled)"}`);
|
|
483
|
+
// No human stated this failure — the closest thing to a feedback excerpt is the
|
|
484
|
+
// recurring finding's own rationale/title. When even that is blank the builder
|
|
485
|
+
// falls back to the explicit no-feedback line (Deck still requires the field).
|
|
486
|
+
const findingFeedback = recurring
|
|
487
|
+
.map((f) => excerpt(f.rationale) ?? excerpt(f.title, 200))
|
|
488
|
+
.find((text) => text !== null) ?? null;
|
|
489
|
+
return {
|
|
490
|
+
trigger: "recurring_blocking",
|
|
491
|
+
sourceKey: signalSourceKey(issueId, "recurring_blocking", "issue"),
|
|
492
|
+
humanActionId: null,
|
|
493
|
+
failure: `${recurring.length} blocking finding(s) recurred across review rounds:\n${lines.join("\n")}`,
|
|
494
|
+
userFeedback: findingFeedback,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function existingSignalKeys(issueId) {
|
|
498
|
+
const sentKeys = new Set();
|
|
499
|
+
const pendingKeys = new Set();
|
|
500
|
+
const actionIds = new Set();
|
|
501
|
+
for (const artifact of listArtifactsForIssueByKind(issueId, DECK_FEEDBACK_SIGNAL_KIND)) {
|
|
502
|
+
try {
|
|
503
|
+
const content = JSON.parse(artifact.contentJson ?? "{}");
|
|
504
|
+
if (typeof content.sourceKey === "string") {
|
|
505
|
+
// Pre-intent rows (written before the pending/sent split) carry
|
|
506
|
+
// status "sent" or no status at all — both count as delivered.
|
|
507
|
+
if (content.status === "pending" || content.status === "sending") {
|
|
508
|
+
pendingKeys.add(content.sourceKey);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
sentKeys.add(content.sourceKey);
|
|
512
|
+
// Only a delivered report claims its human action. A pending intent
|
|
513
|
+
// reserves the source key (no second pending row) but must not
|
|
514
|
+
// suppress the candidate — otherwise the retry the intent exists to
|
|
515
|
+
// enable would never be generated.
|
|
516
|
+
if (typeof content.humanActionId === "string")
|
|
517
|
+
actionIds.add(content.humanActionId);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// malformed content — nothing to dedupe on its account
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return { sentKeys, pendingKeys, actionIds };
|
|
526
|
+
}
|
|
527
|
+
function recordSignalIntent(issueId, candidate, opts) {
|
|
528
|
+
createIssueArtifact({
|
|
529
|
+
issueId,
|
|
530
|
+
kind: DECK_FEEDBACK_SIGNAL_KIND,
|
|
531
|
+
author: "system",
|
|
532
|
+
content: {
|
|
533
|
+
sourceKey: candidate.sourceKey,
|
|
534
|
+
trigger: candidate.trigger,
|
|
535
|
+
humanActionId: candidate.humanActionId,
|
|
536
|
+
signalId: null,
|
|
537
|
+
deckId: opts.deckId,
|
|
538
|
+
workerSessionIds: opts.workerSessionIds,
|
|
539
|
+
playbookIds: opts.playbookIds,
|
|
540
|
+
observedPlaybookUse: opts.playbookIds.length > 0 ? "observed" : "none",
|
|
541
|
+
failure: candidate.failure,
|
|
542
|
+
status: "pending",
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
function recordStatus(issueId, content) {
|
|
547
|
+
createIssueArtifact({ issueId, kind: "reflect_status", author: "system", content });
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Sends one idempotent `signal_only` Deck report per failure/correction trigger found
|
|
551
|
+
* in the issue's durable state. Never throws and never changes the issue outcome: a
|
|
552
|
+
* Deck outage (or malformed response) records a visible, retryable `reflect_status`
|
|
553
|
+
* and preserves whatever state the issue already reached.
|
|
554
|
+
*
|
|
555
|
+
* A clean run — no human correction, no attempts exhaustion, no recurring blocking
|
|
556
|
+
* finding — sends nothing: receipts alone are the record.
|
|
557
|
+
*/
|
|
558
|
+
export async function reportDeckFailureSignals(issueId, deps = defaultDeps) {
|
|
559
|
+
const issue = getIssue(issueId);
|
|
560
|
+
if (!issue)
|
|
561
|
+
return { sent: [], skipped: [], error: "unknown issue" };
|
|
562
|
+
const { sentKeys, pendingKeys, actionIds: signalledActions } = existingSignalKeys(issueId);
|
|
563
|
+
const recurring = recurringBlockingCandidate(issueId);
|
|
564
|
+
const allCandidates = [
|
|
565
|
+
...humanRetryCandidates(issueId, signalledActions),
|
|
566
|
+
...attemptsExhaustedCandidates(issueId, signalledActions),
|
|
567
|
+
...(recurring ? [recurring] : []),
|
|
568
|
+
];
|
|
569
|
+
const candidates = allCandidates.filter((c) => !sentKeys.has(c.sourceKey));
|
|
570
|
+
const skipped = allCandidates.filter((c) => sentKeys.has(c.sourceKey)).map((c) => c.sourceKey);
|
|
571
|
+
if (candidates.length === 0)
|
|
572
|
+
return { sent: [], skipped };
|
|
573
|
+
// Supporting evidence is actual use only: the union of collected receipts. No receipt
|
|
574
|
+
// means the report says `no observed playbook use` — never legacy Agent configuration.
|
|
575
|
+
const playbookIds = [];
|
|
576
|
+
const seen = new Set();
|
|
577
|
+
for (const artifact of listArtifactsForIssueByKind(issueId, PLAYBOOK_USE_RECEIPT_KIND)) {
|
|
578
|
+
const receipt = parseReceiptContent(artifact.contentJson);
|
|
579
|
+
if (receipt?.status !== "collected")
|
|
580
|
+
continue;
|
|
581
|
+
for (const id of receipt.playbookIds) {
|
|
582
|
+
if (!seen.has(id)) {
|
|
583
|
+
seen.add(id);
|
|
584
|
+
playbookIds.push(id);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const workerSessionIds = listWorkerSessionsForIssue(issueId)
|
|
589
|
+
.filter((s) => TERMINAL_SESSION.has(s.status))
|
|
590
|
+
.map((s) => s.id);
|
|
591
|
+
// The deck the sessions actually ran with (frozen snapshots) — no deck, no send.
|
|
592
|
+
const deckId = [...listWorkerSessionsForIssue(issueId)]
|
|
593
|
+
.reverse()
|
|
594
|
+
.map(sessionDeckId)
|
|
595
|
+
.find((id) => id !== null) ?? null;
|
|
596
|
+
if (!deckId) {
|
|
597
|
+
recordStatus(issueId, {
|
|
598
|
+
status: "skipped",
|
|
599
|
+
reason: "no deck in worker session snapshots — failure signals have nowhere to go",
|
|
600
|
+
pendingSignals: candidates.map((c) => c.sourceKey),
|
|
601
|
+
});
|
|
602
|
+
return { sent: [], skipped, error: "no deck in worker session snapshots" };
|
|
603
|
+
}
|
|
604
|
+
const healthy = await deps.checkHealth().catch(() => false);
|
|
605
|
+
if (!healthy) {
|
|
606
|
+
recordStatus(issueId, {
|
|
607
|
+
status: "failed",
|
|
608
|
+
reason: "Agent Deck offline — failure signals resume the next time issue reflection runs (a later completion trigger or an explicit reflection retry)",
|
|
609
|
+
pendingSignals: candidates.map((c) => c.sourceKey),
|
|
610
|
+
});
|
|
611
|
+
return { sent: [], skipped, error: "Agent Deck offline" };
|
|
612
|
+
}
|
|
613
|
+
const sent = [];
|
|
614
|
+
let anyFailed = false;
|
|
615
|
+
for (const candidate of candidates) {
|
|
616
|
+
// signal_only: a feedback record, never a playbook edit. Only Deck-schema
|
|
617
|
+
// fields are sent (kind/rationale/evidence) — no playbook_id (no single
|
|
618
|
+
// attribution), no ops (no synthesized edit), and no Dealer-only top-level
|
|
619
|
+
// keys. All Dealer refs (source key, issue/session refs, observed playbook
|
|
620
|
+
// use) live inside the Deck-stored rationale/evidence fields.
|
|
621
|
+
//
|
|
622
|
+
// Crash safety: the pending intent row is written BEFORE the Deck call, so a
|
|
623
|
+
// restart between the call and the sent row reconciles to the same source
|
|
624
|
+
// key (embedded verbatim in the Deck-stored payload) instead of minting a
|
|
625
|
+
// second report. A pending row without a sent row is retried, never duplicated
|
|
626
|
+
// locally; a repeated send after that window carries the identical key.
|
|
627
|
+
//
|
|
628
|
+
// Residual risk: if the process dies after Deck ACCEPTED the report but before
|
|
629
|
+
// the sent row is written, the retry re-sends and Deck stores a duplicate —
|
|
630
|
+
// Deck does not dedupe on the embedded source key. True exactly-once needs a
|
|
631
|
+
// Deck-side idempotency key on propose_playbook_patch (follow-up against Deck,
|
|
632
|
+
// not Dealer); until then the identical key lets a human recognize the repeat
|
|
633
|
+
// as the same report.
|
|
634
|
+
if (!pendingKeys.has(candidate.sourceKey)) {
|
|
635
|
+
recordSignalIntent(issueId, candidate, { deckId, workerSessionIds, playbookIds });
|
|
636
|
+
pendingKeys.add(candidate.sourceKey);
|
|
637
|
+
}
|
|
638
|
+
const signalArgs = buildSignalOnlyArgs(candidate, { issueId, workerSessionIds, playbookIds });
|
|
639
|
+
const schemaError = validateSignalOnlyArgs(signalArgs);
|
|
640
|
+
if (schemaError) {
|
|
641
|
+
anyFailed = true;
|
|
642
|
+
recordStatus(issueId, {
|
|
643
|
+
status: "failed",
|
|
644
|
+
sourceKey: candidate.sourceKey,
|
|
645
|
+
error: `signal payload rejected by Dealer-side Deck schema check: ${schemaError}`,
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const proposed = await deps.callTool({
|
|
650
|
+
deckId,
|
|
651
|
+
toolName: "propose_playbook_patch",
|
|
652
|
+
arguments: signalArgs,
|
|
653
|
+
timeoutMs: SIGNAL_TOOL_TIMEOUT_MS,
|
|
654
|
+
});
|
|
655
|
+
if (!proposed.ok) {
|
|
656
|
+
anyFailed = true;
|
|
657
|
+
recordStatus(issueId, {
|
|
658
|
+
status: "failed",
|
|
659
|
+
sourceKey: candidate.sourceKey,
|
|
660
|
+
error: proposed.reason,
|
|
661
|
+
});
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const signalId = typeof proposed.data?.id === "string" ? proposed.data.id : null;
|
|
665
|
+
createIssueArtifact({
|
|
666
|
+
issueId,
|
|
667
|
+
kind: DECK_FEEDBACK_SIGNAL_KIND,
|
|
668
|
+
author: "system",
|
|
669
|
+
content: {
|
|
670
|
+
sourceKey: candidate.sourceKey,
|
|
671
|
+
trigger: candidate.trigger,
|
|
672
|
+
humanActionId: candidate.humanActionId,
|
|
673
|
+
signalId,
|
|
674
|
+
deckId,
|
|
675
|
+
workerSessionIds,
|
|
676
|
+
playbookIds,
|
|
677
|
+
observedPlaybookUse: playbookIds.length > 0 ? "observed" : "none",
|
|
678
|
+
failure: candidate.failure,
|
|
679
|
+
status: "sent",
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
sent.push({ sourceKey: candidate.sourceKey, trigger: candidate.trigger, signalId });
|
|
683
|
+
}
|
|
684
|
+
recordStatus(issueId, {
|
|
685
|
+
status: anyFailed ? (sent.length > 0 ? "partial" : "failed") : "completed",
|
|
686
|
+
signalsSent: sent.map((s) => s.sourceKey),
|
|
687
|
+
playbookCount: playbookIds.length,
|
|
688
|
+
});
|
|
689
|
+
return { sent, skipped };
|
|
690
|
+
}
|