pi-crew 0.1.39 → 0.1.41
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 +34 -0
- package/README.md +50 -4
- package/docs/usage.md +11 -0
- package/package.json +1 -1
- package/schema.json +4 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config.ts +87 -2
- package/src/extension/async-notifier.ts +26 -4
- package/src/extension/notification-sink.ts +1 -1
- package/src/extension/register.ts +23 -7
- package/src/extension/registration/subagent-tools.ts +11 -6
- package/src/extension/result-watcher.ts +38 -8
- package/src/extension/team-tool/api.ts +61 -2
- package/src/extension/team-tool/doctor.ts +31 -0
- package/src/extension/team-tool/status.ts +23 -3
- package/src/observability/metric-sink.ts +1 -1
- package/src/runtime/agent-control.ts +4 -5
- package/src/runtime/attention-events.ts +23 -0
- package/src/runtime/child-pi.ts +2 -1
- package/src/runtime/completion-guard.ts +99 -0
- package/src/runtime/crew-agent-records.ts +5 -4
- package/src/runtime/crew-agent-runtime.ts +2 -2
- package/src/runtime/diagnostic-export.ts +2 -16
- package/src/runtime/group-join.ts +22 -4
- package/src/runtime/live-session-runtime.ts +12 -6
- package/src/runtime/sidechain-output.ts +2 -1
- package/src/runtime/subagent-manager.ts +6 -1
- package/src/runtime/task-runner/live-executor.ts +3 -0
- package/src/runtime/task-runner.ts +25 -2
- package/src/runtime/team-runner.ts +131 -6
- package/src/schema/config-schema.ts +3 -0
- package/src/schema/team-tool-schema.ts +12 -3
- package/src/state/artifact-store.ts +4 -2
- package/src/state/event-log.ts +2 -1
- package/src/state/jsonl-writer.ts +3 -1
- package/src/state/mailbox.ts +15 -4
- package/src/state/types.ts +25 -0
- package/src/teams/discover-teams.ts +1 -1
- package/src/ui/dashboard-panes/progress-pane.ts +3 -0
- package/src/ui/run-snapshot-cache.ts +29 -1
- package/src/ui/snapshot-types.ts +8 -0
- package/src/utils/fs-watch.ts +3 -3
- package/src/utils/redaction.ts +41 -0
- package/src/workflows/discover-workflows.ts +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CrewRuntimeConfig } from "../config/config.ts";
|
|
2
2
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
3
3
|
import { appendEvent } from "../state/event-log.ts";
|
|
4
|
-
import { appendMailboxMessage } from "../state/mailbox.ts";
|
|
4
|
+
import { appendMailboxMessage, findMailboxMessageByRequestId, readDeliveryState } from "../state/mailbox.ts";
|
|
5
5
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
6
|
import { aggregateTaskOutputs } from "./task-output-context.ts";
|
|
7
7
|
|
|
@@ -18,6 +18,9 @@ export interface CrewGroupJoinDelivery {
|
|
|
18
18
|
remaining: string[];
|
|
19
19
|
artifact?: ArtifactDescriptor;
|
|
20
20
|
messageId?: string;
|
|
21
|
+
requestId?: string;
|
|
22
|
+
ackRequired?: boolean;
|
|
23
|
+
ackStatus?: "pending" | "acknowledged";
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function resolveGroupJoinMode(runtime?: CrewRuntimeConfig): CrewGroupJoinMode {
|
|
@@ -34,6 +37,10 @@ function batchIdFor(runId: string, taskIds: string[]): string {
|
|
|
34
37
|
return `${runId}_${taskIds.join("+").replace(/[^a-zA-Z0-9_+-]/g, "_")}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
function requestIdFor(runId: string, batchId: string, partial: boolean): string {
|
|
41
|
+
return `${runId}:group-join:${partial ? "partial" : "completed"}:${batchId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
function statusList(tasks: TeamTaskState[], status: TeamTaskState["status"]): string[] {
|
|
38
45
|
return tasks.filter((task) => task.status === status).map((task) => task.id);
|
|
39
46
|
}
|
|
@@ -55,7 +62,10 @@ export function deliverGroupJoin(input: {
|
|
|
55
62
|
const partial = input.partial ?? remaining.length > 0;
|
|
56
63
|
const batchId = batchIdFor(input.manifest.runId, taskIds);
|
|
57
64
|
const summary = aggregateTaskOutputs(latest, input.manifest);
|
|
58
|
-
const
|
|
65
|
+
const requestId = requestIdFor(input.manifest.runId, batchId, partial);
|
|
66
|
+
const existingMailbox = findMailboxMessageByRequestId(input.manifest, requestId);
|
|
67
|
+
const existingStatus = existingMailbox ? readDeliveryState(input.manifest).messages[existingMailbox.id] ?? existingMailbox.status : undefined;
|
|
68
|
+
const delivery: CrewGroupJoinDelivery = { batchId, mode: input.mode, partial, taskIds, completed, failed, skipped, remaining, requestId, ackRequired: true, ackStatus: existingStatus === "acknowledged" ? "acknowledged" : "pending" };
|
|
59
69
|
const content = `${JSON.stringify({ ...delivery, createdAt: new Date().toISOString() }, null, 2)}\n`;
|
|
60
70
|
const artifact = writeArtifact(input.manifest.artifactsRoot, {
|
|
61
71
|
kind: "metadata",
|
|
@@ -63,12 +73,13 @@ export function deliverGroupJoin(input: {
|
|
|
63
73
|
producer: "group-join",
|
|
64
74
|
content,
|
|
65
75
|
});
|
|
66
|
-
const mailbox = appendMailboxMessage(input.manifest, {
|
|
76
|
+
const mailbox = existingMailbox ?? appendMailboxMessage(input.manifest, {
|
|
67
77
|
direction: "outbox",
|
|
68
78
|
from: "group-join",
|
|
69
79
|
to: "leader",
|
|
70
80
|
body: [
|
|
71
81
|
`Group join ${partial ? "partial" : "completed"}: ${taskIds.join(", ")}`,
|
|
82
|
+
`Request: ${requestId}`,
|
|
72
83
|
`Completed: ${completed.join(", ") || "none"}`,
|
|
73
84
|
`Failed: ${failed.join(", ") || "none"}`,
|
|
74
85
|
`Skipped: ${skipped.join(", ") || "none"}`,
|
|
@@ -77,12 +88,19 @@ export function deliverGroupJoin(input: {
|
|
|
77
88
|
summary,
|
|
78
89
|
].join("\n"),
|
|
79
90
|
status: "delivered",
|
|
91
|
+
data: { kind: "group_join", requestId, batchId, partial, ackRequired: true, taskIds, completed, failed, skipped, remaining },
|
|
80
92
|
});
|
|
81
93
|
appendEvent(input.manifest.eventsPath, {
|
|
82
94
|
type: partial ? "agent.group_join.partial" : "agent.group_join.completed",
|
|
83
95
|
runId: input.manifest.runId,
|
|
84
96
|
message: `Group join ${partial ? "partial" : "completed"} for ${taskIds.length} task(s).`,
|
|
85
|
-
data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id },
|
|
97
|
+
data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id, fallback: "mailbox-delivered", reused: Boolean(existingMailbox) },
|
|
98
|
+
});
|
|
99
|
+
if (existingMailbox) appendEvent(input.manifest.eventsPath, {
|
|
100
|
+
type: "agent.group_join.delivery_reused",
|
|
101
|
+
runId: input.manifest.runId,
|
|
102
|
+
message: `Reused group join mailbox delivery for ${taskIds.length} task(s).`,
|
|
103
|
+
data: { requestId, messageId: mailbox.id, batchId, partial },
|
|
86
104
|
});
|
|
87
105
|
return { ...delivery, artifact, messageId: mailbox.id };
|
|
88
106
|
}
|
|
@@ -10,6 +10,7 @@ import { subscribeLiveControlRealtime } from "./live-control-realtime.ts";
|
|
|
10
10
|
import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "./sidechain-output.ts";
|
|
11
11
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
12
12
|
import { isLiveSessionRuntimeAvailable } from "./runtime-resolver.ts";
|
|
13
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
13
14
|
|
|
14
15
|
export interface LiveSessionSpawnInput {
|
|
15
16
|
manifest: TeamRunManifest;
|
|
@@ -25,6 +26,7 @@ export interface LiveSessionSpawnInput {
|
|
|
25
26
|
parentContext?: string;
|
|
26
27
|
parentModel?: unknown;
|
|
27
28
|
modelRegistry?: unknown;
|
|
29
|
+
isCurrent?: () => boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface LiveSessionRunResult {
|
|
@@ -70,7 +72,7 @@ type LiveSessionLike = {
|
|
|
70
72
|
function appendTranscript(filePath: string | undefined, event: unknown): void {
|
|
71
73
|
if (!filePath) return;
|
|
72
74
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
73
|
-
fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, "utf-8");
|
|
75
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(event))}\n`, "utf-8");
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -173,6 +175,7 @@ export async function probeLiveSessionRuntime(): Promise<LiveSessionUnavailableR
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<LiveSessionRunResult> {
|
|
178
|
+
const isCurrent = input.isCurrent ?? (() => true);
|
|
176
179
|
if (process.env.PI_CREW_MOCK_LIVE_SESSION === "success") {
|
|
177
180
|
const agentId = `${input.manifest.runId}:${input.task.id}`;
|
|
178
181
|
const inherited = input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
|
|
@@ -183,9 +186,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
183
186
|
const sidechainPath = sidechainOutputPath(input.manifest.stateRoot, input.task.id);
|
|
184
187
|
writeSidechainEntry(sidechainPath, { agentId, type: "user", message: { role: "user", content: input.prompt }, cwd: input.task.cwd });
|
|
185
188
|
writeSidechainEntry(sidechainPath, { agentId, type: "message", message: event, cwd: input.task.cwd });
|
|
186
|
-
input.onEvent?.(event);
|
|
189
|
+
if (isCurrent()) input.onEvent?.(event);
|
|
187
190
|
const stdout = `Mock live-session success for ${input.agent.name}${inherited}`;
|
|
188
|
-
input.onOutput?.(stdout);
|
|
191
|
+
if (isCurrent()) input.onOutput?.(stdout);
|
|
189
192
|
updateLiveAgentStatus(agentId, "completed");
|
|
190
193
|
return { available: true, exitCode: 0, stdout, stderr: "", jsonEvents: 1 };
|
|
191
194
|
}
|
|
@@ -234,7 +237,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
234
237
|
const seenControlRequestIds = new Set<string>();
|
|
235
238
|
let controlBusy = false;
|
|
236
239
|
const pollControl = async () => {
|
|
237
|
-
if (controlBusy || !session) return;
|
|
240
|
+
if (!isCurrent() || controlBusy || !session) return;
|
|
238
241
|
controlBusy = true;
|
|
239
242
|
try {
|
|
240
243
|
controlCursor = await applyLiveAgentControlRequests({ manifest: input.manifest, taskId: input.task.id, agentId, session, cursor: controlCursor, seenRequestIds: seenControlRequestIds });
|
|
@@ -243,11 +246,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
243
246
|
}
|
|
244
247
|
};
|
|
245
248
|
unsubscribeControlRealtime = subscribeLiveControlRealtime((request) => {
|
|
246
|
-
if (request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
|
|
249
|
+
if (!isCurrent() || request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
|
|
247
250
|
void applyLiveAgentControlRequest({ request, taskId: input.task.id, agentId, session, seenRequestIds: seenControlRequestIds });
|
|
248
251
|
});
|
|
249
252
|
await pollControl();
|
|
250
|
-
controlTimer = setInterval(() => {
|
|
253
|
+
controlTimer = setInterval(() => {
|
|
254
|
+
if (isCurrent()) void pollControl();
|
|
255
|
+
}, 500);
|
|
251
256
|
let turnCount = 0;
|
|
252
257
|
let softLimitReached = false;
|
|
253
258
|
const maxTurns = input.runtimeConfig?.maxTurns;
|
|
@@ -256,6 +261,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
256
261
|
writeSidechainEntry(sidechainPath, { agentId, type: "user", message: { role: "user", content: input.prompt }, cwd: input.task.cwd });
|
|
257
262
|
if (typeof session.subscribe === "function") {
|
|
258
263
|
unsubscribe = session.subscribe((event) => {
|
|
264
|
+
if (!isCurrent()) return;
|
|
259
265
|
jsonEvents += 1;
|
|
260
266
|
appendTranscript(input.transcriptPath, event);
|
|
261
267
|
const sidechainType = eventToSidechainType(event);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
3
4
|
|
|
4
5
|
export interface SidechainEntry {
|
|
5
6
|
isSidechain: true;
|
|
@@ -12,7 +13,7 @@ export interface SidechainEntry {
|
|
|
12
13
|
|
|
13
14
|
export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
|
|
14
15
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15
|
-
fs.appendFileSync(filePath, `${JSON.stringify({ isSidechain: true, timestamp: new Date().toISOString(), ...entry })}\n`, "utf-8");
|
|
16
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`, "utf-8");
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function sidechainOutputPath(stateRoot: string, taskId: string): string {
|
|
@@ -6,6 +6,7 @@ import { DEFAULT_SUBAGENT } from "../config/defaults.ts";
|
|
|
6
6
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
7
7
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
8
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
9
10
|
|
|
10
11
|
export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "blocked" | "stopped";
|
|
11
12
|
|
|
@@ -17,6 +18,7 @@ export interface SubagentSpawnOptions {
|
|
|
17
18
|
background: boolean;
|
|
18
19
|
model?: string;
|
|
19
20
|
maxTurns?: number;
|
|
21
|
+
ownerSessionGeneration?: number;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface SubagentRecord {
|
|
@@ -33,6 +35,7 @@ export interface SubagentRecord {
|
|
|
33
35
|
resultConsumed?: boolean;
|
|
34
36
|
model?: string;
|
|
35
37
|
background: boolean;
|
|
38
|
+
ownerSessionGeneration?: number;
|
|
36
39
|
stuckNotified?: boolean;
|
|
37
40
|
blockedAt?: number;
|
|
38
41
|
promise?: Promise<void>;
|
|
@@ -66,7 +69,7 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
|
|
|
66
69
|
try {
|
|
67
70
|
const filePath = persistedSubagentPath(cwd, record.id);
|
|
68
71
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
69
|
-
fs.writeFileSync(filePath, `${JSON.stringify(serializableRecord(record), null, 2)}\n`, "utf-8");
|
|
72
|
+
fs.writeFileSync(filePath, `${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`, "utf-8");
|
|
70
73
|
} catch (error) {
|
|
71
74
|
logInternalError("subagent-manager.save", error, `id=${record.id}`);
|
|
72
75
|
}
|
|
@@ -136,6 +139,7 @@ export class SubagentManager {
|
|
|
136
139
|
startedAt: Date.now(),
|
|
137
140
|
model: options.model,
|
|
138
141
|
background: options.background,
|
|
142
|
+
ownerSessionGeneration: options.ownerSessionGeneration,
|
|
139
143
|
};
|
|
140
144
|
this.records.set(record.id, record);
|
|
141
145
|
this.cwdByRecord.set(record.id, options.cwd);
|
|
@@ -373,6 +377,7 @@ export class SubagentManager {
|
|
|
373
377
|
id: current.id,
|
|
374
378
|
runId: current.runId,
|
|
375
379
|
durationMs: Math.max(0, Date.now() - current.blockedAt),
|
|
380
|
+
ownerSessionGeneration: current.ownerSessionGeneration,
|
|
376
381
|
});
|
|
377
382
|
savePersistedSubagentRecord(cwd, current);
|
|
378
383
|
};
|
|
@@ -24,6 +24,7 @@ export interface RunLiveTaskInput {
|
|
|
24
24
|
parentContext?: string;
|
|
25
25
|
parentModel?: unknown;
|
|
26
26
|
modelRegistry?: unknown;
|
|
27
|
+
isCurrent?: () => boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface RunLiveTaskOutput {
|
|
@@ -65,6 +66,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
|
|
|
65
66
|
}
|
|
66
67
|
};
|
|
67
68
|
const attemptStartedAt = new Date();
|
|
69
|
+
const isCurrent = input.isCurrent ?? (() => input.signal?.aborted !== true);
|
|
68
70
|
const liveResult = await runLiveSessionTask({
|
|
69
71
|
manifest,
|
|
70
72
|
task,
|
|
@@ -77,6 +79,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
|
|
|
77
79
|
parentContext: input.parentContext,
|
|
78
80
|
parentModel: input.parentModel,
|
|
79
81
|
modelRegistry: input.modelRegistry,
|
|
82
|
+
isCurrent,
|
|
80
83
|
onOutput: (text) => appendCrewAgentOutput(manifest, task.id, text),
|
|
81
84
|
onEvent: (event) => {
|
|
82
85
|
appendCrewAgentEvent(manifest, task.id, event);
|
|
@@ -25,6 +25,8 @@ import { coordinationBridgeInstructions, renderTaskPrompt } from "./task-runner/
|
|
|
25
25
|
import { applyAgentProgressEvent, applyUsageToProgress, progressEventSummary, shouldFlushProgressEvent } from "./task-runner/progress.ts";
|
|
26
26
|
import { checkpointTask, persistSingleTaskUpdate, updateTask } from "./task-runner/state-helpers.ts";
|
|
27
27
|
import { cleanResultText, isFinalChildEvent } from "./task-runner/result-utils.ts";
|
|
28
|
+
import { evaluateCompletionMutationGuard } from "./completion-guard.ts";
|
|
29
|
+
import { appendTaskAttentionEvent } from "./attention-events.ts";
|
|
28
30
|
|
|
29
31
|
export interface TaskRunnerInput {
|
|
30
32
|
manifest: TeamRunManifest;
|
|
@@ -86,6 +88,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
86
88
|
let error: string | undefined;
|
|
87
89
|
let modelAttempts: ModelAttemptSummary[] | undefined;
|
|
88
90
|
let parsedOutput: ParsedPiJsonOutput | undefined;
|
|
91
|
+
let finalStdout = "";
|
|
92
|
+
let transcriptPath: string | undefined;
|
|
89
93
|
|
|
90
94
|
let startupEvidence = createStartupEvidence({ command: runtimeKind === "child-process" ? "pi" : runtimeKind === "live-session" ? "live-session" : "safe-scaffold", startedAt: new Date(task.startedAt ?? new Date().toISOString()), finishedAt: new Date(), promptSentAt: new Date(task.startedAt ?? new Date().toISOString()), promptAccepted: true, exitCode: 0 });
|
|
91
95
|
const inputsArtifact = writeTaskInputsArtifact(manifest, task, dependencyContext);
|
|
@@ -100,10 +104,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
100
104
|
const candidates = modelRoutingPlan.candidates;
|
|
101
105
|
const attemptModels = candidates.length > 0 ? candidates : [undefined];
|
|
102
106
|
const logs: string[] = [];
|
|
103
|
-
let finalStdout = "";
|
|
104
107
|
let finalStderr = "";
|
|
105
108
|
modelAttempts = [];
|
|
106
|
-
|
|
109
|
+
transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
107
110
|
let finalCheckpointWritten = false;
|
|
108
111
|
let lastAgentRecordPersistedAt = 0;
|
|
109
112
|
let lastHeartbeatPersistedAt = 0;
|
|
@@ -258,6 +261,26 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
258
261
|
producer: task.id,
|
|
259
262
|
}) : undefined;
|
|
260
263
|
|
|
264
|
+
const mutationGuardMode = input.runtimeConfig?.completionMutationGuard ?? "warn";
|
|
265
|
+
const mutationGuard = !error && mutationGuardMode !== "off" ? evaluateCompletionMutationGuard({ role: task.role, taskText: `${task.title}\n${input.step.task}`, transcriptPath: runtimeKind === "child-process" ? transcriptPath : transcriptArtifact?.path, stdout: finalStdout }) : undefined;
|
|
266
|
+
if (mutationGuard?.reason === "no_mutation_observed") {
|
|
267
|
+
appendTaskAttentionEvent({
|
|
268
|
+
manifest,
|
|
269
|
+
taskId: task.id,
|
|
270
|
+
message: "Implementation-style task completed without an observed mutation tool call.",
|
|
271
|
+
data: { activityState: "needs_attention", reason: "completion_guard", taskId: task.id, agentName: task.agent, observedTools: mutationGuard.observedTools, suggestedAction: mutationGuardMode === "fail" ? "Review the worker output and rerun with a concrete implementation task." : "Review the worker output; set runtime.completionMutationGuard='fail' to enforce this." },
|
|
272
|
+
});
|
|
273
|
+
task = { ...task, agentProgress: { ...(task.agentProgress ?? emptyCrewAgentProgress()), activityState: "needs_attention" } };
|
|
274
|
+
if (mutationGuardMode === "fail") {
|
|
275
|
+
error = "Completion mutation guard failed: implementation-style task completed without an observed mutation tool call.";
|
|
276
|
+
exitCode = exitCode === 0 ? 1 : exitCode;
|
|
277
|
+
if (modelAttempts?.length) {
|
|
278
|
+
modelAttempts = modelAttempts.map((attempt, index) => index === modelAttempts!.length - 1 ? { ...attempt, success: false, exitCode, error } : attempt);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
tasks = updateTask(tasks, task);
|
|
282
|
+
}
|
|
283
|
+
|
|
261
284
|
task = {
|
|
262
285
|
...task,
|
|
263
286
|
status: error ? "failed" : "completed",
|
|
@@ -24,6 +24,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
|
24
24
|
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
25
25
|
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
26
26
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
27
|
+
import { permissionForRole } from "./role-permission.ts";
|
|
27
28
|
|
|
28
29
|
export interface ExecuteTeamRunInput {
|
|
29
30
|
manifest: TeamRunManifest;
|
|
@@ -117,8 +118,11 @@ function slug(value: string): string {
|
|
|
117
118
|
|
|
118
119
|
function extractAdaptivePlanJson(text: string): string | undefined {
|
|
119
120
|
const markerMatch = text.match(/ADAPTIVE_PLAN_JSON_START\s*([\s\S]*?)\s*ADAPTIVE_PLAN_JSON_END/);
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
if (markerMatch?.[1]) return markerMatch[1];
|
|
122
|
+
const startIndex = text.indexOf("ADAPTIVE_PLAN_JSON_START");
|
|
123
|
+
if (startIndex >= 0) return text.slice(startIndex + "ADAPTIVE_PLAN_JSON_START".length).trim();
|
|
124
|
+
const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
125
|
+
return fencedMatch?.[1];
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
export function __test__parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
|
|
@@ -178,6 +182,52 @@ function closeUnbalancedJson(raw: string): string {
|
|
|
178
182
|
return result;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
function salvageCompletePhaseObjects(raw: string): unknown | undefined {
|
|
186
|
+
const phasesIndex = raw.indexOf('"phases"');
|
|
187
|
+
if (phasesIndex < 0) return undefined;
|
|
188
|
+
const arrayStart = raw.indexOf("[", phasesIndex);
|
|
189
|
+
if (arrayStart < 0) return undefined;
|
|
190
|
+
const phases: unknown[] = [];
|
|
191
|
+
let objectStart = -1;
|
|
192
|
+
let depth = 0;
|
|
193
|
+
let inString = false;
|
|
194
|
+
let escaped = false;
|
|
195
|
+
for (let index = arrayStart + 1; index < raw.length; index++) {
|
|
196
|
+
const char = raw[index];
|
|
197
|
+
if (escaped) {
|
|
198
|
+
escaped = false;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (char === "\\" && inString) {
|
|
202
|
+
escaped = true;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (char === '"') {
|
|
206
|
+
inString = !inString;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (inString) continue;
|
|
210
|
+
if (char === "{") {
|
|
211
|
+
if (depth === 0) objectStart = index;
|
|
212
|
+
depth++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === "}") {
|
|
216
|
+
if (depth <= 0) continue;
|
|
217
|
+
depth--;
|
|
218
|
+
if (depth === 0 && objectStart >= 0) {
|
|
219
|
+
try {
|
|
220
|
+
phases.push(JSON.parse(raw.slice(objectStart, index + 1)));
|
|
221
|
+
} catch {
|
|
222
|
+
// Ignore malformed trailing phase objects and keep earlier complete phases.
|
|
223
|
+
}
|
|
224
|
+
objectStart = -1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return phases.length ? { phases } : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
181
231
|
function adaptiveRoleAlias(role: string, allowed: Set<string>): string | undefined {
|
|
182
232
|
if (allowed.has(role)) return role;
|
|
183
233
|
const normalized = slug(role);
|
|
@@ -198,6 +248,7 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
|
|
|
198
248
|
if (!raw) return { repaired: false, reason: "missing-json" };
|
|
199
249
|
const candidates = [raw, closeUnbalancedJson(raw)];
|
|
200
250
|
let parsed: unknown;
|
|
251
|
+
let salvageUsed = false;
|
|
201
252
|
for (const candidate of candidates) {
|
|
202
253
|
try {
|
|
203
254
|
parsed = JSON.parse(candidate);
|
|
@@ -206,13 +257,17 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
|
|
|
206
257
|
// Try the next repair candidate.
|
|
207
258
|
}
|
|
208
259
|
}
|
|
260
|
+
if (!parsed) {
|
|
261
|
+
parsed = salvageCompletePhaseObjects(raw);
|
|
262
|
+
salvageUsed = parsed !== undefined;
|
|
263
|
+
}
|
|
209
264
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { repaired: false, reason: "invalid-json" };
|
|
210
265
|
const phasesRaw = Array.isArray((parsed as { phases?: unknown }).phases) ? (parsed as { phases: unknown[] }).phases : Array.isArray((parsed as { tasks?: unknown }).tasks) ? [{ name: "adaptive", tasks: (parsed as { tasks: unknown[] }).tasks }] : undefined;
|
|
211
266
|
if (!phasesRaw) return { repaired: false, reason: "missing-phases" };
|
|
212
267
|
const allowed = new Set(allowedRoles);
|
|
213
268
|
const phases: AdaptivePlanPhase[] = [];
|
|
214
269
|
let total = 0;
|
|
215
|
-
let repaired = raw !== closeUnbalancedJson(raw);
|
|
270
|
+
let repaired = salvageUsed || raw !== closeUnbalancedJson(raw);
|
|
216
271
|
for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
|
|
217
272
|
if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) continue;
|
|
218
273
|
const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
|
|
@@ -398,6 +453,47 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
|
|
|
398
453
|
return result.tasks.find((item) => item.id === taskId && item.status === "failed");
|
|
399
454
|
}
|
|
400
455
|
|
|
456
|
+
function requiresPlanApproval(workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
|
|
457
|
+
return workflow.name === "implementation" && runtimeConfig?.requirePlanApproval === true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
|
|
461
|
+
return manifest.planApproval?.required === true && manifest.planApproval.status === "pending";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isMutatingTask(task: TeamTaskState): boolean {
|
|
465
|
+
return permissionForRole(task.role) !== "read_only";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
|
|
469
|
+
if (manifest.planApproval) return manifest;
|
|
470
|
+
const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
|
|
471
|
+
const now = new Date().toISOString();
|
|
472
|
+
const updated: TeamRunManifest = {
|
|
473
|
+
...manifest,
|
|
474
|
+
updatedAt: now,
|
|
475
|
+
planApproval: {
|
|
476
|
+
required: true,
|
|
477
|
+
status: "pending",
|
|
478
|
+
requestedAt: now,
|
|
479
|
+
updatedAt: now,
|
|
480
|
+
planTaskId: assessTask?.id,
|
|
481
|
+
planArtifactPath: assessTask?.resultArtifact?.path,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
saveRunManifest(updated);
|
|
485
|
+
appendEvent(updated.eventsPath, { type: "plan.approval_required", runId: updated.runId, taskId: assessTask?.id, message: "Adaptive implementation plan requires explicit approval before mutating tasks run.", data: { planArtifactPath: assessTask?.resultArtifact?.path } });
|
|
486
|
+
return updated;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function cancelPlanTasks(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
490
|
+
return tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled", finishedAt: new Date().toISOString(), error: reason, graph: task.graph ? { ...task.graph, queue: "done" } : undefined } : task);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function hasPendingMutatingAdaptiveTask(tasks: TeamTaskState[]): boolean {
|
|
494
|
+
return tasks.some((task) => task.status === "queued" && task.adaptive && isMutatingTask(task));
|
|
495
|
+
}
|
|
496
|
+
|
|
401
497
|
export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
402
498
|
let workflow = input.workflow;
|
|
403
499
|
let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
|
|
@@ -422,7 +518,18 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
422
518
|
manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
|
|
423
519
|
return { manifest, tasks };
|
|
424
520
|
}
|
|
425
|
-
if (initialAdaptive.injected)
|
|
521
|
+
if (initialAdaptive.injected) {
|
|
522
|
+
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
523
|
+
queueIndex = buildTaskGraphIndex(tasks);
|
|
524
|
+
} else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
|
|
525
|
+
manifest = ensurePlanApprovalRequested(manifest, tasks);
|
|
526
|
+
}
|
|
527
|
+
if (manifest.planApproval?.status === "cancelled") {
|
|
528
|
+
tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
|
|
529
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
530
|
+
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
531
|
+
return { manifest, tasks };
|
|
532
|
+
}
|
|
426
533
|
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
427
534
|
await saveRunManifestAsync(manifest);
|
|
428
535
|
const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
|
|
@@ -451,8 +558,16 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
451
558
|
if (concurrency.reason.includes(";unbounded:")) {
|
|
452
559
|
appendEvent(manifest.eventsPath, { type: "limits.unbounded", runId: manifest.runId, message: "Unbounded worker concurrency was explicitly enabled for this run.", data: { concurrencyReason: concurrency.reason, maxConcurrent: concurrency.maxConcurrent } });
|
|
453
560
|
}
|
|
454
|
-
const
|
|
561
|
+
const approvalPending = isPlanApprovalPending(manifest);
|
|
562
|
+
const candidateBatch = approvalPending ? getReadyTasks(tasks, tasks.length, queueIndex) : getReadyTasks(tasks, concurrency.selectedCount, queueIndex);
|
|
563
|
+
const readyBatch = approvalPending ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, concurrency.selectedCount) : candidateBatch;
|
|
455
564
|
if (readyBatch.length === 0) {
|
|
565
|
+
if (approvalPending && candidateBatch.some(isMutatingTask)) {
|
|
566
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
567
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
568
|
+
manifest = updateRunStatus(manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
|
|
569
|
+
return { manifest, tasks };
|
|
570
|
+
}
|
|
456
571
|
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
457
572
|
await saveRunTasksAsync(manifest, tasks);
|
|
458
573
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
@@ -460,7 +575,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
460
575
|
return { manifest, tasks };
|
|
461
576
|
}
|
|
462
577
|
|
|
463
|
-
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: concurrency.reason } });
|
|
578
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason } });
|
|
464
579
|
const results = await mapConcurrent(
|
|
465
580
|
readyBatch,
|
|
466
581
|
concurrency.selectedCount,
|
|
@@ -520,7 +635,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
520
635
|
return { manifest, tasks };
|
|
521
636
|
}
|
|
522
637
|
if (injectedAfterBatch.injected) {
|
|
638
|
+
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
523
639
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
640
|
+
} else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
|
|
641
|
+
manifest = ensurePlanApprovalRequested(manifest, tasks);
|
|
642
|
+
}
|
|
643
|
+
if (manifest.planApproval?.status === "cancelled") {
|
|
644
|
+
tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
|
|
645
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
646
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
647
|
+
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
648
|
+
return { manifest, tasks };
|
|
524
649
|
}
|
|
525
650
|
await saveRunTasksAsync(manifest, tasks);
|
|
526
651
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
@@ -36,6 +36,9 @@ export const PiTeamsRuntimeConfigSchema = Type.Object({
|
|
|
36
36
|
inheritContext: Type.Optional(Type.Boolean()),
|
|
37
37
|
promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
|
|
38
38
|
groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
|
|
39
|
+
groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
40
|
+
requirePlanApproval: Type.Optional(Type.Boolean()),
|
|
41
|
+
completionMutationGuard: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")])),
|
|
39
42
|
}, { additionalProperties: false });
|
|
40
43
|
|
|
41
44
|
export const PiTeamsControlConfigSchema = Type.Object({
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
|
|
3
3
|
const SkillOverride = Type.Unsafe({
|
|
4
|
-
type: ["string", "array", "boolean"],
|
|
5
|
-
items: { type: "string" },
|
|
6
4
|
description: "Skill name(s) to inject, array of skill names, or false to disable role defaults.",
|
|
5
|
+
anyOf: [
|
|
6
|
+
{ type: "string" },
|
|
7
|
+
{ type: "array", items: { type: "string" } },
|
|
8
|
+
{ type: "boolean" },
|
|
9
|
+
],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const FreeformConfig = Type.Unsafe({
|
|
13
|
+
description: "Resource config for management actions.",
|
|
14
|
+
type: "object",
|
|
15
|
+
additionalProperties: true,
|
|
7
16
|
});
|
|
8
17
|
|
|
9
18
|
export const TeamToolParams = Type.Object({
|
|
@@ -66,7 +75,7 @@ export const TeamToolParams = Type.Object({
|
|
|
66
75
|
Type.Literal("project"),
|
|
67
76
|
Type.Literal("both"),
|
|
68
77
|
], { description: "Resource scope for discovery or management." })),
|
|
69
|
-
config: Type.Optional(
|
|
78
|
+
config: Type.Optional(FreeformConfig),
|
|
70
79
|
dryRun: Type.Optional(Type.Boolean({ description: "Preview a management mutation without writing files." })),
|
|
71
80
|
confirm: Type.Optional(Type.Boolean({ description: "Required for destructive management actions." })),
|
|
72
81
|
force: Type.Optional(Type.Boolean({ description: "Override reference checks for destructive management actions." })),
|
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import type { ArtifactDescriptor } from "./types.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
6
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
|
+
import { redactSecretString } from "../utils/redaction.ts";
|
|
7
8
|
|
|
8
9
|
function hashContent(content: string): string {
|
|
9
10
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -108,7 +109,8 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
108
109
|
resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
|
|
109
110
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
110
111
|
resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
|
|
111
|
-
|
|
112
|
+
const content = redactSecretString(options.content);
|
|
113
|
+
atomicWriteFile(filePath, content);
|
|
112
114
|
const stats = fs.statSync(filePath);
|
|
113
115
|
return {
|
|
114
116
|
kind: options.kind,
|
|
@@ -116,7 +118,7 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
116
118
|
createdAt: new Date().toISOString(),
|
|
117
119
|
producer: options.producer,
|
|
118
120
|
sizeBytes: stats.size,
|
|
119
|
-
contentHash: hashContent(
|
|
121
|
+
contentHash: hashContent(content),
|
|
120
122
|
retention: options.retention ?? "run",
|
|
121
123
|
};
|
|
122
124
|
}
|
package/src/state/event-log.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
7
8
|
|
|
8
9
|
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
|
|
9
10
|
export type TeamWatcherAction = "act" | "observe" | "ignore";
|
|
@@ -135,7 +136,7 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
|
|
|
135
136
|
} catch (error) {
|
|
136
137
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
137
138
|
}
|
|
138
|
-
fs.appendFileSync(eventsPath, `${JSON.stringify(fullEvent)}\n`, "utf-8");
|
|
139
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
|
|
139
140
|
const seq = fullEvent.metadata?.seq ?? 0;
|
|
140
141
|
try {
|
|
141
142
|
const stat = fs.statSync(eventsPath);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { redactJsonLine } from "../utils/redaction.ts";
|
|
2
3
|
|
|
3
4
|
export interface DrainableSource {
|
|
4
5
|
pause(): void;
|
|
@@ -50,7 +51,8 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
|
|
|
50
51
|
return {
|
|
51
52
|
writeLine(line: string) {
|
|
52
53
|
if (!stream || closed || !line.trim()) return;
|
|
53
|
-
const
|
|
54
|
+
const safeLine = redactJsonLine(line);
|
|
55
|
+
const chunk = `${safeLine}\n`;
|
|
54
56
|
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
|
|
55
57
|
if (bytesWritten + chunkBytes > maxBytes) return;
|
|
56
58
|
try {
|