@mjasnikovs/pi-task 0.40.39 → 0.40.41

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.
@@ -318,12 +318,18 @@ export function judgeDeepSession(f) {
318
318
  && lastHopJudgesTheClient(r)
319
319
  && (r.mimeType ?? '').startsWith('text/html'));
320
320
  if (swallowed) {
321
+ const landed = swallowed.finalPath ?? swallowed.path;
322
+ const hop = landed === swallowed.path ? '' : ` → \`${landed}\``;
323
+ const cause = swallowed.redirected ?
324
+ 'The call was bounced to a page, so the session the client carried is not one this '
325
+ + 'server accepts and the answer is the SPA shell.'
326
+ : 'The route is not mounted and the catch-all answered instead, so the client sees a '
327
+ + '200 it cannot parse.';
321
328
  return {
322
329
  outcome: 'fail',
323
- detail: `\`${swallowed.method} ${swallowed.path}\` → ${String(swallowed.status)} `
330
+ detail: `\`${swallowed.method} ${swallowed.path}\`${hop} → ${String(swallowed.status)} `
324
331
  + `${swallowed.mimeType ?? ''}: an XHR asked this server for data and got the SPA `
325
- + 'shell. The route is not mounted and the catch-all answered instead, so the client '
326
- + 'sees a 200 it cannot parse. No status check can see this.'
332
+ + `shell. ${cause} No status check can see this.`
327
333
  };
328
334
  }
329
335
  const { method, path: p, status, failed, redirected: authRedirected } = f.authRequest;
@@ -695,6 +701,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
695
701
  const existing = requests.get(id);
696
702
  if (p.redirectResponse !== undefined && existing) {
697
703
  existing.finalUrl = String(req?.url ?? existing.finalUrl);
704
+ existing.redirected = true;
698
705
  }
699
706
  else {
700
707
  requests.set(id, {
@@ -705,6 +712,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
705
712
  status: null,
706
713
  mimeType: null,
707
714
  failed: false,
715
+ redirected: false,
708
716
  seq: nextSeq++
709
717
  });
710
718
  }
@@ -779,7 +787,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
779
787
  mimeType: r.mimeType,
780
788
  failed: r.failed,
781
789
  initiator: initiatorOf(r),
782
- redirected: r.finalUrl !== r.url,
790
+ redirected: r.redirected,
783
791
  phase: id === authId ? 'auth'
784
792
  : r.seq >= postSeq ? 'post'
785
793
  : 'pre'
@@ -840,12 +848,11 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
840
848
  ?? firstRequest(r => r.seq >= fillSeq && sameOrigin(r) && r.method !== 'GET' && r.type === 'Document');
841
849
  // An SSO sign-in addresses this app and ends on the provider, so it is absent
842
850
  // from the same-origin log above and "no request to our own origin" would
