@mjasnikovs/pi-task 0.18.24 → 0.18.26

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.
@@ -5,6 +5,7 @@
5
5
  * This module currently holds the planning half (AutoDeps + planAuto). The run
6
6
  * loop, command handlers, and defaultDeps are added by the next task.
7
7
  */
8
+ import { existsSync } from 'node:fs';
8
9
  import * as fsp from 'node:fs/promises';
9
10
  import * as path from 'node:path';
10
11
  import { gateRunTask, markResumable } from './orchestrator.js';
@@ -35,6 +36,7 @@ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, app
35
36
  import { reconcileTitleSources } from './decompose-fidelity.js';
36
37
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
37
38
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
39
+ import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
38
40
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
39
41
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
40
42
  // when the model emits NONE), but a model that never says NONE would otherwise
@@ -493,6 +495,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
493
495
  catch {
494
496
  // best-effort channel
495
497
  }
498
+ // Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
499
+ // files the spec REFERENCES (server snippets, prose "serve the built
500
+ // index.html") that neither its file tree, its parsed build outputs, nor the
501
+ // existing scaffold produce. Sentence-grounded coverage credited the SERVING
502
+ // side and reported "0 unowned" while nothing ever CREATED the file — so
503
+ // these ride the coverage loop's `missing` list as unowned areas until some
504
+ // task title claims the artifact (grounded in titles, which the coverage-map
505
+ // model cannot fake — the run-12 lesson). Deterministic and best-effort.
506
+ let specDangling = [];
507
+ try {
508
+ specDangling = findSpecDanglingArtifacts(featureForModel, rel => existsSync(path.join(cwd, rel)));
509
+ if (specDangling.length > 0) {
510
+ logPlanDebug(cwd, `artifact closure: ${specDangling.length} dangling runtime artifact(s) in the `
511
+ + `spec: ${specDangling.map(d => d.path).join(', ')}`);
512
+ }
513
+ }
514
+ catch {
515
+ // best-effort channel
516
+ }
496
517
  // decompose
497
518
  const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
498
519
  // Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
@@ -589,6 +610,13 @@ export async function planAuto(ctx, cwd, feature, deps) {
589
610
  }
590
611
  }
591
612
  const missing = [...verdictMissing, ...(acc?.unmapped ?? []).map(e => `"${e.quote}"`)];
