@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.23
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 +32 -10
- package/README_ZH.md +32 -10
- package/lib/artifacts.js +342 -0
- package/lib/capabilities.js +6 -4
- package/lib/client/ActivityPanel.js +5 -2
- package/lib/client/agent-teams-card-definition.js +100 -6
- package/lib/client.js +90 -7
- package/lib/client.js.map +1 -1
- package/lib/command.js +3 -2
- package/lib/git-artifact-stamp.json +2 -2
- package/lib/harness-compat.js +70 -21
- package/lib/index.js +26 -3
- package/lib/lineage.js +198 -0
- package/lib/mailbox.js +59 -34
- package/lib/members.js +301 -53
- package/lib/profiles.js +3 -2
- package/lib/quality-gates.js +850 -65
- package/lib/scheduler.js +22 -3
- package/lib/snapshot.js +22 -1
- package/lib/state.js +279 -13
- package/lib/tool-policies.js +187 -0
- package/lib/tools.js +370 -89
- package/lib/types/artifacts.d.ts +66 -0
- package/lib/types/client/activity-monitor.d.ts +6 -0
- package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
- package/lib/types/harness-compat.d.ts +56 -14
- package/lib/types/index.d.ts +26 -1
- package/lib/types/lineage.d.ts +92 -0
- package/lib/types/members.d.ts +112 -1
- package/lib/types/profiles.d.ts +6 -0
- package/lib/types/quality-gates.d.ts +216 -10
- package/lib/types/scheduler.d.ts +2 -1
- package/lib/types/snapshot.d.ts +20 -0
- package/lib/types/state.d.ts +22 -5
- package/lib/types/tool-policies.d.ts +119 -0
- package/lib/types/tools.d.ts +12 -1
- package/lib/types/types.d.ts +95 -0
- package/lib/types/workspace-audit.d.ts +27 -0
- package/lib/workspace-audit.js +52 -0
- package/package.json +12 -5
- package/release-notes/v0.1.23.md +34 -0
- package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/scheduler.js
CHANGED
|
@@ -15,6 +15,8 @@ import { join } from 'node:path';
|
|
|
15
15
|
import { deliverToMember } from "./members.js";
|
|
16
16
|
import { isCurrentMail, mailboxPrompt } from "./mailbox.js";
|
|
17
17
|
import { markMailboxDelivered, discardMailboxMessages, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readPendingMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
|
|
18
|
+
import { contractStaleness, isVerdictKind, taskKindOf, verdictScopePatterns } from "./quality-gates.js";
|
|
19
|
+
import { digestWorkspacePaths, snapshotWorkspacePaths } from "./artifacts.js";
|
|
18
20
|
/** Per-dependency output cap in the assignment prompt. */
|
|
19
21
|
export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
|
|
20
22
|
/** Combined dependency-output budget in the assignment prompt. */
|
|
@@ -120,13 +122,16 @@ function nextReadyTask(tasks, memberName) {
|
|
|
120
122
|
return ready.find(task => task.assignee === memberName)
|
|
121
123
|
?? ready.find(task => task.assignee === undefined);
|
|
122
124
|
}
|
|
123
|
-
export function assignmentPrompt(ticket, stateDir, teamId) {
|
|
125
|
+
export function assignmentPrompt(ticket, stateDir, teamId, workOrderWarnings = []) {
|
|
124
126
|
const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
|
|
125
127
|
const seed = ticket.profileSeedId === undefined ? '' : ` [${ticket.profileSeedId}]`;
|
|
126
128
|
const goal = ticket.teamDescription?.trim() || '(not provided)';
|
|
127
129
|
const protocol = ticket.profileProtocol?.trim() || '(none)';
|
|
128
130
|
const executionPrompt = ticket.executionPrompt?.trim();
|
|
129
131
|
const kind = ticket.kind?.trim() || 'work';
|
|
132
|
+
const staleWorkOrder = workOrderWarnings.length === 0
|
|
133
|
+
? ''
|
|
134
|
+
: `\nBefore you edit anything: this auto-generated work order was derived from a workspace that has since changed.\n${workOrderWarnings.map((warning) => `- ${warning}`).join('\n')}\nRe-read the cited files first, locate the code by content anchor rather than by any position, and report a blocker instead of editing a target that no longer exists.\n`;
|
|
130
135
|
const contract = [
|
|
131
136
|
`Kind: ${kind}${ticket.round === undefined ? '' : ` (round ${ticket.round})`}`,
|
|
132
137
|
ticket.objective === undefined || ticket.objective === '' ? '' : `Objective: ${ticket.objective}`,
|
|
@@ -135,6 +140,7 @@ export function assignmentPrompt(ticket, stateDir, teamId) {
|
|
|
135
140
|
ticket.acceptance === undefined || ticket.acceptance.length === 0 ? '' : `Acceptance: ${ticket.acceptance.join('; ')}`,
|
|
136
141
|
ticket.verify === undefined || ticket.verify.length === 0 ? '' : `Verify: ${ticket.verify.join('; ')}`,
|
|
137
142
|
ticket.reviewedTaskId === undefined ? '' : `Reviewed task: ${ticket.reviewedTaskId}`,
|
|
143
|
+
ticket.findingSourceTaskIds === undefined || ticket.findingSourceTaskIds.length === 0 ? '' : `Fixes findings of: ${ticket.findingSourceTaskIds.join(', ')}`,
|
|
138
144
|
].filter((line) => line !== '').join('\n');
|
|
139
145
|
const structuredCompletion = ['implementation', 'repair', 'verification', 'integration'].includes(kind)
|
|
140
146
|
? `
|
|
@@ -161,7 +167,7 @@ Completed dependency results:
|
|
|
161
167
|
${formatDependencyOutputs(ticket.dependencyOutputs)}
|
|
162
168
|
|
|
163
169
|
Task: ${ticket.taskId}${seed} — ${ticket.subject}${description}
|
|
164
|
-
${contract === '' ? '' : `\nContract:\n${contract}\n`}
|
|
170
|
+
${staleWorkOrder}${contract === '' ? '' : `\nContract:\n${contract}\n`}
|
|
165
171
|
${structuredCompletion}
|
|
166
172
|
Attempt: ${ticket.attempt}
|
|
167
173
|
Attempt id: ${ticket.attemptId}
|
|
@@ -286,6 +292,14 @@ export function installTeamScheduler(ctx, config) {
|
|
|
286
292
|
changedPaths: task.changedPaths, acceptanceResults: task.acceptanceResults, commandsRun: task.commandsRun,
|
|
287
293
|
} : undefined;
|
|
288
294
|
const attemptId = beginTaskAttempt(task, currentMember.name);
|
|
295
|
+
// A verdict must be able to say which revision was on disk when it
|
|
296
|
+
// started, so a review that runs while someone else edits the tree
|
|
297
|
+
// cannot later claim to have judged stable bytes (issue #161).
|
|
298
|
+
if (isVerdictKind(taskKindOf(task))) {
|
|
299
|
+
const scope = verdictScopePatterns(fresh, task);
|
|
300
|
+
if (scope.length > 0)
|
|
301
|
+
task.verdictStartSnapshot = snapshotWorkspacePaths(workspace, scope);
|
|
302
|
+
}
|
|
289
303
|
// A recovered generation is parked before delivery. This makes each
|
|
290
304
|
// (member, attempt) recovery idempotent even if every status poll
|
|
291
305
|
// sees a disposed handle. Fresh pending work remains unparked so a
|
|
@@ -326,12 +340,17 @@ export function installTeamScheduler(ctx, config) {
|
|
|
326
340
|
...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
|
|
327
341
|
...task.verify === undefined ? {} : { verify: task.verify },
|
|
328
342
|
...task.reviewedTaskId === undefined ? {} : { reviewedTaskId: task.reviewedTaskId },
|
|
343
|
+
...task.findingSourceTaskIds === undefined ? {} : { findingSourceTaskIds: task.findingSourceTaskIds },
|
|
329
344
|
dependencyOutputs: collectCompletedDependencyOutputs(fresh.tasks, task.id, (message) => ctx.logger.warn(message)),
|
|
330
345
|
};
|
|
331
346
|
});
|
|
332
347
|
if (ticket === undefined)
|
|
333
348
|
return;
|
|
334
|
-
const
|
|
349
|
+
const taskForWarning = team.tasks.find((item) => item.id === ticket.taskId);
|
|
350
|
+
const workOrderStale = taskForWarning?.contractSnapshot === undefined
|
|
351
|
+
? []
|
|
352
|
+
: contractStaleness(taskForWarning, digestWorkspacePaths(workspace, taskForWarning.contractSnapshot.artifacts.map((artifact) => artifact.path)));
|
|
353
|
+
const prompt = assignmentPrompt(ticket, config.stateDir, team.id, workOrderStale);
|
|
335
354
|
const signal = new AbortController().signal;
|
|
336
355
|
const accepted = config.dispatch === undefined
|
|
337
356
|
? await deliverToMember(ctx, captain, ticket.memberId, prompt, signal)
|
package/lib/snapshot.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { readdir } from 'node:fs/promises';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { memberActivity } from "./members.js";
|
|
13
|
+
import { canDeclareDelivery, describeQualityLoop, verdictAuditNotes } from "./quality-gates.js";
|
|
14
|
+
import { auditTeamWorkspace } from "./workspace-audit.js";
|
|
13
15
|
import { CAPTAIN_KEY, listArchivedTeamIds, readArchivedTeam, readUnreadMailbox, readTeam, taskDepthsById, taskVisualState, } from "./state.js";
|
|
14
16
|
/** The current task of a member: its first unfinished owned task. */
|
|
15
17
|
function currentTaskOf(memberName, tasks) {
|
|
@@ -85,6 +87,17 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
85
87
|
};
|
|
86
88
|
});
|
|
87
89
|
const captainInbox = await readUnreadMailbox(stateRoot, state.id, CAPTAIN_KEY);
|
|
90
|
+
// The panel must reach the same delivery conclusion as the status tool, so
|
|
91
|
+
// it reads the same current bytes instead of trusting task records alone.
|
|
92
|
+
const audit = options.workspacePath === undefined ? undefined : auditTeamWorkspace(options.workspacePath, state);
|
|
93
|
+
const deliveryCheck = audit === undefined ? canDeclareDelivery(state) : canDeclareDelivery(state, audit);
|
|
94
|
+
const loop = audit === undefined ? describeQualityLoop(state) : describeQualityLoop(state, audit);
|
|
95
|
+
const staleById = new Map();
|
|
96
|
+
if (audit !== undefined) {
|
|
97
|
+
for (const note of verdictAuditNotes(state, audit)) {
|
|
98
|
+
staleById.set(note.taskId, [...staleById.get(note.taskId) ?? [], note.message]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
88
101
|
return {
|
|
89
102
|
workspace,
|
|
90
103
|
teamId: state.id,
|
|
@@ -96,6 +109,11 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
96
109
|
? { planReviewState: state.planReviewState ?? 'awaiting_review' }
|
|
97
110
|
: {},
|
|
98
111
|
...state.halted === true ? { halted: true } : {},
|
|
112
|
+
...audit === undefined ? {} : {
|
|
113
|
+
loopState: loop.state,
|
|
114
|
+
deliverable: loop.deliverable,
|
|
115
|
+
delivery: { ok: deliveryCheck.ok, blockers: [...deliveryCheck.blockers], warnings: [...deliveryCheck.warnings] },
|
|
116
|
+
},
|
|
99
117
|
members,
|
|
100
118
|
tasks: tasks.map((task) => ({
|
|
101
119
|
id: task.id,
|
|
@@ -110,6 +128,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
110
128
|
...task.kind === undefined ? {} : { kind: task.kind },
|
|
111
129
|
...task.round === undefined ? {} : { round: task.round },
|
|
112
130
|
...task.verdict === undefined ? {} : { verdict: task.verdict },
|
|
131
|
+
...staleById.get(task.id) === undefined ? {} : { verdictStale: staleById.get(task.id).join('; ') },
|
|
113
132
|
updatedAt: task.updatedAt,
|
|
114
133
|
})),
|
|
115
134
|
messageCount: captainInbox.length
|
|
@@ -146,7 +165,9 @@ export async function collectTeamsActivity(ctx, roots) {
|
|
|
146
165
|
const state = await readTeam(root.stateRoot, entry.name);
|
|
147
166
|
if (state === undefined)
|
|
148
167
|
continue;
|
|
149
|
-
snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state
|
|
168
|
+
snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state, {
|
|
169
|
+
...root.workspacePath === undefined ? {} : { workspacePath: root.workspacePath },
|
|
170
|
+
}));
|
|
150
171
|
}
|
|
151
172
|
catch {
|
|
152
173
|
ctx.logger.warn(`agent-teams: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}"`);
|
package/lib/state.js
CHANGED
|
@@ -16,9 +16,10 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
20
|
-
import { hasValidQualityTaskFields, isReviewPolicy, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
|
|
21
|
-
|
|
19
|
+
import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, TERMINAL_TASK_STATUSES } from "./types.js";
|
|
20
|
+
import { hasValidQualityTaskFields, isReviewPolicy, isValidFindingLine, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
|
|
21
|
+
import { teamLeadMatch } from "./lineage.js";
|
|
22
|
+
export { amendTaskContract, buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, isTaskRevision, isValidFindingLine, normalizeBlankOptionalTaskFields, normalizeSentinelNumericTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
22
23
|
/** Mailbox key of the captain. */
|
|
23
24
|
export const CAPTAIN_KEY = 'captain';
|
|
24
25
|
/** A crashed live-delivery attempt becomes retryable after this interval. */
|
|
@@ -205,7 +206,7 @@ export async function readTeam(stateRoot, teamId) {
|
|
|
205
206
|
const value = JSON.parse(stripLeadingBom(raw));
|
|
206
207
|
const team = coerceTeamState(value, teamId);
|
|
207
208
|
if (team === undefined) {
|
|
208
|
-
throw new Error(`invalid AgentTeams state in team "${teamId}"`);
|
|
209
|
+
throw new Error(`invalid AgentTeams state in team "${teamId}": ${describeTeamStateDefect(value, teamId)}`);
|
|
209
210
|
}
|
|
210
211
|
return team;
|
|
211
212
|
}
|
|
@@ -231,7 +232,7 @@ export function readTeamSync(stateRoot, teamId) {
|
|
|
231
232
|
const value = JSON.parse(stripLeadingBom(raw));
|
|
232
233
|
const team = coerceTeamState(value, teamId);
|
|
233
234
|
if (team === undefined) {
|
|
234
|
-
throw new Error(`invalid AgentTeams state in team "${teamId}"`);
|
|
235
|
+
throw new Error(`invalid AgentTeams state in team "${teamId}": ${describeTeamStateDefect(value, teamId)}`);
|
|
235
236
|
}
|
|
236
237
|
return team;
|
|
237
238
|
}
|
|
@@ -297,9 +298,11 @@ export async function recordRetiredMemberIds(stateRoot, memberIds) {
|
|
|
297
298
|
* Find the team owned by one captain session (at most one per captain).
|
|
298
299
|
* @param stateRoot - resolved absolute state root directory.
|
|
299
300
|
* @param captainSessionId - the owning session id.
|
|
301
|
+
* @param lead - durable lead identity of the calling session, when it may lead
|
|
302
|
+
* through a fork chain; omitted keeps the exact-id contract.
|
|
300
303
|
* @returns the team record, or undefined when the captain leads no team.
|
|
301
304
|
*/
|
|
302
|
-
export async function findTeamByCaptain(stateRoot, captainSessionId) {
|
|
305
|
+
export async function findTeamByCaptain(stateRoot, captainSessionId, lead) {
|
|
303
306
|
let entries;
|
|
304
307
|
try {
|
|
305
308
|
entries = await readdir(stateRoot, { withFileTypes: true });
|
|
@@ -315,7 +318,8 @@ export async function findTeamByCaptain(stateRoot, captainSessionId) {
|
|
|
315
318
|
if (!entry.isDirectory())
|
|
316
319
|
continue;
|
|
317
320
|
const team = await readTeam(stateRoot, entry.name);
|
|
318
|
-
if (team
|
|
321
|
+
if (team !== undefined && (team.captainSessionId === captainSessionId
|
|
322
|
+
|| (lead !== undefined && teamLeadMatch(team, lead)))) {
|
|
319
323
|
if (found !== undefined && found.id !== team.id) {
|
|
320
324
|
throw new Error(`captain session leads multiple active teams ("${found.id}", "${team.id}"); archive one before continuing`);
|
|
321
325
|
}
|
|
@@ -326,13 +330,15 @@ export async function findTeamByCaptain(stateRoot, captainSessionId) {
|
|
|
326
330
|
}
|
|
327
331
|
/**
|
|
328
332
|
* Find the team in which one session is an active participant.
|
|
329
|
-
* Captains match `captainSessionId
|
|
330
|
-
*
|
|
333
|
+
* Captains match `captainSessionId` — including a lead session that inherited
|
|
334
|
+
* it through a fork — and members match their durable child session id.
|
|
335
|
+
* Removed members no longer have access to team-scoped tools.
|
|
331
336
|
* @param stateRoot - resolved absolute state root directory.
|
|
332
337
|
* @param agentSessionId - calling captain/member session id.
|
|
338
|
+
* @param lead - durable lead identity of the calling session, when available.
|
|
333
339
|
* @returns the team record, or undefined when the caller belongs to no team.
|
|
334
340
|
*/
|
|
335
|
-
export async function findTeamByParticipant(stateRoot, agentSessionId) {
|
|
341
|
+
export async function findTeamByParticipant(stateRoot, agentSessionId, lead) {
|
|
336
342
|
let entries;
|
|
337
343
|
try {
|
|
338
344
|
entries = await readdir(stateRoot, { withFileTypes: true });
|
|
@@ -348,8 +354,9 @@ export async function findTeamByParticipant(stateRoot, agentSessionId) {
|
|
|
348
354
|
if (!entry.isDirectory())
|
|
349
355
|
continue;
|
|
350
356
|
const team = await readTeam(stateRoot, entry.name);
|
|
351
|
-
const participates = team
|
|
352
|
-
||
|
|
357
|
+
const participates = team !== undefined && (team.captainSessionId === agentSessionId
|
|
358
|
+
|| (lead !== undefined && teamLeadMatch(team, lead))
|
|
359
|
+
|| team.members.some((member) => member.id === agentSessionId && member.status !== 'removed'));
|
|
353
360
|
if (participates && team !== undefined) {
|
|
354
361
|
if (found !== undefined && found.id !== team.id) {
|
|
355
362
|
throw new Error(`agent session belongs to multiple active teams ("${found.id}", "${team.id}"); the target team is ambiguous`);
|
|
@@ -603,6 +610,36 @@ function isRecord(value) {
|
|
|
603
610
|
function isOptionalString(value) {
|
|
604
611
|
return value === undefined || typeof value === 'string';
|
|
605
612
|
}
|
|
613
|
+
/** Whether a value is a non-empty (non-blank) string. */
|
|
614
|
+
function nonemptyString(value) {
|
|
615
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
616
|
+
}
|
|
617
|
+
/** Whether a value is an absent or non-blank string (a persisted id/name). */
|
|
618
|
+
function isOptionalNonBlankString(value) {
|
|
619
|
+
return value === undefined || (typeof value === 'string' && value.trim() !== '');
|
|
620
|
+
}
|
|
621
|
+
/** Whether a value is a non-empty list of non-blank tool names. */
|
|
622
|
+
function isToolNameList(value) {
|
|
623
|
+
return Array.isArray(value)
|
|
624
|
+
&& value.length > 0
|
|
625
|
+
&& value.every(name => typeof name === 'string' && name.trim() !== '');
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Validate one persisted effective tool filter (#59). `deny` is part of the
|
|
629
|
+
* contract and never empty (the captain-only deny list is always included); an
|
|
630
|
+
* `allow` list, when present, is non-empty for the same reason. A wrong-typed
|
|
631
|
+
* or empty list is rejected instead of being silently accepted at the durable
|
|
632
|
+
* boundary — the same class of drift #188 fixed for team records.
|
|
633
|
+
*/
|
|
634
|
+
function isMemberToolFilter(value) {
|
|
635
|
+
if (!isRecord(value))
|
|
636
|
+
return false;
|
|
637
|
+
const allow = value['allow'];
|
|
638
|
+
const deny = value['deny'];
|
|
639
|
+
if (allow !== undefined && !isToolNameList(allow))
|
|
640
|
+
return false;
|
|
641
|
+
return isToolNameList(deny);
|
|
642
|
+
}
|
|
606
643
|
/** Whether a value is a finite timestamp/counter number. */
|
|
607
644
|
function isFiniteNumber(value) {
|
|
608
645
|
return typeof value === 'number' && Number.isFinite(value);
|
|
@@ -625,6 +662,9 @@ function isTeamMember(value) {
|
|
|
625
662
|
&& (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
|
|
626
663
|
&& (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
|
|
627
664
|
&& (value['fallbackActive'] === undefined || typeof value['fallbackActive'] === 'boolean')
|
|
665
|
+
// A member without a policy keeps the legacy shape: both fields stay absent.
|
|
666
|
+
&& isOptionalNonBlankString(value['toolPolicy'])
|
|
667
|
+
&& (value['toolFilter'] === undefined || isMemberToolFilter(value['toolFilter']))
|
|
628
668
|
&& isFiniteNumber(value['joinedAt'])
|
|
629
669
|
&& (value['status'] === 'idle' || value['status'] === 'working' || value['status'] === 'removed');
|
|
630
670
|
}
|
|
@@ -683,7 +723,9 @@ function coerceTeamState(value, expectedId) {
|
|
|
683
723
|
// Tolerate legacy dirty records instead of bricking the whole team on
|
|
684
724
|
// reload: blank optional fields written by older builds (or by models that
|
|
685
725
|
// materialize optionals as "") are normalized to omitted, matching the
|
|
686
|
-
// profileSeedId handling below and the tool-input normalization.
|
|
726
|
+
// profileSeedId handling below and the tool-input normalization. The same
|
|
727
|
+
// pass drops out-of-contract numeric sentinels such as `line: -1`
|
|
728
|
+
// (issue #188), so an already-written bad record self-heals on read.
|
|
687
729
|
const cleaned = normalizeBlankOptionalTaskFields(task);
|
|
688
730
|
if (cleaned['profileSeedId'] !== undefined && (typeof cleaned['profileSeedId'] !== 'string' || cleaned['profileSeedId'].trim() === '')) {
|
|
689
731
|
const next = { ...cleaned };
|
|
@@ -695,6 +737,230 @@ function coerceTeamState(value, expectedId) {
|
|
|
695
737
|
const coerced = { ...value, tasks };
|
|
696
738
|
return isTeamState(coerced, expectedId) ? coerced : undefined;
|
|
697
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* Name the first durable defect in a record `coerceTeamState` rejected. Before
|
|
742
|
+
* this, a rejected `team.json` surfaced only as
|
|
743
|
+
* `invalid AgentTeams state in team "x"` — no task, no field, no value — while
|
|
744
|
+
* every AgentTeams tool (read *and* write) failed at once, leaving an operator
|
|
745
|
+
* to bisect the file by hand (issue #188). The detail is computed only on the
|
|
746
|
+
* failure path, so the happy path keeps its existing cost.
|
|
747
|
+
* @param raw - the parsed record as read from disk, before coercion.
|
|
748
|
+
* @param expectedId - the team id the record was read for.
|
|
749
|
+
* @returns a `task "t2": findings[0].line -1 must be ...` style detail.
|
|
750
|
+
*/
|
|
751
|
+
export function describeTeamStateDefect(raw, expectedId) {
|
|
752
|
+
if (!isRecord(raw))
|
|
753
|
+
return 'record must be a JSON object';
|
|
754
|
+
if (!Array.isArray(raw['tasks'])) {
|
|
755
|
+
return raw['tasks'] === undefined ? 'tasks is missing' : `tasks must be an array (got ${typeof raw['tasks']})`;
|
|
756
|
+
}
|
|
757
|
+
const tasks = raw['tasks'].map((task) => isRecord(task) ? normalizeBlankOptionalTaskFields(task) : task);
|
|
758
|
+
for (const [index, task] of tasks.entries()) {
|
|
759
|
+
if (!isRecord(task))
|
|
760
|
+
return `tasks[${index}] must be an object`;
|
|
761
|
+
const label = typeof task['id'] === 'string' && task['id'] !== '' ? `task "${task['id']}"` : `tasks[${index}]`;
|
|
762
|
+
const detail = describeTaskDefect(task);
|
|
763
|
+
if (detail === undefined)
|
|
764
|
+
continue;
|
|
765
|
+
return `${label}: ${detail}`;
|
|
766
|
+
}
|
|
767
|
+
const ids = tasks.filter(isRecord).map((task) => task['id']);
|
|
768
|
+
if (ids.some((id) => typeof id !== 'string' || id === ''))
|
|
769
|
+
return 'a task id must be a non-empty string';
|
|
770
|
+
const seen = new Set();
|
|
771
|
+
for (const id of ids) {
|
|
772
|
+
if (seen.has(id))
|
|
773
|
+
return `task "${id}": duplicated task id`;
|
|
774
|
+
seen.add(id);
|
|
775
|
+
}
|
|
776
|
+
if (raw['id'] !== expectedId)
|
|
777
|
+
return `id ${JSON.stringify(raw['id'])} must equal "${expectedId}"`;
|
|
778
|
+
if (typeof raw['name'] !== 'string' || raw['name'].trim() === '')
|
|
779
|
+
return 'name must be a non-empty string';
|
|
780
|
+
if (!isOptionalString(raw['description']))
|
|
781
|
+
return 'description must be a string';
|
|
782
|
+
if (raw['profile'] !== undefined && !isTeamProfileSnapshot(raw['profile']))
|
|
783
|
+
return 'profile is not a valid profile snapshot';
|
|
784
|
+
if (typeof raw['captainSessionId'] !== 'string' || raw['captainSessionId'] === '')
|
|
785
|
+
return 'captainSessionId must be a non-empty string';
|
|
786
|
+
if (!isFiniteNumber(raw['createdAt']))
|
|
787
|
+
return 'createdAt must be a finite number';
|
|
788
|
+
if (!Array.isArray(raw['members']))
|
|
789
|
+
return 'members must be an array';
|
|
790
|
+
const memberKeys = new Set();
|
|
791
|
+
const memberIds = new Set();
|
|
792
|
+
for (const [index, member] of raw['members'].entries()) {
|
|
793
|
+
if (!isTeamMember(member))
|
|
794
|
+
return `members[${index}] is not a valid member record`;
|
|
795
|
+
const key = sanitizeKey(member.name);
|
|
796
|
+
if (key === CAPTAIN_KEY)
|
|
797
|
+
return `members[${index}]: name "${member.name}" is reserved for the captain`;
|
|
798
|
+
if (memberKeys.has(key))
|
|
799
|
+
return `members[${index}]: name "${member.name}" is already used by another member`;
|
|
800
|
+
memberKeys.add(key);
|
|
801
|
+
if (member.id === '')
|
|
802
|
+
continue;
|
|
803
|
+
if (memberIds.has(member.id))
|
|
804
|
+
return `members[${index}]: id "${member.id}" is duplicated`;
|
|
805
|
+
memberIds.add(member.id);
|
|
806
|
+
}
|
|
807
|
+
if (!Number.isSafeInteger(raw['taskSeq']) || raw['taskSeq'] < 0)
|
|
808
|
+
return 'taskSeq must be an integer >= 0';
|
|
809
|
+
if (raw['phase'] !== undefined && raw['phase'] !== 'staged' && raw['phase'] !== 'running') {
|
|
810
|
+
return `phase ${JSON.stringify(raw['phase'])} must be "staged" or "running"`;
|
|
811
|
+
}
|
|
812
|
+
if (raw['planReviewState'] !== undefined
|
|
813
|
+
&& raw['planReviewState'] !== 'awaiting_review'
|
|
814
|
+
&& raw['planReviewState'] !== 'awaiting_feedback') {
|
|
815
|
+
return `planReviewState ${JSON.stringify(raw['planReviewState'])} is invalid`;
|
|
816
|
+
}
|
|
817
|
+
for (const key of ['approvedAt', 'haltedAt']) {
|
|
818
|
+
if (raw[key] !== undefined && !isFiniteNumber(raw[key]))
|
|
819
|
+
return `${key} must be a finite number`;
|
|
820
|
+
}
|
|
821
|
+
if (raw['halted'] !== undefined && typeof raw['halted'] !== 'boolean')
|
|
822
|
+
return 'halted must be a boolean';
|
|
823
|
+
if (raw['reviewPolicy'] !== undefined && !isReviewPolicy(raw['reviewPolicy']))
|
|
824
|
+
return 'reviewPolicy is invalid';
|
|
825
|
+
if (raw['escalated'] !== undefined && typeof raw['escalated'] !== 'boolean')
|
|
826
|
+
return 'escalated must be a boolean';
|
|
827
|
+
return 'record does not satisfy the durable team contract';
|
|
828
|
+
}
|
|
829
|
+
/** The first task-contract violation, or `undefined` when the task is valid. */
|
|
830
|
+
function describeTaskDefect(task) {
|
|
831
|
+
if (typeof task['id'] !== 'string')
|
|
832
|
+
return 'id must be a string';
|
|
833
|
+
if (!isOptionalString(task['profileSeedId']))
|
|
834
|
+
return 'profileSeedId must be a string';
|
|
835
|
+
if (typeof task['profileSeedId'] === 'string' && task['profileSeedId'].trim() === '')
|
|
836
|
+
return 'profileSeedId must not be blank';
|
|
837
|
+
if (typeof task['subject'] !== 'string')
|
|
838
|
+
return 'subject must be a string';
|
|
839
|
+
if (!isOptionalString(task['description']))
|
|
840
|
+
return 'description must be a string';
|
|
841
|
+
const statuses = ['pending', 'claimed', 'in_progress', 'completed', 'failed', 'cancelled'];
|
|
842
|
+
if (!statuses.includes(task['status']))
|
|
843
|
+
return `status ${JSON.stringify(task['status'])} is invalid`;
|
|
844
|
+
if (!isOptionalString(task['assignee']))
|
|
845
|
+
return 'assignee must be a string';
|
|
846
|
+
if (!Array.isArray(task['dependencies']))
|
|
847
|
+
return 'dependencies must be an array';
|
|
848
|
+
if (!task['dependencies'].every((dependency) => typeof dependency === 'string'))
|
|
849
|
+
return 'dependencies must contain only strings';
|
|
850
|
+
if (!isOptionalString(task['output']))
|
|
851
|
+
return 'output must be a string';
|
|
852
|
+
if (task['attempt'] !== undefined && !(Number.isSafeInteger(task['attempt']) && task['attempt'] >= 0)) {
|
|
853
|
+
return `attempt ${JSON.stringify(task['attempt'])} must be an integer >= 0`;
|
|
854
|
+
}
|
|
855
|
+
if (!isOptionalString(task['attemptId']))
|
|
856
|
+
return 'attemptId must be a string';
|
|
857
|
+
if (!isOptionalString(task['handoffId']))
|
|
858
|
+
return 'handoffId must be a string';
|
|
859
|
+
if (!isOptionalString(task['handoffFromMemberId']))
|
|
860
|
+
return 'handoffFromMemberId must be a string';
|
|
861
|
+
if (task['reassigning'] !== undefined && typeof task['reassigning'] !== 'boolean')
|
|
862
|
+
return 'reassigning must be a boolean';
|
|
863
|
+
if (!isFiniteNumber(task['createdAt']))
|
|
864
|
+
return 'createdAt must be a finite number';
|
|
865
|
+
if (!isFiniteNumber(task['updatedAt']))
|
|
866
|
+
return 'updatedAt must be a finite number';
|
|
867
|
+
return describeQualityTaskDefect(task);
|
|
868
|
+
}
|
|
869
|
+
/** Mirror of `hasValidQualityTaskFields`, reporting the first failing field. */
|
|
870
|
+
function describeQualityTaskDefect(task) {
|
|
871
|
+
if (task['kind'] !== undefined && !TASK_KINDS.includes(task['kind'])) {
|
|
872
|
+
return `kind ${JSON.stringify(task['kind'])} is invalid`;
|
|
873
|
+
}
|
|
874
|
+
if (task['verdict'] !== undefined && !REVIEW_VERDICTS.includes(task['verdict'])) {
|
|
875
|
+
return `verdict ${JSON.stringify(task['verdict'])} is invalid`;
|
|
876
|
+
}
|
|
877
|
+
if (task['round'] !== undefined && !(Number.isSafeInteger(task['round']) && task['round'] >= 1)) {
|
|
878
|
+
return `round ${JSON.stringify(task['round'])} must be an integer >= 1`;
|
|
879
|
+
}
|
|
880
|
+
for (const key of ['objective', 'reviewedTaskId', 'sourceTaskId']) {
|
|
881
|
+
if (task[key] !== undefined && !nonemptyString(task[key]))
|
|
882
|
+
return `${key} must be a non-empty string`;
|
|
883
|
+
}
|
|
884
|
+
if (task['reviewedAttempt'] !== undefined
|
|
885
|
+
&& !(Number.isSafeInteger(task['reviewedAttempt']) && task['reviewedAttempt'] >= 0)) {
|
|
886
|
+
return `reviewedAttempt ${JSON.stringify(task['reviewedAttempt'])} must be an integer >= 0`;
|
|
887
|
+
}
|
|
888
|
+
const stringLists = ['inScope', 'outOfScope', 'acceptance', 'verify', 'deliverables', 'nonGoals', 'changedPaths', 'sourceFindingIds', 'coverageOf'];
|
|
889
|
+
for (const key of stringLists) {
|
|
890
|
+
const value = task[key];
|
|
891
|
+
if (value === undefined)
|
|
892
|
+
continue;
|
|
893
|
+
if (!Array.isArray(value))
|
|
894
|
+
return `${key} must be an array`;
|
|
895
|
+
const badIndex = value.findIndex((item) => !nonemptyString(item));
|
|
896
|
+
if (badIndex >= 0)
|
|
897
|
+
return `${key}[${badIndex}] must be a non-empty string`;
|
|
898
|
+
}
|
|
899
|
+
if (task['findings'] !== undefined) {
|
|
900
|
+
const findings = task['findings'];
|
|
901
|
+
if (!Array.isArray(findings))
|
|
902
|
+
return 'findings must be an array';
|
|
903
|
+
for (const [index, finding] of findings.entries()) {
|
|
904
|
+
const detail = describeFindingDefect(finding);
|
|
905
|
+
if (detail !== undefined)
|
|
906
|
+
return `findings[${index}].${detail}`;
|
|
907
|
+
}
|
|
908
|
+
const ids = findings.map((finding) => finding.id);
|
|
909
|
+
if (new Set(ids).size !== ids.length)
|
|
910
|
+
return 'findings ids must be unique';
|
|
911
|
+
}
|
|
912
|
+
if (task['acceptanceResults'] !== undefined) {
|
|
913
|
+
const results = task['acceptanceResults'];
|
|
914
|
+
if (!Array.isArray(results))
|
|
915
|
+
return 'acceptanceResults must be an array';
|
|
916
|
+
for (const [index, result] of results.entries()) {
|
|
917
|
+
if (!isRecord(result))
|
|
918
|
+
return `acceptanceResults[${index}] must be an object`;
|
|
919
|
+
if (!nonemptyString(result['criterion']))
|
|
920
|
+
return `acceptanceResults[${index}].criterion must be a non-empty string`;
|
|
921
|
+
if (result['status'] !== 'passed' && result['status'] !== 'failed')
|
|
922
|
+
return `acceptanceResults[${index}].status must be "passed" or "failed"`;
|
|
923
|
+
if (result['evidence'] !== undefined && typeof result['evidence'] !== 'string')
|
|
924
|
+
return `acceptanceResults[${index}].evidence must be a string`;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (task['commandsRun'] !== undefined) {
|
|
928
|
+
const commands = task['commandsRun'];
|
|
929
|
+
if (!Array.isArray(commands))
|
|
930
|
+
return 'commandsRun must be an array';
|
|
931
|
+
for (const [index, command] of commands.entries()) {
|
|
932
|
+
if (!isRecord(command))
|
|
933
|
+
return `commandsRun[${index}] must be an object`;
|
|
934
|
+
if (!nonemptyString(command['command']))
|
|
935
|
+
return `commandsRun[${index}].command must be a non-empty string`;
|
|
936
|
+
if (command['status'] !== 'passed' && command['status'] !== 'failed')
|
|
937
|
+
return `commandsRun[${index}].status must be "passed" or "failed"`;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return undefined;
|
|
941
|
+
}
|
|
942
|
+
/** The first finding-contract violation, or `undefined` when the finding is valid. */
|
|
943
|
+
function describeFindingDefect(finding) {
|
|
944
|
+
if (!isRecord(finding))
|
|
945
|
+
return 'must be an object';
|
|
946
|
+
if (!nonemptyString(finding['id']))
|
|
947
|
+
return 'id must be a non-empty string';
|
|
948
|
+
if (!FINDING_SEVERITIES.includes(finding['severity'])) {
|
|
949
|
+
return `severity ${JSON.stringify(finding['severity'])} is invalid`;
|
|
950
|
+
}
|
|
951
|
+
if (!nonemptyString(finding['problem']))
|
|
952
|
+
return 'problem must be a non-empty string';
|
|
953
|
+
if (!nonemptyString(finding['requiredFix']))
|
|
954
|
+
return 'requiredFix must be a non-empty string';
|
|
955
|
+
if (finding['file'] !== undefined && !nonemptyString(finding['file']))
|
|
956
|
+
return 'file must be a non-empty string';
|
|
957
|
+
if (finding['line'] !== undefined && !isValidFindingLine(finding['line'])) {
|
|
958
|
+
return `line ${JSON.stringify(finding['line'])} must be an integer >= 0 (omit it when there is no line number)`;
|
|
959
|
+
}
|
|
960
|
+
if (finding['resolved'] !== undefined && typeof finding['resolved'] !== 'boolean')
|
|
961
|
+
return 'resolved must be a boolean';
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
698
964
|
export function isTeamTask(value) {
|
|
699
965
|
if (!isRecord(value))
|
|
700
966
|
return false;
|