dorfl 0.11.2 → 0.11.3

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/src/tasking.ts CHANGED
@@ -44,7 +44,9 @@ import {
44
44
  runTaskReviewLoop,
45
45
  type TaskReviewGate,
46
46
  type RunTaskReviewLoopResult,
47
+ REVIEW_EDITS_SCRATCH_DIR,
47
48
  } from './tasker-review-loop.js';
49
+ import {ReviewParseError, ReviewOutputCappedError} from './review-verdict.js';
48
50
  import type {ReviewGate} from './review-gate.js';
49
51
 
50
52
  /**
@@ -551,22 +553,78 @@ export async function performTask(
551
553
  // AGENT path runs the loop — the human tasking path is unaffected.
552
554
  let loopDisposition: RunTaskReviewLoopResult | undefined;
553
555
  if (options.reviewLoop && doer === 'agent') {
554
- loopDisposition = await runTaskReviewLoop({
555
- slug,
556
- cwd,
557
- gate: options.reviewLoop,
558
- // SCOPING FENCE (the requeue fix): the loop reviews/edits/flags ONLY the
559
- // tasks THIS run produced (new-or-changed vs `before`), never the
560
- // pre-existing staged tasks that share `work/tasks/backlog/`.
561
- before,
562
- taskerLoopMax: options.taskerLoopMax ?? 3,
563
- executions: options.reviewExecutions,
564
- taskerLoopModel: options.taskerLoopModel,
565
- sessionsDir: options.sessionsDir,
566
- // The improver loop's review AGENT launches AMBIENT, never the identity.
567
- env: agentEnv,
568
- note,
569
- });
556
+ // REVIEW-LEG FAILURE handling (observation
557
+ // `tasker-review-edits-payload-caps-the-verdict-response`): the tasker produced
558
+ // a good candidate set but the review/improver leg can fail to return a
559
+ // parseable verdict (a generic parse failure, or the NAMED cap-truncation
560
+ // class). The pre-fix behaviour THREW here, crashing the run, LEAVING THE
561
+ // LOCK HELD, writing NO question sidecar, and DISCARDING the tasker's
562
+ // candidate tasks. Now: catch it, PERSIST the candidate tasks, and BOUNCE
563
+ // through the SAME surface the decomposition-unclear path uses (release the
564
+ // lock + a question sidecar). NEVER a silent approve — a parse failure is
565
+ // always a needs-attention route. A non-review throw (genuine wiring error)
566
+ // is re-thrown.
567
+ try {
568
+ loopDisposition = await runTaskReviewLoop({
569
+ slug,
570
+ cwd,
571
+ gate: options.reviewLoop,
572
+ // SCOPING FENCE (the requeue fix): the loop reviews/edits/flags ONLY the
573
+ // tasks THIS run produced (new-or-changed vs `before`), never the
574
+ // pre-existing staged tasks that share `work/tasks/backlog/`.
575
+ before,
576
+ taskerLoopMax: options.taskerLoopMax ?? 3,
577
+ executions: options.reviewExecutions,
578
+ taskerLoopModel: options.taskerLoopModel,
579
+ sessionsDir: options.sessionsDir,
580
+ // The improver loop's review AGENT launches AMBIENT, never the identity.
581
+ env: agentEnv,
582
+ note,
583
+ });
584
+ } catch (err) {
585
+ if (!(err instanceof ReviewParseError)) {
586
+ throw err;
587
+ }
588
+ const reviewErr = err as ReviewParseError;
589
+ // Reap any review-edits SCRATCH the failed pass left on disk so it is never
590
+ // swept into a later run's integrate commit.
591
+ try {
592
+ rmSync(join(cwd, REVIEW_EDITS_SCRATCH_DIR), {
593
+ recursive: true,
594
+ force: true,
595
+ });
596
+ } catch {
597
+ // Best-effort.
598
+ }
599
+ const candidatePaths = await persistTaskingCandidates(
600
+ cwd,
601
+ slug,
602
+ before,
603
+ arbiter,
604
+ env,
605
+ note,
606
+ );
607
+ const reason = reviewFailureReason(slug, reviewErr, candidatePaths);
608
+ const message = reviewFailureMessage(slug, reviewErr);
609
+ if (useLock) {
610
+ return await surfaceTaskingBlock({
611
+ slug,
612
+ cwd,
613
+ arbiter,
614
+ reason,
615
+ message,
616
+ lockedBlob,
617
+ release: lock.release,
618
+ mode: resolvedMode,
619
+ provider: options.providerInstance,
620
+ env,
621
+ note,
622
+ });
623
+ }
624
+ note(reason);
625
+ // Human, no-lock path: a clean park-for-human is a success terminal (exit 0).
626
+ return {exitCode: 0, outcome: 'needs-attention', slug, message};
627
+ }
570
628
  // DECOMPOSITION UNCLEAR: emit NO guessed tasks — route the held spec to
571
629
  // needs-attention with the questions as the reason. The lock release amends the
572
630
  // `spec:<slug>` unified lock `active → stuck` (the tasking needs-attention surface
@@ -1332,6 +1390,130 @@ function decompositionUnclearReason(slug: string, questions: string[]): string {
1332
1390
  return `${head}\n${body}`;
1333
1391
  }
1334
1392
 
1393
+ /**
1394
+ * The needs-attention REASON for a REVIEW-LEG FAILURE (a parse failure, or the
1395
+ * NAMED cap-truncation class — observation
1396
+ * `tasker-review-edits-payload-caps-the-verdict-response`). The tasker produced a
1397
+ * good candidate set but the review/improver leg could not return a parseable
1398
+ * verdict, so the run is parked (the lock RELEASED, a question sidecar written)
1399
+ * rather than the whole tasking discarded. Names the failure precisely so an
1400
+ * operator does not mis-read a cap-truncation as a model flake and retry blindly.
1401
+ * The candidate tasks the tasker produced are PERSISTED on the work branch (see
1402
+ * {@link persistTaskingCandidates}) so a human can recover them.
1403
+ */
1404
+ function reviewFailureReason(
1405
+ slug: string,
1406
+ err: ReviewParseError,
1407
+ candidatePaths: string[],
1408
+ ): string {
1409
+ const named =
1410
+ err instanceof ReviewOutputCappedError
1411
+ ? `The tasker review leg failed on '${slug}': ${err.message}. This is a ` +
1412
+ "STRUCTURAL cap-truncation (the review edits payload shared the model's " +
1413
+ 'capped output response with the verdict), NOT a model flake — do not ' +
1414
+ 'blindly retry; the run is parked for you to recover the candidate tasks ' +
1415
+ 'and re-task.'
1416
+ : `The tasker review leg failed on '${slug}': the review agent produced no ` +
1417
+ `parseable verdict (${err.message}). The run is parked for you to recover ` +
1418
+ 'the candidate tasks and re-task.';
1419
+ const tasks =
1420
+ candidatePaths.length > 0
1421
+ ? `\n\nThe tasker produced ${candidatePaths.length} candidate task(s) before the ` +
1422
+ 'review leg failed; they are saved on the work branch ' +
1423
+ `'${workBranchRef('spec', slug)}' (commit "chore(tasking): save candidate ` +
1424
+ `tasks") for recovery:\n${candidatePaths.map((p) => `- ${p}`).join('\n')}`
1425
+ : '\n\n(No candidate tasks were on disk when the review leg failed.)';
1426
+ return `${named}${tasks}`;
1427
+ }
1428
+
1429
+ /** The human-readable terminal MESSAGE for a review-leg failure (the result's `message`). */
1430
+ function reviewFailureMessage(slug: string, err: ReviewParseError): string {
1431
+ if (err instanceof ReviewOutputCappedError) {
1432
+ return (
1433
+ `Tasking '${slug}' parked: the review leg hit the model output cap ` +
1434
+ `(${err.outputTokens} tokens) and was truncated before emitting its ` +
1435
+ 'verdict. The candidate tasks were saved on the work branch; the lock was ' +
1436
+ 'released and a question surfaced.'
1437
+ );
1438
+ }
1439
+ return (
1440
+ `Tasking '${slug}' parked: the review leg produced no parseable verdict ` +
1441
+ `(${err.message}). The candidate tasks were saved on the work branch; the ` +
1442
+ 'lock was released and a question surfaced.'
1443
+ );
1444
+ }
1445
+
1446
+ /**
1447
+ * PERSIST the tasker's candidate tasks (priority: do not discard the tasker's work
1448
+ * on the review-failure path). Commits the new-or-changed `work/tasks/backlog/*.md`
1449
+ * (this run's own output vs `before`) to the work branch under a marker commit,
1450
+ * then pushes the branch to the arbiter BEST-EFFORT (a push failure is noted, not
1451
+ * fatal — the local branch commit still survives a worktree teardown and is
1452
+ * recoverable by `git checkout work/spec-<slug>`). Returns the repo-relative
1453
+ * candidate paths persisted (empty when there were none). Never throws — a git
1454
+ * failure is noted and the bounce proceeds (the lock is still released).
1455
+ */
1456
+ async function persistTaskingCandidates(
1457
+ cwd: string,
1458
+ slug: string,
1459
+ before: Map<string, string>,
1460
+ arbiter: string,
1461
+ env: NodeJS.ProcessEnv | undefined,
1462
+ note: (message: string) => void,
1463
+ ): Promise<string[]> {
1464
+ const candidatePaths = newOrChangedStagedTasks(cwd, before);
1465
+ if (candidatePaths.length === 0) {
1466
+ return [];
1467
+ }
1468
+ const branch = workBranchRef('spec', slug);
1469
+ try {
1470
+ await gitHard(['add', '--', ...candidatePaths], cwd, env);
1471
+ // Commit only if something is actually staged (a clean tree → nothing to save).
1472
+ const diffCached = await gitSoft(['diff', '--cached', '--quiet'], cwd, env);
1473
+ if (diffCached.status === 0) {
1474
+ return candidatePaths; // nothing new staged (already committed by a prior step).
1475
+ }
1476
+ await gitHard(
1477
+ [
1478
+ 'commit',
1479
+ '-q',
1480
+ '-m',
1481
+ `chore(tasking): save candidate tasks for '${slug}' (review leg failed; not landed)`,
1482
+ ],
1483
+ cwd,
1484
+ env,
1485
+ );
1486
+ } catch (err) {
1487
+ note(
1488
+ `Could not commit the candidate tasks to the work branch for recovery ` +
1489
+ `(best-effort; the bounce still proceeds): ${
1490
+ err instanceof Error ? err.message : String(err)
1491
+ }`,
1492
+ );
1493
+ return candidatePaths;
1494
+ }
1495
+ // Push the work branch best-effort so the candidate tasks survive a worktree
1496
+ // teardown on an isolated run (the branch ref survives locally too, but a push
1497
+ // makes them remote-recoverable). A push failure is noted, never fatal.
1498
+ try {
1499
+ const pushed = await gitSoft(['push', arbiter, branch], cwd, env);
1500
+ if (pushed.status !== 0) {
1501
+ note(
1502
+ `Could not push the candidate-tasks work branch '${branch}' to ${arbiter} ` +
1503
+ `(best-effort; the local commit still persists for recovery).`,
1504
+ );
1505
+ }
1506
+ } catch (err) {
1507
+ note(
1508
+ `Could not push the candidate-tasks work branch '${branch}' to ${arbiter} ` +
1509
+ `(best-effort; the local commit still persists for recovery): ${
1510
+ err instanceof Error ? err.message : String(err)
1511
+ }`,
1512
+ );
1513
+ }
1514
+ return candidatePaths;
1515
+ }
1516
+
1335
1517
  /**
1336
1518
  * Mark a candidate task file `needsAnswers: true` and record its open questions in
1337
1519
  * its body (the loop's uncertain-task routing outcome). The runner writes the
@@ -149,6 +149,10 @@ interface SessionLogRecord {
149
149
  message?: {
150
150
  role?: unknown;
151
151
  content?: unknown;
152
+ /** The Anthropic-API turn-termination reason (mirrored by pi into the session log). */
153
+ stop_reason?: unknown;
154
+ /** The turn's token usage (mirrored by pi). `output` OR `output_tokens` is the produced-token count. */
155
+ usage?: unknown;
152
156
  };