613
+ // Unclaimed dangling artifacts are unowned areas: they force a coverage
614
+ // round that assigns a producing task, and clear as soon as a title
615
+ // names the file.
616
+ for (const d of specDangling) {
617
+ if (!titlesCoverArtifact(titles, d))
618
+ missing.push(danglingMissingText(d));
619
+ }
592
620
  return {
593
621
  plan: { titles, covered, missing },
594
622
  accounting: acc,
@@ -707,12 +735,23 @@ export async function planAuto(ctx, cwd, feature, deps) {
707
735
  const carriedCrossCutting = accounting?.crossCutting ?? [];
708
736
  const carriedUnmapped = accounting?.unmapped ?? [];
709
737
  const carriedJudge = best.judgeMissing;
710
- if (carriedCrossCutting.length > 0 || carriedUnmapped.length > 0 || carriedJudge.length > 0) {
711
- await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge);
738
+ // Dangling artifacts still unclaimed by any title of the SHIPPING plan are a
739
+ // fourth channel: the producing obligation travels verbatim into every task
740
+ // (whichever task builds the referencing side must also produce the file),
741
+ // and the final gate re-checks the shipped tree regardless.
742
+ const carriedDangling = specDangling
743
+ .filter(d => !titlesCoverArtifact(planTitles, d))
744
+ .map(danglingCarryText);
745
+ if (carriedCrossCutting.length > 0
746
+ || carriedUnmapped.length > 0
747
+ || carriedJudge.length > 0
748
+ || carriedDangling.length > 0) {
749
+ await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge, carriedDangling);
712
750
  const parts = [
713
751
  carriedCrossCutting.length > 0 ? `${carriedCrossCutting.length} cross-cutting` : '',
714
752
  carriedUnmapped.length > 0 ? `${carriedUnmapped.length} unowned` : '',
715
- carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : ''
753
+ carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : '',
754
+ carriedDangling.length > 0 ? `${carriedDangling.length} dangling-artifact` : ''
716
755
  ].filter(p => p.length > 0);
717
756
  ctx.ui.notify(`/task-auto: carrying ${parts.join(', ')} requirement(s) into every task`
718
757
  + ' — see .pi-tasks/requirements.md.', 'info');
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /**
3
+ * Command watchdog — cancels a single tool execution that overruns the
4
+ * configured ceiling and reminds the model to bound its own commands.
5
+ *
6
+ * WHY: a local model in the MAIN session routinely runs a command that never
7
+ * returns — `godot --headless --check-only` with no timeout, a dev server, a
8
+ * hung test — and the run wedges until the user manually aborts and tells the
9
+ * model to add a timeout. pi's bash tool takes an OPTIONAL `timeout` with NO
10
+ * default (see pi-coding-agent tools/bash.js), so any command the model didn't
11
+ * bound runs forever. This supplies the missing default from the host side.
12
+ *
13
+ * HOW: arm a wall-clock timer on `tool_execution_start`, disarm it on
14
+ * `tool_execution_end`. If it elapses, `ctx.abort()` cancels the in-flight
15
+ * operation — which fires the tool's AbortSignal, and pi's bash executor kills
16
+ * the whole process tree on abort — then a follow-up user turn tells the model
17
+ * what happened so it retries with a timeout instead of hanging again.
18
+ *
19
+ * Tool-agnostic: it arms on ANY tool, honouring "any command can run forever",
20
+ * though in practice only bash runs long enough to trip it. The pure timer
21
+ * state lives in {@link CommandWatchdog}; all side effects (abort, reminder,
22
+ * per-call ctx lookup) live in the registration's `onFire`, so the machine is
23
+ * unit-testable without a real pi session.
24
+ */
25
+ /** Opaque timer handle — a real `setTimeout` return in production, anything the
26
+ * test's fake scheduler hands back under test. */
27
+ export type TimerHandle = unknown;
28
+ export interface WatchdogDeps {
29
+ /**
30
+ * The ceiling in ms, read PER command-start so a /task-config change takes
31
+ * effect on the next command with no reload. 0 (or any non-positive value)
32
+ * means the watchdog is off and never arms.
33
+ */
34
+ getTimeoutMs: () => number;
35
+ schedule: (fn: () => void, ms: number) => TimerHandle;
36
+ cancel: (handle: TimerHandle) => void;
37
+ /** Invoked when a command overruns: the registration aborts + reminds here. */
38
+ onFire: (toolCallId: string, toolName: string, timeoutMs: number) => void;
39
+ }
40
+ /**
41
+ * The reminder delivered to the model after its command is cancelled. Kept pure
42
+ * and exported so a test can assert its shape without driving the whole session.
43
+ */
44
+ export declare function reminderMessage(toolName: string, timeoutMs: number): string;
45
+ export declare class CommandWatchdog {
46
+ private readonly deps;
47
+ /** Armed timers, keyed by the tool call they guard. Tool executions are
48
+ * sequential, so this holds at most one entry in normal operation, but the
49
+ * map keeps it correct even if pi ever overlaps two calls. */
50
+ private readonly active;
51
+ constructor(deps: WatchdogDeps);
52
+ /** Arm a timer for a starting tool. No-op when the watchdog is off. */
53
+ onStart(toolCallId: string, toolName: string): void;
54
+ /** Disarm the timer for a finished tool. */
55
+ onEnd(toolCallId: string): void;
56
+ /** Cancel every armed timer — a turn-end / session-shutdown safety net so no
57
+ * stray timer can fire into a later, unrelated command. */
58
+ clearAll(): void;
59
+ private disarm;
60
+ private fire;
61
+ }
62
+ /**
63
+ * Wire the watchdog into the main session. Only ever active in the host session
64
+ * (children run `--no-extensions`), which is exactly where the observed hangs
65
+ * happen.
66
+ */
67
+ export declare function registerCommandWatchdog(pi: ExtensionAPI): void;
@@ -0,0 +1,114 @@
1
+ import { getConfig } from '../config/config.js';
2
+ /**
3
+ * The reminder delivered to the model after its command is cancelled. Kept pure
4
+ * and exported so a test can assert its shape without driving the whole session.
5
+ */
6
+ export function reminderMessage(toolName, timeoutMs) {
7
+ const mins = Math.max(1, Math.round(timeoutMs / 60_000));
8
+ return (`[SYSTEM] Your \`${toolName}\` call ran longer than ${mins} minute`
9
+ + `${mins === 1 ? '' : 's'} and was automatically cancelled — it looked stuck. `
10
+ // Anti-fabrication: a live run showed the model react to the cancel by
11
+ // reporting the killed command as succeeded ("the server is now running").
12
+ // State plainly that it produced nothing so the model can't claim success.
13
+ + `The command was killed before it finished and produced NO result, so do not `
14
+ + `report it as completed or successful, and do not claim that anything it would `
15
+ + `have started (a server, build, or process) is now running. `
16
+ + `If it was a genuinely long-running command, you MUST re-run it with an explicit `
17
+ + `timeout — set the bash tool's \`timeout\` parameter (in seconds) so it cannot hang `
18
+ + `again — or break it into smaller steps. Do NOT simply retry the same unbounded command.`);
19
+ }
20
+ export class CommandWatchdog {
21
+ deps;
22
+ /** Armed timers, keyed by the tool call they guard. Tool executions are
23
+ * sequential, so this holds at most one entry in normal operation, but the
24
+ * map keeps it correct even if pi ever overlaps two calls. */
25
+ active = new Map();
26
+ constructor(deps) {
27
+ this.deps = deps;
28
+ }
29
+ /** Arm a timer for a starting tool. No-op when the watchdog is off. */
30
+ onStart(toolCallId, toolName) {
31
+ const ms = this.deps.getTimeoutMs();
32
+ if (!(ms > 0))
33
+ return;
34
+ // A duplicate start for the same id must not leak the previous timer.
35
+ this.disarm(toolCallId);
36
+ const handle = this.deps.schedule(() => this.fire(toolCallId, toolName, ms), ms);
37
+ this.active.set(toolCallId, handle);
38
+ }
39
+ /** Disarm the timer for a finished tool. */
40
+ onEnd(toolCallId) {
41
+ this.disarm(toolCallId);
42
+ }
43
+ /** Cancel every armed timer — a turn-end / session-shutdown safety net so no
44
+ * stray timer can fire into a later, unrelated command. */
45
+ clearAll() {
46
+ for (const handle of this.active.values())
47
+ this.deps.cancel(handle);
48
+ this.active.clear();
49
+ }
50
+ disarm(toolCallId) {
51
+ const handle = this.active.get(toolCallId);
52
+ if (handle !== undefined) {
53
+ this.deps.cancel(handle);
54
+ this.active.delete(toolCallId);
55
+ }
56
+ }
57
+ fire(toolCallId, toolName, ms) {
58
+ // If the tool ended in the same tick the timer fired, its entry is gone
59
+ // already — never abort a command that has just finished cleanly.
60
+ if (!this.active.has(toolCallId))
61
+ return;
62
+ this.active.delete(toolCallId);
63
+ this.deps.onFire(toolCallId, toolName, ms);
64
+ }
65
+ }
66
+ /**
67
+ * Wire the watchdog into the main session. Only ever active in the host session
68
+ * (children run `--no-extensions`), which is exactly where the observed hangs
69
+ * happen.
70
+ */
71
+ export function registerCommandWatchdog(pi) {
72
+ // The ctx that owns each in-flight tool's AbortSignal, captured per start so
73
+ // the timer callback (which fires outside the event handler) aborts the
74
+ // right operation.
75
+ const ctxByCall = new Map();
76
+ const watchdog = new CommandWatchdog({
77
+ getTimeoutMs: () => getConfig().requestTimeoutMs,
78
+ schedule: (fn, ms) => {
79
+ const handle = setTimeout(fn, ms);
80
+ // Don't let a pending watchdog timer keep the process alive on exit.
81
+ if (typeof handle.unref === 'function') {
82
+ ;
83
+ handle.unref();
84
+ }
85
+ return handle;
86
+ },
87
+ cancel: handle => clearTimeout(handle),
88
+ onFire: (toolCallId, toolName, timeoutMs) => {
89
+ const ctx = ctxByCall.get(toolCallId);
90
+ ctxByCall.delete(toolCallId);
91
+ // Cancel the stuck command (kills the tool's whole process tree via
92
+ // the turn's AbortSignal), then start a fresh turn telling the model
93
+ // to bound its next attempt.
94
+ ctx?.abort();
95
+ pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
96
+ }
97
+ });
98
+ pi.on('tool_execution_start', (event, ctx) => {
99
+ ctxByCall.set(event.toolCallId, ctx);
100
+ watchdog.onStart(event.toolCallId, event.toolName);
101
+ });
102
+ pi.on('tool_execution_end', event => {
103
+ ctxByCall.delete(event.toolCallId);
104
+ watchdog.onEnd(event.toolCallId);
105
+ });
106
+ // Safety net: nothing should outlive its turn, but if a start ever lacks a
107
+ // matching end, clear on turn/session teardown so no timer fires stale.
108
+ const reset = () => {
109
+ watchdog.clearAll();
110
+ ctxByCall.clear();
111
+ };
112
+ pi.on('turn_end', reset);
113
+ pi.on('session_shutdown', reset);
114
+ }
@@ -47,6 +47,7 @@ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts }
47
47
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
48
48
  import { runRenderCheck } from './render-check.js';
49
49
  import { taskThatIntroduced } from './task-provenance.js';
50
+ import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
50
51
  function packageScripts(cwd) {
51
52
  try {
52
53
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -382,6 +383,12 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
382
383
  stdio: ['ignore', 'pipe', 'pipe'],
383
384
  env: { ...process.env }
384
385
  });
386
+ // Best-effort cleanup only: killGroup below can silently fail to reap the
387
+ // process (platform/sandbox-specific — observed on a GH Actions Linux
388
+ // runner where the group-kill did not take, hanging the whole `bun test
389
+ // --isolate` run on the leaked child's piped stdio). unref() so a child
390
+ // we already tried to kill can never itself keep this process alive.
391
+ child.unref();
385
392
  let out = '';
386
393
  let err = '';
387
394
  let listenerSeen = false;
@@ -772,6 +779,21 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
772
779
  warnings.push(b.renderNote);
773
780
  }
