@mjasnikovs/pi-task 0.42.7 → 0.42.9
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.
|
@@ -32,8 +32,15 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
32
32
|
* and shares the same machine from shared/command-watchdog.ts.
|
|
33
33
|
*/
|
|
34
34
|
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WATCHDOG_CANCEL_MARKER, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* @internal Set by onFire when it aborts a turn. Exported for the adapter and tests.
|
|
37
|
+
* Returns the previous value, which an abort that then fails must put back — the
|
|
38
|
+
* flag is shared by both watchdogs, so clearing it unconditionally would swallow a
|
|
39
|
+
* genuine abort's pending flag and leave the steer loop prompting an empty room.
|
|
40
|
+
*/
|
|
41
|
+
export declare function noteWatchdogAbort(): boolean;
|
|
42
|
+
/** @internal Put the flag back after a noted abort did not happen. */
|
|
43
|
+
export declare function restoreWatchdogAbort(was: boolean): void;
|
|
37
44
|
/** True exactly once per watchdog abort; clears the flag. */
|
|
38
45
|
export declare function consumeWatchdogAbort(): boolean;
|
|
39
46
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
2
|
import { SELF_BOUNDED_TOOLS } from '../config/tool-list.js';
|
|
3
3
|
import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/command-watchdog.js';
|
|
4
|
+
import { isStaleCtxError } from './stale-ctx.js';
|
|
4
5
|
/**
|
|
5
6
|
* MAIN-SESSION adapter for the command watchdog.
|
|
6
7
|
*
|
|
@@ -53,9 +54,20 @@ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WA
|
|
|
53
54
|
* prompt.
|
|
54
55
|
*/
|
|
55
56
|
let watchdogAbortPending = false;
|
|
56
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* @internal Set by onFire when it aborts a turn. Exported for the adapter and tests.
|
|
59
|
+
* Returns the previous value, which an abort that then fails must put back — the
|
|
60
|
+
* flag is shared by both watchdogs, so clearing it unconditionally would swallow a
|
|
61
|
+
* genuine abort's pending flag and leave the steer loop prompting an empty room.
|
|
62
|
+
*/
|
|
57
63
|
export function noteWatchdogAbort() {
|
|
64
|
+
const was = watchdogAbortPending;
|
|
58
65
|
watchdogAbortPending = true;
|
|
66
|
+
return was;
|
|
67
|
+
}
|
|
68
|
+
/** @internal Put the flag back after a noted abort did not happen. */
|
|
69
|
+
export function restoreWatchdogAbort(was) {
|
|
70
|
+
watchdogAbortPending = was;
|
|
59
71
|
}
|
|
60
72
|
/** True exactly once per watchdog abort; clears the flag. */
|
|
61
73
|
export function consumeWatchdogAbort() {
|
|
@@ -86,10 +98,28 @@ export function registerCommandWatchdog(pi) {
|
|
|
86
98
|
// to bound its next attempt. The flag must precede the abort so the
|
|
87
99
|
// steer loop can never observe the 'aborted' turn before the flag.
|
|
88
100
|
if (ctx) {
|
|
89
|
-
noteWatchdogAbort();
|
|
90
|
-
|
|
101
|
+
const wasPending = noteWatchdogAbort();
|
|
102
|
+
try {
|
|
103
|
+
ctx.abort();
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
restoreWatchdogAbort(wasPending); // no turn was aborted
|
|
107
|
+
// Timer fired after session replacement/reload: the captured ctx
|
|
108
|
+
// is stale by design (Pi invalidates it in AgentSession.dispose).
|
|
109
|
+
// Swallow only that guard; anything else keeps throwing, and no
|
|
110
|
+
// follow-up is posted into the replacement session.
|
|
111
|
+
if (!isStaleCtxError(err))
|
|
112
|
+
throw err;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (!isStaleCtxError(err))
|
|
121
|
+
throw err;
|
|
91
122
|
}
|
|
92
|
-
pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
|
|
93
123
|
}
|
|
94
124
|
});
|
|
95
125
|
pi.on('tool_execution_start', (event, ctx) => {
|
|
@@ -21,22 +21,37 @@
|
|
|
21
21
|
const TEST_NOUN = /\b(?:tests?|suites?|assertions?)\b/i;
|
|
22
22
|
const BUILD_NOUN = /\b(?:lint|linter|typecheck|build|ci)\b/i;
|
|
23
23
|
const FAILURE = /\b(?:fail\w*|red|broken|breaks?|breakage|errors?)\b/i;
|
|
24
|
-
/** Before a phrase in its clause: the phrase is rejected.
|
|
25
|
-
|
|
24
|
+
/** Before a phrase in its clause: the phrase is rejected. Models emit U+2019 as
|
|
25
|
+
* readily as ASCII, so a contraction must not turn the verdict on the character. */
|
|
26
|
+
const NOT_A_DECISION = /\b(?:not|never|no|do(?:es)?n['’]t|do(?:es)? not|won['’]t|will not|wouldn['’]t|would not|rather than|instead of|isn['’]t|is not|without|avoid|avoiding)\b/i;
|
|
26
27
|
/**
|
|
27
|
-
* A modal cancels a phrase only where the sentence
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* A modal cancels a phrase only where the sentence is weighing something: "IF NOT
|
|
29
|
+
* EXISTS would still leave the test failing" describes what a rejected option
|
|
30
|
+
* does. A bare hedge does not — "I would flag it as a known issue" is the
|
|
31
|
+
* decision, and treating every modal as hypothetical let the guard be rephrased
|
|
32
|
+
* away.
|
|
32
33
|
*/
|
|
33
34
|
const MODAL = /\b(?:would|could|might)\b/i;
|
|
34
|
-
/** A condition governs
|
|
35
|
+
/** A condition governs the clauses it opens and stops at the conclusion drawn. */
|
|
35
36
|
const CONDITION = /\b(?:if|unless)\b/i;
|
|
36
|
-
/** Options are posed once and weighed in
|
|
37
|
-
const OPTION = /\b(?:either|whether|
|
|
38
|
-
/**
|
|
37
|
+
/** Options are posed once and weighed anywhere in the sentence. */
|
|
38
|
+
const OPTION = /\b(?:either|whether|options?|alternatives?|otherwise)\b/i;
|
|
39
|
+
/** Under a condition the subject says whose modal it is: "the latter would leave
|
|
40
|
+
* it red" is the option behaving, "I would leave it red" is the answer deciding. */
|
|
39
41
|
const FIRST_PERSON = /\b(?:i|we)\s+(?:would|could|might)\b/i;
|
|
42
|
+
/**
|
|
43
|
+
* The repair the sentence goes on to choose. An option's consequence is a
|
|
44
|
+
* hypothesis only where the sentence chooses against it; a deferral that nothing
|
|
45
|
+
* overrules is the decision, however the sentence hedges it. Checking for the
|
|
46
|
+
* choice rather than for a pronoun is what stops "it could be left failing for
|
|
47
|
+
* whoever owns it" from riding out on one option word.
|
|
48
|
+
*/
|
|
49
|
+
const CHOOSES_REPAIR = /\b(?:i|we)(?:\s+will|\s+shall|['’]ll)\s+(?:update|fix|change|add|adjust|amend|correct)\b|\bso\s+(?:update|fix|change|adjust|amend|correct)\b/i;
|
|
50
|
+
/** Where a hypothesis's reach ends: a semicolon starts an independent clause, and
|
|
51
|
+
* "so" introduces the conclusion drawn, not another branch of the hypothesis.
|
|
52
|
+
* Neither is a comma, which is why scoping a condition by punctuation alone let
|
|
53
|
+
* "fails only if X, so it could be flagged as known" through. */
|
|
54
|
+
const HYPOTHESIS_END = /;|\bso\b|\btherefore\b|\bhence\b|\bthus\b/i;
|
|
40
55
|
/** Where one clause ends and the next begins. A semicolon joins clauses of ONE
|
|
41
56
|
* thought, so the breakage a clause defers may sit in the other half. */
|
|
42
57
|
const CLAUSE_BOUNDARY = /[,:;()]|\s[—–-]\s|\b(?:and|but|so|then|while|whereas|although|though|because|since|however)\b/gi;
|
|
@@ -59,11 +74,15 @@ const PHRASES = [
|
|
|
59
74
|
// Handing the work to an unnamed someone is the deferral itself, whatever the
|
|
60
75
|
// clause is about; bare `whoever` below still needs a check to be one.
|
|
61
76
|
{ re: /\bwhoever\s+(?:owns|revisits|maintains|touches)\b/i, needs: 'alone' },
|
|
62
|
-
// Ownership handed to a ROLE is handed to nobody: no release manager
|
|
63
|
-
// a /task-auto run.
|
|
64
|
-
// the
|
|
77
|
+
// Ownership handed to a ROLE is handed to nobody: no release manager, QA or
|
|
78
|
+
// on-call engineer sits in a /task-auto run. Anyone else the answer names — a
|
|
79
|
+
// person, a team, the runner, this task — is ownership TAKEN, so the phrase
|
|
80
|
+
// alone is not the deferral. The list names the nobodies rather than exempting
|
|
81
|
+
// the somebodies, because the two misses cost differently: a role it does not
|
|
82
|
+
// know still reaches the `check` rule below, while a somebody it mistakes for
|
|
83
|
+
// nobody destroys the answer this guard exists to protect.
|
|
65
84
|
{
|
|
66
|
-
re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b(
|
|
85
|
+
re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\s+(?:whoever|someone|somebody|anyone|another\b|a\s+(?:later|future|separate)\b|(?:the\s+)?(?:release\s+manager|on[- ]call|qa|sre)\b|the\s+(?:\w+\s+)?(?:owners?|maintainers?)\b)/i,
|
|
67
86
|
needs: 'alone'
|
|
68
87
|
},
|
|
69
88
|
{ re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'check' },
|
|
@@ -89,10 +108,12 @@ function aboutACheck(text) {
|
|
|
89
108
|
/**
|
|
90
109
|
* Parenthetical asides go, and a code span keeps its words but loses the
|
|
91
110
|
* punctuation that would split a clause in two: `toEqual([{filename: X}])` is
|
|
92
|
-
* one token of the sentence around it, not three clauses.
|
|
111
|
+
* one token of the sentence around it, not three clauses. Brackets close up
|
|
112
|
+
* instead of spacing, because a bracket binds to what it encloses — spacing
|
|
113
|
+
* `arr[i]` leaves a bare `i` for the first-person rule to read as a pronoun.
|
|
93
114
|
*/
|
|
94
115
|
function prose(answer) {
|
|
95
|
-
let text = answer.replace(/`([^`]*)`/g, (_m, code) => code.replace(/[
|
|
116
|
+
let text = answer.replace(/`([^`]*)`/g, (_m, code) => code.replace(/[[\]{}]/g, '').replace(/[,;:()]/g, ' '));
|
|
96
117
|
let before;
|
|
97
118
|
do {
|
|
98
119
|
before = text;
|
|
@@ -106,13 +127,16 @@ function clauses(sentence) {
|
|
|
106
127
|
export function defersBreakage(answer) {
|
|
107
128
|
for (const sentence of prose(answer).split(SENTENCE_BOUNDARY)) {
|
|
108
129
|
const sentenceBreaks = aboutACheck(sentence) && FAILURE.test(sentence);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
const weighsRejectedOption = OPTION.test(sentence) && CHOOSES_REPAIR.test(sentence);
|
|
131
|
+
const cancelsModal = (before, underCondition) => (underCondition && !FIRST_PERSON.test(before)) || weighsRejectedOption;
|
|
132
|
+
// A condition governs the clauses it opens and stops at the conclusion the
|
|
133
|
+
// sentence draws, so "fails only if the fixture is stale, so it could be
|
|
134
|
+
// flagged as known" states a condition and then decides. An option is posed
|
|
135
|
+
// once and weighed anywhere, but only where the sentence goes on to pick a
|
|
136
|
+
// repair is the modal describing the branch it rejected.
|
|
137
|
+
for (const segment of sentence.split(HYPOTHESIS_END)) {
|
|
138
|
+
const underCondition = CONDITION.test(segment);
|
|
139
|
+
for (const clause of clauses(segment)) {
|
|
116
140
|
for (const { re, needs } of PHRASES) {
|
|
117
141
|
const hit = re.exec(clause);
|
|
118
142
|
if (!hit)
|
|
@@ -120,7 +144,7 @@ export function defersBreakage(answer) {
|
|
|
120
144
|
const before = clause.slice(0, hit.index);
|
|
121
145
|
if (NOT_A_DECISION.test(before))
|
|
122
146
|
continue;
|
|
123
|
-
if (MODAL.test(before) &&
|
|
147
|
+
if (MODAL.test(before) && cancelsModal(before, underCondition))
|
|
124
148
|
continue;
|
|
125
149
|
if (needs === 'check' && !aboutACheck(clause))
|
|
126
150
|
continue;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi invalidates every captured ctx (and the extension's own `pi` handle) when a
|
|
3
|
+
* session is replaced or reloaded — AgentSession.dispose → ExtensionRunner.invalidate
|
|
4
|
+
* — and the guard throws from the next use. Code that fires from a TIMER rather than
|
|
5
|
+
* an event handler therefore has to expect it: the throw would otherwise escape the
|
|
6
|
+
* callback and take the host down as an uncaughtException.
|
|
7
|
+
*
|
|
8
|
+
* Matched on the message because the runtime throws a plain Error with no code or
|
|
9
|
+
* class to test. Only that one guard is swallowed; every other failure still throws.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isStaleCtxError(err: unknown): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi invalidates every captured ctx (and the extension's own `pi` handle) when a
|
|
3
|
+
* session is replaced or reloaded — AgentSession.dispose → ExtensionRunner.invalidate
|
|
4
|
+
* — and the guard throws from the next use. Code that fires from a TIMER rather than
|
|
5
|
+
* an event handler therefore has to expect it: the throw would otherwise escape the
|
|
6
|
+
* callback and take the host down as an uncaughtException.
|
|
7
|
+
*
|
|
8
|
+
* Matched on the message because the runtime throws a plain Error with no code or
|
|
9
|
+
* class to test. Only that one guard is swallowed; every other failure still throws.
|
|
10
|
+
*/
|
|
11
|
+
export function isStaleCtxError(err) {
|
|
12
|
+
return err instanceof Error && err.message.includes('stale after session');
|
|
13
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
2
|
import { realStreamTimerDeps, StreamWatchdog, streamStallReminder } from '../shared/stream-watchdog.js';
|
|
3
|
-
import { noteWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
3
|
+
import { noteWatchdogAbort, restoreWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
4
|
+
import { isStaleCtxError } from './stale-ctx.js';
|
|
4
5
|
/**
|
|
5
6
|
* MAIN-SESSION adapter for the model-stream watchdog.
|
|
6
7
|
*
|
|
@@ -47,12 +48,30 @@ export function registerStreamWatchdog(pi) {
|
|
|
47
48
|
// steer loop can otherwise observe the 'aborted' turn first and show
|
|
48
49
|
// a steering prompt to an empty room, wedging an unattended run.
|
|
49
50
|
if (ctx) {
|
|
50
|
-
noteWatchdogAbort();
|
|
51
|
-
|
|
51
|
+
const wasPending = noteWatchdogAbort();
|
|
52
|
+
try {
|
|
53
|
+
ctx.abort();
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
restoreWatchdogAbort(wasPending); // no turn was aborted
|
|
57
|
+
// Timer fired after session replacement/reload: the captured ctx
|
|
58
|
+
// is stale by design (Pi invalidates it in AgentSession.dispose).
|
|
59
|
+
// Swallow only that guard; anything else keeps throwing, and no
|
|
60
|
+
// follow-up is posted into the replacement session.
|
|
61
|
+
if (!isStaleCtxError(err))
|
|
62
|
+
throw err;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
pi.sendUserMessage(streamStallReminder(idleMs, WATCHDOG_CANCEL_MARKER), {
|
|
68
|
+
deliverAs: 'followUp'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (!isStaleCtxError(err))
|
|
73
|
+
throw err;
|
|
52
74
|
}
|
|
53
|
-
pi.sendUserMessage(streamStallReminder(idleMs, WATCHDOG_CANCEL_MARKER), {
|
|
54
|
-
deliverAs: 'followUp'
|
|
55
|
-
});
|
|
56
75
|
}
|
|
57
76
|
});
|
|
58
77
|
// Any event proves the stream is alive. `arm` also (re)starts the machine, so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.9",
|
|
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",
|