@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.
Files changed (43) hide show
  1. package/dist/agent-task-runner.d.ts +26 -0
  2. package/dist/agent-task-runner.d.ts.map +1 -0
  3. package/dist/agent-task-runner.js +244 -0
  4. package/dist/agent-task-runner.js.map +1 -0
  5. package/dist/bootstrap-installer.d.ts +3 -0
  6. package/dist/bootstrap-installer.d.ts.map +1 -1
  7. package/dist/bootstrap-installer.js +153 -7
  8. package/dist/bootstrap-installer.js.map +1 -1
  9. package/dist/command-handlers.d.ts +3 -0
  10. package/dist/command-handlers.d.ts.map +1 -1
  11. package/dist/command-handlers.js +38 -4
  12. package/dist/command-handlers.js.map +1 -1
  13. package/dist/local-adapter.d.ts.map +1 -1
  14. package/dist/local-adapter.js +19 -0
  15. package/dist/local-adapter.js.map +1 -1
  16. package/dist/managed-dispatch-adapter.d.ts +2 -0
  17. package/dist/managed-dispatch-adapter.d.ts.map +1 -1
  18. package/dist/managed-dispatch-adapter.js +86 -1
  19. package/dist/managed-dispatch-adapter.js.map +1 -1
  20. package/dist/quick-reviewer-run.d.ts +13 -2
  21. package/dist/quick-reviewer-run.d.ts.map +1 -1
  22. package/dist/quick-reviewer-run.js +213 -69
  23. package/dist/quick-reviewer-run.js.map +1 -1
  24. package/dist/runtime-reviewer-execution-bridge.d.ts.map +1 -1
  25. package/dist/runtime-reviewer-execution-bridge.js +46 -1
  26. package/dist/runtime-reviewer-execution-bridge.js.map +1 -1
  27. package/dist/server.d.ts +47 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +467 -59
  30. package/dist/server.js.map +1 -1
  31. package/dist/shared/with-timeout.d.ts +12 -0
  32. package/dist/shared/with-timeout.d.ts.map +1 -0
  33. package/dist/shared/with-timeout.js +31 -0
  34. package/dist/shared/with-timeout.js.map +1 -0
  35. package/dist/stall-recovery.d.ts +187 -0
  36. package/dist/stall-recovery.d.ts.map +1 -0
  37. package/dist/stall-recovery.js +962 -0
  38. package/dist/stall-recovery.js.map +1 -0
  39. package/dist/status-live-tool.d.ts +1 -0
  40. package/dist/status-live-tool.d.ts.map +1 -1
  41. package/dist/status-live-tool.js +128 -1
  42. package/dist/status-live-tool.js.map +1 -1
  43. 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