Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 96 additions & 6 deletions .agents/skills/senpi-qa/scripts/lib/mock-loop-ttsr.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { writeFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createChecks, evidenceDir, guardRealAuth, installCleanupHooks } from "./common.mjs";

Expand All @@ -20,6 +20,7 @@ function writeTtsrEvidence(slug, scenarioName, result, server) {
writeFileSync(join(dir, `${scenarioName}-stdout.txt`), `${result.stdout}\n${result.stderr}`);
writeFileSync(join(dir, `${scenarioName}-requests.json`), JSON.stringify(server.requests, null, 2));
process.stderr.write(`evidence: ${dir}\n`);
return dir;
}

const REPEATED_STATUS_TURNS = [
Expand All @@ -28,28 +29,91 @@ const REPEATED_STATUS_TURNS = [
"I read this as continue supervising the portable matrix; it has started cleanly with 3 checks green and 6 gates pending.",
];

function writeGoalMonitorFixture(box) {
const eventLogPath = join(box.dir, "goal-monitor-events.jsonl");
const extensionPath = join(box.dir, "goal-monitor-extension.mjs");
const source = `
import { appendFileSync } from "node:fs";

const eventLogPath = ${JSON.stringify(eventLogPath)};
const record = (event) => appendFileSync(eventLogPath, JSON.stringify(event) + "\\n");

export default function(pi) {
pi.on("session_start", () => {
pi.events?.emit("terminal_monitor_state", { activeCount: 1 });
record({ type: "monitor_state", activeCount: 1 });
});
pi.on("agent_end", (event) => {
record({ type: "agent_end", aborted: event.aborted, abortSource: event.abortSource });
});
pi.on("tool_result", (event) => {
if (event.toolName === "create_goal") record({ type: "goal_created" });
});
pi.events?.on("goal_continuation_scheduled", (data) => {
record({ type: "goal_continuation_scheduled", data });
});
}
`;
writeFileSync(extensionPath, source);
return { extraArgs: ["--extension", extensionPath], eventLogPath };
}

function readGoalState(box) {
const goalDir = join(box.sessionDir, "extensions", "goal");
if (!existsSync(goalDir)) return undefined;
const goalFile = readdirSync(goalDir)
.filter((name) => name.endsWith(".json"))
.map((name) => join(goalDir, name))
.at(0);
return goalFile === undefined ? undefined : JSON.parse(readFileSync(goalFile, "utf8"));
}

function readFixtureEvents(path) {
if (!existsSync(path)) return [];
return readFileSync(path, "utf8")
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line));
}