774
781
  }
782
+ // Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
783
+ // reference with NO producer anywhere ships silently — the server read
784
+ // `Bun.file('dist/index.html')` while the build emitted only app.css +
785
+ // main.js, so every non-API GET 404'd behind 32/32 green checkoffs.
786
+ // Deterministic scan of the shipped tree (literal refs only, positive
787
+ // producer evidence required — see artifact-closure.ts); each dangle is a
788
+ // ranked failure naming referencer + missing path. Rank 0: "the app cannot
789
+ // serve what it references" is the same load-bearing class as boot/render.
790
+ try {
791
+ for (const d of findDanglingArtifacts(cwd))
792
+ fail(danglingGateFailureText(d), 0);
793
+ }
794
+ catch {
795
+ // best-effort scan — a scanner fault must never break the gate
796
+ }
775
797
  if (failures.length > 0) {
776
798
  // Stable sort: boot/render (rank 0) leads, everything else keeps execution
777
799
  // order. One failure keeps the exact single-failure wording; several become
@@ -88,8 +88,13 @@ export declare function readRequirements(cwd: string): Promise<string>;
88
88
  * was warned-about then dropped). These are plain strings, not quotes of the
89
89
  * source; marked distinctly so a task can tell an inferred area from a verbatim
90
90
  * obligation.
