@flowdesk/opencode-plugin 0.1.11 → 0.1.12
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/agent-task-runner.d.ts +26 -0
- package/dist/agent-task-runner.d.ts.map +1 -0
- package/dist/agent-task-runner.js +244 -0
- package/dist/agent-task-runner.js.map +1 -0
- package/dist/bootstrap-installer.d.ts +3 -0
- package/dist/bootstrap-installer.d.ts.map +1 -1
- package/dist/bootstrap-installer.js +153 -7
- package/dist/bootstrap-installer.js.map +1 -1
- package/dist/command-handlers.d.ts +3 -0
- package/dist/command-handlers.d.ts.map +1 -1
- package/dist/command-handlers.js +38 -4
- package/dist/command-handlers.js.map +1 -1
- package/dist/local-adapter.d.ts.map +1 -1
- package/dist/local-adapter.js +19 -0
- package/dist/local-adapter.js.map +1 -1
- package/dist/managed-dispatch-adapter.d.ts +2 -0
- package/dist/managed-dispatch-adapter.d.ts.map +1 -1
- package/dist/managed-dispatch-adapter.js +86 -1
- package/dist/managed-dispatch-adapter.js.map +1 -1
- package/dist/quick-reviewer-run.d.ts +13 -2
- package/dist/quick-reviewer-run.d.ts.map +1 -1
- package/dist/quick-reviewer-run.js +213 -69
- package/dist/quick-reviewer-run.js.map +1 -1
- package/dist/runtime-reviewer-execution-bridge.d.ts.map +1 -1
- package/dist/runtime-reviewer-execution-bridge.js +46 -1
- package/dist/runtime-reviewer-execution-bridge.js.map +1 -1
- package/dist/server.d.ts +47 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +467 -59
- package/dist/server.js.map +1 -1
- package/dist/shared/with-timeout.d.ts +12 -0
- package/dist/shared/with-timeout.d.ts.map +1 -0
- package/dist/shared/with-timeout.js +31 -0
- package/dist/shared/with-timeout.js.map +1 -0
- package/dist/stall-recovery.d.ts +187 -0
- package/dist/stall-recovery.d.ts.map +1 -0
- package/dist/stall-recovery.js +962 -0
- package/dist/stall-recovery.js.map +1 -0
- package/dist/status-live-tool.d.ts +1 -0
- package/dist/status-live-tool.d.ts.map +1 -1
- package/dist/status-live-tool.js +128 -1
- package/dist/status-live-tool.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
import { applyFlowDeskSessionEvidenceWriteIntentsV1, prepareFlowDeskSessionEvidenceWriteIntentV1, reloadFlowDeskSessionEvidenceV1, projectFlowDeskLaneStallV1, } from "@flowdesk/core";
|
|
2
|
+
import { createHmac, createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1, } from "./managed-dispatch-adapter.js";
|
|
6
|
+
import { FLOWDESK_TIMEOUT_DEFAULTS, FlowDeskTimeoutError, withTimeout, } from "./shared/with-timeout.js";
|
|
7
|
+
export async function checkSdkSessionApiHealthV1(client, sessionId, timeouts = {}) {
|
|
8
|
+
if (typeof client.session.messages !== "function") {
|
|
9
|
+
return { status: "unknown", reason: "sdk_messages_not_available" };
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
await withTimeout(client.session.messages({ path: { id: sessionId } }), timeouts.sessionReadMs ?? FLOWDESK_TIMEOUT_DEFAULTS.sessionReadMs, "session.messages");
|
|
13
|
+
return { status: "api_responsive" };
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof FlowDeskTimeoutError) {
|
|
17
|
+
return {
|
|
18
|
+
status: "api_timeout",
|
|
19
|
+
reason: "messages_api_did_not_respond_within_threshold",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { status: "unknown", reason: "messages_api_error" };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const FLOWDESK_ABORT_WORKFLOW_PREFIXES = [
|
|
26
|
+
"workflow-quick-reviewer-",
|
|
27
|
+
"workflow-quick-fallback-",
|
|
28
|
+
"workflow-stall-recovery-",
|
|
29
|
+
"workflow-provider-usage-",
|
|
30
|
+
];
|
|
31
|
+
const TERMINAL_LANE_STATES = new Set([
|
|
32
|
+
"complete",
|
|
33
|
+
"incomplete",
|
|
34
|
+
"no_output",
|
|
35
|
+
"missing_verdict",
|
|
36
|
+
"tool_calls_only_no_verdict",
|
|
37
|
+
"aborted",
|
|
38
|
+
"invocation_failed",
|
|
39
|
+
"timeout",
|
|
40
|
+
"late_output",
|
|
41
|
+
"orphaned",
|
|
42
|
+
]);
|
|
43
|
+
const ABORT_ELIGIBLE_LANE_STATES = new Set(["created", "running"]);
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
function isLaneLifecycleRecord(value) {
|
|
48
|
+
return isRecord(value) && value.schema_version === "flowdesk.lane_lifecycle_record.v1";
|
|
49
|
+
}
|
|
50
|
+
function workflowPrefixAllowed(workflowId) {
|
|
51
|
+
return FLOWDESK_ABORT_WORKFLOW_PREFIXES.some((prefix) => workflowId.startsWith(prefix));
|
|
52
|
+
}
|
|
53
|
+
function latestLifecycle(records) {
|
|
54
|
+
return [...records].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())[0];
|
|
55
|
+
}
|
|
56
|
+
function canonicalJson(value) {
|
|
57
|
+
if (value === null)
|
|
58
|
+
return "null";
|
|
59
|
+
if (typeof value === "string" || typeof value === "boolean")
|
|
60
|
+
return JSON.stringify(value);
|
|
61
|
+
if (typeof value === "number") {
|
|
62
|
+
if (!Number.isFinite(value))
|
|
63
|
+
throw new Error("non-finite canonical JSON number");
|
|
64
|
+
return JSON.stringify(value);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
68
|
+
if (isRecord(value)) {
|
|
69
|
+
return `{${Object.keys(value)
|
|
70
|
+
.sort()
|
|
71
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
72
|
+
.join(",")}}`;
|
|
73
|
+
}
|
|
74
|
+
throw new Error("unsupported canonical JSON value");
|
|
75
|
+
}
|
|
76
|
+
function sha256Hex(text) {
|
|
77
|
+
return createHash("sha256").update(text.replaceAll("\r\n", "\n"), "utf8").digest("hex");
|
|
78
|
+
}
|
|
79
|
+
function safeToken(value) {
|
|
80
|
+
const token = value.replaceAll(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 80);
|
|
81
|
+
return token.length > 0 ? token : "unknown";
|
|
82
|
+
}
|
|
83
|
+
function timestampToken(date) {
|
|
84
|
+
return date.toISOString().replace(/[^0-9A-Za-z]/g, "");
|
|
85
|
+
}
|
|
86
|
+
function isGuardSignOff(value) {
|
|
87
|
+
return (isRecord(value) &&
|
|
88
|
+
value.schema_version === "flowdesk.guard_sign_off.v1" &&
|
|
89
|
+
typeof value.sign_off_id === "string" &&
|
|
90
|
+
typeof value.created_at === "string" &&
|
|
91
|
+
typeof value.target_markdown_sha256 === "string" &&
|
|
92
|
+
typeof value.p6_safe === "boolean" &&
|
|
93
|
+
typeof value.nonce === "string" &&
|
|
94
|
+
typeof value.hmac_sha256 === "string" &&
|
|
95
|
+
(value.expires_at === undefined || typeof value.expires_at === "string") &&
|
|
96
|
+
value.dispatch_authority_enabled === false);
|
|
97
|
+
}
|
|
98
|
+
export function verifyGuardSignOffHmacV1(input) {
|
|
99
|
+
if (!isGuardSignOff(input.signOff))
|
|
100
|
+
return { ok: false, reason: "guard_sign_off_schema_invalid" };
|
|
101
|
+
if (typeof input.hmacKey !== "string" || input.hmacKey.length < 16)
|
|
102
|
+
return { ok: false, reason: "guard_hmac_key_missing" };
|
|
103
|
+
if (input.signOff.p6_safe !== true)
|
|
104
|
+
return { ok: false, reason: "guard_sign_off_not_p6_safe" };
|
|
105
|
+
if (input.signOff.expires_at !== undefined) {
|
|
106
|
+
const expiresAtMs = Date.parse(input.signOff.expires_at);
|
|
107
|
+
if (!Number.isFinite(expiresAtMs))
|
|
108
|
+
return { ok: false, reason: "guard_sign_off_expiry_invalid" };
|
|
109
|
+
if ((input.now ?? new Date()).getTime() >= expiresAtMs)
|
|
110
|
+
return { ok: false, reason: "guard_sign_off_expired" };
|
|
111
|
+
}
|
|
112
|
+
if (input.signOff.target_markdown_sha256 !== sha256Hex(input.markdownText))
|
|
113
|
+
return { ok: false, reason: "guard_sign_off_markdown_digest_mismatch" };
|
|
114
|
+
const { hmac_sha256: _hmac, ...unsigned } = input.signOff;
|
|
115
|
+
const expected = createHmac("sha256", input.hmacKey)
|
|
116
|
+
.update(canonicalJson(unsigned), "utf8")
|
|
117
|
+
.digest("hex");
|
|
118
|
+
const actualBuffer = Buffer.from(input.signOff.hmac_sha256, "hex");
|
|
119
|
+
const expectedBuffer = Buffer.from(expected, "hex");
|
|
120
|
+
if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer))
|
|
121
|
+
return { ok: false, reason: "guard_sign_off_hmac_mismatch" };
|
|
122
|
+
return { ok: true, signOff: input.signOff };
|
|
123
|
+
}
|
|
124
|
+
export function computeGuardSignOffHmacV1(input) {
|
|
125
|
+
return createHmac("sha256", input.hmacKey)
|
|
126
|
+
.update(canonicalJson(input.unsignedSignOff), "utf8")
|
|
127
|
+
.digest("hex");
|
|
128
|
+
}
|
|
129
|
+
function loadGuardSignOffFromRoot(rootDir, signOffPath) {
|
|
130
|
+
const sidecarPath = signOffPath ?? join(rootDir, "docs/adr/0002-sdk-surface-verification.guard_sign_off.json");
|
|
131
|
+
if (!existsSync(sidecarPath))
|
|
132
|
+
return undefined;
|
|
133
|
+
const signOff = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
134
|
+
const markdownPath = sidecarPath.replace(/\.guard_sign_off\.json$/, ".md");
|
|
135
|
+
if (!existsSync(markdownPath))
|
|
136
|
+
return undefined;
|
|
137
|
+
return { signOff, markdownText: readFileSync(markdownPath, "utf8") };
|
|
138
|
+
}
|
|
139
|
+
export function isAutoAbortEnabledV1(input) {
|
|
140
|
+
if (input.config.autoAbortOnStall !== true)
|
|
141
|
+
return { enabled: false, reason: "auto_abort_not_configured" };
|
|
142
|
+
const keyFromConfig = input.config.guardHmacKey;
|
|
143
|
+
const keyFromEnv = input.env?.FLOWDESK_GUARD_HMAC_KEY ?? process.env.FLOWDESK_GUARD_HMAC_KEY;
|
|
144
|
+
if (input.config.productionMode === true && keyFromConfig === undefined && keyFromEnv !== undefined)
|
|
145
|
+
return { enabled: false, reason: "env_guard_hmac_key_rejected_in_production" };
|
|
146
|
+
const loaded = input.loadedSignOff ?? loadGuardSignOffFromRoot(input.rootDir, input.config.guardSignOffPath);
|
|
147
|
+
if (loaded === undefined)
|
|
148
|
+
return { enabled: false, reason: "guard_sign_off_missing" };
|
|
149
|
+
const verified = verifyGuardSignOffHmacV1({
|
|
150
|
+
signOff: loaded.signOff,
|
|
151
|
+
markdownText: loaded.markdownText,
|
|
152
|
+
hmacKey: keyFromConfig ?? keyFromEnv,
|
|
153
|
+
now: input.now,
|
|
154
|
+
});
|
|
155
|
+
if (!verified.ok)
|
|
156
|
+
return { enabled: false, reason: verified.reason };
|
|
157
|
+
return { enabled: true, sign_off_id: verified.signOff.sign_off_id };
|
|
158
|
+
}
|
|
159
|
+
function isPendingAbortWarning(value) {
|
|
160
|
+
return isRecord(value) && value.schema_version === "flowdesk.pending_abort_warning.v1";
|
|
161
|
+
}
|
|
162
|
+
function isPendingAbortCancel(value) {
|
|
163
|
+
return isRecord(value) && value.schema_version === "flowdesk.pending_abort_cancel.v1";
|
|
164
|
+
}
|
|
165
|
+
function latestPendingAbortWarning(records) {
|
|
166
|
+
return [...records].sort((a, b) => Date.parse(b.warning_issued_at) - Date.parse(a.warning_issued_at))[0];
|
|
167
|
+
}
|
|
168
|
+
function writeEvidence(input) {
|
|
169
|
+
const prepared = prepareFlowDeskSessionEvidenceWriteIntentV1({
|
|
170
|
+
workflowId: input.workflowId,
|
|
171
|
+
evidenceId: input.evidenceId,
|
|
172
|
+
record: input.record,
|
|
173
|
+
});
|
|
174
|
+
if (!prepared.ok || prepared.writeIntent === undefined)
|
|
175
|
+
return false;
|
|
176
|
+
return applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [prepared.writeIntent]).ok;
|
|
177
|
+
}
|
|
178
|
+
function warningRecord(input) {
|
|
179
|
+
return {
|
|
180
|
+
schema_version: "flowdesk.pending_abort_warning.v1",
|
|
181
|
+
warning_id: input.warningId,
|
|
182
|
+
workflow_id: input.workflowId,
|
|
183
|
+
lane_id: input.laneId,
|
|
184
|
+
warning_issued_at: input.issuedAt.toISOString(),
|
|
185
|
+
expires_at: input.expiresAt.toISOString(),
|
|
186
|
+
cancel_command: `/flowdesk-abort ${input.laneId} cancel`,
|
|
187
|
+
status: input.status,
|
|
188
|
+
dispatch_authority_enabled: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
export function validateAndAbortFlowDeskLaneEvidenceV1(input) {
|
|
192
|
+
if (!workflowPrefixAllowed(input.workflow_id)) {
|
|
193
|
+
return { status: "blocked", reason: "workflow_prefix_not_allowed" };
|
|
194
|
+
}
|
|
195
|
+
const reloaded = reloadFlowDeskSessionEvidenceV1({
|
|
196
|
+
rootDir: input.rootDir,
|
|
197
|
+
workflowId: input.workflow_id,
|
|
198
|
+
});
|
|
199
|
+
if (!reloaded.ok)
|
|
200
|
+
return { status: "blocked", reason: "workflow_not_found" };
|
|
201
|
+
const laneRecords = [];
|
|
202
|
+
for (const entry of reloaded.entries) {
|
|
203
|
+
if (isLaneLifecycleRecord(entry.record) && entry.record.lane_id === input.lane_id) {
|
|
204
|
+
laneRecords.push(entry.record);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (laneRecords.length === 0)
|
|
208
|
+
return { status: "blocked", reason: "lane_not_found" };
|
|
209
|
+
const hasExplicitOwnership = laneRecords.some((record) => record.spawned_by === "flowdesk");
|
|
210
|
+
if (input.requireExplicitOwnership === true && !hasExplicitOwnership) {
|
|
211
|
+
return { status: "blocked", reason: "not_explicitly_flowdesk_owned" };
|
|
212
|
+
}
|
|
213
|
+
const hasLegacyOwnership = laneRecords.some((record) => record.spawned_by === undefined);
|
|
214
|
+
if (!hasExplicitOwnership && !hasLegacyOwnership) {
|
|
215
|
+
return { status: "blocked", reason: "not_flowdesk_owned" };
|
|
216
|
+
}
|
|
217
|
+
const latest = latestLifecycle(laneRecords);
|
|
218
|
+
if (TERMINAL_LANE_STATES.has(latest.state)) {
|
|
219
|
+
return { status: "blocked", reason: "lane_already_terminal", current_state: latest.state };
|
|
220
|
+
}
|
|
221
|
+
if (!ABORT_ELIGIBLE_LANE_STATES.has(latest.state)) {
|
|
222
|
+
return { status: "blocked", reason: "lane_not_eligible", current_state: latest.state };
|
|
223
|
+
}
|
|
224
|
+
const observedAt = (input.now?.() ?? new Date()).toISOString();
|
|
225
|
+
const evidenceId = `lifecycle-abort-${input.lane_id}-${observedAt.replace(/[^0-9A-Za-z]/g, "")}`;
|
|
226
|
+
const abortRecord = {
|
|
227
|
+
...latest,
|
|
228
|
+
state: "aborted",
|
|
229
|
+
updated_at: observedAt,
|
|
230
|
+
verdict_ref: undefined,
|
|
231
|
+
spawned_by: latest.spawned_by ?? "flowdesk",
|
|
232
|
+
};
|
|
233
|
+
const prepared = prepareFlowDeskSessionEvidenceWriteIntentV1({
|
|
234
|
+
workflowId: input.workflow_id,
|
|
235
|
+
evidenceId,
|
|
236
|
+
record: abortRecord,
|
|
237
|
+
});
|
|
238
|
+
if (!prepared.ok || prepared.writeIntent === undefined) {
|
|
239
|
+
return { status: "write_failed", reason: "abort_evidence_prepare_failed" };
|
|
240
|
+
}
|
|
241
|
+
const applyResult = applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [prepared.writeIntent]);
|
|
242
|
+
if (!applyResult.ok)
|
|
243
|
+
return { status: "write_failed", reason: "abort_evidence_write_failed" };
|
|
244
|
+
const verify = reloadFlowDeskSessionEvidenceV1({
|
|
245
|
+
rootDir: input.rootDir,
|
|
246
|
+
workflowId: input.workflow_id,
|
|
247
|
+
});
|
|
248
|
+
const persisted = verify.ok && verify.entries.some((entry) => entry.evidenceClass === "lane_lifecycle" &&
|
|
249
|
+
entry.evidenceId === evidenceId &&
|
|
250
|
+
isLaneLifecycleRecord(entry.record) &&
|
|
251
|
+
entry.record.state === "aborted");
|
|
252
|
+
if (!persisted)
|
|
253
|
+
return { status: "write_failed", reason: "abort_evidence_not_persisted" };
|
|
254
|
+
return {
|
|
255
|
+
status: "aborted",
|
|
256
|
+
lane_id: input.lane_id,
|
|
257
|
+
lifecycle_evidence_id: evidenceId,
|
|
258
|
+
reason: `user-requested-abort at ${observedAt} via /flowdesk-abort`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export function cancelPendingAbortWarningEvidenceV1(input) {
|
|
262
|
+
const now = input.now?.() ?? new Date();
|
|
263
|
+
const cancelId = `cancel-${safeToken(input.lane_id)}-${timestampToken(now)}`;
|
|
264
|
+
const cancelRecord = {
|
|
265
|
+
schema_version: "flowdesk.pending_abort_cancel.v1",
|
|
266
|
+
cancel_id: cancelId,
|
|
267
|
+
warning_id_ref: input.warning_id_ref,
|
|
268
|
+
workflow_id: input.workflow_id,
|
|
269
|
+
lane_id: input.lane_id,
|
|
270
|
+
cancelled_at: now.toISOString(),
|
|
271
|
+
cancel_reason: "user_requested_via_command",
|
|
272
|
+
cancel_actor: "user",
|
|
273
|
+
dispatch_authority_enabled: false,
|
|
274
|
+
};
|
|
275
|
+
const cancelWritten = writeEvidence({
|
|
276
|
+
rootDir: input.rootDir,
|
|
277
|
+
workflowId: input.workflow_id,
|
|
278
|
+
evidenceId: cancelId,
|
|
279
|
+
record: cancelRecord,
|
|
280
|
+
});
|
|
281
|
+
if (!cancelWritten)
|
|
282
|
+
return { status: "blocked", reason: "pending_abort_cancel_write_failed" };
|
|
283
|
+
const tombstoneId = `warning-cancelled-${safeToken(input.lane_id)}-${timestampToken(now)}`;
|
|
284
|
+
const tombstone = warningRecord({
|
|
285
|
+
warningId: tombstoneId,
|
|
286
|
+
workflowId: input.workflow_id,
|
|
287
|
+
laneId: input.lane_id,
|
|
288
|
+
issuedAt: now,
|
|
289
|
+
expiresAt: now,
|
|
290
|
+
status: "cancelled",
|
|
291
|
+
});
|
|
292
|
+
const tombstoneWritten = writeEvidence({
|
|
293
|
+
rootDir: input.rootDir,
|
|
294
|
+
workflowId: input.workflow_id,
|
|
295
|
+
evidenceId: tombstoneId,
|
|
296
|
+
record: tombstone,
|
|
297
|
+
});
|
|
298
|
+
if (!tombstoneWritten)
|
|
299
|
+
return { status: "blocked", reason: "pending_abort_cancel_tombstone_write_failed" };
|
|
300
|
+
return { status: "warning_cancelled", warning_id: input.warning_id_ref, cancel_id: cancelId };
|
|
301
|
+
}
|
|
302
|
+
export function evaluateGuardedAutoAbortHookV1(input) {
|
|
303
|
+
const now = input.now?.() ?? new Date();
|
|
304
|
+
const enablement = isAutoAbortEnabledV1({
|
|
305
|
+
config: input.config,
|
|
306
|
+
rootDir: input.rootDir,
|
|
307
|
+
now,
|
|
308
|
+
loadedSignOff: input.loadedSignOff,
|
|
309
|
+
env: input.env,
|
|
310
|
+
});
|
|
311
|
+
if (!enablement.enabled)
|
|
312
|
+
return { status: "manual_recommended", reason: enablement.reason };
|
|
313
|
+
if (!input.stallConfirmed)
|
|
314
|
+
return { status: "noop", reason: "stall_not_confirmed" };
|
|
315
|
+
if (input.sdkSessionHealth.status !== "api_timeout") {
|
|
316
|
+
return { status: "manual_recommended", reason: `sdk_session_health_${input.sdkSessionHealth.status}` };
|
|
317
|
+
}
|
|
318
|
+
const reloaded = reloadFlowDeskSessionEvidenceV1({
|
|
319
|
+
rootDir: input.rootDir,
|
|
320
|
+
workflowId: input.workflow_id,
|
|
321
|
+
});
|
|
322
|
+
if (!reloaded.ok)
|
|
323
|
+
return { status: "blocked", reason: "session_evidence_reload_failed" };
|
|
324
|
+
const warnings = [];
|
|
325
|
+
for (const entry of reloaded.entries) {
|
|
326
|
+
if (isPendingAbortWarning(entry.record) &&
|
|
327
|
+
entry.record.workflow_id === input.workflow_id &&
|
|
328
|
+
entry.record.lane_id === input.lane_id) {
|
|
329
|
+
warnings.push(entry.record);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const latestWarning = latestPendingAbortWarning(warnings);
|
|
333
|
+
if (latestWarning !== undefined && latestWarning.status === "cancelled")
|
|
334
|
+
return { status: "manual_recommended", reason: "pending_abort_cancelled" };
|
|
335
|
+
if (latestWarning !== undefined && latestWarning.status === "executed")
|
|
336
|
+
return { status: "noop", reason: "pending_abort_already_executed" };
|
|
337
|
+
if (latestWarning !== undefined && latestWarning.status === "tombstoned")
|
|
338
|
+
return { status: "noop", reason: "pending_abort_tombstoned" };
|
|
339
|
+
if (latestWarning !== undefined) {
|
|
340
|
+
const cancels = [];
|
|
341
|
+
for (const entry of reloaded.entries) {
|
|
342
|
+
if (isPendingAbortCancel(entry.record) && entry.record.warning_id_ref === latestWarning.warning_id) {
|
|
343
|
+
cancels.push(entry.record);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (cancels.length > 0) {
|
|
347
|
+
return cancelPendingAbortWarningEvidenceV1({
|
|
348
|
+
rootDir: input.rootDir,
|
|
349
|
+
workflow_id: input.workflow_id,
|
|
350
|
+
lane_id: input.lane_id,
|
|
351
|
+
warning_id_ref: latestWarning.warning_id,
|
|
352
|
+
now: () => now,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (now.getTime() < Date.parse(latestWarning.expires_at)) {
|
|
356
|
+
return {
|
|
357
|
+
status: "warning_pending",
|
|
358
|
+
warning_id: latestWarning.warning_id,
|
|
359
|
+
expires_at: latestWarning.expires_at,
|
|
360
|
+
cancel_command: latestWarning.cancel_command,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const abort = validateAndAbortFlowDeskLaneEvidenceV1({
|
|
364
|
+
rootDir: input.rootDir,
|
|
365
|
+
workflow_id: input.workflow_id,
|
|
366
|
+
lane_id: input.lane_id,
|
|
367
|
+
now: () => now,
|
|
368
|
+
requireExplicitOwnership: true,
|
|
369
|
+
});
|
|
370
|
+
if (abort.status !== "aborted")
|
|
371
|
+
return { status: "blocked", reason: abort.reason };
|
|
372
|
+
const executedId = `warning-executed-${safeToken(input.lane_id)}-${timestampToken(now)}`;
|
|
373
|
+
const executed = warningRecord({
|
|
374
|
+
warningId: executedId,
|
|
375
|
+
workflowId: input.workflow_id,
|
|
376
|
+
laneId: input.lane_id,
|
|
377
|
+
issuedAt: now,
|
|
378
|
+
expiresAt: now,
|
|
379
|
+
status: "executed",
|
|
380
|
+
});
|
|
381
|
+
const executedWritten = writeEvidence({
|
|
382
|
+
rootDir: input.rootDir,
|
|
383
|
+
workflowId: input.workflow_id,
|
|
384
|
+
evidenceId: executedId,
|
|
385
|
+
record: executed,
|
|
386
|
+
});
|
|
387
|
+
if (!executedWritten)
|
|
388
|
+
return { status: "blocked", reason: "pending_abort_executed_tombstone_write_failed" };
|
|
389
|
+
return {
|
|
390
|
+
status: "auto_abort_executed",
|
|
391
|
+
warning_id: latestWarning.warning_id,
|
|
392
|
+
lifecycle_evidence_id: abort.lifecycle_evidence_id,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const warningId = `warning-${safeToken(input.lane_id)}-${timestampToken(now)}`;
|
|
396
|
+
const expiresAt = new Date(now.getTime() + Math.max(10_000, input.config.preAbortWarningMs ?? 60_000));
|
|
397
|
+
const pending = warningRecord({
|
|
398
|
+
warningId,
|
|
399
|
+
workflowId: input.workflow_id,
|
|
400
|
+
laneId: input.lane_id,
|
|
401
|
+
issuedAt: now,
|
|
402
|
+
expiresAt,
|
|
403
|
+
status: "pending",
|
|
404
|
+
});
|
|
405
|
+
const warningWritten = writeEvidence({
|
|
406
|
+
rootDir: input.rootDir,
|
|
407
|
+
workflowId: input.workflow_id,
|
|
408
|
+
evidenceId: warningId,
|
|
409
|
+
record: pending,
|
|
410
|
+
});
|
|
411
|
+
if (!warningWritten)
|
|
412
|
+
return { status: "blocked", reason: "pending_abort_warning_write_failed" };
|
|
413
|
+
return {
|
|
414
|
+
status: "warning_issued",
|
|
415
|
+
warning_id: warningId,
|
|
416
|
+
expires_at: expiresAt.toISOString(),
|
|
417
|
+
cancel_command: pending.cancel_command,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// P7 Guarded Auto-Retry helpers
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
function isReviewerLaneContext(value) {
|
|
424
|
+
return isRecord(value) && value.schema_version === "flowdesk.reviewer_lane_context.v1";
|
|
425
|
+
}
|
|
426
|
+
function isPendingRetryPlan(value) {
|
|
427
|
+
return isRecord(value) && value.schema_version === "flowdesk.pending_retry_plan.v1";
|
|
428
|
+
}
|
|
429
|
+
function isRetryExecuted(value) {
|
|
430
|
+
return isRecord(value) && value.schema_version === "flowdesk.retry_executed.v1";
|
|
431
|
+
}
|
|
432
|
+
function isRetryFailed(value) {
|
|
433
|
+
return isRecord(value) && value.schema_version === "flowdesk.retry_failed.v1";
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* On startup/reload: any `pending_retry_plan` in `launched` state with no matching
|
|
437
|
+
* `retry_executed` or `retry_failed` within 10 minutes of `created_at` is reconciled
|
|
438
|
+
* as `retry_failed(indeterminate_launch)`.
|
|
439
|
+
*/
|
|
440
|
+
export function reconcileStalePendingRetryPlansV1(input) {
|
|
441
|
+
const now = input.now ?? new Date();
|
|
442
|
+
const staleThresholdMs = input.staleThresholdMs ?? 600_000; // 10 minutes default
|
|
443
|
+
const reloaded = reloadFlowDeskSessionEvidenceV1({
|
|
444
|
+
rootDir: input.rootDir,
|
|
445
|
+
workflowId: input.workflowId,
|
|
446
|
+
});
|
|
447
|
+
if (!reloaded.ok)
|
|
448
|
+
return;
|
|
449
|
+
for (const entry of reloaded.entries) {
|
|
450
|
+
if (!isPendingRetryPlan(entry.record))
|
|
451
|
+
continue;
|
|
452
|
+
const plan = entry.record;
|
|
453
|
+
if (plan.status !== "launched")
|
|
454
|
+
continue;
|
|
455
|
+
// Check if stale: created_at + staleThresholdMs < now
|
|
456
|
+
const createdAtMs = Date.parse(plan.created_at);
|
|
457
|
+
if (!Number.isFinite(createdAtMs))
|
|
458
|
+
continue;
|
|
459
|
+
if (now.getTime() < createdAtMs + staleThresholdMs)
|
|
460
|
+
continue;
|
|
461
|
+
// Check if any terminal evidence exists for new_lane_id
|
|
462
|
+
const hasTerminal = reloaded.entries.some((e) => {
|
|
463
|
+
if (isRetryExecuted(e.record) && e.record.new_lane_id === plan.new_lane_id)
|
|
464
|
+
return true;
|
|
465
|
+
if (isRetryFailed(e.record) && e.record.new_lane_id === plan.new_lane_id)
|
|
466
|
+
return true;
|
|
467
|
+
return false;
|
|
468
|
+
});
|
|
469
|
+
if (hasTerminal)
|
|
470
|
+
continue;
|
|
471
|
+
// Write retry_failed(indeterminate_launch)
|
|
472
|
+
const failedId = `retry-failed-indeterminate-${safeToken(plan.new_lane_id)}-${timestampToken(now)}`;
|
|
473
|
+
const failedRecord = {
|
|
474
|
+
schema_version: "flowdesk.retry_failed.v1",
|
|
475
|
+
workflow_id: plan.workflow_id,
|
|
476
|
+
original_lane_id: plan.original_lane_id,
|
|
477
|
+
new_lane_id: plan.new_lane_id,
|
|
478
|
+
retry_attempt: plan.retry_attempt,
|
|
479
|
+
failure_category: "indeterminate_launch",
|
|
480
|
+
redacted_reason: "pending_retry_plan launched state stale without terminal evidence",
|
|
481
|
+
created_at: now.toISOString(),
|
|
482
|
+
dispatch_authority_enabled: false,
|
|
483
|
+
};
|
|
484
|
+
writeEvidence({
|
|
485
|
+
rootDir: input.rootDir,
|
|
486
|
+
workflowId: input.workflowId,
|
|
487
|
+
evidenceId: failedId,
|
|
488
|
+
record: failedRecord,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Build a minimal FlowDeskRuntimeLaneLaunchPlanV1-compatible structure
|
|
494
|
+
* from reviewer lane context evidence, for use in retry launch.
|
|
495
|
+
*/
|
|
496
|
+
function buildRetryLaunchPlanFromContextV1(context, newLaneId, parentSessionId) {
|
|
497
|
+
const token = timestampToken(new Date());
|
|
498
|
+
return {
|
|
499
|
+
schema_version: "flowdesk.runtime_lane_launch_plan.v1",
|
|
500
|
+
ok: true,
|
|
501
|
+
errors: [],
|
|
502
|
+
launch_request_id: `launch-request-retry-${context.perspective}-${token}`,
|
|
503
|
+
workflow_id: context.workflow_id,
|
|
504
|
+
attempt_id: context.original_attempt_id,
|
|
505
|
+
lane_id: newLaneId,
|
|
506
|
+
state: "launch_ready",
|
|
507
|
+
blocked_labels: [],
|
|
508
|
+
parent_session_ref: `ses-${parentSessionId}`,
|
|
509
|
+
agent_ref: context.agent_ref,
|
|
510
|
+
provider_qualified_model_id: context.provider_qualified_model_id,
|
|
511
|
+
launch_reason: "reviewer_fanout",
|
|
512
|
+
pre_launch_audit_ref: `audit-retry-pre-launch-${context.perspective}-${token}`,
|
|
513
|
+
lane_launch_approval_ref: `approval-retry-lane-launch-${context.perspective}-${token}`,
|
|
514
|
+
durable_evidence_root_ref: `evidence-root-retry-${token}`,
|
|
515
|
+
lifecycle_evidence_class: "lane_lifecycle",
|
|
516
|
+
exact_binding_confirmed: true,
|
|
517
|
+
sdk_client_required: true,
|
|
518
|
+
launch_attempted: false,
|
|
519
|
+
dispatch_authority_enabled: false,
|
|
520
|
+
providerCall: false,
|
|
521
|
+
actualLaneLaunch: false,
|
|
522
|
+
runtimeExecution: false,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Evaluate guarded auto-retry hook following the execution order from the design doc exactly.
|
|
527
|
+
* Called after `evaluateGuardedAutoAbortHookV1` returns `auto_abort_executed`.
|
|
528
|
+
*/
|
|
529
|
+
export async function evaluateGuardedAutoRetryHookV1(input) {
|
|
530
|
+
const now = input.now ?? new Date();
|
|
531
|
+
const maxAutoRetries = Math.min(2, Math.max(1, input.config.maxAutoRetries ?? 1));
|
|
532
|
+
const timeoutMs = input.timeoutMs ?? 30_000;
|
|
533
|
+
// Step 1: Check opt-in
|
|
534
|
+
if (input.config.autoRetryAfterAbort !== true) {
|
|
535
|
+
return { status: "auto_retry_not_configured", reason: "opt_in_false" };
|
|
536
|
+
}
|
|
537
|
+
// Step 2: Re-verify Guard HMAC
|
|
538
|
+
const guardLoaded = loadGuardSignOffFromRoot(input.rootDir, input.config.guardSignOffPath);
|
|
539
|
+
if (guardLoaded === undefined) {
|
|
540
|
+
return { status: "auto_retry_disabled", reason: "guard_unverified" };
|
|
541
|
+
}
|
|
542
|
+
const guardVerified = verifyGuardSignOffHmacV1({
|
|
543
|
+
signOff: guardLoaded.signOff,
|
|
544
|
+
markdownText: guardLoaded.markdownText,
|
|
545
|
+
hmacKey: input.config.guardHmacKey,
|
|
546
|
+
now,
|
|
547
|
+
});
|
|
548
|
+
if (!guardVerified.ok) {
|
|
549
|
+
return { status: "auto_retry_disabled", reason: "guard_unverified" };
|
|
550
|
+
}
|
|
551
|
+
// Step 3: Check SDK client availability
|
|
552
|
+
if (input.client === undefined || typeof input.client.session?.create !== "function") {
|
|
553
|
+
const failedId = `retry-failed-sdk-unavailable-${safeToken(input.laneId)}-${timestampToken(now)}`;
|
|
554
|
+
writeEvidence({
|
|
555
|
+
rootDir: input.rootDir,
|
|
556
|
+
workflowId: input.workflowId,
|
|
557
|
+
evidenceId: failedId,
|
|
558
|
+
record: {
|
|
559
|
+
schema_version: "flowdesk.retry_failed.v1",
|
|
560
|
+
workflow_id: input.workflowId,
|
|
561
|
+
original_lane_id: input.laneId,
|
|
562
|
+
retry_attempt: 1,
|
|
563
|
+
failure_category: "sdk_unavailable",
|
|
564
|
+
redacted_reason: "sdk_client_missing_or_session_create_unavailable",
|
|
565
|
+
created_at: now.toISOString(),
|
|
566
|
+
dispatch_authority_enabled: false,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
return {
|
|
570
|
+
status: "retry_failed",
|
|
571
|
+
failureCategory: "sdk_unavailable",
|
|
572
|
+
redactedReason: "sdk_client_missing_or_session_create_unavailable",
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
// Step 4: Load reviewer_lane_context.v1 for laneId
|
|
576
|
+
const reloaded = reloadFlowDeskSessionEvidenceV1({
|
|
577
|
+
rootDir: input.rootDir,
|
|
578
|
+
workflowId: input.workflowId,
|
|
579
|
+
});
|
|
580
|
+
if (!reloaded.ok) {
|
|
581
|
+
return { status: "auto_retry_disabled", reason: "context_missing" };
|
|
582
|
+
}
|
|
583
|
+
const contextEntry = reloaded.entries.find((e) => isReviewerLaneContext(e.record) && e.record.lane_id === input.laneId);
|
|
584
|
+
if (contextEntry === undefined) {
|
|
585
|
+
return { status: "auto_retry_disabled", reason: "context_missing" };
|
|
586
|
+
}
|
|
587
|
+
const context = contextEntry.record;
|
|
588
|
+
// Step 5: Verify context.redaction_version present
|
|
589
|
+
if (!context.redaction_version || typeof context.redaction_version !== "string" || context.redaction_version.trim().length === 0) {
|
|
590
|
+
return { status: "auto_retry_disabled", reason: "context_redaction_invalid" };
|
|
591
|
+
}
|
|
592
|
+
// Step 6: Verify context.workflow_id === workflowId && context.perspective valid
|
|
593
|
+
const VALID_PERSPECTIVES = new Set(["policy_security", "architecture", "verification_implementation"]);
|
|
594
|
+
if (context.workflow_id !== input.workflowId || !VALID_PERSPECTIVES.has(context.perspective)) {
|
|
595
|
+
return { status: "auto_retry_disabled", reason: "invariant_violated" };
|
|
596
|
+
}
|
|
597
|
+
// Step 7: Count cap — retry_executed + retry_failed + pending_retry_plan(pending|launched) for laneId
|
|
598
|
+
const retryExecutedCount = reloaded.entries.filter((e) => isRetryExecuted(e.record) && e.record.original_lane_id === input.laneId).length;
|
|
599
|
+
const retryFailedCount = reloaded.entries.filter((e) => isRetryFailed(e.record) && e.record.original_lane_id === input.laneId).length;
|
|
600
|
+
const pendingActiveCount = reloaded.entries.filter((e) => {
|
|
601
|
+
if (!isPendingRetryPlan(e.record))
|
|
602
|
+
return false;
|
|
603
|
+
const plan = e.record;
|
|
604
|
+
return plan.original_lane_id === input.laneId && (plan.status === "pending" || plan.status === "launched");
|
|
605
|
+
}).length;
|
|
606
|
+
const retriesUsed = retryExecutedCount + retryFailedCount + pendingActiveCount;
|
|
607
|
+
if (retriesUsed >= maxAutoRetries) {
|
|
608
|
+
const capFailedId = `retry-failed-cap-${safeToken(input.laneId)}-${timestampToken(now)}`;
|
|
609
|
+
writeEvidence({
|
|
610
|
+
rootDir: input.rootDir,
|
|
611
|
+
workflowId: input.workflowId,
|
|
612
|
+
evidenceId: capFailedId,
|
|
613
|
+
record: {
|
|
614
|
+
schema_version: "flowdesk.retry_failed.v1",
|
|
615
|
+
workflow_id: input.workflowId,
|
|
616
|
+
original_lane_id: input.laneId,
|
|
617
|
+
retry_attempt: retriesUsed + 1,
|
|
618
|
+
failure_category: "cap_reached",
|
|
619
|
+
redacted_reason: `retry_cap_reached(max=${maxAutoRetries},used=${retriesUsed})`,
|
|
620
|
+
created_at: now.toISOString(),
|
|
621
|
+
dispatch_authority_enabled: false,
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
return { status: "auto_retry_disabled", reason: "cap_reached", retriesUsed };
|
|
625
|
+
}
|
|
626
|
+
// Step 8: Check no pending_retry_plan(pending|launched) already exists for laneId
|
|
627
|
+
if (pendingActiveCount > 0) {
|
|
628
|
+
return { status: "auto_retry_disabled", reason: "concurrent_retry_in_progress" };
|
|
629
|
+
}
|
|
630
|
+
// Step 9: Verify lane_lifecycle terminal state = aborted for laneId (monotonic check)
|
|
631
|
+
const lifecycleEntries = reloaded.entries.filter((e) => e.evidenceClass === "lane_lifecycle" && isRecord(e.record) && e.record.lane_id === input.laneId);
|
|
632
|
+
if (lifecycleEntries.length === 0) {
|
|
633
|
+
return { status: "auto_retry_disabled", reason: "lane_not_terminal_aborted" };
|
|
634
|
+
}
|
|
635
|
+
const latestLifecycle = lifecycleEntries
|
|
636
|
+
.map((e) => e.record)
|
|
637
|
+
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())[0];
|
|
638
|
+
if (latestLifecycle.state !== "aborted") {
|
|
639
|
+
return { status: "auto_retry_disabled", reason: "lane_not_terminal_aborted" };
|
|
640
|
+
}
|
|
641
|
+
// Step 10: Generate newLaneId and pendingRetryEvidenceId
|
|
642
|
+
const retryAttempt = retriesUsed + 1;
|
|
643
|
+
const retryToken = timestampToken(now);
|
|
644
|
+
const newLaneId = `lane-retry-${safeToken(input.laneId)}-${retryToken}`;
|
|
645
|
+
const pendingRetryEvidenceId = `pending-retry-${safeToken(input.laneId)}-${retryToken}`;
|
|
646
|
+
// Step 11: Write pending_retry_plan.v1(status=pending) — IDEMPOTENCY FENCE — before any SDK call
|
|
647
|
+
const guardSignOffExpiry = guardLoaded.signOff && isRecord(guardLoaded.signOff) && typeof guardLoaded.signOff.expires_at === "string"
|
|
648
|
+
? Date.parse(guardLoaded.signOff.expires_at)
|
|
649
|
+
: now.getTime() + 30 * 24 * 60 * 60 * 1000; // 30 days default
|
|
650
|
+
const pendingExpiresAt = new Date(Math.min(guardSignOffExpiry, now.getTime() + 60 * 60 * 1000)); // 1h max
|
|
651
|
+
const pendingRecord = {
|
|
652
|
+
schema_version: "flowdesk.pending_retry_plan.v1",
|
|
653
|
+
workflow_id: input.workflowId,
|
|
654
|
+
original_lane_id: input.laneId,
|
|
655
|
+
new_lane_id: newLaneId,
|
|
656
|
+
retry_attempt: retryAttempt,
|
|
657
|
+
context_evidence_id: contextEntry.evidenceId,
|
|
658
|
+
abort_evidence_id: input.abortEvidenceId,
|
|
659
|
+
status: "pending",
|
|
660
|
+
created_at: now.toISOString(),
|
|
661
|
+
expires_at: pendingExpiresAt.toISOString(),
|
|
662
|
+
dispatch_authority_enabled: false,
|
|
663
|
+
};
|
|
664
|
+
const pendingWritten = writeEvidence({
|
|
665
|
+
rootDir: input.rootDir,
|
|
666
|
+
workflowId: input.workflowId,
|
|
667
|
+
evidenceId: pendingRetryEvidenceId,
|
|
668
|
+
record: pendingRecord,
|
|
669
|
+
});
|
|
670
|
+
if (!pendingWritten) {
|
|
671
|
+
return {
|
|
672
|
+
status: "retry_failed",
|
|
673
|
+
failureCategory: "invariant_violated",
|
|
674
|
+
redactedReason: "pending_retry_plan_write_failed",
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// Step 12: Call launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1
|
|
678
|
+
const launchPlan = buildRetryLaunchPlanFromContextV1(context, newLaneId, input.parentSessionId);
|
|
679
|
+
let launchResult;
|
|
680
|
+
try {
|
|
681
|
+
const launchPromise = launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1({
|
|
682
|
+
client: input.client,
|
|
683
|
+
launchPlan: launchPlan,
|
|
684
|
+
request: {
|
|
685
|
+
allowActualLaneLaunch: true,
|
|
686
|
+
parentSessionId: input.parentSessionId,
|
|
687
|
+
promptText: context.prompt_text,
|
|
688
|
+
dispatchMethod: "prompt",
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
launchResult = await withTimeout(launchPromise, timeoutMs, "retry_lane_launch");
|
|
692
|
+
}
|
|
693
|
+
catch (launchErr) {
|
|
694
|
+
// SDK call itself threw — treat as sdk_create_failed
|
|
695
|
+
const failedId = `retry-failed-launch-err-${safeToken(newLaneId)}-${retryToken}`;
|
|
696
|
+
const failedRecord = {
|
|
697
|
+
schema_version: "flowdesk.retry_failed.v1",
|
|
698
|
+
workflow_id: input.workflowId,
|
|
699
|
+
original_lane_id: input.laneId,
|
|
700
|
+
new_lane_id: newLaneId,
|
|
701
|
+
retry_attempt: retryAttempt,
|
|
702
|
+
failure_category: "sdk_create_failed",
|
|
703
|
+
redacted_reason: "sdk_launch_threw_exception",
|
|
704
|
+
created_at: now.toISOString(),
|
|
705
|
+
dispatch_authority_enabled: false,
|
|
706
|
+
};
|
|
707
|
+
writeEvidence({
|
|
708
|
+
rootDir: input.rootDir,
|
|
709
|
+
workflowId: input.workflowId,
|
|
710
|
+
evidenceId: failedId,
|
|
711
|
+
record: failedRecord,
|
|
712
|
+
});
|
|
713
|
+
// Update pending plan to failed
|
|
714
|
+
const failedPendingRecord = { ...pendingRecord, status: "failed" };
|
|
715
|
+
writeEvidence({
|
|
716
|
+
rootDir: input.rootDir,
|
|
717
|
+
workflowId: input.workflowId,
|
|
718
|
+
evidenceId: `${pendingRetryEvidenceId}-failed`,
|
|
719
|
+
record: failedPendingRecord,
|
|
720
|
+
});
|
|
721
|
+
return {
|
|
722
|
+
status: "retry_failed",
|
|
723
|
+
failureCategory: "sdk_create_failed",
|
|
724
|
+
redactedReason: "sdk_launch_threw_exception",
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// Step 13/14: Handle session.create success but promptAsync rejection, or create failure
|
|
728
|
+
if (launchResult.status !== "lane_launch_started") {
|
|
729
|
+
const isCreateAttempted = launchResult.createAttempted;
|
|
730
|
+
const failureCategory = isCreateAttempted
|
|
731
|
+
? "sdk_prompt_rejected"
|
|
732
|
+
: "sdk_create_failed";
|
|
733
|
+
const failedId = `retry-failed-${failureCategory}-${safeToken(newLaneId)}-${retryToken}`;
|
|
734
|
+
const failedRecord = {
|
|
735
|
+
schema_version: "flowdesk.retry_failed.v1",
|
|
736
|
+
workflow_id: input.workflowId,
|
|
737
|
+
original_lane_id: input.laneId,
|
|
738
|
+
new_lane_id: newLaneId,
|
|
739
|
+
retry_attempt: retryAttempt,
|
|
740
|
+
failure_category: failureCategory,
|
|
741
|
+
redacted_reason: `sdk_launch_${launchResult.status}`,
|
|
742
|
+
created_at: now.toISOString(),
|
|
743
|
+
dispatch_authority_enabled: false,
|
|
744
|
+
};
|
|
745
|
+
writeEvidence({
|
|
746
|
+
rootDir: input.rootDir,
|
|
747
|
+
workflowId: input.workflowId,
|
|
748
|
+
evidenceId: failedId,
|
|
749
|
+
record: failedRecord,
|
|
750
|
+
});
|
|
751
|
+
// Update pending plan to failed
|
|
752
|
+
const failedPendingRecord = { ...pendingRecord, status: "failed" };
|
|
753
|
+
writeEvidence({
|
|
754
|
+
rootDir: input.rootDir,
|
|
755
|
+
workflowId: input.workflowId,
|
|
756
|
+
evidenceId: `${pendingRetryEvidenceId}-failed`,
|
|
757
|
+
record: failedPendingRecord,
|
|
758
|
+
});
|
|
759
|
+
return {
|
|
760
|
+
status: "retry_failed",
|
|
761
|
+
failureCategory,
|
|
762
|
+
redactedReason: `sdk_launch_${launchResult.status}`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
// Step 15: Full success — write retry_executed.v1 + update pending to launched
|
|
766
|
+
const newParentSessionRef = launchResult.childSessionRef ?? `ses-${input.parentSessionId}`;
|
|
767
|
+
const executedId = `retry-executed-${safeToken(newLaneId)}-${retryToken}`;
|
|
768
|
+
const executedRecord = {
|
|
769
|
+
schema_version: "flowdesk.retry_executed.v1",
|
|
770
|
+
workflow_id: input.workflowId,
|
|
771
|
+
original_lane_id: input.laneId,
|
|
772
|
+
new_lane_id: newLaneId,
|
|
773
|
+
retry_attempt: retryAttempt,
|
|
774
|
+
perspective: context.perspective,
|
|
775
|
+
provider_qualified_model_id: context.provider_qualified_model_id,
|
|
776
|
+
new_parent_session_ref: newParentSessionRef,
|
|
777
|
+
original_attempt_id: context.original_attempt_id,
|
|
778
|
+
created_at: now.toISOString(),
|
|
779
|
+
dispatch_authority_enabled: false,
|
|
780
|
+
};
|
|
781
|
+
writeEvidence({
|
|
782
|
+
rootDir: input.rootDir,
|
|
783
|
+
workflowId: input.workflowId,
|
|
784
|
+
evidenceId: executedId,
|
|
785
|
+
record: executedRecord,
|
|
786
|
+
});
|
|
787
|
+
// Update pending plan to launched
|
|
788
|
+
const launchedPendingRecord = { ...pendingRecord, status: "launched" };
|
|
789
|
+
writeEvidence({
|
|
790
|
+
rootDir: input.rootDir,
|
|
791
|
+
workflowId: input.workflowId,
|
|
792
|
+
evidenceId: `${pendingRetryEvidenceId}-launched`,
|
|
793
|
+
record: launchedPendingRecord,
|
|
794
|
+
});
|
|
795
|
+
return {
|
|
796
|
+
status: "retry_launched",
|
|
797
|
+
newLaneId,
|
|
798
|
+
pendingRetryEvidenceId,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
// Module-level flag to prevent concurrent cycles
|
|
802
|
+
let _isWatchdogCycleRunning = false;
|
|
803
|
+
const FLOWDESK_SESSION_EVIDENCE_ROOT = ".flowdesk/sessions";
|
|
804
|
+
function listWatchdogWorkflowIds(rootDir) {
|
|
805
|
+
const sessionsDir = join(rootDir, FLOWDESK_SESSION_EVIDENCE_ROOT);
|
|
806
|
+
if (!existsSync(sessionsDir))
|
|
807
|
+
return [];
|
|
808
|
+
let entries;
|
|
809
|
+
try {
|
|
810
|
+
entries = readdirSync(sessionsDir);
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return [];
|
|
814
|
+
}
|
|
815
|
+
const result = [];
|
|
816
|
+
for (const name of entries) {
|
|
817
|
+
const candidatePath = join(sessionsDir, name);
|
|
818
|
+
try {
|
|
819
|
+
const stat = statSync(candidatePath);
|
|
820
|
+
if (stat.isDirectory())
|
|
821
|
+
result.push(name);
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
// skip unreadable entries
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
export async function runFlowDeskWatchdogCycleV1(input) {
|
|
830
|
+
const cycleAt = (input.now ?? new Date()).toISOString();
|
|
831
|
+
// Check Guard HMAC first (before concurrency check per the plan spec)
|
|
832
|
+
const guardLoaded = loadGuardSignOffFromRoot(input.rootDir, input.config.guardSignOffPath);
|
|
833
|
+
if (guardLoaded === undefined) {
|
|
834
|
+
return {
|
|
835
|
+
cycleAt,
|
|
836
|
+
guardValid: false,
|
|
837
|
+
lanesChecked: 0,
|
|
838
|
+
lanesAborted: 0,
|
|
839
|
+
lanesRetried: 0,
|
|
840
|
+
lanesFailed: 0,
|
|
841
|
+
skippedReason: "guard_invalid",
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const guardVerified = verifyGuardSignOffHmacV1({
|
|
845
|
+
signOff: guardLoaded.signOff,
|
|
846
|
+
markdownText: guardLoaded.markdownText,
|
|
847
|
+
hmacKey: input.config.guardHmacKey,
|
|
848
|
+
now: input.now,
|
|
849
|
+
});
|
|
850
|
+
if (!guardVerified.ok) {
|
|
851
|
+
return {
|
|
852
|
+
cycleAt,
|
|
853
|
+
guardValid: false,
|
|
854
|
+
lanesChecked: 0,
|
|
855
|
+
lanesAborted: 0,
|
|
856
|
+
lanesRetried: 0,
|
|
857
|
+
lanesFailed: 0,
|
|
858
|
+
skippedReason: "guard_invalid",
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
// Check concurrent execution flag
|
|
862
|
+
if (_isWatchdogCycleRunning) {
|
|
863
|
+
return {
|
|
864
|
+
cycleAt,
|
|
865
|
+
guardValid: true,
|
|
866
|
+
lanesChecked: 0,
|
|
867
|
+
lanesAborted: 0,
|
|
868
|
+
lanesRetried: 0,
|
|
869
|
+
lanesFailed: 0,
|
|
870
|
+
skippedReason: "cycle_already_running",
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
_isWatchdogCycleRunning = true;
|
|
874
|
+
let lanesChecked = 0;
|
|
875
|
+
let lanesAborted = 0;
|
|
876
|
+
let lanesRetried = 0;
|
|
877
|
+
let lanesFailed = 0;
|
|
878
|
+
try {
|
|
879
|
+
const workflowIds = listWatchdogWorkflowIds(input.rootDir);
|
|
880
|
+
const now = input.now ?? new Date();
|
|
881
|
+
for (const workflowId of workflowIds) {
|
|
882
|
+
// Reload evidence for this workflow
|
|
883
|
+
const reloaded = reloadFlowDeskSessionEvidenceV1({
|
|
884
|
+
rootDir: input.rootDir,
|
|
885
|
+
workflowId,
|
|
886
|
+
});
|
|
887
|
+
if (!reloaded.ok)
|
|
888
|
+
continue;
|
|
889
|
+
// Run stall projection
|
|
890
|
+
const stallProjection = projectFlowDeskLaneStallV1({
|
|
891
|
+
workflowId,
|
|
892
|
+
reload: reloaded,
|
|
893
|
+
observedAt: now.toISOString(),
|
|
894
|
+
});
|
|
895
|
+
// Find stalled lanes
|
|
896
|
+
const stalledEntries = stallProjection.entries.filter((entry) => entry.classification === "stalled");
|
|
897
|
+
for (const stalledEntry of stalledEntries) {
|
|
898
|
+
lanesChecked++;
|
|
899
|
+
try {
|
|
900
|
+
// Run guarded auto-abort
|
|
901
|
+
const autoAbort = evaluateGuardedAutoAbortHookV1({
|
|
902
|
+
rootDir: input.rootDir,
|
|
903
|
+
workflow_id: workflowId,
|
|
904
|
+
lane_id: stalledEntry.laneId,
|
|
905
|
+
config: input.config,
|
|
906
|
+
stallConfirmed: true,
|
|
907
|
+
sdkSessionHealth: { status: "api_timeout", reason: "watchdog_cycle_stall_detected" },
|
|
908
|
+
now: () => now,
|
|
909
|
+
loadedSignOff: guardLoaded,
|
|
910
|
+
});
|
|
911
|
+
if (autoAbort.status === "auto_abort_executed") {
|
|
912
|
+
lanesAborted++;
|
|
913
|
+
// Run guarded auto-retry if configured
|
|
914
|
+
if (input.config.autoRetryAfterAbort === true &&
|
|
915
|
+
input.client !== undefined) {
|
|
916
|
+
try {
|
|
917
|
+
const retryResult = await evaluateGuardedAutoRetryHookV1({
|
|
918
|
+
config: input.config,
|
|
919
|
+
rootDir: input.rootDir,
|
|
920
|
+
workflowId,
|
|
921
|
+
laneId: stalledEntry.laneId,
|
|
922
|
+
abortEvidenceId: autoAbort.lifecycle_evidence_id,
|
|
923
|
+
client: input.client,
|
|
924
|
+
parentSessionId: input.parentSessionId,
|
|
925
|
+
now,
|
|
926
|
+
});
|
|
927
|
+
if (retryResult.status === "retry_launched") {
|
|
928
|
+
lanesRetried++;
|
|
929
|
+
}
|
|
930
|
+
else if (retryResult.status === "retry_failed" ||
|
|
931
|
+
retryResult.status === "auto_retry_disabled") {
|
|
932
|
+
// Not a failure of the watchdog cycle itself
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
// Retry evaluation is best-effort; do not count as lanesFailed
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else if (autoAbort.status === "blocked") {
|
|
941
|
+
lanesFailed++;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
lanesFailed++;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
finally {
|
|
951
|
+
_isWatchdogCycleRunning = false;
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
cycleAt,
|
|
955
|
+
guardValid: true,
|
|
956
|
+
lanesChecked,
|
|
957
|
+
lanesAborted,
|
|
958
|
+
lanesRetried,
|
|
959
|
+
lanesFailed,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
//# sourceMappingURL=stall-recovery.js.map
|