Skip to content

Commit 0cf61ee

Browse files
authored
Merge pull request #733 from code-yeongyu/fix/ttsr-goal-wait-widget-dedup
fix(coding-agent): preserve goals and dedupe stream notices
2 parents 0aa183b + 1fda259 commit 0cf61ee

40 files changed

Lines changed: 1526 additions & 291 deletions

.agents/skills/senpi-qa/scripts/lib/mock-loop-ttsr.mjs

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { writeFileSync } from "node:fs";
1+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { createChecks, evidenceDir, guardRealAuth, installCleanupHooks } from "./common.mjs";
44

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

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

32+
function writeGoalMonitorFixture(box) {
33+
const eventLogPath = join(box.dir, "goal-monitor-events.jsonl");
34+
const extensionPath = join(box.dir, "goal-monitor-extension.mjs");
35+
const source = `
36+
import { appendFileSync } from "node:fs";
37+
38+
const eventLogPath = ${JSON.stringify(eventLogPath)};
39+
const record = (event) => appendFileSync(eventLogPath, JSON.stringify(event) + "\\n");
40+
41+
export default function(pi) {
42+
pi.on("session_start", () => {
43+
pi.events?.emit("terminal_monitor_state", { activeCount: 1 });
44+
record({ type: "monitor_state", activeCount: 1 });
45+
});
46+
pi.on("agent_end", (event) => {
47+
record({ type: "agent_end", aborted: event.aborted, abortSource: event.abortSource });
48+
});
49+
pi.on("tool_result", (event) => {
50+
if (event.toolName === "create_goal") record({ type: "goal_created" });
51+
});
52+
pi.events?.on("goal_continuation_scheduled", (data) => {
53+
record({ type: "goal_continuation_scheduled", data });
54+
});
55+
}
56+
`;
57+
writeFileSync(extensionPath, source);
58+
return { extraArgs: ["--extension", extensionPath], eventLogPath };
59+
}
60+
61+
function readGoalState(box) {
62+
const goalDir = join(box.sessionDir, "extensions", "goal");
63+
if (!existsSync(goalDir)) return undefined;
64+
const goalFile = readdirSync(goalDir)
65+
.filter((name) => name.endsWith(".json"))
66+
.map((name) => join(goalDir, name))
67+
.at(0);
68+
return goalFile === undefined ? undefined : JSON.parse(readFileSync(goalFile, "utf8"));
69+
}
70+
71+
function readFixtureEvents(path) {
72+
if (!existsSync(path)) return [];
73+
return readFileSync(path, "utf8")
74+
.split(/\r?\n/)
75+
.filter(Boolean)
76+
.map((line) => JSON.parse(line));
77+
}
78+
3179
async function runRepetitiveTurnsScenario({ apiName, driveTurn, evidenceSlug, checks, guard, finalMarker, scenarioName }) {
32-
const { box, server, result } = await driveTurn({
80+
const { box, server, result, prepared } = await driveTurn({
3381
apiName,
3482
turns: [
3583
{ text: REPEATED_STATUS_TURNS[0] },
84+
{ toolCalls: [{ name: "create_goal", args: { objective: "Keep the live monitor wait active" } }] },
3685
{ text: REPEATED_STATUS_TURNS[1] },
37-
{ text: REPEATED_STATUS_TURNS[2] },
3886
{ text: finalMarker },
3987
],
4088
prompt: `Report status repeatedly and finish with ${finalMarker}.`,
4189
extraArgs: ["--approve"],
42-
followUpPrompts: ["continue", "continue"],
90+
followUpPrompts: ["Create a Goal and continue monitoring"],
91+
prepareSandbox: writeGoalMonitorFixture,
4392
timeoutMs: 180000,
4493
});
4594

4695
try {
4796
const output = `${result.stdout}\n${result.stderr}`;
4897
const allBodies = JSON.stringify(server.requests.map((r) => r.body ?? r.raw ?? ""));
98+
const goalState = readGoalState(box);
99+
const fixtureEvents = readFixtureEvents(prepared.eventLogPath);
100+
const goalCreatedIndex = fixtureEvents.findIndex((event) => event.type === "goal_created");
101+
const systemAbortIndex = fixtureEvents.findIndex(
102+
(event) => event.type === "agent_end" && event.aborted === true && event.abortSource === "system",
103+
);
104+
const recoveryIndex = fixtureEvents.findIndex(
105+
(event, index) => index > systemAbortIndex && event.type === "agent_end" && event.abortSource === undefined,
106+
);
107+
const monitorScheduleIndex = fixtureEvents.findIndex(
108+
(event, index) =>
109+
index > goalCreatedIndex &&
110+
event.type === "goal_continuation_scheduled" &&
111+
event.data?.activeMonitorCount === 1,
112+
);
49113
checks.ok(`${scenarioName}: CLI exits zero`, result.code === 0 && !result.timedOut, `code=${result.code}`);
50114
checks.ok(
51115
`${scenarioName}: cross-turn repetition triggered an extra bounded turn`,
52-
server.requests.length > 2,
116+
server.requests.length === 4,
53117
`requests=${server.requests.length}`,
54118
);
55119
checks.ok(
@@ -58,8 +122,34 @@ async function runRepetitiveTurnsScenario({ apiName, driveTurn, evidenceSlug, ch
58122
`interruptPresent=${allBodies.includes("repetitive-turns")}`,
59123
);
60124
checks.ok(`${scenarioName}: recovery answer returned`, output.includes(finalMarker), `marker=${finalMarker}`);
125+
const hiddenRuntimeError = /Agent is already processing|Extension error \([^)]*\): This extension ctx is stale/.test(output);
126+
checks.ok(
127+
`${scenarioName}: no hidden runtime or stale-context errors`,
128+
!hiddenRuntimeError,
129+
`hiddenRuntimeError=${hiddenRuntimeError}`,
130+
);
131+
checks.ok(
132+
`${scenarioName}: final persisted Goal remains active`,
133+
goalState?.goal?.status === "active",
134+
`status=${goalState?.goal?.status ?? "missing"}`,
135+
);
136+
checks.ok(
137+
`${scenarioName}: active Goal exists before the TTSR system abort`,
138+
goalCreatedIndex >= 0 && systemAbortIndex > goalCreatedIndex,
139+
`goalCreatedIndex=${goalCreatedIndex} systemAbortIndex=${systemAbortIndex}`,
140+
);
141+
checks.ok(
142+
`${scenarioName}: TTSR system abort is followed by recovery with monitor wait live`,
143+
recoveryIndex > systemAbortIndex &&
144+
monitorScheduleIndex > goalCreatedIndex &&
145+
monitorScheduleIndex < recoveryIndex,
146+
`systemAbortIndex=${systemAbortIndex} recoveryIndex=${recoveryIndex} monitorScheduleIndex=${monitorScheduleIndex}`,
147+
);
61148
guard.assertUnchanged();
62-
if (evidenceSlug) writeTtsrEvidence(evidenceSlug, scenarioName, result, server);
149+
if (evidenceSlug) {
150+
const dir = writeTtsrEvidence(evidenceSlug, scenarioName, result, server);
151+
writeFileSync(join(dir, `${scenarioName}-state.json`), JSON.stringify({ goalState, fixtureEvents }, null, 2));
152+
}
63153
} finally {
64154
await server.stop();
65155
box.cleanup();

.agents/skills/senpi-qa/scripts/mock-loop.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ async function driveTurn({
149149
followArgs.push(followUp);
150150
const next = await runCli(followArgs, { env: hermeticEnv(box.env), cwd: box.cwd, timeoutMs });
151151
combined = {
152-
code: next.code,
152+
code: combined.code === 0 ? next.code : combined.code,
153153
stdout: `${combined.stdout}\n${next.stdout}`,
154154
stderr: `${combined.stderr}\n${next.stderr}`,
155155
timedOut: combined.timedOut || next.timedOut,

packages/coding-agent/AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
src/cli.ts, cli-main.ts, main.ts Bootstrap, args, mode dispatch
99
src/package-manager-cli.ts install/update/config subcommands (incl. `senpi update --models`)
1010
src/core/agent-session.ts Session lifecycle and runtime
11+
src/core/agent-abort-provenance.ts Abort ownership across retries and event dispatch
12+
src/core/agent-settled-delivery.ts Cancellable extension messages after settlement
1113
src/core/dynamic-prompt/ Dynamic system-prompt assembly + workstation facts
1214
src/core/model-runtime.ts Model runtime bootstrap
1315
src/core/model-config.ts Per-model config resolution

packages/coding-agent/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@
1212

1313
### Fixed
1414

15+
- Fixed system-owned TTSR interruptions being recorded as user interruptions, which could block an active Goal even
16+
while a background child or monitor could still resume the run. System-owned provider-error shells and consecutive
17+
remediation generations now retain system provenance, while a late Escape cancels corrective and provider-retry
18+
work through `agent_settled` even when it arrives after TTSR or extension dispatch. Settlement-triggered messages
19+
are held until every handler completes and discarded on cancellation, with Goal's single-flight admission released
20+
so `/goal resume` can recover normally. A terminal system error with no retry or monitor now launches a guarded
21+
Goal-owned recovery after settlement instead of leaving an active Goal idle, and any guard that blocks that
22+
recovery immediately updates accounting and the visible Goal status. Stream-rule
23+
and Goal cache-warm status now render through one durable notice owner instead of duplicate transient and persisted UI messages
24+
([#733](https://github.com/code-yeongyu/senpi/pull/733)).
1525
- Fixed required compaction fatally ending a turn once the per-turn soft cap (3 accepted or ineffective
1626
compactions) was reached. Compaction admission is now bounded only by the absolute session cap (10) and the
1727
failure circuit breaker, so long turns that legitimately need more than three compactions keep running

packages/coding-agent/src/changes.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,47 @@
1+
## Joined user aborts override system provenance (2026-08-05)
2+
3+
### What changed
4+
5+
- `AgentSession` now promotes an in-flight system-owned abort to user-owned when
6+
an explicit user abort joins the same operation.
7+
- Joining an existing abort awaits the shared promise without issuing a second
8+
`agent.abort()` call, and a later system abort cannot downgrade user provenance.
9+
- A later recovery generation with no active provenance issues its own
10+
`agent.abort()` and records a fresh source instead of incorrectly joining the
11+
prior generation's completed abort.
12+
- User intent that arrives while `agent_end` handlers are dispatching promotes
13+
the shared event in place. A late join that occurs after an earlier handler
14+
already observed system provenance emits one `session_abort` before
15+
`agent_settled`, so TTSR corrective follow-ups and provider retries admitted
16+
before dispatch cannot outrun the user cancellation.
17+
- The same cancellation boundary remains open through the public `agent_end`
18+
notification, covering Escape handlers that run after extension dispatch but
19+
before retry and settlement processing.
20+
- The boundary now remains mutable through `agent_settled` dispatch as well.
21+
Extension messages requested from that event are held by
22+
`agent-settled-delivery.ts` until every handler and public listener completes;
23+
a user abort drops the held actions before one can become a corrective
24+
provider turn, without disturbing user-owned steering or follow-up queues.
25+
- System-owned aborts no longer set the user-only queued-continuation suppression
26+
latch; a user join still sets it before awaiting the shared abort.
27+
28+
### Why
29+
30+
- TTSR can begin a corrective system abort immediately before the user presses
31+
Escape. The old early-return path kept `"system"` provenance and invoked the
32+
underlying abort twice, so Goal could ignore the user's durable stop intent.
33+
34+
### Why this cannot be expressed externally
35+
36+
- Abort provenance, shared-promise ownership, and queued-continuation suppression
37+
are private `AgentSession` lifecycle state.
38+
39+
### Expected merge conflict zones
40+
41+
- `core/agent-abort-provenance.ts`, `core/agent-settled-delivery.ts`, and
42+
`core/agent-session.ts` around `_emitExtensionEvent`, `_emitAgentSettled`,
43+
`abort`, and `_abortActiveAgentAndRetry`.
44+
145
## Required-recovery admission supersession and bounded fallback sizing (2026-08-03)
246

347
### What changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
2+
import type { AgentEndEvent } from "./extensions/types.ts";
3+
4+
type AbortSource = NonNullable<AgentEndEvent["abortSource"]>;
5+
6+
export type JoinedAbort = {
7+
readonly abortCurrentAgent: boolean;
8+
readonly userOwned: boolean;
9+
};
10+
11+
export class AgentAbortProvenance {
12+
#source: AbortSource | undefined;
13+
#agentEndEvent: AgentEndEvent | undefined;
14+
#settlingAgentEndEvent: AgentEndEvent | undefined;
15+
#agentEndBoundaryOpen = false;
16+
#lateUserJoin = false;
17+
#lateUserJoinDelivered = false;
18+
19+
get hasOpenAgentEndBoundary(): boolean {
20+
return this.#agentEndBoundaryOpen || this.#agentEndEvent !== undefined;
21+
}
22+
23+
begin(source: AbortSource): boolean {
24+
this.#source = source;
25+
this.#settlingAgentEndEvent = undefined;
26+
this.#agentEndBoundaryOpen = false;
27+
this.#lateUserJoin = false;
28+
this.#lateUserJoinDelivered = false;
29+
return source === "user";
30+
}
31+
32+
join(source: AbortSource, isStreaming: boolean): JoinedAbort {
33+
if (source === "user" && (this.#agentEndEvent !== undefined || this.#agentEndBoundaryOpen)) {
34+
this.#source = "user";
35+
if (!this.#lateUserJoinDelivered) this.#lateUserJoin = true;
36+
const event = this.#agentEndEvent ?? this.#settlingAgentEndEvent;
37+
if (event !== undefined) {
38+
event.aborted = true;
39+
event.abortSource = "user";
40+
}
41+
return { abortCurrentAgent: false, userOwned: true };
42+
}
43+
if (source === "user" && this.#source !== undefined) {
44+
this.#source = "user";
45+
return { abortCurrentAgent: false, userOwned: true };
46+
}
47+
if (this.#source === undefined) {
48+
if (!isStreaming) return { abortCurrentAgent: false, userOwned: false };
49+
this.#source = source;
50+
return { abortCurrentAgent: true, userOwned: source === "user" };
51+
}
52+
return { abortCurrentAgent: false, userOwned: false };
53+
}
54+
55+
beginAgentEnd(messages: AgentMessage[], willRetry: boolean, abortedWithoutSource: boolean): AgentEndEvent {
56+
const event: AgentEndEvent = {
57+
type: "agent_end",
58+
messages,
59+
willRetry,
60+
...(this.#source !== undefined || abortedWithoutSource ? { aborted: true } : {}),
61+
...(this.#source === undefined ? {} : { abortSource: this.#source }),
62+
};
63+
this.#agentEndEvent = event;
64+
this.#settlingAgentEndEvent = undefined;
65+
this.#agentEndBoundaryOpen = false;
66+
this.#lateUserJoin = false;
67+
this.#lateUserJoinDelivered = false;
68+
return event;
69+
}
70+
71+
endAgentEnd(event: AgentEndEvent): void {
72+
if (this.#agentEndEvent === event) {
73+
this.#agentEndEvent = undefined;
74+
this.#settlingAgentEndEvent = event;
75+
this.#agentEndBoundaryOpen = true;
76+
}
77+
this.#source = undefined;
78+
}
79+
80+
takeLateUserJoin(): boolean {
81+
const lateUserJoin = this.#lateUserJoin;
82+
this.#lateUserJoin = false;
83+
if (lateUserJoin) this.#lateUserJoinDelivered = true;
84+
return lateUserJoin;
85+
}
86+
87+
closeAgentEndBoundary(): void {
88+
this.#agentEndBoundaryOpen = false;
89+
this.#settlingAgentEndEvent = undefined;
90+
}
91+
92+
joinOpenBoundary(source: AbortSource): JoinedAbort | undefined {
93+
if (!this.#agentEndBoundaryOpen && this.#agentEndEvent === undefined) return undefined;
94+
return this.join(source, false);
95+
}
96+
}

0 commit comments

Comments
 (0)