@yagni-app/code-staging 0.3.4-staging.1145.1 → 0.3.4-staging.1146.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.
@@ -90,6 +90,14 @@ export interface CatalogResult {
90
90
  * "off" (fail toward not sending).
91
91
  */
92
92
  guardianStorage: GuardianStorageTier;
93
+ /**
94
+ * Backend-owned per-request Guardian wall ceiling (YAG-562). The client
95
+ * derives its whole-consult deadline from this instead of a hardcoded abort.
96
+ * Absent on older backends → the extension falls back to its default.
97
+ */
98
+ guardianTimeoutMs?: number;
99
+ /** Number of attempts the backend makes (primary + one failover). */
100
+ guardianMaxAttempts?: number;
93
101
  }
94
102
  /**
95
103
  * Fetch the YAGNI model catalog at startup.
@@ -91,6 +91,12 @@ export async function fetchCatalog(opts) {
91
91
  guardianStorage: data.guardianStorage === "raw" || data.guardianStorage === "hash"
92
92
  ? data.guardianStorage
93
93
  : "off",
94
+ ...(typeof data.guardianTimeoutMs === "number" && Number.isFinite(data.guardianTimeoutMs) && data.guardianTimeoutMs > 0
95
+ ? { guardianTimeoutMs: data.guardianTimeoutMs }
96
+ : {}),
97
+ ...(typeof data.guardianMaxAttempts === "number" && Number.isSafeInteger(data.guardianMaxAttempts) && data.guardianMaxAttempts >= 1
98
+ ? { guardianMaxAttempts: data.guardianMaxAttempts }
99
+ : {}),
94
100
  };
95
101
  }
96
102
  /** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
@@ -121,7 +121,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
121
121
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
122
122
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
123
123
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
124
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
124
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
125
125
  export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./permission/guardian.js";
126
126
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
127
127
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
@@ -5,7 +5,7 @@ import { Text } from "@earendil-works/pi-tui";
5
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
6
  import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
7
7
  import { redactCommand } from "./redact.js";
8
- import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./permission/guardian.js";
8
+ import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
9
9
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
10
10
  import { makeAskYagniTool } from "./askYagniTool.js";
11
11
  import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
@@ -135,7 +135,7 @@ export async function registerYagni(pi, deps = {}) {
135
135
  // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
136
136
  // before onResponse is reached), so message_end is the only seam.
137
137
  let lastAuthRecovery = null;
138
- const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
138
+ const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, guardianTimeoutMs: guardianTimeoutAdvertisedMs, guardianMaxAttempts: guardianMaxAttemptsAdvertised, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
139
139
  // Lock the interactive session to the `advanced` tier only. The backend
140
140
  // catalog returns all tiers, but only `advanced` is registered with the
141
141
  // `yagni` provider, so /model and Ctrl+P show a single entry. Child
@@ -272,6 +272,16 @@ export async function registerYagni(pi, deps = {}) {
272
272
  !workspaceGuardianEnabled;
273
273
  const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
274
274
  const guardianLimits = resolveGuardianLimits(env);
275
+ // YAG-562: the backend owns the Guardian timeout and retries once. The client
276
+ // derives its whole-consult deadline from the catalog's advertised wall
277
+ // ceiling + attempt count (plus a boot/tool overhead margin) rather than
278
+ // hard-aborting at 15s and preempting the backend's retry. Absent an
279
+ // advertisement (older backend) this keeps the default.
280
+ const guardianTimeoutMs = deriveGuardianTimeoutMs(guardianLimits, {
281
+ ...(guardianTimeoutAdvertisedMs !== undefined ? { timeoutMs: guardianTimeoutAdvertisedMs } : {}),
282
+ ...(guardianMaxAttemptsAdvertised !== undefined ? { maxAttempts: guardianMaxAttemptsAdvertised } : {}),
283
+ });
284
+ guardianLimits.timeoutMs = guardianTimeoutMs;
275
285
  // YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
276
286
  // never the command). The remote guardian-events stream below is the
277
287
  // separate, opt-in, per-workspace analytics sink; the two are independent.
@@ -887,7 +897,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
887
897
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
888
898
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
889
899
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
890
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
900
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
891
901
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
892
902
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
893
903
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
@@ -45,13 +45,32 @@ export interface GuardianLimits {
45
45
  timeoutMs: number;
46
46
  }
47
47
  export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
48
+ /**
49
+ * Client-side safety margin on top of the backend's advertised wall ceiling
50
+ * (YAG-562). The backend now owns the real timeout (first-output + wall) and
51
+ * retries once, so the client must NOT pre-empt it; it derives its own
52
+ * deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
53
+ * for child boot + read-tool round-trips + teardown, and treats that bound as
54
+ * a last-resort floor, not the normal path.
55
+ */
56
+ export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 10000;
48
57
  /**
49
58
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
50
59
  * overrides the sliding-window review cap; anything non-numeric or < 1 falls
51
60
  * back to the default (a bad value must never zero out the cap and lock the
52
- * session).
61
+ * session). `YAGNI_GUARDIAN_TIMEOUT_MS` overrides the consult deadline for a
62
+ * single developer (previously documented but unwired).
53
63
  */
