sortie-dogs 0.3.21 → 0.4.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/README.md CHANGED
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
21
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.3.21](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.21)
23
+ Release: [v0.4.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.4.0)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.6-card43";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.7-fast-lane-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.6-card43";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.7-fast-lane-v1";
@@ -0,0 +1,21 @@
1
+ export declare const BACKLOG_DRAIN_CAPABILITY = "sortie_enable_backlog_drain";
2
+ export type FastLaneDenialCode = "TURN_STATE_REQUIRED" | "ROLE_FORBIDDEN" | "WORKER_LIMIT" | "SCOUT_GAP_REQUIRED" | "SCOUT_LIMIT" | "SCOUT_TOO_LATE" | "REVIEW_EVIDENCE_REQUIRED" | "REVIEW_PHASE_INVALID" | "REVIEW_LIMIT" | "ADVISOR_TRIGGER_REQUIRED" | "ADVISOR_LIMIT" | "CONSULTATION_RETRY_INVALID" | "CONSULTATION_RETRY_UNAUTHORIZED" | "CONSULTATION_RETRY_MISMATCH" | "BACKLOG_DRAIN_INVALID" | "BACKLOG_DRAIN_TOO_LATE" | "MANUAL_COMPACTION_FORBIDDEN";
3
+ export declare class FastLaneDeniedError extends Error {
4
+ readonly code: FastLaneDenialCode;
5
+ constructor(code: FastLaneDenialCode);
6
+ }
7
+ export interface FastLaneToolOptions {
8
+ readonly consultationFallbackAuthorized?: boolean;
9
+ }
10
+ export declare class FastLaneController {
11
+ private readonly sessions;
12
+ private setSession;
13
+ beginTurn(sessionID: string, synthetic: boolean): void;
14
+ forget(sessionID: string): void;
15
+ manualCompactionForbidden(sessionID: string): boolean;
16
+ backlogDrainEnabled(sessionID: string): boolean;
17
+ backlogContinuationAllowed(sessionID: string): boolean;
18
+ enableBacklogDrain(sessionID: string, maxUnits: number): void;
19
+ continuationQueued(sessionID: string): void;
20
+ beforeTool(sessionID: string, tool: string, args: unknown, options?: FastLaneToolOptions): void;
21
+ }
@@ -0,0 +1,230 @@
1
+ import { isSourceReviewRiskTag, STRATEGY_TRIGGERS } from "../core/consultation.js";
2
+ const MAX_SESSIONS = 256;
3
+ const GAP_CODES = new Set(["manifest", "validation", "owner-risk"]);
4
+ const STRATEGY_TRIGGER_SET = new Set(STRATEGY_TRIGGERS);
5
+ const MANUAL_COMPACTION_TOOLS = new Set(["compact_and_continue", "sortie_compact_and_continue"]);
6
+ export const BACKLOG_DRAIN_CAPABILITY = "sortie_enable_backlog_drain";
7
+ export class FastLaneDeniedError extends Error {
8
+ code;
9
+ constructor(code) {
10
+ super(`SORTIE_FAST_LANE_DENIED: ${code}`);
11
+ this.name = "FastLaneDeniedError";
12
+ this.code = code;
13
+ }
14
+ }
15
+ function taskArgument(args, key) {
16
+ if (args === null || typeof args !== "object" || Array.isArray(args))
17
+ return undefined;
18
+ const value = args[key];
19
+ return typeof value === "string" ? value : undefined;
20
+ }
21
+ function lineValue(prompt, key) {
22
+ const values = [...prompt.matchAll(new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, "gmu"))];
23
+ return values.length === 1 ? values[0][1] : undefined;
24
+ }
25
+ function hasReviewEvidence(prompt) {
26
+ if (lineValue(prompt, "canonical_validation_exit") !== "0")
27
+ return false;
28
+ const rawTags = lineValue(prompt, "risk_tags");
29
+ if (rawTags === undefined || !/^\[[^\[\]]+\]$/u.test(rawTags))
30
+ return false;
31
+ const tags = rawTags.slice(1, -1).split(",").map((tag) => tag.trim());
32
+ return tags.length > 0 && tags.every((tag) => tag.length > 0) &&
33
+ new Set(tags).size === tags.length && tags.every(isSourceReviewRiskTag);
34
+ }
35
+ function freshState() {
36
+ return {
37
+ advisorDispatches: 0,
38
+ backlogDrain: false,
39
+ continuationPending: false,
40
+ initialReviewDispatches: 0,
41
+ verificationReviewDispatches: 0,
42
+ scoutDispatches: 0,
43
+ totalWorkerDispatches: 0,
44
+ workerLimit: 1,
45
+ workerDispatches: 0,
46
+ };
47
+ }
48
+ function lockedState() {
49
+ return {
50
+ advisorDispatches: 2,
51
+ backlogDrain: false,
52
+ continuationPending: false,
53
+ initialReviewDispatches: 2,
54
+ verificationReviewDispatches: 2,
55
+ scoutDispatches: 1,
56
+ totalWorkerDispatches: 1,
57
+ workerLimit: 1,
58
+ workerDispatches: 1,
59
+ };
60
+ }
61
+ function fallbackBasis(prompt) {
62
+ return prompt.replace(/\r\n?/gu, "\n")
63
+ .split("\n")
64
+ .filter((line) => !/^\s*fallback_retry\s*:\s*true\s*$/u.test(line))
65
+ .join("\n")
66
+ .trimEnd();
67
+ }
68
+ export class FastLaneController {
69
+ sessions = new Map();
70
+ setSession(sessionID, state) {
71
+ this.sessions.delete(sessionID);
72
+ this.sessions.set(sessionID, state);
73
+ while (this.sessions.size > MAX_SESSIONS)
74
+ this.sessions.delete(this.sessions.keys().next().value);
75
+ }
76
+ beginTurn(sessionID, synthetic) {
77
+ if (synthetic) {
78
+ const state = this.sessions.get(sessionID);
79
+ if (state === undefined) {
80
+ this.setSession(sessionID, lockedState());
81
+ }
82
+ else if (state.backlogDrain && state.continuationPending) {
83
+ state.advisorDispatches = 0;
84
+ delete state.advisorPrompt;
85
+ state.continuationPending = false;
86
+ state.initialReviewDispatches = 0;
87
+ delete state.initialReviewPrompt;
88
+ state.verificationReviewDispatches = 0;
89
+ delete state.verificationReviewPrompt;
90
+ state.scoutDispatches = 0;
91
+ state.workerDispatches = 0;
92
+ }
93
+ return;
94
+ }
95
+ this.setSession(sessionID, freshState());
96
+ }
97
+ forget(sessionID) {
98
+ this.sessions.delete(sessionID);
99
+ }
100
+ manualCompactionForbidden(sessionID) {
101
+ const state = this.sessions.get(sessionID);
102
+ return state !== undefined && state.workerDispatches > 0 &&
103
+ (!state.backlogDrain || state.totalWorkerDispatches >= state.workerLimit);
104
+ }
105
+ backlogDrainEnabled(sessionID) {
106
+ return this.sessions.get(sessionID)?.backlogDrain === true;
107
+ }
108
+ backlogContinuationAllowed(sessionID) {
109
+ const state = this.sessions.get(sessionID);
110
+ return state?.backlogDrain === true && state.workerDispatches === 1 &&
111
+ state.totalWorkerDispatches < state.workerLimit && !state.continuationPending;
112
+ }
113
+ enableBacklogDrain(sessionID, maxUnits) {
114
+ const state = this.sessions.get(sessionID);
115
+ if (state === undefined)
116
+ throw new FastLaneDeniedError("TURN_STATE_REQUIRED");
117
+ if (!Number.isInteger(maxUnits) || maxUnits < 4 || maxUnits > 11) {
118
+ throw new FastLaneDeniedError("BACKLOG_DRAIN_INVALID");
119
+ }
120
+ if (state.backlogDrain || state.totalWorkerDispatches > 0) {
121
+ throw new FastLaneDeniedError("BACKLOG_DRAIN_TOO_LATE");
122
+ }
123
+ state.backlogDrain = true;
124
+ state.workerLimit = maxUnits;
125
+ }
126
+ continuationQueued(sessionID) {
127
+ const state = this.sessions.get(sessionID);
128
+ if (this.backlogContinuationAllowed(sessionID) && state !== undefined) {
129
+ state.continuationPending = true;
130
+ }
131
+ }
132
+ beforeTool(sessionID, tool, args, options = {}) {
133
+ const state = this.sessions.get(sessionID);
134
+ if (state === undefined) {
135
+ if (tool === "task" || MANUAL_COMPACTION_TOOLS.has(tool)) {
136
+ throw new FastLaneDeniedError("TURN_STATE_REQUIRED");
137
+ }
138
+ return;
139
+ }
140
+ if (MANUAL_COMPACTION_TOOLS.has(tool)) {
141
+ if (!this.backlogContinuationAllowed(sessionID)) {
142
+ throw new FastLaneDeniedError("MANUAL_COMPACTION_FORBIDDEN");
143
+ }
144
+ return;
145
+ }
146
+ if (tool !== "task")
147
+ return;
148
+ const role = taskArgument(args, "subagent_type");
149
+ const prompt = taskArgument(args, "prompt") ?? "";
150
+ if (role === "dog-worker") {
151
+ if (state.workerDispatches >= 1 || state.totalWorkerDispatches >= state.workerLimit) {
152
+ throw new FastLaneDeniedError("WORKER_LIMIT");
153
+ }
154
+ state.workerDispatches += 1;
155
+ state.totalWorkerDispatches += 1;
156
+ return;
157
+ }
158
+ if (role === "dog-scout") {
159
+ if (state.workerDispatches > 0)
160
+ throw new FastLaneDeniedError("SCOUT_TOO_LATE");
161
+ const gap = lineValue(prompt, "missing_evidence_code");
162
+ if (gap === undefined || !GAP_CODES.has(gap))
163
+ throw new FastLaneDeniedError("SCOUT_GAP_REQUIRED");
164
+ if (state.scoutDispatches >= 1)
165
+ throw new FastLaneDeniedError("SCOUT_LIMIT");
166
+ state.scoutDispatches += 1;
167
+ return;
168
+ }
169
+ if (role === "dog-reviewer") {
170
+ if (!hasReviewEvidence(prompt))
171
+ throw new FastLaneDeniedError("REVIEW_EVIDENCE_REQUIRED");
172
+ const phase = lineValue(prompt, "review_phase");
173
+ if (phase !== "initial" && phase !== "verification") {
174
+ throw new FastLaneDeniedError("REVIEW_PHASE_INVALID");
175
+ }
176
+ const retry = lineValue(prompt, "fallback_retry");
177
+ const count = phase === "initial" ? state.initialReviewDispatches : state.verificationReviewDispatches;
178
+ const previousPrompt = phase === "initial" ? state.initialReviewPrompt : state.verificationReviewPrompt;
179
+ if (phase === "initial" && state.verificationReviewDispatches > 0) {
180
+ throw new FastLaneDeniedError("REVIEW_PHASE_INVALID");
181
+ }
182
+ if (phase === "verification" && state.initialReviewDispatches === 0) {
183
+ throw new FastLaneDeniedError("REVIEW_PHASE_INVALID");
184
+ }
185
+ if ((count === 0 && retry !== undefined) || (count === 1 && retry !== "true")) {
186
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_INVALID");
187
+ }
188
+ if (count === 1 && options.consultationFallbackAuthorized !== true) {
189
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_UNAUTHORIZED");
190
+ }
191
+ if (count === 1 && previousPrompt !== fallbackBasis(prompt)) {
192
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_MISMATCH");
193
+ }
194
+ if (count >= 2)
195
+ throw new FastLaneDeniedError("REVIEW_LIMIT");
196
+ if (phase === "initial") {
197
+ state.initialReviewPrompt ??= fallbackBasis(prompt);
198
+ state.initialReviewDispatches += 1;
199
+ }
200
+ else {
201
+ state.verificationReviewPrompt ??= fallbackBasis(prompt);
202
+ state.verificationReviewDispatches += 1;
203
+ }
204
+ return;
205
+ }
206
+ if (role === "dog-advisor") {
207
+ const trigger = lineValue(prompt, "strategy_trigger");
208
+ if (trigger === undefined || !STRATEGY_TRIGGER_SET.has(trigger)) {
209
+ throw new FastLaneDeniedError("ADVISOR_TRIGGER_REQUIRED");
210
+ }
211
+ const retry = lineValue(prompt, "fallback_retry");
212
+ if ((state.advisorDispatches === 0 && retry !== undefined) ||
213
+ (state.advisorDispatches === 1 && retry !== "true")) {
214
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_INVALID");
215
+ }
216
+ if (state.advisorDispatches === 1 && options.consultationFallbackAuthorized !== true) {
217
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_UNAUTHORIZED");
218
+ }
219
+ if (state.advisorDispatches === 1 && state.advisorPrompt !== fallbackBasis(prompt)) {
220
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_MISMATCH");
221
+ }
222
+ if (state.advisorDispatches >= 2)
223
+ throw new FastLaneDeniedError("ADVISOR_LIMIT");
224
+ state.advisorPrompt ??= fallbackBasis(prompt);
225
+ state.advisorDispatches += 1;
226
+ return;
227
+ }
228
+ throw new FastLaneDeniedError("ROLE_FORBIDDEN");
229
+ }
230
+ }
@@ -6,10 +6,11 @@ import { normalizeRelativePath, RelativePathError } from "../core/path.js";
6
6
  import { validateManifest } from "../core/validate-manifest.js";
