pi-subagents 0.63.0 → 0.64.0
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/CHANGELOG.md +24 -1
- package/docs/watchdog.md +92 -114
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +4 -3
- package/src/runs/background/async-execution.ts +3 -0
- package/src/runs/background/notify.ts +41 -2
- package/src/runs/background/subagent-runner.ts +23 -1
- package/src/runs/foreground/execution.ts +7 -1
- package/src/runs/foreground/subagent-executor.ts +21 -1
- package/src/runs/shared/acceptance.ts +10 -0
- package/src/runs/shared/async-status-projection.ts +11 -43
- package/src/runs/shared/pi-args.ts +1 -4
- package/src/runs/shared/subagent-control.ts +4 -2
- package/src/shared/types.ts +9 -2
- package/src/tui/render.ts +5 -0
- package/src/watchdog/child-status.ts +54 -33
- package/src/watchdog/diff-tool.ts +77 -0
- package/src/watchdog/emission-guard.ts +5 -3
- package/src/watchdog/guidance.ts +20 -0
- package/src/watchdog/register-child.ts +16 -13
- package/src/watchdog/register-main.ts +10 -9
- package/src/watchdog/render.ts +4 -5
- package/src/watchdog/review.ts +15 -4
- package/src/watchdog/rules.ts +70 -0
- package/src/watchdog/runtime.ts +75 -92
- package/src/watchdog/scope.ts +0 -11
- package/src/watchdog/settings.ts +48 -104
- package/src/watchdog/types.ts +18 -32
- package/src/watchdog/warning-format.ts +0 -1
- package/src/workflows/chat-progress.ts +3 -2
- package/src/workflows/workflow-checklist.ts +10 -12
- package/src/workflows/workflow-preflight.ts +28 -1
|
@@ -27,6 +27,7 @@ import { normalizePublicSubagentExecution, validateWorkflowCapacityOverrides } f
|
|
|
27
27
|
import { runSync } from "./execution.ts";
|
|
28
28
|
import { handleWatchdogToolAction, WATCHDOG_TOOL_ACTIONS } from "../../watchdog/tool-actions.ts";
|
|
29
29
|
import type { MainWatchdogRuntime } from "../../watchdog/runtime.ts";
|
|
30
|
+
import { applyWatchdogLaunchRules } from "../../watchdog/rules.ts";
|
|
30
31
|
import { buildModelCandidates, normalizeParentModel, resolveEffectiveSubagentModel, resolveModelOrigin, type ModelOrigin, type ParentModel } from "../shared/model-fallback.ts";
|
|
31
32
|
import { formatRetainedChildren, listRetainedChildren } from "../background/retained-children.ts";
|
|
32
33
|
import { resolveModelScopesForAgent, type ModelScopeConfig } from "../shared/model-scope.ts";
|
|
@@ -2069,7 +2070,7 @@ async function resumeAsyncRun(input: {
|
|
|
2069
2070
|
// place rather than allocating a second provider worktree around it.
|
|
2070
2071
|
worktree: input.params.worktree === true && !("managedWorktree" in target && target.managedWorktree === true),
|
|
2071
2072
|
lane: input.params.lane ?? recoveryDescriptor?.lane,
|
|
2072
|
-
controlConfig:
|
|
2073
|
+
controlConfig: resolveRevivalControlConfig({ globalConfig: input.deps.config.control, requestedControl: input.params.control, recoveryControlConfig: recoveryDescriptor?.controlConfig }),
|
|
2073
2074
|
intercomBridge: input.params.intercomBridge ?? recoveryDescriptor?.intercomBridge,
|
|
2074
2075
|
controlIntercomTarget: intercomBridge.active ? intercomBridge.orchestratorTarget : undefined,
|
|
2075
2076
|
childIntercomTarget: intercomBridge.active ? (agent, index) => resolveSubagentIntercomTarget(runId, agent, index) : undefined,
|
|
@@ -3229,6 +3230,8 @@ async function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
|
|
|
3229
3230
|
source: modelOrigin === "explicit" ? "explicit" : "inherited",
|
|
3230
3231
|
});
|
|
3231
3232
|
const modelOverrideFromParent = modelOrigin === "inherited";
|
|
3233
|
+
const launchRuleError = applyWatchdogLaunchRules({ cwd: effectiveCwd, agent: a.name, model: modelOverride ?? (parentModel && `${parentModel.provider}/${parentModel.id}`), warn: (violation) => deps.watchdog?.displayRuleWarning(violation) });
|
|
3234
|
+
if (launchRuleError) return toExecutionErrorResult(params, new Error(launchRuleError), data.contextPolicy.contextSummary);
|
|
3232
3235
|
const asyncResult = executeAsyncSingle(id, compactOptional<Parameters<typeof executeAsyncSingle>[1]>({
|
|
3233
3236
|
agent: params.agent!,
|
|
3234
3237
|
task: shouldForkAgent(contextPolicy, params.agent!) ? wrapForkTask(params.task ?? "") : (params.task ?? ""),
|
|
@@ -3664,6 +3667,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
3664
3667
|
},
|
|
3665
3668
|
);
|
|
3666
3669
|
const modelOverrideFromParent = modelOrigin === "inherited";
|
|
3670
|
+
const launchRuleError = applyWatchdogLaunchRules({ cwd: effectiveCwd, agent: agentConfig.name, model: modelOverride ?? (parentModel && `${parentModel.provider}/${parentModel.id}`), warn: (violation) => deps.watchdog?.displayRuleWarning(violation) });
|
|
3671
|
+
if (launchRuleError) return toExecutionErrorResult(params, new Error(launchRuleError), data.contextPolicy.contextSummary);
|
|
3667
3672
|
let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
|
|
3668
3673
|
let readsOverride: string[] | false | undefined = params.reads;
|
|
3669
3674
|
const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
|
|
@@ -4438,6 +4443,7 @@ export function prepareWorkflowLaunchParams(
|
|
|
4438
4443
|
if (output !== undefined && typeof output !== "string" && typeof output !== "boolean") throw new Error("output must be a path string or boolean.");
|
|
4439
4444
|
const outputMode = Object.hasOwn(childParams, "outputMode") ? childParams.outputMode : workflowDefaults.outputMode;
|
|
4440
4445
|
if (outputMode !== undefined && outputMode !== "inline" && outputMode !== "file-only") throw new Error("outputMode must be 'inline' or 'file-only'.");
|
|
4446
|
+
const control = mergeWorkflowControlOverrides(workflowDefaults.control, childParams.control as ControlConfig | undefined);
|
|
4441
4447
|
return {
|
|
4442
4448
|
action: "resume",
|
|
4443
4449
|
id: childParams.resume.trim(),
|
|
@@ -4456,14 +4462,17 @@ export function prepareWorkflowLaunchParams(
|
|
|
4456
4462
|
...(options.missionDetached ? { mission: false } : {}),
|
|
4457
4463
|
...(timeoutMs !== undefined ? { timeoutMs: timeoutMs as number } : {}),
|
|
4458
4464
|
...(toolBudget !== undefined ? { toolBudget: toolBudget as ToolBudgetConfig } : {}),
|
|
4465
|
+
...(control !== undefined ? { control } : {}),
|
|
4459
4466
|
...(intercomBridge !== undefined ? { intercomBridge: intercomBridge as IntercomBridgeConfig } : {}),
|
|
4460
4467
|
...(capabilityCeiling ? { capabilityCeiling } : {}),
|
|
4461
4468
|
};
|
|
4462
4469
|
}
|
|
4470
|
+
const control = mergeWorkflowControlOverrides(workflowDefaults.control, childParams.control as ControlConfig | undefined);
|
|
4463
4471
|
const launchParams = {
|
|
4464
4472
|
...workflowDefaults,
|
|
4465
4473
|
async: options.externalAsyncRequired === true && childParams.async === undefined && workflowDefaults.async === undefined ? true : false,
|
|
4466
4474
|
...childParams,
|
|
4475
|
+
...(control !== undefined ? { control } : {}),
|
|
4467
4476
|
...(options.externalAsyncRequired === true && childParams.async === undefined && workflowDefaults.async === undefined ? { workflowAwaitAsync: true } : {}),
|
|
4468
4477
|
...(options.missionDetached ? { mission: false } : {}),
|
|
4469
4478
|
workflowParentRunId: parentWorkflowRunId,
|
|
@@ -4482,6 +4491,17 @@ export function prepareWorkflowLaunchParams(
|
|
|
4482
4491
|
return normalizedGate.params;
|
|
4483
4492
|
}
|
|
4484
4493
|
|
|
4494
|
+
function mergeWorkflowControlOverrides(workflowControl: ControlConfig | undefined, childControl: ControlConfig | undefined): ControlConfig | undefined {
|
|
4495
|
+
if (childControl === undefined) return workflowControl;
|
|
4496
|
+
if (workflowControl === undefined) return childControl;
|
|
4497
|
+
return { ...workflowControl, ...childControl };
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
export function resolveRevivalControlConfig(input: { globalConfig?: ControlConfig; requestedControl?: ControlConfig; recoveryControlConfig?: ResolvedControlConfig }): ResolvedControlConfig {
|
|
4501
|
+
if (input.requestedControl === undefined) return input.recoveryControlConfig ?? resolveControlConfig(input.globalConfig, undefined);
|
|
4502
|
+
return resolveControlConfig(input.recoveryControlConfig ?? input.globalConfig, input.requestedControl);
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4485
4505
|
type GateParamsNormalizationResult =
|
|
4486
4506
|
| { ok: true; params: SubagentParamsLike }
|
|
4487
4507
|
| { ok: false; error: string };
|
|
@@ -21,7 +21,9 @@ import type {
|
|
|
21
21
|
ResolvedAcceptanceGate,
|
|
22
22
|
SingleResult,
|
|
23
23
|
SubagentRunMode,
|
|
24
|
+
ChildWatchdogProgress,
|
|
24
25
|
} from "../../shared/types.ts";
|
|
26
|
+
import { unresolvedChildWatchdogBlockers } from "../../watchdog/child-status.ts";
|
|
25
27
|
import { isAgentContractV1 } from "./agent-contract.ts";
|
|
26
28
|
import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
|
|
27
29
|
|
|
@@ -1360,6 +1362,7 @@ export async function evaluateAcceptance(input: {
|
|
|
1360
1362
|
reportOptional?: boolean;
|
|
1361
1363
|
artifactsDir?: string;
|
|
1362
1364
|
runId?: string;
|
|
1365
|
+
watchdog?: ChildWatchdogProgress;
|
|
1363
1366
|
}): Promise<AcceptanceLedger> {
|
|
1364
1367
|
const acceptance = input.acceptance;
|
|
1365
1368
|
const initialStatus = acceptance.level === "none" ? "not-required" : "claimed";
|
|
@@ -1375,6 +1378,13 @@ export async function evaluateAcceptance(input: {
|
|
|
1375
1378
|
};
|
|
1376
1379
|
if (acceptance.level === "none") return ledger;
|
|
1377
1380
|
|
|
1381
|
+
if (input.watchdog) {
|
|
1382
|
+
const unresolved = unresolvedChildWatchdogBlockers(input.watchdog);
|
|
1383
|
+
ledger.runtimeChecks.push(unresolved.length
|
|
1384
|
+
? { id: "watchdog-blocker", status: "failed", message: `Unresolved watchdog blocker: ${unresolved[0]!.summary}` }
|
|
1385
|
+
: { id: "watchdog-blocker", status: "passed", message: "No unresolved watchdog blockers." });
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1378
1388
|
const parsed: AcceptanceReportParseResult = input.reportError
|
|
1379
1389
|
? { error: input.reportError }
|
|
1380
1390
|
: input.report !== undefined
|
|
@@ -2,6 +2,7 @@ import { sanitizeDisplayText, truncateDisplayText } from "../../shared/display-t
|
|
|
2
2
|
import { formatModelThinking } from "../../shared/formatters.ts";
|
|
3
3
|
import type { AsyncJobState, AsyncJobStep, HostStepFreshnessV1, HostStepMonitorKind, HostStepNodeV1, HostStepState, HostStepVerdict, NestedRunSummary, NestedStepSummary, SubagentRunMode, WorkflowGraphSnapshot, WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../../shared/types.ts";
|
|
4
4
|
import { HOST_STEP_MAX_COUNT, HOST_STEP_MAX_DETAIL_CHARS, HOST_STEP_MAX_LABEL_CHARS, HOST_STEP_MAX_PROVIDER_CHARS, HOST_STEP_MAX_REASON_CHARS, HOST_STEP_MAX_REF_CHARS, HOST_STEP_MAX_ROLE_CHARS, HOST_STEP_MAX_TARGET_CHARS, hostStepReportName, parseHostStepNode, validHostStepNodes } from "./host-step-status.ts";
|
|
5
|
+
import { workflowPreflightLaneForRuntimeKey } from "../../workflows/workflow-preflight.ts";
|
|
5
6
|
import { workflowGraphStageNodes } from "./workflow-graph.ts";
|
|
6
7
|
|
|
7
8
|
export const ASYNC_STATUS_SNAPSHOT_KIND = "pi-subagents.async-status-snapshot";
|
|
@@ -472,7 +473,7 @@ function projectWorkflowGraphRow(node: WorkflowGraphSnapshot["nodes"][number], p
|
|
|
472
473
|
};
|
|
473
474
|
}
|
|
474
475
|
|
|
475
|
-
/** Project
|
|
476
|
+
/** Project authoritative workflow facts into compact rows, annotated by preflight hints. */
|
|
476
477
|
export function projectAsyncWorkflowRows(
|
|
477
478
|
steps: readonly AsyncJobStep[] | undefined,
|
|
478
479
|
hostStepsOrPreflight?: readonly HostStepNodeV1[] | WorkflowGraphSnapshot | WorkflowPreflightV1,
|
|
@@ -482,7 +483,8 @@ export function projectAsyncWorkflowRows(
|
|
|
482
483
|
const graph = isWorkflowGraph(hostStepsOrPreflight) ? hostStepsOrPreflight : undefined;
|
|
483
484
|
const hostSteps = isWorkflowPreflight(hostStepsOrPreflight) || graph ? undefined : hostStepsOrPreflight;
|
|
484
485
|
const loaded = steps ?? [];
|
|
485
|
-
const
|
|
486
|
+
const preflightForKey = (key: string, groupKeys: readonly (string | undefined)[] = []): WorkflowPreflightLaneV1 | undefined =>
|
|
487
|
+
workflowPreflightLaneForRuntimeKey(preflight, key, groupKeys);
|
|
486
488
|
if (graph) {
|
|
487
489
|
const loadedIndexesByKey = new Map<string, number[]>();
|
|
488
490
|
for (const [index, step] of loaded.entries()) {
|
|
@@ -493,7 +495,6 @@ export function projectAsyncWorkflowRows(
|
|
|
493
495
|
}
|
|
494
496
|
const consumed = new Set<number>();
|
|
495
497
|
const childRows: AsyncStatusWorkflowRow[] = [];
|
|
496
|
-
for (const lane of preflight?.lanes ?? []) declared.set(lane.key, lane);
|
|
497
498
|
const graphStages = workflowGraphStageNodes(graph);
|
|
498
499
|
const graphKeys = new Set(graphStages.map((node) => node.id));
|
|
499
500
|
const graphPhaseByNodeId = new Map<string, string>();
|
|
@@ -502,60 +503,27 @@ export function projectAsyncWorkflowRows(
|
|
|
502
503
|
if (graphKeys.has(nodeId) && !graphPhaseByNodeId.has(nodeId)) graphPhaseByNodeId.set(nodeId, phase.title);
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
|
-
const graphLaneKeys = new Set(graphPhaseByNodeId.values());
|
|
506
|
-
const preflightForNode = (node: WorkflowGraphSnapshot["nodes"][number]): WorkflowPreflightLaneV1 | undefined => {
|
|
507
|
-
const phaseTitle = graphPhaseByNodeId.get(node.id);
|
|
508
|
-
return declared.get(node.id) ?? (node.phase ? declared.get(node.phase) : undefined) ?? (phaseTitle ? declared.get(phaseTitle) : undefined);
|
|
509
|
-
};
|
|
510
506
|
for (const node of graphStages) {
|
|
507
|
+
const lane = preflightForKey(node.id, [node.phase, graphPhaseByNodeId.get(node.id)]);
|
|
511
508
|
const indexes = (loadedIndexesByKey.get(node.id) ?? []).filter((index) => !consumed.has(index));
|
|
512
|
-
if (indexes.length > 0) {
|
|
513
|
-
for (const index of indexes) {
|
|
514
|
-
consumed.add(index);
|
|
515
|
-
childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, preflightForNode(node)));
|
|
516
|
-
}
|
|
517
|
-
} else {
|
|
518
|
-
childRows.push(projectWorkflowGraphRow(node, preflightForNode(node)));
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
for (const lane of preflight?.lanes ?? []) {
|
|
522
|
-
if (graphKeys.has(lane.key) || graphLaneKeys.has(lane.key)) continue;
|
|
523
|
-
const indexes = (loadedIndexesByKey.get(lane.key) ?? []).filter((index) => !consumed.has(index));
|
|
524
509
|
if (indexes.length > 0) {
|
|
525
510
|
for (const index of indexes) {
|
|
526
511
|
consumed.add(index);
|
|
527
512
|
childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, lane));
|
|
528
513
|
}
|
|
529
514
|
} else {
|
|
530
|
-
childRows.push(
|
|
515
|
+
childRows.push(projectWorkflowGraphRow(node, lane));
|
|
531
516
|
}
|
|
532
517
|
}
|
|
533
518
|
for (const [index, step] of loaded.entries()) {
|
|
534
|
-
if (!consumed.has(index)) childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ?
|
|
519
|
+
if (!consumed.has(index)) childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ? preflightForKey(step.workflowKey, [step.phase]) : undefined));
|
|
535
520
|
}
|
|
536
521
|
return [...childRows, ...validHostStepList(graph).map(hostStepRow)];
|
|
537
522
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const consumed = new Set<number>();
|
|
543
|
-
const childRows: AsyncStatusWorkflowRow[] = [];
|
|
544
|
-
for (const lane of preflight?.lanes ?? []) {
|
|
545
|
-
declared.set(lane.key, lane);
|
|
546
|
-
const index = loadedIndexByKey.get(lane.key);
|
|
547
|
-
if (index === undefined) {
|
|
548
|
-
childRows.push({ name: lane.key, state: "planned", preflight: lane });
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
consumed.add(index);
|
|
552
|
-
childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, lane));
|
|
553
|
-
}
|
|
554
|
-
for (const [index, step] of loaded.entries()) {
|
|
555
|
-
if (consumed.has(index)) continue;
|
|
556
|
-
childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ? declared.get(step.workflowKey) : undefined));
|
|
557
|
-
}
|
|
558
|
-
return [...childRows, ...validHostStepList(hostSteps).map(hostStepRow)];
|
|
523
|
+
return [
|
|
524
|
+
...loaded.map((step, index) => projectLoadedWorkflowRow(step, index, step.workflowKey ? preflightForKey(step.workflowKey, [step.phase]) : undefined)),
|
|
525
|
+
...validHostStepList(hostSteps).map(hostStepRow),
|
|
526
|
+
];
|
|
559
527
|
}
|
|
560
528
|
|
|
561
529
|
function projectLoadedWorkflowRow(step: AsyncJobStep, index: number, preflight?: WorkflowPreflightLaneV1): AsyncStatusWorkflowRow {
|
|
@@ -47,7 +47,6 @@ import {
|
|
|
47
47
|
} from "./tool-availability.ts";
|
|
48
48
|
import {
|
|
49
49
|
CHILD_WATCHDOG_CONFIG_ENV,
|
|
50
|
-
encodeChildWatchdogConfig,
|
|
51
50
|
type ChildWatchdogConfig,
|
|
52
51
|
} from "../../watchdog/child-status.ts";
|
|
53
52
|
import { WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV, WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
|
|
@@ -1031,9 +1030,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
1031
1030
|
const encodedToolBudget = encodeToolBudgetEnv(input.toolBudget);
|
|
1032
1031
|
if (encodedToolBudget) env[TOOL_BUDGET_ENV] = encodedToolBudget;
|
|
1033
1032
|
env[TOOL_BUDGET_ZERO_AUTH_ENV] = input.allowZeroToolBudget ? "1" : undefined;
|
|
1034
|
-
|
|
1035
|
-
if (encodedChildWatchdog)
|
|
1036
|
-
env[CHILD_WATCHDOG_CONFIG_ENV] = encodedChildWatchdog;
|
|
1033
|
+
if (input.childWatchdog) env[CHILD_WATCHDOG_CONFIG_ENV] = JSON.stringify(input.childWatchdog);
|
|
1037
1034
|
|
|
1038
1035
|
env[SUBAGENT_PARENT_SESSION_ENV] =
|
|
1039
1036
|
input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
|
|
@@ -45,11 +45,12 @@ export function resolveControlConfig(
|
|
|
45
45
|
const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
|
|
46
46
|
const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
|
|
47
47
|
const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
|
|
48
|
+
const globalNeedsAttentionAfterMsIsExplicit = (globalConfig as Partial<ResolvedControlConfig> | undefined)?.needsAttentionAfterMsIsExplicit ?? globalNeedsAttentionAfterMs !== undefined;
|
|
48
49
|
const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
|
|
49
50
|
?? globalNeedsAttentionAfterMs
|
|
50
51
|
?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
|
|
51
52
|
const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
|
|
52
|
-
||
|
|
53
|
+
|| globalNeedsAttentionAfterMsIsExplicit;
|
|
53
54
|
const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
|
|
54
55
|
?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
|
|
55
56
|
?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
|
|
@@ -98,11 +99,12 @@ export function deriveActivityState(input: {
|
|
|
98
99
|
config: ResolvedControlConfig;
|
|
99
100
|
startedAt: number;
|
|
100
101
|
lastActivityAt?: number;
|
|
102
|
+
turnCount?: number;
|
|
101
103
|
currentTool?: string;
|
|
102
104
|
thinking?: string | false;
|
|
103
105
|
now?: number;
|
|
104
106
|
}): ActivityState | undefined {
|
|
105
|
-
if (!input.config.enabled || input.currentTool) return undefined;
|
|
107
|
+
if (!input.config.enabled || input.currentTool || (input.turnCount ?? 0) === 0) return undefined;
|
|
106
108
|
const now = input.now ?? Date.now();
|
|
107
109
|
const lastActivity = input.lastActivityAt ?? input.startedAt;
|
|
108
110
|
const ageMs = Math.max(0, now - lastActivity);
|
package/src/shared/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { ThinkingLevel } from "./model-info.ts";
|
|
|
15
15
|
import type { GlobalMissionIndexRecord, MissionRecord, MissionStoreConfig } from "../missions/types.ts";
|
|
16
16
|
import type { ExtensionBindings } from "../runs/shared/extension-bindings.ts";
|
|
17
17
|
import type { WorkflowChildPermitContext } from "./workflow-child-permit.ts";
|
|
18
|
+
import type { WatchdogWarningDetails } from "../watchdog/types.ts";
|
|
18
19
|
|
|
19
20
|
// ============================================================================
|
|
20
21
|
// Basic Types
|
|
@@ -894,13 +895,19 @@ export interface SubagentResultIntercomPayload {
|
|
|
894
895
|
// Progress Tracking
|
|
895
896
|
// ============================================================================
|
|
896
897
|
|
|
898
|
+
export interface ChildWatchdogWarningSummary extends Pick<WatchdogWarningDetails, "severity" | "category" | "summary" | "evidence" | "recommendedAction" | "displayedAt"> {
|
|
899
|
+
/** True when a later assistant turn in the child followed the warning. */
|
|
900
|
+
addressed: boolean;
|
|
901
|
+
stalemate: boolean;
|
|
902
|
+
}
|
|
903
|
+
|
|
897
904
|
export interface ChildWatchdogProgress {
|
|
898
|
-
phase: "idle" | "reviewing" | "
|
|
905
|
+
phase: "idle" | "reviewing" | "stale" | "failed";
|
|
899
906
|
seq: number;
|
|
900
907
|
lastUpdate: number;
|
|
901
|
-
followUpPending: boolean;
|
|
902
908
|
reason?: string;
|
|
903
909
|
timedOut?: boolean;
|
|
910
|
+
warnings?: ChildWatchdogWarningSummary[];
|
|
904
911
|
}
|
|
905
912
|
|
|
906
913
|
export interface AgentProgress {
|
package/src/tui/render.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
8
8
|
import { getMarkdownTheme, keyText, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
10
|
+
import { unresolvedChildWatchdogBlockers } from "../watchdog/child-status.ts";
|
|
10
11
|
import {
|
|
11
12
|
type AgentProgress,
|
|
12
13
|
type AsyncJobState,
|
|
@@ -360,6 +361,7 @@ function workflowStepPriority(step: AsyncJobStep, currentNodeId?: string): numbe
|
|
|
360
361
|
|| step.turnBudgetExceeded === true
|
|
361
362
|
|| step.activityState === "needs_attention"
|
|
362
363
|
|| step.watchdog?.phase === "stale"
|
|
364
|
+
|| unresolvedChildWatchdogBlockers(step.watchdog).length > 0
|
|
363
365
|
|| gate !== undefined
|
|
364
366
|
) return 1;
|
|
365
367
|
if (step.status === "pending") return 2;
|
|
@@ -505,6 +507,7 @@ function laneGate(step: AsyncJobStep | undefined): string | undefined {
|
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
function laneNextAction(state: AsyncLaneProjection["state"], step: AsyncJobStep | undefined, output: string | undefined, gate: string | undefined): string | undefined {
|
|
510
|
+
if (unresolvedChildWatchdogBlockers(step?.watchdog).length > 0) return "resolve watchdog blockers";
|
|
508
511
|
if (step?.watchdog?.phase === "stale") return "inspect stale state";
|
|
509
512
|
if (step?.toolBudgetBlocked === true || step?.turnBudgetExceeded === true) return "inspect blocked state";
|
|
510
513
|
if (gate === "review blockers") return "resolve review blockers";
|
|
@@ -543,6 +546,7 @@ export function projectAsyncLane(job: AsyncJobState, ...args: [selectedStep?: As
|
|
|
543
546
|
selectedStep?.activityState === "active_long_running" ? "long-running" : undefined,
|
|
544
547
|
selectedStep?.activityState === "needs_attention" ? "attention" : undefined,
|
|
545
548
|
selectedStep?.watchdog?.phase === "stale" ? "stale" : undefined,
|
|
549
|
+
unresolvedChildWatchdogBlockers(selectedStep?.watchdog).length > 0 ? `wd:${unresolvedChildWatchdogBlockers(selectedStep?.watchdog).length}` : undefined,
|
|
546
550
|
selectedStep?.toolBudgetBlocked === true || selectedStep?.turnBudgetExceeded === true ? "blocked" : undefined,
|
|
547
551
|
].filter((chip): chip is string => Boolean(chip));
|
|
548
552
|
const state = isTerminalLaneState(job.status) ? job.status : selectedStep?.status ?? job.status;
|
|
@@ -1001,6 +1005,7 @@ function widgetStepRenderKey(step: AsyncJobStep, index: number, expanded = false
|
|
|
1001
1005
|
step.execution?.stopped,
|
|
1002
1006
|
step.execution?.detached,
|
|
1003
1007
|
step.watchdog?.phase,
|
|
1008
|
+
unresolvedChildWatchdogBlockers(step.watchdog).length,
|
|
1004
1009
|
step.error,
|
|
1005
1010
|
expanded ? expandedStepActivityRenderKey(step) : undefined,
|
|
1006
1011
|
nestedRenderKey(step.children, expanded),
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ChildWatchdogProgress, ChildWatchdogWarningSummary } from "../shared/types.ts";
|
|
2
|
+
import { SUBAGENT_WATCHDOG_WARNING_TYPE, type ResolvedWatchdogConfig, type WatchdogCadenceConfig, type WatchdogCategory, type WatchdogLspConfig } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export const CHILD_WATCHDOG_WARNING_LIMIT = 20;
|
|
2
5
|
|
|
3
6
|
export const CHILD_WATCHDOG_CONFIG_ENV = "PI_SUBAGENT_WATCHDOG_CHILD_CONFIG";
|
|
4
7
|
export const CHILD_WATCHDOG_STATUS_EVENT = "subagent.watchdog.status";
|
|
5
8
|
|
|
6
|
-
export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "
|
|
9
|
+
export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "stale", "failed"] as const;
|
|
7
10
|
export type ChildWatchdogPhase = typeof CHILD_WATCHDOG_PHASES[number];
|
|
8
11
|
|
|
9
12
|
export interface ChildWatchdogConfig {
|
|
10
|
-
enabled: boolean;
|
|
11
13
|
runId?: string;
|
|
12
14
|
agent?: string;
|
|
13
15
|
childIndex?: number;
|
|
@@ -17,9 +19,9 @@ export interface ChildWatchdogConfig {
|
|
|
17
19
|
model?: string;
|
|
18
20
|
thinking?: string | false;
|
|
19
21
|
lsp: WatchdogLspConfig;
|
|
20
|
-
autoFollowBlockers: boolean;
|
|
21
|
-
autoFollowMaxAttempts: number | null;
|
|
22
22
|
stalemateRepeats: number;
|
|
23
|
+
/** Mid-run review cadence; everyNTools null means boundary reviews only. */
|
|
24
|
+
cadence: WatchdogCadenceConfig;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface ChildWatchdogStatusEvent {
|
|
@@ -31,18 +33,10 @@ export interface ChildWatchdogStatusEvent {
|
|
|
31
33
|
seq: number;
|
|
32
34
|
phase: ChildWatchdogPhase;
|
|
33
35
|
ts: number;
|
|
34
|
-
followUpPending: boolean;
|
|
35
36
|
reason?: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export
|
|
39
|
-
phase: ChildWatchdogPhase;
|
|
40
|
-
seq: number;
|
|
41
|
-
lastUpdate: number;
|
|
42
|
-
followUpPending: boolean;
|
|
43
|
-
reason?: string;
|
|
44
|
-
timedOut?: boolean;
|
|
45
|
-
}
|
|
39
|
+
export type ChildWatchdogStateSnapshot = ChildWatchdogProgress;
|
|
46
40
|
|
|
47
41
|
export function resolveChildWatchdogConfig(input: {
|
|
48
42
|
config: ResolvedWatchdogConfig;
|
|
@@ -55,8 +49,8 @@ export function resolveChildWatchdogConfig(input: {
|
|
|
55
49
|
if (!enabled) return undefined;
|
|
56
50
|
const model = override?.model ?? input.config.children.model;
|
|
57
51
|
const thinking = override?.thinking ?? input.config.children.thinking;
|
|
52
|
+
const cadence = override?.cadence ?? input.config.children.cadence ?? input.config.cadence;
|
|
58
53
|
return {
|
|
59
|
-
enabled: true,
|
|
60
54
|
...(input.runId ? { runId: input.runId } : {}),
|
|
61
55
|
...(input.agent ? { agent: input.agent } : {}),
|
|
62
56
|
...(input.childIndex !== undefined ? { childIndex: input.childIndex } : {}),
|
|
@@ -66,16 +60,11 @@ export function resolveChildWatchdogConfig(input: {
|
|
|
66
60
|
...(model ? { model } : {}),
|
|
67
61
|
...(thinking !== undefined ? { thinking } : {}),
|
|
68
62
|
lsp: { ...input.config.lsp },
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
stalemateRepeats: input.config.children.autoFollow.stalemateRepeats,
|
|
63
|
+
stalemateRepeats: input.config.stalemateRepeats,
|
|
64
|
+
cadence: { everyNTools: cadence.everyNTools ?? null },
|
|
72
65
|
};
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
export function encodeChildWatchdogConfig(config: ChildWatchdogConfig | undefined): string | undefined {
|
|
76
|
-
return config ? JSON.stringify(config) : undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
68
|
function childConfigObject(value: unknown, field: string): Record<string, unknown> {
|
|
80
69
|
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
81
70
|
throw new Error(`Invalid child watchdog config: ${field} must be an object.`);
|
|
@@ -108,10 +97,12 @@ function childConfigNullableNonNegativeInteger(input: Record<string, unknown>, f
|
|
|
108
97
|
throw new Error(`Invalid child watchdog config: ${field} must be null or a non-negative integer.`);
|
|
109
98
|
}
|
|
110
99
|
|
|
111
|
-
function
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
100
|
+
function childConfigCadence(value: unknown): WatchdogCadenceConfig {
|
|
101
|
+
const input = childConfigObject(value, "cadence");
|
|
102
|
+
const everyNTools = input.everyNTools;
|
|
103
|
+
if (everyNTools === null) return { everyNTools: null };
|
|
104
|
+
if (typeof everyNTools === "number" && Number.isInteger(everyNTools) && everyNTools >= 5) return { everyNTools };
|
|
105
|
+
throw new Error("Invalid child watchdog config: cadence.everyNTools must be null or an integer >= 5.");
|
|
115
106
|
}
|
|
116
107
|
|
|
117
108
|
function childConfigLsp(value: unknown): WatchdogLspConfig {
|
|
@@ -138,7 +129,7 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
|
|
|
138
129
|
if (!raw) return undefined;
|
|
139
130
|
const parsed = childConfigObject(JSON.parse(raw), "root");
|
|
140
131
|
if (parsed.enabled === false) return undefined;
|
|
141
|
-
if (parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
|
|
132
|
+
if ("enabled" in parsed && parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
|
|
142
133
|
const thinking = parsed.thinking;
|
|
143
134
|
if (thinking !== undefined && typeof thinking !== "string" && thinking !== false) {
|
|
144
135
|
throw new Error("Invalid child watchdog config: thinking must be a string or false.");
|
|
@@ -148,7 +139,6 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
|
|
|
148
139
|
const childIndex = childConfigOptionalIndex(parsed, "childIndex");
|
|
149
140
|
const model = childConfigOptionalString(parsed, "model");
|
|
150
141
|
return {
|
|
151
|
-
enabled: true,
|
|
152
142
|
...(runId ? { runId } : {}),
|
|
153
143
|
...(agent ? { agent } : {}),
|
|
154
144
|
...(childIndex !== undefined ? { childIndex } : {}),
|
|
@@ -158,9 +148,8 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
|
|
|
158
148
|
...(model ? { model } : {}),
|
|
159
149
|
...(thinking !== undefined ? { thinking: thinking as string | false } : {}),
|
|
160
150
|
lsp: childConfigLsp(parsed.lsp),
|
|
161
|
-
autoFollowBlockers: childConfigBoolean(parsed, "autoFollowBlockers"),
|
|
162
|
-
autoFollowMaxAttempts: childConfigNullableNonNegativeInteger(parsed, "autoFollowMaxAttempts"),
|
|
163
151
|
stalemateRepeats: childConfigPositiveInteger(parsed, "stalemateRepeats"),
|
|
152
|
+
cadence: childConfigCadence(parsed.cadence),
|
|
164
153
|
};
|
|
165
154
|
}
|
|
166
155
|
|
|
@@ -173,14 +162,13 @@ export function isChildWatchdogStatusEvent(value: unknown): value is ChildWatchd
|
|
|
173
162
|
&& event.seq >= 0
|
|
174
163
|
&& typeof event.ts === "number"
|
|
175
164
|
&& Number.isFinite(event.ts)
|
|
176
|
-
&& typeof event.followUpPending === "boolean"
|
|
177
165
|
&& typeof event.phase === "string"
|
|
178
166
|
&& (CHILD_WATCHDOG_PHASES as readonly string[]).includes(event.phase);
|
|
179
167
|
}
|
|
180
168
|
|
|
181
169
|
export function childWatchdogIsActive(snapshot: ChildWatchdogStateSnapshot | undefined): boolean {
|
|
182
170
|
if (!snapshot) return false;
|
|
183
|
-
return snapshot.
|
|
171
|
+
return snapshot.phase === "reviewing";
|
|
184
172
|
}
|
|
185
173
|
|
|
186
174
|
export function acceptChildWatchdogEvent(input: {
|
|
@@ -199,7 +187,40 @@ export function acceptChildWatchdogEvent(input: {
|
|
|
199
187
|
phase: input.event.phase,
|
|
200
188
|
seq: input.event.seq,
|
|
201
189
|
lastUpdate: input.event.ts,
|
|
202
|
-
followUpPending: input.event.followUpPending,
|
|
203
190
|
...(input.event.reason ? { reason: input.event.reason } : {}),
|
|
191
|
+
...(input.current?.warnings?.length ? { warnings: input.current.warnings } : {}),
|
|
204
192
|
};
|
|
205
193
|
}
|
|
194
|
+
|
|
195
|
+
function childWatchdogWarningFromMessage(message: unknown): ChildWatchdogWarningSummary | undefined {
|
|
196
|
+
const candidate = message as { role?: unknown; customType?: unknown; details?: Record<string, unknown> } | undefined;
|
|
197
|
+
if (candidate?.role !== "custom" || candidate.customType !== SUBAGENT_WATCHDOG_WARNING_TYPE) return undefined;
|
|
198
|
+
const details = candidate.details ?? {};
|
|
199
|
+
if (details.severity !== "concern" && details.severity !== "blocker") return undefined;
|
|
200
|
+
if (typeof details.summary !== "string" || typeof details.evidence !== "string" || typeof details.recommendedAction !== "string") return undefined;
|
|
201
|
+
return {
|
|
202
|
+
severity: details.severity,
|
|
203
|
+
category: details.category as WatchdogCategory,
|
|
204
|
+
summary: details.summary,
|
|
205
|
+
evidence: details.evidence,
|
|
206
|
+
recommendedAction: details.recommendedAction,
|
|
207
|
+
...(typeof details.displayedAt === "string" ? { displayedAt: details.displayedAt } : {}),
|
|
208
|
+
addressed: false,
|
|
209
|
+
stalemate: details.state === "stalemate",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A watchdog warning message is appended; an assistant turn marks earlier warnings addressed. Undefined when unchanged. */
|
|
214
|
+
export function applyChildWatchdogMessage(current: ChildWatchdogStateSnapshot | undefined, message: unknown, now = Date.now()): ChildWatchdogStateSnapshot | undefined {
|
|
215
|
+
const warning = childWatchdogWarningFromMessage(message);
|
|
216
|
+
if (warning) {
|
|
217
|
+
const warnings = [...(current?.warnings ?? []), warning].slice(-CHILD_WATCHDOG_WARNING_LIMIT);
|
|
218
|
+
return current ? { ...current, warnings } : { phase: "idle", seq: 0, lastUpdate: now, warnings };
|
|
219
|
+
}
|
|
220
|
+
if ((message as { role?: unknown } | undefined)?.role !== "assistant" || !current?.warnings?.some((entry) => !entry.addressed)) return undefined;
|
|
221
|
+
return { ...current, warnings: current.warnings.map((entry) => entry.addressed ? entry : { ...entry, addressed: true }) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function unresolvedChildWatchdogBlockers(progress: Pick<ChildWatchdogProgress, "warnings"> | undefined): ChildWatchdogWarningSummary[] {
|
|
225
|
+
return (progress?.warnings ?? []).filter((warning) => warning.severity === "blocker" && (!warning.addressed || warning.stalemate));
|
|
226
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { Type, type Static } from "typebox";
|
|
5
|
+
|
|
6
|
+
export const WATCHDOG_DIFF_TOOL_NAME = "watchdog_diff";
|
|
7
|
+
export const WATCHDOG_DIFF_MAX_CHARS = 24_000;
|
|
8
|
+
const MAX_UNTRACKED_FILES = 50;
|
|
9
|
+
|
|
10
|
+
export interface WatchdogDiffBaseline {
|
|
11
|
+
root: string;
|
|
12
|
+
ref: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WatchdogDiffParams = Type.Object({
|
|
16
|
+
path: Type.Optional(Type.String({ description: "Restrict the diff to one file or directory, relative to the repo root." })),
|
|
17
|
+
stat: Type.Optional(Type.Boolean({ description: "Return per-file change counts instead of the full diff." })),
|
|
18
|
+
}, { additionalProperties: false });
|
|
19
|
+
|
|
20
|
+
type WatchdogDiffParams = Static<typeof WatchdogDiffParams>;
|
|
21
|
+
|
|
22
|
+
function runGit(root: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
23
|
+
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf-8", maxBuffer: 16 * 1024 * 1024, windowsHide: true });
|
|
24
|
+
return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: (result.stderr ?? "").trim() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** HEAD at session start, so later child commits still show in the diff. */
|
|
28
|
+
export function captureWatchdogDiffBaseline(cwd: string): WatchdogDiffBaseline | undefined {
|
|
29
|
+
const toplevel = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
30
|
+
if (!toplevel.ok) return undefined;
|
|
31
|
+
const head = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
32
|
+
if (!head.ok) return undefined;
|
|
33
|
+
const root = toplevel.stdout.trim();
|
|
34
|
+
const ref = head.stdout.trim();
|
|
35
|
+
return root && ref ? { root, ref } : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validatePath(value: string | undefined): string | undefined {
|
|
39
|
+
const trimmed = value?.trim();
|
|
40
|
+
if (!trimmed) return undefined;
|
|
41
|
+
if (trimmed.startsWith("-")) throw new Error("watchdog_diff path must not start with '-'.");
|
|
42
|
+
if (path.isAbsolute(trimmed)) throw new Error("watchdog_diff path must be relative to the repo root.");
|
|
43
|
+
if (trimmed.split(/[\\/]/).includes("..")) throw new Error("watchdog_diff path must not contain '..'.");
|
|
44
|
+
return trimmed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bound(text: string): string {
|
|
48
|
+
if (text.length <= WATCHDOG_DIFF_MAX_CHARS) return text;
|
|
49
|
+
const marker = `\n\n[... ${text.length - WATCHDOG_DIFF_MAX_CHARS} characters omitted; call again with a narrower path ...]`;
|
|
50
|
+
return `${text.slice(0, WATCHDOG_DIFF_MAX_CHARS - marker.length)}${marker}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** In a shared cwd, changes already pending when the session started also appear. */
|
|
54
|
+
export function createWatchdogDiffTool(baseline: WatchdogDiffBaseline): AgentTool<typeof WatchdogDiffParams, { chars: number }> {
|
|
55
|
+
return {
|
|
56
|
+
name: WATCHDOG_DIFF_TOOL_NAME,
|
|
57
|
+
label: "Watchdog diff",
|
|
58
|
+
description: "Show the repository diff since the review baseline, plus untracked file paths. Optional path narrows it; stat:true returns per-file counts only.",
|
|
59
|
+
parameters: WatchdogDiffParams,
|
|
60
|
+
executionMode: "sequential",
|
|
61
|
+
async execute(_toolCallId, params: WatchdogDiffParams) {
|
|
62
|
+
const pathFilter = validatePath(params.path);
|
|
63
|
+
const diff = runGit(baseline.root, ["diff", "--no-color", "--no-ext-diff", ...(params.stat === true ? ["--stat"] : []), baseline.ref, "--", ...(pathFilter ? [pathFilter] : [])]);
|
|
64
|
+
if (!diff.ok) throw new Error(`git diff failed: ${diff.stderr || "unknown error"}`);
|
|
65
|
+
const untrackedResult = runGit(baseline.root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...(pathFilter ? [pathFilter] : [])]);
|
|
66
|
+
const untracked = untrackedResult.ok ? untrackedResult.stdout.split("\0").filter(Boolean) : [];
|
|
67
|
+
const sections = [diff.stdout.trimEnd()];
|
|
68
|
+
if (untracked.length) {
|
|
69
|
+
const shown = untracked.slice(0, MAX_UNTRACKED_FILES);
|
|
70
|
+
sections.push(["Untracked files (use read to inspect):", ...shown.map((file) => ` ${file}`)].join("\n"));
|
|
71
|
+
if (untracked.length > shown.length) sections.push(`... ${untracked.length - shown.length} more untracked files`);
|
|
72
|
+
}
|
|
73
|
+
const text = bound(sections.filter(Boolean).join("\n\n")) || `No changes since baseline ${baseline.ref.slice(0, 12)}.`;
|
|
74
|
+
return { content: [{ type: "text", text }], details: { chars: text.length } };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -88,7 +88,8 @@ export class WatchdogEmissionGuard {
|
|
|
88
88
|
this.startModelUpdate();
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/** `allowRepeatOf`: one already-accepted identity that may repeat (boundary re-findings before stalemate). */
|
|
92
|
+
evaluate(warning: WatchdogWarning, options: { allowRepeatOf?: string } = {}): WatchdogEmissionDecision {
|
|
92
93
|
if (isContentFree(warning.summary) || isContentFree(warning.evidence) || isContentFree(warning.recommendedAction)) {
|
|
93
94
|
return { accepted: false, reason: "content-free" };
|
|
94
95
|
}
|
|
@@ -102,8 +103,9 @@ export class WatchdogEmissionGuard {
|
|
|
102
103
|
&& warning.severity === "blocker";
|
|
103
104
|
if (!updateEscalation) return { accepted: false, reason: "update-budget", identity, underlyingIdentity };
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
if (
|
|
106
|
+
const repeat = priorSeverity !== undefined && options.allowRepeatOf === identity;
|
|
107
|
+
if (priorSeverity !== undefined && !escalation && !repeat) return { accepted: false, reason: "duplicate", identity, underlyingIdentity };
|
|
108
|
+
if (this.maxWarnings !== null && this.acceptedCount >= this.maxWarnings && !escalation && !repeat) {
|
|
107
109
|
return { accepted: false, reason: "max-warnings", identity, underlyingIdentity };
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
|
|
4
|
+
|
|
5
|
+
export const WATCHDOG_GUIDANCE_MAX_CHARS = 8_000;
|
|
6
|
+
|
|
7
|
+
function readOptional(filePath: string): string {
|
|
8
|
+
try {
|
|
9
|
+
return fs.readFileSync(filePath, "utf-8").trim();
|
|
10
|
+
} catch {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read fresh on every review; project file first, then user file. */
|
|
16
|
+
export function loadWatchdogGuidance(cwd: string, enabled: boolean): string {
|
|
17
|
+
if (!enabled) return "";
|
|
18
|
+
const sections = [getProjectConfigDir(cwd), getAgentDir()].map((dir) => readOptional(path.join(dir, "WATCHDOG.md"))).filter(Boolean);
|
|
19
|
+
return sections.join("\n\n").slice(0, WATCHDOG_GUIDANCE_MAX_CHARS);
|
|
20
|
+
}
|