@yeaft/webchat-agent 1.0.568 → 1.0.570

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.568",
3
+ "version": "1.0.570",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * Reference: yeaft-yeaft-implementation-plan.md §3.1, §4 (Phase 2)
18
18
  */
19
19
 
20
- import { randomUUID } from 'crypto';
20
+ import { randomUUID, createHash } from 'crypto';
21
21
  import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
@@ -50,6 +50,13 @@ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
50
50
  // Default thread marker for legacy / non-group flows. Group VP runtime may
51
51
  // pass a real threadId per (sessionId, vpId, threadId) engine instance.
52
52
  const MAIN_THREAD_ID = 'main';
53
+
54
+ function foldingMessageKey(message) {
55
+ return message?._persistedMessageId || message?.id || createHash('sha256').update(JSON.stringify([
56
+ message?.role, message?.toolCallId, message?.toolCalls, message?.content,
57
+ ])).digest('hex');
58
+ }
59
+
53
60
  import { pickEffort, parseEffortPrefix, snapshotEffortDecision } from './effort.js';
54
61
  import { bindProviderState } from './llm/provider-state.js';
55
62
  import {
@@ -1666,9 +1673,9 @@ export class Engine {
1666
1673
  return this.#conversationStore.append(this.#conversationRecord(message, context));
1667
1674
  }
1668
1675
 
1669
- #persistFoldedRange(messages, startIdx, endIdx, reflection, context = {}) {
1676
+ #persistFoldedRange(foldedMessages, reflection, context = {}) {
1670
1677
  if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
1671
- const persistedRows = (messages || []).slice(startIdx, endIdx + 1)
1678
+ const persistedRows = (foldedMessages || [])
1672
1679
  .map(message => message?._persistedMessageId || message?.id)
1673
1680
  .filter(id => typeof id === 'string' && id)
1674
1681
  .map(id => ({ id }));
@@ -4101,6 +4108,10 @@ export class Engine {
4101
4108
  assistantText,
4102
4109
  language: this.#config.language,
4103
4110
  signal,
4111
+ onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
4112
+ sessionId: runtimeSessionId, turnId: queryTurnId,
4113
+ trigger: 't2', model: this.#config.model, ...diagnostic,
4114
+ }),
4104
4115
  });
4105
4116
  // Detach: never await. The promise outlives this query() and
4106
4117
  // the next call will pick it up (or use the fallback stub if
@@ -4111,6 +4122,12 @@ export class Engine {
4111
4122
  const info = {
4112
4123
  promise,
4113
4124
  loopRange: [arcStart, arcEnd],
4125
+ // Absolute indexes do not survive history-window reconstruction.
4126
+ arcKeys: conversationMessages.slice(arcStart, arcEnd + 1).map(foldingMessageKey),
4127
+ arcRows: conversationMessages.slice(arcStart, arcEnd + 1).map(message => ({
4128
+ role: message.role, id: message.id, _persistedMessageId: message._persistedMessageId,
4129
+ toolCallId: message.toolCallId, toolCalls: message.toolCalls?.map(call => ({ id: call.id })),
4130
+ })),
4114
4131
  count: pairs.length,
4115
4132
  originalUserMsg: prompt,
4116
4133
  originatingTurnId: queryTurnId,
@@ -4904,24 +4921,25 @@ export class Engine {
4904
4921
  assistantText,
4905
4922
  language: this.#config.language,
4906
4923
  signal,
4924
+ onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
4925
+ sessionId: runtimeSessionId, turnId: queryTurnId,
4926
+ trigger: 't1', model: this.#config.model, ...diagnostic,
4927
+ }),
4907
4928
  });
4908
- const next = collapseRangeToReflection(
4929
+ const { messages: next, reflection: reflectionMessage, foldedMessages } = collapseRangeToReflection(
4909
4930
  conversationMessages, batchStart, batchEnd, content,
4910
4931
  );
4911
- const reflectionMessage = next[batchStart];
4912
- const durableRowsInRange = conversationMessages
4913
- .slice(batchStart, batchEnd + 1)
4932
+ const durableRowsInRange = foldedMessages
4914
4933
  .some(message => message?._persistedMessageId || message?.id);
4915
4934
  const persistedReflection = this.#persistFoldedRange(
4916
- conversationMessages,
4917
- batchStart,
4918
- batchEnd,
4935
+ foldedMessages,
4919
4936
  reflectionMessage,
4920
4937
  { sessionId: runtimeSessionId, model: currentModel, executionOrigin },
4921
4938
  );