7
7
  import { safeSchemaPointer, validateHandoffSchema, validateOperationManifestSchema, } from "../core/validate-schema.js";
8
8
  import { DEFAULT_PLUGIN_OPTIONS, resolvePluginConfiguration, resolvePluginConfigurationSourcesWithGlobal, } from "./config.js";
9
- import { CONTINUATION_CAPABILITY, createContinuationHooks, } from "./continuation.js";
9
+ import { CONTINUATION_CAPABILITY, CONTINUATION_MARKER, ROLLOVER_MARKER, createContinuationHooks, } from "./continuation.js";
10
10
  import { WriteDeniedError, canonicalManifestReadScopes, canonicalManifestWriteScopes, createProjectPaths, createWriteGate, describeUnclassifiedCommand, isGitMutation, isKnownReadOnlyTool, isRemoteMutation, normalizeCommand, resolveProjectRoot, safePath, writeScopesOverlap, } from "./gate.js";
11
+ import { BACKLOG_DRAIN_CAPABILITY, FastLaneController } from "./fast-lane.js";
11
12
  import { createModelRoutingHook, } from "./model-routing-hook.js";
12
- import { createTaskResultRepairHook, markConsultationFallbackRetry, } from "./task-result-repair.js";
13
+ import { createTaskResultRepairHook, markConsultationFallbackRetry, taskChildSessionID, } from "./task-result-repair.js";
13
14
  import { configRoot, nearestPackageVersion, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
14
15
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024 };
15
16
  const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