async function runRepetitiveTurnsScenario({ apiName, driveTurn, evidenceSlug, checks, guard, finalMarker, scenarioName }) {
const { box, server, result } = await driveTurn({
const { box, server, result, prepared } = await driveTurn({
apiName,
turns: [
{ text: REPEATED_STATUS_TURNS[0] },
{ toolCalls: [{ name: "create_goal", args: { objective: "Keep the live monitor wait active" } }] },
{ text: REPEATED_STATUS_TURNS[1] },
{ text: REPEATED_STATUS_TURNS[2] },
{ text: finalMarker },
],
prompt: `Report status repeatedly and finish with ${finalMarker}.`,
extraArgs: ["--approve"],
followUpPrompts: ["continue", "continue"],
followUpPrompts: ["Create a Goal and continue monitoring"],
prepareSandbox: writeGoalMonitorFixture,
timeoutMs: 180000,
});

try {
const output = `${result.stdout}\n${result.stderr}`;
const allBodies = JSON.stringify(server.requests.map((r) => r.body ?? r.raw ?? ""));
const goalState = readGoalState(box);
const fixtureEvents = readFixtureEvents(prepared.eventLogPath);
const goalCreatedIndex = fixtureEvents.findIndex((event) => event.type === "goal_created");
const systemAbortIndex = fixtureEvents.findIndex(
(event) => event.type === "agent_end" && event.aborted === true && event.abortSource === "system",
);
const recoveryIndex = fixtureEvents.findIndex(
(event, index) => index > systemAbortIndex && event.type === "agent_end" && event.abortSource === undefined,
);
const monitorScheduleIndex = fixtureEvents.findIndex(
(event, index) =>
index > goalCreatedIndex &&
event.type === "goal_continuation_scheduled" &&
event.data?.activeMonitorCount === 1,
);
checks.ok(`${scenarioName}: CLI exits zero`, result.code === 0 && !result.timedOut, `code=${result.code}`);
checks.ok(
`${scenarioName}: cross-turn repetition triggered an extra bounded turn`,
server.requests.length > 2,
server.requests.length === 4,
`requests=${server.requests.length}`,
);
checks.ok(
Expand All @@ -58,8 +122,34 @@ async function runRepetitiveTurnsScenario({ apiName, driveTurn, evidenceSlug, ch
`interruptPresent=${allBodies.includes("repetitive-turns")}`,
);
checks.ok(`${scenarioName}: recovery answer returned`, output.includes(finalMarker), `marker=${finalMarker}`);
const hiddenRuntimeError = /Agent is already processing|Extension error \([^)]*\): This extension ctx is stale/.test(output);
checks.ok(
`${scenarioName}: no hidden runtime or stale-context errors`,
!hiddenRuntimeError,
`hiddenRuntimeError=${hiddenRuntimeError}`,
);
checks.ok(
`${scenarioName}: final persisted Goal remains active`,
goalState?.goal?.status === "active",
`status=${goalState?.goal?.status ?? "missing"}`,
);
checks.ok(
`${scenarioName}: active Goal exists before the TTSR system abort`,
goalCreatedIndex >= 0 && systemAbortIndex > goalCreatedIndex,
`goalCreatedIndex=${goalCreatedIndex} systemAbortIndex=${systemAbortIndex}`,
);
checks.ok(
`${scenarioName}: TTSR system abort is followed by recovery with monitor wait live`,
recoveryIndex > systemAbortIndex &&
monitorScheduleIndex > goalCreatedIndex &&
monitorScheduleIndex < recoveryIndex,
`systemAbortIndex=${systemAbortIndex} recoveryIndex=${recoveryIndex} monitorScheduleIndex=${monitorScheduleIndex}`,
);
guard.assertUnchanged();
if (evidenceSlug) writeTtsrEvidence(evidenceSlug, scenarioName, result, server);
if (evidenceSlug) {
const dir = writeTtsrEvidence(evidenceSlug, scenarioName, result, server);
writeFileSync(join(dir, `${scenarioName}-state.json`), JSON.stringify({ goalState, fixtureEvents }, null, 2));
}
} finally {
await server.stop();
box.cleanup();
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/senpi-qa/scripts/mock-loop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ async function driveTurn({
followArgs.push(followUp);
const next = await runCli(followArgs, { env: hermeticEnv(box.env), cwd: box.cwd, timeoutMs });
combined = {
code: next.code,
code: combined.code === 0 ? next.code : combined.code,
stdout: `${combined.stdout}\n${next.stdout}`,
stderr: `${combined.stderr}\n${next.stderr}`,
timedOut: combined.timedOut || next.timedOut,
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
src/cli.ts, cli-main.ts, main.ts Bootstrap, args, mode dispatch
src/package-manager-cli.ts install/update/config subcommands (incl. `senpi update --models`)
src/core/agent-session.ts Session lifecycle and runtime
src/core/agent-abort-provenance.ts Abort ownership across retries and event dispatch
src/core/agent-settled-delivery.ts Cancellable extension messages after settlement
src/core/dynamic-prompt/ Dynamic system-prompt assembly + workstation facts
src/core/model-runtime.ts Model runtime bootstrap
src/core/model-config.ts Per-model config resolution
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@

### Fixed

- Fixed system-owned TTSR interruptions being recorded as user interruptions, which could block an active Goal even
while a background child or monitor could still resume the run. System-owned provider-error shells and consecutive
remediation generations now retain system provenance, while a late Escape cancels corrective and provider-retry
work through `agent_settled` even when it arrives after TTSR or extension dispatch. Settlement-triggered messages
are held until every handler completes and discarded on cancellation, with Goal's single-flight admission released
so `/goal resume` can recover normally. A terminal system error with no retry or monitor now launches a guarded
Goal-owned recovery after settlement instead of leaving an active Goal idle, and any guard that blocks that
recovery immediately updates accounting and the visible Goal status. Stream-rule
and Goal cache-warm status now render through one durable notice owner instead of duplicate transient and persisted UI messages
([#733](https://github.com/code-yeongyu/senpi/pull/733)).
- Fixed required compaction fatally ending a turn once the per-turn soft cap (3 accepted or ineffective
compactions) was reached. Compaction admission is now bounded only by the absolute session cap (10) and the
failure circuit breaker, so long turns that legitimately need more than three compactions keep running
Expand Down
44 changes: 44 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,47 @@
## Joined user aborts override system provenance (2026-08-05)

### What changed

- `AgentSession` now promotes an in-flight system-owned abort to user-owned when
an explicit user abort joins the same operation.
- Joining an existing abort awaits the shared promise without issuing a second
`agent.abort()` call, and a later system abort cannot downgrade user provenance.
- A later recovery generation with no active provenance issues its own
`agent.abort()` and records a fresh source instead of incorrectly joining the
prior generation's completed abort.
- User intent that arrives while `agent_end` handlers are dispatching promotes
the shared event in place. A late join that occurs after an earlier handler
already observed system provenance emits one `session_abort` before
`agent_settled`, so TTSR corrective follow-ups and provider retries admitted
before dispatch cannot outrun the user cancellation.
- The same cancellation boundary remains open through the public `agent_end`
notification, covering Escape handlers that run after extension dispatch but
before retry and settlement processing.
- The boundary now remains mutable through `agent_settled` dispatch as well.
Extension messages requested from that event are held by
`agent-settled-delivery.ts` until every handler and public listener completes;
a user abort drops the held actions before one can become a corrective
provider turn, without disturbing user-owned steering or follow-up queues.
- System-owned aborts no longer set the user-only queued-continuation suppression
latch; a user join still sets it before awaiting the shared abort.

### Why

- TTSR can begin a corrective system abort immediately before the user presses
Escape. The old early-return path kept `"system"` provenance and invoked the
underlying abort twice, so Goal could ignore the user's durable stop intent.

### Why this cannot be expressed externally

- Abort provenance, shared-promise ownership, and queued-continuation suppression
are private `AgentSession` lifecycle state.

### Expected merge conflict zones

- `core/agent-abort-provenance.ts`, `core/agent-settled-delivery.ts`, and
`core/agent-session.ts` around `_emitExtensionEvent`, `_emitAgentSettled`,
`abort`, and `_abortActiveAgentAndRetry`.

## Required-recovery admission supersession and bounded fallback sizing (2026-08-03)

### What changed
Expand Down
96 changes: 96 additions & 0 deletions packages/coding-agent/src/core/agent-abort-provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AgentEndEvent } from "./extensions/types.ts";

type AbortSource = NonNullable<AgentEndEvent["abortSource"]>;

export type JoinedAbort = {
readonly abortCurrentAgent: boolean;
readonly userOwned: boolean;
};

export class AgentAbortProvenance {
#source: AbortSource | undefined;
#agentEndEvent: AgentEndEvent | undefined;
#settlingAgentEndEvent: AgentEndEvent | undefined;
#agentEndBoundaryOpen = false;
#lateUserJoin = false;
#lateUserJoinDelivered = false;

get hasOpenAgentEndBoundary(): boolean {
return this.#agentEndBoundaryOpen || this.#agentEndEvent !== undefined;
}

begin(source: AbortSource): boolean {
this.#source = source;
this.#settlingAgentEndEvent = undefined;
this.#agentEndBoundaryOpen = false;
this.#lateUserJoin = false;
this.#lateUserJoinDelivered = false;
return source === "user";
}

join(source: AbortSource, isStreaming: boolean): JoinedAbort {
if (source === "user" && (this.#agentEndEvent !== undefined || this.#agentEndBoundaryOpen)) {
this.#source = "user";
if (!this.#lateUserJoinDelivered) this.#lateUserJoin = true;
const event = this.#agentEndEvent ?? this.#settlingAgentEndEvent;
if (event !== undefined) {
event.aborted = true;
event.abortSource = "user";
}
return { abortCurrentAgent: false, userOwned: true };
}
if (source === "user" && this.#source !== undefined) {
this.#source = "user";
return { abortCurrentAgent: false, userOwned: true };
}
if (this.#source === undefined) {
if (!isStreaming) return { abortCurrentAgent: false, userOwned: false };
this.#source = source;
return { abortCurrentAgent: true, userOwned: source === "user" };
}
return { abortCurrentAgent: false, userOwned: false };
}

beginAgentEnd(messages: AgentMessage[], willRetry: boolean, abortedWithoutSource: boolean): AgentEndEvent {
const event: AgentEndEvent = {
type: "agent_end",
messages,
willRetry,
...(this.#source !== undefined || abortedWithoutSource ? { aborted: true } : {}),
...(this.#source === undefined ? {} : { abortSource: this.#source }),
};
this.#agentEndEvent = event;
this.#settlingAgentEndEvent = undefined;
this.#agentEndBoundaryOpen = false;
this.#lateUserJoin = false;
this.#lateUserJoinDelivered = false;
return event;
}

endAgentEnd(event: AgentEndEvent): void {
if (this.#agentEndEvent === event) {
this.#agentEndEvent = undefined;
this.#settlingAgentEndEvent = event;
this.#agentEndBoundaryOpen = true;
}
this.#source = undefined;
}

takeLateUserJoin(): boolean {
const lateUserJoin = this.#lateUserJoin;
this.#lateUserJoin = false;
if (lateUserJoin) this.#lateUserJoinDelivered = true;
return lateUserJoin;
}

closeAgentEndBoundary(): void {
this.#agentEndBoundaryOpen = false;
this.#settlingAgentEndEvent = undefined;
}

joinOpenBoundary(source: AbortSource): JoinedAbort | undefined {
if (!this.#agentEndBoundaryOpen && this.#agentEndEvent === undefined) return undefined;
return this.join(source, false);
}
}
Loading