4922
4939
  if (durableRowsInRange && !persistedReflection) {
4923
4940
  throw new Error('T1 reflection could not publish its durable range replacement');
4924
4941
  }
4942
+ if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
4925
4943
  // Raw tool rows inside the replacement range are now tombstoned in
4926
4944
  // durable history. Late async completions must follow the folded
4927
4945
  // continuation path rather than appending to those stale rows.
@@ -4934,11 +4952,12 @@ export class Engine {
4934
4952
  }
4935
4953
  conversationMessages.length = 0;
4936
4954
  for (const m of next) conversationMessages.push(m);
4937
- // After collapse: the just-inserted reflection lives at
4938
- // index `batchStart`. The next tool arc therefore starts
4955
+ // The preserved users precede the reflection. The next arc starts
4939
4956
  // immediately after it, i.e. at conversationMessages.length
4940
4957
  // (the next assistant message will land here).
4941
4958
  arcStartIdx = conversationMessages.length;
4959
+ // Read hints must not imply raw ranges remain in model context.
4960
+ fileReadObservations.clear();
4942
4961
  lastT1AtLoopCount = completedToolLoops;
4943
4962
  // Bump the success counter — used by the T2 schedule check
4944
4963
  // to decide whether T2 still has work to do at end_turn.
@@ -5180,12 +5199,13 @@ export class Engine {
5180
5199
  this.#pendingT2.clear();
5181
5200
 
5182
5201
  for (const [turnNumber, info] of drained) {
5183
- const range = info.loopRange;
5184
- if (!Array.isArray(range) || range.length !== 2) continue;
5185
- const [startIdx, endIdx] = range;
5186
- if (startIdx < 0 || endIdx < startIdx || endIdx >= conversationMessages.length) {
5187
- continue;
5188
- }
5202
+ const keys = info.arcKeys;
5203
+ if (!Array.isArray(keys) || keys.length === 0) continue;
5204
+ const currentKeys = conversationMessages.map(foldingMessageKey);
5205
+ const startIdx = currentKeys.findIndex((key, index) => key === keys[0]
5206
+ && keys.every((expected, offset) => currentKeys[index + offset] === expected));
5207
+ const endIdx = startIdx + keys.length - 1;
5208
+ const rangePresent = startIdx >= 0;
5189
5209
 
5190
5210
  // PR-L follow-up: deterministic readiness check. The info record
5191
5211
  // carries `ready / result / error` flags that are flipped from the
@@ -5214,15 +5234,13 @@ export class Engine {
5214
5234
  }
5215
5235
 
5216
5236
  // Rewrite history and publish the same logical replacement to disk.
5217
- const next = collapseRangeToReflection(conversationMessages, startIdx, endIdx, content);
5218
- const reflectionMessage = next[startIdx];
5219
- const durableRowsInRange = conversationMessages
5220
- .slice(startIdx, endIdx + 1)
5237
+ const { messages: next, reflection: reflectionMessage, foldedMessages } = rangePresent
5238
+ ? collapseRangeToReflection(conversationMessages, startIdx, endIdx, content)
5239
+ : collapseRangeToReflection(info.arcRows, 0, info.arcRows.length - 1, content);
5240
+ const durableRowsInRange = foldedMessages
5221
5241
  .some(message => message?._persistedMessageId || message?.id);
5222
5242
  const persistedReflection = this.#persistFoldedRange(
5223
- conversationMessages,
5224
- startIdx,
5225
- endIdx,
5243
+ foldedMessages,
5226
5244
  reflectionMessage,
5227
5245
  {
5228
5246
  ...context,
@@ -5231,26 +5249,31 @@ export class Engine {
5231
5249
  },
5232
5250
  );
5233
5251
  if (durableRowsInRange && !persistedReflection) continue;
5252
+ if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
5234
5253
  // T2 replaces this arc in durable and in-memory history just like T1.
5235
5254
  // Forget its raw result handles before a late async task can append to a
5236
5255
  // tombstoned tool row at the next provider boundary.
5237
- const foldedToolCallIds = conversationMessages
5238
- .slice(startIdx, endIdx + 1)
5256
+ const foldedToolCallIds = foldedMessages
5239
5257
  .filter(message => message?.role === 'tool' && message.toolCallId)
5240
5258
  .map(message => message.toolCallId);
5241
5259
  for (const toolCallId of foldedToolCallIds) {
5242
5260
  this.#persistedToolMessages.delete(toolCallId);
5243
5261
  }
5244
- // Mutate in place so caller's reference stays valid.
5245
- conversationMessages.length = 0;
5246
- for (const m of next) conversationMessages.push(m);
5262
+ // History may have evicted the old arc. Publish only its original
5263
+ // durable row IDs; never replace unrelated rows at stale indexes.
5264
+ if (rangePresent) {
5265
+ conversationMessages.length = 0;
5266
+ for (const m of next) conversationMessages.push(m);
5267
+ }
5247
5268
 
5248
5269
  yield {
5249
5270
  type: 'reflection',
5250
5271
  turnId: info.originatingTurnId || null,
5251
5272
  trigger,
5252
5273
  status: 'ready',
5253
- loopRange: [startIdx, endIdx],
5274
+ // Wire identity must match the original pending card, even if the
5275
+ // history window shifted or evicted its internal replacement range.
5276
+ loopRange: info.loopRange,
5254
5277
  toolCount: info.count || 0,
5255
5278
  content,
5256
5279
  durationMs,
@@ -64,6 +64,14 @@ export class SubAgentToolRegistry extends ToolRegistry {
64
64
  const limit = agent.budget?.max_tool_calls;
65
65
  const llmLimit = agent.budget?.max_llm_calls;
66
66
  const llmCalls = agent.usage?.llmCalls || 0;
67
+ if (agent.finalizationRequested) {
68
+ agent.finalizationStarted = true;
69
+ return {
70
+ finalize: true,
71
+ maxOutputTokens: 4096,
72
+ prompt: '[Parent wrap-up control] Stop investigating and do not call tools. Return the final task report now using only evidence already collected. State verification, incomplete work, and blockers honestly; do not claim success merely because the lifecycle is ending.',
73
+ };
74
+ }
67
75
  const reason = limit && stats.toolCalls >= limit ? `max_tool_calls (${limit}) reached`
68
76
  : llmLimit && llmCalls >= llmLimit ? `max_llm_calls (${llmLimit}) reached` : null;
69
77
  if (reason) {
@@ -112,6 +120,9 @@ export class SubAgentToolRegistry extends ToolRegistry {
112
120
  // after cancellation, even when the underlying tool ignores AbortSignal.
113
121
  const signal = agent.abortController?.signal;
114
122
  if (signal?.aborted) throw new Error(String(signal.reason || 'Sub-agent aborted'));
123
+ if (agent.finalizationRequested) {
124
+ throw new Error('Parent requested finalization; no further child tools may execute');
125
+ }
115
126
  const stats = agent.execution || (agent.execution = createExecutionStats());
116
127
  const limit = agent.budget?.max_tool_calls;
117
128
  if (limit !== undefined && stats.toolCalls >= limit) {
@@ -104,6 +104,10 @@ export function enqueueTerminalNotification(input) {
104
104
  budgetExceeded: Boolean(input.budgetExceeded),
105
105
  budgetReason: input.budgetReason || null,
106
106
  budgetUsage: input.budgetUsage || null,
107
+ outcome: input.outcome || (input.budgetExceeded ? 'incomplete' : null),
108
+ incomplete: Boolean(input.incomplete || input.budgetExceeded),
109
+ truncated: Boolean(input.truncated),
110
+ finalReport: input.finalReport || null,
107
111
  createdAt: Date.now(),
108
112
  };
109
113
  const key = bucketKey(scope);
@@ -218,17 +222,29 @@ export function formatNotificationsForPrompt(notifs) {
218
222
  );
219
223
  for (const n of notifs) {
220
224
  parts.push('');
221
- parts.push(`<notification agent="${n.agentName}" id="${n.agentId}" status="${n.status}" turns="${n.turns}">`);
225
+ parts.push(`<notification agent="${n.agentName}" id="${n.agentId}" status="${n.status}" outcome="${n.outcome || 'unknown'}" turns="${n.turns}">`);
222
226
  if (n.error) parts.push(` error: ${n.error}`);
227
+ if (n.incomplete) {
228
+ parts.push(' incomplete: true');
229
+ parts.push(' warning: This is partial evidence, not task success; do not treat verdict text such as APPROVE as a completed review.');
230
+ }
231
+ if (n.truncated) parts.push(' truncated: true');
223
232
  if (n.budgetExceeded) {
224
233
  parts.push(' budgetExceeded: true');
225
234
  if (n.budgetReason) parts.push(` budgetReason: ${n.budgetReason}`);
226
235
  if (n.budgetUsage) parts.push(` budgetUsage: ${JSON.stringify(n.budgetUsage)}`);
227
236
  }
228
237
  if (n.outputFile) parts.push(` outputFile: ${n.outputFile}`);
238
+ if (n.finalReport) {
239
+ parts.push(` finalReportReserved: ${Boolean(n.finalReport.reserved)}`);
240
+ parts.push(` finalReportReceived: ${Boolean(n.finalReport.received)}`);
241
+ parts.push(` finalReportTruncated: ${Boolean(n.finalReport.truncated)}`);
242
+ }
229
243
  if (n.result) {
230
- const r = n.result.length > 1500 ? n.result.slice(0, 1500) + '…(truncated)' : n.result;
231
- parts.push(' result:');
244
+ const clipped = n.result.length > 1500;
245
+ const r = clipped ? n.result.slice(0, 1500) + '…(display truncated)' : n.result;
246
+ if (clipped) parts.push(' displayTruncated: true');
247
+ parts.push(n.incomplete ? ' partialOutput:' : ' result:');
232
248
  parts.push(` ${r.split('\n').join('\n ')}`);
233
249
  }
234
250
  parts.push('</notification>');
@@ -0,0 +1,39 @@
1
+ import { STATUS, isTerminalAgentStatus } from './status.js';
2
+
3
+ /** Lifecycle state and task outcome are separate contracts. */
4
+ export function describeAgentOutcome(agent) {
5
+ const result = agent?.result;
6
+ const budgetResult = result && typeof result === 'object'
7
+ && result.status === 'budget_exceeded' ? result : null;
8
+ if (budgetResult) {
9
+ return {
10
+ status: 'incomplete',
11
+ complete: false,
12
+ reason: 'budget_exceeded',
13
+ truncated: Boolean(budgetResult.truncated || budgetResult.final_report?.truncated),
14
+ };
15
+ }
16
+ if (agent?.finalizationRequested) {
17
+ return { status: 'incomplete', complete: false, reason: 'parent_requested_finalization',
18
+ truncated: Boolean(agent.finalReport?.truncated) };
19
+ }
20
+ switch (agent?.status) {
21
+ case STATUS.COMPLETED:
22
+ return { status: 'succeeded', complete: true, reason: null, truncated: false };
23
+ case STATUS.FAILED:
24
+ return { status: 'failed', complete: false, reason: 'execution_failed', truncated: false };
25
+ case STATUS.CLOSED:
26
+ return { status: 'cancelled', complete: false, reason: 'closed', truncated: false };
27
+ case STATUS.ABANDONED:
28
+ return { status: 'incomplete', complete: false, reason: 'abandoned', truncated: false };
29
+ default:
30
+ return { status: 'pending', complete: false, reason: null, truncated: false };
31
+ }
32
+ }
33
+
34
+ export function describeAgentLifecycle(agent) {
35
+ return {
36
+ status: agent?.status || null,
37
+ terminal: isTerminalAgentStatus(agent?.status),
38
+ };
39
+ }
@@ -42,6 +42,7 @@ import { getPersona } from '../personas.js';
42
42
  import { RESTRICTED_TOOLS, createChildToolPolicy } from './tool-access.js';
43
43
  import { buildSpawnedPreamble } from './spawned-prompt.js';
44
44
  import { STATUS, isTerminalAgentStatus } from './status.js';
45
+ import { describeAgentOutcome } from './outcome.js';
45
46
  import { createOutputLog } from './output-log.js';
46
47
  import { makeLiveness, bumpLivenessFromEvent } from './liveness.js';
47
48
  import { consumeNotificationForAgent, enqueueTerminalNotification } from './notifications.js';
@@ -259,6 +260,8 @@ export function startSubAgent(agent, deps = {}) {
259
260
  function buildWallTimeBudgetResult(agent, reason) {
260
261
  return {
261
262
  status: 'budget_exceeded',
263
+ outcome: 'incomplete',
264
+ complete: false,
262
265
  partial_output: agent.partial_output || agent.lastResult
263
266
  || (typeof agent.result === 'string' ? agent.result : agent.result?.partial_output) || '',
264
267
  reason,
@@ -343,6 +346,19 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
343
346
  };
344
347
 
345
348
  const dequeueNextUserPrompt = () => {
349
+ if (agent.finalizationRequested && !agent.finalizationStarted) {
350
+ agent.finalizationStarted = true;
351
+ return {
352
+ // The parent's reason is audit evidence, not a child prompt. The
353
+ // registry supplies the authenticated control instruction separately.
354
+ prompt: 'Return the requested evidence-only final report; identify any unchecked scope.',
355
+ finalization: true,
356
+ parentEffortDecision: snapshotEffortDecision(agent.parentEffortDecision),
357
+ projectSessionIds: Array.isArray(deps.projectSessionIds) ? deps.projectSessionIds.slice() : [],
358
+ projectLabel: typeof deps.projectLabel === 'string' ? deps.projectLabel : '',
359
+ projectInstruction: typeof deps.projectInstruction === 'string' ? deps.projectInstruction : '',
360
+ };
361
+ }
346
362
  if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
347
363
  const entry = agent.pendingPrompts.shift();
348
364
  if (!entry) return null;
@@ -444,7 +460,9 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
444
460
  agent.result = '';
445
461
  let assistantText = '';
446
462
  let budgetReportText = '';
463
+ let finalReportText = '';
447
464
  let endedNormally = false;
465
+ let outputTruncated = false;
448
466
  let streamError = null;
449
467
  const priorUsageTokens = agent.usage?.tokens || 0;
450
468
  let turnUsageTokens = 0;
@@ -489,6 +507,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
489
507
  if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
490
508
  assistantText += evt.text;
491
509
  if (agent.budgetReportStarted) budgetReportText += evt.text;
510
+ if (agent.finalizationStarted) finalReportText += evt.text;
492
511
  // Mid-stream visibility: keep lastResult fresh so a parent
493
512
  // calling WaitAgent during a long generation sees what the
494
513
  // child is currently saying, not stale text from the prior
@@ -509,6 +528,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
509
528
  streamError = evt.error.message || String(evt.error);
510
529
  }
511
530
  if (evt && evt.type === 'stop') {
531
+ if (evt.stopReason === 'max_tokens') outputTruncated = true;
512
532
  if (evt.stopReason === 'end_turn' || evt.stopReason === 'stop_sequence') {
513
533
  endedNormally = true;
514
534
  }
@@ -544,6 +564,13 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
544
564
  const reason = agent.executionBudgetReason || agent.toolBudgetReason;
545
565
  agent.result = buildWallTimeBudgetResult(agent, reason);
546
566
  agent.result.reporting = { attempted: !!agent.budgetReportStarted, received: !!budgetReportText.trim() };
567
+ agent.result.truncated = outputTruncated;
568
+ agent.result.final_report = {
569
+ reserved: true,
570
+ received: !!budgetReportText.trim(),
571
+ truncated: outputTruncated,
572
+ text: budgetReportText.trim(),
573
+ };
547
574
  if (streamError) agent.result.reporting.error = streamError;
548
575
  agent.usage.turns += 1;
549
576
  agent.result.usage = { ...agent.usage };
@@ -553,6 +580,18 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
553
580
  return;
554
581
  }
555
582
 
583
+ if (agent.finalizationRequested) {
584
+ agent.finalReport = { reserved: true, received: !!finalReportText.trim(),
585
+ truncated: outputTruncated, text: finalReportText.trim() };
586
+ agent.result = finalReportText.trim() || assistantText.trim();
587
+ agent.lastResult = capTail(agent.result, LAST_RESULT_MAX_CHARS);
588
+ agent.usage.turns += 1;
589
+ transitionTerminal(agent, STATUS.COMPLETED, {
590
+ error: streamError, diagnostic: 'parent_requested_finalization', deps,
591
+ });
592
+ return;
593
+ }
594
+
556
595
  if (streamError) {
557
596
  transitionTerminal(agent, STATUS.FAILED, {
558
597
  error: streamError,
@@ -673,11 +712,13 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
673
712
  if (agent.__terminalNotified) return;
674
713
  agent.__terminalNotified = true;
675
714
 
715
+ const outcome = describeAgentOutcome(agent);
676
716
  const evt = {
677
717
  type: 'sub_agent_status',
678
718
  agentId: agent.id,
679
719
  agentName: agent.name,
680
720
  status,
721
+ outcome: outcome.status,
681
722
  error: error || agent.error || null,
682
723
  parentSessionId: agent.parentSessionId || deps?.parentSessionId || null,
683
724
  parentVpId: agent.parentVpId || deps?.parentVpId || null,
@@ -686,14 +727,17 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
686
727
  try { agent.outputLog?.write(evt); } catch { /* ignore */ }
687
728
  if (agent.taskId && deps?.taskManager && agent.parentSessionId) {
688
729
  const budgetExceeded = agent.result?.status === 'budget_exceeded';
689
- const taskStatus = budgetExceeded ? 'failed' : status === STATUS.COMPLETED ? 'succeeded'
730
+ const taskStatus = outcome.status === 'incomplete' ? 'failed' : status === STATUS.COMPLETED ? 'succeeded'
690
731
  : status === STATUS.CLOSED ? 'cancelled'
691
732
  : 'failed';
692
733
  try {
693
734
  deps.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
694
735
  status: taskStatus,
695
- error: budgetExceeded ? agent.result.reason : (error || agent.error || null),
696
- summary: budgetExceeded ? JSON.stringify(agent.result) : status === STATUS.COMPLETED
736
+ error: outcome.status === 'incomplete' ? (agent.result?.reason || outcome.reason) : (error || agent.error || null),
737
+ summary: budgetExceeded ? JSON.stringify(agent.result) : agent.finalizationRequested
738
+ ? JSON.stringify({ outcome: outcome.status, complete: false, reason: outcome.reason,
739
+ partial_output: agent.result || agent.lastResult || '', final_report: agent.finalReport || null })
740
+ : status === STATUS.COMPLETED
697
741
  ? (typeof agent.result === 'string' ? agent.result : (agent.lastResult || null))
698
742
  : null,
699
743
  });
@@ -731,6 +775,10 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
731
775
  budgetExceeded: !!budgetResult,
732
776
  budgetReason: budgetResult?.reason || null,
733
777
  budgetUsage: budgetResult?.usage || null,
778
+ outcome: outcome.status,
779
+ incomplete: !outcome.complete,
780
+ truncated: outcome.truncated,
781
+ finalReport: budgetResult?.final_report || agent.finalReport || null,
734
782
  });
735
783
  } catch { /* never let the notification queue throw kill the driver */ }
736
784
  }
@@ -756,6 +804,9 @@ function waitUntilResumed(agent, idleAbandonMs) {
756
804
  if (Array.isArray(agent.pendingPrompts) && agent.pendingPrompts.length > 0) {
757
805
  return resolve('prompt');
758
806
  }
807
+ if (agent.finalizationRequested && !agent.finalizationStarted) {
808
+ return resolve('prompt');
809
+ }
759
810
  if (idleAbandonMs > 0 && Date.now() - start >= idleAbandonMs) {
760
811
  return resolve('abandoned');
761
812
  }
@@ -21,8 +21,8 @@
21
21
  * - 'running' : the sub-engine is processing a prompt.
22
22
  * - 'idle' : the previous turn ended cleanly and the queue is empty.
23
23
  * The driver is parked in waitUntilResumed().
24
- * - 'completed' : terminal success — typically set by tickAgent() when a
25
- * budget cutoff is hit cleanly with a partial_output.
24
+ * - 'completed' : terminal lifecycle state. Inspect the separate outcome;
25
+ * a budget cutoff also ends the lifecycle but is incomplete.
26
26
  * - 'failed' : terminal — driver/adapter/stream raised; agent.error set.
27
27
  * - 'closed' : terminal — CloseAgent called (or driver finally{} reaped
28
28
  * a cleanly-finishing agent).
@@ -72,7 +72,7 @@ export function describeAgentStatus(status) {
72
72
  case STATUS.CREATED: return 'just spawned';
73
73
  case STATUS.RUNNING: return 'running a turn';
74
74
  case STATUS.IDLE: return 'idle (turn ended, queue empty)';
75
- case STATUS.COMPLETED: return 'completed (terminal)';
75
+ case STATUS.COMPLETED: return 'completed lifecycle (terminal; inspect outcome)';
76
76
  case STATUS.FAILED: return 'failed (terminal)';
77
77
  case STATUS.CLOSED: return 'closed (terminal)';
78
78
  case STATUS.ABANDONED: return 'abandoned by idle watchdog (terminal)';
@@ -66,6 +66,7 @@ export class TaskManager {
66
66
  this.active = new Map();
67
67
  this.processes = new Map();
68
68
  this.cancelEscalationTimers = new Map();
69
+ this.waiters = new Map();
69
70
  this.pendingStartupEvents = [];
70
71
  this.#loadPersistedRunningTasks();
71
72
  }
@@ -88,6 +89,20 @@ export class TaskManager {
88
89
  try { sink?.(event); } catch { /* event sinks must not break tasks */ }
89
90
  }
90
91
 
92
+ #taskForOwner(sessionId, taskId, ownerVpId = null) {
93
+ const task = this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId);
94
+ if (!task) return null;
95
+ if (ownerVpId && task.ownerVpId !== ownerVpId) return null;
96
+ return task;
97
+ }
98
+
99
+ #settleWaiters(key, task) {
100
+ const waiters = this.waiters.get(key);
101
+ if (!waiters) return;
102
+ this.waiters.delete(key);
103
+ for (const settle of [...waiters]) settle(task);
104
+ }
105
+
91
106
  #emit(event, task, extra = {}, { deferUntilSink = false } = {}) {
92
107
  const payload = { type: 'yeaft_task_event', event, task: publicSnapshot(task), ...extra };
93
108
  if (!this.onEvent && deferUntilSink) {
@@ -246,12 +261,13 @@ export class TaskManager {
246
261
  this.active.delete(key);
247
262
  this.processes.delete(key);
248
263
  this.#emit('completed', task);
264
+ this.#settleWaiters(key, task);
249
265
  return publicSnapshot(task);
250
266
  }
251
267
 
252
- cancelTask(sessionId, taskId) {
268
+ cancelTask(sessionId, taskId, ownerVpId = null) {
253
269
  const key = this.#key(sessionId, taskId);
254
- const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
270
+ const task = this.#taskForOwner(sessionId, taskId, ownerVpId);
255
271
  if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
256
272
  if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
257
273
  const runner = this.processes.get(key);
@@ -312,21 +328,77 @@ export class TaskManager {
312
328
  return { ok: true, task: publicSnapshot(task), pending: true };
313
329
  }
314
330
 
315
- listActiveTasks(sessionId = null) {
316
- const tasks = Array.from(this.active.values()).filter(task => !sessionId || task.sessionId === sessionId);
331
+ listActiveTasks(sessionId = null, ownerVpId = null) {
332
+ const tasks = Array.from(this.active.values()).filter(task => (
333
+ (!sessionId || task.sessionId === sessionId)
334
+ && (!ownerVpId || task.ownerVpId === ownerVpId)
335
+ ));
317
336
  return tasks.map(publicSnapshot);
318
337
  }
319
338
 
320
- getTask(sessionId, taskId) {
321
- return publicSnapshot(this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId));
339
+ getTask(sessionId, taskId, ownerVpId = null) {
340
+ return publicSnapshot(this.#taskForOwner(sessionId, taskId, ownerVpId));
322
341
  }
323
342
 
324
- readTaskLog(sessionId, taskId, opts = {}) {
325
- const task = this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId);
343
+ readTaskLog(sessionId, taskId, opts = {}, ownerVpId = null) {
344
+ const task = this.#taskForOwner(sessionId, taskId, ownerVpId);
345
+ if (!task) return null;
326
346
  if (task?.log?.path) return this.store.readLogFile(task.log.path, opts);
327
347
  return this.store.readLog(sessionId, taskId, opts);
328
348
  }
329
349
 
350
+ /** Wait for exactly one task without reading its log into memory. */
351
+ waitForTask(sessionId, taskId, { timeoutMs = 120_000, signal = null, ownerVpId = null } = {}) {
352
+ const key = this.#key(sessionId, taskId);
353
+ const initial = this.#taskForOwner(sessionId, taskId, ownerVpId);
354
+ if (!initial) return Promise.resolve({ ok: false, error: `Unknown task: ${taskId}` });
355
+ if (isTerminalTaskStatus(initial.status)) {
356
+ return Promise.resolve({ ok: true, timedOut: false, task: publicSnapshot(initial) });
357
+ }
358
+
359
+ return new Promise((resolve, reject) => {
360
+ let timer = null;
361
+ let settled = false;
362
+ const waiters = this.waiters.get(key) || new Set();
363
+ this.waiters.set(key, waiters);
364
+ const cleanup = () => {
365
+ if (timer) clearTimeout(timer);
366
+ signal?.removeEventListener?.('abort', onAbort);
367
+ waiters.delete(onComplete);
368
+ if (waiters.size === 0 && this.waiters.get(key) === waiters) this.waiters.delete(key);
369
+ };
370
+ const finish = (value, error = null) => {
371
+ if (settled) return;
372
+ settled = true;
373
+ cleanup();
374
+ if (error) reject(error);
375
+ else resolve(value);
376
+ };
377
+ const onComplete = task => finish({ ok: true, timedOut: false, task: publicSnapshot(task) });
378
+ const onAbort = () => {
379
+ const error = new Error('Task wait aborted');
380
+ error.name = 'AbortError';
381
+ finish(null, error);
382
+ };
383
+ waiters.add(onComplete);
384
+
385
+ // Register before the second read so completion cannot be missed.
386
+ const current = this.#taskForOwner(sessionId, taskId, ownerVpId);
387
+ if (!current) finish({ ok: false, error: `Unknown task: ${taskId}` });
388
+ else if (isTerminalTaskStatus(current.status)) onComplete(current);
389
+ else if (signal?.aborted) onAbort();
390
+ else {
391
+ signal?.addEventListener?.('abort', onAbort, { once: true });
392
+ timer = setTimeout(() => {
393
+ const latest = this.#taskForOwner(sessionId, taskId, ownerVpId);
394
+ if (!latest) finish({ ok: false, error: `Unknown task: ${taskId}` });
395
+ else finish({ ok: true, timedOut: !isTerminalTaskStatus(latest.status), task: publicSnapshot(latest) });
396
+ }, Math.max(0, Number.isFinite(timeoutMs) ? Math.floor(timeoutMs) : 120_000));
397
+ if (typeof timer.unref === 'function') timer.unref();
398
+ }
399
+ });
400
+ }
401
+
330
402
  setTaskLogPath(sessionId, taskId, logPath) {
331
403
  if (!logPath || typeof logPath !== 'string') return null;
332
404
  const key = this.#key(sessionId, taskId);