91
+ * • `danglingArtifacts` — runtime files the spec references but nothing
92
+ * produces (mx5 run 13: the served `index.html` no task, tree entry, or
93
+ * build output ever created), still unclaimed by any title at coverage
94
+ * exhaustion. Deterministically extracted (artifact-closure.ts), so like
95
+ * judge areas they are host-authored strings, not source quotes.
91
96
  */
92
- export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[]): Promise<void>;
97
+ export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[], danglingArtifacts?: string[]): Promise<void>;
93
98
  /**
94
99
  * The read-only block refine/compose receive when carried requirements exist.
95
100
  * Verbatim content travels with every task (the directive pattern that works),
@@ -320,10 +320,19 @@ function formatEntry(e, marker) {
320
320
  * was warned-about then dropped). These are plain strings, not quotes of the
321
321
  * source; marked distinctly so a task can tell an inferred area from a verbatim
322
322
  * obligation.
323
+ * • `danglingArtifacts` — runtime files the spec references but nothing
324
+ * produces (mx5 run 13: the served `index.html` no task, tree entry, or
325
+ * build output ever created), still unclaimed by any title at coverage
326
+ * exhaustion. Deterministically extracted (artifact-closure.ts), so like
327
+ * judge areas they are host-authored strings, not source quotes.
323
328
  */
324
- export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = []) {
325
- if (crossCutting.length === 0 && unresolved.length === 0 && judgeFlagged.length === 0)
329
+ export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = [], danglingArtifacts = []) {
330
+ if (crossCutting.length === 0
331
+ && unresolved.length === 0
332
+ && judgeFlagged.length === 0
333
+ && danglingArtifacts.length === 0) {
326
334
  return;
335
+ }
327
336
  try {
328
337
  const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
329
338
  const seen = new Set(existing.map(l => {
@@ -337,6 +346,10 @@ export async function appendCarriedRequirements(cwd, crossCutting, unresolved =
337
346
  [
338
347
  judgeFlagged.map(q => ({ quote: q, anchor: '' })),
339
348
  'judge-flagged uncovered area, no task owns this — surfaced at plan time'
349
+ ],
350
+ [
351
+ danglingArtifacts.map(q => ({ quote: q, anchor: '' })),
352
+ 'dangling runtime artifact, nothing produces it — surfaced at plan time'
340
353
  ]
341
354
  ]) {
342
355
  for (const e of entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.24",
3
+ "version": "0.18.26",
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",