153
157
  }
154
158
 
@@ -286,10 +290,10 @@ function assistantContentText(content: unknown): string {
286
290
  /**
287
291
  * Extract the **last assistant message's text** from a pi session `.jsonl`
288
292
  * (task `harness-agent-output`) — the agent's final ANSWER, surfaced through
289
- * the harness seam as `LaunchResult.output`. It REUSES this module's session-log
290
- * shape walk (one parser, not two): it scans the `{type:"message",
291
- * message:{role:"assistant", content[]}}` records, takes the LAST one carrying
292
- * non-empty `text`, and returns its concatenated text.
293
+ * the harness seam as `LaunchResult.output`. A thin delegate to
294
+ * {@link lastAssistantTurn} (one walk, returning the `.text`); the turn reader
295
+ * ALSO carries the `stop_reason`/`usage` the pi adapter uses for the
296
+ * `LaunchResult.outputCapped` cap-truncation signal.
293
297
  *
294
298
  * Kept as a PURE `string → string | undefined` function on purpose: the pi
295
299
  * adapter reads the session file and passes the JSONL text in, but a future
@@ -307,7 +311,33 @@ function assistantContentText(content: unknown): string {
307
311
  * line in a just-closed log must not crash the reader.
308
312
  */
309
313
  export function lastAssistantText(jsonl: string): string | undefined {
310
- let last: string | undefined;
314
+ return lastAssistantTurn(jsonl).text;
315
+ }
316
+
317
+ /**
318
+ * The last assistant turn's TEXT + its Anthropic-API turn-termination signal
319
+ * (`stop_reason` + `usage.output`/`usage.output_tokens` token count). Reused by
320
+ * the pi adapter to populate BOTH `LaunchResult.output` (the `.text`) AND
321
+ * `LaunchResult.outputCapped` (the cap-truncation signal — see
322
+ * {@link isOutputCappedTurn}). One walk over the `.jsonl`, not two. `text` is the
323
+ * LAST assistant turn carrying non-empty text (a tool-only turn does not
324
+ * supersede it); `stopReason`/`outputTokens` come from THAT same last-text turn when
325
+ * the record carries them, else `undefined`. Returns an all-`undefined` object
326
+ * for an empty / assistant-text-less log.
327
+ */
328
+ export interface LastAssistantTurn {
329
+ /** The last assistant turn's concatenated `text` parts (its answer). */
330
+ text?: string;
331
+ /** The turn's `stop_reason` (raw — `null`, `'end_turn'`, `'max_tokens'`, …). */
332
+ stopReason?: string | null;
333
+ /** The turn's produced output-token count (`usage.output` OR `usage.output_tokens`). */
334
+ outputTokens?: number;
335
+ }
336
+
337
+ export function lastAssistantTurn(jsonl: string): LastAssistantTurn {
338
+ let lastText: string | undefined;
339
+ let lastStopReason: string | null | undefined = undefined;
340
+ let lastOutputTokens: number | undefined = undefined;
311
341
  for (const line of jsonl.split('\n')) {
312
342
  const trimmed = line.trim();
313
343
  if (trimmed === '') {
@@ -332,10 +362,56 @@ export function lastAssistantText(jsonl: string): string | undefined {
332
362
  }
333
363
  const text = assistantContentText(message.content);
334
364
  if (text !== '') {
335
- last = text; // a later text turn supersedes an earlier one.
365
+ lastText = text; // a later text turn supersedes an earlier one.
366
+ lastStopReason = readStopReason(message.stop_reason);
367
+ lastOutputTokens = readOutputTokens(message.usage);
336
368
  }
337
369
  }
338
- return last;
370
+ return {
371
+ text: lastText,
372
+ stopReason: lastStopReason,
373
+ outputTokens: lastOutputTokens,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Is this assistant turn's signal an OUTPUT-CAP truncation — the turn did NOT
379
+ * end naturally? `stop_reason` `null`/`None`/`undefined` (the turn was cut off) OR
380
+ * `'max_tokens'` (the model hit its output-token cap), together with a positive
381
+ * produced-token count. The `null`/`None` form is what pi's session log records
382
+ * when the `--print` run is truncated before the turn closes (observation
383
+ * `tasker-review-edits-payload-caps-the-verdict-response`); `'max_tokens'` is the
384
+ * standard API cap signal. Both name the same structural cause: the verdict never
385
+ * finished.
386
+ */
387
+ export function isOutputCappedTurn(turn: LastAssistantTurn): boolean {
388
+ const cappedReason =
389
+ turn.stopReason === null ||
390
+ turn.stopReason === undefined ||
391
+ turn.stopReason === 'None' ||
392
+ turn.stopReason === 'max_tokens';
393
+ return cappedReason && (turn.outputTokens ?? 0) > 0;
394
+ }
395
+
396
+ /** Read `stop_reason` defensively as a string-or-null (pi may emit `None`/`null`). */
397
+ function readStopReason(raw: unknown): string | null | undefined {
398
+ if (raw === null) {
399
+ return null;
400
+ }
401
+ if (typeof raw === 'string') {
402
+ return raw;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ /** Read the produced output-token count from `usage.output` OR `usage.output_tokens`. */
408
+ function readOutputTokens(raw: unknown): number | undefined {
409
+ if (typeof raw !== 'object' || raw === null) {
410
+ return undefined;
411
+ }
412
+ const usage = raw as Record<string, unknown>;
413
+ const value = usage.output ?? usage.output_tokens;
414
+ return typeof value === 'number' ? value : undefined;
339
415
  }
340
416
 
341
417
  export interface SessionTailerOptions {