843
- // misname it. Document-only for the same reason the authId fallback is: a
844
- // telemetry beacon the fill races that happens to end off-origin would otherwise
845
- // name a bogus identity provider.
846
- const offsiteSignIn = authId !== null ? null : (firstRequest(r => r.seq >= fillSeq
851
+ // misname it. The two windows are authId's, for the same reasons: anything the
852
+ // submit issued counts, and only a NAVIGATION counts back to the fill, where the
853
+ // background traffic it races would otherwise name a bogus identity provider.
854
+ const offsiteSignIn = authId !== null ? null : (firstRequest(r => (r.seq >= submitSeq || (r.seq >= fillSeq && r.type === 'Document'))
847
855
  && r.method !== 'GET'
848
- && r.type === 'Document'
849
856
  && addressedOrigin(r)
850
857
  && !sameOrigin(r)
851
858
  && r.finalUrl.startsWith('http')));
@@ -96,6 +96,7 @@ export declare function parseEnforceVerdict(text: string): {
96
96
  /** The subset of a runWorker result the enforcement-child mapping reads. */
97
97
  export interface EnforceChildResult extends WorkerFailureInput {
98
98
  text: string;
99
+ modelError?: string;
99
100
  }
100
101
  /**
101
102
  * Map the enforcement child's runWorker result to a fatal error message, or null
@@ -236,9 +236,19 @@ export function parseEnforceVerdict(text) {
236
236
  * effects don't get re-classified as a user cancel or a crash.
237
237
  */
238
238
  export function classifyEnforceChildFailure(r) {
239
- const failure = classifyWorkerFailure(r);
240
- if (!failure)
241
- return null;
239
+ const kill = classifyWorkerFailure(r);
240
+ const named = kill ? describeKill(kill) : null;
241
+ if (named !== null)
242
+ return named;
243
+ // Not a kill, so the ladder leaves it to the consumer: pi reports a failed
244
+ // turn as exit 0, empty text, and the cause in `modelError`. Unread, the
245
+ // empty text parses as "no verdict" and a dead provider is blamed on the work.
246
+ // Checked after a surviving loop too — that arm is a warning, not an answer.
247
+ return r.modelError && r.text.trim().length === 0 ?
248
+ `model error — ${r.modelError.slice(0, 200)}`
249
+ : null;
250
+ }
251
+ function describeKill(failure) {
242
252
  switch (failure.kind) {
243
253
  case 'stalled':
244
254
  // Otherwise a long silence is reported as a user cancel.
@@ -12,4 +12,10 @@
12
12
  * — the tool whitelist, not the extension flag, is the bound that holds.
13
13
  */
14
14
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
15
- export declare function registerPiWorker(pi: ExtensionAPI): void;
15
+ import type { SpawnFn } from '../shared/child-process.js';
16
+ /** Test seams: a fake child, and no real backoff sleep between its attempts. */
17
+ export interface PiWorkerInternals {
18
+ spawn?: SpawnFn;
19
+ sleepFor?: (ms: number) => Promise<void>;
20
+ }
21
+ export declare function registerPiWorker(pi: ExtensionAPI, internals?: PiWorkerInternals): void;
@@ -19,10 +19,28 @@ import { runWorker } from './pi-worker-core.js';
19
19
  import { contextWindowForGroup } from '../task/context-usage.js';
20
20
  import { childFailureReason, formatChildFailure, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
21
21
  const RENDER_PROMPT_MAX = 120;
22
+ const STDERR_TAIL = 500;
23
+ function workerDetails(r) {
24
+ return {
25
+ exitCode: r.exitCode,
26
+ attempts: r.attempts,
27
+ restarts: r.restarts.map(x => x.reason),
28
+ ...(r.modelError !== undefined ? { modelError: r.modelError } : {}),
29
+ ...(r.stderr ? { stderr: r.stderr.slice(-STDERR_TAIL) } : {})
30
+ };
31
+ }
32
+ /** An empty final answer, with the spawns it cost, so the model can tell a
33
+ * retried failure from a child that simply had nothing to say. */
34
+ function describeEmptyAnswer(r) {
35
+ if (r.restarts.length === 0)
36
+ return '(no output)';
37
+ const reasons = r.restarts.map(x => x.reason).join(', ');
38
+ return `(no output after ${r.attempts} attempts; discarded: ${reasons})`;
39
+ }
22
40
  const WorkerParams = Type.Object({
23
41
  prompt: Type.String({ description: 'Task for the worker to perform.' })
24
42
  });
25
- export function registerPiWorker(pi) {
43
+ export function registerPiWorker(pi, internals = {}) {
26
44
  makeWorkerTool(pi, {
27
45
  name: 'pi-worker',
28
46
  label: 'Pi Worker',
@@ -67,14 +85,25 @@ export function registerPiWorker(pi) {
67
85
  // model and its window is the honest one. Without this the churn
68
86
  // rule cannot fire — see RunWorkerInput.contextWindow.
69
87
  contextWindow: contextWindowForGroup(ctx, 'research') || 'unknown',
70
- groupArgs: groupChildArgs('research')
88
+ groupArgs: groupChildArgs('research'),
89
+ ...(internals.spawn ? { spawn: internals.spawn } : {}),
90
+ ...(internals.sleepFor ? { sleepFor: internals.sleepFor } : {})
71
91
  });
72
- const details = { exitCode: result.exitCode };
92
+ const details = workerDetails(result);
73
93
  const failure = formatChildFailure(result, 'Worker aborted.');
74
94
  if (failure !== null) {
75
95
  return workerUnavailable(failure, details, childFailureReason(result));
76
96
  }
77
- return workerAnswer(result.text || '(no output)', details);
97
+ // Not a kill, so the ladder leaves it to us: pi reports a failed turn
98
+ // as exit 0, empty text, and the cause in `modelError`.
99
+ if (result.modelError && result.text.trim().length === 0) {
100
+ return workerUnavailable(`Worker failed: model error — ${result.modelError.slice(0, 200)}`, details, 'model-error');
101
+ }
102
+ const text = result.text.trim();
103
+ if (text.length === 0) {
104
+ return workerUnavailable(describeEmptyAnswer(result), details, 'no-answer');
105
+ }
106
+ return workerAnswer(result.text, details);
78
107
  },
79
108
  renderCall(args, theme) {
80
109
  const prompt = args.prompt.replace(/\s+/g, ' ').trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.39",
3
+ "version": "0.40.41",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",