54
64
  export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
65
+ /**
66
+ * Derive the client's whole-consult deadline from the backend's advertised
67
+ * wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
68
+ * when the catalog omitted the fields (older backend).
69
+ */
70
+ export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised: {
71
+ timeoutMs?: number;
72
+ maxAttempts?: number;
73
+ }): number;
55
74
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
56
75
  export declare const GUARDIAN_MODEL_TIER = "efficient";
57
76
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
@@ -33,17 +33,41 @@ export const DEFAULT_GUARDIAN_LIMITS = {
33
33
  maxConsecutiveDenials: 3,
34
34
  timeoutMs: 15_000,
35
35
  };
36
+ /**
37
+ * Client-side safety margin on top of the backend's advertised wall ceiling
38
+ * (YAG-562). The backend now owns the real timeout (first-output + wall) and
39
+ * retries once, so the client must NOT pre-empt it; it derives its own
40
+ * deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
41
+ * for child boot + read-tool round-trips + teardown, and treats that bound as
42
+ * a last-resort floor, not the normal path.
43
+ */
44
+ export const GUARDIAN_CLIENT_OVERHEAD_MS = 10_000;
36
45
  /**
37
46
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
38
47
  * overrides the sliding-window review cap; anything non-numeric or < 1 falls
39
48
  * back to the default (a bad value must never zero out the cap and lock the
40
- * session).
49
+ * session). `YAGNI_GUARDIAN_TIMEOUT_MS` overrides the consult deadline for a
50
+ * single developer (previously documented but unwired).
41
51
  */
42
52
  export function resolveGuardianLimits(env = process.env) {
43
- const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
44
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
45
- const maxReviews = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_GUARDIAN_LIMITS.maxReviews;
46
- return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews };
53
+ const rawReviews = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
54
+ const parsedReviews = rawReviews ? Number.parseInt(rawReviews, 10) : NaN;
55
+ const maxReviews = Number.isFinite(parsedReviews) && parsedReviews >= 1 ? parsedReviews : DEFAULT_GUARDIAN_LIMITS.maxReviews;
56
+ const rawTimeout = env.YAGNI_GUARDIAN_TIMEOUT_MS?.trim();
57
+ const parsedTimeout = rawTimeout ? Number.parseInt(rawTimeout, 10) : NaN;
58
+ const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 1 ? parsedTimeout : DEFAULT_GUARDIAN_LIMITS.timeoutMs;
59
+ return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews, timeoutMs };
60
+ }
61
+ /**
62
+ * Derive the client's whole-consult deadline from the backend's advertised
63
+ * wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
64
+ * when the catalog omitted the fields (older backend).
65
+ */
66
+ export function deriveGuardianTimeoutMs(base, advertised) {
67
+ if (advertised.timeoutMs !== undefined && advertised.maxAttempts !== undefined) {
68
+ return advertised.timeoutMs * advertised.maxAttempts + GUARDIAN_CLIENT_OVERHEAD_MS;
69
+ }
70
+ return base.timeoutMs;
47
71
  }
48
72
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
49
73
  export const GUARDIAN_MODEL_TIER = "efficient";
@@ -190,7 +214,19 @@ export async function reviewCommand(command, deps) {
190
214
  if (deps.signal?.aborted) {
191
215
  return { verdict: null, error: "aborted", cost };
192
216
  }
217
+ // The backend now owns the timeout (YAG-562) and streams a terminal error
218
+ // frame when the consult fails; a result with a `stopReason: "error"` or an
219
+ // `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
220
+ // it to a concrete error so the gate shows "review timed out" / "service
221
+ // unavailable" instead of the misleading "no response". A timeout-shaped
222
+ // message (stream_idle_timeout / "timed out") is distinguished so the gate
223
+ // can add its timeout-specific note.
193
224
  if (!output) {
225
+ if (result.stopReason === "error" || result.errorMessage) {
226
+ const msg = (result.errorMessage ?? "").toLowerCase();
227
+ const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
228
+ return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
229
+ }
194
230
  return { verdict: null, error: "empty", cost };
195
231
  }
196
232
  const verdict = parseVerdict(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.4-staging.1145.1",
3
+ "version": "0.3.4-staging.1146.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -39,5 +39,5 @@
39
39
  "smol-toml": "^1.8.0",
40
40
  "typebox": "^1.3.11"
41
41
  },
42
- "yagniSourceSha": "23495abda59558b06033f88e56f41c8e2417e294"
42
+ "yagniSourceSha": "e48db7745c85aeb3907d42ad4481bf7d70bdf36c"
43
43
  }