@akagilnc/pi-workflow-roles 0.1.2091 → 0.1.2101
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 +8 -1
- package/README.zh-CN.md +8 -1
- package/dist/engine-detour-tool.js +57 -4
- package/dist/engine-detour.js +53 -4
- package/dist/engine-labor-fallback.js +121 -0
- package/dist/evidence-child-executor.js +3 -15
- package/dist/package-owned-tool-idle.js +64 -7
- package/dist/public-cli/main.js +56 -22
- package/package.json +1 -1
- package/resources/engines/cursor.md +7 -0
- package/resources/engines/opus.md +12 -5
- package/src/engine-detour-tool.ts +83 -13
- package/src/engine-detour.ts +55 -4
- package/src/engine-labor-fallback.ts +172 -0
- package/src/evidence-child-executor.ts +3 -15
- package/src/judge-role.ts +11 -3
- package/src/package-owned-tool-idle.ts +73 -7
- package/src/public-cli/cli.ts +6 -4
- package/src/public-cli/config.ts +15 -28
- package/src/public-cli/settlement.ts +9 -1
- package/src/reviewer-role.ts +13 -5
- package/src/role-runtime.ts +22 -2
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ pi install npm:@akagilnc/pi-workflow-roles
|
|
|
11
11
|
export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Update with `pi update npm:@akagilnc/pi-workflow-roles`—never a second global `npm install -g`. Inspect with `ak-role roles` and `ak-role help <role>`; set per-seat defaults with `ak-role config set judge openai-codex/gpt-5.6-sol:high
|
|
14
|
+
Update with `pi update npm:@akagilnc/pi-workflow-roles`—never a second global `npm install -g`. Inspect with `ak-role roles` and `ak-role help <role>`; set per-seat model defaults with `ak-role config set judge openai-codex/gpt-5.6-sol:high`; set or clear a persistent labor engine (judge|reviewer) with `ak-role config set-engine <seat> <name>` / `ak-role config unset-engine <seat>`.
|
|
15
15
|
|
|
16
16
|
## Reading results
|
|
17
17
|
|
|
@@ -31,10 +31,17 @@ Every run also prepares Navigator advice in the same Terminal. Configure it like
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
ak-role config set navigator openai-codex/gpt-5.6-luna:medium
|
|
34
|
+
# persistent labor engine (judge|reviewer only); one-shot override remains --engine
|
|
35
|
+
ak-role config set-engine judge opus
|
|
36
|
+
ak-role config unset-engine judge
|
|
34
37
|
```
|
|
35
38
|
|
|
39
|
+
`config set` stores the seat model default; `config set-engine` / `unset-engine` store or clear the persistent labor-engine name on judge|reviewer only (same seats as `--engine`). Usage and refusal text are owned by `ak-role config` in the public CLI.
|
|
40
|
+
|
|
36
41
|
Receipts are typed, so callers compose roles without parsing prose; ordering and stopping stay caller-owned. Programmatic consumers derive contracts from the exported schemas in `src/package-contracts/`, not from this guide.
|
|
37
42
|
|
|
43
|
+
When a Judge/Reviewer labor-engine detour fails and the seat continues the labor on the main road, the typed receipt may carry a mechanical `engineLaborFallback` field: `{ engine, failure, laborBy: "seat" }`. It appears only after a real detour failure that fell back to seat labor (including package-owned idle timeout on the detour tool)—not on detour success or caller cancel. First failure wins for the activation; model-forged `engineLaborFallback` keys are stripped unless the package latch recorded one. Sole producer: `src/engine-labor-fallback.ts`; decision record: [ADR 0071](docs/adr/0071-engine-detour-failure-seat-fallback-declaration.md). This README only projects that contract.
|
|
44
|
+
|
|
38
45
|
## Call the roles
|
|
39
46
|
|
|
40
47
|
Public option identity, aliases, requiredness, and mode faces live in the generated [Public CLI options](#public-cli-options-generated) table and in `ak-role help <command>` — both project the same typed source. The examples below are usage sketches, not a second flag contract. An instruction is optional for judge, collector, and doctor, and required nonblank for coder, fixer, reviewer, and merger.
|
package/README.zh-CN.md
CHANGED
|
@@ -11,7 +11,7 @@ pi install npm:@akagilnc/pi-workflow-roles
|
|
|
11
11
|
export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
更新用 `pi update npm:@akagilnc/pi-workflow-roles`——勿另起全局 `npm install -g`。查看能力:`ak-role roles`、`ak-role help <role
|
|
14
|
+
更新用 `pi update npm:@akagilnc/pi-workflow-roles`——勿另起全局 `npm install -g`。查看能力:`ak-role roles`、`ak-role help <role>`;设席位模型默认:`ak-role config set judge openai-codex/gpt-5.6-sol:high`;设或清持久劳务引擎(仅 judge|reviewer):`ak-role config set-engine <seat> <name>` / `ak-role config unset-engine <seat>`。
|
|
15
15
|
|
|
16
16
|
## 读结果
|
|
17
17
|
|
|
@@ -31,10 +31,17 @@ ak-role judge --attach ./plan.md "Review this plan." > result.txt
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
ak-role config set navigator openai-codex/gpt-5.6-luna:medium
|
|
34
|
+
# 持久劳务引擎(仅 judge|reviewer);一次性覆盖仍用 --engine
|
|
35
|
+
ak-role config set-engine judge opus
|
|
36
|
+
ak-role config unset-engine judge
|
|
34
37
|
```
|
|
35
38
|
|
|
39
|
+
`config set` 存席位模型默认;`config set-engine` / `unset-engine` 在 judge|reviewer 上写入或清除持久劳务引擎名(与 `--engine` 同轴)。用法与拒绝文案以公开 CLI 的 `ak-role config` 为准。
|
|
40
|
+
|
|
36
41
|
回执是 typed 的,调用者不必解析散文即可组合角色;顺序与停止归调用者。编程消费者从 `src/package-contracts/` 导出推导契约,不从本文。
|
|
37
42
|
|
|
43
|
+
当 Judge/Reviewer 劳务引擎绕行失败、座席回到主路继续劳务时,typed 回执可带机械字段 `engineLaborFallback`:`{ engine, failure, laborBy: "seat" }`。仅在真实绕行失败并座席顶班后出现(含 detour 工具命中 package-owned idle 超时)——成功绕行或调用方 cancel 不出现。同一次 activation 内先到先得;无包内 latch 时剥离模型伪造的 `engineLaborFallback` 键。唯一构造点:`src/engine-labor-fallback.ts`;决策记录:[ADR 0071](docs/adr/0071-engine-detour-failure-seat-fallback-declaration.md)。本文只投影该契约。
|
|
44
|
+
|
|
38
45
|
## 调用百官
|
|
39
46
|
|
|
40
47
|
公开 option 身份、别名、必填性与 mode 面以生成区 [公开 CLI 选项(生成)](#公开-cli-选项生成) 与 `ak-role help <command>` 为准——二者同源。下例只是用法速写,不是第二份旗标合同。指令对大理寺、门下省、太医署可省略,对将作监、修内司、御史台、校书郎必须非空。
|
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC, ENGINE_DETOUR_TOOL_NAME, engineDetourFailureDiagnostic, engineNameFromEnv, isEngineDetourFailure, runEngineDetourOnce, } from "./engine-detour.js";
|
|
3
|
-
import {
|
|
3
|
+
import { activationEngineLaborFallbackLatch, recordEngineLaborFallback, } from "./engine-labor-fallback.js";
|
|
4
|
+
import { isPackageOwnedToolIdleTimeoutError, pokePackageOwnedToolIdle, wrapPackageOwnedToolDefinition, } from "./package-owned-tool-idle.js";
|
|
4
5
|
const engineDetourArgsSchema = Type.Object({
|
|
5
6
|
argv: Type.Array(Type.String({ minLength: 1 }), {
|
|
6
7
|
minItems: 1,
|
|
7
8
|
description: "Executable argv for one engine subprocess. First element is the command (PATH lookup); remaining elements are arguments. Build argv from the host CLI actual interface for the configured engine name; when optional packaged notes are present in the session prompt, follow those bytes. Do not invent package flags.",
|
|
8
9
|
}),
|
|
9
10
|
}, { additionalProperties: false });
|
|
11
|
+
function seatFallbackToolResult(field, failure) {
|
|
12
|
+
return {
|
|
13
|
+
content: [
|
|
14
|
+
{
|
|
15
|
+
type: "text",
|
|
16
|
+
text: `Engine detour failed: ${failure}. Perform the labor in this session (seat main road) and submit via the existing typed path. A mechanical fallback declaration will attach to the typed receipt.`,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
details: {
|
|
20
|
+
tool: ENGINE_DETOUR_TOOL_NAME,
|
|
21
|
+
detourFailed: true,
|
|
22
|
+
...field,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Caller/upper-layer cancel must propagate; idle backstop is seat-fallback, not cancel. */
|
|
27
|
+
function isCallerCancellation(error, signal) {
|
|
28
|
+
if (isPackageOwnedToolIdleTimeoutError(error))
|
|
29
|
+
return false;
|
|
30
|
+
if (signal !== undefined &&
|
|
31
|
+
isPackageOwnedToolIdleTimeoutError(signal.reason)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (signal?.aborted === true)
|
|
35
|
+
return true;
|
|
36
|
+
if (typeof error === "object" &&
|
|
37
|
+
error !== null &&
|
|
38
|
+
error.name === "AbortError") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
10
43
|
/**
|
|
11
44
|
* Build one once-latch detour tool definition for a configured engine name.
|
|
12
45
|
* `latch` is shared so parent registration can reset between activations.
|
|
13
|
-
* `fail` owns host abort (parent) vs throw (evidence child).
|
|
46
|
+
* `fail` owns host abort (parent) vs throw (evidence child) for tool misuse only.
|
|
47
|
+
* Engine process failure (nonzero/empty/spawn/idle-timeout) soft-returns seat fallback (#380).
|
|
48
|
+
* Caller AbortSignal cancel propagates without writing fallback.
|
|
14
49
|
*/
|
|
15
50
|
export function createEngineDetourToolDefinition(input) {
|
|
16
51
|
const latch = input.latch ?? { used: false };
|
|
@@ -24,6 +59,7 @@ export function createEngineDetourToolDefinition(input) {
|
|
|
24
59
|
`Use ${ENGINE_DETOUR_TOOL_NAME} exactly once for the configured engine (${engineName}). Optional packaged notes are guidance when present; a bare engine name alone is also a valid call path.`,
|
|
25
60
|
"Pass argv for the host CLI of this engine name — first element is the executable name on PATH. Follow optional packaged notes when delivered; otherwise act from the engine name and the host CLI actual interface. Do not invent package flags.",
|
|
26
61
|
"On success, use the returned stdout as labor content for the existing typed submission / report path.",
|
|
62
|
+
"On engine failure the tool returns a soft failure: continue labor in this session and submit via the existing typed path. Do not treat engine failure as a reason to withhold the typed receipt.",
|
|
27
63
|
],
|
|
28
64
|
parameters: engineDetourArgsSchema,
|
|
29
65
|
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
@@ -36,19 +72,36 @@ export function createEngineDetourToolDefinition(input) {
|
|
|
36
72
|
if (argv.length === 0 || argv.some((part) => typeof part !== "string" || part.length === 0)) {
|
|
37
73
|
input.fail(new Error("engine detour argv must be a non-empty string array"), toolCallId, ctx);
|
|
38
74
|
}
|
|
75
|
+
const softFail = (failure) => {
|
|
76
|
+
// Activation-scoped latch is the sole shared recorder (parent seat + legs).
|
|
77
|
+
const fallbackLatch = activationEngineLaborFallbackLatch() ?? { field: undefined };
|
|
78
|
+
const field = recordEngineLaborFallback(fallbackLatch, {
|
|
79
|
+
engine: engineName,
|
|
80
|
+
failure,
|
|
81
|
+
});
|
|
82
|
+
return seatFallbackToolResult(field, failure);
|
|
83
|
+
};
|
|
39
84
|
let result;
|
|
40
85
|
try {
|
|
86
|
+
// Byte activity on stdout/stderr touches the outer package-owned idle clock
|
|
87
|
+
// (183s silence law unchanged). True hangs still die; slow streaming engines live.
|
|
41
88
|
result = await runEngineDetourOnce({
|
|
42
89
|
argv,
|
|
43
90
|
cwd: ctx.cwd,
|
|
44
91
|
...(signal === undefined ? {} : { signal }),
|
|
92
|
+
onOutputActivity: pokePackageOwnedToolIdle,
|
|
45
93
|
});
|
|
46
94
|
}
|
|
47
95
|
catch (error) {
|
|
48
|
-
|
|
96
|
+
// Caller cancel: propagate. Idle backstop + spawn/engine failure: seat fallback.
|
|
97
|
+
if (isCallerCancellation(error, signal)) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
101
|
+
return softFail(failure.trim() === "" ? "engine detour spawn failed" : failure);
|
|
49
102
|
}
|
|
50
103
|
if (isEngineDetourFailure(result)) {
|
|
51
|
-
|
|
104
|
+
return softFail(engineDetourFailureDiagnostic(result));
|
|
52
105
|
}
|
|
53
106
|
return {
|
|
54
107
|
content: [{ type: "text", text: result.stdout }],
|
package/dist/engine-detour.js
CHANGED
|
@@ -10,9 +10,21 @@ export const ENGINE_DETOUR_TOOL_NAME = "ak_engine_detour";
|
|
|
10
10
|
export const AK_ROLE_ENGINE_ENV = "AK_ROLE_ENGINE";
|
|
11
11
|
export const ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC = "engine detour produced empty stdout";
|
|
12
12
|
export const ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC = "engine detour already used in this activation";
|
|
13
|
+
function abortReasonError(signal) {
|
|
14
|
+
const reason = signal.reason;
|
|
15
|
+
if (reason instanceof Error)
|
|
16
|
+
return reason;
|
|
17
|
+
if (typeof reason === "string" && reason.trim() !== "") {
|
|
18
|
+
return new Error(reason);
|
|
19
|
+
}
|
|
20
|
+
const error = new Error("aborted");
|
|
21
|
+
error.name = "AbortError";
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
13
24
|
/**
|
|
14
25
|
* Run one engine subprocess. First argv element is the executable (PATH lookup).
|
|
15
26
|
* stdio: ignore stdin, pipe stdout+stderr. No shell, no retry, no hang timer.
|
|
27
|
+
* AbortSignal cancels the child immediately via an explicit listener (reason preserved).
|
|
16
28
|
*/
|
|
17
29
|
export async function runEngineDetourOnce(input) {
|
|
18
30
|
if (input.argv.length === 0) {
|
|
@@ -22,32 +34,69 @@ export async function runEngineDetourOnce(input) {
|
|
|
22
34
|
const args = input.argv.slice(1);
|
|
23
35
|
return await new Promise((resolve, reject) => {
|
|
24
36
|
let settled = false;
|
|
37
|
+
const signal = input.signal;
|
|
38
|
+
// Own abort→kill explicitly so rejection preserves signal.reason (caller cancel
|
|
39
|
+
// vs package-owned idle). Do not pass `signal` to spawn (Node replaces reason).
|
|
25
40
|
const child = spawn(command, args, {
|
|
26
41
|
cwd: input.cwd,
|
|
27
42
|
env: input.env ?? process.env,
|
|
28
43
|
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
-
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
30
44
|
});
|
|
31
45
|
let stdout = "";
|
|
32
46
|
let stderr = "";
|
|
47
|
+
const noteActivity = (chunk) => {
|
|
48
|
+
if (chunk.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
input.onOutputActivity?.();
|
|
51
|
+
};
|
|
33
52
|
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
34
53
|
stdout += chunk;
|
|
54
|
+
noteActivity(chunk);
|
|
35
55
|
});
|
|
36
56
|
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
37
57
|
stderr += chunk;
|
|
58
|
+
noteActivity(chunk);
|
|
38
59
|
});
|
|
39
60
|
const fail = (error) => {
|
|
40
61
|
if (settled)
|
|
41
62
|
return;
|
|
42
63
|
settled = true;
|
|
64
|
+
if (signal !== undefined) {
|
|
65
|
+
signal.removeEventListener("abort", onAbort);
|
|
66
|
+
}
|
|
43
67
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
44
68
|
};
|
|
45
|
-
|
|
46
|
-
child.on("close", (code) => {
|
|
69
|
+
const succeed = (result) => {
|
|
47
70
|
if (settled)
|
|
48
71
|
return;
|
|
49
72
|
settled = true;
|
|
50
|
-
|
|
73
|
+
if (signal !== undefined) {
|
|
74
|
+
signal.removeEventListener("abort", onAbort);
|
|
75
|
+
}
|
|
76
|
+
resolve(result);
|
|
77
|
+
};
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
// Fail synchronously so cooperative idle/cancel paths can soft-settle
|
|
80
|
+
// before the outer package-owned idle hard-reject drain.
|
|
81
|
+
fail(signal !== undefined ? abortReasonError(signal) : new Error("aborted"));
|
|
82
|
+
try {
|
|
83
|
+
child.kill("SIGTERM");
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// already exited
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (signal !== undefined) {
|
|
90
|
+
if (signal.aborted) {
|
|
91
|
+
onAbort();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
child.on("error", (error) => fail(error));
|
|
98
|
+
child.on("close", (code) => {
|
|
99
|
+
succeed({ code: code ?? 1, stdout, stderr });
|
|
51
100
|
});
|
|
52
101
|
});
|
|
53
102
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #380 — sole shared seat-fallback declaration after engine detour failure.
|
|
3
|
+
* Detour rejoins the main road (ADR 0069); silence is the only crime.
|
|
4
|
+
* Construction of the typed receipt field lives in exactly one site (S1).
|
|
5
|
+
*/
|
|
6
|
+
/** Activation-scoped latch holder (one parent seat activation at a time). */
|
|
7
|
+
let activationLatch;
|
|
8
|
+
/** Sole construction site for typed receipt field `engineLaborFallback` (#380 S1). */
|
|
9
|
+
export function buildEngineLaborFallbackField(input) {
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
engineLaborFallback: Object.freeze({
|
|
12
|
+
engine: input.engine,
|
|
13
|
+
failure: input.failure,
|
|
14
|
+
laborBy: "seat",
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function createEngineLaborFallbackLatch() {
|
|
19
|
+
return { field: undefined };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Record first detour failure for this latch (first wins; parallel legs share one field).
|
|
23
|
+
* Always returns the latched first-wins value so tool details and receipt projection match.
|
|
24
|
+
*/
|
|
25
|
+
export function recordEngineLaborFallback(latch, input) {
|
|
26
|
+
const field = buildEngineLaborFallbackField(input);
|
|
27
|
+
if (latch.field === undefined)
|
|
28
|
+
latch.field = field;
|
|
29
|
+
return latch.field;
|
|
30
|
+
}
|
|
31
|
+
export function readEngineLaborFallbackField(latch) {
|
|
32
|
+
return latch?.field;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merge sole-built field into typed receipt details.
|
|
36
|
+
* Spread only — must not construct the field key again (S1).
|
|
37
|
+
* Without a mechanical latch, strip any model-injected reserved key (no forged declaration).
|
|
38
|
+
*/
|
|
39
|
+
export function withEngineLaborFallbackField(receipt, field) {
|
|
40
|
+
if (field !== undefined) {
|
|
41
|
+
return { ...receipt, ...field };
|
|
42
|
+
}
|
|
43
|
+
if (!Object.prototype.hasOwnProperty.call(receipt, "engineLaborFallback")) {
|
|
44
|
+
return receipt;
|
|
45
|
+
}
|
|
46
|
+
const { engineLaborFallback: _forged, ...rest } = receipt;
|
|
47
|
+
return rest;
|
|
48
|
+
}
|
|
49
|
+
/** Install the activation-scoped latch (Judge/Reviewer session_start). */
|
|
50
|
+
export function installActivationEngineLaborFallbackLatch(latch) {
|
|
51
|
+
activationLatch = latch;
|
|
52
|
+
}
|
|
53
|
+
/** Clear activation latch (session end / next activation). */
|
|
54
|
+
export function clearActivationEngineLaborFallbackLatch() {
|
|
55
|
+
activationLatch = undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Active activation latch, if any (legs inherit parent seat activation). */
|
|
58
|
+
export function activationEngineLaborFallbackLatch() {
|
|
59
|
+
return activationLatch;
|
|
60
|
+
}
|
|
61
|
+
/** Read fallback field from the active activation latch. */
|
|
62
|
+
export function readActivationEngineLaborFallbackField() {
|
|
63
|
+
return readEngineLaborFallbackField(activationLatch);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read a previously attached declaration from a typed receipt / details object.
|
|
67
|
+
* Rebuilds via the sole construction site — callers must spread, never re-key.
|
|
68
|
+
*/
|
|
69
|
+
export function readEngineLaborFallbackFieldFrom(source) {
|
|
70
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = source.engineLaborFallback;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
81
|
+
return undefined;
|
|
82
|
+
const rec = raw;
|
|
83
|
+
if (typeof rec.engine !== "string" ||
|
|
84
|
+
typeof rec.failure !== "string" ||
|
|
85
|
+
rec.laborBy !== "seat") {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return buildEngineLaborFallbackField({
|
|
89
|
+
engine: rec.engine,
|
|
90
|
+
failure: rec.failure,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Restore activation latch from durable session tool results (#380 resume).
|
|
95
|
+
* Scans existing same-session detour tool results only — no sidecar / new entry type.
|
|
96
|
+
* Replays through recordEngineLaborFallback so first-wins + sole producer stay intact.
|
|
97
|
+
*/
|
|
98
|
+
export function restoreEngineLaborFallbackFromSessionEntries(latch, entries, toolName) {
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (typeof entry !== "object" || entry === null)
|
|
101
|
+
continue;
|
|
102
|
+
const row = entry;
|
|
103
|
+
if (row.type !== "message")
|
|
104
|
+
continue;
|
|
105
|
+
const message = row.message;
|
|
106
|
+
if (typeof message !== "object" || message === null)
|
|
107
|
+
continue;
|
|
108
|
+
const msg = message;
|
|
109
|
+
if (msg.role !== "toolResult")
|
|
110
|
+
continue;
|
|
111
|
+
if (msg.toolName !== toolName)
|
|
112
|
+
continue;
|
|
113
|
+
const field = readEngineLaborFallbackFieldFrom(msg.details);
|
|
114
|
+
if (field === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
recordEngineLaborFallback(latch, {
|
|
117
|
+
engine: field.engineLaborFallback.engine,
|
|
118
|
+
failure: field.engineLaborFallback.failure,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -472,16 +472,14 @@ export async function executeEvidenceChild(workspace, prompt, context, options =
|
|
|
472
472
|
engine: engineName,
|
|
473
473
|
packageRoot: options.packageRoot,
|
|
474
474
|
});
|
|
475
|
-
// #378: detour
|
|
476
|
-
|
|
475
|
+
// #378/#380: same detour tool as parent seat. Engine process failure soft-returns
|
|
476
|
+
// seat main-road fallback (ADR 0069 detour-rejoins-main-road); tool misuse still throws.
|
|
477
477
|
const engineDetourTool = engineName === undefined
|
|
478
478
|
? undefined
|
|
479
479
|
: createEngineDetourToolDefinition({
|
|
480
480
|
engineName,
|
|
481
481
|
fail(error) {
|
|
482
|
-
|
|
483
|
-
engineDetourFailure = failure;
|
|
484
|
-
throw failure;
|
|
482
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
485
483
|
},
|
|
486
484
|
});
|
|
487
485
|
// No tools allowlist — Pi defaults + unrestricted evidence surface (ADR 0064).
|
|
@@ -520,18 +518,8 @@ export async function executeEvidenceChild(workspace, prompt, context, options =
|
|
|
520
518
|
await session.prompt(delivered);
|
|
521
519
|
}
|
|
522
520
|
catch (error) {
|
|
523
|
-
// Engine detour fail becomes isError toolResult and must outrank a later
|
|
524
|
-
// provider-shaped throw from the same prompt turn (#378).
|
|
525
|
-
if (engineDetourFailure !== undefined) {
|
|
526
|
-
throw classifiedError(engineDetourFailure, "child");
|
|
527
|
-
}
|
|
528
521
|
throw classifiedError(error, "provider");
|
|
529
522
|
}
|
|
530
|
-
// Launched-leg detour non-zero / empty stdout / spawn failure is infrastructure:
|
|
531
|
-
// reject the leg even when the model still emits a non-blank report afterward.
|
|
532
|
-
if (engineDetourFailure !== undefined) {
|
|
533
|
-
throw classifiedError(engineDetourFailure, "child");
|
|
534
|
-
}
|
|
535
523
|
if (signal?.aborted)
|
|
536
524
|
throw new Error("Evidence child was cancelled");
|
|
537
525
|
const lastAssistant = [...session.messages]
|
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
* Fixed 183000ms silence clock on package-owned tool execute only.
|
|
5
5
|
* Real producing onUpdate resets; final resolve/reject clears; timeout throws so
|
|
6
6
|
* Pi settles the current call as an LLM-visible isError tool result. No retry,
|
|
7
|
-
* role failure, process termination,
|
|
7
|
+
* role failure, process termination, config, or Pi built-in coverage.
|
|
8
8
|
*
|
|
9
9
|
* #339: do not name-exempt whole terminating tools. Outer idle stays armed for
|
|
10
10
|
* pre/post-audit work. Only the real compliance-audit await suspends this single
|
|
11
11
|
* layer (ADR 0059 owns that interval); resume re-arms the same outer backstop.
|
|
12
|
+
*
|
|
13
|
+
* #380: on idle, abort a derived tool signal (not the caller signal) so detour
|
|
14
|
+
* spawn cleanup and softFail can run through the existing AbortSignal path.
|
|
15
|
+
* Cooperative soft-settle may win; non-listeners still hard-reject after a
|
|
16
|
+
* microtask drain (no second timer).
|
|
12
17
|
*/
|
|
13
18
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
19
|
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, createStreamIdleGuard, } from "./stream-idle-guard.js";
|
|
@@ -29,6 +34,13 @@ export class PackageOwnedToolIdleTimeoutError extends Error {
|
|
|
29
34
|
this.name = "PackageOwnedToolIdleTimeoutError";
|
|
30
35
|
}
|
|
31
36
|
}
|
|
37
|
+
export function isPackageOwnedToolIdleTimeoutError(value) {
|
|
38
|
+
return (value instanceof PackageOwnedToolIdleTimeoutError ||
|
|
39
|
+
(typeof value === "object" &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
value.name === "PackageOwnedToolIdleTimeoutError" &&
|
|
42
|
+
value.code === PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_CODE));
|
|
43
|
+
}
|
|
32
44
|
/**
|
|
33
45
|
* Package-tool activity includes content production and host-only details
|
|
34
46
|
* progress. Keep the observation-plane oracle separate: its stderr heartbeat
|
|
@@ -68,6 +80,14 @@ export async function withPackageOwnedToolIdleSuspended(run) {
|
|
|
68
80
|
scope.resume();
|
|
69
81
|
}
|
|
70
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Touch the active package-owned execute idle clock (reuse stream-idle-guard poke).
|
|
85
|
+
* No-op outside a wrapped package-owned execute or while that layer is suspended.
|
|
86
|
+
* Used by long-running tools (engine detour) that see real subprocess output bytes.
|
|
87
|
+
*/
|
|
88
|
+
export function pokePackageOwnedToolIdle() {
|
|
89
|
+
packageOwnedToolIdleScope.getStore()?.poke();
|
|
90
|
+
}
|
|
71
91
|
/**
|
|
72
92
|
* Single shared execute wrapper for package-owned tool definitions.
|
|
73
93
|
* Idempotent: wrapping twice returns the same protected definition.
|
|
@@ -79,7 +99,7 @@ export function wrapPackageOwnedToolDefinition(tool) {
|
|
|
79
99
|
return tool;
|
|
80
100
|
const originalExecute = tool.execute.bind(tool);
|
|
81
101
|
const wrappedExecute = function packageOwnedToolIdleExecute(...args) {
|
|
82
|
-
const
|
|
102
|
+
const parentSignal = args[2];
|
|
83
103
|
const onUpdate = args[3];
|
|
84
104
|
return new Promise((resolve, reject) => {
|
|
85
105
|
let settled = false;
|
|
@@ -87,16 +107,47 @@ export function wrapPackageOwnedToolDefinition(tool) {
|
|
|
87
107
|
let idle = createStreamIdleGuard({
|
|
88
108
|
idleTimeoutMs: PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_MS,
|
|
89
109
|
});
|
|
110
|
+
// Derived tool signal: caller cancel forwards; idle aborts this only (not parent).
|
|
111
|
+
const toolController = new AbortController();
|
|
112
|
+
const onParentAbort = () => {
|
|
113
|
+
if (toolController.signal.aborted)
|
|
114
|
+
return;
|
|
115
|
+
toolController.abort(parentSignal?.reason);
|
|
116
|
+
};
|
|
117
|
+
if (parentSignal !== undefined) {
|
|
118
|
+
if (parentSignal.aborted) {
|
|
119
|
+
toolController.abort(parentSignal.reason);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
parentSignal.addEventListener("abort", onParentAbort);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const cleanup = () => {
|
|
126
|
+
idle.signal.removeEventListener("abort", onIdle);
|
|
127
|
+
idle.dispose();
|
|
128
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
129
|
+
};
|
|
90
130
|
const settle = (deliver) => {
|
|
91
131
|
if (settled)
|
|
92
132
|
return;
|
|
93
133
|
settled = true;
|
|
94
|
-
|
|
95
|
-
idle.dispose();
|
|
134
|
+
cleanup();
|
|
96
135
|
deliver();
|
|
97
136
|
};
|
|
98
137
|
const onIdle = () => {
|
|
99
|
-
|
|
138
|
+
const idleError = new PackageOwnedToolIdleTimeoutError();
|
|
139
|
+
if (!toolController.signal.aborted) {
|
|
140
|
+
toolController.abort(idleError);
|
|
141
|
+
}
|
|
142
|
+
// Cooperative tools (detour) soft-settle via the aborted signal in the
|
|
143
|
+
// next microtasks. Non-listeners still hard-reject after a short drain
|
|
144
|
+
// of those microtasks — no second wall-clock timer.
|
|
145
|
+
void Promise.resolve()
|
|
146
|
+
.then(() => { })
|
|
147
|
+
.then(() => { })
|
|
148
|
+
.then(() => {
|
|
149
|
+
settle(() => reject(idleError));
|
|
150
|
+
});
|
|
100
151
|
};
|
|
101
152
|
idle.signal.addEventListener("abort", onIdle, { once: true });
|
|
102
153
|
const suspension = {
|
|
@@ -118,6 +169,11 @@ export function wrapPackageOwnedToolDefinition(tool) {
|
|
|
118
169
|
});
|
|
119
170
|
idle.signal.addEventListener("abort", onIdle, { once: true });
|
|
120
171
|
},
|
|
172
|
+
poke() {
|
|
173
|
+
if (settled || suspended)
|
|
174
|
+
return;
|
|
175
|
+
idle.poke();
|
|
176
|
+
},
|
|
121
177
|
};
|
|
122
178
|
const guardedOnUpdate = onUpdate === undefined
|
|
123
179
|
? undefined
|
|
@@ -129,8 +185,9 @@ export function wrapPackageOwnedToolDefinition(tool) {
|
|
|
129
185
|
onUpdate(partialResult);
|
|
130
186
|
};
|
|
131
187
|
const callArgs = args.slice();
|
|
132
|
-
//
|
|
133
|
-
|
|
188
|
+
// Pass derived signal so idle/cancel can clean up subprocesses without
|
|
189
|
+
// aborting the caller/parent agent signal identity.
|
|
190
|
+
callArgs[2] = toolController.signal;
|
|
134
191
|
callArgs[3] = guardedOnUpdate;
|
|
135
192
|
void packageOwnedToolIdleScope.run(suspension, async () => {
|
|
136
193
|
try {
|