@piagent/platform 1.6.0 → 1.6.1

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 (213) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +17 -14
  3. package/README.vi.md +26 -1
  4. package/benchmarks/production-v1/spend-control.v1.json +27 -3
  5. package/benchmarks/production-v1/suite.json +5 -1
  6. package/catalog/capabilities.json +16 -16
  7. package/docs/authority-manifest.md +1 -1
  8. package/docs/auto-delegation-policy.md +38 -73
  9. package/docs/command-reference-vietnamese.md +23 -31
  10. package/docs/context-engine.md +5 -3
  11. package/docs/context-window-policy.md +37 -1
  12. package/docs/distribution-standard.md +7 -7
  13. package/docs/en/architecture.md +26 -13
  14. package/docs/languages.json +4 -4
  15. package/docs/operator-manual-vietnamese.md +21 -41
  16. package/docs/publishing-for-teams.md +5 -0
  17. package/docs/quality-benchmark.md +179 -23
  18. package/docs/release-install-policy.md +6 -6
  19. package/docs/runtime-harness-standard.md +3 -3
  20. package/docs/security-threat-model.md +1 -1
  21. package/docs/subagent-orchestration-capabilities.md +19 -51
  22. package/docs/subagents-and-multiagent.md +21 -47
  23. package/docs/task-implementation-contract.md +3 -3
  24. package/docs/task-lifecycle-vietnamese.md +1 -1
  25. package/docs/team-onboarding.md +2 -2
  26. package/docs/vi/architecture.md +26 -13
  27. package/docs-site/architecture.html +1 -1
  28. package/docs-site/benchmark.html +89 -95
  29. package/docs-site/commands.html +1 -1
  30. package/docs-site/content/benchmark.html +88 -94
  31. package/docs-site/content/en/benchmark.html +134 -90
  32. package/docs-site/content/en/index.html +19 -19
  33. package/docs-site/content/en/runtime.html +4 -4
  34. package/docs-site/content/en/whats-new.html +12 -1
  35. package/docs-site/content/en/workflows.html +2 -2
  36. package/docs-site/content/index.html +19 -19
  37. package/docs-site/content/runtime.html +4 -4
  38. package/docs-site/content/whats-new.html +12 -1
  39. package/docs-site/content/workflows.html +2 -2
  40. package/docs-site/context-engine.html +1 -1
  41. package/docs-site/en/architecture.html +1 -1
  42. package/docs-site/en/benchmark.html +135 -91
  43. package/docs-site/en/commands.html +1 -1
  44. package/docs-site/en/context-engine.html +1 -1
  45. package/docs-site/en/index.html +20 -20
  46. package/docs-site/en/mcp-approval.html +1 -1
  47. package/docs-site/en/mcp-auth.html +1 -1
  48. package/docs-site/en/mcp-servers.html +1 -1
  49. package/docs-site/en/mcp.html +1 -1
  50. package/docs-site/en/permissions.html +1 -1
  51. package/docs-site/en/profiles.html +1 -1
  52. package/docs-site/en/quickstart.html +1 -1
  53. package/docs-site/en/runtime.html +5 -5
  54. package/docs-site/en/scope.html +1 -1
  55. package/docs-site/en/security.html +1 -1
  56. package/docs-site/en/team.html +1 -1
  57. package/docs-site/en/whats-new.html +13 -2
  58. package/docs-site/en/workflows.html +3 -3
  59. package/docs-site/index.html +20 -20
  60. package/docs-site/mcp-approval.html +1 -1
  61. package/docs-site/mcp-auth.html +1 -1
  62. package/docs-site/mcp-servers.html +1 -1
  63. package/docs-site/mcp.html +1 -1
  64. package/docs-site/permissions.html +1 -1
  65. package/docs-site/profiles.html +1 -1
  66. package/docs-site/quickstart.html +1 -1
  67. package/docs-site/runtime.html +5 -5
  68. package/docs-site/scope.html +1 -1
  69. package/docs-site/security.html +1 -1
  70. package/docs-site/team.html +1 -1
  71. package/docs-site/whats-new.html +13 -2
  72. package/docs-site/workflows.html +3 -3
  73. package/evals/fixtures/benchmark-assurance-evidence.invalid.json +1 -1
  74. package/evals/fixtures/benchmark-assurance-evidence.valid.json +1 -1
  75. package/evals/fixtures/codex-relative-efficiency-v1.invalid.json +4 -0
  76. package/evals/fixtures/codex-relative-efficiency-v1.valid.json +94 -0
  77. package/evals/fixtures/helper-request.invalid.json +1 -1
  78. package/evals/fixtures/helper-request.valid.json +1 -1
  79. package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +7 -0
  80. package/evals/fixtures/task-authority-snapshot.valid.json +4 -4
  81. package/evals/fs4-readiness-matrix.v1.json +2 -2
  82. package/evals/fs5-causal-arm.v1.json +2 -2
  83. package/evals/fs5-pilot-protocol.v1.json +3 -3
  84. package/evals/fs5-pilot-protocol.v2.json +8 -8
  85. package/evals/fs5-pilot-protocol.v3.json +8 -8
  86. package/evals/fs5-pilot-protocol.v4.json +6 -6
  87. package/evals/fs5-pilot-protocol.v5.json +7 -6
  88. package/evals/private-holdout-v1/public-exposure.v1.json +1 -1
  89. package/evals/runtime-conformance-v1/README.md +43 -0
  90. package/evals/runtime-conformance-v1/evaluate-paid-canary.mjs +198 -0
  91. package/evals/runtime-conformance-v1/lane.json +57 -0
  92. package/evals/runtime-conformance-v1/paid-canary.json +31 -0
  93. package/evals/runtime-conformance-v1/runner.mjs +456 -0
  94. package/package.json +1 -1
  95. package/packages/piagent-core/README.md +13 -4
  96. package/packages/piagent-core/benchmark/benchmark-codex-relative-efficiency.js +698 -0
  97. package/packages/piagent-core/benchmark/benchmark-core.js +4 -3
  98. package/packages/piagent-core/benchmark/benchmark-forensics.js +4 -2
  99. package/packages/piagent-core/benchmark/benchmark-preflight.js +3 -1
  100. package/packages/piagent-core/benchmark/benchmark-provider-free-evidence.js +345 -0
  101. package/packages/piagent-core/benchmark/benchmark-record-validation.js +74 -5
  102. package/packages/piagent-core/benchmark/benchmark-report.js +15 -1
  103. package/packages/piagent-core/benchmark/benchmark-runtime-causal.js +244 -0
  104. package/packages/piagent-core/benchmark/benchmark-stage-diagnostic.js +201 -4
  105. package/packages/piagent-core/benchmark/benchmark-suite.js +22 -1
  106. package/packages/piagent-core/benchmark/benchmark-summary-support.js +4 -0
  107. package/packages/piagent-core/benchmark/benchmark-usage.js +15 -4
  108. package/packages/piagent-core/capabilities/runtime-integrity.js +3 -0
  109. package/packages/piagent-core/extensions/acceptance-behavior-proof.js +806 -0
  110. package/packages/piagent-core/extensions/acceptance-receipt.js +89 -48
  111. package/packages/piagent-core/extensions/context-engine.js +1 -11
  112. package/packages/piagent-core/extensions/context-index-policy.js +4 -0
  113. package/packages/piagent-core/extensions/context-query-paths.js +57 -0
  114. package/packages/piagent-core/extensions/criterion-context-pack.js +16 -3
  115. package/packages/piagent-core/extensions/criterion-graph.js +4 -1
  116. package/packages/piagent-core/extensions/fallback-base-policy.ts +101 -0
  117. package/packages/piagent-core/extensions/guard-types.ts +4 -0
  118. package/packages/piagent-core/extensions/piagent-guard.ts +194 -226
  119. package/packages/piagent-core/extensions/task-lifecycle.js +32 -0
  120. package/packages/piagent-core/extensions/task-state.js +69 -8
  121. package/packages/piagent-core/extensions/verification-intelligence.js +49 -12
  122. package/packages/piagent-core/extensions/workspace-evidence-roots.js +52 -16
  123. package/packages/piagent-core/package.json +1 -1
  124. package/packages/piagent-core/policies/base-policy.json +42 -2
  125. package/packages/piagent-core/policy/authority-manifest.v1.json +3 -3
  126. package/packages/piagent-core/prompts/be-to-fe.md +2 -8
  127. package/packages/piagent-core/prompts/plan.md +1 -1
  128. package/packages/piagent-core/prompts/platform-improve.md +2 -6
  129. package/packages/piagent-core/prompts/review.md +2 -7
  130. package/packages/piagent-core/prompts/scout.md +1 -1
  131. package/packages/piagent-core/prompts/task.md +4 -3
  132. package/packages/piagent-core/runtime/hooks/completion-hook.ts +7 -6
  133. package/packages/piagent-core/runtime/hooks/input-hook.ts +6 -0
  134. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +100 -1
  135. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +58 -72
  136. package/packages/piagent-core/runtime/hooks/tool-result-value-helpers.ts +66 -0
  137. package/packages/piagent-core/runtime/inspection/bounded-emission-guard.ts +286 -0
  138. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +37 -7
  139. package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +27 -4
  140. package/packages/piagent-core/runtime/inspection/tool-failure-classification.ts +20 -3
  141. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +47 -11
  142. package/packages/piagent-core/runtime/orchestration/helper-lifecycle.ts +16 -12
  143. package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +71 -0
  144. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +28 -20
  145. package/packages/piagent-core/runtime/orchestration/role-policy.ts +27 -9
  146. package/packages/piagent-core/runtime/orchestration/subagent-tool-policy.ts +329 -0
  147. package/packages/piagent-core/runtime/policy/block-remedy.ts +0 -4
  148. package/packages/piagent-core/runtime/product/efficiency-metrics.ts +3 -0
  149. package/packages/piagent-core/runtime/quality/edit-freshness-guard.ts +102 -0
  150. package/packages/piagent-core/runtime/quality/exact-output-contract.ts +20 -10
  151. package/packages/piagent-core/runtime/quality/performance-assurance.ts +3 -3
  152. package/packages/piagent-core/runtime/recovery/continuation-budget.ts +1 -1
  153. package/packages/piagent-core/runtime/recovery/handoff-projection.ts +4 -1
  154. package/packages/piagent-core/runtime/recovery/pristine-task-policy-refresh.ts +91 -0
  155. package/packages/piagent-core/runtime/recovery/recovery-policy.ts +7 -4
  156. package/packages/piagent-core/runtime/recovery/resume-state.ts +112 -14
  157. package/packages/piagent-core/runtime/recovery/semantic-repair-handshake.ts +7 -13
  158. package/packages/piagent-core/runtime/recovery/task-evidence-coverage-refresh.ts +217 -0
  159. package/packages/piagent-core/runtime/registration/context-commands.ts +16 -2
  160. package/packages/piagent-core/runtime/registration/knowledge-tools.ts +9 -5
  161. package/packages/piagent-core/runtime/registration/operator-request-intake.ts +49 -0
  162. package/packages/piagent-core/runtime/registration/policy-tools.ts +4 -4
  163. package/packages/piagent-core/runtime/registration/session-commands.ts +1 -1
  164. package/packages/piagent-core/runtime/registration/task-completion-tools.ts +12 -9
  165. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +21 -12
  166. package/packages/piagent-core/runtime/registration/task-start-guidance.ts +19 -13
  167. package/packages/piagent-core/runtime/registration/task-start-manifest.ts +54 -0
  168. package/packages/piagent-core/runtime/registration/task-start-tool.ts +57 -57
  169. package/packages/piagent-core/runtime/runtime-limits.ts +16 -2
  170. package/packages/piagent-core/runtime/session/adaptive-context-analysis.ts +461 -0
  171. package/packages/piagent-core/runtime/session/adaptive-context-governor.ts +464 -0
  172. package/packages/piagent-core/runtime/session/adaptive-context-ledger.ts +265 -0
  173. package/packages/piagent-core/runtime/session/message-signals.ts +7 -3
  174. package/packages/piagent-core/runtime/session/operator-request-carry.ts +44 -0
  175. package/packages/piagent-core/runtime/session/runtime-state.ts +11 -7
  176. package/packages/piagent-core/runtime/session/source-checkout-read-grants.ts +35 -0
  177. package/packages/piagent-core/runtime/session/system-prompt.ts +76 -14
  178. package/packages/piagent-core/runtime/session/task-contract-presentation.ts +39 -0
  179. package/packages/piagent-core/runtime/tools/phase-tools.ts +8 -8
  180. package/packages/piagent-core/runtime/tools/tool-groups.ts +12 -2
  181. package/packages/piagent-core/runtime/verification/exact-verifier-reuse.ts +101 -0
  182. package/packages/piagent-core/runtime/workflows/runtime-intake-compaction.ts +164 -0
  183. package/packages/piagent-core/runtime/workflows/task-intake.ts +167 -39
  184. package/packages/piagent-core/subagents/piagent-oracle.md +5 -3
  185. package/packages/piagent-core/subagents/piagent-planner.md +6 -4
  186. package/packages/piagent-core/subagents/piagent-reviewer.md +5 -3
  187. package/packages/piagent-core/subagents/piagent-scout.md +4 -2
  188. package/packages/piagent-core/subagents/piagent-worker.md +6 -4
  189. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CxeZqTte.js → MarkdownMessage-B4XehTCU.js} +1 -1
  190. package/packages/piagent-webui/dist/client/assets/{index-DynIMe8I.js → index-CgOEyRPG.js} +4 -4
  191. package/packages/piagent-webui/dist/client/index.html +1 -1
  192. package/packages/piagent-webui/extension/piagent-webui.ts +3 -1
  193. package/packages/piagent-webui/extension/same-session-bridge.ts +1 -1
  194. package/packages/piagent-webui/extension/session-stream-adapter.ts +33 -10
  195. package/packages/piagent-webui/gateway/gateway-session-stream.ts +37 -21
  196. package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +237 -0
  197. package/packages/piagent-webui/gateway/session-operation-retry-control.ts +38 -0
  198. package/packages/piagent-webui/gateway/session-operation-watchdog.ts +9 -2
  199. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -7
  200. package/packages/piagent-webui/ownership/session-lease-store.ts +5 -2
  201. package/packages/piagent-webui/server/core-inspection-provider.ts +2 -1
  202. package/packages/piagent-webui/server/transcript-projection.ts +91 -5
  203. package/schemas/codex-relative-efficiency-v1.schema.json +155 -0
  204. package/schemas/piagent-webui/runtime-event-v2.schema.json +15 -5
  205. package/schemas/piagent-webui/subagent-tree-v1.schema.json +11 -1
  206. package/schemas/project-profile.schema.json +1 -1
  207. package/schemas/task-contract.schema.json +15 -0
  208. package/scripts/benchmark-runner-core.mjs +14 -6
  209. package/scripts/benchmark-runner-finalization.mjs +4 -0
  210. package/scripts/configure-subagents.sh +42 -34
  211. package/scripts/helper-evaluation.mjs +1 -1
  212. package/scripts/team-doctor.sh +1 -1
  213. package/scripts/verify-local.sh +5 -0
