@flowdesk/opencode-plugin 0.1.13 → 0.1.14

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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-task-output.d.ts +17 -0
  3. package/dist/agent-task-output.d.ts.map +1 -0
  4. package/dist/agent-task-output.js +119 -0
  5. package/dist/agent-task-output.js.map +1 -0
  6. package/dist/agent-task-runner.d.ts +23 -0
  7. package/dist/agent-task-runner.d.ts.map +1 -1
  8. package/dist/agent-task-runner.js +410 -81
  9. package/dist/agent-task-runner.js.map +1 -1
  10. package/dist/auto-continue-preview-tool.d.ts +36 -0
  11. package/dist/auto-continue-preview-tool.d.ts.map +1 -0
  12. package/dist/auto-continue-preview-tool.js +119 -0
  13. package/dist/auto-continue-preview-tool.js.map +1 -0
  14. package/dist/completion-ui-cache.d.ts +6 -0
  15. package/dist/completion-ui-cache.d.ts.map +1 -0
  16. package/dist/completion-ui-cache.js +260 -0
  17. package/dist/completion-ui-cache.js.map +1 -0
  18. package/dist/event-hook-observer.d.ts +14 -0
  19. package/dist/event-hook-observer.d.ts.map +1 -0
  20. package/dist/event-hook-observer.js +193 -0
  21. package/dist/event-hook-observer.js.map +1 -0
  22. package/dist/managed-dispatch-adapter.d.ts.map +1 -1
  23. package/dist/managed-dispatch-adapter.js +7 -3
  24. package/dist/managed-dispatch-adapter.js.map +1 -1
  25. package/dist/model-selection-engine.d.ts +47 -0
  26. package/dist/model-selection-engine.d.ts.map +1 -0
  27. package/dist/model-selection-engine.js +175 -0
  28. package/dist/model-selection-engine.js.map +1 -0
  29. package/dist/provider-usage-live-tool.d.ts +10 -0
  30. package/dist/provider-usage-live-tool.d.ts.map +1 -1
  31. package/dist/provider-usage-live-tool.js +145 -18
  32. package/dist/provider-usage-live-tool.js.map +1 -1
  33. package/dist/server.d.ts +35 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +447 -19
  36. package/dist/server.js.map +1 -1
  37. package/dist/stall-recovery.d.ts +33 -0
  38. package/dist/stall-recovery.d.ts.map +1 -1
  39. package/dist/stall-recovery.js +459 -2
  40. package/dist/stall-recovery.js.map +1 -1
  41. package/dist/status-live-tool.d.ts +54 -0
  42. package/dist/status-live-tool.d.ts.map +1 -1
  43. package/dist/status-live-tool.js +448 -44
  44. package/dist/status-live-tool.js.map +1 -1
  45. package/dist/tui-subtask-activity.d.ts +69 -0
  46. package/dist/tui-subtask-activity.d.ts.map +1 -0
  47. package/dist/tui-subtask-activity.js +266 -0
  48. package/dist/tui-subtask-activity.js.map +1 -0
  49. package/dist/tui-usage-snapshot.d.ts +14 -0
  50. package/dist/tui-usage-snapshot.d.ts.map +1 -1
  51. package/dist/tui-usage-snapshot.js +189 -8
  52. package/dist/tui-usage-snapshot.js.map +1 -1
  53. package/dist/tui.d.ts.map +1 -1
  54. package/dist/tui.js +72 -41
  55. package/dist/tui.js.map +1 -1
  56. package/dist/workflow-assign-tool.d.ts +23 -0
  57. package/dist/workflow-assign-tool.d.ts.map +1 -0
  58. package/dist/workflow-assign-tool.js +117 -0
  59. package/dist/workflow-assign-tool.js.map +1 -0
  60. package/dist/workflow-author-tool.d.ts +29 -0
  61. package/dist/workflow-author-tool.d.ts.map +1 -0
  62. package/dist/workflow-author-tool.js +227 -0
  63. package/dist/workflow-author-tool.js.map +1 -0
  64. package/dist/workflow-dispatch-tool.d.ts.map +1 -1
  65. package/dist/workflow-dispatch-tool.js +32 -2
  66. package/dist/workflow-dispatch-tool.js.map +1 -1
  67. package/dist/workflow-orchestrator.d.ts +31 -0
  68. package/dist/workflow-orchestrator.d.ts.map +1 -0
  69. package/dist/workflow-orchestrator.js +160 -0
  70. package/dist/workflow-orchestrator.js.map +1 -0
  71. package/dist/workflow-scheduler.d.ts.map +1 -1
  72. package/dist/workflow-scheduler.js +3 -1
  73. package/dist/workflow-scheduler.js.map +1 -1
  74. package/dist/workflow-synthesis-tool.d.ts +31 -0
  75. package/dist/workflow-synthesis-tool.d.ts.map +1 -0
  76. package/dist/workflow-synthesis-tool.js +194 -0
  77. package/dist/workflow-synthesis-tool.js.map +1 -0
  78. package/package.json +2 -2
