@nanmicoder/dsh-agent-teams 0.1.17 → 0.1.19
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 +7 -5
- package/README_ZH.md +8 -4
- package/lib/client/AgentTeamsCard.js +1 -1
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/harness-compat.js +106 -0
- package/lib/index.js +4 -4
- package/lib/mailbox.js +154 -0
- package/lib/members.js +97 -23
- package/lib/quality-gates.js +181 -10
- package/lib/scheduler.js +27 -15
- package/lib/snapshot.js +1 -1
- package/lib/state.js +26 -6
- package/lib/tool-names.js +1 -0
- package/lib/tools.js +312 -214
- package/lib/types/event-types.d.ts +15 -1
- package/lib/types/harness-compat.d.ts +46 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/mailbox.d.ts +7 -0
- package/lib/types/members.d.ts +6 -4
- package/lib/types/quality-gates.d.ts +51 -1
- package/lib/types/scheduler.d.ts +1 -0
- package/lib/types/state.d.ts +7 -1
- package/lib/types/tool-names.d.ts +2 -2
- package/lib/types/types.d.ts +30 -0
- package/package.json +6 -4
- package/release-notes/v0.1.18.md +21 -0
- package/release-notes/v0.1.19.md +20 -0
package/lib/quality-gates.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* coverage, and resume. Tools and persistence call these; they do not I/O.
|
|
4
4
|
* @module dsh-agent-teams/quality-gates
|
|
5
5
|
*/
|
|
6
|
-
import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, } from "./types.js";
|
|
6
|
+
import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, TERMINAL_TASK_STATUSES, } from "./types.js";
|
|
7
7
|
const QUALITY_KINDS = [
|
|
8
8
|
'requirements',
|
|
9
9
|
'implementation',
|
|
@@ -269,13 +269,14 @@ export function validateCreateTask(team, input) {
|
|
|
269
269
|
if (kind === 'implementation') {
|
|
270
270
|
const requirements = team.tasks.filter((item) => taskKindOf(item) === 'requirements');
|
|
271
271
|
const passed = requirements.some((item) => item.status === 'completed' && item.verdict === 'pass');
|
|
272
|
-
const
|
|
273
|
-
|
|
272
|
+
const behindRequirements = requirements.some((item) => (dependencyClosureContains(team.tasks, dependencies, item.id)));
|
|
273
|
+
// Planning an implementation is safe in either approval mode when its
|
|
274
|
+
// dependency chain fences execution behind requirements. Scheduling and
|
|
275
|
+
// claiming still wait for successful dependency completion.
|
|
276
|
+
if (requirements.length > 0 && !passed && !behindRequirements) {
|
|
274
277
|
return {
|
|
275
278
|
ok: false,
|
|
276
|
-
error:
|
|
277
|
-
? 'implementation must depend on the staged requirements task; it will run only after requirements passes'
|
|
278
|
-
: 'implementation is blocked until a requirements task completes with verdict=pass',
|
|
279
|
+
error: 'implementation must depend on a requirements task until requirements completes with verdict=pass',
|
|
279
280
|
};
|
|
280
281
|
}
|
|
281
282
|
}
|
|
@@ -404,6 +405,140 @@ function unresolvedFindings(task) {
|
|
|
404
405
|
function findingKey(ids) {
|
|
405
406
|
return [...ids].sort().join(',');
|
|
406
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Path-like tokens worth considering as repair-scope candidates. Two shapes:
|
|
410
|
+
* slash paths (`src/parser.ts`, `docs/guide.md`) and bare filenames with a
|
|
411
|
+
* known code/doc extension (`README.md`, `wc.js`). An optional `:line`
|
|
412
|
+
* suffix is tolerated and stripped. The extension allowlist keeps version
|
|
413
|
+
* tokens (`v0.1.17`), hex hashes, and prose out of the derived scope.
|
|
414
|
+
*/
|
|
415
|
+
const REPAIR_SCOPE_PATH_PATTERN = /(?:[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)+|[\w.\-]+\.(?:tsx?|jsx?|mjs|cjs|json|md|txt|ya?ml|py|rs|go|java|html?|css|scss|sh|ps1|toml|xml|sql))(?::\d+)?/gu;
|
|
416
|
+
const REPAIR_SCOPE_LINE_SUFFIX = /:\d+$/;
|
|
417
|
+
/**
|
|
418
|
+
* Derive the repair round's inScope from the findings that caused it.
|
|
419
|
+
*
|
|
420
|
+
* `finding.file` records where the problem was OBSERVED, but the fix often
|
|
421
|
+
* targets a different file named in `requiredFix` (docs vs sample data,
|
|
422
|
+
* config vs code). Deriving the scope from both keeps the auto-generated
|
|
423
|
+
* repair contract satisfiable; deriving from `file` alone can produce a
|
|
424
|
+
* contract where the acceptance ("edit README.md") names a path the scope
|
|
425
|
+
* forbids, so no honest completion exists and the repair dead-locks.
|
|
426
|
+
*
|
|
427
|
+
* Absolute and otherwise illegal paths are dropped (they can never match
|
|
428
|
+
* workspace-relative scope patterns anyway); when nothing legal remains,
|
|
429
|
+
* the source task's own inScope is kept as the fallback. Over-inclusion is
|
|
430
|
+
* accepted: inScope is an audit upper bound, and the requiredFix text still
|
|
431
|
+
* tells the implementer what to touch.
|
|
432
|
+
*
|
|
433
|
+
* Keep all derived paths until the generator resolves inherited exclusions;
|
|
434
|
+
* filtering first would silently discard a required fix target.
|
|
435
|
+
*/
|
|
436
|
+
export function repairScopeFromFindings(findings, fallback) {
|
|
437
|
+
const derived = [];
|
|
438
|
+
const push = (raw) => {
|
|
439
|
+
const normalized = normalizeWorkspacePath(raw.replace(REPAIR_SCOPE_LINE_SUFFIX, ''));
|
|
440
|
+
if (normalized === undefined || derived.includes(normalized))
|
|
441
|
+
return;
|
|
442
|
+
derived.push(normalized);
|
|
443
|
+
};
|
|
444
|
+
for (const finding of findings) {
|
|
445
|
+
if (nonemptyString(finding.file))
|
|
446
|
+
push(finding.file);
|
|
447
|
+
for (const match of finding.requiredFix.matchAll(REPAIR_SCOPE_PATH_PATTERN))
|
|
448
|
+
push(match[0]);
|
|
449
|
+
}
|
|
450
|
+
return derived.length > 0 ? derived : fallback === undefined ? undefined : [...new Set(fallback)];
|
|
451
|
+
}
|
|
452
|
+
const AMENDABLE_CONTRACT_FIELDS = ['objective', 'acceptance', 'verify', 'inScope', 'outOfScope'];
|
|
453
|
+
/**
|
|
454
|
+
* Controlled contract amendment (the pure rule; tooling keeps it
|
|
455
|
+
* captain-only). When a quality contract is wrong — a verify command that
|
|
456
|
+
* cannot pass, an inScope that forbids the file the objective names — the
|
|
457
|
+
* worker has no honest completion and either dead-locks or games the gate.
|
|
458
|
+
* Instead the captain may fix the contract mid-flight: every amendment is
|
|
459
|
+
* recorded on the task as a {@link TaskRevision} (previous values + reason),
|
|
460
|
+
* and once a review/requirements task has passed judgment on this task the
|
|
461
|
+
* contract is frozen. Amendments replace whole fields (lists are full
|
|
462
|
+
* replacements, not deltas); the implementer re-reads the amended contract
|
|
463
|
+
* before its next quality gate. Completion gates need no special casing:
|
|
464
|
+
* they read the task's current fields, so they naturally evaluate the
|
|
465
|
+
* amended contract.
|
|
466
|
+
*/
|
|
467
|
+
export function amendTaskContract(team, task, input, by, reason) {
|
|
468
|
+
if (!nonemptyString(by))
|
|
469
|
+
return { ok: false, error: 'contract amendment requires a non-empty author identity' };
|
|
470
|
+
if (!nonemptyString(reason))
|
|
471
|
+
return { ok: false, error: 'contract amendment requires a non-empty reason' };
|
|
472
|
+
if (TERMINAL_TASK_STATUSES.includes(task.status)) {
|
|
473
|
+
return { ok: false, error: `task ${task.id} is ${task.status}; terminal contracts are immutable` };
|
|
474
|
+
}
|
|
475
|
+
if (taskKindOf(task) === 'work') {
|
|
476
|
+
return { ok: false, error: `task ${task.id} has kind=work and no contract to amend` };
|
|
477
|
+
}
|
|
478
|
+
if (!AMENDABLE_CONTRACT_FIELDS.some((field) => input[field] !== undefined)) {
|
|
479
|
+
return { ok: false, error: `amendment requires at least one of: ${AMENDABLE_CONTRACT_FIELDS.join(', ')}` };
|
|
480
|
+
}
|
|
481
|
+
const next = {};
|
|
482
|
+
const previous = {};
|
|
483
|
+
if (input.objective !== undefined) {
|
|
484
|
+
if (!nonemptyString(input.objective)) {
|
|
485
|
+
return { ok: false, error: 'amended objective must be a non-empty string' };
|
|
486
|
+
}
|
|
487
|
+
next['objective'] = input.objective;
|
|
488
|
+
previous['objective'] = task.objective;
|
|
489
|
+
}
|
|
490
|
+
for (const field of ['acceptance', 'verify']) {
|
|
491
|
+
const value = input[field];
|
|
492
|
+
if (value === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
if (!nonemptyStringList(value)) {
|
|
495
|
+
return { ok: false, error: `amended ${field} must be a non-empty list of non-empty strings` };
|
|
496
|
+
}
|
|
497
|
+
next[field] = value;
|
|
498
|
+
previous[field] = task[field];
|
|
499
|
+
}
|
|
500
|
+
for (const field of ['inScope', 'outOfScope']) {
|
|
501
|
+
const value = input[field];
|
|
502
|
+
if (value === undefined)
|
|
503
|
+
continue;
|
|
504
|
+
if (!nonemptyStringList(value)) {
|
|
505
|
+
return { ok: false, error: `amended ${field} must be a non-empty list of non-empty strings` };
|
|
506
|
+
}
|
|
507
|
+
for (const entry of value) {
|
|
508
|
+
if (normalizeWorkspacePath(entry) === undefined) {
|
|
509
|
+
return {
|
|
510
|
+
ok: false,
|
|
511
|
+
error: `amended ${field} entry "${entry}" is not a workspace-relative path (absolute paths and ".." can never match scope patterns)`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
next[field] = value;
|
|
516
|
+
previous[field] = task[field];
|
|
517
|
+
}
|
|
518
|
+
const reviewPassed = team.tasks.some((item) => ((taskKindOf(item) === 'review' || taskKindOf(item) === 'requirements')
|
|
519
|
+
&& item.reviewedTaskId === task.id
|
|
520
|
+
&& item.verdict === 'pass'));
|
|
521
|
+
if (reviewPassed) {
|
|
522
|
+
return { ok: false, error: `task ${task.id} already passed review; its contract is frozen` };
|
|
523
|
+
}
|
|
524
|
+
const revision = {
|
|
525
|
+
at: Date.now(),
|
|
526
|
+
by,
|
|
527
|
+
reason,
|
|
528
|
+
fields: Object.keys(next),
|
|
529
|
+
previous,
|
|
530
|
+
};
|
|
531
|
+
return {
|
|
532
|
+
ok: true,
|
|
533
|
+
revision,
|
|
534
|
+
task: {
|
|
535
|
+
...task,
|
|
536
|
+
...next,
|
|
537
|
+
revisions: [...(task.revisions ?? []), revision],
|
|
538
|
+
updatedAt: Date.now(),
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
407
542
|
const CAPTAIN_ASSIGNEE = 'captain';
|
|
408
543
|
const OPEN_FOLLOW_UP_STATUSES = ['pending', 'claimed', 'in_progress'];
|
|
409
544
|
function schedulableAssignee(preferred, team, forbidden) {
|
|
@@ -429,6 +564,13 @@ function hasOpenFollowUp(team, sourceTaskId, findingIds) {
|
|
|
429
564
|
&& findingKey(item.sourceFindingIds ?? []) === key
|
|
430
565
|
&& OPEN_FOLLOW_UP_STATUSES.includes(item.status)));
|
|
431
566
|
}
|
|
567
|
+
/** Drop previous-round exclusions that intersect this generated repair's scope. */
|
|
568
|
+
function withoutScopeConflicts(outOfScope, inScope) {
|
|
569
|
+
if (outOfScope === undefined)
|
|
570
|
+
return undefined;
|
|
571
|
+
const conflicting = new Set(inScopeOverlap(outOfScope, inScope ?? []));
|
|
572
|
+
return outOfScope.filter((pattern) => !conflicting.has(pattern));
|
|
573
|
+
}
|
|
432
574
|
export function planQualityFollowUp(team, closed) {
|
|
433
575
|
const empty = { created: [], tasks: [] };
|
|
434
576
|
const kind = taskKindOf(closed);
|
|
@@ -467,7 +609,9 @@ export function planQualityFollowUp(team, closed) {
|
|
|
467
609
|
if (countRepairAttempts(team, sourceId, findingIds) >= policy.maxRepairAttempts) {
|
|
468
610
|
return { ...empty, escalated: true, status: 'escalated' };
|
|
469
611
|
}
|
|
470
|
-
|
|
612
|
+
// inScope is derived from the findings below: the observed file plus any
|
|
613
|
+
// workspace-relative paths referenced by the requiredFix instructions.
|
|
614
|
+
const repairScope = repairScopeFromFindings(findings, source?.inScope);
|
|
471
615
|
const implementer = schedulableAssignee(source?.assignee, team);
|
|
472
616
|
const repair = {
|
|
473
617
|
id: `repair-round-${nextRound}`,
|
|
@@ -477,8 +621,8 @@ export function planQualityFollowUp(team, closed) {
|
|
|
477
621
|
dependencies: [sourceId],
|
|
478
622
|
round: nextRound,
|
|
479
623
|
objective: source?.objective ?? closed.objective ?? `Fix findings from ${sourceId}`,
|
|
480
|
-
inScope:
|
|
481
|
-
outOfScope: source?.outOfScope,
|
|
624
|
+
inScope: repairScope,
|
|
625
|
+
outOfScope: withoutScopeConflicts(source?.outOfScope, repairScope),
|
|
482
626
|
verify: source?.verify,
|
|
483
627
|
acceptance: findings.map((finding) => finding.requiredFix),
|
|
484
628
|
sourceTaskId: sourceId,
|
|
@@ -515,6 +659,20 @@ export function buildCoverageMatrix(goalItems, tasks) {
|
|
|
515
659
|
}
|
|
516
660
|
export function canDeclareDelivery(team) {
|
|
517
661
|
const blockers = [];
|
|
662
|
+
if (team.phase === 'staged')
|
|
663
|
+
blockers.push('team plan is awaiting approval');
|
|
664
|
+
if (team.halted === true)
|
|
665
|
+
blockers.push('team is halted');
|
|
666
|
+
if (team.escalated === true)
|
|
667
|
+
blockers.push('team requires escalation resolution');
|
|
668
|
+
if (team.tasks.length === 0)
|
|
669
|
+
blockers.push('team has no completed work');
|
|
670
|
+
for (const item of team.tasks.filter(item => !isQualityKind(taskKindOf(item)))) {
|
|
671
|
+
if (item.status !== 'completed' && item.status !== 'cancelled')
|
|
672
|
+
blockers.push(`${item.id} (${taskKindOf(item)}) is not completed`);
|
|
673
|
+
}
|
|
674
|
+
if (team.tasks.length > 0 && team.tasks.every(item => item.status === 'cancelled'))
|
|
675
|
+
blockers.push('all work was cancelled');
|
|
518
676
|
const quality = team.tasks.filter((item) => isQualityKind(taskKindOf(item)));
|
|
519
677
|
const implementations = quality.filter((item) => taskKindOf(item) === 'implementation' || taskKindOf(item) === 'repair');
|
|
520
678
|
const reviews = quality.filter((item) => taskKindOf(item) === 'review');
|
|
@@ -600,6 +758,15 @@ export function isCommandResult(value) {
|
|
|
600
758
|
&& (value['exitCode'] === undefined || (Number.isSafeInteger(value['exitCode'])))
|
|
601
759
|
&& (value['evidence'] === undefined || typeof value['evidence'] === 'string');
|
|
602
760
|
}
|
|
761
|
+
export function isTaskRevision(value) {
|
|
762
|
+
if (!isRecord(value))
|
|
763
|
+
return false;
|
|
764
|
+
return Number.isSafeInteger(value['at'])
|
|
765
|
+
&& nonemptyString(value['by'])
|
|
766
|
+
&& nonemptyString(value['reason'])
|
|
767
|
+
&& nonemptyStringList(value['fields'])
|
|
768
|
+
&& isRecord(value['previous']);
|
|
769
|
+
}
|
|
603
770
|
// Optional fields whose persisted values must be non-empty when present
|
|
604
771
|
// (mirrors the checks in hasValidQualityTaskFields). Some models materialize
|
|
605
772
|
// optional tool parameters as "" instead of omitting them (e.g. GPT-5.6
|
|
@@ -683,6 +850,10 @@ export function hasValidQualityTaskFields(value) {
|
|
|
683
850
|
if (!Array.isArray(value['commandsRun']) || !value['commandsRun'].every(isCommandResult))
|
|
684
851
|
return false;
|
|
685
852
|
}
|
|
853
|
+
if (value['revisions'] !== undefined) {
|
|
854
|
+
if (!Array.isArray(value['revisions']) || !value['revisions'].every(isTaskRevision))
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
686
857
|
return true;
|
|
687
858
|
}
|
|
688
859
|
function isRecord(value) {
|
|
@@ -796,7 +967,7 @@ export function describeQualityLoop(team) {
|
|
|
796
967
|
halted: false,
|
|
797
968
|
escalated: team.escalated === true,
|
|
798
969
|
deliverable: true,
|
|
799
|
-
summary: 'All required quality gates passed. The captain may report delivery.',
|
|
970
|
+
summary: 'All required work and quality gates passed. The captain may report delivery.',
|
|
800
971
|
};
|
|
801
972
|
}
|
|
802
973
|
if (team.escalated === true) {
|
package/lib/scheduler.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { deliverToMember } from "./members.js";
|
|
16
|
-
import {
|
|
16
|
+
import { isCurrentMail, mailboxPrompt } from "./mailbox.js";
|
|
17
|
+
import { markMailboxDelivered, discardMailboxMessages, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readPendingMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
|
|
17
18
|
/** Per-dependency output cap in the assignment prompt. */
|
|
18
19
|
export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
|
|
19
20
|
/** Combined dependency-output budget in the assignment prompt. */
|
|
@@ -103,6 +104,8 @@ function liveMember(ctx, member) {
|
|
|
103
104
|
return ctx.agents.get(member.id);
|
|
104
105
|
}
|
|
105
106
|
function isMemberAvailable(ctx, member) {
|
|
107
|
+
if (member.stopping === true)
|
|
108
|
+
return false;
|
|
106
109
|
const live = liveMember(ctx, member);
|
|
107
110
|
return live === undefined || live.status === 'idle';
|
|
108
111
|
}
|
|
@@ -168,13 +171,6 @@ When finishing: use status=completed only when the task's success criteria are s
|
|
|
168
171
|
|
|
169
172
|
State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
|
|
170
173
|
}
|
|
171
|
-
function fallbackMailboxPrompt(messages) {
|
|
172
|
-
return [
|
|
173
|
-
'AgentTeams delivered messages that were persisted while live delivery was unavailable:',
|
|
174
|
-
...messages.map(message => `\nFrom ${message.from}:\n${message.content}`),
|
|
175
|
-
'\nHandle these messages in this turn. Task assignments still require agent_teams_claim_task and the current attempt_id.',
|
|
176
|
-
].join('\n');
|
|
177
|
-
}
|
|
178
174
|
/** Install one scheduler and its member activity observer. */
|
|
179
175
|
export function installTeamScheduler(ctx, config) {
|
|
180
176
|
const memberQueues = new Map();
|
|
@@ -227,16 +223,28 @@ export function installTeamScheduler(ctx, config) {
|
|
|
227
223
|
if (captain === undefined)
|
|
228
224
|
return;
|
|
229
225
|
let member = team.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
|
|
230
|
-
if (member === undefined ||
|
|
226
|
+
if (member === undefined || !isMemberAvailable(ctx, member))
|
|
231
227
|
return;
|
|
232
228
|
// A mailbox-only fallback is real pending work. Deliver it before a
|
|
233
229
|
// fresh task and acknowledge only after Harness accepts the follow-up.
|
|
234
|
-
const unread = await
|
|
230
|
+
const unread = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
231
|
+
const fresh = await readTeam(stateRoot, team.id);
|
|
232
|
+
if (fresh === undefined)
|
|
233
|
+
return [];
|
|
234
|
+
const pending = await readPendingMailbox(stateRoot, fresh.id, member.name);
|
|
235
|
+
await discardMailboxMessages(stateRoot, fresh.id, member.name, pending.filter(message => !isCurrentMail(fresh, message)).map(message => message.id));
|
|
236
|
+
const current = pending.filter(message => isCurrentMail(fresh, message));
|
|
237
|
+
await claimMailboxDelivery(stateRoot, fresh.id, member.name, current.map(message => message.id));
|
|
238
|
+
return current;
|
|
239
|
+
});
|
|
235
240
|
if (unread.length > 0) {
|
|
236
|
-
|
|
237
|
-
const
|
|
241
|
+
const prompt = mailboxPrompt(team.id, member.name, unread);
|
|
242
|
+
const signal = new AbortController().signal;
|
|
243
|
+
const accepted = config.dispatch === undefined
|
|
244
|
+
? await deliverToMember(ctx, captain, member.id, prompt, signal, 'steer')
|
|
245
|
+
: await config.dispatch(captain, team.id, member.name, prompt, signal, 'steer');
|
|
238
246
|
if (accepted) {
|
|
239
|
-
await withTeamLock(teamLockKey(stateRoot, team.id), () => (
|
|
247
|
+
await withTeamLock(teamLockKey(stateRoot, team.id), () => (markMailboxDelivered(stateRoot, team.id, member.name, unread.map(message => message.id))));
|
|
240
248
|
}
|
|
241
249
|
else {
|
|
242
250
|
await withTeamLock(teamLockKey(stateRoot, team.id), () => (releaseMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
|
|
@@ -248,7 +256,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
248
256
|
if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
|
|
249
257
|
return undefined;
|
|
250
258
|
const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
|
|
251
|
-
if (currentMember === undefined ||
|
|
259
|
+
if (currentMember === undefined || !isMemberAvailable(ctx, currentMember))
|
|
252
260
|
return undefined;
|
|
253
261
|
const owned = ownedOpenTask(fresh.tasks, currentMember.name);
|
|
254
262
|
// An idle edge observed by this scheduler parks the exact open
|
|
@@ -318,7 +326,11 @@ export function installTeamScheduler(ctx, config) {
|
|
|
318
326
|
});
|
|
319
327
|
if (ticket === undefined)
|
|
320
328
|
return;
|
|
321
|
-
const
|
|
329
|
+
const prompt = assignmentPrompt(ticket, config.stateDir, team.id);
|
|
330
|
+
const signal = new AbortController().signal;
|
|
331
|
+
const accepted = config.dispatch === undefined
|
|
332
|
+
? await deliverToMember(ctx, captain, ticket.memberId, prompt, signal)
|
|
333
|
+
: await config.dispatch(captain, team.id, ticket.memberName, prompt, signal, 'queue', ticket.attemptId);
|
|
322
334
|
if (accepted)
|
|
323
335
|
return;
|
|
324
336
|
// Roll back only our exact failed dispatch. A concurrent captain
|
package/lib/snapshot.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
76
76
|
: activity.get(member.id) === 'idle' || activity.get(member.id) === 'ready'
|
|
77
77
|
? 'idle'
|
|
78
78
|
: 'unknown')
|
|
79
|
-
:
|
|
79
|
+
: 'idle',
|
|
80
80
|
progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
|
|
81
81
|
done,
|
|
82
82
|
total: owned.length,
|
package/lib/state.js
CHANGED
|
@@ -18,7 +18,7 @@ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promise
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
20
20
|
import { hasValidQualityTaskFields, isReviewPolicy, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
|
|
21
|
-
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
21
|
+
export { amendTaskContract, buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, isTaskRevision, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
22
22
|
/** Mailbox key of the captain. */
|
|
23
23
|
export const CAPTAIN_KEY = 'captain';
|
|
24
24
|
/** A crashed live-delivery attempt becomes retryable after this interval. */
|
|
@@ -419,9 +419,14 @@ export async function readMailbox(stateRoot, teamId, agentKey, onMalformedLine)
|
|
|
419
419
|
}
|
|
420
420
|
/** Read only messages that have not been acknowledged by their recipient. */
|
|
421
421
|
export async function readUnreadMailbox(stateRoot, teamId, agentKey, onMalformedLine) {
|
|
422
|
-
const now = Date.now();
|
|
423
422
|
return (await readMailbox(stateRoot, teamId, agentKey, onMalformedLine))
|
|
424
|
-
.filter(message => message.readAt === undefined
|
|
423
|
+
.filter(message => message.readAt === undefined && message.discardedAt === undefined);
|
|
424
|
+
}
|
|
425
|
+
/** Pending delivery is distinct from delivered-but-not-yet-read input. */
|
|
426
|
+
export async function readPendingMailbox(stateRoot, teamId, agentKey) {
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
return (await readUnreadMailbox(stateRoot, teamId, agentKey))
|
|
429
|
+
.filter(message => message.deliveredAt === undefined
|
|
425
430
|
&& (message.deliveryClaimedAt === undefined
|
|
426
431
|
|| now - message.deliveryClaimedAt >= MAILBOX_DELIVERY_LEASE_MS));
|
|
427
432
|
}
|
|
@@ -485,6 +490,16 @@ export async function acknowledgeMailbox(stateRoot, teamId, agentKey, messageIds
|
|
|
485
490
|
};
|
|
486
491
|
});
|
|
487
492
|
}
|
|
493
|
+
/** Acceptance by Harness is not evidence that a model step consumed input. */
|
|
494
|
+
export async function markMailboxDelivered(stateRoot, teamId, agentKey, ids) {
|
|
495
|
+
await mutateMailbox(stateRoot, teamId, agentKey, ids, (message) => {
|
|
496
|
+
const { deliveryClaimedAt: _claimed, ...rest } = message;
|
|
497
|
+
return { ...rest, deliveredAt: message.deliveredAt ?? Date.now() };
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
export async function discardMailboxMessages(stateRoot, teamId, agentKey, ids) {
|
|
501
|
+
await mutateMailbox(stateRoot, teamId, agentKey, ids, message => ({ ...message, discardedAt: message.discardedAt ?? Date.now() }));
|
|
502
|
+
}
|
|
488
503
|
/** Remove the optional UTF-8 BOM some editors prepend to JSON text. */
|
|
489
504
|
function stripLeadingBom(value) {
|
|
490
505
|
return value.charCodeAt(0) === 0xFEFF ? value.slice(1) : value;
|
|
@@ -591,11 +606,13 @@ function isTeamMember(value) {
|
|
|
591
606
|
&& typeof value['name'] === 'string'
|
|
592
607
|
&& value['name'].trim() !== ''
|
|
593
608
|
&& isOptionalString(value['role'])
|
|
609
|
+
&& (value['stopping'] === undefined || typeof value['stopping'] === 'boolean')
|
|
594
610
|
&& isOptionalString(value['provider'])
|
|
595
611
|
&& isOptionalString(value['model'])
|
|
596
612
|
&& isOptionalString(value['reasoningEffort'])
|
|
597
613
|
&& isOptionalString(value['activeProvider'])
|
|
598
614
|
&& isOptionalString(value['activeModel'])
|
|
615
|
+
&& isOptionalString(value['spawnError'])
|
|
599
616
|
&& (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
|
|
600
617
|
&& (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
|
|
601
618
|
&& (value['fallbackActive'] === undefined || typeof value['fallbackActive'] === 'boolean')
|
|
@@ -691,6 +708,7 @@ export function isTeamTask(value) {
|
|
|
691
708
|
|| (Number.isSafeInteger(value['attempt']) && value['attempt'] >= 0))
|
|
692
709
|
&& isOptionalString(value['attemptId'])
|
|
693
710
|
&& isOptionalString(value['handoffId'])
|
|
711
|
+
&& isOptionalString(value['handoffFromMemberId'])
|
|
694
712
|
&& (value['reassigning'] === undefined || typeof value['reassigning'] === 'boolean')
|
|
695
713
|
&& isFiniteNumber(value['createdAt'])
|
|
696
714
|
&& isFiniteNumber(value['updatedAt'])
|
|
@@ -729,10 +747,9 @@ function isTeamState(value, expectedId) {
|
|
|
729
747
|
const tasks = value['tasks'];
|
|
730
748
|
const memberIds = new Set();
|
|
731
749
|
const memberKeys = new Set();
|
|
732
|
-
const staged = value['phase'] === 'staged';
|
|
733
750
|
for (const member of members) {
|
|
734
751
|
const key = sanitizeKey(member.name);
|
|
735
|
-
if (
|
|
752
|
+
if (key === CAPTAIN_KEY || memberKeys.has(key))
|
|
736
753
|
return false;
|
|
737
754
|
if (member.id !== '') {
|
|
738
755
|
if (memberIds.has(member.id))
|
|
@@ -760,7 +777,10 @@ function isTeamMessage(value) {
|
|
|
760
777
|
&& isFiniteNumber(value['ts'])
|
|
761
778
|
&& (value['deliveryClaimedAt'] === undefined || isFiniteNumber(value['deliveryClaimedAt']))
|
|
762
779
|
&& (value['deliveredAt'] === undefined || isFiniteNumber(value['deliveredAt']))
|
|
763
|
-
&& (value['readAt'] === undefined || isFiniteNumber(value['readAt']))
|
|
780
|
+
&& (value['readAt'] === undefined || isFiniteNumber(value['readAt']))
|
|
781
|
+
&& (value['discardedAt'] === undefined || isFiniteNumber(value['discardedAt']))
|
|
782
|
+
&& (value['taskId'] === undefined || typeof value['taskId'] === 'string')
|
|
783
|
+
&& (value['attemptId'] === undefined || typeof value['attemptId'] === 'string');
|
|
764
784
|
}
|
|
765
785
|
/**
|
|
766
786
|
* Remove a team's whole directory (members should be interrupted first).
|
package/lib/tool-names.js
CHANGED
|
@@ -3,6 +3,7 @@ export const TEAM_TOOL_NAMES = [
|
|
|
3
3
|
'agent_teams_create', 'agent_teams_approve', 'agent_teams_edit_plan',
|
|
4
4
|
'agent_teams_add_member', 'agent_teams_remove_member', 'agent_teams_create_task',
|
|
5
5
|
'agent_teams_reassign_task', 'agent_teams_claim_task', 'agent_teams_update_task',
|
|
6
|
+
'agent_teams_amend_task',
|
|
6
7
|
'agent_teams_send_message', 'agent_teams_status', 'agent_teams_resume', 'agent_teams_delete',
|
|
7
8
|
];
|
|
8
9
|
export const MEMBER_TOOL_NAMES = [
|