@cmmd-center/forge 0.13.96 → 0.13.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.cjs CHANGED
@@ -66332,7 +66332,7 @@ function normalizeNumberish(value) {
66332
66332
  }
66333
66333
  //#endregion
66334
66334
  //#region package.json
66335
- var version$1 = "0.13.96";
66335
+ var version$1 = "0.13.98";
66336
66336
  //#endregion
66337
66337
  //#region src/sentry.ts
66338
66338
  const SERVER_APP_NAME = "forge-server";
@@ -98404,6 +98404,119 @@ function providerAuthFailureMessage(message, provider, env = process.env) {
98404
98404
  }).message;
98405
98405
  }
98406
98406
  //#endregion
98407
+ //#region ../../packages/shared/src/publicProviderCopy.ts
98408
+ const PUBLIC_PROVIDER_LEAK_PATTERNS = [
98409
+ /\bDeepSeek\b/i,
98410
+ /\bKimi\b/i,
98411
+ /\bMoonshot\b/i,
98412
+ /\bdeepseek[-._a-z0-9]*\b/i,
98413
+ /\bkimi[-._a-z0-9]*\b/i,
98414
+ /\bmoonshot[-._a-z0-9]*\b/i,
98415
+ /\bAPEX_API_KEY\b/,
98416
+ /\bDEEPSEEK_API_KEY\b/,
98417
+ /\bKIMI_API_KEY\b/,
98418
+ /\bOPENAI_API_KEY\b/,
98419
+ /\bANTHROPIC_API_KEY\b/,
98420
+ /\bCURSOR_API_KEY\b/,
98421
+ /\bOPENCODE_API_KEY\b/,
98422
+ /\bforge_proxy_[A-Za-z0-9._-]+\b/,
98423
+ /\bProviderValidationError\b/,
98424
+ /\/forge-server\/node_modules\//,
98425
+ /https?:\/\/[^\s'"<>)]*\/api\/provider-egress\/[^\s'"<>)]*/i,
98426
+ /https?:\/\/[^\s'"<>)]*(?:apex|deepseek|moonshot|kimi)[^\s'"<>)]*/i
98427
+ ];
98428
+ const DEFAULT_PUBLIC_PROVIDER_ERROR = "CMMD is temporarily unavailable. Check the workspace AI connection and retry.";
98429
+ function isProviderRateOrQuotaError(message) {
98430
+ if (!message) return false;
98431
+ const normalized = message.toLowerCase();
98432
+ return /\b(rate[\s-]?limit|too\s+many\s+requests|usage[ _-]limit|usage_limit_reached|quota|insufficient_quota)\b/.test(normalized) || /\b429\b/.test(normalized);
98433
+ }
98434
+ function providerLifecycleOrAccountCopy(message) {
98435
+ if (/\bbound to provider account\b/.test(message) && /\bcannot use provider account\b/.test(message)) return "Forge could not switch this conversation to the selected AI account. Retry the account switch.";
98436
+ if (/\bforge_runtime_repairing\b/.test(message) || /\bthis turn was interrupted because the environment is being repaired\b/.test(message)) return "This turn was interrupted because the environment is being repaired. Retry after the repair finishes.";
98437
+ if (/\bforge_runtime_stopped\b/.test(message) || /\bthis turn was interrupted because the environment was stopped\b/.test(message)) return "This turn was interrupted because the environment was stopped. Start the environment and retry.";
98438
+ return null;
98439
+ }
98440
+ /**
98441
+ * Infer a SAFE, clearer failure category from a (possibly leaky) provider error.
98442
+ *
98443
+ * Returns a canned, user-facing message — it never echoes the raw input, so it
98444
+ * is safe to use even when the source message contains provider internals.
98445
+ * Used to give actionable feedback ("model isn't available", "rate-limited",
98446
+ * "auth failed") instead of the blanket "CMMD is temporarily unavailable."
98447
+ * Returns null when no category can be confidently inferred.
98448
+ */
98449
+ function categorizePublicProviderError(message) {
98450
+ if (!message) return null;
98451
+ const m = message.toLowerCase();
98452
+ const lifecycleOrAccountCopy = providerLifecycleOrAccountCopy(m);
98453
+ if (lifecycleOrAccountCopy !== null) return lifecycleOrAccountCopy;
98454
+ if (/\bforge_provider_model_not_allowed\b/.test(m) || /\bnot\s+scoped\s+to\s+the\s+requested\s+model\b/.test(m)) return "The workspace AI credential does not cover the model this turn asked for. Retry, or pick that model for the thread.";
98455
+ if (/\bmodel\b[\s\S]*\b(not\s+(found|available|supported)|does\s+not\s+exist|unsupported|invalid|unknown)\b/.test(m) || /\b(unsupported|unknown|invalid)\b[\s\S]*\bmodel\b/.test(m) || /\b(model_not_found|invalid_model|model_not_supported)\b/.test(m)) return "The selected model isn't available right now. Pick a different model and retry.";
98456
+ if (isProviderRateOrQuotaError(m)) return "The AI service is rate-limited or out of quota. Wait a moment and retry.";
98457
+ if (/_credential_reconnect_required\b/.test(m)) return "The workspace AI credential needs to be reconnected. Reconnect it in Settings > Providers, then retry.";
98458
+ if (/\b(unauthorized|forbidden|authentication\s+(failed|error|required)|invalid\s+api\s+key|expired\s+(token|credential))\b/.test(m) || /\b40[13]\b/.test(m)) return "Workspace AI authentication failed. Reconnect the workspace and retry.";
98459
+ if (/\b(timed?\s*out|timeout|overloaded|service\s+unavailable)\b/.test(m) || /\b50[234]\b/.test(m)) return "The AI service is temporarily overloaded. Retry in a moment.";
98460
+ return null;
98461
+ }
98462
+ function containsPublicProviderLeak(message) {
98463
+ if (!message) return false;
98464
+ return PUBLIC_PROVIDER_LEAK_PATTERNS.some((pattern) => pattern.test(message));
98465
+ }
98466
+ function sanitizePublicProviderText(message, options = {}) {
98467
+ if (message === void 0 || message === null) return null;
98468
+ const trimmed = message.trim();
98469
+ if (trimmed.length === 0) return null;
98470
+ if (!containsPublicProviderLeak(trimmed)) return trimmed;
98471
+ return categorizePublicProviderError(trimmed) ?? options.fallback ?? "CMMD is temporarily unavailable. Check the workspace AI connection and retry.";
98472
+ }
98473
+ //#endregion
98474
+ //#region src/orchestration/providerRetryableFailure.ts
98475
+ /**
98476
+ * Classifies a provider turn-start failure as a retryable rate-limit or
98477
+ * transient-availability failure (429/503), a terminal failure that names why
98478
+ * it will never clear on its own, or unrecognized (null).
98479
+ *
98480
+ * CMMD-Center/forge#3558: a 429 from CMMD's provider egress used to leave no
98481
+ * trace at all, because nothing in the turn-start path recognized it as a
98482
+ * distinct, retryable failure. `ProviderCommandReactor` uses this to decide
98483
+ * whether to retry a turn start with backoff, and to name the provider,
98484
+ * status and Retry-After in the `provider.turn.start.failed` activity CMMD's
98485
+ * watchdog reads once retries are exhausted.
98486
+ *
98487
+ * Reuses `isProviderRateOrQuotaError` (the same predicate the public-copy
98488
+ * sanitizer uses to recognize a rate-limit message) rather than restating its
98489
+ * word list here.
98490
+ */
98491
+ const SERVICE_UNAVAILABLE_PATTERN = /\b(503|service\s+unavailable)\b/i;
98492
+ const RATE_LIMITED_PAST_TENSE_PATTERN = /\brate[\s-]?limit(?:ed|ing)?\b/i;
98493
+ const QUOTA_OR_USAGE_LIMIT_PATTERN = /\b(usage[ _-]limit|usage_limit_reached|quota|insufficient_quota)\b/i;
98494
+ const EXPLICIT_429_PATTERN = /\b429\b/;
98495
+ const RETRY_AFTER_SECONDS_PATTERN = /retry-after[^0-9]{0,10}(\d+)|(?:try again|retry)\s+in\s+(\d+)\s*(?:s\b|sec(?:onds?)?\b)?/i;
98496
+ function parseRetryAfterSeconds(detail) {
98497
+ const match = detail.match(RETRY_AFTER_SECONDS_PATTERN);
98498
+ if (!match) return null;
98499
+ const raw = match[1] ?? match[2];
98500
+ const seconds = raw ? Number(raw) : NaN;
98501
+ return Number.isFinite(seconds) ? seconds : null;
98502
+ }
98503
+ function classifyRetryableProviderFailure(detail) {
98504
+ if (!detail || detail.trim().length === 0) return null;
98505
+ if (QUOTA_OR_USAGE_LIMIT_PATTERN.test(detail) && !EXPLICIT_429_PATTERN.test(detail)) return {
98506
+ kind: "terminal",
98507
+ reason: "quota",
98508
+ message: detail
98509
+ };
98510
+ const isRateLimited = isProviderRateOrQuotaError(detail) || RATE_LIMITED_PAST_TENSE_PATTERN.test(detail);
98511
+ const isUnavailable = SERVICE_UNAVAILABLE_PATTERN.test(detail);
98512
+ if (!isRateLimited && !isUnavailable) return null;
98513
+ return {
98514
+ kind: "retryable",
98515
+ status: isRateLimited ? 429 : 503,
98516
+ retryAfterSeconds: parseRetryAfterSeconds(detail)
98517
+ };
98518
+ }
98519
+ //#endregion
98407
98520
  //#region src/orchestration/turnFailureReason.ts
98408
98521
  /**
98409
98522
  * Machine-readable reasons for a turn that failed before it produced any
@@ -98437,6 +98550,31 @@ function classifyProviderTurnStartFailureReason(detail) {
98437
98550
  message
98438
98551
  };
98439
98552
  }
98553
+ /**
98554
+ * Classifies a turn that ended mid-flight, after it had already started
98555
+ * (CMMD-Center/forge#3562 piece A): a `runtime.error` that reached CMMD as
98556
+ * `error_summary`, now also read into a `turn_status: failed` reason.
98557
+ *
98558
+ * `provider_turn_failed` is the code CMMD's own error_summary heuristic
98559
+ * already assigns this class of failure
98560
+ * (`shared/forge-work-session-failure-reasons.ts`), so a Forge-sent
98561
+ * `turn_status` carrying it lines up with what CMMD already infers instead
98562
+ * of teaching it a new code for the common case. `provider_usage_limit`
98563
+ * reuses the same wording `classifyRetryableProviderFailure` already treats
98564
+ * as a wall that will not clear by retrying (CMMD-Center/forge#3558): a
98565
+ * usage-limit message inside a turn that already ended is worth naming
98566
+ * specifically, even without the fuller upstream port (see the piece A PR's
98567
+ * follow-up issue) that would stop Codex from retrying against that wall in
98568
+ * the first place.
98569
+ */
98570
+ function classifyMidTurnFailureReason(detail) {
98571
+ const message = detail && detail.trim().length > 0 ? detail : "The runtime reported an error while this run was in progress.";
98572
+ const classified = classifyRetryableProviderFailure(detail);
98573
+ return {
98574
+ code: classified?.kind === "terminal" && classified.reason === "quota" ? "provider_usage_limit" : "provider_turn_failed",
98575
+ message
98576
+ };
98577
+ }
98440
98578
  function checkpointTimeoutReason(detail) {
98441
98579
  return {
98442
98580
  code: "checkpoint_timeout",
@@ -98571,6 +98709,108 @@ function serializeReplicaMessage(message) {
98571
98709
  };
98572
98710
  }
98573
98711
  //#endregion
98712
+ //#region src/orchestration/turnTerminalStatus.ts
98713
+ /**
98714
+ * Shared vocabulary between the reactor that records how a turn ended
98715
+ * (`Layers/CheckpointReactor.ts`, on the provider's own `turn.completed`
98716
+ * event) and the serializer that turns that record into CMMD's `turn_status`
98717
+ * terminal event (`runtimeReplication/turnTerminalStatusSerializer.ts`).
98718
+ * CMMD-Center/forge#3562 piece A, CMMD-Center/CMMD#5822.
98719
+ */
98720
+ /** The activity kind this durable record uses. Never sent to CMMD as-is: the
98721
+ * serializer reads it and emits the wire `turn_status` event instead. */
98722
+ const TURN_TERMINAL_STATUS_ACTIVITY_KIND = "turn.terminal-status";
98723
+ /**
98724
+ * CMMD's three-way outcome from the provider's own four-way `turn.completed`
98725
+ * state. `cancelled` and `interrupted` both read as `interrupted`: neither
98726
+ * the provider wording nor CMMD's Work Session UI distinguishes an
98727
+ * operator-cancelled turn from one the provider gave up on mid-flight.
98728
+ */
98729
+ function turnTerminalOutcomeFromRuntimeState(state) {
98730
+ if (state === "failed") return "failed";
98731
+ if (state === "completed") return "completed";
98732
+ return "interrupted";
98733
+ }
98734
+ /**
98735
+ * Deterministic activity id for a turn's terminal record: never
98736
+ * `crypto.randomUUID()`. The projector replaces an activity by id
98737
+ * (`thread.activity-appended`), so a redelivered `turn.completed` for the
98738
+ * same turn lands on the same row instead of adding a second one, and the
98739
+ * serializer's own `eventId` (`activity:<id>`) inherits the same guarantee.
98740
+ */
98741
+ function turnTerminalStatusActivityId(turnId) {
98742
+ return `turn-terminal:${turnId}`;
98743
+ }
98744
+ //#endregion
98745
+ //#region src/runtimeReplication/eventFormatting.ts
98746
+ /**
98747
+ * Small formatting helpers shared by every source `serializer.ts` folds into
98748
+ * a thread's replicated event list. Kept in their own module, with no
98749
+ * dependency on `serializer.ts` itself, so a narrower serializer (such as
98750
+ * `turnTerminalStatusSerializer.ts`) can reuse them without creating an
98751
+ * import cycle back into the file that assembles the full event list.
98752
+ */
98753
+ function text(value, max) {
98754
+ const normalized = value.trim();
98755
+ return normalized ? normalized.slice(0, max) : null;
98756
+ }
98757
+ function canonicalRuntimeEventTimestamp(value) {
98758
+ let timestamp = new Date(value);
98759
+ if (Number.isNaN(timestamp.getTime())) {
98760
+ const legacyBsdNanoseconds = value.match(/^(.*:\d{2})\.\d+NZ$/);
98761
+ timestamp = new Date(legacyBsdNanoseconds ? `${legacyBsdNanoseconds[1]}Z` : value);
98762
+ }
98763
+ if (Number.isNaN(timestamp.getTime())) throw new Error(`Invalid runtime event timestamp: ${value}`);
98764
+ return timestamp.toISOString();
98765
+ }
98766
+ //#endregion
98767
+ //#region src/runtimeReplication/turnTerminalStatusSerializer.ts
98768
+ function outcomeOf(payload) {
98769
+ if (!payload || typeof payload !== "object") return null;
98770
+ const outcome = payload.outcome;
98771
+ return outcome === "completed" || outcome === "interrupted" || outcome === "failed" ? outcome : null;
98772
+ }
98773
+ function errorMessageOf(payload) {
98774
+ if (!payload || typeof payload !== "object") return null;
98775
+ const message = payload.errorMessage;
98776
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
98777
+ }
98778
+ /**
98779
+ * Turns the one durable activity `Layers/CheckpointReactor.ts` records when a
98780
+ * turn ends into CMMD's `turn_status` terminal event (CMMD-Center/forge#3562
98781
+ * piece A, CMMD-Center/CMMD#5822): `completed`, `interrupted`, or `failed`
98782
+ * with a structured `reason`. `error_summary` (`serializeFailureActivity` in
98783
+ * `serializer.ts`) keeps replicating separately for a mid-turn failure; this
98784
+ * is added next to it, never instead of it.
98785
+ *
98786
+ * The activity's own id is deterministic (`turn-terminal:<turnId>`,
98787
+ * `turnTerminalStatusActivityId`), so this event's `eventId` -- built from
98788
+ * that id the same way every other activity-sourced event on this thread is
98789
+ * (`activity:<id>`) -- can never collide with a second, different terminal
98790
+ * status for the same turn: there is exactly one activity row to read it
98791
+ * from, and the reactor that writes that row never overwrites it once set.
98792
+ */
98793
+ function serializeTurnTerminalStatusActivity(activity) {
98794
+ if (activity.kind !== "turn.terminal-status" || !activity.turnId) return null;
98795
+ const outcome = outcomeOf(activity.payload);
98796
+ if (outcome === null) return null;
98797
+ const reason = outcome === "failed" ? classifyMidTurnFailureReason(errorMessageOf(activity.payload)) : null;
98798
+ return {
98799
+ sourceSequence: null,
98800
+ event: {
98801
+ eventId: `activity:${activity.id}`,
98802
+ occurredAt: canonicalRuntimeEventTimestamp(activity.createdAt),
98803
+ turnId: activity.turnId,
98804
+ kind: "turn_status",
98805
+ status: outcome,
98806
+ ...reason ? { reason: {
98807
+ code: reason.code,
98808
+ message: text(reason.message, 2e3) ?? reason.message
98809
+ } } : {}
98810
+ }
98811
+ };
98812
+ }
98813
+ //#endregion
98574
98814
  //#region src/runtimeReplication/serializer.ts
98575
98815
  function threadStatus(thread) {
98576
98816
  if (thread.archivedAt) return "archived";
@@ -98637,19 +98877,6 @@ function serializeRuntimeInventory(snapshot, providerInventory) {
98637
98877
  ...providerInventory === void 0 ? {} : { providers: serializeProviderCatalog(providerInventory) }
98638
98878
  };
98639
98879
  }
98640
- function text(value, max) {
98641
- const normalized = value.trim();
98642
- return normalized ? normalized.slice(0, max) : null;
98643
- }
98644
- function canonicalRuntimeEventTimestamp(value) {
98645
- let timestamp = new Date(value);
98646
- if (Number.isNaN(timestamp.getTime())) {
98647
- const legacyBsdNanoseconds = value.match(/^(.*:\d{2})\.\d+NZ$/);
98648
- timestamp = new Date(legacyBsdNanoseconds ? `${legacyBsdNanoseconds[1]}Z` : value);
98649
- }
98650
- if (Number.isNaN(timestamp.getTime())) throw new Error(`Invalid runtime event timestamp: ${value}`);
98651
- return timestamp.toISOString();
98652
- }
98653
98880
  /** Spread onto an event: present only when the projection knows the turn. */
98654
98881
  function eventTurn(turnId) {
98655
98882
  return turnId ? { turnId } : {};
@@ -98954,10 +99181,13 @@ function foldReportedUsage(reported) {
98954
99181
  * model, so a mid-thread switch used to republish a finished turn as a second
98955
99182
  * row for spend that happened once (FOR-36388, fixed in FOR-36438).
98956
99183
  */
98957
- /** Turns named on this thread's own rows; hydrated replica rows carry no local turn. */
99184
+ /** Turns named on this thread's own rows; hydrated replica rows carry no local
99185
+ * turn, and the terminal-status activity (CMMD-Center/forge#3562 piece A) is
99186
+ * a wire-protocol source in its own right, not a Usage signal, so it is
99187
+ * excluded the same way a `replica.*` activity is. */
98958
99188
  function localTurnIds(thread) {
98959
99189
  const fromMessages = thread.messages.filter((message) => !message.streaming).map((message) => message.turnId);
98960
- const fromActivities = thread.activities.filter((activity) => !activity.kind.startsWith("replica.")).map((activity) => activity.turnId);
99190
+ const fromActivities = thread.activities.filter((activity) => !activity.kind.startsWith("replica.") && activity.kind !== "turn.terminal-status").map((activity) => activity.turnId);
98961
99191
  const turnIds = /* @__PURE__ */ new Set();
98962
99192
  for (const turnId of [...fromMessages, ...fromActivities]) if (turnId) turnIds.add(turnId);
98963
99193
  return turnIds;
@@ -98967,8 +99197,12 @@ function settledTurnIds(thread) {
98967
99197
  if (thread.latestTurn?.state === "running") turnIds.delete(thread.latestTurn.turnId);
98968
99198
  return turnIds;
98969
99199
  }
99200
+ /** A turn's own activities for Usage derivation, excluding the terminal-status
99201
+ * activity: that record's own `createdAt` (the turn's very end) must never
99202
+ * shift `turnLatestAt`, or an already-acknowledged usage row would rehash
99203
+ * the moment a turn that used to have no terminal activity gains one. */
98970
99204
  function turnActivities(thread, turnId) {
98971
- return thread.activities.filter((activity) => activity.turnId === turnId).toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
99205
+ return thread.activities.filter((activity) => activity.turnId === turnId && activity.kind !== "turn.terminal-status").toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
98972
99206
  }
98973
99207
  /** The last thing that happened in the turn: what the Recorded turn is stamped with. */
98974
99208
  function turnLatestAt(thread, turnId, activities) {
@@ -99177,6 +99411,10 @@ function serializeRuntimeThreadEvents(thread) {
99177
99411
  const event = serializePlanActivity(activity);
99178
99412
  return event === null ? [] : [event];
99179
99413
  });
99414
+ const turnTerminalStatuses = thread.activities.flatMap((activity) => {
99415
+ const event = serializeTurnTerminalStatusActivity(activity);
99416
+ return event === null ? [] : [event];
99417
+ });
99180
99418
  const recordedTurns = serializeRecordedTurns(thread);
99181
99419
  const textGenerations = serializeTextGenerations(thread);
99182
99420
  const priorEvents = [
@@ -99186,6 +99424,7 @@ function serializeRuntimeThreadEvents(thread) {
99186
99424
  ...worktreeLaunchActivities,
99187
99425
  ...planActivities,
99188
99426
  ...failureActivities,
99427
+ ...turnTerminalStatuses,
99189
99428
  ...recordedTurns,
99190
99429
  ...textGenerations
99191
99430
  ];
@@ -214176,73 +214415,6 @@ const makeCodexAdapter = require_Schema$1.fn("makeCodexAdapter")(function* (opti
214176
214415
  function makeCodexAdapterLive(options) {
214177
214416
  return require_Schema$1.effect(CodexAdapter, makeCodexAdapter(options));
214178
214417
  }
214179
- //#endregion
214180
- //#region ../../packages/shared/src/publicProviderCopy.ts
214181
- const PUBLIC_PROVIDER_LEAK_PATTERNS = [
214182
- /\bDeepSeek\b/i,
214183
- /\bKimi\b/i,
214184
- /\bMoonshot\b/i,
214185
- /\bdeepseek[-._a-z0-9]*\b/i,
214186
- /\bkimi[-._a-z0-9]*\b/i,
214187
- /\bmoonshot[-._a-z0-9]*\b/i,
214188
- /\bAPEX_API_KEY\b/,
214189
- /\bDEEPSEEK_API_KEY\b/,
214190
- /\bKIMI_API_KEY\b/,
214191
- /\bOPENAI_API_KEY\b/,
214192
- /\bANTHROPIC_API_KEY\b/,
214193
- /\bCURSOR_API_KEY\b/,
214194
- /\bOPENCODE_API_KEY\b/,
214195
- /\bforge_proxy_[A-Za-z0-9._-]+\b/,
214196
- /\bProviderValidationError\b/,
214197
- /\/forge-server\/node_modules\//,
214198
- /https?:\/\/[^\s'"<>)]*\/api\/provider-egress\/[^\s'"<>)]*/i,
214199
- /https?:\/\/[^\s'"<>)]*(?:apex|deepseek|moonshot|kimi)[^\s'"<>)]*/i
214200
- ];
214201
- const DEFAULT_PUBLIC_PROVIDER_ERROR = "CMMD is temporarily unavailable. Check the workspace AI connection and retry.";
214202
- function isProviderRateOrQuotaError(message) {
214203
- if (!message) return false;
214204
- const normalized = message.toLowerCase();
214205
- return /\b(rate[\s-]?limit|too\s+many\s+requests|usage[ _-]limit|usage_limit_reached|quota|insufficient_quota)\b/.test(normalized) || /\b429\b/.test(normalized);
214206
- }
214207
- function providerLifecycleOrAccountCopy(message) {
214208
- if (/\bbound to provider account\b/.test(message) && /\bcannot use provider account\b/.test(message)) return "Forge could not switch this conversation to the selected AI account. Retry the account switch.";
214209
- if (/\bforge_runtime_repairing\b/.test(message) || /\bthis turn was interrupted because the environment is being repaired\b/.test(message)) return "This turn was interrupted because the environment is being repaired. Retry after the repair finishes.";
214210
- if (/\bforge_runtime_stopped\b/.test(message) || /\bthis turn was interrupted because the environment was stopped\b/.test(message)) return "This turn was interrupted because the environment was stopped. Start the environment and retry.";
214211
- return null;
214212
- }
214213
- /**
214214
- * Infer a SAFE, clearer failure category from a (possibly leaky) provider error.
214215
- *
214216
- * Returns a canned, user-facing message — it never echoes the raw input, so it
214217
- * is safe to use even when the source message contains provider internals.
214218
- * Used to give actionable feedback ("model isn't available", "rate-limited",
214219
- * "auth failed") instead of the blanket "CMMD is temporarily unavailable."
214220
- * Returns null when no category can be confidently inferred.
214221
- */
214222
- function categorizePublicProviderError(message) {
214223
- if (!message) return null;
214224
- const m = message.toLowerCase();
214225
- const lifecycleOrAccountCopy = providerLifecycleOrAccountCopy(m);
214226
- if (lifecycleOrAccountCopy !== null) return lifecycleOrAccountCopy;
214227
- if (/\bforge_provider_model_not_allowed\b/.test(m) || /\bnot\s+scoped\s+to\s+the\s+requested\s+model\b/.test(m)) return "The workspace AI credential does not cover the model this turn asked for. Retry, or pick that model for the thread.";
214228
- if (/\bmodel\b[\s\S]*\b(not\s+(found|available|supported)|does\s+not\s+exist|unsupported|invalid|unknown)\b/.test(m) || /\b(unsupported|unknown|invalid)\b[\s\S]*\bmodel\b/.test(m) || /\b(model_not_found|invalid_model|model_not_supported)\b/.test(m)) return "The selected model isn't available right now. Pick a different model and retry.";
214229
- if (isProviderRateOrQuotaError(m)) return "The AI service is rate-limited or out of quota. Wait a moment and retry.";
214230
- if (/_credential_reconnect_required\b/.test(m)) return "The workspace AI credential needs to be reconnected. Reconnect it in Settings > Providers, then retry.";
214231
- if (/\b(unauthorized|forbidden|authentication\s+(failed|error|required)|invalid\s+api\s+key|expired\s+(token|credential))\b/.test(m) || /\b40[13]\b/.test(m)) return "Workspace AI authentication failed. Reconnect the workspace and retry.";
214232
- if (/\b(timed?\s*out|timeout|overloaded|service\s+unavailable)\b/.test(m) || /\b50[234]\b/.test(m)) return "The AI service is temporarily overloaded. Retry in a moment.";
214233
- return null;
214234
- }
214235
- function containsPublicProviderLeak(message) {
214236
- if (!message) return false;
214237
- return PUBLIC_PROVIDER_LEAK_PATTERNS.some((pattern) => pattern.test(message));
214238
- }
214239
- function sanitizePublicProviderText(message, options = {}) {
214240
- if (message === void 0 || message === null) return null;
214241
- const trimmed = message.trim();
214242
- if (trimmed.length === 0) return null;
214243
- if (!containsPublicProviderLeak(trimmed)) return trimmed;
214244
- return categorizePublicProviderError(trimmed) ?? options.fallback ?? "CMMD is temporarily unavailable. Check the workspace AI connection and retry.";
214245
- }
214246
214418
  function resolveApexBaseUrl(settings, config) {
214247
214419
  return config?.forgeApexBaseUrl || settings.baseUrl || "https://apex.cmmd.ai";
214248
214420
  }
@@ -293037,52 +293209,6 @@ function applyThreadGoalPreamble(goal, input) {
293037
293209
  return trimmedInput.length > 0 ? `${preamble}\n\n${trimmedInput}` : preamble;
293038
293210
  }
293039
293211
  //#endregion
293040
- //#region src/orchestration/providerRetryableFailure.ts
293041
- /**
293042
- * Classifies a provider turn-start failure as a retryable rate-limit or
293043
- * transient-availability failure (429/503), a terminal failure that names why
293044
- * it will never clear on its own, or unrecognized (null).
293045
- *
293046
- * CMMD-Center/forge#3558: a 429 from CMMD's provider egress used to leave no
293047
- * trace at all, because nothing in the turn-start path recognized it as a
293048
- * distinct, retryable failure. `ProviderCommandReactor` uses this to decide
293049
- * whether to retry a turn start with backoff, and to name the provider,
293050
- * status and Retry-After in the `provider.turn.start.failed` activity CMMD's
293051
- * watchdog reads once retries are exhausted.
293052
- *
293053
- * Reuses `isProviderRateOrQuotaError` (the same predicate the public-copy
293054
- * sanitizer uses to recognize a rate-limit message) rather than restating its
293055
- * word list here.
293056
- */
293057
- const SERVICE_UNAVAILABLE_PATTERN = /\b(503|service\s+unavailable)\b/i;
293058
- const RATE_LIMITED_PAST_TENSE_PATTERN = /\brate[\s-]?limit(?:ed|ing)?\b/i;
293059
- const QUOTA_OR_USAGE_LIMIT_PATTERN = /\b(usage[ _-]limit|usage_limit_reached|quota|insufficient_quota)\b/i;
293060
- const EXPLICIT_429_PATTERN = /\b429\b/;
293061
- const RETRY_AFTER_SECONDS_PATTERN = /retry-after[^0-9]{0,10}(\d+)|(?:try again|retry)\s+in\s+(\d+)\s*(?:s\b|sec(?:onds?)?\b)?/i;
293062
- function parseRetryAfterSeconds(detail) {
293063
- const match = detail.match(RETRY_AFTER_SECONDS_PATTERN);
293064
- if (!match) return null;
293065
- const raw = match[1] ?? match[2];
293066
- const seconds = raw ? Number(raw) : NaN;
293067
- return Number.isFinite(seconds) ? seconds : null;
293068
- }
293069
- function classifyRetryableProviderFailure(detail) {
293070
- if (!detail || detail.trim().length === 0) return null;
293071
- if (QUOTA_OR_USAGE_LIMIT_PATTERN.test(detail) && !EXPLICIT_429_PATTERN.test(detail)) return {
293072
- kind: "terminal",
293073
- reason: "quota",
293074
- message: detail
293075
- };
293076
- const isRateLimited = isProviderRateOrQuotaError(detail) || RATE_LIMITED_PAST_TENSE_PATTERN.test(detail);
293077
- const isUnavailable = SERVICE_UNAVAILABLE_PATTERN.test(detail);
293078
- if (!isRateLimited && !isUnavailable) return null;
293079
- return {
293080
- kind: "retryable",
293081
- status: isRateLimited ? 429 : 503,
293082
- retryAfterSeconds: parseRetryAfterSeconds(detail)
293083
- };
293084
- }
293085
- //#endregion
293086
293212
  //#region src/orchestration/Layers/ProviderCommandReactor.ts
293087
293213
  function toNonEmptyProviderInput(value) {
293088
293214
  const normalized = value?.trim();
@@ -295260,12 +295386,62 @@ const make$6 = require_Schema$1.gen(function* () {
295260
295386
  createdAt: input.createdAt
295261
295387
  });
295262
295388
  });
295389
+ /**
295390
+ * The one durable, deterministic-id activity that records how a turn ended
295391
+ * (CMMD-Center/forge#3562 piece A, CMMD-Center/CMMD#5822): `completed`,
295392
+ * `interrupted` (an abort or a provider cancellation), or `failed` with the
295393
+ * provider's own error message. Recorded here, ahead of every
295394
+ * checkpoint-specific early return below, so it fires for every turn
295395
+ * regardless of whether this workspace can capture a git checkpoint at
295396
+ * all -- unlike a checkpoint, this never depends on `resolveCheckpointCwd`.
295397
+ *
295398
+ * The activity id is derived from the turn id, never `crypto.randomUUID()`,
295399
+ * so a redelivered `turn.completed` for the same turn replaces the same
295400
+ * row rather than adding a second one. The guard below additionally
295401
+ * refuses to touch a turn that already has one: first write wins, so a
295402
+ * later, different signal for the same turn can never overwrite an
295403
+ * outcome CMMD may already have acknowledged.
295404
+ *
295405
+ * Deriving the wire `turn_status` event from a new activity recorded at
295406
+ * the moment the turn actually ends -- not from `thread.latestTurn` or
295407
+ * `thread.checkpoints`, both of which already hold outcomes for turns that
295408
+ * settled before this shipped -- is deliberate: those would insert a brand
295409
+ * new event into history CMMD already acknowledged for every existing
295410
+ * thread, which `RuntimeThreadHistoryConflict` exists specifically to
295411
+ * reject (CMMD-Center/forge#3497). A turn that settled before this shipped
295412
+ * gets no terminal event; only a turn that ends after it does.
295413
+ */
295414
+ const recordTurnTerminalStatus = require_Schema$1.fn("recordTurnTerminalStatus")(function* (event, thread, turnId) {
295415
+ const activityId = turnTerminalStatusActivityId(turnId);
295416
+ if (thread.activities.some((activity) => activity.id === activityId)) return;
295417
+ const outcome = turnTerminalOutcomeFromRuntimeState(event.payload.state);
295418
+ const recordedAt = (/* @__PURE__ */ new Date()).toISOString();
295419
+ yield* orchestrationEngine.dispatch({
295420
+ type: "thread.activity.append",
295421
+ commandId: serverCommandId$1("turn-terminal-status"),
295422
+ threadId: event.threadId,
295423
+ activity: {
295424
+ id: EventId.make(activityId),
295425
+ tone: outcome === "failed" ? "error" : "info",
295426
+ kind: TURN_TERMINAL_STATUS_ACTIVITY_KIND,
295427
+ summary: `Turn ${outcome}`,
295428
+ payload: {
295429
+ outcome,
295430
+ ...outcome === "failed" && event.payload.errorMessage ? { errorMessage: event.payload.errorMessage } : {}
295431
+ },
295432
+ turnId,
295433
+ createdAt: recordedAt
295434
+ },
295435
+ createdAt: recordedAt
295436
+ });
295437
+ });
295263
295438
  const captureCheckpointFromTurnCompletion = require_Schema$1.fn("captureCheckpointFromTurnCompletion")(function* (event) {
295264
295439
  const turnId = toTurnId(event.turnId);
295265
295440
  if (!turnId) return;
295266
295441
  const readModel = yield* orchestrationEngine.getReadModel();
295267
295442
  const thread = readModel.threads.find((entry) => entry.id === event.threadId);
295268
295443
  if (!thread) return;
295444
+ yield* recordTurnTerminalStatus(event, thread, turnId);
295269
295445
  if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) return;
295270
295446
  if (thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId && checkpoint.status !== "missing")) return;
295271
295447
  const checkpointCwd = yield* resolveCheckpointCwd({
@@ -300054,7 +300230,7 @@ function resolveBuildCommitFromEnv(env) {
300054
300230
  * environment descriptor down instead of reporting an honest "unknown".
300055
300231
  */
300056
300232
  function readBakedBuildCommit() {
300057
- return "96780e9bb987ecd23f530709589bda1b4b5791e5";
300233
+ return "af7d46dcc7bdd81e6994e3590d9fca779411f1c4";
300058
300234
  }
300059
300235
  async function resolveServerBuildCommit(input) {
300060
300236
  if (isFullCommitSha(input.baked)) return input.baked;