@bermudi/pi-delegate 0.1.11 → 0.1.12
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 +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +8 -1
- package/dispatch.ts +410 -38
- package/extension.ts +14 -0
- package/format.ts +50 -4
- package/host.ts +6 -1
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +50 -16
- package/manual.ts +4 -3
- package/package.json +1 -1
- package/pool.ts +23 -1
- package/provider-extensions.ts +11 -2
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +3 -1
- package/schema.ts +56 -49
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +87 -16
- package/telemetry.ts +135 -68
- package/ticket-format.ts +13 -5
- package/tickets.ts +23 -11
- package/types.ts +79 -0
package/lifecycle.ts
CHANGED
|
@@ -70,11 +70,15 @@ export function _setWholeTaskRetryForTesting(
|
|
|
70
70
|
testWholeTaskBaseDelayMs = opts?.baseDelayMs;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function resolvedWholeTaskMaxRetries(): number {
|
|
74
|
-
return
|
|
73
|
+
function resolvedWholeTaskMaxRetries(env: TaskRunEnv): number {
|
|
74
|
+
return (
|
|
75
|
+
testWholeTaskMaxRetries ?? getWholeTaskMaxRetries(env.config ?? undefined)
|
|
76
|
+
);
|
|
75
77
|
}
|
|
76
|
-
function resolvedWholeTaskBaseDelayMs(): number {
|
|
77
|
-
return
|
|
78
|
+
function resolvedWholeTaskBaseDelayMs(env: TaskRunEnv): number {
|
|
79
|
+
return (
|
|
80
|
+
testWholeTaskBaseDelayMs ?? getWholeTaskBaseDelayMs(env.config ?? undefined)
|
|
81
|
+
);
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** Build a failed TaskResult. Used for early-failure paths (abort, busy, validation). */
|
|
@@ -254,6 +258,7 @@ function recordTaskOutcome(
|
|
|
254
258
|
recordTask({
|
|
255
259
|
callId: env.telemetryCallId,
|
|
256
260
|
generation: env.telemetryGeneration,
|
|
261
|
+
telemetryConfig: env.telemetryConfig,
|
|
257
262
|
async: env.async ?? false,
|
|
258
263
|
taskIndex: p.index,
|
|
259
264
|
task,
|
|
@@ -385,7 +390,7 @@ async function sleepForWholeTaskRetry(
|
|
|
385
390
|
async function buildDelegateSession(
|
|
386
391
|
task: ResolvedTask,
|
|
387
392
|
sessionManager: SessionManager,
|
|
388
|
-
|
|
393
|
+
env: TaskRunEnv,
|
|
389
394
|
): Promise<AgentSession> {
|
|
390
395
|
// Resolve host deps for this task's cwd + system prompt. Extension-free
|
|
391
396
|
// resource loaders are cached after the first call; provider-configured or
|
|
@@ -396,7 +401,7 @@ async function buildDelegateSession(
|
|
|
396
401
|
// Pass only the provider needed by this task. This keeps a non-Kilo task
|
|
397
402
|
// from receiving Kilo's provider/auth adapter merely because Kilo is also
|
|
398
403
|
// configured in the parent runtime.
|
|
399
|
-
const providerConfig = modelRegistry.getRegisteredProviderConfig?.(
|
|
404
|
+
const providerConfig = env.modelRegistry.getRegisteredProviderConfig?.(
|
|
400
405
|
task.model.provider,
|
|
401
406
|
);
|
|
402
407
|
const providerConfigs = providerConfig
|
|
@@ -407,6 +412,10 @@ async function buildDelegateSession(
|
|
|
407
412
|
systemPrompt: task.systemPrompt,
|
|
408
413
|
providerConfigs,
|
|
409
414
|
modelProvider: task.model.provider,
|
|
415
|
+
// Freeze the provider-extension allowlist to the dispatch-scoped snapshot
|
|
416
|
+
// so a later delegate.json edit cannot change which executable code an
|
|
417
|
+
// already-spawned async worker is allowed to load.
|
|
418
|
+
delegateConfig: env.config,
|
|
410
419
|
});
|
|
411
420
|
|
|
412
421
|
const { session } = await createAgentSession({
|
|
@@ -444,11 +453,16 @@ function checkoutPooledSession(
|
|
|
444
453
|
cwd: task.cwd,
|
|
445
454
|
thinking: task.thinking,
|
|
446
455
|
tools: task.tools,
|
|
456
|
+
providerExtensions: task.providerExtensionSources ?? "",
|
|
447
457
|
...task.reuseIntent,
|
|
448
458
|
});
|
|
449
459
|
if (co.status === "mismatch") {
|
|
450
460
|
const detail = co.mismatches
|
|
451
|
-
.map((m) =>
|
|
461
|
+
.map((m) =>
|
|
462
|
+
m.field === "providerExtensions"
|
|
463
|
+
? "providerExtensions: changed"
|
|
464
|
+
: `${m.field}: '${m.frozen}' vs '${m.requested}'`,
|
|
465
|
+
)
|
|
452
466
|
.join("; ");
|
|
453
467
|
return {
|
|
454
468
|
error: failTask(
|
|
@@ -529,7 +543,7 @@ async function resumeFromSessionFile(
|
|
|
529
543
|
};
|
|
530
544
|
}
|
|
531
545
|
|
|
532
|
-
const session = await buildDelegateSession(task, resumed, env
|
|
546
|
+
const session = await buildDelegateSession(task, resumed, env);
|
|
533
547
|
return {
|
|
534
548
|
session,
|
|
535
549
|
sessionManager: resumed,
|
|
@@ -544,7 +558,7 @@ async function createFreshSession(
|
|
|
544
558
|
): Promise<AcquireResult> {
|
|
545
559
|
let sessionManager: SessionManager;
|
|
546
560
|
let sessionFile: string | undefined;
|
|
547
|
-
if (task.workspace === "scratch") {
|
|
561
|
+
if (task.workspace === "scratch" || task.workspace === "isolated") {
|
|
548
562
|
// A discarded filesystem must not advertise a resumable conversation: a
|
|
549
563
|
// later resume would run against the source cwd and silently lose scratch
|
|
550
564
|
// isolation. Keep scratch transcripts in memory only.
|
|
@@ -560,11 +574,7 @@ async function createFreshSession(
|
|
|
560
574
|
sessionFile = fresh.file;
|
|
561
575
|
}
|
|
562
576
|
|
|
563
|
-
const session = await buildDelegateSession(
|
|
564
|
-
task,
|
|
565
|
-
sessionManager,
|
|
566
|
-
env.modelRegistry,
|
|
567
|
-
);
|
|
577
|
+
const session = await buildDelegateSession(task, sessionManager, env);
|
|
568
578
|
return {
|
|
569
579
|
session,
|
|
570
580
|
sessionManager,
|
|
@@ -635,6 +645,24 @@ async function runResolvedTaskUnlocked(
|
|
|
635
645
|
p: TaskProgress,
|
|
636
646
|
taskIndex: number,
|
|
637
647
|
): Promise<TaskResult> {
|
|
648
|
+
if (
|
|
649
|
+
task.workspace === "isolated" &&
|
|
650
|
+
(task.sessionId || task.resumeFrom || task.sessionAction)
|
|
651
|
+
) {
|
|
652
|
+
return recordTaskOutcome(
|
|
653
|
+
env,
|
|
654
|
+
p,
|
|
655
|
+
task,
|
|
656
|
+
finishTask(
|
|
657
|
+
env,
|
|
658
|
+
p,
|
|
659
|
+
failTask(
|
|
660
|
+
task,
|
|
661
|
+
"workspace 'isolated' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.",
|
|
662
|
+
),
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
638
666
|
if (task.workspace !== "scratch") {
|
|
639
667
|
const outcome = await runResolvedTaskCore(env, task, p, taskIndex);
|
|
640
668
|
return recordTaskOutcome(env, p, task, outcome);
|
|
@@ -944,6 +972,7 @@ async function settlePooledAttempt(
|
|
|
944
972
|
thinking: task.thinking,
|
|
945
973
|
tools: task.tools,
|
|
946
974
|
cwd: task.cwd,
|
|
975
|
+
providerExtensions: task.providerExtensionSources ?? "",
|
|
947
976
|
},
|
|
948
977
|
tokens: r.tokens,
|
|
949
978
|
});
|
|
@@ -1035,6 +1064,7 @@ async function runTaskAttempt(
|
|
|
1035
1064
|
gitBaseline,
|
|
1036
1065
|
timing.taskStartedAt,
|
|
1037
1066
|
timing.deadlineAt,
|
|
1067
|
+
env.config,
|
|
1038
1068
|
);
|
|
1039
1069
|
|
|
1040
1070
|
accounting.cumulativeTokens += r.tokens;
|
|
@@ -1159,8 +1189,12 @@ async function runWithWholeTaskRetries(
|
|
|
1159
1189
|
};
|
|
1160
1190
|
}
|
|
1161
1191
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1192
|
+
// An isolated worker is snapshotted once and reconciled once. Retrying the
|
|
1193
|
+
// whole conversation against a mutated proposal tree would make attribution
|
|
1194
|
+
// ambiguous, so v1 deliberately runs one attempt.
|
|
1195
|
+
const maxRetries =
|
|
1196
|
+
task.workspace === "isolated" ? 0 : resolvedWholeTaskMaxRetries(env);
|
|
1197
|
+
const baseDelayMs = resolvedWholeTaskBaseDelayMs(env);
|
|
1164
1198
|
let retries = 0;
|
|
1165
1199
|
for (
|
|
1166
1200
|
let retry = 0;
|
package/manual.ts
CHANGED
|
@@ -110,7 +110,7 @@ export function getSubagentManualMarkdown(
|
|
|
110
110
|
'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
|
|
111
111
|
"```",
|
|
112
112
|
"",
|
|
113
|
-
|
|
113
|
+
'Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation. A batch with multiple write-capable tasks in one shared tree is rejected before dispatch; run them sequentially, use synchronous one-shot `workspace: "isolated"` for Git-backed ordered reconciliation, or use disposable scratch workspaces.',
|
|
114
114
|
"",
|
|
115
115
|
"The three handles have different lifetimes:",
|
|
116
116
|
"",
|
|
@@ -136,7 +136,7 @@ export function getSubagentManualMarkdown(
|
|
|
136
136
|
"",
|
|
137
137
|
...builtinLines,
|
|
138
138
|
"",
|
|
139
|
-
"Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`,
|
|
139
|
+
"Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
|
|
140
140
|
"",
|
|
141
141
|
"## Available Custom Agents",
|
|
142
142
|
"",
|
|
@@ -234,6 +234,7 @@ export function getSubagentManualMarkdown(
|
|
|
234
234
|
"## Gotchas",
|
|
235
235
|
"",
|
|
236
236
|
"- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
|
|
237
|
+
'- Shared writers that overlap tasks in the same call or a running sync/async dispatch are rejected. Unknown tools count as mutating. Run them sequentially, use `workspace: "isolated"` for Git-backed ordered reconciliation, or use `workspace: "scratch"` when changes may be discarded.',
|
|
237
238
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
238
239
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
239
240
|
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Built-ins are `default`, `scout`, `coder`, and `reviewer`; omitting `agent` creates an ad-hoc task.',
|
|
@@ -244,7 +245,7 @@ export function getSubagentManualMarkdown(
|
|
|
244
245
|
"",
|
|
245
246
|
"## Config",
|
|
246
247
|
"",
|
|
247
|
-
"Tunables live in `~/.pi/agent/delegate.json
|
|
248
|
+
"Tunables live in `~/.pi/agent/delegate.json` (user scope, global — no project-level config): `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, legacy custom-agent model overrides (`agent`), and agent model/thinking/tools overrides — `agentOverrides` and `agentOverridesByParentModel` (exact `provider/model-id` key of the parent model; wins over `agentOverrides` on match). Config edits apply from the next delegate call.",
|
|
248
249
|
"The inactivity watchdog requests cooperative `AgentSession.abort()` cancellation and waits for the subagent to become idle; it is not a hard wall-clock execution deadline.",
|
|
249
250
|
"",
|
|
250
251
|
`Output bounding: subagent outputs longer than ${OUTPUT_SPILL_THRESHOLD_CHARS} characters are spilled to a temp file, and only the last ${OUTPUT_SPILL_TAIL_CHARS} characters stay in the LLM-facing result. Adjust with \`output.spillThresholdChars\` and \`output.spillTailChars\`. Spill files are written to the system temp directory with owner-only permissions; the full output is always available in the expanded TUI view and the spilled file.`,
|
package/package.json
CHANGED
package/pool.ts
CHANGED
|
@@ -18,6 +18,10 @@ export interface FrozenConfig {
|
|
|
18
18
|
thinking: ThinkingLevel;
|
|
19
19
|
tools: string[];
|
|
20
20
|
cwd: string;
|
|
21
|
+
/** Stable signature of the provider-scoped extension allowlist this session
|
|
22
|
+
* was built with. A change in `delegate.json` providerExtensions must force
|
|
23
|
+
* session recreation so the old executable runtime is not silently reused. */
|
|
24
|
+
providerExtensions?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/** The subset a reuse request supplies for validation. `model` and
|
|
@@ -29,12 +33,20 @@ export interface ConfigCandidate {
|
|
|
29
33
|
tools: string[];
|
|
30
34
|
model?: Model<Api>;
|
|
31
35
|
systemPrompt?: string;
|
|
36
|
+
/** Provider-scoped extension allowlist signature for the current dispatch. */
|
|
37
|
+
providerExtensions?: string;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** One field-level diff from a reuse that conflicts with the frozen config. The
|
|
35
41
|
* pool computes these; the caller formats the error string. */
|
|
36
42
|
export interface ConfigMismatch {
|
|
37
|
-
field:
|
|
43
|
+
field:
|
|
44
|
+
| "cwd"
|
|
45
|
+
| "thinking"
|
|
46
|
+
| "tools"
|
|
47
|
+
| "model"
|
|
48
|
+
| "systemPrompt"
|
|
49
|
+
| "providerExtensions";
|
|
38
50
|
frozen: string;
|
|
39
51
|
requested: string;
|
|
40
52
|
}
|
|
@@ -189,6 +201,16 @@ export function checkout(
|
|
|
189
201
|
requested: "<requested>",
|
|
190
202
|
});
|
|
191
203
|
}
|
|
204
|
+
if (frozen.providerExtensions !== candidate.providerExtensions) {
|
|
205
|
+
// This field is derived from configured package sources, which may contain
|
|
206
|
+
// credentials. Never expose either the sources or their digest through the
|
|
207
|
+
// public checkout result; a digest can still enable dictionary guessing.
|
|
208
|
+
mismatches.push({
|
|
209
|
+
field: "providerExtensions",
|
|
210
|
+
frozen: "<redacted>",
|
|
211
|
+
requested: "<redacted>",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
192
214
|
if (mismatches.length) return { status: "mismatch", mismatches };
|
|
193
215
|
|
|
194
216
|
return {
|
package/provider-extensions.ts
CHANGED
|
@@ -35,7 +35,11 @@ import {
|
|
|
35
35
|
SettingsManager,
|
|
36
36
|
} from "@earendil-works/pi-coding-agent";
|
|
37
37
|
import { getSubagentProviderExtensionSourcesForProvider } from "./config.ts";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
canonicalPath,
|
|
40
|
+
isPathWithinDirectory,
|
|
41
|
+
isPathWithinDirectoryLexical,
|
|
42
|
+
} from "./trusted-paths.ts";
|
|
39
43
|
import {
|
|
40
44
|
parseGitOriginIdentity,
|
|
41
45
|
parsePackageSource,
|
|
@@ -331,8 +335,13 @@ export async function getProviderExtensionPaths(
|
|
|
331
335
|
provider: string | undefined,
|
|
332
336
|
cwd: string,
|
|
333
337
|
agentDir: string,
|
|
338
|
+
/** Dispatch-scoped snapshot for the provider-extension allowlist. */
|
|
339
|
+
config?: import("./config.ts").DelegateConfig,
|
|
334
340
|
): Promise<ProviderExtensionResolution> {
|
|
335
|
-
const requested = getSubagentProviderExtensionSourcesForProvider(
|
|
341
|
+
const requested = getSubagentProviderExtensionSourcesForProvider(
|
|
342
|
+
provider,
|
|
343
|
+
config,
|
|
344
|
+
);
|
|
336
345
|
if (!requested.length) {
|
|
337
346
|
return { paths: [], bestEffortPaths: new Set<string>() };
|
|
338
347
|
}
|
package/render-branches.ts
CHANGED
|
@@ -378,6 +378,36 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
378
378
|
if (!isLive && !isCancelledPending && r && "error" in r && r.error) {
|
|
379
379
|
lines.push(truncLine(`${ind}${theme.fg("error", r.error)}`, w));
|
|
380
380
|
}
|
|
381
|
+
if (r && "integration" in r && r.integration) {
|
|
382
|
+
const integration = r.integration;
|
|
383
|
+
const tone =
|
|
384
|
+
integration.status === "applied_unverified" ||
|
|
385
|
+
integration.status === "no_changes"
|
|
386
|
+
? "warning"
|
|
387
|
+
: "error";
|
|
388
|
+
lines.push(
|
|
389
|
+
truncLine(
|
|
390
|
+
`${ind}${theme.fg(tone, `integration: ${integration.status} · ${integration.appliedFiles.length}/${integration.proposedFiles.length} files applied`)}`,
|
|
391
|
+
w,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
if (expanded) {
|
|
395
|
+
if (integration.patchPath)
|
|
396
|
+
lines.push(
|
|
397
|
+
truncLine(
|
|
398
|
+
`${ind}${theme.fg("muted", `patch: ${integration.patchPath}`)}`,
|
|
399
|
+
w,
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
if (integration.worktreePath)
|
|
403
|
+
lines.push(
|
|
404
|
+
truncLine(
|
|
405
|
+
`${ind}${theme.fg("muted", `worktree: ${integration.worktreePath}`)}`,
|
|
406
|
+
w,
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
381
411
|
// Collapsed: one-line output preview so a human scanning the TUI sees
|
|
382
412
|
// the payoff without expanding every task. Expanded mode renders the
|
|
383
413
|
// full markdown below instead.
|
package/render-result.ts
CHANGED
|
@@ -166,11 +166,14 @@ export function renderDelegateResult(
|
|
|
166
166
|
ticketStatus,
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
-
// Surface
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
// Surface batch-level warnings at the top of the TUI. The same text already
|
|
170
|
+
// lives in textual content, but the progress renderer ignores content.
|
|
171
|
+
if (details?.dispatchWarning) {
|
|
172
|
+
lines.push(
|
|
173
|
+
truncLine(theme.fg("warning", `⚠ ${details.dispatchWarning}`), w),
|
|
174
|
+
"",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
174
177
|
if (details?.overlapWarning) {
|
|
175
178
|
lines.push(
|
|
176
179
|
truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
|
package/runner.ts
CHANGED
|
@@ -72,6 +72,8 @@ export async function runAgentSession(
|
|
|
72
72
|
gitBaseline: Set<string> | undefined,
|
|
73
73
|
start: number,
|
|
74
74
|
deadlineAt?: number,
|
|
75
|
+
/** Dispatch-scoped delegate.json snapshot for the stall timeout. */
|
|
76
|
+
delegateConfig?: import("./config.ts").DelegateConfig,
|
|
75
77
|
): Promise<{
|
|
76
78
|
output: string;
|
|
77
79
|
error?: string;
|
|
@@ -89,7 +91,7 @@ export async function runAgentSession(
|
|
|
89
91
|
prompted: boolean;
|
|
90
92
|
}> {
|
|
91
93
|
const startTime = start ?? Date.now();
|
|
92
|
-
const stallTimeoutMs = getStallTimeoutMs();
|
|
94
|
+
const stallTimeoutMs = getStallTimeoutMs(delegateConfig);
|
|
93
95
|
let toolUses = 0;
|
|
94
96
|
let lastActivityAt: number | undefined = startTime;
|
|
95
97
|
let phase = "starting agent";
|
package/schema.ts
CHANGED
|
@@ -106,9 +106,9 @@ export const delegateTaskSchema = Type.Object({
|
|
|
106
106
|
}),
|
|
107
107
|
),
|
|
108
108
|
workspace: Type.Optional(
|
|
109
|
-
StringEnum(["shared", "scratch"], {
|
|
109
|
+
StringEnum(["shared", "scratch", "isolated"], {
|
|
110
110
|
description:
|
|
111
|
-
"shared
|
|
111
|
+
"shared; scratch discarded; isolated=sync one-shot Git apply; not security isolation. Reviewer=scratch.",
|
|
112
112
|
}),
|
|
113
113
|
),
|
|
114
114
|
});
|
|
@@ -116,47 +116,50 @@ export const delegateTaskSchema = Type.Object({
|
|
|
116
116
|
// Single source of truth for registration and generated help. The exported
|
|
117
117
|
// argument types in types.ts project this canonical schema; providers see
|
|
118
118
|
// only these fields.
|
|
119
|
-
export const delegateArgumentsSchema = Type.Object(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
Type.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Type.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Type.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
Type.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Type.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
119
|
+
export const delegateArgumentsSchema = Type.Object(
|
|
120
|
+
{
|
|
121
|
+
ticketAction: Type.Optional(
|
|
122
|
+
StringEnum(["poll", "cancel", "wait"], {
|
|
123
|
+
description:
|
|
124
|
+
"Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
async: Type.Optional(
|
|
128
|
+
Type.Boolean({
|
|
129
|
+
description:
|
|
130
|
+
"Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
|
|
131
|
+
default: false,
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
ticket: Type.Optional(
|
|
135
|
+
Type.String({
|
|
136
|
+
description: "Ticket ID; omit only when polling all tickets.",
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
force: Type.Optional(
|
|
140
|
+
Type.Boolean({
|
|
141
|
+
description:
|
|
142
|
+
"With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
|
|
143
|
+
default: false,
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
timeoutMs: Type.Optional(
|
|
147
|
+
Type.Number({
|
|
148
|
+
minimum: 0,
|
|
149
|
+
description:
|
|
150
|
+
"Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
tasks: Type.Optional(
|
|
154
|
+
Type.Array(delegateTaskSchema, {
|
|
155
|
+
minItems: 0,
|
|
156
|
+
description:
|
|
157
|
+
"Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
{ additionalProperties: false },
|
|
162
|
+
);
|
|
160
163
|
|
|
161
164
|
/** Fields that belong to a task entry. Models sometimes place these at the top
|
|
162
165
|
* level of the arguments; the shim folds them back into a single task. */
|
|
@@ -238,6 +241,10 @@ export function validateDelegateOperation(
|
|
|
238
241
|
: undefined; // Intentional help request.
|
|
239
242
|
}
|
|
240
243
|
|
|
244
|
+
if (params.async && tasks.some((task) => task.workspace === "isolated")) {
|
|
245
|
+
return 'workspace "isolated" is synchronous; remove async.';
|
|
246
|
+
}
|
|
247
|
+
|
|
241
248
|
// Reject mixed shapes: flat task fields at the top level alongside a
|
|
242
249
|
// nonempty tasks array. The normalize shim only wraps flat fields when
|
|
243
250
|
// there is no tasks array, so a mixed call silently lets tasks win —
|
|
@@ -263,13 +270,14 @@ export function validateDelegateOperation(
|
|
|
263
270
|
(key) => !VALID_TASK_KEYS.has(key),
|
|
264
271
|
);
|
|
265
272
|
if (unknownKeys.length) {
|
|
266
|
-
const
|
|
267
|
-
|
|
273
|
+
const misplacedTopLevel = unknownKeys.filter((key) => key === "async");
|
|
274
|
+
const topLevelHint = misplacedTopLevel.length
|
|
275
|
+
? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level flag${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
|
|
268
276
|
: "";
|
|
269
277
|
return (
|
|
270
278
|
`task ${index + 1}: unknown field(s) ${unknownKeys
|
|
271
279
|
.map((key) => `'${key}'`)
|
|
272
|
-
.join(", ")}.${
|
|
280
|
+
.join(", ")}.${topLevelHint} ` +
|
|
273
281
|
`Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
|
|
274
282
|
);
|
|
275
283
|
}
|
|
@@ -282,11 +290,10 @@ export function validateDelegateOperation(
|
|
|
282
290
|
return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
|
|
283
291
|
}
|
|
284
292
|
if (
|
|
285
|
-
task.workspace === "scratch" &&
|
|
286
|
-
!task.agent &&
|
|
293
|
+
(task.workspace === "scratch" || task.workspace === "isolated") &&
|
|
287
294
|
(task.sessionId || task.resumeFrom || sessionAction !== undefined)
|
|
288
295
|
) {
|
|
289
|
-
return `task ${index + 1}: workspace '
|
|
296
|
+
return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
|
|
290
297
|
}
|
|
291
298
|
if (sessionAction === "close") {
|
|
292
299
|
if (!task.sessionId) {
|