@@ -592,6 +593,7 @@ export const SortieDogsPlugin = async (input, options) => {
592
593
  const bindingOperations = new Set();
593
594
  const activeSessions = new Map();
594
595
  const coordinatorRoots = new Map();
596
+ const coordinatorTaskCalls = new Map();
595
597
  const reflectionOwnedRoots = new Set();
596
598
  const reflectionClosingRoots = new Set();
597
599
  const reflectionInFlight = new Map();
@@ -602,6 +604,34 @@ export const SortieDogsPlugin = async (input, options) => {
602
604
  const sessionRoots = new Map();
603
605
  const consultationRetries = new Map();
604
606
  const taskResultRepair = createTaskResultRepairHook(input.client);
607
+ const fastLane = new FastLaneController();
608
+ function beginCoordinatorTask(sessionID, callID) {
609
+ const calls = coordinatorTaskCalls.get(sessionID) ?? new Set();
610
+ calls.add(callID);
611
+ coordinatorTaskCalls.set(sessionID, calls);
612
+ }
613
+ function finishCoordinatorTask(sessionID, callID) {
614
+ if (sessionID === undefined || callID === undefined)
615
+ return;
616
+ const calls = coordinatorTaskCalls.get(sessionID);
617
+ if (calls === undefined)
618
+ return;
619
+ calls.delete(callID);
620
+ if (calls.size === 0)
621
+ coordinatorTaskCalls.delete(sessionID);
622
+ }
623
+ function childHasInFlightParentTask(sessionID) {
624
+ const parentID = sessionParents.get(sessionID);
625
+ return parentID !== undefined && (coordinatorTaskCalls.get(parentID)?.size ?? 0) > 0;
626
+ }
627
+ function abortCoordinatorTasks(sessionID) {
628
+ if (!coordinatorTaskCalls.delete(sessionID))
629
+ return;
630
+ for (const [childID, parentID] of [...sessionParents]) {
631
+ if (parentID === sessionID)
632
+ evictSession(childID);
633
+ }
634
+ }
605
635
  function hasSessionEnforcementState(sessionID) {
606
636
  return sessionAuthorizations.has(sessionID) || bindingPins.has(sessionID);
607
637
  }
@@ -1465,7 +1495,20 @@ export const SortieDogsPlugin = async (input, options) => {
1465
1495
  async execute(_args, context) {
1466
1496
  // A configuration failure must not remove the loop; the shipped default still resolves.
1467
1497
  await ensureLoaded().catch(() => undefined);
1468
- return await continuation.tool.execute({}, context);
1498
+ const result = await continuation.tool.execute({}, context);
1499
+ if (result === "SORTIE_COMPACT_AND_CONTINUE_QUEUED") {
1500
+ fastLane.continuationQueued(context.sessionID);
1501
+ }
1502
+ return result;
1503
+ },
1504
+ }),
1505
+ [BACKLOG_DRAIN_CAPABILITY]: defineTool({
1506
+ description: "Enable one explicit bounded backlog drain before its first worker dispatch.",
1507
+ args: { max_units: defineTool.schema.string() },
1508
+ async execute(args, context) {
1509
+ const maxUnits = Number(args.max_units);
1510
+ fastLane.enableBacklogDrain(context.sessionID, maxUnits);
1511
+ return JSON.stringify({ status: "enabled", max_units: maxUnits });
1469
1512
  },
1470
1513
  }),
1471
1514
  ...(reflectionStartup ? {
@@ -1506,7 +1549,19 @@ export const SortieDogsPlugin = async (input, options) => {
1506
1549
  } : {}),
1507
1550
  },
1508
1551
  "experimental.text.complete": async (textInput, textOutput) => {
1552
+ const backlogMarker = fastLane.backlogContinuationAllowed(textInput.sessionID) &&
1553
+ textOutput.text.includes(CONTINUATION_MARKER);
1554
+ if (fastLane.manualCompactionForbidden(textInput.sessionID)) {
1555
+ textOutput.text = textOutput.text
1556
+ .replaceAll(ROLLOVER_MARKER, "")
1557
+ .replaceAll(CONTINUATION_MARKER, "")
1558
+ .trimEnd();
1559
+ return;
1560
+ }
1509
1561
  await continuation.textComplete(textInput, textOutput);
1562
+ if (backlogMarker && continuation.blocksTool(textInput.sessionID)) {
1563
+ fastLane.continuationQueued(textInput.sessionID);
1564
+ }
1510
1565
  },
1511
1566
  "experimental.session.compacting": async (compactInput, compactOutput) => {
1512
1567
  await continuation.sessionCompacting(compactInput, compactOutput);
@@ -1516,10 +1571,12 @@ export const SortieDogsPlugin = async (input, options) => {
1516
1571
  },
1517
1572
  "chat.message": async (chatInput, output) => {
1518
1573
  const parentID = chatParentID(chatInput);
1574
+ const synthetic = output.parts.some((part) => isRecord(part) && part.synthetic === true);
1519
1575
  if (parentID !== undefined)
1520
1576
  rememberParent(chatInput.sessionID, parentID);
1521
1577
  const coordinatorOrigin = chatInput.agent === COORDINATOR_AGENT || output.message.agent === COORDINATOR_AGENT;
1522
1578
  if (coordinatorOrigin) {
1579
+ fastLane.beginTurn(chatInput.sessionID, synthetic);
1523
1580
  releaseSessionEnforcement(chatInput.sessionID);
1524
1581
  await rememberCoordinatorRoot(chatInput.sessionID);
1525
1582
  }
@@ -1566,31 +1623,36 @@ export const SortieDogsPlugin = async (input, options) => {
1566
1623
  }
1567
1624
  }
1568
1625
  if (coordinatorOrigin) {
1569
- const synthetic = output.parts.some((part) => isRecord(part) && part.synthetic === true);
1570
1626
  continuation.observeModel(chatInput.sessionID, output.message.model, synthetic);
1571
1627
  }
1572
1628
  },
1573
- ...(reflectionStartup ? { "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1574
- if (!(await beginReflection(transformInput.sessionID)))
1629
+ "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1630
+ if (fastLane.manualCompactionForbidden(transformInput.sessionID)) {
1631
+ transformOutput.system = [...(transformOutput.system ?? []),
1632
+ "SORTIE_FAST_LANE_TERMINAL\nA worker was already dispatched in this normal lane. " +
1633
+ "Do not call a compaction capability or emit a continuation marker. " +
1634
+ "After any required risk-based review or coordinator-owned finalization, return the terminal report and stop."];
1635
+ }
1636
+ if (!reflectionStartup || !(await beginReflection(transformInput.sessionID)))
1637
+ return;
1638
+ const config = reflectionConfiguration;
1639
+ try {
1640
+ if (!config)
1575
1641
  return;
1576
- const config = reflectionConfiguration;
1577
- try {
1578
- if (!config)
1579
- return;
1580
- const heading = "SORTIE_PROCESS_REFLECTIONS";
1581
- const buckets = ["run", "project", "global"]
1582
- .filter((layer) => config.layers[layer])
1583
- .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
1584
- const budget = Math.max(0, config.maxInjectedTokens - Buffer.byteLength(`${heading}\n`, "utf8"));
1585
- const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, budget, reflectionVersion);
1586
- if (text)
1587
- transformOutput.system = [...(transformOutput.system ?? []), `${heading}\n${text}`];
1588
- }
1589
- catch { /* reflection is strictly non-invasive */ }
1590
- finally {
1591
- endReflection(transformInput.sessionID);
1592
- }
1593
- } } : {}),
1642
+ const heading = "SORTIE_PROCESS_REFLECTIONS";
1643
+ const buckets = ["run", "project", "global"]
1644
+ .filter((layer) => config.layers[layer])
1645
+ .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
1646
+ const budget = Math.max(0, config.maxInjectedTokens - Buffer.byteLength(`${heading}\n`, "utf8"));
1647
+ const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, budget, reflectionVersion);
1648
+ if (text)
1649
+ transformOutput.system = [...(transformOutput.system ?? []), `${heading}\n${text}`];
1650
+ }
1651
+ catch { /* reflection is strictly non-invasive */ }
1652
+ finally {
1653
+ endReflection(transformInput.sessionID);
1654
+ }
1655
+ },
1594
1656
  "permission.ask": async (permission) => {
1595
1657
  if (permission.permission !== "edit")
1596
1658
  return;
@@ -1626,6 +1688,7 @@ export const SortieDogsPlugin = async (input, options) => {
1626
1688
  * erases an answer the worker already produced and the coordinator re-dispatches the same work.
1627
1689
  */
1628
1690
  "tool.execute.after": async (toolInput, output) => {
1691
+ const completedChildSessionID = toolInput.tool === "task" ? taskChildSessionID(output) : undefined;
1629
1692
  try {
1630
1693
  const repair = await taskResultRepair(toolInput, output);
1631
1694
  if (repair.kind === "unrecoverable-empty" && toolInput.sessionID !== undefined) {
@@ -1643,6 +1706,10 @@ export const SortieDogsPlugin = async (input, options) => {
1643
1706
  }
1644
1707
  finally {
1645
1708
  activeSessions.get(toolInput.sessionID ?? "")?.inFlightCalls.delete(toolInput.callID ?? "");
1709
+ if (toolInput.tool === "task")
1710
+ finishCoordinatorTask(toolInput.sessionID, toolInput.callID);
1711
+ if (completedChildSessionID !== undefined)
1712
+ evictSession(completedChildSessionID);
1646
1713
  }
1647
1714
  },
1648
1715
  "tool.execute.before": async (toolInput, output) => {
@@ -1650,6 +1717,18 @@ export const SortieDogsPlugin = async (input, options) => {
1650
1717
  if (continuation.blocksTool(toolInput.sessionID)) {
1651
1718
  throw new Error("SORTIE_ROLLOVER_PENDING: stop this turn and wait for compaction");
1652
1719
  }
1720
+ const taskRole = isRecord(output.args) && typeof output.args.subagent_type === "string"
1721
+ ? output.args.subagent_type
1722
+ : undefined;
1723
+ const role = consultationAgent(taskRole);
1724
+ const consultationFallbackAuthorized = role !== undefined &&
1725
+ consultationRetries.get(consultationRetryKey(toolInput.sessionID, role))?.phase === "pending";
1726
+ fastLane.beforeTool(toolInput.sessionID, toolInput.tool, output.args, {
1727
+ consultationFallbackAuthorized,
1728
+ });
1729
+ if (toolInput.tool === "task" && taskRole === "dog-worker") {
1730
+ beginCoordinatorTask(toolInput.sessionID, toolInput.callID);
1731
+ }
1653
1732
  return;
1654
1733
  }
1655
1734
  const status = activeSessionStatus(toolInput.sessionID);
@@ -1747,6 +1826,8 @@ export const SortieDogsPlugin = async (input, options) => {
1747
1826
  return;
1748
1827
  }
1749
1828
  if (event.type === "session.deleted") {
1829
+ fastLane.forget(eventSessionID);
1830
+ abortCoordinatorTasks(eventSessionID);
1750
1831
  if (reflectionStore !== undefined && reflectionConfiguration?.layers.run && reflectionOwnedRoots.has(eventSessionID)) {
1751
1832
  reflectionClosingRoots.add(eventSessionID);
1752
1833
  await waitForReflections(eventSessionID);
@@ -1785,18 +1866,24 @@ export const SortieDogsPlugin = async (input, options) => {
1785
1866
  await continuation.sessionCompacted(eventSessionID);
1786
1867
  if (event.type === "session.idle")
1787
1868
  await continuation.sessionIdle(eventSessionID);
1869
+ if (event.type === "session.idle" && isCoordinatorSession(eventSessionID)) {
1870
+ abortCoordinatorTasks(eventSessionID);
1871
+ }
1788
1872
  if (!isActiveSession(eventSessionID))
1789
1873
  return;
1790
1874
  if (event.type !== "session.idle")
1791
1875
  touchActiveSession(eventSessionID);
1792
1876
  if (event.type === "session.idle" && eventSessionID !== undefined) {
1793
1877
  activeSessions.get(eventSessionID)?.inFlightCalls.clear();
1878
+ if (childHasInFlightParentTask(eventSessionID)) {
1879
+ touchActiveSession(eventSessionID);
1880
+ return;
1881
+ }
1794
1882
  const authorization = sessionAuthorizations.get(eventSessionID);
1795
1883
  if (authorization === undefined)
1796
1884
  return;
1797
1885
  try {
1798
- // Idle revalidates the pinned handoff but always releases write ownership. A resumed
1799
- // worker must bind again, so a completed serial worker cannot block later work.
1886
+ // Idle is the abnormal-exit fallback when the parent Task completion hook never arrives.
1800
1887
  await inspect(authorization.handoffPath, eventSessionID);
1801
1888
  }
1802
1889
  catch {
@@ -56,6 +56,7 @@ export type TaskResultRepairOutcome = {
56
56
  export declare const CONSULTATION_FALLBACK_RETRY_MARKER = "SORTIE_CONSULTATION_FALLBACK_RETRY";
57
57
  /** Assistant text the child actually produced, ignoring the empty tail that caused the defect. */
58
58
  export declare function lastAssistantText(messages: readonly SessionMessage[]): string | undefined;
59
+ export declare function taskChildSessionID(output: TaskToolExecuteOutput): string | undefined;
59
60
  /**
60
61
  * Repair only the exact defect: a completed `task` call whose result body is empty while the child
61
62
  * session holds real assistant text. Everything else is left byte-identical.
@@ -36,7 +36,7 @@ export function lastAssistantText(messages) {
36
36
  }
37
37
  return undefined;
38
38
  }
39
- function childSessionID(output) {
39
+ export function taskChildSessionID(output) {
40
40
  const metadata = output.metadata;
41
41
  if (metadata === null || typeof metadata !== "object")
42
42
  return undefined;
@@ -72,7 +72,7 @@ export function createTaskResultRepairHook(client) {
72
72
  const match = emptyResultMatch(output);
73
73
  if (match === undefined)
74
74
  return unchanged;
75
- const sessionID = childSessionID(output);
75
+ const sessionID = taskChildSessionID(output);
76
76
  if (sessionID === undefined)
77
77
  return unchanged;
78
78
  let recovered;