@@ -7,7 +7,7 @@
7
7
  <meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
8
8
  <meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
9
9
  <title>Piagent WebUI</title>
10
- <script type="module" crossorigin src="/assets/index-DynIMe8I.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-CgOEyRPG.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-BV-_03yl.css">
12
12
  </head>
13
13
  <body>
@@ -446,7 +446,9 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
446
446
  pi.on("tool_execution_end", (event, ctx) => { publishDrafts(ctx, stream.toolEnded(event, bridge.snapshot())); bridgeSoft(() => lifecycle.observeToolEnd(ctx)); });
447
447
  pi.on("turn_end", (event, ctx) => publishDrafts(ctx, stream.turnEnded(event, bridge.snapshot())));
448
448
  pi.on("agent_settled", (_event, ctx) => {
449
- publishDrafts(ctx, stream.agentSettled(bridge.snapshot(), typeof ctx.hasPendingMessages === "function" ? ctx.hasPendingMessages() : null));
449
+ const hasPendingMessages = typeof ctx.hasPendingMessages === "function" ? ctx.hasPendingMessages() : null;
450
+ publishDrafts(ctx, stream.agentSettled(bridge.snapshot(), hasPendingMessages));
451
+ if (hasPendingMessages === true) { publishPersisted(ctx); return; }
450
452
  const operationId = bridge.snapshot().identity?.agentOperationId ?? null;
451
453
  bridgeSoft(() => bridge.observeAgentSettled(ctx)); bridgeSoft(() => lifecycle.observeAgentSettled(ctx, operationId)); publishPersisted(ctx);
452
454
  });
