@danypops/papyrus 0.27.10 → 0.27.12
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/extension/src/discuss-ask-view.ts +8 -60
- package/extension/src/discuss.ts +2 -2
- package/extension/src/domain-tools.ts +1 -2
- package/package.json +1 -1
- package/src/ops.ts +22 -15
|
@@ -63,19 +63,10 @@ export interface AskQuestionParams {
|
|
|
63
63
|
displayMode?: AskDisplayMode;
|
|
64
64
|
timeout?: number;
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
key?: string;
|
|
71
|
-
/**
|
|
72
|
-
* Streamed once before blocking on the human. A live ask can legitimately sit pending far
|
|
73
|
-
* longer than a typical tool call (real human response time, not milliseconds) -- without any
|
|
74
|
-
* progress signal, a tool call sitting silent that long looks indistinguishable from a dead
|
|
75
|
-
* one to anything upstream watching for stalled calls. pi-ask-user's own original code (the
|
|
76
|
-
* prior art this view is adapted from) sent exactly this same heartbeat before presenting its
|
|
77
|
-
* UI; dropping it during the port was the regression that let two independent executions of
|
|
78
|
-
* the same live ask run concurrently, each opening its own picker for the same question.
|
|
66
|
+
* Streamed once before blocking on the human, matching pi-ask-user's own original code (the
|
|
67
|
+
* prior art this view is adapted from) -- gives the tool call's progress UI something to show
|
|
68
|
+
* during a wait that legitimately runs far longer than a typical tool call (real human response
|
|
69
|
+
* time, not milliseconds).
|
|
79
70
|
*/
|
|
80
71
|
onUpdate?: AgentToolUpdateCallback;
|
|
81
72
|
/**
|
|
@@ -1099,9 +1090,8 @@ async function askViaDialogs(
|
|
|
1099
1090
|
* driver (extension/src/index.ts's driveActiveTasks, on agent_settled) queue a "continue the
|
|
1100
1091
|
* active task" nudge as a `deliverAs: "nextTurn"` message while this exact live ask is still
|
|
1101
1092
|
* awaiting an answer -- starting a second, concurrent turn that reasons about the very Discussion
|
|
1102
|
-
* this call is already resolving, independently of it.
|
|
1103
|
-
*
|
|
1104
|
-
* to exactly this race. driveActiveTasks checks isLiveAskPending() and skips queuing while true.
|
|
1093
|
+
* this call is already resolving, independently of it. driveActiveTasks checks isLiveAskPending()
|
|
1094
|
+
* and skips queuing while true.
|
|
1105
1095
|
*/
|
|
1106
1096
|
let livePendingCount = 0;
|
|
1107
1097
|
|
|
@@ -1109,16 +1099,6 @@ export function isLiveAskPending(): boolean {
|
|
|
1109
1099
|
return livePendingCount > 0;
|
|
1110
1100
|
}
|
|
1111
1101
|
|
|
1112
|
-
/**
|
|
1113
|
-
* Keyed reentrancy join: whatever the exact external cause (an upstream retry, a duplicate turn,
|
|
1114
|
-
* anything outside code this package controls -- verified Pi's own ctx.ui.custom() is a clean,
|
|
1115
|
-
* single-shot, well-guarded call with no retry/timeout logic of its own), a second concurrent
|
|
1116
|
-
* askQuestion() call for the SAME key must never open a second picker for the same question. It
|
|
1117
|
-
* joins the already-in-flight promise instead. Keyed by the target Discussion's id (stable across
|
|
1118
|
-
* a genuine duplicate call, unlike a fresh toolCallId each retry might mint).
|
|
1119
|
-
*/
|
|
1120
|
-
const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
|
|
1121
|
-
|
|
1122
1102
|
/**
|
|
1123
1103
|
* Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
|
|
1124
1104
|
* dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
|
|
@@ -1127,36 +1107,7 @@ const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
|
|
|
1127
1107
|
*/
|
|
1128
1108
|
export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
|
|
1129
1109
|
if (!ctx.hasUI || !ctx.ui) return undefined;
|
|
1130
|
-
|
|
1131
|
-
const existing = pendingByKey.get(params.key);
|
|
1132
|
-
if (existing) return existing;
|
|
1133
|
-
}
|
|
1134
|
-
const promise = askQuestionUnguarded(ctx, params);
|
|
1135
|
-
if (params.key !== undefined) {
|
|
1136
|
-
const key = params.key;
|
|
1137
|
-
pendingByKey.set(key, promise);
|
|
1138
|
-
void promise.finally(() => {
|
|
1139
|
-
if (pendingByKey.get(key) === promise) pendingByKey.delete(key);
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
return promise;
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
/**
|
|
1146
|
-
* Sent at least this often while blocked on the human -- well under any 8-second window a
|
|
1147
|
-
* watchdog upstream might require to consider a tool call still alive. A single upfront ping
|
|
1148
|
-
* (this view's original heartbeat) only covers the first ~8s; for genuinely long human response
|
|
1149
|
-
* times (the whole point of a live ask) it goes silent again after that, indistinguishable from
|
|
1150
|
-
* dead. A live-observed bug traced back to exactly this: cancellation always cited "idle timeout,
|
|
1151
|
-
* no interaction within 8000ms" regardless of whether the real wait was 5 seconds or 18 minutes
|
|
1152
|
-
* -- a fixed re-check window, not a measure of total elapsed time -- meaning periodic liveness,
|
|
1153
|
-
* not a one-time ping, is what's required here.
|
|
1154
|
-
*/
|
|
1155
|
-
let liveAskHeartbeatIntervalMs = 4_000;
|
|
1156
|
-
|
|
1157
|
-
/** Test seam: real tiny interval, not a faked global timer -- exercises the periodic heartbeat deterministically and fast. */
|
|
1158
|
-
export function setLiveAskHeartbeatIntervalMsForTests(ms: number): void {
|
|
1159
|
-
liveAskHeartbeatIntervalMs = ms;
|
|
1110
|
+
return askQuestionUnguarded(ctx, params);
|
|
1160
1111
|
}
|
|
1161
1112
|
|
|
1162
1113
|
async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
|
|
@@ -1169,15 +1120,12 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
|
|
|
1169
1120
|
const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
|
|
1170
1121
|
const normalizedContext = params.context?.trim() || undefined;
|
|
1171
1122
|
|
|
1172
|
-
|
|
1173
|
-
sendHeartbeat();
|
|
1174
|
-
const heartbeatTimer = params.onUpdate ? setInterval(sendHeartbeat, liveAskHeartbeatIntervalMs) : undefined;
|
|
1123
|
+
params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
|
|
1175
1124
|
livePendingCount += 1;
|
|
1176
1125
|
try {
|
|
1177
1126
|
return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
|
|
1178
1127
|
} finally {
|
|
1179
1128
|
livePendingCount -= 1;
|
|
1180
|
-
if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer);
|
|
1181
1129
|
}
|
|
1182
1130
|
}
|
|
1183
1131
|
|
package/extension/src/discuss.ts
CHANGED
|
@@ -84,8 +84,8 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
|
|
|
84
84
|
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
85
85
|
const question = `Reply to "${discussion.title}":`;
|
|
86
86
|
const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
|
|
87
|
-
? await askQuestion(commandCtx, { question, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi"
|
|
88
|
-
: await askQuestion(commandCtx, { question
|
|
87
|
+
? await askQuestion(commandCtx, { question, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
|
|
88
|
+
: await askQuestion(commandCtx, { question });
|
|
89
89
|
if (!answer) return; // canceled
|
|
90
90
|
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
|
|
91
91
|
commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
|
|
@@ -50,10 +50,9 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, onUpdate:
|
|
|
50
50
|
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
51
51
|
onUpdate,
|
|
52
52
|
signal,
|
|
53
|
-
key: discussion.id,
|
|
54
53
|
});
|
|
55
54
|
}
|
|
56
|
-
return askQuestion(ctx, { question, onUpdate, signal
|
|
55
|
+
return askQuestion(ctx, { question, onUpdate, signal });
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
/**
|
package/package.json
CHANGED
package/src/ops.ts
CHANGED
|
@@ -529,14 +529,17 @@ export function runGates(db: Db, artifactId: string, options: GateRunOptions = {
|
|
|
529
529
|
}
|
|
530
530
|
}
|
|
531
531
|
case "command": {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
}
|
|
532
|
+
// spawnSync + manual stdout/stderr concatenation, not execSync: execSync's return value is
|
|
533
|
+
// stdout only. Many real commands (bun test's own per-test lines and its pass/fail summary
|
|
534
|
+
// among them) write their actual output to stderr, so an execSync-based match against
|
|
535
|
+
// gate.expect saw only the first line of a banner and never the result -- every such gate
|
|
536
|
+
// failed regardless of whether the command actually passed.
|
|
537
|
+
const { spawnSync } = require_("node:child_process");
|
|
538
|
+
const result = spawnSync(gate.target, { shell: true, encoding: "utf-8", timeout: GATE_COMMAND_TIMEOUT_MS, ...(cwd ? { cwd } : {}) });
|
|
539
|
+
if (result.error) return { gate, passed: false, output: result.error.message.slice(0, GATE_OUTPUT_LIMIT) };
|
|
540
|
+
const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
541
|
+
const passed = result.status === 0 && (gate.expect ? combined.includes(gate.expect) : true);
|
|
542
|
+
return { gate, passed, output: combined.slice(0, GATE_OUTPUT_LIMIT) || (result.status === 0 ? "ok" : `command exited with code ${result.status}`) };
|
|
540
543
|
}
|
|
541
544
|
case "test": {
|
|
542
545
|
const { execSync } = require_("node:child_process");
|
|
@@ -564,7 +567,7 @@ export function runGates(db: Db, artifactId: string, options: GateRunOptions = {
|
|
|
564
567
|
* indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
|
|
565
568
|
* process group) and killing the negated pid on our own timer reaches the whole tree.
|
|
566
569
|
*/
|
|
567
|
-
function executeGateCommand(command: string, timeout: number, cwd?: string): Promise<{ passed: boolean; output: string }> {
|
|
570
|
+
function executeGateCommand(command: string, timeout: number, cwd?: string): Promise<{ passed: boolean; output: string; matchable: string }> {
|
|
568
571
|
// `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
|
|
569
572
|
// `detached` (needed to make the shell the leader of its own process group, so the negated pid
|
|
570
573
|
// below reaches every descendant, not just the shell) is not part of Node's `exec()`/
|
|
@@ -587,17 +590,21 @@ function executeGateCommand(command: string, timeout: number, cwd?: string): Pro
|
|
|
587
590
|
child.stdout?.on("data", append);
|
|
588
591
|
child.stderr?.on("data", append);
|
|
589
592
|
|
|
590
|
-
const finish = (result: { passed: boolean; output: string }): void => {
|
|
593
|
+
const finish = (result: { passed: boolean; output: string; matchable: string }): void => {
|
|
591
594
|
if (settled) return;
|
|
592
595
|
settled = true;
|
|
593
596
|
clearTimeout(timer);
|
|
594
597
|
resolve(result);
|
|
595
598
|
};
|
|
596
599
|
|
|
597
|
-
child.on("error", (error) => finish({ passed: false, output: error.message.slice(0, GATE_OUTPUT_LIMIT) }));
|
|
600
|
+
child.on("error", (error) => finish({ passed: false, output: error.message.slice(0, GATE_OUTPUT_LIMIT), matchable: error.message }));
|
|
598
601
|
child.on("close", (code) => {
|
|
599
|
-
|
|
600
|
-
|
|
602
|
+
// `matchable` carries the full (GATE_MAX_BUFFER_BYTES-bounded) buffer so the caller's
|
|
603
|
+
// gate.expect substring check sees the whole run, not just the first GATE_OUTPUT_LIMIT
|
|
604
|
+
// characters -- a real bun test run's pass/fail summary is its last line, not its first.
|
|
605
|
+
const full = buffered.trim();
|
|
606
|
+
const output = full.slice(0, GATE_OUTPUT_LIMIT);
|
|
607
|
+
finish({ passed: code === 0, output: output || (code === 0 ? "ok" : `command exited with code ${code}`), matchable: full });
|
|
601
608
|
});
|
|
602
609
|
|
|
603
610
|
const timer = setTimeout(() => {
|
|
@@ -609,7 +616,7 @@ function executeGateCommand(command: string, timeout: number, cwd?: string): Pro
|
|
|
609
616
|
child.kill("SIGKILL");
|
|
610
617
|
}
|
|
611
618
|
}
|
|
612
|
-
finish({ passed: false, output: `gate command timed out after ${timeout}ms
|
|
619
|
+
finish({ passed: false, output: `gate command timed out after ${timeout}ms`, matchable: "" });
|
|
613
620
|
}, timeout);
|
|
614
621
|
});
|
|
615
622
|
}
|
|
@@ -651,7 +658,7 @@ export async function runGatesAsync(db: Db, artifactId: string, options: GateRun
|
|
|
651
658
|
const executed = await executeGateCommand(command, timeout, options.cwd);
|
|
652
659
|
results.push({
|
|
653
660
|
gate,
|
|
654
|
-
passed: executed.passed && (gate.expect ? executed.
|
|
661
|
+
passed: executed.passed && (gate.expect ? executed.matchable.includes(gate.expect) : true),
|
|
655
662
|
output: executed.output,
|
|
656
663
|
});
|
|
657
664
|
} else {
|