@mjasnikovs/pi-task 0.38.22 → 0.38.24
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/README.md +2 -2
- package/dist/config/reasoning-args.d.ts +12 -1
- package/dist/config/reasoning-args.js +5 -2
- package/dist/config/reasoning.d.ts +60 -2
- package/dist/config/reasoning.js +344 -37
- package/dist/config/register.d.ts +76 -32
- package/dist/config/register.js +124 -82
- package/dist/shared/reasoning-capability.d.ts +2 -5
- package/dist/shared/reasoning-capability.js +31 -4
- package/dist/task/auto-orchestrator.d.ts +2 -0
- package/dist/task/auto-orchestrator.js +28 -41
- package/dist/task/child-runner.d.ts +89 -24
- package/dist/task/child-runner.js +67 -46
- package/dist/task/orchestrator.d.ts +14 -20
- package/dist/task/orchestrator.js +12 -9
- package/dist/task/phases.d.ts +16 -23
- package/dist/task/phases.js +47 -452
- package/dist/task/question-dialog.d.ts +56 -0
- package/dist/task/question-dialog.js +53 -0
- package/dist/task/research-worker.d.ts +180 -0
- package/dist/task/research-worker.js +432 -0
- package/dist/workers/brave-warning.js +4 -30
- package/dist/workers/docs-core.d.ts +8 -4
- package/dist/workers/docs-core.js +30 -21
- package/dist/workers/docs-lookup.d.ts +72 -0
- package/dist/workers/docs-lookup.js +53 -0
- package/dist/workers/docs-project.d.ts +9 -0
- package/dist/workers/docs-project.js +15 -0
- package/dist/workers/pi-worker-core.d.ts +87 -1
- package/dist/workers/pi-worker-core.js +3 -7
- package/dist/workers/pi-worker-docs.js +27 -31
- package/dist/workers/reasoning-warning.d.ts +10 -16
- package/dist/workers/reasoning-warning.js +25 -57
- package/dist/workers/session-hint.d.ts +37 -0
- package/dist/workers/session-hint.js +82 -0
- package/dist/workers/worker-failure.d.ts +34 -0
- package/dist/workers/worker-failure.js +27 -16
- package/dist/workers/worker-kill.d.ts +84 -0
- package/dist/workers/worker-kill.js +124 -0
- package/package.json +1 -1
- package/dist/task/reasoning-groups.d.ts +0 -36
- package/dist/task/reasoning-groups.js +0 -36
package/dist/task/phases.js
CHANGED
|
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { docsFocused } from '../workers/docs-core.js';
|
|
7
7
|
import { fetchFocused } from '../workers/fetch-core.js';
|
|
8
8
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
9
|
-
import { classifyWorkerFailure } from '../workers/worker-failure.js';
|
|
10
9
|
import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
11
10
|
import { searchProviderKey } from '../workers/search-types.js';
|
|
12
11
|
import { channelSet } from '../workers/worker-channels.js';
|
|
@@ -14,7 +13,6 @@ import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, proje
|
|
|
14
13
|
import { isIntegrationUnknown } from './unknown-routing.js';
|
|
15
14
|
import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
|
|
16
15
|
import { demoteUnsourcedAttributions } from './context-attribution.js';
|
|
17
|
-
import { classifyContextSilence, countBullets } from './context-silence.js';
|
|
18
16
|
import { getFileInventory } from './file-inventory.js';
|
|
19
17
|
import { buildOrientation, orientationTier } from './orientation.js';
|
|
20
18
|
import { getConfig } from '../config/config.js';
|
|
@@ -31,18 +29,18 @@ import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyTool
|
|
|
31
29
|
import { compressTitle } from './title-label.js';
|
|
32
30
|
import { parseVerifyBlock, validateSpecShape, validateRefineShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
|
|
33
31
|
import { collectCritiqueDefects } from './critique-probes.js';
|
|
34
|
-
import {
|
|
32
|
+
import { settleQuestion } from './question-dialog.js';
|
|
35
33
|
import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
|
|
36
34
|
import { GREP_THEATER_RETRY_HINT } from './verify-quality.js';
|
|
37
35
|
import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
|
|
38
36
|
import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
|
|
39
37
|
import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
|
|
40
38
|
import { trackedSourceOracle } from './owned-freeze-conflict.js';
|
|
41
|
-
import { runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
39
|
+
import { thinkingForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
40
|
+
import { runResearchWorker, researchWorkerCacheHeading } from './research-worker.js';
|
|
42
41
|
import { SessionUI } from '../remote/bridge.js';
|
|
43
42
|
import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
|
|
44
43
|
import { QaTranscript, GRILL_QA_POLICY } from './qa-transcript.js';
|
|
45
|
-
import { groupThinkingArgs } from '../config/reasoning-args.js';
|
|
46
44
|
// ─── Re-export constants from their home modules ────────────────────────────
|
|
47
45
|
export { MAX_GRILL_QUESTIONS };
|
|
48
46
|
// ─── Tooling helpers ─────────────────────────────────────────────────────────
|
|
@@ -326,7 +324,7 @@ export async function phaseVerifyTooling(deps, research) {
|
|
|
326
324
|
* Both halves of "given a channel" — the tools string and the `-e` path — come
|
|
327
325
|
* from the same rows, so they cannot disagree.
|
|
328
326
|
*/
|
|
329
|
-
function apisWorkerChannels() {
|
|
327
|
+
export function apisWorkerChannels() {
|
|
330
328
|
return channelSet([
|
|
331
329
|
'pi-worker-docs',
|
|
332
330
|
...(searchConfigured() ? ['pi-worker-search', 'pi-worker-fetch'] : [])
|
|
@@ -360,15 +358,6 @@ export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for e
|
|
|
360
358
|
* See single-read-guard.ts.
|
|
361
359
|
*/
|
|
362
360
|
export const SINGLE_READ_EXTENSION_PATH = fileURLToPath(new URL('../workers/single-read-extension.js', import.meta.url));
|
|
363
|
-
/**
|
|
364
|
-
* Task-file heading under which a research worker's validated output is cached.
|
|
365
|
-
* A resumed research phase reads these to skip workers that already succeeded,
|
|
366
|
-
* instead of re-running all four from scratch when one of them fails — the
|
|
367
|
-
* expensive case (e.g. 3 healthy workers thrown away because the 4th looped).
|
|
368
|
-
*/
|
|
369
|
-
function researchWorkerCacheHeading(section) {
|
|
370
|
-
return `research worker ${section}`;
|
|
371
|
-
}
|
|
372
361
|
/**
|
|
373
362
|
* The TOOLING worker only needs to know which verification commands the task
|
|
374
363
|
* cares about — never the per-file edit list. Big refined prompts embed a long
|
|
@@ -392,136 +381,6 @@ export function scopedToolingGoal(refined) {
|
|
|
392
381
|
const firstBullet = goal.search(/\n[ \t]*[-*]\s/);
|
|
393
382
|
return firstBullet === -1 ? goal : goal.slice(0, firstBullet).trim();
|
|
394
383
|
}
|
|
395
|
-
/**
|
|
396
|
-
* Classify a research worker's result so the phase can react per-worker instead
|
|
397
|
-
* of treating every failure the same. Two distinct failure shapes:
|
|
398
|
-
*
|
|
399
|
-
* - 'runaway' (loop-kill OR per-worker wall-clock timeout): the worker explored
|
|
400
|
-
* too long and was killed *after* burning its MAX_LOOP_RESTARTS restarts. It
|
|
401
|
-
* did real work and left partial text; the other three workers are unaffected.
|
|
402
|
-
* Failing the whole task here would throw away every already-good worker AND
|
|
403
|
-
* abort the entire auto-run over the weakest section — and because the loop is
|
|
404
|
-
* deterministic, a resume just re-loops and re-fails. So this DEGRADES: keep
|
|
405
|
-
* the partial answer (marked), cache it, move on. A loop-kill is a SIGTERM
|
|
406
|
-
* (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
|
|
407
|
-
* exitCode — are the reliable signal and are checked first.
|
|
408
|
-
*
|
|
409
|
-
* - 'fatal' (non-zero exit that isn't a loop-kill, a provider error behind an
|
|
410
|
-
* empty answer, or a leaked never-executed tool call): the output is
|
|
411
|
-
* untrustworthy in a way partial text can't paper over (broken env, model
|
|
412
|
-
* disconnect, wrong tool-call dialect). These still throw — degrading them
|
|
413
|
-
* would launder a real breakage into a plausible-looking section.
|
|
414
|
-
*
|
|
415
|
-
* - 'empty' (clean exit 0, no provider error, no loop/timeout — the model simply
|
|
416
|
-
* wrote nothing): NOT a failure. On an extremely simple task ("create a folder
|
|
417
|
-
* with an index.html in it") three of the four workers have genuinely nothing
|
|
418
|
-
* to report, and each worker prompt tells the model to emit ONLY what this task
|
|
419
|
-
* touches and to drop everything else — so silence is the CORRECT answer and
|
|
420
|
-
* was killing the whole task at research (issue #10). Measured live on the
|
|
421
|
-
* issue's own prompt (30 reps/worker, local Qwen3.6-27B): every APIS answer was
|
|
422
|
-
* semantically "there is nothing here", and 2/30 were literally zero bytes on a
|
|
423
|
-
* clean exit — the other 28 survived only because the model happened to wrap the
|
|
424
|
-
* same non-answer in a parenthetical, which is model style, not signal. The
|
|
425
|
-
* caller retries once and then accepts an explicit empty section; what stays
|
|
426
|
-
* fatal is silence WITH a reported cause, which is the masked-disconnect case
|
|
427
|
-
* this branch was written for and which `modelError` now names outright.
|
|
428
|
-
*
|
|
429
|
-
* Returns null when the result is trustworthy.
|
|
430
|
-
*/
|
|
431
|
-
function classifyResearchWorker(name, result) {
|
|
432
|
-
// What KILLED the child, if anything — classified once, in the ladder that
|
|
433
|
-
// owns the precedence (workers/worker-failure.ts), because every kill path
|
|
434
|
-
// also sets `aborted` and a non-zero exit. This switch says only what each
|
|
435
|
-
// cause means to RESEARCH; being exhaustive, a new cause is a compile error
|
|
436
|
-
// here instead of falling through to the generic "exit N".
|
|
437
|
-
const failure = classifyWorkerFailure(result);
|
|
438
|
-
if (failure) {
|
|
439
|
-
switch (failure.kind) {
|
|
440
|
-
case 'loop': {
|
|
441
|
-
const argsStr = JSON.stringify(failure.hit.call.args);
|
|
442
|
-
return {
|
|
443
|
-
kind: 'runaway',
|
|
444
|
-
reason: `stuck in a loop — called ${failure.hit.call.name}(${argsStr}) `
|
|
445
|
-
+ `×${failure.hit.count} in the last ${failure.hit.windowSize} calls `
|
|
446
|
-
+ `and still looped after restarts`
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
case 'worker-timeout':
|
|
450
|
-
return { kind: 'runaway', reason: 'timed out after restarts' };
|
|
451
|
-
case 'command-timeout':
|
|
452
|
-
return {
|
|
453
|
-
kind: 'runaway',
|
|
454
|
-
reason: `ran a \`${failure.toolName}\` command that never returned and was killed `
|
|
455
|
-
+ 'after restarts'
|
|
456
|
-
};
|
|
457
|
-
case 'stream-stall':
|
|
458
|
-
return {
|
|
459
|
-
kind: 'runaway',
|
|
460
|
-
reason: `model stream went silent for ${failure.idleMs}ms after restarts`
|
|
461
|
-
};
|
|
462
|
-
case 'stalled':
|
|
463
|
-
return {
|
|
464
|
-
kind: 'fatal',
|
|
465
|
-
error: new Error(`Research ${name} worker: model server unreachable — the child produced no `
|
|
466
|
-
+ 'output and the model endpoint did not respond')
|
|
467
|
-
};
|
|
468
|
-
case 'leaked-tool-call':
|
|
469
|
-
return {
|
|
470
|
-
kind: 'fatal',
|
|
471
|
-
error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
|
|
472
|
-
+ `(${failure.text.trim()}) — it never ran`)
|
|
473
|
-
};
|
|
474
|
-
case 'aborted':
|
|
475
|
-
case 'exit':
|
|
476
|
-
return {
|
|
477
|
-
kind: 'fatal',
|
|
478
|
-
error: new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`)
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (result.text.trim().length === 0) {
|
|
483
|
-
// NOTHING CAME BACK — two different events wear the same face, and the whole
|
|
484
|
-
// point of this branch is to tell them apart:
|
|
485
|
-
//
|
|
486
|
-
// FAILED, cause reported: pi delivers a failed turn as an empty assistant
|
|
487
|
-
// message with stopReason "error" and exit 0, so the real cause used to be
|
|
488
|
-
// discarded and reported as the useless "produced no output". Name it.
|
|
489
|
-
// FAILED, child never spoke: no stdout at all means the child died before it
|
|
490
|
-
// could run (unresolvable provider, missing key, bad argv) — it never
|
|
491
|
-
// answered, so it cannot have answered "nothing".
|
|
492
|
-
// EMPTY: a child that streamed, exited 0, reported no error, and wrote no
|
|
493
|
-
// answer. The worker ran and the model had nothing to say — a real answer
|
|
494
|
-
// on a task that touches nothing, not a failure.
|
|
495
|
-
if (result.modelError) {
|
|
496
|
-
return {
|
|
497
|
-
kind: 'fatal',
|
|
498
|
-
error: new Error(`Research ${name} worker: model error — ${result.modelError.slice(0, 200)}`)
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
if (!result.sawOutput) {
|
|
502
|
-
return {
|
|
503
|
-
kind: 'fatal',
|
|
504
|
-
error: new Error(`Research ${name} worker produced no output — the child never wrote a `
|
|
505
|
-
+ 'single byte, so it died before it could answer'
|
|
506
|
-
+ (result.stderr ? `: ${result.stderr.slice(-300)}` : ''))
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
return { kind: 'empty' };
|
|
510
|
-
}
|
|
511
|
-
return null;
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* Build a degraded section body for a runaway worker: a one-line marker naming
|
|
515
|
-
* the failure (so downstream phases and a human reading the task file know this
|
|
516
|
-
* section is incomplete) followed by whatever partial answer the worker streamed
|
|
517
|
-
* before it was killed. The marker is always present even when there is no
|
|
518
|
-
* partial text, so an empty degrade is never mistaken for a real finding.
|
|
519
|
-
*/
|
|
520
|
-
export function degradedSectionBody(name, reason, partial) {
|
|
521
|
-
const marker = `(degraded: research ${name} worker ${reason}; this section may be incomplete)`;
|
|
522
|
-
const body = partial.trim();
|
|
523
|
-
return body.length > 0 ? `${marker}\n\n${body}` : marker;
|
|
524
|
-
}
|
|
525
384
|
/**
|
|
526
385
|
* Dependency names declared by the project manifest, used by the CONTEXT post-check to
|
|
527
386
|
* tell "this bullet is about an external library" from "this bullet is about our source".
|
|
@@ -538,67 +397,6 @@ async function manifestDependencyNames(cwd) {
|
|
|
538
397
|
return [];
|
|
539
398
|
}
|
|
540
399
|
}
|
|
541
|
-
/**
|
|
542
|
-
* The body written for a research section the worker confirmed has no entries.
|
|
543
|
-
*
|
|
544
|
-
* Three states have to stay distinguishable to anyone — human or later phase —
|
|
545
|
-
* reading a research section, so each carries its own marker:
|
|
546
|
-
* `(none — …)` the worker RAN and answered "nothing applies" (this)
|
|
547
|
-
* `(degraded: …)` the worker was killed mid-answer, text may be partial
|
|
548
|
-
* (degradedSectionBody)
|
|
549
|
-
* section absent the worker never got that far — the phase threw
|
|
550
|
-
*
|
|
551
|
-
* Naming the worker inside the marker keeps it true after assembly, where the
|
|
552
|
-
* section headings are all that separate the four workers' output.
|
|
553
|
-
*/
|
|
554
|
-
export function emptySectionBody(name) {
|
|
555
|
-
return `(none — the ${name} worker ran and reported no entries for this task)`;
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* A worker answer that IS the word "nothing" and carries no other content:
|
|
559
|
-
* `(none)`, `N/A`, `- none`, `(no content)`, `(no entries)`. Live workers write
|
|
560
|
-
* these often on a task that touches nothing (measured on the issue's prompt:
|
|
561
|
-
* `(no content)`, `(no response)`, a bare `(none)` from the gate's own retry),
|
|
562
|
-
* and each one means exactly what an empty answer means — so they are recorded
|
|
563
|
-
* with the same marker rather than passed through in whatever shape the model
|
|
564
|
-
* happened to pick. Deliberately NARROW: it matches only a lone token, never
|
|
565
|
-
* prose like "(no APIs to list — this task creates a plain HTML file …)", which
|
|
566
|
-
* carries a reason worth keeping.
|
|
567
|
-
*/
|
|
568
|
-
const BARE_NONE_ANSWER = /^[-*\s]*\(?\s*(?:none|n\/?a|nothing|no (?:content|entries|response|items|results))\s*\.?\s*\)?\s*$/i;
|
|
569
|
-
export function isBareNoneAnswer(text) {
|
|
570
|
-
return BARE_NONE_ANSWER.test(text.trim());
|
|
571
|
-
}
|
|
572
|
-
/**
|
|
573
|
-
* Prepended on the ONE retry the empty-section gate triggers. A zero-byte answer is
|
|
574
|
-
* ambiguous — a crashed worker looks exactly like a worker with nothing to say — so
|
|
575
|
-
* the retry's only job is to remove the ambiguity: answer properly, or say "(none)"
|
|
576
|
-
* in as many words.
|
|
577
|
-
*
|
|
578
|
-
* It must NOT turn into an invitation to skip the work: `(none)` is offered only
|
|
579
|
-
* behind an explicit "after you have looked" condition, because this retry also
|
|
580
|
-
* fires on a normal project where the first attempt died for an unrelated reason,
|
|
581
|
-
* and an easy opt-out there would silence real research.
|
|
582
|
-
*
|
|
583
|
-
* MEASUREMENT OPEN. The recovery path's QUALITY on a real repo is being measured
|
|
584
|
-
* (scripts live under /home/edgars/tmp/issue10: first FILES answer faulted to
|
|
585
|
-
* empty, every other child live, against an uninterrupted control). First rep on
|
|
586
|
-
* an earlier wording did NOT take the `(none)` exit but drifted into writing code
|
|
587
|
-
* instead of listing paths — the deliverable-not-inputs failure the base prompt
|
|
588
|
-
* already forbids below this preamble. Blast radius is bounded: the gate fires
|
|
589
|
-
* only on a run that would otherwise have FAILED outright, so a mediocre recovered
|
|
590
|
-
* section is strictly better than the dead task it replaces — but if the drift
|
|
591
|
-
* reproduces, this preamble must restate the section's output contract, not just
|
|
592
|
-
* demand an answer.
|
|
593
|
-
*/
|
|
594
|
-
const EMPTY_SECTION_PREAMBLE = 'STOP. Your previous attempt returned an EMPTY answer — zero characters. An empty '
|
|
595
|
-
+ 'response cannot be accepted, because it is indistinguishable from a worker that '
|
|
596
|
-
+ 'crashed before it wrote anything. Answer again now, and do the research properly '
|
|
597
|
-
+ 'this time: look first, then write what you found, in the required format. Only if '
|
|
598
|
-
+ 'you have looked and there is genuinely nothing to report — the task touches no '
|
|
599
|
-
+ 'existing file, needs no external symbol, or the project has no such tooling — write '
|
|
600
|
-
+ 'exactly `(none)` and nothing else. Do not answer `(none)` to avoid the work, and '
|
|
601
|
-
+ 'never answer with silence.';
|
|
602
400
|
/**
|
|
603
401
|
* Prepended to worker:apis's prompt on the ONE retry the zero-retrieval gate triggers. It
|
|
604
402
|
* names the exact failure (a section written with no retrieval) so the correction is concrete,
|
|
@@ -876,212 +674,38 @@ export async function phaseResearch(deps, refined) {
|
|
|
876
674
|
persistChain = next.catch(() => { });
|
|
877
675
|
return next;
|
|
878
676
|
};
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
// All four research workers share one group: they are the same
|
|
905
|
-
// job (read-only exploration of the repo) run over four
|
|
906
|
-
// questions, and a table that could set them apart would be
|
|
907
|
-
// four cells nobody has the trials to fill.
|
|
908
|
-
thinking: groupThinkingArgs('research'),
|
|
909
|
-
...(spec.tools ? { tools: spec.tools } : {}),
|
|
910
|
-
...(spec.extensions ? { extensions: spec.extensions } : {}),
|
|
911
|
-
// 5B SCALE arm — null unless both env vars are set. Only the
|
|
912
|
-
// docs-capable worker can fan out, so only it can be scaled.
|
|
913
|
-
...(spec.fanoutBounded && fanoutTimeout ? { fanoutTimeout } : {}),
|
|
914
|
-
// 5B RESCUE. Applies to EVERY research worker, not just the
|
|
915
|
-
// docs-capable one: any worker that gets killed loses its work
|
|
916
|
-
// the same way. carry-forward stays OFF unless asked for
|
|
917
|
-
// (measured harmful on its own); the progress deadline SHIPPED
|
|
918
|
-
// ON in nexttask 9 and is null only when explicitly disabled.
|
|
919
|
-
...(carryForward ? { carryForward: true } : {}),
|
|
920
|
-
...(progressCeilingMs !== null ?
|
|
921
|
-
{ progressTimeoutCeilingMs: progressCeilingMs }
|
|
922
|
-
: {}),
|
|
923
|
-
// One line per DISCARDED attempt. The `done` line below reports
|
|
924
|
-
// the final attempt only, so a worker that timed out twice at
|
|
925
|
-
// 240s and then answered used to log exactly like a clean one —
|
|
926
|
-
// 8 minutes of burned compute recoverable only by subtracting
|
|
927
|
-
// its own wait+work from the start/done timestamps.
|
|
928
|
-
onCarryForward: ci => {
|
|
929
|
-
deps.logDebug?.(`${spec.label}: CARRY-FORWARD injected into attempt ${ci.attempt}`
|
|
930
|
-
+ ` (${ci.chars} chars onto a ${ci.promptCharsBefore}-char prompt)`);
|
|
931
|
-
},
|
|
932
|
-
onRestart: rs => {
|
|
933
|
-
deps.logDebug?.(`${spec.label}: RESTART (attempt ${rs.attempt} discarded)`
|
|
934
|
-
+ ` reason=${rs.reason} wall=${rs.wallMs}ms`
|
|
935
|
-
+ ` wait=${rs.waitMs}ms work=${rs.workMs}ms`
|
|
936
|
-
+ (rs.detail ? ` — ${rs.detail}` : ''));
|
|
937
|
-
deps.onChildOutput?.(`${spec.label}: restart (${rs.reason})`);
|
|
938
|
-
},
|
|
939
|
-
onLine: line => {
|
|
940
|
-
// The one 'stream' site in this file: raw research-worker
|
|
941
|
-
// output. Every other logDebug here records a decision.
|
|
942
|
-
// onChildOutput drives the widget and is not gated.
|
|
943
|
-
deps.logDebug?.(`${spec.label}: ${line}`, 'stream');
|
|
944
|
-
deps.onChildOutput?.(`${spec.label}: ${line}`);
|
|
945
|
-
}
|
|
946
|
-
}));
|
|
947
|
-
let r = await runOnce();
|
|
948
|
-
// EMPTY-SECTION GATE (issue #10). A worker that returns zero bytes on a clean run
|
|
949
|
-
// used to fail the whole task ("Research APIS worker produced no output"), which is
|
|
950
|
-
// exactly what an extremely simple task provokes: with nothing on disk to survey and
|
|
951
|
-
// no external symbol in play, silence is the correct answer and the run died on it.
|
|
952
|
-
// Retry ONCE — silence is genuinely ambiguous, and a worker that crashed before
|
|
953
|
-
// writing deserves a second attempt — then accept an explicitly empty section. A
|
|
954
|
-
// provider error behind the silence is classified fatal below and never reaches here.
|
|
955
|
-
let confirmedEmpty = false;
|
|
956
|
-
if (classifyResearchWorker(spec.section, r)?.kind === 'empty') {
|
|
957
|
-
deps.logDebug?.(`${spec.label}: EMPTY answer on a clean exit — retrying once before`
|
|
958
|
-
+ ' accepting the section as having no entries');
|
|
959
|
-
deps.onChildOutput?.(`${spec.label}: empty — retrying`);
|
|
960
|
-
const retry = await runOnce(EMPTY_SECTION_PREAMBLE);
|
|
961
|
-
if (retry.text.trim().length > 0) {
|
|
962
|
-
deps.logDebug?.(`${spec.label}: retry answered (len=${retry.text.trim().length})`
|
|
963
|
-
+ ' — replacing the empty section');
|
|
964
|
-
r = retry;
|
|
965
|
-
}
|
|
966
|
-
else {
|
|
967
|
-
confirmedEmpty = classifyResearchWorker(spec.section, retry)?.kind === 'empty';
|
|
968
|
-
deps.logDebug?.(`${spec.label}: retry STILL empty — `
|
|
969
|
-
+ (confirmedEmpty ?
|
|
970
|
-
'the worker ran twice and reported no entries; recording the'
|
|
971
|
-
+ ' section as empty (NOT a failure)'
|
|
972
|
-
: 'and this attempt did not run cleanly — failing the phase'));
|
|
973
|
-
if (!confirmedEmpty)
|
|
974
|
-
r = retry;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
// ZERO-RETRIEVAL GATE — a deterministic handle, not another instruction. A non-empty
|
|
978
|
-
// section produced with no grounding-retrieval call was written from memory; retry ONCE
|
|
979
|
-
// with a forced retrieval-first pass and keep the retry only if it actually retrieved.
|
|
980
|
-
if (spec.zeroRetrievalRetry
|
|
981
|
-
&& r.groundingRetrievalCount === 0
|
|
982
|
-
&& r.text.trim().length > 0) {
|
|
983
|
-
deps.logDebug?.(`${spec.label}: ZERO grounding-retrieval on a non-empty section`
|
|
984
|
-
+ ' — every symbol is unverified memory; re-running once with a forced'
|
|
985
|
-
+ ' retrieval-first pass');
|
|
986
|
-
deps.onChildOutput?.(`${spec.label}: zero-retrieval — retrying with forced retrieval`);
|
|
987
|
-
const retry = await runOnce(spec.zeroRetrievalRetry);
|
|
988
|
-
if (retry.groundingRetrievalCount > 0 && retry.text.trim().length > 0) {
|
|
989
|
-
deps.logDebug?.(`${spec.label}: retry grounded (${retry.groundingRetrievalCount} retrieval`
|
|
990
|
-
+ ' calls) — replacing the memory-written section');
|
|
991
|
-
r = retry;
|
|
992
|
-
}
|
|
993
|
-
else {
|
|
994
|
-
deps.logDebug?.(`${spec.label}: retry STILL zero-retrieval`
|
|
995
|
-
+ ` (calls=${retry.groundingRetrievalCount}, len=${retry.text.trim().length})`
|
|
996
|
-
+ ' — keeping the original (no regression, entry count preserved)');
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
// SILENT-RETRY GATE — a deterministic handle over the section body, not another
|
|
1000
|
-
// instruction. A section that parses to ZERO bullets from a loop-degrade banner or a
|
|
1001
|
-
// hallucinated non-bullet fragment (classifyContextSilence → genuineLoss) dropped
|
|
1002
|
-
// context that was there to surface; retry ONCE with a forced-emit preamble and keep
|
|
1003
|
-
// the retry only if it produces bullets. A legitimately-empty section (an honest
|
|
1004
|
-
// "nothing to surface") and a fatal failure are BOTH left alone — the former is not a
|
|
1005
|
-
// loss, the latter throws below and must stay a loud failure, not a silent retry.
|
|
1006
|
-
const silentBodyOf = (res) => {
|
|
1007
|
-
const f = classifyResearchWorker(spec.section, res);
|
|
1008
|
-
if (f?.kind === 'fatal')
|
|
1009
|
-
return null;
|
|
1010
|
-
return f?.kind === 'runaway' ?
|
|
1011
|
-
degradedSectionBody(spec.section, f.reason, res.text)
|
|
1012
|
-
: res.text.trim();
|
|
1013
|
-
};
|
|
1014
|
-
// `confirmedEmpty` already spent a retry on exactly this ("you wrote nothing"), and
|
|
1015
|
-
// the worker answered "nothing applies" a second time — re-asking here would just
|
|
1016
|
-
// burn a third child for the same answer.
|
|
1017
|
-
if (spec.retryIfSilent && !confirmedEmpty) {
|
|
1018
|
-
const body = silentBodyOf(r);
|
|
1019
|
-
const verdict = body === null ? null : classifyContextSilence(body);
|
|
1020
|
-
if (verdict?.silent && verdict.genuineLoss) {
|
|
1021
|
-
deps.logDebug?.(`${spec.label}: silent-retry first-silent cause=${verdict.cause}`
|
|
1022
|
-
+ ` — zero bullets, re-running once with a forced-emit preamble`);
|
|
1023
|
-
deps.onChildOutput?.(`${spec.label}: silent — retrying`);
|
|
1024
|
-
const retry = await runOnce(spec.retryIfSilent);
|
|
1025
|
-
const retryBody = silentBodyOf(retry);
|
|
1026
|
-
const retryBullets = retryBody === null ? 0 : countBullets(retryBody);
|
|
1027
|
-
if (retryBullets > 0) {
|
|
1028
|
-
deps.logDebug?.(`${spec.label}: silent-retry recovered bullets=${retryBullets}`
|
|
1029
|
-
+ ' — replacing the silent section');
|
|
1030
|
-
r = retry;
|
|
1031
|
-
}
|
|
1032
|
-
else {
|
|
1033
|
-
deps.logDebug?.(`${spec.label}: silent-retry still-silent`
|
|
1034
|
-
+ ` (bullets=${retryBullets}) — keeping the original`);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
deps.logDebug?.(`${spec.label}: done exit=${r.exitCode} wait=${r.waitMs}ms work=${r.workMs}ms`
|
|
1039
|
-
// attempts/total are the pair that makes wait+work honest: they are
|
|
1040
|
-
// the FINAL attempt's split, and only `total` sees the discarded ones.
|
|
1041
|
-
+ ` attempts=${r.attempts} total=${r.totalWallMs}ms`
|
|
1042
|
-
+ (r.restarts.length > 0 ?
|
|
1043
|
-
` restarts=[${r.restarts.map(x => x.reason).join(',')}]`
|
|
1044
|
-
: '')
|
|
1045
|
-
// Attribution for the RESCUE arm: a run with zero restarts was
|
|
1046
|
-
// never killed (the progress deadline did it), while a run that
|
|
1047
|
-
// restarted and salvaged was killed but kept its work. Without
|
|
1048
|
-
// this the two are indistinguishable in the logs, and "0
|
|
1049
|
-
// timeouts" cannot be traced to the half that earned it.
|
|
1050
|
-
+ (r.salvagedFromDiscardedAttempt ? ' salvaged=1' : '')
|
|
1051
|
-
+ (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
|
|
1052
|
-
+ (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
|
|
1053
|
-
updateProgress();
|
|
1054
|
-
const failure = classifyResearchWorker(spec.section, r);
|
|
1055
|
-
if (failure?.kind === 'fatal')
|
|
1056
|
-
throw failure.error;
|
|
1057
|
-
// A worker that answers "nothing applies" is recorded the same way whether it
|
|
1058
|
-
// said so with zero bytes (confirmedEmpty) or with a bare "(none)"/"N/A" — the
|
|
1059
|
-
// two are the same answer, and only the marker makes either one distinguishable
|
|
1060
|
-
// from a worker that never answered at all.
|
|
1061
|
-
const rawText = failure?.kind === 'runaway' ? degradedSectionBody(spec.section, failure.reason, r.text)
|
|
1062
|
-
: confirmedEmpty || isBareNoneAnswer(r.text) ? emptySectionBody(spec.section)
|
|
1063
|
-
: r.text.trim();
|
|
1064
|
-
if (failure?.kind === 'runaway') {
|
|
1065
|
-
deps.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
|
|
1066
|
-
}
|
|
1067
|
-
if (!confirmedEmpty && isBareNoneAnswer(r.text)) {
|
|
1068
|
-
deps.logDebug?.(`${spec.label}: answered "${r.text.trim().slice(0, 40)}" — recording it as`
|
|
1069
|
-
+ ' an empty section (the worker ran and found nothing)');
|
|
1070
|
-
}
|
|
1071
|
-
// Post-check the worker's own output before it is persisted, so the cache a
|
|
1072
|
-
// resume reads back is already gated. A degraded partial goes through it too —
|
|
1073
|
-
// a truncated section can still carry a laundered claim.
|
|
1074
|
-
const sectionText = spec.postProcess ? spec.postProcess(rawText) : rawText;
|
|
1075
|
-
await persistSection(cacheHeading, sectionText);
|
|
1076
|
-
return { name: spec.section, text: sectionText };
|
|
1077
|
-
};
|
|
677
|
+
/**
|
|
678
|
+
* This phase's binding of the research-worker driver: everything about THIS
|
|
679
|
+
* RUN, gathered once, so each of the four rows is plain data.
|
|
680
|
+
*/
|
|
681
|
+
const drive = (spec, prior) => runResearchWorker(spec, {
|
|
682
|
+
runWorker: runWorkerFn,
|
|
683
|
+
cwd: deps.cwd,
|
|
684
|
+
taskId: deps.taskId,
|
|
685
|
+
signal: deps.signal,
|
|
686
|
+
spawn: deps.spawn,
|
|
687
|
+
// ONE CELL PER WORKER since 2026-08-28. The four workers used to
|
|
688
|
+
// share the `research` cell on the grounds that they are the same
|
|
689
|
+
// job over four questions; the run logs disagree. The cells DO NOT
|
|
690
|
+
// ship identical — the evidence is on each of them in reasoning.ts.
|
|
691
|
+
thinkingFor: thinkingForChild,
|
|
692
|
+
logDebug: deps.logDebug,
|
|
693
|
+
onChildOutput: deps.onChildOutput,
|
|
694
|
+
record: recordWorker,
|
|
695
|
+
onDone: updateProgress,
|
|
696
|
+
readCached: async (heading) => (await readSection(deps.cwd, deps.taskId, heading)) ?? '',
|
|
697
|
+
persistSection,
|
|
698
|
+
carryForward,
|
|
699
|
+
fanoutTimeout,
|
|
700
|
+
progressCeilingMs
|
|
701
|
+
}, prior);
|
|
1078
702
|
const sections = [];
|
|
1079
703
|
if (!getConfig().parallelResearchWorkers) {
|
|
1080
704
|
// Default: ONE AT A TIME (see the A/B note above the specs) — a fatal
|
|
1081
705
|
// failure throws before later workers run, and each worker can see the
|
|
1082
706
|
// finished sections before it (APIS builds on the FILES map).
|
|
1083
707
|
for (const spec of workerSpecs) {
|
|
1084
|
-
sections.push(await
|
|
708
|
+
sections.push(await drive(spec, sections));
|
|
1085
709
|
}
|
|
1086
710
|
}
|
|
1087
711
|
else {
|
|
@@ -1091,7 +715,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
1091
715
|
// before the failure is thrown. Assembly order stays the spec order
|
|
1092
716
|
// regardless of completion order. No prior sections exist here, so
|
|
1093
717
|
// prompt builders get none (APIS runs map-less, as before this option).
|
|
1094
|
-
const settled = await Promise.allSettled(workerSpecs.map(spec =>
|
|
718
|
+
const settled = await Promise.allSettled(workerSpecs.map(spec => drive(spec, [])));
|
|
1095
719
|
for (const s of settled) {
|
|
1096
720
|
if (s.status === 'rejected')
|
|
1097
721
|
throw s.reason;
|
|
@@ -1283,60 +907,31 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
1283
907
|
transcript.add('auto', plainQ, stripInlineMarkdown(auto.text));
|
|
1284
908
|
}
|
|
1285
909
|
else {
|
|
1286
|
-
const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
|
|
1287
|
-
const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
|
|
1288
910
|
// YOLO: take the recommended option and never build the prompt (which
|
|
1289
911
|
// is also what suppresses its notification — see yolo.ts). An answer the
|
|
1290
912
|
// anti-synthesis guard demoted, or a question with no recommendation at
|
|
1291
913
|
// all, is SKIPPED instead: costing the spec one unanswered fork is the
|
|
1292
914
|
// guard direction, promoting a hallucination is not.
|
|
1293
|
-
|
|
1294
|
-
if (yolo !== null) {
|
|
1295
|
-
// The YOLO stamp is a RECORD fact. It used to be pushed into the
|
|
1296
|
-
// generator feedback too, against the rule stated a dozen lines
|
|
1297
|
-
// below — the policy now decides, so the two cannot disagree.
|
|
1298
|
-
if (yolo.kind === 'answer') {
|
|
1299
|
-
transcript.add('yolo', plainQ, stripInlineMarkdown(yolo.answer));
|
|
1300
|
-
}
|
|
1301
|
-
else {
|
|
1302
|
-
transcript.add('yolo-skip', plainQ, `(skipped — ${yolo.note})`);
|
|
1303
|
-
}
|
|
1304
|
-
continue;
|
|
1305
|
-
}
|
|
1306
|
-
// The picker cards and the reply mapping are shared with /task-auto's
|
|
1307
|
-
// clarify loop and the plan session (question-dialog.ts) — all three
|
|
1308
|
-
// used to write them out, and had drifted.
|
|
1309
|
-
const pending = {
|
|
1310
|
-
plain: plainQ,
|
|
1311
|
-
shown: shownQ,
|
|
1312
|
-
...(plainSuggested !== undefined && {
|
|
1313
|
-
suggested: plainSuggested,
|
|
1314
|
-
shownSuggested: renderInlineMarkdown(auto.suggested, theme)
|
|
1315
|
-
}),
|
|
1316
|
-
...(plainAlt !== undefined && {
|
|
1317
|
-
alt: plainAlt,
|
|
1318
|
-
shownAlt: renderInlineMarkdown(auto.alt, theme)
|
|
1319
|
-
})
|
|
1320
|
-
};
|
|
1321
|
-
const options = buildOptionCards(pending);
|
|
1322
|
-
widgetState.lastLine = `awaiting Q${n + 1}`;
|
|
1323
|
-
const a = await ui.ask({
|
|
1324
|
-
localTitle: shownQ,
|
|
1325
|
-
displayQuestion: shownQ,
|
|
1326
|
-
question: plainQ,
|
|
1327
|
-
recommended: plainSuggested,
|
|
1328
|
-
recommended2: plainAlt,
|
|
1329
|
-
allowSkip: plainSuggested === undefined && plainAlt === undefined,
|
|
1330
|
-
...(options && { options })
|
|
1331
|
-
});
|
|
1332
|
-
if (a === undefined)
|
|
1333
|
-
throw new Error(USER_CANCELLED);
|
|
915
|
+
//
|
|
1334
916
|
// Grill's generator sees NO provenance — its feedback is fed verbatim
|
|
1335
917
|
// into the next grill-gen prompt, where a suffix would describe how the
|
|
1336
918
|
// answer was obtained rather than what it was. That is
|
|
1337
919
|
// `GRILL_QA_POLICY.generatorSeesProvenance: false`, stated once.
|
|
1338
|
-
const
|
|
1339
|
-
|
|
920
|
+
const outcome = await settleQuestion({
|
|
921
|
+
ui,
|
|
922
|
+
transcript,
|
|
923
|
+
plain: plainQ,
|
|
924
|
+
shown: shownQ,
|
|
925
|
+
...(auto.suggested !== undefined && { suggested: auto.suggested }),
|
|
926
|
+
...(auto.alt !== undefined && { alt: auto.alt }),
|
|
927
|
+
render: md => renderInlineMarkdown(md, theme),
|
|
928
|
+
yolo: yoloPickAutoAnswer(isYoloMode(), auto),
|
|
929
|
+
onAsk: () => {
|
|
930
|
+
widgetState.lastLine = `awaiting Q${n + 1}`;
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
if (outcome === 'cancelled')
|
|
934
|
+
throw new Error(USER_CANCELLED);
|
|
1340
935
|
}
|
|
1341
936
|
}
|
|
1342
937
|
if (transcript.length === 0)
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* actions. Those genuinely differ.
|
|
30
30
|
*/
|
|
31
31
|
import type { AnswerSource } from './plan-io.js';
|
|
32
|
+
import type { YoloPick } from './yolo.js';
|
|
32
33
|
/** One question awaiting an answer, in both the stored and the displayed form. */
|
|
33
34
|
export interface PendingQuestion {
|
|
34
35
|
/** Plain text — persisted, and fed back to the model. */
|
|
@@ -69,3 +70,58 @@ export declare function resolveAnswer(p: PendingQuestion, raw: string): {
|
|
|
69
70
|
answer: string;
|
|
70
71
|
source: AnswerSource;
|
|
71
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Everything one adaptive dialog needs to settle ONE question.
|
|
75
|
+
*
|
|
76
|
+
* `ui.ask` is typed structurally rather than as `SessionUI` so this module stays
|
|
77
|
+
* out of the remote bridge's import graph — the only thing it needs is the ask.
|
|
78
|
+
*/
|
|
79
|
+
export interface SettleQuestionInput {
|
|
80
|
+
ui: {
|
|
81
|
+
ask: (spec: {
|
|
82
|
+
localTitle: string;
|
|
83
|
+
displayQuestion: string;
|
|
84
|
+
question: string;
|
|
85
|
+
recommended?: string;
|
|
86
|
+
recommended2?: string;
|
|
87
|
+
allowSkip: boolean;
|
|
88
|
+
options?: Array<{
|
|
89
|
+
label: string;
|
|
90
|
+
value: string;
|
|
91
|
+
}>;
|
|
92
|
+
}) => Promise<string | undefined>;
|
|
93
|
+
};
|
|
94
|
+
/** Where the settled answer is recorded. */
|
|
95
|
+
transcript: {
|
|
96
|
+
add: (kind: SettledKind, question: string, answer: string) => void;
|
|
97
|
+
};
|
|
98
|
+
/** The question, plain (persisted / fed back) and rendered (displayed). */
|
|
99
|
+
plain: string;
|
|
100
|
+
shown: string;
|
|
101
|
+
/** The recommendation and the alternative, as the model wrote them (markdown). */
|
|
102
|
+
suggested?: string;
|
|
103
|
+
alt?: string;
|
|
104
|
+
/** Render inline markdown for display. */
|
|
105
|
+
render: (md: string) => string;
|
|
106
|
+
/**
|
|
107
|
+
* This site's already-decided YOLO outcome. A PARAMETER, not a hook: yolo.ts
|
|
108
|
+
* states why the policy is per-site (grill has an anti-synthesis channel to
|
|
109
|
+
* step aside from, clarify runs before research and has none), and settling a
|
|
110
|
+
* question must not become the place that decides one.
|
|
111
|
+
*/
|
|
112
|
+
yolo: YoloPick;
|
|
113
|
+
/** Run just before the ask — grill's "awaiting Qn" widget line. */
|
|
114
|
+
onAsk?: () => void;
|
|
115
|
+
}
|
|
116
|
+
/** The provenance kinds settling one question can produce. */
|
|
117
|
+
export type SettledKind = 'yolo' | 'yolo-skip' | 'accepted' | 'typed';
|
|
118
|
+
/**
|
|
119
|
+
* Settle one question: YOLO short-circuit, cards, ask, record.
|
|
120
|
+
*
|
|
121
|
+
* Returns `'cancelled'` rather than throwing, because that is the one thing the
|
|
122
|
+
* two callers genuinely disagree about — grill throws `USER_CANCELLED` into the
|
|
123
|
+
* phase ladder, clarify announces and returns null from the plan. Everything
|
|
124
|
+
* before it was written out twice at ~50 lines each and had already drifted on
|
|
125
|
+
* `recommended2`, harmless today only because the bridge re-guards it.
|
|
126
|
+
*/
|
|
127
|
+
export declare function settleQuestion(input: SettleQuestionInput): Promise<'settled' | 'cancelled'>;
|