@gajae-code/coding-agent 0.16.1 → 0.16.3
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 +11 -0
- package/dist/types/sdk/cli/rows.d.ts +7 -0
- package/dist/types/sdk/host/events.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/tools/index.d.ts +0 -2
- package/dist/types/utils/external-editor.d.ts +5 -0
- package/package.json +7 -7
- package/src/gjc-runtime/session-state-lock.ts +2 -4
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/acp/acp-agent.ts +29 -14
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +1 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +7 -0
- package/src/sdk/cli/rows.ts +47 -2
- package/src/sdk/cli/session-cli.ts +38 -17
- package/src/sdk/host/events.ts +25 -2
- package/src/sdk/host/host.ts +4 -1
- package/src/sdk/host/session-runtime.test.ts +34 -23
- package/src/sdk/host/session-runtime.ts +4 -0
- package/src/sdk/router/session-router.ts +60 -37
- package/src/sdk/session.ts +0 -1
- package/src/tools/bash.ts +15 -13
- package/src/tools/index.ts +0 -2
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/utils/external-editor.ts +16 -2
|
@@ -1699,9 +1699,13 @@ export class AcpAgent implements Agent {
|
|
|
1699
1699
|
try {
|
|
1700
1700
|
await record.adapter.ensureProviders();
|
|
1701
1701
|
} catch (error) {
|
|
1702
|
-
if (waiter.settled || record.
|
|
1702
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
1703
1703
|
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
1704
|
-
|
|
1704
|
+
return await response;
|
|
1705
|
+
}
|
|
1706
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
1707
|
+
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
1708
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
1705
1709
|
return await response;
|
|
1706
1710
|
}
|
|
1707
1711
|
if (record.activePrompt === waiter) {
|
|
@@ -1712,9 +1716,13 @@ export class AcpAgent implements Agent {
|
|
|
1712
1716
|
}
|
|
1713
1717
|
throw error;
|
|
1714
1718
|
}
|
|
1715
|
-
if (waiter.settled || record.
|
|
1719
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
1716
1720
|
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
1717
|
-
|
|
1721
|
+
return await response;
|
|
1722
|
+
}
|
|
1723
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
1724
|
+
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
1725
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
1718
1726
|
return await response;
|
|
1719
1727
|
}
|
|
1720
1728
|
|
|
@@ -1739,8 +1747,11 @@ export class AcpAgent implements Agent {
|
|
|
1739
1747
|
);
|
|
1740
1748
|
}
|
|
1741
1749
|
waiter.dispatched = true;
|
|
1742
|
-
if (waiter.settled || record.
|
|
1743
|
-
|
|
1750
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
1751
|
+
return await response;
|
|
1752
|
+
}
|
|
1753
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
1754
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
1744
1755
|
return await response;
|
|
1745
1756
|
}
|
|
1746
1757
|
|
|
@@ -1922,7 +1933,7 @@ export class AcpAgent implements Agent {
|
|
|
1922
1933
|
// return the semantically meaningful `cancelled` stop reason, so that Clients
|
|
1923
1934
|
// can reliably confirm the cancellation." Surfacing the transport's `busy`
|
|
1924
1935
|
// rejection instead would show the user a spurious error for their own cancel.
|
|
1925
|
-
if (record.cancelRequested) {
|
|
1936
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
1926
1937
|
record.cancelRequested = false;
|
|
1927
1938
|
// The client's turn is settled by the return; the advisory idle publication
|
|
1928
1939
|
// must not gate it and must still be attempted so the running phase is
|
|
@@ -2006,11 +2017,6 @@ export class AcpAgent implements Agent {
|
|
|
2006
2017
|
}
|
|
2007
2018
|
waiter?.cancelAttemptResolve?.(true);
|
|
2008
2019
|
} catch (error) {
|
|
2009
|
-
if (waiter && !waiter.dispatched) {
|
|
2010
|
-
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2011
|
-
waiter.cancelAttemptResolve?.(true);
|
|
2012
|
-
return;
|
|
2013
|
-
}
|
|
2014
2020
|
if (!waiter) record.cancelRequested = false;
|
|
2015
2021
|
// Only the LAST in-flight attempt resolves the shared promise false;
|
|
2016
2022
|
// an earlier attempt may still acknowledge (review thread P2). After
|
|
@@ -2058,7 +2064,8 @@ export class AcpAgent implements Agent {
|
|
|
2058
2064
|
|
|
2059
2065
|
async #settleCancelledPrompt(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
|
|
2060
2066
|
// The authoritative terminal wins whenever it arrives in time; this only runs
|
|
2061
|
-
// when nothing settled the prompt the
|
|
2067
|
+
// when nothing settled the prompt after the SDK acknowledged the cancellation.
|
|
2068
|
+
if (!waiter.cancelAcknowledged) return;
|
|
2062
2069
|
if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
|
|
2063
2070
|
if (waiter.terminalReserved) return;
|
|
2064
2071
|
record.activePrompt = undefined;
|
|
@@ -2896,6 +2903,14 @@ export class AcpAgent implements Agent {
|
|
|
2896
2903
|
if (reconnected) {
|
|
2897
2904
|
const waiter = record.activePrompt;
|
|
2898
2905
|
if (waiter && !waiter.settled && !waiter.terminal) {
|
|
2906
|
+
// A successful abort acknowledgement is authoritative for this prompt even
|
|
2907
|
+
// when the SDK transport changes identity during the cancellation grace.
|
|
2908
|
+
// Treating the reconnect as owner loss would replace the acknowledged
|
|
2909
|
+
// cancellation with connection_closed and leave cancellation nondeterministic.
|
|
2910
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
2911
|
+
await this.#settleCancelledPrompt(id, record, waiter);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2899
2914
|
record.activePrompt = undefined;
|
|
2900
2915
|
record.busy = record.backgroundBusy;
|
|
2901
2916
|
clearPromptWatchdog(waiter);
|
|
@@ -3113,7 +3128,7 @@ export class AcpAgent implements Agent {
|
|
|
3113
3128
|
id: string,
|
|
3114
3129
|
notification: SessionNotification,
|
|
3115
3130
|
adapter: AcpSdkAdapter,
|
|
3116
|
-
|
|
3131
|
+
_publicationGeneration: number,
|
|
3117
3132
|
): void {
|
|
3118
3133
|
const prior = this.#failureDiagnosticTails.get(id) ?? Promise.resolve();
|
|
3119
3134
|
const task = prior.then(async () => {
|
|
@@ -18,7 +18,7 @@ Executes bash command in shell session for terminal operations like git, bun, ca
|
|
|
18
18
|
{{/if}}
|
|
19
19
|
{{#if foregroundFoldEnabled}}
|
|
20
20
|
- In the interactive TUI, the user can press the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via the `app.tool.backgroundFold` keybinding) while a foreground command is still running to fold it into a background job. All three command surfaces fold: managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded command keeps its original timeout and is never killed or restarted by folding; a folded PTY continues output-only, so it stops accepting input. The turn then ends cleanly and a later turn resumes automatically with the command's result, so the interrupted task is finished rather than abandoned. `task`/`subagent` waits are deliberately NOT foldable. Do not instruct users to use raw shell `Ctrl+Z`/`bg` inside the GJC TUI; ownership and output routing are not safe there.
|
|
21
|
-
- A user steer folds the same way: when the user sends a message while a foreground command has already run for at least 2 seconds, the command is folded into a background job and this call returns the background-start result plus the line `Folded into background job <id> because a user steer arrived; the command keeps running with its original timeout and its result will wake a later turn.` The steer is then delivered at this tool boundary, so handle it next; do not re-run the folded command. Steers that arrive within the first 2 seconds let the command finish normally. This applies only while `busyPromptMode` is `steer
|
|
21
|
+
- A user steer folds the same way: when the user sends a message while a foreground command has already run for at least 2 seconds, the command is folded into a background job and this call returns the background-start result plus the line `Folded into background job <id> because a user steer arrived; the command keeps running with its original timeout and its result will wake a later turn.` The steer is then delivered at this tool boundary, so handle it next; do not re-run the folded command. Steers that arrive within the first 2 seconds let the command finish normally. This applies only while `busyPromptMode` is `steer`; `toolInterruptPolicy` is not a gate, since a fold never kills the command.
|
|
22
22
|
{{/if}}
|
|
23
23
|
</instruction>
|
|
24
24
|
{{#if restrictedAllowedPrefixes}}
|
package/src/prompts/tools/job.md
CHANGED
|
@@ -2,7 +2,7 @@ Inspects, waits, or cancels async jobs.
|
|
|
2
2
|
|
|
3
3
|
Background job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.
|
|
4
4
|
|
|
5
|
-
In the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`), or automatically when the user sends a steer after the command has run for at least 2 seconds (with `busyPromptMode=steer`
|
|
5
|
+
In the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`), or automatically when the user sends a steer after the command has run for at least 2 seconds (with `busyPromptMode=steer` (`toolInterruptPolicy` is not a gate)). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. Job listings expose `foldReason` (`steer`, `chord`, `timer`, or `sdk_control`) beside `backgrounded` so you can tell why a job left the foreground. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.
|
|
6
6
|
|
|
7
7
|
# Operations
|
|
8
8
|
|
package/src/sdk/acp/adapter.ts
CHANGED
|
@@ -875,7 +875,7 @@ export class AcpSdkAdapter {
|
|
|
875
875
|
|
|
876
876
|
#expiredOwnedLeaseFrame(frame: Record<string, unknown>): boolean {
|
|
877
877
|
const response = this.#leaseErrorFrame(frame);
|
|
878
|
-
if (
|
|
878
|
+
if (response?.ok !== false) return false;
|
|
879
879
|
const leaseId = typeof response.leaseId === "string" ? response.leaseId : "";
|
|
880
880
|
const connectionId = typeof response.connectionId === "string" ? response.connectionId : "";
|
|
881
881
|
if (!leaseId || !this.#ownsLeaseId(leaseId)) return false;
|
|
@@ -471,7 +471,14 @@ export class ChatDaemonRuntime {
|
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
async #onAttachment(attachment: SessionAttachment): Promise<void> {
|
|
474
|
+
const revivedTransport = this.#attachments.get(attachment.sessionId) === attachment;
|
|
474
475
|
this.#attachments.set(attachment.sessionId, attachment);
|
|
476
|
+
if (revivedTransport) {
|
|
477
|
+
this.#presentation?.connectSession(attachment.sessionId, {
|
|
478
|
+
sendReply: route => attachment.send({ type: "reply", id: route.actionId, answer: route.answer }),
|
|
479
|
+
});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
475
482
|
const discord = this.#discord;
|
|
476
483
|
const slack = this.#slack;
|
|
477
484
|
const barrier = (async () => {
|
package/src/sdk/cli/rows.ts
CHANGED
|
@@ -78,6 +78,43 @@ export interface SdkTailEnvelopeV1 {
|
|
|
78
78
|
items: SdkTailItemV1[];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export function tailItemKey(item: SdkTailItemV1): string {
|
|
82
|
+
// (generation, seq) restarts per revision; without the revision two turns'
|
|
83
|
+
// first frames share "1:1" and the second is silently dropped (#5200).
|
|
84
|
+
if (item.generation !== undefined && item.seq !== undefined) {
|
|
85
|
+
if (item.revision === undefined || !Number.isSafeInteger(item.revision) || item.revision < 0)
|
|
86
|
+
throw new Error("Cannot key a positioned tail item without its authoritative revision.");
|
|
87
|
+
return `${item.kind}\u0000${item.revision}\u0000${item.generation}\u0000${item.seq}`;
|
|
88
|
+
}
|
|
89
|
+
if (item.id !== undefined) return `${item.kind}\u0000${item.id}`;
|
|
90
|
+
return `${item.kind}\u0000${JSON.stringify(item.payload)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Holds positioned live frames until the checkpoint can stamp their authoritative revision. */
|
|
94
|
+
export class TailRevisionBuffer {
|
|
95
|
+
#pending: SdkTailItemV1[] = [];
|
|
96
|
+
#resolved = false;
|
|
97
|
+
|
|
98
|
+
push(item: SdkTailItemV1): SdkTailItemV1[] {
|
|
99
|
+
if (item.revision !== undefined || item.generation === undefined || item.seq === undefined) return [item];
|
|
100
|
+
if (!this.#resolved) {
|
|
101
|
+
this.#pending.push(item);
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
throw new Error("A positioned live tail item arrived without an authoritative revision after checkpoint.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
resolve(revision: number): SdkTailItemV1[] {
|
|
108
|
+
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
109
|
+
throw new Error("Tail revision must be a non-negative integer.");
|
|
110
|
+
this.#resolved = true;
|
|
111
|
+
const pending = this.#pending;
|
|
112
|
+
this.#pending = [];
|
|
113
|
+
for (const item of pending) item.revision = revision;
|
|
114
|
+
return pending;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
const SECRET_FIELD = /(?:secret|token|password|credential|authorization|api[_-]?key)/i;
|
|
82
119
|
|
|
83
120
|
/** Recursively removes secret-shaped fields (defense in depth for DTO output). */
|
|
@@ -228,12 +265,20 @@ export function toTailItemV1(
|
|
|
228
265
|
const item: SdkTailItemV1 = {
|
|
229
266
|
kind,
|
|
230
267
|
...(payloadId !== undefined ? { id: payloadId } : id !== undefined ? { id } : {}),
|
|
231
|
-
...(typeof value.revision === "number"
|
|
268
|
+
...(typeof value.revision === "number" && Number.isSafeInteger(value.revision) && value.revision >= 0
|
|
269
|
+
? { revision: value.revision }
|
|
270
|
+
: {}),
|
|
232
271
|
...(typeof value.generation === "number" ? { generation: value.generation } : {}),
|
|
233
272
|
...(typeof value.seq === "number" ? { seq: value.seq } : {}),
|
|
234
273
|
payload: stripSecretFields(payload),
|
|
235
274
|
};
|
|
236
|
-
if (
|
|
275
|
+
if (
|
|
276
|
+
fallback.revision !== undefined &&
|
|
277
|
+
Number.isSafeInteger(fallback.revision) &&
|
|
278
|
+
fallback.revision >= 0 &&
|
|
279
|
+
item.revision === undefined
|
|
280
|
+
)
|
|
281
|
+
item.revision = fallback.revision;
|
|
237
282
|
if (fallback.generation !== undefined && item.generation === undefined) item.generation = fallback.generation;
|
|
238
283
|
if (fallback.seq !== undefined && item.seq === undefined) item.seq = fallback.seq;
|
|
239
284
|
return item;
|
|
@@ -38,10 +38,13 @@ import { SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
|
|
|
38
38
|
import {
|
|
39
39
|
type SdkCheckpointRecordV1,
|
|
40
40
|
type SdkRetentionGapV1,
|
|
41
|
+
type SdkRingPositionV1,
|
|
41
42
|
type SdkSessionRowV1,
|
|
42
43
|
type SdkTailItemV1,
|
|
43
44
|
SESSION_ROWS_VERSION,
|
|
44
45
|
stripSecretFields,
|
|
46
|
+
TailRevisionBuffer,
|
|
47
|
+
tailItemKey,
|
|
45
48
|
toCheckpointRecordV1,
|
|
46
49
|
toRetentionGapV1,
|
|
47
50
|
toSessionRowV1,
|
|
@@ -985,13 +988,6 @@ function extractTranscriptPage(response: unknown): { items: unknown[]; complete:
|
|
|
985
988
|
};
|
|
986
989
|
}
|
|
987
990
|
|
|
988
|
-
function tailItemKey(item: SdkTailItemV1): string {
|
|
989
|
-
if (item.generation !== undefined && item.seq !== undefined)
|
|
990
|
-
return `${item.kind}\u0000${item.generation}\u0000${item.seq}`;
|
|
991
|
-
if (item.id !== undefined) return `${item.kind}\u0000${item.id}`;
|
|
992
|
-
return `${item.kind}\u0000${JSON.stringify(item.payload)}`;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
991
|
function mergeTailItems(
|
|
996
992
|
target: SdkTailItemV1[],
|
|
997
993
|
seen: Set<string>,
|
|
@@ -1006,8 +1002,9 @@ function mergeTailItems(
|
|
|
1006
1002
|
}
|
|
1007
1003
|
}
|
|
1008
1004
|
|
|
1009
|
-
/** Canonical ring position of an event item, when the host stated one. */
|
|
1010
|
-
type TailSeqKey =
|
|
1005
|
+
/** Canonical session-wide ring position of an event item, when the host stated one. */
|
|
1006
|
+
type TailSeqKey = SdkRingPositionV1;
|
|
1007
|
+
type TailLocalPosition = Pick<SdkRingPositionV1, "generation" | "seq">;
|
|
1011
1008
|
|
|
1012
1009
|
function isPositionCoordinate(value: unknown): value is number {
|
|
1013
1010
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
@@ -1057,7 +1054,7 @@ function classifyRawEventPosition(event: unknown): "unpositioned" | "malformed"
|
|
|
1057
1054
|
* it is never silently downgraded to unpositioned. Claiming neither coordinate
|
|
1058
1055
|
* is a genuine unpositioned event.
|
|
1059
1056
|
*/
|
|
1060
|
-
function classifyTailPosition(item: SdkTailItemV1):
|
|
1057
|
+
function classifyTailPosition(item: SdkTailItemV1): TailLocalPosition | "unpositioned" | "malformed" {
|
|
1061
1058
|
if (item.generation === undefined && item.seq === undefined) return "unpositioned";
|
|
1062
1059
|
if (!isPositionCoordinate(item.generation) || !isPositionCoordinate(item.seq)) return "malformed";
|
|
1063
1060
|
return { generation: item.generation, seq: item.seq };
|
|
@@ -1065,10 +1062,13 @@ function classifyTailPosition(item: SdkTailItemV1): TailSeqKey | "unpositioned"
|
|
|
1065
1062
|
|
|
1066
1063
|
function tailSeqKey(item: SdkTailItemV1): TailSeqKey | undefined {
|
|
1067
1064
|
const position = classifyTailPosition(item);
|
|
1068
|
-
return typeof position === "string"
|
|
1065
|
+
return typeof position === "string" || !isPositionCoordinate(item.revision)
|
|
1066
|
+
? undefined
|
|
1067
|
+
: { revision: item.revision, ...position };
|
|
1069
1068
|
}
|
|
1070
1069
|
|
|
1071
1070
|
function compareTailSeqKeys(left: TailSeqKey, right: TailSeqKey): number {
|
|
1071
|
+
if (left.revision !== right.revision) return left.revision - right.revision;
|
|
1072
1072
|
return left.generation !== right.generation ? left.generation - right.generation : left.seq - right.seq;
|
|
1073
1073
|
}
|
|
1074
1074
|
|
|
@@ -1112,10 +1112,12 @@ function mergeEventTailItems(
|
|
|
1112
1112
|
target.unpositioned.push(item);
|
|
1113
1113
|
continue;
|
|
1114
1114
|
}
|
|
1115
|
+
const orderedPosition = tailSeqKey(item);
|
|
1116
|
+
if (orderedPosition === undefined) throw new Error("A positioned tail item has no authoritative revision.");
|
|
1115
1117
|
let index = target.positioned.length;
|
|
1116
1118
|
for (; index > 0; index--) {
|
|
1117
1119
|
const previous = tailSeqKey(target.positioned[index - 1]!);
|
|
1118
|
-
if (previous === undefined || compareTailSeqKeys(previous,
|
|
1120
|
+
if (previous === undefined || compareTailSeqKeys(previous, orderedPosition) <= 0) break;
|
|
1119
1121
|
}
|
|
1120
1122
|
target.positioned.splice(index, 0, item);
|
|
1121
1123
|
}
|
|
@@ -1158,11 +1160,13 @@ function tailItemFromRouterFrame(frame: SessionRouterFrame): SdkTailItemV1 | und
|
|
|
1158
1160
|
return toTailItemV1(
|
|
1159
1161
|
{
|
|
1160
1162
|
kind: frame.name,
|
|
1163
|
+
...(frame.revision === undefined ? {} : { revision: frame.revision }),
|
|
1161
1164
|
...(seq === undefined ? {} : { generation: frame.generation, seq }),
|
|
1162
1165
|
payload: frame.body,
|
|
1163
1166
|
},
|
|
1164
1167
|
{
|
|
1165
1168
|
kind: frame.name,
|
|
1169
|
+
...(frame.revision === undefined ? {} : { revision: frame.revision }),
|
|
1166
1170
|
...(seq === undefined ? {} : { generation: frame.generation, seq }),
|
|
1167
1171
|
},
|
|
1168
1172
|
);
|
|
@@ -1406,6 +1410,7 @@ async function runLiveTail(
|
|
|
1406
1410
|
// evidence.
|
|
1407
1411
|
const seenTranscript = new Set<string>();
|
|
1408
1412
|
const seenEvents = new Set<string>();
|
|
1413
|
+
const liveRevisionBuffer = new TailRevisionBuffer();
|
|
1409
1414
|
let checkpoint: SdkCheckpointRecordV1 | undefined;
|
|
1410
1415
|
let gap: SdkRetentionGapV1 | undefined;
|
|
1411
1416
|
let liveReason: TailExitReason | undefined;
|
|
@@ -1445,7 +1450,7 @@ async function runLiveTail(
|
|
|
1445
1450
|
// close at a contested position win instead of failing closed, and
|
|
1446
1451
|
// checking it after the monotonic guard would silently skip a
|
|
1447
1452
|
// conflicting kind at an already-decided position.
|
|
1448
|
-
const positionKey = `${position.generation}\u0000${position.seq}`;
|
|
1453
|
+
const positionKey = `${position.revision}\u0000${position.generation}\u0000${position.seq}`;
|
|
1449
1454
|
const claimed = claimedLifecycleKinds.get(positionKey);
|
|
1450
1455
|
if (claimed !== undefined && claimed !== item.kind) {
|
|
1451
1456
|
malformed ??= new SdkSessionCliError(
|
|
@@ -1487,9 +1492,17 @@ async function runLiveTail(
|
|
|
1487
1492
|
if (attachment.sessionId !== sessionId) return;
|
|
1488
1493
|
const item = tailItemFromRouterFrame(frame);
|
|
1489
1494
|
if (!item) return;
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1495
|
+
try {
|
|
1496
|
+
applyLifecycle(mergeEventTailItems(eventItems, seenEvents, liveRevisionBuffer.push(item), include));
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
malformed ??= new SdkSessionCliError(
|
|
1499
|
+
"protocol_error",
|
|
1500
|
+
error instanceof Error ? error.message : "A live tail item had no authoritative revision.",
|
|
1501
|
+
1,
|
|
1502
|
+
{ sessionId, kind: item.kind, generation: item.generation, seq: item.seq },
|
|
1503
|
+
);
|
|
1504
|
+
rejectLive?.(malformed);
|
|
1505
|
+
}
|
|
1493
1506
|
};
|
|
1494
1507
|
|
|
1495
1508
|
return await withRouter(
|
|
@@ -1511,6 +1524,10 @@ async function runLiveTail(
|
|
|
1511
1524
|
const extraction = extractCheckpoint(checkpointResponse);
|
|
1512
1525
|
checkpoint = extraction.record;
|
|
1513
1526
|
gap = extraction.gap;
|
|
1527
|
+
if (checkpoint !== undefined)
|
|
1528
|
+
applyLifecycle(
|
|
1529
|
+
mergeEventTailItems(eventItems, seenEvents, liveRevisionBuffer.resolve(checkpoint.revision), include),
|
|
1530
|
+
);
|
|
1514
1531
|
if (gap !== undefined && args.strict === true)
|
|
1515
1532
|
throw new SdkSessionCliError(
|
|
1516
1533
|
"retention_gap",
|
|
@@ -1595,7 +1612,11 @@ async function runLiveTail(
|
|
|
1595
1612
|
applyLifecycle(mergeEventTailItems(eventItems, seenEvents, replayItems, include));
|
|
1596
1613
|
if (malformed !== undefined) throw malformed;
|
|
1597
1614
|
if (checkpoint !== undefined) {
|
|
1598
|
-
const checkpointKey = {
|
|
1615
|
+
const checkpointKey = {
|
|
1616
|
+
revision: checkpoint.revision,
|
|
1617
|
+
generation: checkpoint.generation,
|
|
1618
|
+
seq: checkpoint.seq,
|
|
1619
|
+
};
|
|
1599
1620
|
if (turnStateKey === undefined || compareTailSeqKeys(checkpointKey, turnStateKey) >= 0) {
|
|
1600
1621
|
turnStateKey = checkpointKey;
|
|
1601
1622
|
turnIdle = checkpoint.idle;
|
package/src/sdk/host/events.ts
CHANGED
|
@@ -32,11 +32,20 @@ export class SessionEventStream {
|
|
|
32
32
|
#frames: EventFrame[] = [];
|
|
33
33
|
readonly #ringSize: number;
|
|
34
34
|
readonly #resyncQueries: string[];
|
|
35
|
+
readonly #revisionProvider: (() => number | undefined) | undefined;
|
|
35
36
|
|
|
36
|
-
constructor(
|
|
37
|
+
constructor(
|
|
38
|
+
options: {
|
|
39
|
+
generation?: number;
|
|
40
|
+
ringSize?: number;
|
|
41
|
+
resyncQueryIds?: string[];
|
|
42
|
+
revisionProvider?: () => number | undefined;
|
|
43
|
+
} = {},
|
|
44
|
+
) {
|
|
37
45
|
this.#generation = options.generation ?? 0;
|
|
38
46
|
this.#ringSize = options.ringSize ?? 256;
|
|
39
47
|
this.#resyncQueries = options.resyncQueryIds ?? ["Q01", "Q02", "Q03"];
|
|
48
|
+
this.#revisionProvider = options.revisionProvider;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
get generation(): number {
|
|
@@ -54,7 +63,21 @@ export class SessionEventStream {
|
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
emit(frame: SdkFrame): EventFrame {
|
|
57
|
-
const
|
|
66
|
+
const suppliedRevision = frame.revision;
|
|
67
|
+
const providedRevision = this.#revisionProvider?.();
|
|
68
|
+
const revision =
|
|
69
|
+
typeof suppliedRevision === "number" && Number.isSafeInteger(suppliedRevision) && suppliedRevision >= 0
|
|
70
|
+
? suppliedRevision
|
|
71
|
+
: typeof providedRevision === "number" && Number.isSafeInteger(providedRevision) && providedRevision >= 0
|
|
72
|
+
? providedRevision
|
|
73
|
+
: undefined;
|
|
74
|
+
const event: EventFrame = {
|
|
75
|
+
...frame,
|
|
76
|
+
type: "event",
|
|
77
|
+
...(revision === undefined ? {} : { revision }),
|
|
78
|
+
generation: this.#generation,
|
|
79
|
+
seq: ++this.#seq,
|
|
80
|
+
};
|
|
58
81
|
this.#frames.push(event);
|
|
59
82
|
if (this.#frames.length > this.#ringSize) this.#frames.shift();
|
|
60
83
|
return event;
|
package/src/sdk/host/host.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type SessionActivationGate = (input: { sessionId: string; generation: num
|
|
|
47
47
|
export interface SessionSdkHostOptions extends HostEndpointAdapters {
|
|
48
48
|
control?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
|
|
49
49
|
query?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
|
|
50
|
+
/** Supplies the durable transcript revision stamped on ordinary emitted events. */
|
|
51
|
+
eventRevision?: () => number | undefined;
|
|
50
52
|
/** Test/lifecycle seam invoked synchronously before fire-and-forget dispatch. */
|
|
51
53
|
onFrameAdmitted?: (connectionId: string, frame: SdkFrame) => void;
|
|
52
54
|
/** Synchronously rejects external input while a runtime is being replaced. */
|
|
@@ -199,7 +201,7 @@ function has(frame: SdkFrame, field: string): boolean {
|
|
|
199
201
|
|
|
200
202
|
/** Adapter-based session host; bus wiring owns NotificationServer creation and transport framing. */
|
|
201
203
|
export class SessionSdkHost {
|
|
202
|
-
readonly events
|
|
204
|
+
readonly events: SessionEventStream;
|
|
203
205
|
readonly reverse: ReverseLeaseRuntime;
|
|
204
206
|
readonly #options: SessionSdkHostOptions;
|
|
205
207
|
#started = false;
|
|
@@ -214,6 +216,7 @@ export class SessionSdkHost {
|
|
|
214
216
|
|
|
215
217
|
constructor(options: SessionSdkHostOptions) {
|
|
216
218
|
this.#options = options;
|
|
219
|
+
this.events = new SessionEventStream({ revisionProvider: options.eventRevision });
|
|
217
220
|
this.reverse = new ReverseLeaseRuntime({
|
|
218
221
|
sendFrame: options.sendFrame,
|
|
219
222
|
installDefinitions: options.installProviderDefinitions,
|
|
@@ -1220,6 +1220,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1220
1220
|
const transport = memoryTransport();
|
|
1221
1221
|
const runtime = new SessionSdkSessionRuntime({
|
|
1222
1222
|
transport,
|
|
1223
|
+
eventRevision: () => 7,
|
|
1223
1224
|
control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
|
|
1224
1225
|
query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
|
|
1225
1226
|
});
|
|
@@ -1239,7 +1240,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1239
1240
|
});
|
|
1240
1241
|
transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
|
|
1241
1242
|
await Bun.sleep(0);
|
|
1242
|
-
expect(transport.broadcasts.
|
|
1243
|
+
expect(transport.broadcasts).toContainEqual(expect.objectContaining({ kind: "session_ready", revision: 7 }));
|
|
1243
1244
|
expect(transport.sent).toEqual(
|
|
1244
1245
|
expect.arrayContaining([
|
|
1245
1246
|
expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
|
|
@@ -2833,26 +2834,32 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2833
2834
|
|
|
2834
2835
|
// B's accepted receipt is redacted to {} once its submission rejects,
|
|
2835
2836
|
// so select its record by the stable clientRef instead.
|
|
2836
|
-
// Let the async rejection settle and retire B's pending entry.
|
|
2837
|
-
await Bun.sleep(50);
|
|
2838
2837
|
// The rejected submission must TERMINALIZE as failed (#4668 review
|
|
2839
2838
|
// P1): agent_failed alone is diagnostic-only, so the settlement also
|
|
2840
2839
|
// writes agent_end. Before that fix B stayed accepted forever.
|
|
2841
2840
|
{
|
|
2842
2841
|
const ids = { clientRef: "fail-b-ref" };
|
|
2843
|
-
transport.feed("conn-b", {
|
|
2844
|
-
type: "query_request",
|
|
2845
|
-
id: "fail-b-status",
|
|
2846
|
-
query: "turn.prompt_status",
|
|
2847
|
-
input: { kind: "prompt", ...ids },
|
|
2848
|
-
} as SdkFrame);
|
|
2849
|
-
// waitResponse polls control_response only; queries get query_response.
|
|
2850
2842
|
const statusDeadline = Date.now() + 15_000;
|
|
2851
|
-
|
|
2843
|
+
let status: Record<string, unknown> | undefined;
|
|
2844
|
+
for (let attempt = 0; status === undefined; attempt += 1) {
|
|
2845
|
+
const id = `fail-b-status-${attempt}`;
|
|
2846
|
+
transport.feed("conn-b", {
|
|
2847
|
+
type: "query_request",
|
|
2848
|
+
id,
|
|
2849
|
+
query: "turn.prompt_status",
|
|
2850
|
+
input: { kind: "prompt", ...ids },
|
|
2851
|
+
} as SdkFrame);
|
|
2852
|
+
while (!transport.sent.some(frame => frame.id === id)) {
|
|
2853
|
+
if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
|
|
2854
|
+
await Bun.sleep(20);
|
|
2855
|
+
}
|
|
2856
|
+
const response = transport.sent.find(frame => frame.id === id);
|
|
2857
|
+
const result = response?.result as Record<string, unknown> | undefined;
|
|
2858
|
+
if (result?.status === "failed") status = response;
|
|
2859
|
+
else await Bun.sleep(20);
|
|
2852
2860
|
if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
|
|
2853
|
-
await Bun.sleep(20);
|
|
2854
2861
|
}
|
|
2855
|
-
expect(
|
|
2862
|
+
expect(status).toMatchObject({
|
|
2856
2863
|
ok: true,
|
|
2857
2864
|
result: expect.objectContaining({ status: "failed" }),
|
|
2858
2865
|
});
|
|
@@ -4765,7 +4772,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4765
4772
|
const harness = await invocationHarness("successor-progress", cwd, {
|
|
4766
4773
|
settings: {
|
|
4767
4774
|
get: (key: string) =>
|
|
4768
|
-
key === "sdk.promptDeadlineMs" ?
|
|
4775
|
+
key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
4769
4776
|
} as unknown as Settings,
|
|
4770
4777
|
sendUserMessage: async (_content, options) => {
|
|
4771
4778
|
prompts += 1;
|
|
@@ -4790,7 +4797,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4790
4797
|
await successorStarted.promise;
|
|
4791
4798
|
for (let index = 0; index < 4; index += 1) {
|
|
4792
4799
|
await harness.emit("tool_execution_start");
|
|
4793
|
-
await Bun.sleep(
|
|
4800
|
+
await Bun.sleep(800);
|
|
4794
4801
|
}
|
|
4795
4802
|
expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
|
|
4796
4803
|
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
@@ -5553,9 +5560,13 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5553
5560
|
// renewal wiring at the SDK boundary.
|
|
5554
5561
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-renew-attached-"));
|
|
5555
5562
|
try {
|
|
5563
|
+
const renewalSettings = {
|
|
5564
|
+
get: (key: string) =>
|
|
5565
|
+
key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
5566
|
+
} as unknown as Settings;
|
|
5556
5567
|
let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
|
|
5557
5568
|
const harness = await invocationHarness("renew-attached", cwd, {
|
|
5558
|
-
settings:
|
|
5569
|
+
settings: renewalSettings,
|
|
5559
5570
|
sendUserMessage: async (content, options) => {
|
|
5560
5571
|
await options?.onPreflightAcceptCommit?.();
|
|
5561
5572
|
if (content === "consumed") {
|
|
@@ -5574,12 +5585,12 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5574
5585
|
expect(consumed.ok).toBe(true);
|
|
5575
5586
|
promoted?.({ startsOwnRun: false });
|
|
5576
5587
|
const ids = { commandId: consumed.result?.commandId, turnId: consumed.result?.turnId };
|
|
5577
|
-
// Past the
|
|
5578
|
-
// correlation alive
|
|
5579
|
-
//
|
|
5588
|
+
// Past the initial 2s lease, repeated tool activity keeps the
|
|
5589
|
+
// attached correlation alive. Each interval stays well within one
|
|
5590
|
+
// renewed lease while their cumulative duration exceeds the original.
|
|
5580
5591
|
for (let i = 0; i < 3; i += 1) {
|
|
5581
5592
|
await harness.emit("tool_execution_start");
|
|
5582
|
-
await Bun.sleep(
|
|
5593
|
+
await Bun.sleep(800);
|
|
5583
5594
|
}
|
|
5584
5595
|
const midRun = await harness.query("turn.prompt_status", ids);
|
|
5585
5596
|
expect(midRun.result?.status).not.toBe("failed");
|
|
@@ -5783,7 +5794,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5783
5794
|
const harness = await invocationHarness("dispatch-race-lease", cwd, {
|
|
5784
5795
|
settings: {
|
|
5785
5796
|
get: (key: string) =>
|
|
5786
|
-
key === "sdk.promptDeadlineMs" ?
|
|
5797
|
+
key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
5787
5798
|
} as unknown as Settings,
|
|
5788
5799
|
sendUserMessage: async (content, options) => {
|
|
5789
5800
|
await options?.onPreflightAcceptCommit?.();
|
|
@@ -5808,9 +5819,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5808
5819
|
const raced = await harness.control("turn.prompt", { text: "raced" });
|
|
5809
5820
|
expect(raced.ok).toBe(true);
|
|
5810
5821
|
const idsRaced = { commandId: raced.result?.commandId, turnId: raced.result?.turnId };
|
|
5811
|
-
// Far past the
|
|
5822
|
+
// Far past the 2s lease while queued: the acceptance-anchored lease
|
|
5812
5823
|
// was dropped at the divert disposition, so no false deadline fire.
|
|
5813
|
-
await Bun.sleep(
|
|
5824
|
+
await Bun.sleep(2_200);
|
|
5814
5825
|
const queued = await harness.query("turn.prompt_status", idsRaced);
|
|
5815
5826
|
expect(queued.result?.status).not.toBe("failed");
|
|
5816
5827
|
// Real consumption re-leases and attaches to the in-flight run.
|
|
@@ -5224,6 +5224,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
5224
5224
|
};
|
|
5225
5225
|
runtime = new SessionSdkSessionRuntime({
|
|
5226
5226
|
transport,
|
|
5227
|
+
eventRevision: () =>
|
|
5228
|
+
typeof (ctx as Partial<ExtensionContext>).getTranscript === "function"
|
|
5229
|
+
? ctx.getTranscript().length
|
|
5230
|
+
: undefined,
|
|
5227
5231
|
masterCapabilityVerify: frame =>
|
|
5228
5232
|
verifyMasterCapabilityFrame({
|
|
5229
5233
|
frame,
|