@@ -1,9 +1,21 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { applyFlowDeskSessionEvidenceWriteIntentsV1, prepareFlowDeskSessionEvidenceWriteIntentV1, } from "@flowdesk/core";
3
3
  import { launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1, materializeFlowDeskRuntimeLaneLaunchLifecycleEvidenceV1, } from "./managed-dispatch-adapter.js";
4
+ import { observeFlowDeskAgentTaskOutputV1 } from "./agent-task-output.js";
5
+ import { refreshFlowDeskCompletionUiCachesV1 } from "./completion-ui-cache.js";
4
6
  import { recordFlowDeskLaneHeartbeatV1 } from "./lane-heartbeat-writer.js";
5
7
  const TASK_RESULT_MAX_TEXT = 32_768;
6
8
  const AGENT_TASK_CONTEXT_MAX_PROMPT_TEXT = 32_768;
9
+ const INVALID_PARENT_SESSION_REF = "ses-invalid-parent-session-binding";
10
+ /** Schema version for async child session tracking evidence */
11
+ export const AGENT_TASK_CHILD_SESSION_SCHEMA_VERSION = "flowdesk.agent_task_child_session.v1";
12
+ export function sanitizeFlowDeskTaskResultTextV1(text) {
13
+ return {
14
+ text: text.length > TASK_RESULT_MAX_TEXT ? text.slice(0, TASK_RESULT_MAX_TEXT) : text,
15
+ changed: false,
16
+ truncated: text.length > TASK_RESULT_MAX_TEXT,
17
+ };
18
+ }
7
19
  function agentTaskLaunchPlan(input) {
8
20
  return {
9
21
  schema_version: "flowdesk.runtime_lane_launch_plan.v1",
@@ -32,66 +44,162 @@ function agentTaskLaunchPlan(input) {
32
44
  runtimeExecution: false,
33
45
  };
34
46
  }
35
- function extractAssistantTextFromResponse(client, childSessionId) {
36
- // We extract response text via messages API
47
+ function validateAgentTaskParentSessionId(parentSessionId) {
48
+ const value = parentSessionId.trim();
49
+ if (value.length === 0)
50
+ return { ok: false, redactedReason: "missing_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
51
+ if (value.length > 128)
52
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
53
+ // `ses-...` is FlowDesk's opaque session-ref wrapper, not the raw OpenCode
54
+ // session id expected by SDK `session.create({ parentID })`. Accepting it here
55
+ // causes evidence such as `ses-ses-flowdesk-coordinator` and can make the SDK
56
+ // wait on a non-existent synthetic parent session until launch timeout.
57
+ if (value.startsWith("ses-"))
58
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
59
+ if (/\s/.test(value))
60
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
61
+ if (!/^[A-Za-z0-9_.:-]+$/.test(value))
62
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
63
+ return { ok: true, parentSessionRef: `ses-${value}` };
64
+ }
65
+ /** Bounded nudge text — versioned constant, never echoes user input */
66
+ const AGENT_TASK_NUDGE_TEXT = "Please provide your final answer now. If you have completed your analysis, output your complete response.";
67
+ /**
68
+ * Polls `session.messages` with a per-call 3-second cap so it works whether the SDK
69
+ * uses snapshot (returns immediately) or long-poll (blocks until output) semantics.
70
+ *
71
+ * Heartbeat: fires every `quietPeriodMs` of silence — only when inactive.
72
+ * Nudge: after `quietPeriodMs` of silence, sends a bounded prompt to the child
73
+ * session asking for the final answer. Max `maxNudges` nudges total.
74
+ * After exhausting nudges with no response, returns undefined.
75
+ */
76
+ async function extractAssistantTextFromResponse(client, childSessionId, opts) {
37
77
  const messages = client.session.messages;
38
78
  if (messages === undefined)
39
79
  return undefined;
40
- return (async () => {
41
- try {
42
- const method = messages;
80
+ const quietPeriodMs = opts?.quietPeriodMs ?? 30_000;
81
+ const maxNudges = opts?.maxNudges ?? 2;
82
+ const MESSAGES_TIMEOUT_MS = opts?.messagesTimeoutMs ?? 3_000; // per-call cap — handles both snapshot and long-poll
83
+ const method = messages;
84
+ /**
85
+ * Call session.messages with a ceiling timeout so we can check inactivity periodically.
86
+ * This handles both snapshot APIs (return immediately) and long-poll APIs
87
+ * (block until LLM produces output). With the timeout, a long-poll call that
88
+ * hasn't returned after MESSAGES_TIMEOUT_MS resolves as null so we can
89
+ * check the inactivity clock and possibly send a nudge.
90
+ */
91
+ const callMessages = () => {
92
+ const messagePromise = (async () => {
43
93
  const current = await method.call(client.session, { sessionID: childSessionId });
44
- const response = isSdkErrorResponse(current)
45
- ? await method.call(client.session, { path: { id: childSessionId } })
46
- : current;
47
- const data = asResponseData(response);
48
- const record = asRecord(data);
49
- const items = Array.isArray(data)
50
- ? data
51
- : Array.isArray(record?.items)
52
- ? record.items
53
- : Array.isArray(record?.messages)
54
- ? record.messages
55
- : [];
56
- for (let index = items.length - 1; index >= 0; index -= 1) {
57
- const message = items[index];
58
- const record = asRecord(message);
59
- const info = asRecord(record?.info) ?? record;
60
- if (info?.role !== "assistant")
61
- continue;
62
- const parts = Array.isArray(record?.parts)
63
- ? record.parts
64
- : Array.isArray(info?.parts)
65
- ? info.parts
66
- : [];
67
- for (const part of parts) {
68
- const partRecord = asRecord(part);
69
- const text = typeof partRecord?.text === "string"
70
- ? partRecord.text
71
- : typeof partRecord?.content === "string"
72
- ? partRecord.content
73
- : undefined;
74
- if (typeof text === "string" && text.trim().length > 0)
75
- return text;
76
- }
77
- }
78
- return undefined;
94
+ if (isSdkErrorResponse(current))
95
+ return method.call(client.session, { path: { id: childSessionId } });
96
+ return current;
97
+ })();
98
+ // Only race against timeout when the API might block (MESSAGES_TIMEOUT_MS > 0)
99
+ if (MESSAGES_TIMEOUT_MS <= 0)
100
+ return messagePromise;
101
+ return Promise.race([
102
+ messagePromise,
103
+ new Promise(resolve => setTimeout(() => resolve(null), MESSAGES_TIMEOUT_MS)),
104
+ ]);
105
+ };
106
+ /** Send a nudge to the child session with a hard timeout to prevent blocking.
107
+ * Uses noReply: true so the child does not generate a spurious second assistant turn.
108
+ */
109
+ const sendNudge = async () => {
110
+ const promptFn = client.session.prompt ?? client.session.promptAsync;
111
+ if (promptFn === undefined)
112
+ return "skipped";
113
+ const NUDGE_TIMEOUT_MS = 5_000;
114
+ try {
115
+ await Promise.race([
116
+ promptFn.call(client.session, {
117
+ sessionID: childSessionId,
118
+ noReply: true,
119
+ ...(opts?.runtimeModel !== undefined ? { model: opts.runtimeModel } : {}),
120
+ ...(opts?.agentName !== undefined ? { agent: opts.agentName } : {}),
121
+ parts: [{ type: "text", text: AGENT_TASK_NUDGE_TEXT }],
122
+ }),
123
+ new Promise((_, reject) => setTimeout(() => reject(new Error("nudge timeout")), NUDGE_TIMEOUT_MS)),
124
+ ]);
125
+ return "sent";
79
126
  }
80
127
  catch {
81
- return undefined;
128
+ return "timeout";
82
129
  }
83
- })();
84
- }
85
- function isProcessOnlyAssistantOutput(text) {
86
- const normalized = text.trim().toLowerCase();
87
- return normalized.length === 0 || [
88
- "working",
89
- "thinking",
90
- "i'll take a look",
91
- "i will take a look",
92
- "let me inspect",
93
- "i'm going to inspect",
94
- ].some((fragment) => normalized.includes(fragment));
130
+ };
131
+ const observe = (response) => {
132
+ if (response === null)
133
+ return undefined; // timed-out poll cycle
134
+ return observeFlowDeskAgentTaskOutputV1(response);
135
+ };
136
+ const startMs = Date.now();
137
+ let lastActivityMs = startMs;
138
+ let lastSignature = "";
139
+ let lastHeartbeatMs = startMs;
140
+ let nudgeCount = 0;
141
+ let latestCandidate;
142
+ try {
143
+ while (true) {
144
+ const response = await callMessages();
145
+ const nowMs = Date.now();
146
+ // Build signature (null response = timeout, no change)
147
+ const sig = response === null ? lastSignature : (() => {
148
+ const data = asResponseData(response);
149
+ const record = asRecord(data);
150
+ const items = Array.isArray(data) ? data
151
+ : Array.isArray(record?.items) ? record.items
152
+ : Array.isArray(record?.messages) ? record.messages : [];
153
+ const observed = observe(response);
154
+ return `${items.length}:${observed?.latestText?.length ?? 0}:${observed?.terminalObserved === true ? "terminal" : "open"}`;
155
+ })();
156
+ if (sig !== lastSignature) {
157
+ // New activity — reset all inactivity clocks
158
+ lastSignature = sig;
159
+ lastActivityMs = nowMs;
160
+ lastHeartbeatMs = nowMs;
161
+ }
162
+ const observed = observe(response);
163
+ if (observed?.latestText !== undefined && observed.latestText.trim().length > 0)
164
+ latestCandidate = observed;
165
+ if (observed?.terminalObserved === true && observed.latestText !== undefined && observed.latestText.trim().length > 0) {
166
+ return { text: observed.latestText, completionStatus: "final", outputKind: observed.outputKind, usableForSynthesis: observed.usableForSynthesis };
167
+ }
168
+ const silenceMs = nowMs - lastActivityMs;
169
+ if (silenceMs >= quietPeriodMs) {
170
+ // Emit heartbeat on first quiet-period expiry of each silence window
171
+ if (nowMs - lastHeartbeatMs >= quietPeriodMs) {
172
+ lastHeartbeatMs = nowMs;
173
+ opts?.heartbeatFn?.(nowMs - startMs);
174
+ }
175
+ // Send nudge after quiet period
176
+ if (nudgeCount < maxNudges) {
177
+ nudgeCount++;
178
+ await sendNudge();
179
+ // Reset activity clock after nudge — give a fresh quiet window
180
+ lastActivityMs = Date.now();
181
+ lastHeartbeatMs = lastActivityMs;
182
+ }
183
+ else {
184
+ // Exhausted all nudges. Preserve usable candidate text as partial output.
185
+ if (latestCandidate?.latestText !== undefined && latestCandidate.latestText.trim().length > 0) {
186
+ return { text: latestCandidate.latestText, completionStatus: "partial", outputKind: latestCandidate.outputKind, usableForSynthesis: latestCandidate.usableForSynthesis };
187
+ }
188
+ return undefined;
189
+ }
190
+ }
191
+ else {
192
+ // No activity and not yet at quiet period — yield to event loop before next poll.
193
+ // Sleep for up to 1s or quietPeriodMs/10, whichever is smaller, to avoid tight loops
194
+ // while still being responsive when messages arrive quickly (snapshot mode).
195
+ const yieldMs = Math.max(10, Math.min(1_000, Math.floor(quietPeriodMs / 10)));
196
+ await new Promise(resolve => setTimeout(resolve, yieldMs));
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
95
203
  }
96
204
  function asRecord(value) {
97
205
  return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -117,8 +225,38 @@ function writeSessionEvidence(input) {
117
225
  record: input.record,
118
226
  });
119
227
  if (prepared.ok && prepared.writeIntent !== undefined) {
120
- applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [prepared.writeIntent]);
228
+ const applied = applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [prepared.writeIntent]);
229
+ return applied.ok && applied.writtenPaths.length > 0;
121
230
  }
231
+ return false;
232
+ }
233
+ function progressLabel(value) {
234
+ const compact = value.replace(/\s+/g, " ").trim();
235
+ return compact.length > 120 ? `${compact.slice(0, 119)}…` : compact;
236
+ }
237
+ function writeAgentTaskProgress(input) {
238
+ const observedAt = input.observedAt ?? new Date().toISOString();
239
+ const record = {
240
+ schema_version: "flowdesk.agent_task_progress.v1",
241
+ workflow_id: input.workflowId,
242
+ lane_id: input.laneId,
243
+ task_id: input.taskId,
244
+ agent_ref: input.agentRef,
245
+ provider_qualified_model_id: input.providerQualifiedModelId,
246
+ progress_seq: input.progressSeq,
247
+ observed_at: observedAt,
248
+ phase: input.phase,
249
+ progress_label: progressLabel(input.progressLabel),
250
+ progress_ref: `progress-${input.laneId}-${input.progressSeq}`,
251
+ redaction_version: "v1",
252
+ dispatch_authority_enabled: false,
253
+ };
254
+ writeSessionEvidence({
255
+ rootDir: input.rootDir,
256
+ workflowId: input.workflowId,
257
+ evidenceId: `agent-task-progress-${input.laneId}-${input.progressSeq}`,
258
+ record: record,
259
+ });
122
260
  }
123
261
  function writeAgentTaskTerminalLifecycle(input) {
124
262
  const childSessionRef = input.childSessionRef === input.parentSessionRef ? undefined : input.childSessionRef;
@@ -154,6 +292,44 @@ function writeAgentTaskTerminalLifecycle(input) {
154
292
  export async function executeFlowDeskAgentTaskV1(input) {
155
293
  const observedAt = new Date().toISOString();
156
294
  const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
295
+ const parentBinding = validateAgentTaskParentSessionId(input.parentSessionId);
296
+ const parentSessionRef = parentBinding.parentSessionRef;
297
+ const attemptId = `attempt-task-${token}`;
298
+ if (!parentBinding.ok) {
299
+ const taskFailedEvidenceId = `task-failed-${input.taskId}-${token}-invalid-parent`;
300
+ const redactedReason = parentBinding.redactedReason;
301
+ writeSessionEvidence({
302
+ rootDir: input.rootDir,
303
+ workflowId: input.workflowId,
304
+ evidenceId: taskFailedEvidenceId,
305
+ record: {
306
+ schema_version: "flowdesk.task_failed.v1",
307
+ workflow_id: input.workflowId,
308
+ lane_id: input.laneId,
309
+ task_id: input.taskId,
310
+ agent_ref: input.agentRef,
311
+ provider_qualified_model_id: input.providerQualifiedModelId,
312
+ failure_category: "sdk_create_failed",
313
+ redacted_reason: redactedReason,
314
+ created_at: observedAt,
315
+ dispatch_authority_enabled: false,
316
+ },
317
+ });
318
+ writeAgentTaskTerminalLifecycle({
319
+ rootDir: input.rootDir,
320
+ workflowId: input.workflowId,
321
+ laneId: input.laneId,
322
+ attemptId,
323
+ parentSessionRef,
324
+ agentRef: input.agentRef,
325
+ providerQualifiedModelId: input.providerQualifiedModelId,
326
+ state: "invocation_failed",
327
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}-invalid-parent`,
328
+ createdAt: observedAt,
329
+ updatedAt: observedAt,
330
+ });
331
+ return { status: "task_failed", failureCategory: "sdk_create_failed", redactedReason, laneId: input.laneId };
332
+ }
157
333
  const launchPlan = agentTaskLaunchPlan({
158
334
  workflowId: input.workflowId,
159
335
  laneId: input.laneId,
@@ -163,8 +339,6 @@ export async function executeFlowDeskAgentTaskV1(input) {
163
339
  token,
164
340
  });
165
341
  const runningLifecycleEvidenceId = `lifecycle-task-running-${input.laneId}-${token}`;
166
- const attemptId = launchPlan.attempt_id ?? `attempt-task-${token}`;
167
- const parentSessionRef = `ses-${input.parentSessionId}`;
168
342
  const promptTextTruncated = input.promptText.length > AGENT_TASK_CONTEXT_MAX_PROMPT_TEXT;
169
343
  const agentTaskContextRecord = {
170
344
  schema_version: "flowdesk.agent_task_context.v1",
@@ -189,17 +363,73 @@ export async function executeFlowDeskAgentTaskV1(input) {
189
363
  evidenceId: `agent-task-context-${input.taskId}-${token}`,
190
364
  record: agentTaskContextRecord,
191
365
  });
192
- // Launch the lane
193
- const launchResult = await launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1({
194
- client: input.client,
195
- launchPlan,
196
- request: {
197
- allowActualLaneLaunch: true,
198
- parentSessionId: input.parentSessionId,
199
- promptText: input.promptText,
200
- dispatchMethod: "prompt",
201
- },
366
+ writeAgentTaskProgress({
367
+ rootDir: input.rootDir,
368
+ workflowId: input.workflowId,
369
+ laneId: input.laneId,
370
+ taskId: input.taskId,
371
+ agentRef: input.agentRef,
372
+ providerQualifiedModelId: input.providerQualifiedModelId,
373
+ phase: "started",
374
+ progressSeq: 1,
375
+ progressLabel: "agent task lane launch started",
376
+ observedAt,
202
377
  });
378
+ // Launch the lane — wrap in absolute timeout so session.prompt blocking doesn't hang forever.
379
+ // The launch phase timeout is longer (5 min) since promptAsync may queue work before responding.
380
+ // 1 min default — if session.prompt blocks for more than 1 min with no activity, give up
381
+ const LAUNCH_TIMEOUT_MS = input._launchTimeoutMs ?? 60_000;
382
+ const launchTimeoutHandle = setTimeout(() => { }, LAUNCH_TIMEOUT_MS);
383
+ const dispatchMethod = input.client.session.promptAsync !== undefined ? "promptAsync" : "prompt";
384
+ const launchResult = await Promise.race([
385
+ launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1({
386
+ client: input.client,
387
+ launchPlan,
388
+ request: {
389
+ allowActualLaneLaunch: true,
390
+ parentSessionId: input.parentSessionId,
391
+ promptText: input.promptText,
392
+ dispatchMethod,
393
+ },
394
+ }),
395
+ new Promise(resolve => setTimeout(() => resolve({ status: "launch_timeout" }), LAUNCH_TIMEOUT_MS)),
396
+ ]);
397
+ clearTimeout(launchTimeoutHandle);
398
+ if ("status" in launchResult && launchResult.status === "launch_timeout") {
399
+ // session.prompt blocked for too long — treat as invocation failure
400
+ const failedEvidenceId = `task-failed-${input.taskId}-${token}-launch-timeout`;
401
+ writeSessionEvidence({
402
+ rootDir: input.rootDir,
403
+ workflowId: input.workflowId,
404
+ evidenceId: failedEvidenceId,
405
+ record: {
406
+ schema_version: "flowdesk.task_failed.v1",
407
+ workflow_id: input.workflowId,
408
+ lane_id: input.laneId,
409
+ task_id: input.taskId,
410
+ agent_ref: input.agentRef,
411
+ provider_qualified_model_id: input.providerQualifiedModelId,
412
+ failure_category: "sdk_create_failed",
413
+ redacted_reason: "lane launch timed out: session.prompt did not respond",
414
+ created_at: observedAt,
415
+ dispatch_authority_enabled: false,
416
+ },
417
+ });
418
+ writeAgentTaskTerminalLifecycle({
419
+ rootDir: input.rootDir,
420
+ workflowId: input.workflowId,
421
+ laneId: input.laneId,
422
+ attemptId,
423
+ parentSessionRef,
424
+ agentRef: input.agentRef,
425
+ providerQualifiedModelId: input.providerQualifiedModelId,
426
+ state: "invocation_failed",
427
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}-launch-timeout`,
428
+ createdAt: observedAt,
429
+ updatedAt: new Date().toISOString(),
430
+ });
431
+ return { status: "task_failed", failureCategory: "sdk_create_failed", redactedReason: "launch timeout: session.prompt did not respond within the allowed window", laneId: input.laneId };
432
+ }
203
433
  // Write running lifecycle evidence
204
434
  materializeFlowDeskRuntimeLaneLaunchLifecycleEvidenceV1({
205
435
  rootDir: input.rootDir,
@@ -278,22 +508,81 @@ export async function executeFlowDeskAgentTaskV1(input) {
278
508
  observedAt,
279
509
  progressSummaryLabel: `agent task lane launch heartbeat`,
280
510
  });
281
- // Extract child session ID and get response text
511
+ // Extract child session ID
282
512
  const childSessionId = launchResult.childSessionRef?.startsWith("ses-")
283
513
  ? launchResult.childSessionRef.slice("ses-".length)
284
514
  : undefined;
285
- let resultText;
515
+ // ── Async mode: return immediately, watchdog handles polling/nudging/abort ──
516
+ if (input.asyncMode === true) {
517
+ const resolvedChildId = childSessionId ?? "";
518
+ // Write child session evidence so watchdog can find it
519
+ writeSessionEvidence({
520
+ rootDir: input.rootDir,
521
+ workflowId: input.workflowId,
522
+ evidenceId: `agent-task-child-session-${input.laneId}-${token}`,
523
+ record: {
524
+ schema_version: AGENT_TASK_CHILD_SESSION_SCHEMA_VERSION,
525
+ workflow_id: input.workflowId,
526
+ lane_id: input.laneId,
527
+ task_id: input.taskId,
528
+ child_session_id: resolvedChildId,
529
+ parent_session_ref: parentSessionRef,
530
+ provider_qualified_model_id: input.providerQualifiedModelId,
531
+ agent_ref: input.agentRef,
532
+ nudge_count: 0,
533
+ last_nudge_at: null,
534
+ created_at: observedAt,
535
+ dispatch_authority_enabled: false,
536
+ },
537
+ });
538
+ writeAgentTaskProgress({
539
+ rootDir: input.rootDir,
540
+ workflowId: input.workflowId,
541
+ laneId: input.laneId,
542
+ taskId: input.taskId,
543
+ agentRef: input.agentRef,
544
+ providerQualifiedModelId: input.providerQualifiedModelId,
545
+ phase: "waiting",
546
+ progressSeq: 2,
547
+ progressLabel: "agent task waiting for async child result",
548
+ });
549
+ return { status: "task_launched", laneId: input.laneId, childSessionId: resolvedChildId };
550
+ }
551
+ let resultObservation;
286
552
  if (childSessionId !== undefined) {
287
- resultText = await extractAssistantTextFromResponse(input.client, childSessionId);
553
+ const runtimeModel = launchResult.status === "lane_launch_started" && typeof launchResult.model === "string"
554
+ ? launchResult.model : undefined;
555
+ const agentName = launchResult.status === "lane_launch_started" && typeof launchResult.agent === "string"
556
+ ? launchResult.agent : undefined;
557
+ resultObservation = await extractAssistantTextFromResponse(input.client, childSessionId, {
558
+ quietPeriodMs: input._nudgeQuietPeriodMs ?? 20_000, // default 20s per policy
559
+ maxNudges: 2,
560
+ runtimeModel,
561
+ agentName,
562
+ messagesTimeoutMs: input._messagesTimeoutMs,
563
+ heartbeatFn: (elapsedMs) => {
564
+ recordFlowDeskLaneHeartbeatV1({
565
+ rootDir: input.rootDir,
566
+ workflowId: input.workflowId,
567
+ attemptId,
568
+ laneId: input.laneId,
569
+ parentSessionRef,
570
+ agentRef: input.agentRef,
571
+ providerQualifiedModelId: input.providerQualifiedModelId,
572
+ state: "running",
573
+ observedAt: new Date().toISOString(),
574
+ progressSummaryLabel: `agent task waiting for response elapsed=${Math.floor(elapsedMs / 1000)}s`,
575
+ });
576
+ },
577
+ });
288
578
  }
289
- if (resultText === undefined || (input.outputContract === "final_assistant_text" && isProcessOnlyAssistantOutput(resultText))) {
579
+ const resultText = resultObservation?.text;
580
+ if (resultText === undefined) {
290
581
  // No response text - write task_failed
291
582
  const taskFailedEvidenceId = `task-failed-${input.taskId}-${token}`;
292
- const failureCategory = resultText === undefined ? "no_response" : "contract_not_satisfied";
293
- const evidenceFailureCategory = resultText === undefined ? "no_response" : "unknown";
294
- const redactedReason = resultText === undefined
295
- ? "lane launched but no assistant response text found"
296
- : "lane launched but final assistant response did not satisfy requested output contract";
583
+ const failureCategory = "no_response";
584
+ const evidenceFailureCategory = "no_response";
585
+ const redactedReason = "lane launched but no assistant response text found";
297
586
  const taskFailedRecord = {
298
587
  schema_version: "flowdesk.task_failed.v1",
299
588
  workflow_id: input.workflowId,
@@ -312,6 +601,17 @@ export async function executeFlowDeskAgentTaskV1(input) {
312
601
  evidenceId: taskFailedEvidenceId,
313
602
  record: taskFailedRecord,
314
603
  });
604
+ writeAgentTaskProgress({
605
+ rootDir: input.rootDir,
606
+ workflowId: input.workflowId,
607
+ laneId: input.laneId,
608
+ taskId: input.taskId,
609
+ agentRef: input.agentRef,
610
+ providerQualifiedModelId: input.providerQualifiedModelId,
611
+ phase: "failed",
612
+ progressSeq: 3,
613
+ progressLabel: failureCategory === "no_response" ? "agent task finished without response" : "agent task output contract not satisfied",
614
+ });
315
615
  writeAgentTaskTerminalLifecycle({
316
616
  rootDir: input.rootDir,
317
617
  workflowId: input.workflowId,
@@ -322,7 +622,7 @@ export async function executeFlowDeskAgentTaskV1(input) {
322
622
  messageRef: launchResult.messageRef?.startsWith("msg-") ? launchResult.messageRef : undefined,
323
623
  agentRef: input.agentRef,
324
624
  providerQualifiedModelId: input.providerQualifiedModelId,
325
- state: resultText === undefined ? "no_output" : "incomplete",
625
+ state: "no_output",
326
626
  evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}`,
327
627
  createdAt: observedAt,
328
628
  updatedAt: new Date().toISOString(),
@@ -335,10 +635,9 @@ export async function executeFlowDeskAgentTaskV1(input) {
335
635
  laneId: input.laneId,
336
636
  };
337
637
  }
338
- // Truncate if needed
339
638
  const fullResultText = resultText;
340
- const truncated = fullResultText.length > TASK_RESULT_MAX_TEXT;
341
- const storedResultText = truncated ? fullResultText.slice(0, TASK_RESULT_MAX_TEXT) : fullResultText;
639
+ const sanitizedResult = sanitizeFlowDeskTaskResultTextV1(fullResultText);
640
+ const storedResultText = sanitizedResult.text;
342
641
  const promptSha256 = sha256Hex(input.promptText);
343
642
  const resultSha256 = sha256Hex(fullResultText);
344
643
  // Write task_result evidence
@@ -352,17 +651,42 @@ export async function executeFlowDeskAgentTaskV1(input) {
352
651
  provider_qualified_model_id: input.providerQualifiedModelId,
353
652
  task_prompt_sha256: promptSha256,
354
653
  result_text: storedResultText,
355
- result_text_truncated: truncated,
654
+ result_text_truncated: sanitizedResult.truncated,
356
655
  result_text_sha256: resultSha256,
656
+ completion_status: resultObservation?.completionStatus ?? "final",
657
+ output_kind: resultObservation?.outputKind ?? "final_answer",
658
+ usable_for_synthesis: resultObservation?.usableForSynthesis ?? true,
659
+ missing_contract: input.outputContract === "final_assistant_text" &&
660
+ (resultObservation?.completionStatus !== "final" ||
661
+ ["empty", "process_notes", "tool_trace_only"].includes(String(resultObservation?.outputKind ?? ""))),
357
662
  created_at: observedAt,
358
663
  dispatch_authority_enabled: false,
359
664
  };
360
- writeSessionEvidence({
665
+ const taskResultWritten = writeSessionEvidence({
361
666
  rootDir: input.rootDir,
362
667
  workflowId: input.workflowId,
363
668
  evidenceId: taskResultEvidenceId,
364
669
  record: taskResultRecord,
365
670
  });
671
+ if (!taskResultWritten) {
672
+ return {
673
+ status: "task_failed",
674
+ failureCategory: "unknown",
675
+ redactedReason: "task_result evidence persistence failed",
676
+ laneId: input.laneId,
677
+ };
678
+ }
679
+ writeAgentTaskProgress({
680
+ rootDir: input.rootDir,
681
+ workflowId: input.workflowId,
682
+ laneId: input.laneId,
683
+ taskId: input.taskId,
684
+ agentRef: input.agentRef,
685
+ providerQualifiedModelId: input.providerQualifiedModelId,
686
+ phase: "finalizing",
687
+ progressSeq: 3,
688
+ progressLabel: "agent task result captured",
689
+ });
366
690
  writeAgentTaskTerminalLifecycle({
367
691
  rootDir: input.rootDir,
368
692
  workflowId: input.workflowId,
@@ -380,6 +704,11 @@ export async function executeFlowDeskAgentTaskV1(input) {
380
704
  updatedAt: new Date().toISOString(),
381
705
  timeoutMs: input.timeoutMs,
382
706
  });
707
+ refreshFlowDeskCompletionUiCachesV1({
708
+ rootDir: input.rootDir,
709
+ workflowId: input.workflowId,
710
+ observedAt,
711
+ });
383
712
  return {
384
713
  status: "task_completed",
385
714
  resultText: fullResultText,