@@ -317,7 +317,7 @@ export class SameSessionPiBridge {
317
317
  }
318
318
  observeAgentStart(ctx: ExtensionContext): void {
319
319
  if (!this.refresh(ctx) || !this.#binding || this.#binding.state !== "ready") return;
320
- const pending = this.#pending;
320
+ const pending = this.#pending; if (this.#binding.identity.agentOperationId && !pending) return;
321
321
  const operationId = pending?.operationId ?? this.#revision("operation", ++this.#operationCounter, "external");
322
322
  this.#binding.identity.agentOperationId = operationId; this.#refresh(); this.#event("operation.started", { operationRef: operationId, commandId: pending?.command.commandId });
323
323
  const observedEntry = pending ? dispatchEntry(pending, ctx) : null;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
3
  import { redactSensitiveText } from "../../piagent-core/extensions/redaction-core.js";
4
+ import { classifyToolFailure, handledToolFailure } from "../../piagent-core/runtime/inspection/tool-failure-classification.ts";
4
5
  import type { RuntimeEventDraft, RuntimeEventRevision } from "../../piagent-core/runtime/inspection/runtime-event-store.ts";
5
6
  import type { BridgeIdentity, BridgeSnapshot } from "./same-session-bridge.ts";
6
7
 
@@ -35,6 +36,13 @@ function safeToolName(value: unknown): string {
35
36
  if (projected.redacted) return "redacted-tool";
36
37
  return projected.text.replace(/[^A-Za-z0-9._:-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 128) || "tool";
37
38
  }
39
+ function toolClassificationInput(value: unknown): { command: string } | undefined {
40
+ if (!value || typeof value !== "object") return undefined;
41
+ const direct = (value as { command?: unknown }).command;
42
+ const nested = (value as { args?: { command?: unknown } }).args?.command;
43
+ const command = typeof direct === "string" ? direct : typeof nested === "string" ? nested : null;
44
+ return command === null ? undefined : { command: command.slice(0, 8_192) };
45
+ }
38
46
  function role(message: any): MessageRole | null {
39
47
  return message?.role === "user" ? "user" : message?.role === "assistant" ? "assistant" : message?.role === "toolResult" ? "tool-result" : null;
40
48
  }
@@ -68,10 +76,11 @@ function redaction(applied = false, truncated = false) { return { applied, value
68
76
  function identityReady(identity: BridgeIdentity | null): identity is BridgeIdentity & { agentOperationId: string } {
69
77
  return Boolean(identity?.agentOperationId);
70
78
  }
71
- function activityPayload(state: "started" | "progress" | "finished" | "failed", activityRef: string, toolName: string) {
79
+ function activityPayload(state: "started" | "progress" | "finished" | "failed", activityRef: string, toolName: string,
80
+ reasonCode: string | null = null) {
72
81
  return { state, activityType: "tool", activityRef, toolName, inputDigest: null, outputDigest: null, preview: null, previewKind: "none",
73
82
  outputBytes: null, outputLines: null, exitCode: null, isError: state === "finished" ? false : state === "failed" ? true : null,
74
- affectedFileRefs: [], criterionIds: [], verifierAttemptIds: [], reasonCode: state === "failed" ? "tool-execution-failed" : null };
83
+ affectedFileRefs: [], criterionIds: [], verifierAttemptIds: [], reasonCode };
75
84
  }
76
85
 
77
86
  export class PiSessionStreamAdapter {
@@ -81,12 +90,13 @@ export class PiSessionStreamAdapter {
81
90
  #activeByRole = new Map<MessageRole, ActiveMessage>();
82
91
  #lastMessageRef: string | null = null;
83
92
  #toolRefs = new Map<string, string>();
93
+ #toolInputs = new Map<string, { command: string } | undefined>();
84
94
  #lastToolProgress = new Map<string, number>();
85
95
 
86
96
  constructor(options: { now?: () => Date } = {}) { this.#now = options.now ?? (() => new Date()); }
87
97
  reset(): void {
88
98
  this.#turnIndex = null; this.#messageCounter = 0; this.#activeByRole.clear(); this.#lastMessageRef = null;
89
- this.#toolRefs.clear(); this.#lastToolProgress.clear();
99
+ this.#toolRefs.clear(); this.#toolInputs.clear(); this.#lastToolProgress.clear();
90
100
  }
91
101
 
92
102
  #base(snapshot: BridgeSnapshot, sourceObservedAt?: unknown): Omit<RuntimeEventDraft, "kind" | "payload" | "evidence" | "redaction"> | null {
@@ -210,13 +220,14 @@ export class PiSessionStreamAdapter {
210
220
  const draft = this.#draft(snapshot, "turn.ended", { phase: "ended", finalMessageRef: this.#lastMessageRef, toolResultCount: count, stopReason: reason });
211
221
  return draft ? [draft] : [];
212
222
  }
213
- toolStarted(event: { toolCallId?: unknown; toolName?: unknown }, snapshot: BridgeSnapshot): RuntimeEventDraft[] {
223
+ toolStarted(event: { toolCallId?: unknown; toolName?: unknown; args?: unknown }, snapshot: BridgeSnapshot): RuntimeEventDraft[] {
214
224
  if (typeof event.toolCallId !== "string" || !event.toolCallId) return [];
215
225
  const drafts = this.#activeByRole.get("assistant")
216
226
  ? this.#drainLines(snapshot, this.#activeByRole.get("assistant") as ActiveMessage, false) : [];
217
227
  const name = safeToolName(event.toolName);
218
228
  const ref = opaque("tool", [snapshot.identity?.sessionRef, event.toolCallId]), activityRef = opaque("activity", [snapshot.identity?.sessionRef, event.toolCallId]);
219
- this.#toolRefs.set(event.toolCallId, ref); this.#lastToolProgress.set(event.toolCallId, 0);
229
+ this.#toolRefs.set(event.toolCallId, ref); this.#toolInputs.set(event.toolCallId, toolClassificationInput(event.args));
230
+ this.#lastToolProgress.set(event.toolCallId, 0);
220
231
  const draft = this.#draft(snapshot, "activity.started", activityPayload("started", activityRef, name), { toolCallId: ref });
221
232
  if (draft) drafts.push(draft);
222
233
  return drafts;
@@ -231,19 +242,31 @@ export class PiSessionStreamAdapter {
231
242
  const draft = this.#draft(snapshot, "activity.progress", activityPayload("progress", activityRef, name), { toolCallId: ref });
232
243
  return draft ? [draft] : [];
233
244
  }
234
- toolEnded(event: { toolCallId?: unknown; toolName?: unknown; isError?: unknown }, snapshot: BridgeSnapshot): RuntimeEventDraft[] {
245
+ toolEnded(event: { toolCallId?: unknown; toolName?: unknown; result?: unknown; isError?: unknown }, snapshot: BridgeSnapshot): RuntimeEventDraft[] {
235
246
  if (typeof event.toolCallId !== "string") return [];
236
247
  const ref = this.#toolRefs.get(event.toolCallId); if (!ref) return [];
237
248
  const name = safeToolName(event.toolName);
238
- const activityRef = opaque("activity", [snapshot.identity?.sessionRef, event.toolCallId]), state = event.isError === true ? "failed" : "finished";
239
- this.#toolRefs.delete(event.toolCallId); this.#lastToolProgress.delete(event.toolCallId);
240
- const draft = this.#draft(snapshot, `activity.${state}`, activityPayload(state, activityRef, name), { toolCallId: ref });
249
+ const reasonCode = classifyToolFailure(name, event.isError === true, event.result, this.#toolInputs.get(event.toolCallId));
250
+ const state = (event.isError === true || reasonCode !== null) && !handledToolFailure(reasonCode) ? "failed" : "finished";
251
+ const activityRef = opaque("activity", [snapshot.identity?.sessionRef, event.toolCallId]);
252
+ this.#toolRefs.delete(event.toolCallId); this.#toolInputs.delete(event.toolCallId); this.#lastToolProgress.delete(event.toolCallId);
253
+ const draft = this.#draft(snapshot, `activity.${state}`, activityPayload(state, activityRef, name, reasonCode), { toolCallId: ref });
241
254
  return draft ? [draft] : [];
242
255
  }
243
256
  agentSettled(snapshot: BridgeSnapshot, hasPendingMessages: boolean | null): RuntimeEventDraft[] {
257
+ // A queued follow-up belongs to the same logical operation. Do not emit a
258
+ // terminal edge or clear stream identity between recovery turns.
259
+ if (hasPendingMessages === true) return [];
244
260
  const value = hasPendingMessages === null ? { state: "unknown", value: null, reasonCode: "queue-fact-unavailable" }
245
261
  : { state: "known", value: hasPendingMessages, reasonCode: null };
246
- const draft = this.#draft(snapshot, "agent-operation.settled", { settlement: "completed", lastStopReason: null, hasPendingMessages: value });
262
+ // Host-idle only proves that this agent operation stopped producing turns.
263
+ // A governed task is successful only after its durable contract is terminal.
264
+ // Fail closed here so a missed/late completion hook cannot paint an open
265
+ // task as completed in the WebUI.
266
+ const governedTaskOpen = Boolean(snapshot.identity?.taskRunId) && snapshot.taskState !== "terminal";
267
+ const draft = this.#draft(snapshot, "agent-operation.settled", {
268
+ settlement: governedTaskOpen ? "unknown" : "completed", lastStopReason: null, hasPendingMessages: value
269
+ });
247
270
  this.reset(); return draft ? [draft] : [];
248
271
  }
249
272
  }
@@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
3
3
  import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
4
4
  import { hasVisibleText } from "../shared/text-visibility.ts";
5
5
  import { GatewayEventStore } from "./gateway-events.ts";
6
+ import { SessionOperationLifecycle, type SessionOperationObservation,
7
+ type SessionOperationPhase, type SessionOperationRetryPolicyOptions } from "./session-operation-lifecycle.ts";
6
8
 
7
9
  const MAX_BUFFER = 65_536;
8
10
  const MAX_EVENTS = 128;
@@ -73,20 +75,20 @@ export class GatewaySessionStream {
73
75
  #eventCount = 0;
74
76
  #buffer = "";
75
77
  #truncated = false;
76
- #started = false;
77
78
  #startListeners: Array<() => void> = [];
78
- #settled = false;
79
79
  #runtimeRestartRequired = false;
80
80
  #lastMessageRef: string | null = null;
81
81
  #settlement: Settlement = { outcome: "unknown", reasonCode: "operation-settlement-unknown" };
82
82
  #forcedSettlement: Settlement | null = null;
83
83
  #lifecycleTerminationReasonCode: string | null = null;
84
- #terminalEmitted = false;
85
84
  #settleListeners: Array<() => void> = [];
86
85
  readonly #toolRefs = new Map<string, string>();
86
+ readonly #lifecycle: SessionOperationLifecycle;
87
87
 
88
88
  get runtimeRestartRequired(): boolean { return this.#runtimeRestartRequired; }
89
89
  get lifecycleTerminationReasonCode(): string | null { return this.#lifecycleTerminationReasonCode; }
90
+ get operationPhase(): SessionOperationPhase { return this.#lifecycle.phase; }
91
+ get retryAbortRequired(): boolean { return this.#lifecycle.retryAbortRequired; }
90
92
 
91
93
  markAborted(reasonCode = "operation-aborted"): void { this.#forcedSettlement = { outcome: "aborted", reasonCode }; }
92
94
  markError(reasonCode = "operation-failed"): void {
@@ -94,60 +96,70 @@ export class GatewaySessionStream {
94
96
  }
95
97
  forceLifecycleTermination(reasonCode: string): void {
96
98
  this.#lifecycleTerminationReasonCode ??= reasonCode;
97
- if (!this.#started) { this.#started = true; for (const resolve of this.#startListeners.splice(0)) resolve(); }
98
- if (!this.#settled) { this.#settled = true; for (const resolve of this.#settleListeners.splice(0)) resolve(); }
99
+ const wasStarted = this.#lifecycle.started, wasSettled = this.#lifecycle.hostSettled;
100
+ this.#lifecycle.forceHostSettlement();
101
+ if (!wasStarted) for (const resolve of this.#startListeners.splice(0)) resolve();
102
+ if (!wasSettled) for (const resolve of this.#settleListeners.splice(0)) resolve();
99
103
  }
100
104
 
101
- constructor(options: { sessionRef: string; operationRef: string; events: GatewayEventStore }) {
105
+ constructor(options: { sessionRef: string; operationRef: string; events: GatewayEventStore;
106
+ retryPolicy?: SessionOperationRetryPolicyOptions }) {
102
107
  this.sessionRef = options.sessionRef; this.operationRef = options.operationRef; this.#events = options.events;
108
+ this.#lifecycle = new SessionOperationLifecycle({ operationRef: options.operationRef, retryPolicy: options.retryPolicy });
103
109
  }
104
110
 
105
111
  started(): Promise<void> {
106
- if (this.#started) return Promise.resolve();
112
+ if (this.#lifecycle.started) return Promise.resolve();
107
113
  return new Promise((resolve) => this.#startListeners.push(resolve));
108
114
  }
109
115
 
110
116
  settled(): Promise<void> {
111
- if (this.#settled) return Promise.resolve();
117
+ if (this.#lifecycle.hostSettled) return Promise.resolve();
112
118
  return new Promise((resolve) => this.#settleListeners.push(resolve));
113
119
  }
114
120
 
115
- observe(event: any): void {
116
- if (this.#terminalEmitted) return;
121
+ observe(event: any): SessionOperationObservation {
122
+ const wasStarted = this.#lifecycle.started, wasSettled = this.#lifecycle.hostSettled;
123
+ const observation = this.#lifecycle.observe(event);
124
+ if (!observation.accepted) return observation;
125
+ if (!wasStarted && this.#lifecycle.started) for (const resolve of this.#startListeners.splice(0)) resolve();
126
+ if (!wasSettled && this.#lifecycle.hostSettled) for (const resolve of this.#settleListeners.splice(0)) resolve();
127
+ if (observation.retry === "abort") this.markError(observation.reasonCode ?? "automatic-retry-blocked");
117
128
  if (event?.type === "agent_start") {
118
- this.#started = true; for (const resolve of this.#startListeners.splice(0)) resolve(); return;
129
+ return observation;
119
130
  }
120
131
  if (event?.type === "agent_settled") {
121
- this.#settled = true; for (const resolve of this.#settleListeners.splice(0)) resolve(); return;
132
+ return observation;
122
133
  }
123
134
  if (event?.type === "message_start" && event.message?.role === "assistant") {
124
- this.#messageRef = ref("message"); this.#buffer = ""; return;
135
+ this.#messageRef = ref("message"); this.#buffer = ""; return observation;
125
136
  }
126
137
  if (event?.type === "message_update" && event.assistantMessageEvent?.type === "text_delta"
127
138
  && typeof event.assistantMessageEvent.delta === "string") {
128
139
  const remaining = MAX_BUFFER - this.#buffer.length;
129
- if (remaining <= 0) { this.#truncated = true; return; }
140
+ if (remaining <= 0) { this.#truncated = true; return observation; }
130
141
  this.#buffer += event.assistantMessageEvent.delta.slice(0, remaining);
131
142
  if (event.assistantMessageEvent.delta.length > remaining) this.#truncated = true;
132
- this.#flush(false); return;
143
+ this.#flush(false); return observation;
133
144
  }
134
145
  if (event?.type === "message_end" && event.message?.role === "assistant") {
135
- this.#flush(true); this.#lastMessageRef = this.#messageRef; this.#settlement = assistantSettlement(event.message); return;
146
+ this.#flush(true); this.#lastMessageRef = this.#messageRef; this.#settlement = assistantSettlement(event.message); return observation;
136
147
  }
137
148
  if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") {
138
149
  const toolCallRef = ref("tool"); this.#toolRefs.set(event.toolCallId, toolCallRef);
139
150
  this.#events.publish("tool.started", { sessionRef: this.sessionRef, operationRef: this.operationRef, toolCallRef,
140
151
  toolLabel: safeTool(event.toolName), isError: null, reasonCode: null });
141
- return;
152
+ return observation;
142
153
  }
143
154
  if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") {
144
- const toolCallRef = this.#toolRefs.get(event.toolCallId); if (!toolCallRef) return;
155
+ const toolCallRef = this.#toolRefs.get(event.toolCallId); if (!toolCallRef) return observation;
145
156
  const reasonCode = runtimeRestartReasonCode(event);
146
157
  if (reasonCode) this.#runtimeRestartRequired = true;
147
158
  this.#events.publish("tool.completed", { sessionRef: this.sessionRef, operationRef: this.operationRef, toolCallRef,
148
159
  toolLabel: safeTool(event.toolName), isError: event.isError === true, reasonCode });
149
160
  this.#toolRefs.delete(event.toolCallId);
150
161
  }
162
+ return observation;
151
163
  }
152
164
 
153
165
  #flush(final: boolean): void {
@@ -180,9 +192,8 @@ export class GatewaySessionStream {
180
192
  }
181
193
  }
182
194
 
183
- complete(sessionRevision: string | null): void {
184
- if (this.#terminalEmitted) return;
185
- this.#terminalEmitted = true;
195
+ complete(sessionRevision: string | null, taskOutcome: string | null = null): void {
196
+ if (!this.#lifecycle.markTerminal()) return;
186
197
  this.#flush(true);
187
198
  let settlement = this.#forcedSettlement ?? (this.#runtimeRestartRequired
188
199
  ? { outcome: "unknown" as const, reasonCode: "runtime-restart-required" }
@@ -190,6 +201,11 @@ export class GatewaySessionStream {
190
201
  const messageRef = this.#lastMessageRef ?? this.#messageRef;
191
202
  if (settlement.outcome === "completed" && !messageRef) {
192
203
  settlement = { outcome: "unknown", reasonCode: "assistant-message-unavailable" };
204
+ } else if (settlement.outcome === "completed" && taskOutcome === "pending") {
205
+ // Provider completion is not governed-task completion. The durable task
206
+ // contract is authoritative, so withhold both success settlement and the
207
+ // durable assistant-success event until that contract becomes terminal.
208
+ settlement = { outcome: "unknown", reasonCode: "task-completion-pending" };
193
209
  } else if (settlement.outcome === "completed" && sessionRevision === null) {
194
210
  // Streaming text is not durable success until the canonical session
195
211
  // projection confirms it. Still terminate the UI, but do not show draft.
@@ -0,0 +1,237 @@
1
+ import { hasVisibleText } from "../shared/text-visibility.ts";
2
+
3
+ export type SessionOperationPhase = "pending" | "running" | "retry" | "compaction" | "settled";
4
+
5
+ export type SessionOperationRetryPolicy = Readonly<{
6
+ maximumAttempts: number;
7
+ maximumDelayMs: number;
8
+ }>;
9
+
10
+ export type SessionOperationRetryPolicyOptions = Partial<SessionOperationRetryPolicy>;
11
+
12
+ export type SessionOperationObservation = Readonly<{
13
+ accepted: boolean;
14
+ phase: SessionOperationPhase;
15
+ retry: "none" | "allowed" | "abort";
16
+ reasonCode: string | null;
17
+ }>;
18
+
19
+ const DEFAULT_RETRY_POLICY: SessionOperationRetryPolicy = Object.freeze({
20
+ // Pi's provider retry is useful before a response exists, but a long retry
21
+ // chain burns quota and keeps the browser in Working. One automatic retry
22
+ // absorbs an ordinary transient failure without replaying a persistent one.
23
+ maximumAttempts: 1,
24
+ maximumDelayMs: 8_000
25
+ });
26
+
27
+ function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number {
28
+ if (value === undefined) return fallback;
29
+ if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum) {
30
+ throw new Error("session-operation-retry-policy-invalid");
31
+ }
32
+ return Number(value);
33
+ }
34
+
35
+ export function sessionOperationRetryPolicy(
36
+ options: SessionOperationRetryPolicyOptions = {}
37
+ ): SessionOperationRetryPolicy {
38
+ return Object.freeze({
39
+ maximumAttempts: boundedInteger(options.maximumAttempts, DEFAULT_RETRY_POLICY.maximumAttempts, 0, 10),
40
+ maximumDelayMs: boundedInteger(options.maximumDelayMs, DEFAULT_RETRY_POLICY.maximumDelayMs, 0, 300_000)
41
+ });
42
+ }
43
+
44
+ function correlationRef(event: any): string | null {
45
+ for (const key of ["operationRef", "agentOperationId", "operationId"]) {
46
+ if (typeof event?.[key] === "string" && event[key]) return event[key];
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function visibleAssistantContent(message: any): boolean {
52
+ if (typeof message?.content === "string") return hasVisibleText(message.content);
53
+ if (!Array.isArray(message?.content)) return false;
54
+ return message.content.some((item: any) => {
55
+ if (item?.type === "text") return hasVisibleText(item.text);
56
+ // Images and every model-emitted tool/server-tool block are observable
57
+ // output even when they do not contain user-visible text.
58
+ return ["image", "image_url", "toolCall", "tool_call", "tool_use", "server_tool_use"]
59
+ .includes(String(item?.type ?? ""));
60
+ });
61
+ }
62
+
63
+ function attempt(value: unknown): number | null {
64
+ return Number.isSafeInteger(value) && Number(value) >= 1 ? Number(value) : null;
65
+ }
66
+
67
+ function delay(value: unknown): number | null {
68
+ return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
69
+ }
70
+
71
+ /**
72
+ * Operation-local authority for provider retry, compaction and settlement.
73
+ *
74
+ * Pi's pinned host owns the actual provider retry. This state machine owns the
75
+ * stricter Piagent admission decision and is shared by the Gateway stream and
76
+ * runtime supervisor. It never treats compaction as a provider retry, and it
77
+ * fails closed once assistant output or a tool call makes replay unsafe.
78
+ */
79
+ export class SessionOperationLifecycle {
80
+ readonly operationRef: string;
81
+ readonly retryPolicy: SessionOperationRetryPolicy;
82
+ #phase: SessionOperationPhase = "pending";
83
+ #started = false;
84
+ #hostSettled = false;
85
+ #terminal = false;
86
+ #visibleAssistantOutput = false;
87
+ #toolCallObserved = false;
88
+ #lastRetryAttempt = 0;
89
+ #activeRetryAttempt: number | null = null;
90
+ #retryAbortRequired = false;
91
+
92
+ constructor(options: {
93
+ operationRef: string;
94
+ retryPolicy?: SessionOperationRetryPolicyOptions | SessionOperationRetryPolicy;
95
+ }) {
96
+ this.operationRef = options.operationRef;
97
+ this.retryPolicy = sessionOperationRetryPolicy(options.retryPolicy);
98
+ }
99
+
100
+ get phase(): SessionOperationPhase { return this.#phase; }
101
+ get started(): boolean { return this.#started; }
102
+ get hostSettled(): boolean { return this.#hostSettled; }
103
+ get terminal(): boolean { return this.#terminal; }
104
+ get replayUnsafe(): boolean { return this.#visibleAssistantOutput || this.#toolCallObserved; }
105
+ get retryAbortRequired(): boolean { return this.#retryAbortRequired; }
106
+
107
+ forceHostSettlement(): void {
108
+ this.#started = true;
109
+ this.#hostSettled = true;
110
+ this.#phase = "settled";
111
+ }
112
+
113
+ markTerminal(): boolean {
114
+ if (this.#terminal) return false;
115
+ this.#terminal = true;
116
+ this.#hostSettled = true;
117
+ this.#phase = "settled";
118
+ return true;
119
+ }
120
+
121
+ observe(event: any): SessionOperationObservation {
122
+ if (this.#terminal || this.#hostSettled) return this.#ignored("operation-event-after-settlement");
123
+ const correlated = correlationRef(event);
124
+ if (correlated && correlated !== this.operationRef) return this.#ignored("operation-correlation-mismatch");
125
+ const type = String(event?.type ?? "");
126
+
127
+ if (type === "agent_start") {
128
+ this.#started = true;
129
+ this.#phase = "running";
130
+ return this.#accepted();
131
+ }
132
+ if (type === "agent_settled") {
133
+ this.#started = true;
134
+ this.#hostSettled = true;
135
+ this.#phase = "settled";
136
+ return this.#accepted();
137
+ }
138
+
139
+ // Direct stream fixtures and some deferred command hosts begin with a
140
+ // message/tool event. Treat that as an observed start while still refusing
141
+ // uncorrelated retry/compaction lifecycle events before any operation work.
142
+ if (["message_start", "message_update", "message_end", "tool_execution_start",
143
+ "tool_execution_update", "tool_execution_end", "turn_start", "turn_end"].includes(type)) {
144
+ this.#started = true;
145
+ if (this.#phase === "pending") this.#phase = "running";
146
+ }
147
+
148
+ if (type === "message_update") {
149
+ const update = event?.assistantMessageEvent;
150
+ if (update?.type === "text_delta" && hasVisibleText(update.delta)) this.#visibleAssistantOutput = true;
151
+ if (visibleAssistantContent(event?.message)) this.#visibleAssistantOutput = true;
152
+ return this.#accepted();
153
+ }
154
+ if (type === "message_end" && event?.message?.role === "assistant") {
155
+ if (visibleAssistantContent(event.message)) this.#visibleAssistantOutput = true;
156
+ return this.#accepted();
157
+ }
158
+ if (type === "tool_execution_start") {
159
+ this.#toolCallObserved = true;
160
+ return this.#accepted();
161
+ }
162
+
163
+ if (type === "agent_end" && event?.willRetry === true && this.replayUnsafe) {
164
+ return this.#abortRetry("automatic-retry-replay-unsafe");
165
+ }
166
+ if (type === "auto_retry_start") return this.#retryStarted(event);
167
+ if (type === "auto_retry_end") return this.#retryEnded(event);
168
+
169
+ // Summarization retries belong to compaction and never consume or inherit
170
+ // the provider-turn retry budget.
171
+ if (type === "compaction_start") {
172
+ if (!this.#started) return this.#ignored("operation-event-before-start");
173
+ if (this.#phase === "retry") this.#retryAbortRequired = true;
174
+ this.#phase = "compaction";
175
+ return this.#retryAbortRequired
176
+ ? this.#abortRetry("automatic-retry-compaction-overlap")
177
+ : this.#accepted();
178
+ }
179
+ if (type === "compaction_end") {
180
+ if (this.#phase !== "compaction") return this.#ignored("compaction-event-stale");
181
+ this.#phase = "running";
182
+ return this.#accepted();
183
+ }
184
+ if (["summarization_retry_scheduled", "summarization_retry_attempt_start",
185
+ "summarization_retry_finished"].includes(type)) {
186
+ return this.#phase === "compaction" ? this.#accepted() : this.#ignored("compaction-event-stale");
187
+ }
188
+ return this.#accepted();
189
+ }
190
+
191
+ #retryStarted(event: any): SessionOperationObservation {
192
+ if (!this.#started) return this.#ignored("operation-event-before-start");
193
+ const currentAttempt = attempt(event?.attempt), currentDelay = delay(event?.delayMs);
194
+ if (currentAttempt === null || currentDelay === null) return this.#abortRetry("automatic-retry-metadata-invalid");
195
+ if (currentAttempt === this.#lastRetryAttempt) return this.#ignored("automatic-retry-event-duplicate");
196
+ if (currentAttempt !== this.#lastRetryAttempt + 1) {
197
+ this.#activeRetryAttempt = currentAttempt;
198
+ this.#phase = "retry";
199
+ return this.#abortRetry("automatic-retry-attempt-stale");
200
+ }
201
+ this.#lastRetryAttempt = currentAttempt;
202
+ this.#activeRetryAttempt = currentAttempt;
203
+ if (this.#phase === "compaction") return this.#abortRetry("automatic-retry-compaction-overlap");
204
+ this.#phase = "retry";
205
+ if (this.replayUnsafe) return this.#abortRetry("automatic-retry-replay-unsafe");
206
+ if (currentAttempt > this.retryPolicy.maximumAttempts) return this.#abortRetry("automatic-retry-attempt-limit");
207
+ if (currentDelay > this.retryPolicy.maximumDelayMs) return this.#abortRetry("automatic-retry-delay-limit");
208
+ return { accepted: true, phase: this.#phase, retry: "allowed", reasonCode: null };
209
+ }
210
+
211
+ #retryEnded(event: any): SessionOperationObservation {
212
+ const currentAttempt = attempt(event?.attempt);
213
+ if (currentAttempt === null || currentAttempt !== this.#activeRetryAttempt) {
214
+ return this.#ignored("automatic-retry-event-stale");
215
+ }
216
+ this.#activeRetryAttempt = null;
217
+ // Keep the highest attempt for the whole logical operation so hosts that
218
+ // emit retry_end between attempts still have a monotonic sequence. Keep an
219
+ // abort latched too: retry_end can arrive synchronously before the
220
+ // supervisor's cancellation microtask observes the host AbortController.
221
+ if (this.#phase === "retry") this.#phase = "running";
222
+ return this.#accepted();
223
+ }
224
+
225
+ #abortRetry(reasonCode: string): SessionOperationObservation {
226
+ this.#retryAbortRequired = true;
227
+ return { accepted: true, phase: this.#phase, retry: "abort", reasonCode };
228
+ }
229
+
230
+ #accepted(): SessionOperationObservation {
231
+ return { accepted: true, phase: this.#phase, retry: "none", reasonCode: null };
232
+ }
233
+
234
+ #ignored(reasonCode: string): SessionOperationObservation {
235
+ return { accepted: false, phase: this.#phase, retry: "none", reasonCode };
236
+ }
237
+ }
@@ -0,0 +1,38 @@
1
+ type RetryObservation = { retry?: unknown } | null | undefined;
2
+
3
+ function invoke(action: (() => unknown) | null): void {
4
+ if (!action) return;
5
+ try {
6
+ const result = action();
7
+ if (result && typeof (result as PromiseLike<unknown>).then === "function") {
8
+ void Promise.resolve(result).catch(() => undefined);
9
+ }
10
+ } catch { /* the operation watchdog remains the terminal authority */ }
11
+ }
12
+
13
+ /**
14
+ * Cancels a host-owned provider retry without replaying the prompt. Pinned Pi
15
+ * creates its retry AbortController immediately after auto_retry_start is
16
+ * emitted, so the microtask always runs before even a zero-millisecond timer.
17
+ */
18
+ export class SessionOperationRetryCancellation {
19
+ readonly #session: any;
20
+ #fallbackIssued = false;
21
+
22
+ constructor(session: any) { this.#session = session; }
23
+
24
+ observe(observation: RetryObservation): void {
25
+ if (observation?.retry !== "abort") return;
26
+ if (typeof this.#session?.abortRetry === "function") {
27
+ const cancel = () => invoke(() => this.#session.abortRetry());
28
+ cancel();
29
+ queueMicrotask(cancel);
30
+ return;
31
+ }
32
+ // Unknown hosts fail closed once. Replaying a tool-capable turn is more
33
+ // dangerous than ending the logical operation with an explicit error.
34
+ if (this.#fallbackIssued || typeof this.#session?.abort !== "function") return;
35
+ this.#fallbackIssued = true;
36
+ invoke(() => this.#session.abort());
37
+ }
38
+ }
@@ -1,3 +1,5 @@
1
+ import { SessionOperationRetryCancellation } from "./session-operation-retry-control.ts";
2
+
1
3
  export type SessionOperationDeadlineReason = "operation-inactivity-timeout" | "operation-deadline-exceeded";
2
4
 
3
5
  export type SessionOperationDeadlinePolicy = Readonly<{
@@ -113,10 +115,15 @@ export class SessionOperationWatchdog {
113
115
  export function armSessionOperationWatchdog(options: {
114
116
  watchdog: SessionOperationWatchdog;
115
117
  subscribe(listener: (event: unknown) => void): unknown;
116
- observe(event: unknown): void;
118
+ observe(event: unknown): unknown;
119
+ retrySession?: any;
117
120
  expire(reason: SessionOperationDeadlineReason): void;
118
121
  }): () => void {
119
- const unsubscribe = options.subscribe((event) => { options.watchdog.progress(); options.observe(event); });
122
+ const retry = new SessionOperationRetryCancellation(options.retrySession);
123
+ const unsubscribe = options.subscribe((event) => {
124
+ options.watchdog.progress();
125
+ retry.observe(options.observe(event) as { retry?: unknown } | null | undefined);
126
+ });
120
127
  if (typeof unsubscribe !== "function") throw new Error("session-operation-subscribe-invalid");
121
128
  try { options.watchdog.start(options.expire); }
122
129
  catch (error) { try { unsubscribe(); } catch { /* authority is quarantined by the caller */ } throw error; }
@@ -9,8 +9,7 @@ import { SessionLeaseStore, type SessionLeaseSnapshot } from "./session-lease-st
9
9
  import { GatewayEventStore } from "./gateway-events.ts";
10
10
  import { GatewaySessionStream } from "./gateway-session-stream.ts";
11
11
  import { executePermissionCommand, executeRuntimeCommand } from "./runtime-session-controls.ts";
12
- import { configureSessionOptions, effectiveModelRef, effectiveThinkingLevel,
13
- type EffectiveSessionOptions } from "./session-effective-options.ts";
12
+ import { configureSessionOptions, effectiveModelRef, effectiveThinkingLevel, type EffectiveSessionOptions } from "./session-effective-options.ts";
14
13
  import { waitForOperationStart } from "./session-operation-start.ts";
15
14
  import { armSessionOperationWatchdog, bestEffortUnsubscribe, boundedResult, sessionOperationDeadlinePolicy, SessionOperationWatchdog, terminateWatchedSessionOperation,
16
15
  type SessionOperationDeadlinePolicy, type SessionOperationWatchdogOptions } from "./session-operation-watchdog.ts";
@@ -133,7 +132,7 @@ export class SessionRuntimeSupervisor {
133
132
  if (this.#active.has(sessionRef)) throw new Error("session-owner-conflict");
134
133
  const runtimeInstanceRef = `runtime_${randomBytes(24).toString("base64url")}`;
135
134
  const prior = this.#leases.inspect(sessionRef);
136
- if (prior.state === "gateway-owned" || prior.state === "terminal-owned") {
135
+ if (prior.state === "gateway-owned" || prior.state === "terminal-owned" || prior.state === "recovery-required") {
137
136
  try { this.#leases.releaseDeadOwnerForExplicitRecovery(sessionRef); } catch { /* live or unprovable owners stay authoritative */ }
138
137
  }
139
138
  const lease = this.#leases.acquire(sessionRef, this.#gatewayInstanceRef, runtimeInstanceRef);
@@ -254,7 +253,7 @@ export class SessionRuntimeSupervisor {
254
253
  const watchdog = new SessionOperationWatchdog(this.#operationDeadlinePolicy);
255
254
  active.operationRef = operationRef; active.stream = stream; active.watchdog = watchdog; active.lastSessionRevision = sessionRevision;
256
255
  try {
257
- active.unsubscribe = armSessionOperationWatchdog({ watchdog, subscribe: (listener) => session.subscribe(listener),
256
+ active.unsubscribe = armSessionOperationWatchdog({ watchdog, subscribe: (listener) => session.subscribe(listener), retrySession: session,
258
257
  observe: (event) => stream.observe(event), expire: (reasonCode) => { void this.#terminateOperation(sessionRef,
259
258
  operationRef, "error", reasonCode, reasonCode, true).catch(() => undefined); } });
260
259
  } catch (error) { stream.markError("session-operation-start-failed");
@@ -468,11 +467,13 @@ export class SessionRuntimeSupervisor {
468
467
  } catch { /* Canonical refresh failure cannot be replaced by an invented revision. */ }
469
468
  if (this.#active.get(sessionRef) !== active || active.operationRef !== operationRef) return;
470
469
  if (this.ownership(sessionRef).state !== "gateway-owned") { stream.markError("session-owner-continuity-lost"); await this.#quarantineRuntime(sessionRef, operationRef, active, stream, "session-owner-continuity-lost"); return; }
471
- active.operationRef = null;
470
+ // The pre-clear projection is stale; derive post-settlement liveness from exact ownership.
471
+ active.operationRef = null; const settledLiveState = this.ownership(sessionRef).liveState;
472
472
  if (projection) active.lastSessionRevision = projection.sessionRevision;
473
- stream.complete(projection?.sessionRevision ?? null);
473
+ const taskOutcome = activeSessionTask(active.info.cwd, active.info.id)?.trace?.outcome ?? null;
474
+ stream.complete(projection?.sessionRevision ?? null, taskOutcome);
474
475
  if (projection) this.#events.publish("runtime.changed", { sessionRef, sessionRevision: projection.sessionRevision,
475
- liveState: restartRequired ? "uncertain" : projection.liveState, operationRef: null,
476
+ liveState: restartRequired ? "uncertain" : settledLiveState, operationRef: null,
476
477
  reasonCode: restartRequired ? "runtime-restart-required" : null });
477
478
  if (this.#active.get(sessionRef) === active) { active.completion = null;
478
479
  if (!active.watchdog?.terminating) { active.stream = null; active.watchdog = null; active.settling = false; } }
@@ -237,8 +237,11 @@ export class SessionLeaseStore {
237
237
  if (!Number.isFinite(now.getTime())) throw new Error("session-lease-input-invalid");
238
238
  return this.#withMutationLock(sessionRef, () => {
239
239
  const current = this.inspect(sessionRef);
240
- const pid = current.state === "terminal-owned" ? terminalOwnerPid(current.gatewayInstanceRef)
241
- : current.state === "gateway-owned" ? gatewayOwnerPid(current.gatewayInstanceRef) : null;
240
+ const terminalOwner = current.state === "terminal-owned"
241
+ || (current.state === "recovery-required" && current.gatewayInstanceRef?.startsWith(TERMINAL_OWNER_PREFIX));
242
+ const pid = terminalOwner ? terminalOwnerPid(current.gatewayInstanceRef)
243
+ : current.state === "gateway-owned" || current.state === "recovery-required"
244
+ ? gatewayOwnerPid(current.gatewayInstanceRef) : null;
242
245
  if (!pid || processAlive(pid)) throw new Error("session-owner-not-proven-dead");
243
246
  this.#append(sessionRef, { recordedAt: now.toISOString(), sessionRef, event: "recovery-required", ownerEpoch: current.ownerEpoch!,
244
247
  gatewayInstanceRef: current.gatewayInstanceRef!, runtimeInstanceRef: current.runtimeInstanceRef!, reasonCode: "owner-process-exited" });