feat(member-work-sync): track task impact handoffs
This commit is contained in:
parent
bbafedf06a
commit
899596b258
56 changed files with 3352 additions and 485 deletions
|
|
@ -253,11 +253,13 @@ function formatAllowedTaskCommentAuthors(paths, explicitMembers, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
const leadName = inferLeadName(paths);
|
||||
const leadKey = normalizeMemberKey(leadName);
|
||||
if (leadKey && explicitMembers.membersByKey.has(leadKey)) {
|
||||
allowed.add('lead');
|
||||
allowed.add('team-lead');
|
||||
if (options.allowLeadAliases !== false) {
|
||||
const leadName = inferLeadName(paths);
|
||||
const leadKey = normalizeMemberKey(leadName);
|
||||
if (leadKey && explicitMembers.membersByKey.has(leadKey)) {
|
||||
allowed.add('lead');
|
||||
allowed.add('team-lead');
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(allowed).sort((a, b) => a.localeCompare(b)).join(', ');
|
||||
|
|
@ -285,14 +287,18 @@ function resolveTaskCommentAuthorName(paths, candidate, label = 'task comment au
|
|||
return directMember.name;
|
||||
}
|
||||
|
||||
const leadAlias = resolveExplicitTeamMemberName(paths, normalized, { allowLeadAliases: true });
|
||||
const leadAlias = resolveExplicitTeamMemberName(paths, normalized, {
|
||||
allowLeadAliases: options.allowLeadAliases !== false,
|
||||
});
|
||||
if (leadAlias) {
|
||||
return leadAlias;
|
||||
}
|
||||
|
||||
const { leadName, keys } = getLeadProviderKeys(paths, explicit);
|
||||
if (leadName && keys.has(key)) {
|
||||
return leadName;
|
||||
if (options.allowProviderAliases !== false) {
|
||||
const { leadName, keys } = getLeadProviderKeys(paths, explicit);
|
||||
if (leadName && keys.has(key)) {
|
||||
return leadName;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -518,7 +518,11 @@ function addTaskCommentWithOptions(context, taskId, flags, options = {}) {
|
|||
context.paths,
|
||||
commentFlags.from,
|
||||
'task comment author',
|
||||
{ allowReservedAuthors: !fromRequiredForAgentTool }
|
||||
{
|
||||
allowReservedAuthors: !fromRequiredForAgentTool,
|
||||
allowLeadAliases: !fromRequiredForAgentTool,
|
||||
allowProviderAliases: !fromRequiredForAgentTool,
|
||||
}
|
||||
);
|
||||
const result = withTeamBoardLock(context.paths, () =>
|
||||
taskStore.addTaskComment(context.paths, taskId, commentFlags.text, {
|
||||
|
|
|
|||
|
|
@ -94,14 +94,16 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
|
|||
}
|
||||
|
||||
function compactReportBody(context, memberName, flags = {}) {
|
||||
const taskIds = normalizeTaskIds(
|
||||
Array.isArray(flags['task-ids']) ? flags['task-ids'] : flags.taskIds
|
||||
);
|
||||
return {
|
||||
teamName: context.teamName,
|
||||
memberName,
|
||||
state: flags.state,
|
||||
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
|
||||
reportToken: flags.reportToken || flags['report-token'],
|
||||
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}),
|
||||
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
|
||||
...(taskIds.length > 0 ? { taskIds } : {}),
|
||||
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
|
||||
...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim()
|
||||
? { reportedAt: flags.reportedAt.trim() }
|
||||
|
|
@ -110,6 +112,12 @@ function compactReportBody(context, memberName, flags = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeTaskIds(value) {
|
||||
return Array.isArray(value)
|
||||
? Array.from(new Set(value.map((taskId) => String(taskId).trim()).filter(Boolean)))
|
||||
: [];
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
|
|
@ -125,7 +133,9 @@ function stableStringify(value) {
|
|||
|
||||
function buildPendingIntentId(body) {
|
||||
const taskIds = Array.isArray(body.taskIds)
|
||||
? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort()
|
||||
? Array.from(
|
||||
new Set(body.taskIds.map((taskId) => String(taskId).trim()).filter(Boolean))
|
||||
).sort()
|
||||
: [];
|
||||
const payload = {
|
||||
teamName: body.teamName,
|
||||
|
|
@ -229,8 +239,12 @@ async function memberWorkSyncStatus(context, flags = {}) {
|
|||
baseUrls,
|
||||
`/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent(
|
||||
memberName
|
||||
)}`,
|
||||
{ timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) }
|
||||
)}/refresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {},
|
||||
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1228,6 +1228,67 @@ describe('agent-teams-controller API', () => {
|
|||
expect(fromSystem.comment.author).toBe('system');
|
||||
});
|
||||
|
||||
it('rejects provider and lead aliases for agent-facing task comments', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const appController = createController({ teamName: 'my-team', claudeDir });
|
||||
const agentController = createController({
|
||||
teamName: 'my-team',
|
||||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const task = appController.tasks.createTask({
|
||||
subject: 'Reject agent aliases',
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'codex',
|
||||
text: 'Provider alias should not be accepted from MCP.',
|
||||
})
|
||||
).toThrow('Unknown task comment author: codex');
|
||||
|
||||
expect(() =>
|
||||
agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'lead',
|
||||
text: 'Lead alias should not be accepted from MCP.',
|
||||
})
|
||||
).toThrow('Unknown task comment author: lead');
|
||||
|
||||
let unknownAuthorError;
|
||||
try {
|
||||
agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'Codex',
|
||||
text: 'Provider alias case should not be accepted from MCP.',
|
||||
});
|
||||
} catch (error) {
|
||||
unknownAuthorError = error;
|
||||
}
|
||||
expect(unknownAuthorError.message).toContain('Unknown task comment author: Codex');
|
||||
expect(unknownAuthorError.message).toContain('Use one of: bob, team-lead');
|
||||
expect(unknownAuthorError.message).not.toContain('user');
|
||||
expect(unknownAuthorError.message).not.toContain('system');
|
||||
|
||||
expect(appController.tasks.getTask(task.id).comments || []).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not map a real teammate named like the lead provider id to the lead', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
fs.writeFileSync(
|
||||
|
|
@ -1255,6 +1316,18 @@ describe('agent-teams-controller API', () => {
|
|||
});
|
||||
|
||||
expect(commented.comment.author).toBe('codex');
|
||||
|
||||
const agentController = createController({
|
||||
teamName: 'my-team',
|
||||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const agentCommented = agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'codex',
|
||||
text: 'Agent-facing real teammate comment.',
|
||||
});
|
||||
|
||||
expect(agentCommented.comment.author).toBe('codex');
|
||||
});
|
||||
|
||||
it('rejects task comments from unknown authors', () => {
|
||||
|
|
@ -2445,7 +2518,7 @@ describe('agent-teams-controller API', () => {
|
|||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') {
|
||||
if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/bob/refresh') {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'my-team',
|
||||
|
|
@ -2483,7 +2556,7 @@ describe('agent-teams-controller API', () => {
|
|||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-1'],
|
||||
taskIds: [' task-1 ', '', 'task-1'],
|
||||
note: 'Continuing work',
|
||||
leaseTtlMs: 120000,
|
||||
});
|
||||
|
|
@ -2492,9 +2565,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(report.accepted).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/teams/my-team/member-work-sync/bob',
|
||||
body: undefined,
|
||||
method: 'POST',
|
||||
url: '/api/teams/my-team/member-work-sync/bob/refresh',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Member Work Sync Control Plane Plan
|
||||
|
||||
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented
|
||||
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and active-by-default Phase 2 nudge outbox/dispatcher/scheduler implemented
|
||||
**Scope:** Team management, task work synchronization, agent work coordination
|
||||
**Primary repo:** `claude_team`
|
||||
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
|
||||
|
|
@ -35,11 +35,11 @@ Current implementation note:
|
|||
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
|
||||
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
||||
- Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states.
|
||||
- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive.
|
||||
- Phase 2 nudge side effects are additionally disabled by default in production composition. Set `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1` only for isolated live validation. This keeps status/report/metrics active while guaranteeing that shadow-ready metrics cannot start inbox nudges by accident.
|
||||
- Dispatcher use case can run after queued reconcile and is also exposed through the facade when nudge side effects are explicitly enabled. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
|
||||
- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam guard and keeps UI/status reads passive.
|
||||
- Phase 2 nudge delivery is active by default in production composition. Safety is provided by internal guards: `shadow_ready`, current fingerprint, active team, busy signal, watchdog cooldown, rate limit, and idempotent outbox.
|
||||
- Dispatcher use case runs after queued reconcile and is also exposed through the facade. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
|
||||
- Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work.
|
||||
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only when nudge side effects are enabled. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
|
||||
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams when a lifecycle-active team source is available. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
|
||||
- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts.
|
||||
- Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint.
|
||||
- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
||||
|
|
@ -378,7 +378,7 @@ Pre-coding hardening checklist:
|
|||
- Add identity tests before report persistence. `from` is not authority unless runtime context or report token proves it.
|
||||
- Treat every app restart as a replay scenario. Pending intents, queued reconciles, and stale reports must be safe to process again.
|
||||
- Make every Phase 2 side effect idempotent before adding the dispatcher.
|
||||
- Add one explicit kill switch per side-effect class: reconcile/status, report acceptance, and nudges.
|
||||
- Keep side effects bounded by internal guards and narrow ports. Do not add permanent product switches for reconcile/status, report acceptance, or nudges.
|
||||
- Do not merge watchdog and work-sync concepts. Work-sync is agenda observation; watchdog is semantic progress.
|
||||
|
||||
Failure-mode matrix:
|
||||
|
|
@ -801,7 +801,7 @@ It should not mean:
|
|||
- a delivery retry marker was appended;
|
||||
- a status condition timestamp changed.
|
||||
|
||||
Phase 1 must track `fingerprintChangeCount` and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, do not enable Phase 2 nudges.
|
||||
Phase 1 must track `fingerprintChangeCount` and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, keep `phase2Readiness` blocked until the source of churn is fixed.
|
||||
|
||||
Recommended transition diagnostic:
|
||||
|
||||
|
|
@ -1364,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`.
|
|||
|
||||
### 10.4 Shadow Would-Nudge Semantics
|
||||
|
||||
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. Production composition enforces this by default by not wiring `outboxStore`/`inboxNudge` unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1`.
|
||||
Read-only status and diagnostics may compute `wouldNudgeCount`, but must not enqueue or send. Production composition always wires the outbox and inbox nudge sink; side effects stay limited to queued reconcile planning and dispatcher delivery guards.
|
||||
|
||||
`wouldNudge` is true only when all are true:
|
||||
|
||||
|
|
@ -1911,7 +1911,7 @@ Preferred Phase 1 read surface:
|
|||
- extend `task_briefing` with a compact `workSync` block;
|
||||
- include current `agendaFingerprint`;
|
||||
- include a short actionable agenda preview;
|
||||
- include report instructions only when the feature is enabled.
|
||||
- include report instructions only when the report tool is available.
|
||||
|
||||
Example `task_briefing` addition:
|
||||
|
||||
|
|
@ -2235,7 +2235,7 @@ Dispatcher revalidation:
|
|||
|
||||
Before inserting an inbox nudge, dispatcher must re-read:
|
||||
|
||||
- feature gate state;
|
||||
- current `phase2Readiness`;
|
||||
- current roster membership;
|
||||
- current agenda fingerprint;
|
||||
- latest accepted report;
|
||||
|
|
@ -2729,9 +2729,9 @@ busy suppressions: 8
|
|||
|
||||
### 18.1 Phase 2 Entry Thresholds
|
||||
|
||||
Do not enable nudges until shadow metrics are stable.
|
||||
Do not let nudges deliver until shadow metrics are stable and `phase2Readiness=shadow_ready`.
|
||||
|
||||
Recommended gates:
|
||||
Recommended guardrails:
|
||||
|
||||
| Metric | Target before Phase 2 |
|
||||
|---|---:|
|
||||
|
|
@ -2742,7 +2742,7 @@ Recommended gates:
|
|||
| busy suppression correctness | no known prompt during active tool/runtime turn |
|
||||
| report intent replay errors | 0 lost accepted reports |
|
||||
|
||||
If a metric misses the target, keep Phase 2 disabled and fix the specific source of noise. Do not compensate with a shorter lease or more nudges.
|
||||
If a metric misses the target, keep `phase2Readiness` blocked/collecting and fix the specific source of noise. Do not compensate with a shorter lease or more nudges.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -3039,7 +3039,8 @@ No accelerator is proof.
|
|||
Current implementation:
|
||||
|
||||
- tool-finish enqueue and tool-activity busy suppression are implemented through `TeamChangeEvent` and the feature-owned busy signal;
|
||||
- Claude Stop hook and OpenCode turn-settled hooks are intentionally not wired yet because the current feature boundary does not expose one authoritative cross-provider "turn settled and idle" signal. Adding an adapter around prompt text, idle notifications, or provider-specific transcript heuristics would be less reliable than the current tool-finish + scheduled reconcile path;
|
||||
- Claude Stop hook settings are wired through the `member-work-sync` feature facade into `TeamProvisioningService`, then merged into Anthropic launch settings;
|
||||
- Codex native and OpenCode turn-settled signals are wired through provider-specific runtime env/spool emitters, shared payload normalization, `RuntimeTurnSettledIngestor`, and the feature-owned drain scheduler. OpenCode bridge env receives the spool root before `OpenCodeBridgeCommandClient` construction;
|
||||
- manual "sync now" remains optional because details/status reads are passive by design, and explicit manual nudges should reuse the existing outbox/dispatcher instead of bypassing readiness guards.
|
||||
|
||||
---
|
||||
|
|
@ -3070,7 +3071,7 @@ Do not add:
|
|||
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED`
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY`
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`
|
||||
- any new member-work-sync nudge env or feature flag
|
||||
|
||||
If Phase 1 needs to be disabled during development, revert or patch the narrow composition wiring. Do not add a permanent product branch for a passive feature.
|
||||
|
||||
|
|
@ -3088,7 +3089,7 @@ const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000;
|
|||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||
```
|
||||
|
||||
If we ever need an emergency kill switch for production nudges, it must only wrap the Phase 2 dispatcher. It must not disable agenda/status/report validation.
|
||||
No config/env kill switch is part of the design. Production nudge safety must come from dispatcher guards and typed runtime defaults, without disabling agenda/status/report validation.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -3238,7 +3239,7 @@ Phase 2 must not start if any of these are true:
|
|||
- report validation can accept leases with claimed `from` only and no trusted identity/report token;
|
||||
- queue can run more than one reconcile for the same member concurrently;
|
||||
- watchdog cooldown integration is untested;
|
||||
- outbox dispatcher can send while feature is disabled;
|
||||
- outbox dispatcher can send without `shadow_ready`;
|
||||
- outbox dispatcher can send for a stopped/cancelled team;
|
||||
- pending intent replay can turn stale reports into accepted leases;
|
||||
- fingerprint transition diagnostics are missing;
|
||||
|
|
@ -3379,8 +3380,8 @@ Step order:
|
|||
- Writes status only through `MemberWorkSyncStatusStorePort`.
|
||||
- Does not send any message or call runtime delivery.
|
||||
|
||||
10. Add shadow trigger wiring behind feature gate.
|
||||
- Default shadow status can be on, but all side effects are status-only.
|
||||
10. Add shadow trigger wiring without an env gate.
|
||||
- Default shadow status is on, while read-only status/diagnostics stay passive.
|
||||
- Use quiet-window queue and bounded concurrency.
|
||||
- Wire broad team/task change events only after domain tests are green.
|
||||
- Drop queued entries when team/member is removed or stopped.
|
||||
|
|
@ -3536,12 +3537,12 @@ Cut 3 stop criteria:
|
|||
|
||||
### 27.4 Phase 2: Nudges Later, Separate Work
|
||||
|
||||
Do not start Phase 2 until shadow metrics prove low noise.
|
||||
Do not rely on Phase 2 delivery until shadow metrics prove low noise.
|
||||
|
||||
Phase 2 sequence:
|
||||
|
||||
1. Add outbox schema and idempotency key.
|
||||
2. Add dispatcher with feature gate default off.
|
||||
2. Add dispatcher active by default behind internal guards.
|
||||
3. Add stale revalidation before dispatch.
|
||||
4. Add watchdog cooldown integration.
|
||||
5. Add one-in-flight per `(teamName, memberName, fingerprint)`.
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ Rules:
|
|||
- turn-settled does not directly nudge;
|
||||
- turn-settled does not count as meaningful task progress;
|
||||
- watchdog cooldowns still prevent duplicate nudges;
|
||||
- existing `member-work-sync` nudge side-effects gate remains the only way to deliver sync nudges.
|
||||
- `member-work-sync` dispatcher remains the only path that can deliver sync nudges, and it must pass its internal guards first.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -2039,7 +2039,7 @@ More reconcile triggers could expose existing Phase 2 nudges.
|
|||
|
||||
Mitigation:
|
||||
|
||||
- current nudge side effects remain gated by `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`;
|
||||
- nudges are active by default, but delivery remains bounded by dispatcher guards;
|
||||
- queue quiet window debounces events;
|
||||
- outbox has one item per fingerprint;
|
||||
- dispatcher revalidates busy/watchdog cooldown before delivery.
|
||||
|
|
@ -2472,7 +2472,7 @@ and a valid OpenCode turn-settled event file
|
|||
when drainRuntimeTurnSettledEvents runs
|
||||
then queue receives member-turn-settled
|
||||
and member-work-sync status is recomputed
|
||||
and no direct nudge is sent unless existing nudge side-effects are enabled
|
||||
and no direct nudge is sent outside the existing outbox/dispatcher path
|
||||
```
|
||||
|
||||
### 13.4 Live E2E Prototype Test
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Member Work Sync Runtime Stop Hook Plan
|
||||
|
||||
**Status:** design ready, not implemented
|
||||
**Scope:** `member-work-sync`, Claude runtime hook integration, future Codex hook adapter
|
||||
**Status:** implemented for provider-neutral spool/drain, Claude Stop hook settings, and Codex/OpenCode turn-settled adapters
|
||||
**Scope:** `member-work-sync`, Claude runtime hook integration, Codex/OpenCode runtime turn-settled adapters
|
||||
**Primary repo:** `claude_team`
|
||||
**Secondary dependency:** `agent_teams_orchestrator` runtime hook payload contract
|
||||
**Feature name:** `member-work-sync`
|
||||
|
|
@ -33,15 +33,15 @@ Recommended approach:
|
|||
Why this is the safest direction:
|
||||
|
||||
- `Stop` is a useful "turn settled" signal, but not proof that work is complete.
|
||||
- `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to future Codex runtime hooks.
|
||||
- `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to Codex/OpenCode turn-settled adapters.
|
||||
- Hook execution must be fast, non-blocking, and fail-open.
|
||||
- The existing `member-work-sync` agenda fingerprint, lease, cooldown, busy signal, and watchdog separation remain authoritative.
|
||||
- Codex can be added later by implementing a second provider adapter that emits the same normalized event contract.
|
||||
- Codex/OpenCode use provider adapters that emit the same normalized event contract.
|
||||
|
||||
Architecture checkpoint:
|
||||
|
||||
- No blocker question is required before implementation. The safest defaults are clear.
|
||||
- The only intentionally deferred decision is production Codex installation. Codex receives a tested adapter seam, but no production launch behavior until its hook payload/config contract is verified.
|
||||
- Codex/OpenCode production wiring is fail-open: missing runtime turn-settled env disables telemetry for that provider process, but does not fail team launch or prompt delivery.
|
||||
- The implementation should be done as an extension of `member-work-sync`, not as ad hoc logic inside `TeamProvisioningService`.
|
||||
- The hook pipeline is an input signal. It must not become a second watchdog, a second delivery ledger, or a runtime liveness detector.
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ Reason:
|
|||
- Existing leases, fingerprints, cooldowns, and watchdog separation still decide behavior.
|
||||
- A flag would add another state combination without reducing the main risk, which is attribution correctness.
|
||||
|
||||
If an emergency kill switch is needed later, prefer an internal config/env-only disable around hook settings generation, not branching inside the core policy.
|
||||
If emergency disable behavior is needed later, patch the narrow hook composition wiring directly rather than adding a permanent config/env flag or branching inside the core policy.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus';
|
||||
export const MEMBER_WORK_SYNC_REFRESH_STATUS = 'member-work-sync:refreshStatus';
|
||||
export const MEMBER_WORK_SYNC_GET_METRICS = 'member-work-sync:getMetrics';
|
||||
export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report';
|
||||
|
|
|
|||
|
|
@ -1,16 +1,48 @@
|
|||
import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler';
|
||||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export class MemberWorkSyncDiagnosticsReader {
|
||||
private readonly reconciler: MemberWorkSyncReconciler;
|
||||
|
||||
constructor(deps: MemberWorkSyncUseCaseDeps) {
|
||||
this.reconciler = new MemberWorkSyncReconciler(deps);
|
||||
}
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> {
|
||||
return this.reconciler.execute(request);
|
||||
const stored = await this.deps.statusStore.read(request);
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const source = await this.deps.agendaSource.loadAgenda(request);
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
|
||||
: true;
|
||||
const decision = decideMemberWorkSyncStatus({
|
||||
agenda,
|
||||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
});
|
||||
|
||||
return {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
state: decision.state,
|
||||
agenda,
|
||||
shadow: {
|
||||
reconciledBy: 'request',
|
||||
wouldNudge: false,
|
||||
fingerprintChanged: false,
|
||||
},
|
||||
evaluatedAt: nowIso,
|
||||
diagnostics: [
|
||||
...agenda.diagnostics,
|
||||
...(!teamActive ? ['team_runtime_inactive'] : []),
|
||||
...decision.diagnostics,
|
||||
'status_snapshot_not_persisted',
|
||||
],
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
|
||||
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
|
@ -188,21 +190,41 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
): Promise<
|
||||
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
|
||||
> {
|
||||
if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) {
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(item.teamName)
|
||||
: true;
|
||||
if (!teamActive) {
|
||||
return { ok: false, reason: 'team_inactive', retryable: false };
|
||||
}
|
||||
|
||||
const status = await this.deps.statusStore.read({
|
||||
const previous = await this.deps.statusStore.read({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
});
|
||||
if (!status) {
|
||||
if (!previous) {
|
||||
return { ok: false, reason: 'status_missing', retryable: false };
|
||||
}
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = await this.deps.agendaSource.loadAgenda({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, reason: `agenda_revalidation_failed:${String(error)}`, retryable: true };
|
||||
}
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
const decision = decideMemberWorkSyncStatus({
|
||||
agenda,
|
||||
latestAcceptedReport: previous.report?.accepted ? previous.report : null,
|
||||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
});
|
||||
if (
|
||||
status.state !== 'needs_sync' ||
|
||||
status.shadow?.wouldNudge !== true ||
|
||||
status.agenda.fingerprint !== item.agendaFingerprint
|
||||
decision.state !== 'needs_sync' ||
|
||||
agenda.items.length === 0 ||
|
||||
agenda.fingerprint !== item.agendaFingerprint
|
||||
) {
|
||||
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,22 @@ function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string>
|
|||
);
|
||||
}
|
||||
|
||||
function isLeadLike(member: MemberWorkSyncMemberLike): boolean {
|
||||
const name = normalizeMemberName(member.name);
|
||||
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
|
||||
return (
|
||||
name === 'team-lead' ||
|
||||
agentType === 'team-lead' ||
|
||||
agentType === 'lead' ||
|
||||
agentType === 'orchestrator'
|
||||
);
|
||||
}
|
||||
|
||||
function getActiveLeadName(members: MemberWorkSyncMemberLike[]): string | null {
|
||||
const lead = members.find((member) => !member.removedAt && isLeadLike(member));
|
||||
return lead ? normalizeMemberName(lead.name) : null;
|
||||
}
|
||||
|
||||
function buildBaseItem(
|
||||
task: MemberWorkSyncTaskLike,
|
||||
memberName: string
|
||||
|
|
@ -70,12 +86,23 @@ function buildBaseItem(
|
|||
};
|
||||
}
|
||||
|
||||
function taskReferenceKeys(task: Pick<MemberWorkSyncTaskLike, 'id' | 'displayId'>): string[] {
|
||||
const keys = [task.id, task.displayId]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
||||
}
|
||||
|
||||
export function buildActionableWorkAgenda(
|
||||
input: BuildActionableWorkAgendaInput
|
||||
): MemberWorkSyncAgenda {
|
||||
const memberName = normalizeMemberName(input.memberName);
|
||||
const diagnostics: string[] = [];
|
||||
const activeMemberNames = getActiveMemberNames(input.members);
|
||||
const activeLeadName = getActiveLeadName(input.members);
|
||||
const tasksByReference = new Map(
|
||||
input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const))
|
||||
);
|
||||
|
||||
if (!memberName || isReservedMemberName(memberName)) {
|
||||
diagnostics.push('member_invalid_or_reserved');
|
||||
|
|
@ -95,6 +122,57 @@ export function buildActionableWorkAgenda(
|
|||
const base = buildBaseItem(task, memberName);
|
||||
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
|
||||
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
|
||||
const brokenDependencyIds: string[] = [];
|
||||
const waitingDependencyIds: string[] = [];
|
||||
for (const dependencyId of blockedBy) {
|
||||
const dependency = tasksByReference.get(dependencyId) ?? null;
|
||||
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
||||
brokenDependencyIds.push(dependencyId);
|
||||
} else if (dependency.status !== 'completed') {
|
||||
waitingDependencyIds.push(dependencyId);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
activeLeadName &&
|
||||
sameMemberName(activeLeadName, memberName) &&
|
||||
task.needsClarification === 'lead'
|
||||
) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'clarification',
|
||||
priority: 'needs_clarification',
|
||||
reason: 'task_needs_lead_clarification',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
...(owner ? { owner } : {}),
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
needsClarification: 'lead',
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
activeLeadName &&
|
||||
sameMemberName(activeLeadName, memberName) &&
|
||||
brokenDependencyIds.length > 0
|
||||
) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'blocked_dependency',
|
||||
priority: 'blocked',
|
||||
reason: 'task_has_broken_dependency',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
...(owner ? { owner } : {}),
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
blockedByTaskIds: brokenDependencyIds,
|
||||
...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewOwner = resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
|
|
@ -126,44 +204,28 @@ export function buildActionableWorkAgenda(
|
|||
}
|
||||
|
||||
if (task.needsClarification === 'lead' || task.needsClarification === 'user') {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'clarification',
|
||||
priority: 'needs_clarification',
|
||||
reason: `task_needs_${task.needsClarification}_clarification`,
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
needsClarification: task.needsClarification,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockedBy.length > 0) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'blocked_dependency',
|
||||
priority: 'blocked',
|
||||
reason: 'owned_task_has_blocked_dependency',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
blockedByTaskIds: blockedBy,
|
||||
...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}),
|
||||
},
|
||||
});
|
||||
if (waitingDependencyIds.length > 0 || brokenDependencyIds.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.status === 'pending' || task.status === 'in_progress') {
|
||||
if (
|
||||
task.status === 'pending' ||
|
||||
task.status === 'in_progress' ||
|
||||
task.reviewState === 'needsFix'
|
||||
) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'work',
|
||||
priority: 'normal',
|
||||
reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task',
|
||||
reason:
|
||||
task.reviewState === 'needsFix'
|
||||
? 'review_changes_requested'
|
||||
: task.status === 'pending'
|
||||
? 'owned_pending_task'
|
||||
: 'owned_in_progress_task',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ export function buildMemberWorkSyncNudgeId(input: {
|
|||
].join(':');
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
|
||||
export function buildMemberWorkSyncNudgePayload(
|
||||
status: MemberWorkSyncStatus
|
||||
): MemberWorkSyncNudgePayload {
|
||||
const taskRefs = status.agenda.items.map((item) => ({
|
||||
teamName: status.teamName,
|
||||
taskId: item.taskId,
|
||||
|
|
@ -48,6 +50,7 @@ export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): M
|
|||
.slice(0, 3)
|
||||
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
|
||||
.join('; ');
|
||||
const taskIds = status.agenda.items.map((item) => item.taskId).filter(Boolean);
|
||||
|
||||
return {
|
||||
from: 'system',
|
||||
|
|
@ -59,7 +62,13 @@ export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): M
|
|||
text: [
|
||||
'Work sync check: you have current actionable work assigned.',
|
||||
preview ? `Current agenda: ${preview}.` : '',
|
||||
'Continue concrete task work, report a real blocker with task tools, or call member_work_sync_report for the current fingerprint.',
|
||||
`Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`,
|
||||
taskIds.length
|
||||
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
|
||||
: '',
|
||||
`Do not use provider names, runtime names, or team names as memberName; use exactly "${status.memberName}".`,
|
||||
'If you are still working, report state "still_working"; if you are blocked, report state "blocked" and record the blocker on the task.',
|
||||
'Continue concrete task work, report a real blocker with task tools, or sync your current fingerprint before going idle.',
|
||||
'Do not reply only with acknowledgement.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
|
|||
|
|
@ -38,15 +38,32 @@ function agendaHasBlockedEvidence(
|
|||
agenda: MemberWorkSyncAgenda,
|
||||
taskIds: string[] | undefined
|
||||
): boolean {
|
||||
const targetIds = new Set((taskIds ?? []).filter(Boolean));
|
||||
const targetIds = new Set((taskIds ?? []).flatMap(taskReferenceKeys));
|
||||
return agenda.items.some((item) => {
|
||||
if (targetIds.size > 0 && !targetIds.has(item.taskId)) {
|
||||
if (
|
||||
targetIds.size > 0 &&
|
||||
!taskReferenceKeys(item).some((reference) => targetIds.has(reference))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return item.kind === 'blocked_dependency' || item.priority === 'blocked';
|
||||
});
|
||||
}
|
||||
|
||||
function taskReferenceKeys(
|
||||
task: Pick<MemberWorkSyncAgenda['items'][number], 'taskId' | 'displayId'> | string
|
||||
): string[] {
|
||||
const values = typeof task === 'string' ? [task] : [task.taskId, task.displayId];
|
||||
return [
|
||||
...new Set(
|
||||
values
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.flatMap((value) => [value, value.replace(/^#/, '')])
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function validateMemberWorkSyncReport(input: {
|
||||
request: MemberWorkSyncReportRequest;
|
||||
agenda: MemberWorkSyncAgenda;
|
||||
|
|
@ -92,9 +109,9 @@ export function validateMemberWorkSyncReport(input: {
|
|||
};
|
||||
}
|
||||
|
||||
const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId));
|
||||
const agendaTaskIds = new Set(input.agenda.items.flatMap(taskReferenceKeys));
|
||||
for (const taskId of input.request.taskIds ?? []) {
|
||||
if (!agendaTaskIds.has(taskId)) {
|
||||
if (!taskReferenceKeys(taskId).some((reference) => agendaTaskIds.has(reference))) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'foreign_task_id',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ export function resolveCurrentReviewOwner(input: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const kanbanReviewer = normalizeMemberName(input.kanbanReviewer);
|
||||
if (kanbanReviewer) {
|
||||
return {
|
||||
reviewer: kanbanReviewer,
|
||||
historyEventIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const latestStarted = [...historyEvents]
|
||||
.reverse()
|
||||
.find((event) => event.type === 'review_started');
|
||||
|
|
@ -58,9 +66,7 @@ export function resolveCurrentReviewOwner(input: {
|
|||
.find((event) => event.type === 'review_requested');
|
||||
|
||||
const reviewer =
|
||||
normalizeMemberName(latestStarted?.actor) ||
|
||||
normalizeMemberName(latestRequested?.reviewer) ||
|
||||
normalizeMemberName(input.kanbanReviewer);
|
||||
normalizeMemberName(latestStarted?.actor) || normalizeMemberName(latestRequested?.reviewer);
|
||||
|
||||
if (!reviewer) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
||||
|
||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import type { TeamTask } from '@shared/types';
|
||||
|
||||
export interface MemberWorkSyncTaskImpactResolverDeps {
|
||||
taskReader: Pick<TeamTaskReader, 'getTasks'>;
|
||||
kanbanManager: Pick<TeamKanbanManager, 'getState'>;
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames(teamName: string): Promise<string[]>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncTaskImpactResolverResult {
|
||||
memberNames: string[];
|
||||
fallbackTeamWide: boolean;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
function isTerminalTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
function taskMatchesId(task: TeamTask, taskId: string): boolean {
|
||||
const normalized = taskId.trim().replace(/^#/, '');
|
||||
return (
|
||||
task.id === taskId ||
|
||||
task.id === normalized ||
|
||||
task.displayId === taskId ||
|
||||
task.displayId === normalized ||
|
||||
task.displayId === `#${normalized}`
|
||||
);
|
||||
}
|
||||
|
||||
function taskReferenceKeys(task: Pick<TeamTask, 'id' | 'displayId'>): string[] {
|
||||
const keys = [task.id, task.displayId]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
||||
}
|
||||
|
||||
function findLeadMemberName(activeMembers: string[]): string | null {
|
||||
return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null;
|
||||
}
|
||||
|
||||
export function extractMemberWorkSyncTaskId(input: {
|
||||
taskId?: string;
|
||||
detail?: string;
|
||||
}): string | null {
|
||||
const explicit = input.taskId?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const detail = input.detail?.trim();
|
||||
if (!detail || detail.startsWith('.') || !detail.endsWith('.json')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = detail.split(/[\\/]/).filter(Boolean).at(-1);
|
||||
const taskId = fileName?.replace(/\.json$/i, '').trim();
|
||||
return taskId && !taskId.startsWith('.') ? taskId : null;
|
||||
}
|
||||
|
||||
export class MemberWorkSyncTaskImpactResolver {
|
||||
constructor(private readonly deps: MemberWorkSyncTaskImpactResolverDeps) {}
|
||||
|
||||
async resolve(input: {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
}): Promise<MemberWorkSyncTaskImpactResolverResult> {
|
||||
const taskId = input.taskId.trim();
|
||||
if (!taskId) {
|
||||
return {
|
||||
memberNames: [],
|
||||
fallbackTeamWide: true,
|
||||
diagnostics: ['task_id_missing'],
|
||||
};
|
||||
}
|
||||
|
||||
const [activeMembers, tasks, kanban] = await Promise.all([
|
||||
this.deps.activeMemberSource.loadActiveMemberNames(input.teamName),
|
||||
this.deps.taskReader.getTasks(input.teamName),
|
||||
this.deps.kanbanManager.getState(input.teamName),
|
||||
]);
|
||||
const activeByName = new Map(
|
||||
activeMembers.map((memberName) => [normalizeMemberName(memberName), memberName] as const)
|
||||
);
|
||||
const impacted = new Set<string>();
|
||||
const diagnostics: string[] = [];
|
||||
const addDiagnostic = (diagnostic: string): void => {
|
||||
if (!diagnostics.includes(diagnostic)) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
};
|
||||
const addMember = (value: unknown): void => {
|
||||
const normalized = normalizeMemberName(value);
|
||||
const activeName = activeByName.get(normalized);
|
||||
if (activeName) {
|
||||
impacted.add(activeName);
|
||||
}
|
||||
};
|
||||
const addLead = (): void => {
|
||||
const leadName = findLeadMemberName(activeMembers);
|
||||
if (leadName) {
|
||||
impacted.add(leadName);
|
||||
} else {
|
||||
addDiagnostic('lead_member_unavailable');
|
||||
}
|
||||
};
|
||||
|
||||
const task = tasks.find((candidate) => taskMatchesId(candidate, taskId));
|
||||
if (!task) {
|
||||
return {
|
||||
memberNames: [],
|
||||
fallbackTeamWide: true,
|
||||
diagnostics: ['task_not_found'],
|
||||
};
|
||||
}
|
||||
|
||||
addMember(task.owner);
|
||||
|
||||
const reviewOwner = resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
});
|
||||
addMember(reviewOwner?.reviewer);
|
||||
|
||||
if (!normalizeMemberName(task.owner)) {
|
||||
addLead();
|
||||
addDiagnostic('task_owner_missing');
|
||||
} else if (!activeByName.has(normalizeMemberName(task.owner))) {
|
||||
addLead();
|
||||
addDiagnostic('task_owner_inactive');
|
||||
}
|
||||
|
||||
if (task.reviewState === 'review' && !reviewOwner?.reviewer) {
|
||||
addLead();
|
||||
addDiagnostic('task_reviewer_missing');
|
||||
}
|
||||
|
||||
if (task.needsClarification === 'lead') {
|
||||
addLead();
|
||||
}
|
||||
|
||||
const tasksByReference = new Map(
|
||||
tasks.flatMap((candidate) =>
|
||||
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
|
||||
)
|
||||
);
|
||||
const brokenDependencies = (task.blockedBy ?? []).filter((dependencyId) => {
|
||||
const dependency = tasksByReference.get(dependencyId);
|
||||
return !dependency || isDeletedTask(dependency);
|
||||
});
|
||||
if (brokenDependencies.length > 0) {
|
||||
addLead();
|
||||
addDiagnostic('task_has_broken_dependencies');
|
||||
}
|
||||
|
||||
for (const candidate of tasks) {
|
||||
if (candidate.id === task.id || isTerminalTask(candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(candidate.blockedBy ?? []).some(
|
||||
(dependencyId) => tasksByReference.get(dependencyId) === task
|
||||
)
|
||||
) {
|
||||
addMember(candidate.owner);
|
||||
if (isDeletedTask(task)) {
|
||||
addLead();
|
||||
addDiagnostic('dependent_task_has_deleted_dependency');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
memberNames: [...impacted].sort((left, right) => left.localeCompare(right)),
|
||||
fallbackTeamWide: false,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ import type {
|
|||
MemberWorkSyncEventQueue,
|
||||
MemberWorkSyncTriggerReason,
|
||||
} from '../../infrastructure/MemberWorkSyncEventQueue';
|
||||
import type { MemberWorkSyncTaskImpactResolver } from './MemberWorkSyncTaskImpactResolver';
|
||||
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
|
||||
import { extractMemberWorkSyncTaskId } from './MemberWorkSyncTaskImpactResolver';
|
||||
|
||||
interface MemberTurnSettledEventPayload {
|
||||
memberName?: string;
|
||||
|
|
@ -20,8 +22,6 @@ interface MemberWorkSyncMemberStorageMaterializer {
|
|||
|
||||
const TEAM_WIDE_REASONS: Partial<Record<TeamChangeEvent['type'], MemberWorkSyncTriggerReason>> = {
|
||||
config: 'config_changed',
|
||||
task: 'task_changed',
|
||||
'task-log-change': 'runtime_activity',
|
||||
'log-source-change': 'runtime_activity',
|
||||
process: 'runtime_activity',
|
||||
'lead-activity': 'runtime_activity',
|
||||
|
|
@ -63,7 +63,8 @@ export class MemberWorkSyncTeamChangeRouter {
|
|||
constructor(
|
||||
private readonly rosterSource: MemberWorkSyncRosterSource,
|
||||
private readonly queue: MemberWorkSyncEventQueue,
|
||||
private readonly materializer?: MemberWorkSyncMemberStorageMaterializer
|
||||
private readonly materializer?: MemberWorkSyncMemberStorageMaterializer,
|
||||
private readonly taskImpactResolver?: MemberWorkSyncTaskImpactResolver
|
||||
) {}
|
||||
|
||||
async enqueueStartupScan(teamNames: string[]): Promise<void> {
|
||||
|
|
@ -118,6 +119,14 @@ export class MemberWorkSyncTeamChangeRouter {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'task' || event.type === 'task-log-change') {
|
||||
const triggerReason = event.type === 'task' ? 'task_changed' : 'runtime_activity';
|
||||
void this.enqueueTaskRelatedMembers(event, triggerReason).catch(() =>
|
||||
this.enqueueTeam(event.teamName, triggerReason).catch(() => undefined)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'inbox' || event.type === 'lead-message') {
|
||||
const recipient = parseInboxRecipient(event.detail);
|
||||
if (recipient) {
|
||||
|
|
@ -152,4 +161,41 @@ export class MemberWorkSyncTeamChangeRouter {
|
|||
this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs });
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueueTaskRelatedMembers(
|
||||
event: TeamChangeEvent,
|
||||
triggerReason: MemberWorkSyncTriggerReason
|
||||
): Promise<void> {
|
||||
const taskId = extractMemberWorkSyncTaskId({
|
||||
taskId: event.taskId,
|
||||
detail: event.detail,
|
||||
});
|
||||
if (!taskId || !this.taskImpactResolver) {
|
||||
await this.enqueueTeam(event.teamName, triggerReason);
|
||||
return;
|
||||
}
|
||||
|
||||
const impact = await this.taskImpactResolver.resolve({
|
||||
teamName: event.teamName,
|
||||
taskId,
|
||||
});
|
||||
if (impact.fallbackTeamWide) {
|
||||
await this.enqueueTeam(event.teamName, triggerReason);
|
||||
return;
|
||||
}
|
||||
if (this.materializer) {
|
||||
await Promise.allSettled(
|
||||
impact.memberNames.map((memberName) =>
|
||||
this.materializer?.materializeMember(event.teamName, memberName)
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const memberName of impact.memberNames) {
|
||||
this.queue.enqueue({
|
||||
teamName: event.teamName,
|
||||
memberName,
|
||||
triggerReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import {
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
type MemberWorkSyncMetricsRequest,
|
||||
type MemberWorkSyncReportRequest,
|
||||
|
|
@ -45,6 +46,18 @@ export function registerMemberWorkSyncIpc(
|
|||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
||||
async (_event, request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> => {
|
||||
try {
|
||||
return await feature.refreshStatus(request);
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh member work sync status', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
|
||||
|
|
@ -60,6 +73,7 @@ export function registerMemberWorkSyncIpc(
|
|||
|
||||
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_REFRESH_STATUS);
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS);
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type RuntimeTurnSettledTargetResolverPort,
|
||||
} from '../../core/application';
|
||||
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||
import { MemberWorkSyncTaskImpactResolver } from '../adapters/input/MemberWorkSyncTaskImpactResolver';
|
||||
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
|
||||
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||
|
|
@ -53,34 +54,6 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt
|
|||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
export const MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV =
|
||||
'CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED';
|
||||
|
||||
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
||||
|
||||
function emptyNudgeDispatchSummary(): MemberWorkSyncNudgeDispatchSummary {
|
||||
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||
}
|
||||
|
||||
export function resolveMemberWorkSyncNudgeSideEffectsEnabled(
|
||||
env: Record<string, string | undefined> = process.env
|
||||
): boolean {
|
||||
const rawValue = env[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV];
|
||||
if (rawValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = rawValue.trim().toLowerCase();
|
||||
if (TRUE_ENV_VALUES.has(value)) {
|
||||
return true;
|
||||
}
|
||||
if (FALSE_ENV_VALUES.has(value)) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
|
||||
teamsBasePath: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
|
|
@ -92,6 +65,7 @@ export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
|
|||
|
||||
export interface MemberWorkSyncFeatureFacade {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
noteTeamChange(event: TeamChangeEvent): void;
|
||||
|
|
@ -117,7 +91,6 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
membersMetaStore: TeamMembersMetaStore;
|
||||
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||
nudgeSideEffectsEnabled?: boolean;
|
||||
queueQuietWindowMs?: number;
|
||||
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
|
|
@ -166,17 +139,15 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
|
||||
const busySignal = new MemberWorkSyncToolActivityBusySignal();
|
||||
const nudgeSideEffectsEnabled =
|
||||
deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled();
|
||||
const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null;
|
||||
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
|
||||
const useCaseDeps = {
|
||||
clock,
|
||||
hash,
|
||||
agendaSource,
|
||||
statusStore: store,
|
||||
reportStore: store,
|
||||
...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}),
|
||||
...(inboxNudge ? { inboxNudge } : {}),
|
||||
outboxStore: store,
|
||||
inboxNudge,
|
||||
watchdogCooldown,
|
||||
busySignal,
|
||||
reportToken,
|
||||
|
|
@ -193,22 +164,30 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||
await reconciler.execute(request, context);
|
||||
if (nudgeSideEffectsEnabled) {
|
||||
await nudgeDispatcher.dispatchDue({
|
||||
teamNames: [request.teamName],
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
});
|
||||
}
|
||||
await nudgeDispatcher.dispatchDue({
|
||||
teamNames: [request.teamName],
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
});
|
||||
},
|
||||
isTeamActive: deps.isTeamActive ?? (() => true),
|
||||
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
||||
auditJournal,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue, {
|
||||
materializeMember: (teamName, memberName) =>
|
||||
storePaths.ensureMemberWorkSyncDir(teamName, memberName),
|
||||
const taskImpactResolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: deps.taskReader,
|
||||
kanbanManager: deps.kanbanManager,
|
||||
activeMemberSource: agendaSource,
|
||||
});
|
||||
const router = new MemberWorkSyncTeamChangeRouter(
|
||||
agendaSource,
|
||||
queue,
|
||||
{
|
||||
materializeMember: (teamName, memberName) =>
|
||||
storePaths.ensureMemberWorkSyncDir(teamName, memberName),
|
||||
},
|
||||
taskImpactResolver
|
||||
);
|
||||
const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({
|
||||
eventStore: runtimeTurnSettledStore,
|
||||
normalizer: runtimeTurnSettledNormalizer,
|
||||
|
|
@ -234,23 +213,23 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
drain: () => runtimeTurnSettledIngestor.drainPending(),
|
||||
logger: deps.logger,
|
||||
});
|
||||
const nudgeDispatchScheduler =
|
||||
nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames
|
||||
? new MemberWorkSyncNudgeDispatchScheduler({
|
||||
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
|
||||
dispatchDue: (teamNames) =>
|
||||
nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
claimedBy: `member-work-sync:${process.pid}:scheduled`,
|
||||
}),
|
||||
logger: deps.logger,
|
||||
})
|
||||
: null;
|
||||
const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames
|
||||
? new MemberWorkSyncNudgeDispatchScheduler({
|
||||
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
|
||||
dispatchDue: (teamNames) =>
|
||||
nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
claimedBy: `member-work-sync:${process.pid}:scheduled`,
|
||||
}),
|
||||
logger: deps.logger,
|
||||
})
|
||||
: null;
|
||||
runtimeTurnSettledDrainScheduler.start();
|
||||
nudgeDispatchScheduler?.start();
|
||||
|
||||
return {
|
||||
getStatus: (request) => diagnosticsReader.execute(request),
|
||||
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
|
||||
getMetrics: (request) => metricsReader.execute(request),
|
||||
report: (request) => reporter.execute(request),
|
||||
noteTeamChange: (event) => {
|
||||
|
|
@ -277,12 +256,10 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
);
|
||||
},
|
||||
dispatchDueNudges: (teamNames) =>
|
||||
nudgeSideEffectsEnabled
|
||||
? nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
})
|
||||
: Promise.resolve(emptyNudgeDispatchSummary()),
|
||||
nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
}),
|
||||
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
|
||||
runtimeTurnSettledSpool.buildHookSettings({ provider }),
|
||||
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,4 @@ export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWork
|
|||
export {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
} from './composition/createMemberWorkSyncFeature';
|
||||
|
|
|
|||
|
|
@ -139,7 +139,10 @@ export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncRepor
|
|||
return existing;
|
||||
}
|
||||
|
||||
const next = this.loadOrCreateSecret(teamName);
|
||||
const next = this.loadOrCreateSecret(teamName).catch((error: unknown) => {
|
||||
this.secretCache.delete(teamName);
|
||||
throw error;
|
||||
});
|
||||
this.secretCache.set(teamName, next);
|
||||
return next;
|
||||
}
|
||||
|
|
@ -153,7 +156,8 @@ export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncRepor
|
|||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
// A corrupt token secret only affects short-lived proof tokens. Regenerate it so
|
||||
// member work sync can recover without requiring an app restart.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,20 +24,57 @@ export interface MemberWorkSyncQueueDiagnostics {
|
|||
reconciled: number;
|
||||
dropped: number;
|
||||
failed: number;
|
||||
nextRunAt?: string;
|
||||
oldestQueuedAgeMs?: number;
|
||||
oldestRunningAgeMs?: number;
|
||||
queuedItems: MemberWorkSyncQueuedItemDiagnostics[];
|
||||
runningItems: MemberWorkSyncRunningItemDiagnostics[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncQueuedItemDiagnostics {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
firstQueuedAt: string;
|
||||
lastQueuedAt: string;
|
||||
runAt: string;
|
||||
maxRunAt: string;
|
||||
triggerReasons: MemberWorkSyncTriggerReason[];
|
||||
triggerReasonCounts: Partial<Record<MemberWorkSyncTriggerReason, number>>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncRunningItemDiagnostics {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
startedAt: string;
|
||||
ageMs: number;
|
||||
rerunRequested: boolean;
|
||||
triggerReasons: MemberWorkSyncTriggerReason[];
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
firstQueuedAt: number;
|
||||
lastQueuedAt: number;
|
||||
runAt: number;
|
||||
maxRunAt: number;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
|
||||
}
|
||||
|
||||
interface RunningItem {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
startedAt: number;
|
||||
rerunRequested: boolean;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
}
|
||||
|
||||
interface TriggerTimingPolicy {
|
||||
runAfterMs: number;
|
||||
maxCoalesceWaitMs: number;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncEventQueueDeps {
|
||||
reconcile(
|
||||
input: { teamName: string; memberName: string },
|
||||
|
|
@ -45,6 +82,7 @@ export interface MemberWorkSyncEventQueueDeps {
|
|||
): Promise<void>;
|
||||
isTeamActive(teamName: string): Promise<boolean> | boolean;
|
||||
quietWindowMs?: number;
|
||||
triggerTiming?: Partial<Record<MemberWorkSyncTriggerReason, Partial<TriggerTimingPolicy>>>;
|
||||
concurrency?: number;
|
||||
now?: () => number;
|
||||
nowIso?: () => string;
|
||||
|
|
@ -85,6 +123,29 @@ export class MemberWorkSyncEventQueue {
|
|||
this.nowIso = deps.nowIso ?? (() => new Date().toISOString());
|
||||
}
|
||||
|
||||
private resolveTimingPolicy(
|
||||
triggerReason: MemberWorkSyncTriggerReason,
|
||||
explicitRunAfterMs?: number
|
||||
): TriggerTimingPolicy {
|
||||
const custom = this.deps.triggerTiming?.[triggerReason];
|
||||
const quietWindowFallback =
|
||||
this.deps.quietWindowMs != null && triggerReason !== 'manual_refresh';
|
||||
const runAfterMs = Math.max(
|
||||
0,
|
||||
explicitRunAfterMs ??
|
||||
custom?.runAfterMs ??
|
||||
(quietWindowFallback ? this.quietWindowMs : defaultRunAfterMs(triggerReason))
|
||||
);
|
||||
const maxCoalesceWaitMs = Math.max(
|
||||
runAfterMs,
|
||||
custom?.maxCoalesceWaitMs ??
|
||||
(quietWindowFallback
|
||||
? Math.max(this.quietWindowMs, this.quietWindowMs * 5)
|
||||
: defaultMaxCoalesceWaitMs(triggerReason))
|
||||
);
|
||||
return { runAfterMs, maxCoalesceWaitMs };
|
||||
}
|
||||
|
||||
enqueue(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
|
|
@ -103,7 +164,9 @@ export class MemberWorkSyncEventQueue {
|
|||
}
|
||||
|
||||
const key = keyOf(teamName, memberName);
|
||||
const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs);
|
||||
const now = this.now();
|
||||
const timing = this.resolveTimingPolicy(input.triggerReason, input.runAfterMs);
|
||||
const runAt = now + timing.runAfterMs;
|
||||
const running = this.running.get(key);
|
||||
if (running) {
|
||||
running.rerunRequested = true;
|
||||
|
|
@ -122,7 +185,20 @@ export class MemberWorkSyncEventQueue {
|
|||
const existing = this.items.get(key);
|
||||
if (existing) {
|
||||
existing.triggerReasons.add(input.triggerReason);
|
||||
existing.runAt = Math.max(existing.runAt, runAt);
|
||||
existing.lastQueuedAt = now;
|
||||
existing.maxRunAt = Math.max(
|
||||
existing.maxRunAt,
|
||||
existing.firstQueuedAt + timing.maxCoalesceWaitMs
|
||||
);
|
||||
const preserveEarlierRun =
|
||||
existing.runAt <= now ||
|
||||
existing.triggerReasons.has('manual_refresh') ||
|
||||
input.triggerReason === 'manual_refresh' ||
|
||||
runAt < existing.runAt;
|
||||
existing.runAt = preserveEarlierRun
|
||||
? Math.min(existing.runAt, runAt)
|
||||
: Math.min(Math.max(existing.runAt, runAt), existing.maxRunAt);
|
||||
incrementReasonCount(existing.triggerReasonCounts, input.triggerReason);
|
||||
this.counters.coalesced += 1;
|
||||
this.appendAudit({
|
||||
teamName,
|
||||
|
|
@ -138,8 +214,12 @@ export class MemberWorkSyncEventQueue {
|
|||
this.items.set(key, {
|
||||
teamName,
|
||||
memberName,
|
||||
firstQueuedAt: now,
|
||||
lastQueuedAt: now,
|
||||
runAt,
|
||||
maxRunAt: now + timing.maxCoalesceWaitMs,
|
||||
triggerReasons: new Set([input.triggerReason]),
|
||||
triggerReasonCounts: new Map([[input.triggerReason, 1]]),
|
||||
});
|
||||
this.counters.enqueued += 1;
|
||||
this.appendAudit({
|
||||
|
|
@ -163,10 +243,50 @@ export class MemberWorkSyncEventQueue {
|
|||
}
|
||||
|
||||
getDiagnostics(): MemberWorkSyncQueueDiagnostics {
|
||||
const now = this.now();
|
||||
const queuedItems = [...this.items.values()]
|
||||
.sort((left, right) => left.runAt - right.runAt)
|
||||
.map((item) => ({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
firstQueuedAt: new Date(item.firstQueuedAt).toISOString(),
|
||||
lastQueuedAt: new Date(item.lastQueuedAt).toISOString(),
|
||||
runAt: new Date(item.runAt).toISOString(),
|
||||
maxRunAt: new Date(item.maxRunAt).toISOString(),
|
||||
triggerReasons: [...item.triggerReasons].sort(),
|
||||
triggerReasonCounts: Object.fromEntries(item.triggerReasonCounts),
|
||||
}));
|
||||
const runningItems = [...this.running.values()]
|
||||
.sort((left, right) => left.startedAt - right.startedAt)
|
||||
.map((item) => ({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
startedAt: new Date(item.startedAt).toISOString(),
|
||||
ageMs: Math.max(0, now - item.startedAt),
|
||||
rerunRequested: item.rerunRequested,
|
||||
triggerReasons: [...item.triggerReasons].sort(),
|
||||
}));
|
||||
const oldestQueuedAt =
|
||||
queuedItems.length > 0
|
||||
? Math.min(...[...this.items.values()].map((item) => item.firstQueuedAt))
|
||||
: null;
|
||||
const oldestRunningAt =
|
||||
runningItems.length > 0
|
||||
? Math.min(...[...this.running.values()].map((item) => item.startedAt))
|
||||
: null;
|
||||
const nextRunAt =
|
||||
this.items.size > 0 ? Math.min(...[...this.items.values()].map((item) => item.runAt)) : null;
|
||||
return {
|
||||
queued: this.items.size,
|
||||
running: this.running.size,
|
||||
...this.counters,
|
||||
...(nextRunAt != null ? { nextRunAt: new Date(nextRunAt).toISOString() } : {}),
|
||||
...(oldestQueuedAt != null ? { oldestQueuedAgeMs: Math.max(0, now - oldestQueuedAt) } : {}),
|
||||
...(oldestRunningAt != null
|
||||
? { oldestRunningAgeMs: Math.max(0, now - oldestRunningAt) }
|
||||
: {}),
|
||||
queuedItems,
|
||||
runningItems,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +346,9 @@ export class MemberWorkSyncEventQueue {
|
|||
|
||||
private runItem(key: string, item: QueueItem): void {
|
||||
const running: RunningItem = {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
startedAt: this.now(),
|
||||
rerunRequested: false,
|
||||
triggerReasons: new Set(item.triggerReasons),
|
||||
};
|
||||
|
|
@ -244,13 +367,7 @@ export class MemberWorkSyncEventQueue {
|
|||
this.running.delete(key);
|
||||
this.inFlight.delete(promise);
|
||||
if (running.rerunRequested && !this.stopped) {
|
||||
for (const reason of running.triggerReasons) {
|
||||
this.enqueue({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
triggerReason: reason,
|
||||
});
|
||||
}
|
||||
this.enqueueFollowUp(item, running);
|
||||
}
|
||||
this.pump();
|
||||
});
|
||||
|
|
@ -258,6 +375,31 @@ export class MemberWorkSyncEventQueue {
|
|||
this.inFlight.add(promise);
|
||||
}
|
||||
|
||||
private enqueueFollowUp(item: QueueItem, running: RunningItem): void {
|
||||
const reasons = [...running.triggerReasons].sort();
|
||||
const primaryReason =
|
||||
reasons.find((reason) => reason === 'manual_refresh') ??
|
||||
reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ??
|
||||
reasons[0] ??
|
||||
'manual_refresh';
|
||||
this.enqueue({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
triggerReason: primaryReason,
|
||||
runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000),
|
||||
});
|
||||
const queued = this.items.get(keyOf(item.teamName, item.memberName));
|
||||
if (!queued) {
|
||||
return;
|
||||
}
|
||||
for (const reason of reasons) {
|
||||
queued.triggerReasons.add(reason);
|
||||
if (reason !== primaryReason) {
|
||||
incrementReasonCount(queued.triggerReasonCounts, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise<void> {
|
||||
if (!(await this.deps.isTeamActive(item.teamName))) {
|
||||
this.counters.dropped += 1;
|
||||
|
|
@ -307,3 +449,46 @@ export class MemberWorkSyncEventQueue {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
function incrementReasonCount(
|
||||
counts: Map<MemberWorkSyncTriggerReason, number>,
|
||||
reason: MemberWorkSyncTriggerReason
|
||||
): void {
|
||||
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number {
|
||||
switch (reason) {
|
||||
case 'manual_refresh':
|
||||
return 0;
|
||||
case 'turn_settled':
|
||||
case 'tool_finished':
|
||||
return 5_000;
|
||||
case 'task_changed':
|
||||
case 'inbox_changed':
|
||||
case 'runtime_activity':
|
||||
return 15_000;
|
||||
case 'startup_scan':
|
||||
case 'config_changed':
|
||||
case 'member_spawned':
|
||||
return 30_000;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number {
|
||||
switch (reason) {
|
||||
case 'manual_refresh':
|
||||
return 0;
|
||||
case 'turn_settled':
|
||||
case 'tool_finished':
|
||||
return 30_000;
|
||||
case 'task_changed':
|
||||
case 'inbox_changed':
|
||||
case 'runtime_activity':
|
||||
return 60_000;
|
||||
case 'startup_scan':
|
||||
case 'config_changed':
|
||||
case 'member_spawned':
|
||||
return 90_000;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
type MemberWorkSyncMetricsRequest,
|
||||
type MemberWorkSyncReportRequest,
|
||||
|
|
@ -14,6 +15,7 @@ import type { IpcRenderer } from 'electron';
|
|||
|
||||
export interface MemberWorkSyncElectronApi {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
|
@ -21,6 +23,7 @@ export interface MemberWorkSyncElectronApi {
|
|||
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
|
||||
return {
|
||||
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
|
||||
refreshStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REFRESH_STATUS, request),
|
||||
getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request),
|
||||
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -753,6 +753,34 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { teamName: string } }>(
|
||||
'/api/teams/:teamName/member-work-sync/diagnostics',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const validatedTeamName = validateTeamName(request.params.teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
const feature = getMemberWorkSyncFeature(services);
|
||||
const metrics = await feature.getMetrics({ teamName: validatedTeamName.value! });
|
||||
return reply.send({
|
||||
teamName: validatedTeamName.value!,
|
||||
generatedAt: new Date().toISOString(),
|
||||
queue: feature.getQueueDiagnostics(),
|
||||
metrics,
|
||||
});
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(
|
||||
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/diagnostics:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { teamName: string } }>(
|
||||
'/api/teams/:teamName/member-work-sync/metrics',
|
||||
async (request, reply) => {
|
||||
|
|
@ -808,6 +836,36 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { teamName: string; memberName: string } }>(
|
||||
'/api/teams/:teamName/member-work-sync/:memberName/refresh',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const validatedTeamName = validateTeamName(request.params.teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
const memberName = request.params.memberName?.trim();
|
||||
if (!memberName) {
|
||||
return reply.status(400).send({ error: 'memberName is required' });
|
||||
}
|
||||
return reply.send(
|
||||
await getMemberWorkSyncFeature(services).refreshStatus({
|
||||
teamName: validatedTeamName.value!,
|
||||
memberName,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(
|
||||
`Error in POST /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}/refresh:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
|
||||
'/api/teams/:teamName/member-work-sync/report',
|
||||
async (request, reply) => {
|
||||
|
|
@ -832,7 +890,14 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
.send({ error: 'state must be still_working, blocked, or caught_up' });
|
||||
}
|
||||
const taskIds = Array.isArray(payload.taskIds)
|
||||
? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string')
|
||||
? [
|
||||
...new Set(
|
||||
payload.taskIds
|
||||
.filter((taskId): taskId is string => typeof taskId === 'string')
|
||||
.map((taskId) => taskId.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
]
|
||||
: undefined;
|
||||
return reply.send(
|
||||
await getMemberWorkSyncFeature(services).report({
|
||||
|
|
@ -843,7 +908,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
...(typeof payload.reportToken === 'string'
|
||||
? { reportToken: payload.reportToken }
|
||||
: {}),
|
||||
...(taskIds ? { taskIds } : {}),
|
||||
...(taskIds?.length ? { taskIds } : {}),
|
||||
...(typeof payload.note === 'string' ? { note: payload.note } : {}),
|
||||
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
|
||||
...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}),
|
||||
|
|
|
|||
|
|
@ -834,7 +834,6 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
launchIoGovernor?.noteTeamChange(row as TeamChangeEvent);
|
||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||
|
||||
if (row.type === 'config') {
|
||||
if (detail === 'config.json') {
|
||||
|
|
@ -850,6 +849,8 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
|
||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||
|
||||
if (
|
||||
teamDataService &&
|
||||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||
|
|
|
|||
|
|
@ -1065,6 +1065,7 @@ export class FileWatcher extends EventEmitter {
|
|||
type: 'task',
|
||||
teamName,
|
||||
detail: relative,
|
||||
taskId: relative.replace(/\.json$/i, ''),
|
||||
};
|
||||
this.emit('team-change', event);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6558,6 +6558,12 @@ export class TeamProvisioningService {
|
|||
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
|
||||
return toolNames.some((toolName) => {
|
||||
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||
if (
|
||||
ledgerRecord?.messageKind === 'member_work_sync_nudge' &&
|
||||
normalized === 'member_work_sync_report'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalized === 'task_start' ||
|
||||
normalized === 'task_add_comment' ||
|
||||
|
|
@ -6577,6 +6583,7 @@ export class TeamProvisioningService {
|
|||
private normalizeOpenCodeObservedToolName(toolName: string): string {
|
||||
return toolName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^mcp__agent[-_]teams__/, '')
|
||||
.replace(/^agent[-_]teams_/, '')
|
||||
.replace(/^mcp__agent_teams__/, '')
|
||||
|
|
@ -7449,6 +7456,7 @@ export class TeamProvisioningService {
|
|||
source: 'watchdog',
|
||||
replyRecipient,
|
||||
actionMode: message.actionMode ?? null,
|
||||
messageKind: message.messageKind ?? null,
|
||||
taskRefs: message.taskRefs ?? [],
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: message.text,
|
||||
|
|
@ -7500,6 +7508,7 @@ export class TeamProvisioningService {
|
|||
messageId?: string;
|
||||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
messageKind?: InboxMessage['messageKind'];
|
||||
taskRefs?: TaskRef[];
|
||||
source?: OpenCodeMemberInboxRelayOptions['source'];
|
||||
inboxTimestamp?: string;
|
||||
|
|
@ -7700,6 +7709,7 @@ export class TeamProvisioningService {
|
|||
messageId: input.messageId,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
taskRefs: input.taskRefs,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
|
|
@ -7806,6 +7816,7 @@ export class TeamProvisioningService {
|
|||
source: input.source ?? 'manual',
|
||||
replyRecipient: input.replyRecipient ?? 'user',
|
||||
actionMode: input.actionMode ?? null,
|
||||
messageKind: input.messageKind ?? null,
|
||||
taskRefs: input.taskRefs ?? [],
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: input.text,
|
||||
|
|
@ -7943,6 +7954,7 @@ export class TeamProvisioningService {
|
|||
messageId,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
});
|
||||
|
|
@ -8071,6 +8083,7 @@ export class TeamProvisioningService {
|
|||
messageId: input.messageId,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
taskRefs: input.taskRefs,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
|
|
@ -18484,6 +18497,7 @@ export class TeamProvisioningService {
|
|||
source: effectiveSource,
|
||||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode,
|
||||
messageKind: message.messageKind ?? null,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: message.text,
|
||||
|
|
@ -18524,6 +18538,7 @@ export class TeamProvisioningService {
|
|||
messageId: message.messageId,
|
||||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode ?? undefined,
|
||||
messageKind: message.messageKind,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
source: effectiveSource,
|
||||
inboxTimestamp: message.timestamp,
|
||||
|
|
@ -18913,6 +18928,10 @@ export class TeamProvisioningService {
|
|||
...(member.role?.trim() ? { role: member.role.trim() } : {}),
|
||||
}));
|
||||
const rosterContextBlock = buildLeadRosterContextBlock(teamName, leadName, teammateRoster);
|
||||
const workSyncControlUrl = await this.resolveControlApiBaseUrl();
|
||||
const workSyncControlUrlClause = workSyncControlUrl
|
||||
? `, controlUrl="${workSyncControlUrl}"`
|
||||
: '';
|
||||
run.activeCrossTeamReplyHints = batch.flatMap((m) => {
|
||||
if (m.source !== 'cross_team') return [];
|
||||
const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : '';
|
||||
|
|
@ -18937,6 +18956,7 @@ export class TeamProvisioningService {
|
|||
`For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`,
|
||||
`Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`,
|
||||
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`,
|
||||
`If a message below has Message kind: member_work_sync_nudge, it is actionable work-sync control traffic, not routine notification noise. Do NOT ignore it as a pure system notification. Call member_work_sync_status with teamName="${teamName}", memberName="${leadName}"${workSyncControlUrlClause}, then call member_work_sync_report with the same teamName/memberName${workSyncControlUrlClause}, the returned agendaFingerprint/reportToken, and taskIds from the nudge task refs. Do not use provider names, runtime names, or team names as memberName. If the agenda still has actionable work you are continuing, use state "still_working"; if blocked, use state "blocked" and record the blocker on the task.`,
|
||||
`Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the task comment notification is FYI and no durable update is needed, say nothing.`,
|
||||
`If a message below includes a hidden structured task-context block, treat that block as authoritative for teamName/taskId/commentId. Do NOT infer alternate ids or namespaces from visible prose.`,
|
||||
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,13 @@ export interface OpenCodeSendMessageCommandBody {
|
|||
text: string;
|
||||
messageId?: string;
|
||||
actionMode?: 'do' | 'ask' | 'delegate';
|
||||
messageKind?:
|
||||
| 'default'
|
||||
| 'slash_command'
|
||||
| 'slash_command_result'
|
||||
| 'task_comment_notification'
|
||||
| 'member_work_sync_nudge'
|
||||
| 'agent_error';
|
||||
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
|
||||
agent?: string;
|
||||
noReply?: boolean;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
OpenCodeDeliveryResponseState,
|
||||
OpenCodeDeliveryVisibleReplyCorrelation,
|
||||
} from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { AgentActionMode, TaskRef } from '@shared/types/team';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
|
||||
export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1;
|
||||
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
|
@ -32,6 +32,7 @@ export interface OpenCodePromptDeliveryLedgerRecord {
|
|||
inboxMessageId: string;
|
||||
inboxTimestamp: string;
|
||||
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog';
|
||||
messageKind: InboxMessageKind | null;
|
||||
replyRecipient: string;
|
||||
actionMode: AgentActionMode | null;
|
||||
taskRefs: TaskRef[];
|
||||
|
|
@ -117,6 +118,7 @@ export interface EnsureOpenCodePromptDeliveryInput {
|
|||
inboxMessageId: string;
|
||||
inboxTimestamp: string;
|
||||
source: OpenCodePromptDeliveryLedgerRecord['source'];
|
||||
messageKind?: InboxMessageKind | null;
|
||||
replyRecipient: string;
|
||||
actionMode?: AgentActionMode | null;
|
||||
taskRefs?: TaskRef[];
|
||||
|
|
@ -175,6 +177,15 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
result = updated;
|
||||
return records.map((record) => (record.id === existing.id ? updated : record));
|
||||
}
|
||||
if (existing.messageKind == null && input.messageKind) {
|
||||
const updated: OpenCodePromptDeliveryLedgerRecord = {
|
||||
...existing,
|
||||
messageKind: input.messageKind,
|
||||
updatedAt: input.now,
|
||||
};
|
||||
result = updated;
|
||||
return records.map((record) => (record.id === existing.id ? updated : record));
|
||||
}
|
||||
result = existing;
|
||||
return records;
|
||||
}
|
||||
|
|
@ -189,6 +200,7 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
inboxMessageId: input.inboxMessageId,
|
||||
inboxTimestamp: input.inboxTimestamp,
|
||||
source: input.source,
|
||||
messageKind: input.messageKind ?? null,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode ?? null,
|
||||
taskRefs: input.taskRefs ?? [],
|
||||
|
|
@ -691,6 +703,7 @@ function isOpenCodePromptDeliveryLedgerRecord(
|
|||
typeof record.inboxMessageId === 'string' &&
|
||||
typeof record.inboxTimestamp === 'string' &&
|
||||
isOpenCodePromptDeliverySource(record.source) &&
|
||||
isOptionalNullableInboxMessageKind(record.messageKind) &&
|
||||
typeof record.replyRecipient === 'string' &&
|
||||
isOptionalNullableActionMode(record.actionMode) &&
|
||||
isTaskRefArray(record.taskRefs) &&
|
||||
|
|
@ -769,6 +782,21 @@ function isOptionalNullableActionMode(value: unknown): value is AgentActionMode
|
|||
);
|
||||
}
|
||||
|
||||
function isOptionalNullableInboxMessageKind(
|
||||
value: unknown
|
||||
): value is InboxMessageKind | null | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === 'default' ||
|
||||
value === 'slash_command' ||
|
||||
value === 'slash_command_result' ||
|
||||
value === 'task_comment_notification' ||
|
||||
value === 'member_work_sync_nudge' ||
|
||||
value === 'agent_error'
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNullableString(value: unknown): value is string | null | undefined {
|
||||
return value === undefined || value === null || typeof value === 'string';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import type {
|
|||
TeamRuntimeStopInput,
|
||||
TeamRuntimeStopResult,
|
||||
} from './TeamRuntimeAdapter';
|
||||
import type { AgentActionMode, TaskRef } from '@shared/types/team';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
|
||||
export interface OpenCodeTeamRuntimeBridgePort {
|
||||
checkOpenCodeTeamLaunchReadiness(input: {
|
||||
|
|
@ -58,6 +58,7 @@ export interface OpenCodeTeamRuntimeMessageInput {
|
|||
messageId?: string;
|
||||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
messageKind?: InboxMessageKind;
|
||||
taskRefs?: TaskRef[];
|
||||
bootstrapCheckinRetry?: {
|
||||
runtimeSessionId: string;
|
||||
|
|
@ -313,6 +314,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
text: buildOpenCodeRuntimeMessageText(input),
|
||||
messageId: input.messageId,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
taskRefs: input.taskRefs,
|
||||
agent: 'teammate',
|
||||
});
|
||||
|
|
@ -773,7 +775,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
|
||||
const replyRecipient = input.replyRecipient?.trim() || 'user';
|
||||
const deliveryContext =
|
||||
input.messageId && input.taskRefs?.length
|
||||
input.messageId && (input.taskRefs?.length || input.messageKind)
|
||||
? JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'opencode-delivery-context',
|
||||
|
|
@ -781,9 +783,38 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
laneId: input.laneId,
|
||||
memberName: input.memberName,
|
||||
inboundMessageId: input.messageId,
|
||||
...(input.messageKind ? { messageKind: input.messageKind } : {}),
|
||||
taskRefs: input.taskRefs,
|
||||
})
|
||||
: null;
|
||||
const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge';
|
||||
const taskIds =
|
||||
input.taskRefs
|
||||
?.map((ref) => ref.taskId?.trim())
|
||||
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
|
||||
const responseInstructions = isWorkSyncNudge
|
||||
? [
|
||||
'This delivered app message is a member-work-sync nudge.',
|
||||
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}".`,
|
||||
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
|
||||
taskIds.length
|
||||
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
|
||||
: null,
|
||||
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
|
||||
'Do not reply only with acknowledgement.',
|
||||
]
|
||||
: [
|
||||
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
|
||||
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
|
||||
'Include source="runtime_delivery" in that message_send call.',
|
||||
input.messageId
|
||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||
: null,
|
||||
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
|
||||
'You must not end this turn empty.',
|
||||
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
||||
];
|
||||
|
||||
return [
|
||||
'<opencode_app_message_delivery>',
|
||||
|
|
@ -791,16 +822,8 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>`
|
||||
: null,
|
||||
'You are running in OpenCode, not Claude Code or Codex native.',
|
||||
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
|
||||
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
|
||||
'Include source="runtime_delivery" in that message_send call.',
|
||||
input.messageId
|
||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||
: null,
|
||||
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
|
||||
'You must not end this turn empty.',
|
||||
...responseInstructions,
|
||||
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
|
||||
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
||||
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
||||
'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.',
|
||||
'The inbound app message follows. Treat it as the actual instruction to process now, not as background context.',
|
||||
|
|
|
|||
|
|
@ -1310,6 +1310,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
request.memberName
|
||||
)}`
|
||||
),
|
||||
refreshStatus: (request) =>
|
||||
this.post(
|
||||
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/${encodeURIComponent(
|
||||
request.memberName
|
||||
)}/refresh`,
|
||||
{}
|
||||
),
|
||||
getMetrics: (request) =>
|
||||
this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`),
|
||||
report: (request) =>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ export const ClaudeLogsPanel = ({
|
|||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<span className="font-mono">{data.total}</span> lines
|
||||
<span className="font-mono">{data.total}</span> raw line
|
||||
{data.total === 1 ? '' : 's'}
|
||||
</>
|
||||
) : isAlive ? (
|
||||
'No logs yet.'
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ interface CodexNativeJsonEvent {
|
|||
result?: unknown;
|
||||
error?: unknown;
|
||||
status?: string;
|
||||
command?: string;
|
||||
aggregated_output?: string;
|
||||
output?: string;
|
||||
stderr?: string;
|
||||
exit_code?: number;
|
||||
exitCode?: number;
|
||||
changes?: unknown;
|
||||
};
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
|
|
@ -165,7 +172,17 @@ function getCodexToolDisplayName(serverName: string, toolName: string): string {
|
|||
return serverName === 'agent-teams' ? `agent-teams_${toolName}` : `${serverName}_${toolName}`;
|
||||
}
|
||||
|
||||
function createCodexToolItem(
|
||||
function readRawString(record: Record<string, unknown> | undefined, key: string): string | null {
|
||||
const value = record?.[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function readFiniteNumber(record: Record<string, unknown> | undefined, key: string): number | null {
|
||||
const value = record?.[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function createCodexMcpToolItem(
|
||||
event: CodexNativeJsonEvent,
|
||||
timestamp: Date,
|
||||
lineIndex: number
|
||||
|
|
@ -210,6 +227,141 @@ function createCodexToolItem(
|
|||
return { type: 'tool', tool: linkedTool };
|
||||
}
|
||||
|
||||
function createCodexCommandExecutionToolItem(
|
||||
event: CodexNativeJsonEvent,
|
||||
timestamp: Date,
|
||||
lineIndex: number
|
||||
): AIGroupDisplayItem | null {
|
||||
const item = event.item;
|
||||
if (
|
||||
(event.type !== 'item.started' && event.type !== 'item.completed') ||
|
||||
item?.type !== 'command_execution'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCompleted = event.type === 'item.completed';
|
||||
const itemRecord = asRecord(item) ?? {};
|
||||
const command = readRawString(itemRecord, 'command') ?? '';
|
||||
const status =
|
||||
typeof item.status === 'string' && item.status.trim()
|
||||
? item.status
|
||||
: isCompleted
|
||||
? 'unknown'
|
||||
: 'in_progress';
|
||||
const exitCode =
|
||||
readFiniteNumber(itemRecord, 'exit_code') ?? readFiniteNumber(itemRecord, 'exitCode');
|
||||
const output =
|
||||
readRawString(itemRecord, 'aggregated_output') ??
|
||||
readRawString(itemRecord, 'output') ??
|
||||
readRawString(itemRecord, 'stderr') ??
|
||||
'';
|
||||
const input = { command };
|
||||
const linkedTool: LinkedToolItem = {
|
||||
id: item.id ?? `codex-command-L${lineIndex}`,
|
||||
name: 'Bash',
|
||||
input,
|
||||
inputPreview: getToolSummary('Bash', input),
|
||||
startTime: timestamp,
|
||||
isOrphaned: !isCompleted,
|
||||
};
|
||||
|
||||
if (isCompleted) {
|
||||
const isError =
|
||||
status === 'failed' || status === 'declined' || (exitCode !== null && exitCode !== 0);
|
||||
linkedTool.endTime = timestamp;
|
||||
linkedTool.isOrphaned = false;
|
||||
linkedTool.result = {
|
||||
content: output,
|
||||
isError,
|
||||
};
|
||||
linkedTool.outputPreview = output || undefined;
|
||||
}
|
||||
|
||||
return { type: 'tool', tool: linkedTool };
|
||||
}
|
||||
|
||||
function getFirstFileChangePath(changes: unknown[]): string {
|
||||
for (const change of changes) {
|
||||
const record = asRecord(change);
|
||||
if (typeof record?.path === 'string' && record.path.trim()) {
|
||||
return record.path;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatFileChangesResult(changes: unknown[]): string {
|
||||
const rows = changes.map((change) => {
|
||||
const record = asRecord(change);
|
||||
const path =
|
||||
typeof record?.path === 'string' && record.path.trim() ? record.path : '(unknown path)';
|
||||
const kind = typeof record?.kind === 'string' && record.kind.trim() ? record.kind : 'update';
|
||||
return `- ${path} (${kind})`;
|
||||
});
|
||||
return ['File changes:', ...rows].join('\n');
|
||||
}
|
||||
|
||||
function createCodexFileChangeToolItem(
|
||||
event: CodexNativeJsonEvent,
|
||||
timestamp: Date,
|
||||
lineIndex: number
|
||||
): AIGroupDisplayItem | null {
|
||||
const item = event.item;
|
||||
if (
|
||||
(event.type !== 'item.started' && event.type !== 'item.completed') ||
|
||||
item?.type !== 'file_change'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCompleted = event.type === 'item.completed';
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const input = {
|
||||
file_path: getFirstFileChangePath(changes),
|
||||
changes,
|
||||
};
|
||||
const status =
|
||||
typeof item.status === 'string' && item.status.trim()
|
||||
? item.status
|
||||
: isCompleted
|
||||
? 'unknown'
|
||||
: 'in_progress';
|
||||
const linkedTool: LinkedToolItem = {
|
||||
id: item.id ?? `codex-file-change-L${lineIndex}`,
|
||||
name: 'Edit',
|
||||
input,
|
||||
inputPreview: getToolSummary('Edit', input),
|
||||
startTime: timestamp,
|
||||
isOrphaned: !isCompleted,
|
||||
};
|
||||
|
||||
if (isCompleted) {
|
||||
const resultContent = formatFileChangesResult(changes);
|
||||
linkedTool.endTime = timestamp;
|
||||
linkedTool.isOrphaned = false;
|
||||
linkedTool.result = {
|
||||
content: resultContent,
|
||||
isError: status === 'failed',
|
||||
};
|
||||
linkedTool.outputPreview = resultContent;
|
||||
}
|
||||
|
||||
return { type: 'tool', tool: linkedTool };
|
||||
}
|
||||
|
||||
function createCodexToolItem(
|
||||
event: CodexNativeJsonEvent,
|
||||
timestamp: Date,
|
||||
lineIndex: number
|
||||
): AIGroupDisplayItem | null {
|
||||
return (
|
||||
createCodexMcpToolItem(event, timestamp, lineIndex) ??
|
||||
createCodexCommandExecutionToolItem(event, timestamp, lineIndex) ??
|
||||
createCodexFileChangeToolItem(event, timestamp, lineIndex)
|
||||
);
|
||||
}
|
||||
|
||||
function codexNativeEventToDisplayItems(
|
||||
parsed: unknown,
|
||||
timestamp: Date,
|
||||
|
|
|
|||
|
|
@ -624,6 +624,7 @@ export interface TeamsAPI {
|
|||
|
||||
export interface MemberWorkSyncElectronApi {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,12 +113,48 @@ describe('buildActionableWorkAgenda', () => {
|
|||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('projects clarification and blocked dependency work for the owner', () => {
|
||||
it('prefers current kanban reviewer over older review history while task remains in review', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'carol',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }, { name: 'carol' }],
|
||||
kanbanReviewersByTaskId: { 'task-1': 'carol' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Review me',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
type: 'review_started',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
actor: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toHaveLength(1);
|
||||
expect(agenda.items[0]).toMatchObject({
|
||||
taskId: 'task-1',
|
||||
kind: 'review',
|
||||
assignee: 'carol',
|
||||
evidence: { reviewer: 'carol' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not nudge owners while work is waiting on user or unfinished dependencies', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'bob' }],
|
||||
members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
|
|
@ -127,20 +163,106 @@ describe('buildActionableWorkAgenda', () => {
|
|||
owner: 'bob',
|
||||
needsClarification: 'user',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
displayId: '#33333333',
|
||||
subject: 'Dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
subject: 'Blocked',
|
||||
subject: 'Waiting dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
blockedBy: ['task-3'],
|
||||
blockedBy: ['#33333333'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.kind, item.priority])).toEqual([
|
||||
['task-1', 'clarification', 'needs_clarification'],
|
||||
['task-2', 'blocked_dependency', 'blocked'],
|
||||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not project display-id dependencies as broken when the dependency exists', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-dep',
|
||||
displayId: '#33333333',
|
||||
subject: 'Existing dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
subject: 'Waiting dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
blockedBy: ['33333333'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('projects lead-owned oversight for lead clarification and broken dependencies', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Need lead',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
needsClarification: 'lead',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
subject: 'Broken dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
blockedBy: ['missing-task'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
||||
['task-1', 'clarification', 'task_needs_lead_clarification'],
|
||||
['task-2', 'blocked_dependency', 'task_has_broken_dependency'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats needsFix as owner work', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Fix review',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
reviewState: 'needsFix',
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
||||
['task-1', 'work', 'review_changes_requested'],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,35 @@ function agendaWithWork() {
|
|||
memberName: 'bob',
|
||||
generatedAt: nowIso,
|
||||
members: [{ name: 'bob' }],
|
||||
tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '#11111111',
|
||||
subject: 'Work',
|
||||
status: 'pending',
|
||||
owner: 'bob',
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
}
|
||||
|
||||
function leadAgendaWithBrokenDependency() {
|
||||
return buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
generatedAt: nowIso,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-2',
|
||||
displayId: '#22222222',
|
||||
subject: 'Blocked work',
|
||||
status: 'pending',
|
||||
owner: 'bob',
|
||||
blockedBy: ['missing-task'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
}
|
||||
|
|
@ -40,6 +68,39 @@ describe('validateMemberWorkSyncReport', () => {
|
|||
expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z');
|
||||
});
|
||||
|
||||
it('accepts display task ids for current agenda references', () => {
|
||||
const agenda = agendaWithWork();
|
||||
const withHash = validateMemberWorkSyncReport({
|
||||
request: {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
taskIds: ['#11111111'],
|
||||
},
|
||||
agenda,
|
||||
nowIso,
|
||||
activeMemberNames: ['bob'],
|
||||
tokenValidation: validToken,
|
||||
});
|
||||
const withoutHash = validateMemberWorkSyncReport({
|
||||
request: {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
taskIds: ['11111111'],
|
||||
},
|
||||
agenda,
|
||||
nowIso,
|
||||
activeMemberNames: ['bob'],
|
||||
tokenValidation: validToken,
|
||||
});
|
||||
|
||||
expect(withHash.ok).toBe(true);
|
||||
expect(withoutHash.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects caught_up while actionable work remains', () => {
|
||||
const agenda = agendaWithWork();
|
||||
const result = validateMemberWorkSyncReport({
|
||||
|
|
@ -79,6 +140,26 @@ describe('validateMemberWorkSyncReport', () => {
|
|||
expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' });
|
||||
});
|
||||
|
||||
it('accepts blocked reports when blocker evidence is referenced by display id', () => {
|
||||
const agenda = leadAgendaWithBrokenDependency();
|
||||
const result = validateMemberWorkSyncReport({
|
||||
request: {
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
state: 'blocked',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
taskIds: ['22222222'],
|
||||
},
|
||||
agenda,
|
||||
nowIso,
|
||||
activeMemberNames: ['team-lead', 'bob'],
|
||||
tokenValidation: validToken,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.expiresAt).toBe('2026-04-29T00:30:00.000Z');
|
||||
});
|
||||
|
||||
it('rejects stale fingerprints and foreign task ids', () => {
|
||||
const agenda = agendaWithWork();
|
||||
const stale = validateMemberWorkSyncReport({
|
||||
|
|
|
|||
|
|
@ -210,10 +210,7 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
|||
}
|
||||
}
|
||||
|
||||
async countRecentDelivered(input: {
|
||||
memberName: string;
|
||||
sinceIso: string;
|
||||
}): Promise<number> {
|
||||
async countRecentDelivered(input: { memberName: string; sinceIso: string }): Promise<number> {
|
||||
return [...this.items.values()].filter(
|
||||
(item) =>
|
||||
item.status === 'delivered' &&
|
||||
|
|
@ -300,7 +297,7 @@ function createDeps(options?: {
|
|||
describe('MemberWorkSync use cases', () => {
|
||||
it('reconciles actionable work into needs_sync without side effects', async () => {
|
||||
const { auditEvents, deps, store } = createDeps();
|
||||
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
});
|
||||
|
|
@ -324,7 +321,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('accepts still_working as a bounded lease for the current fingerprint', async () => {
|
||||
const { auditEvents, clock, deps } = createDeps();
|
||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const reporter = new MemberWorkSyncReporter(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
|
||||
|
|
@ -357,7 +354,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('uses app clock instead of model supplied reportedAt for lease timing', async () => {
|
||||
const { deps } = createDeps();
|
||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const reporter = new MemberWorkSyncReporter(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
|
||||
|
|
@ -409,7 +406,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('accepts caught_up only when the app-side agenda is empty', async () => {
|
||||
const { deps } = createDeps({ items: [] });
|
||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const reporter = new MemberWorkSyncReporter(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
|
||||
|
|
@ -428,7 +425,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('marks status inactive when the team runtime is not active', async () => {
|
||||
const { deps } = createDeps({ teamActive: false });
|
||||
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
});
|
||||
|
|
@ -440,7 +437,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('records fingerprint transitions without treating them as progress proof', async () => {
|
||||
const { deps, source } = createDeps();
|
||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
|
||||
source.agenda.items = [
|
||||
|
|
@ -487,6 +484,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
});
|
||||
|
||||
expect(outbox.ensures).toEqual([]);
|
||||
expect(store.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => {
|
||||
|
|
@ -517,6 +515,15 @@ describe('MemberWorkSync use cases', () => {
|
|||
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||
},
|
||||
});
|
||||
const nudgeText = outbox.ensures[0]?.payload.text ?? '';
|
||||
expect(nudgeText).toContain(
|
||||
'member_work_sync_status with teamName "team-a" and memberName "bob"'
|
||||
);
|
||||
expect(nudgeText).toContain('member_work_sync_report with the same teamName/memberName');
|
||||
expect(nudgeText).toContain('taskIds: "task-1"');
|
||||
expect(nudgeText).toContain(
|
||||
'Do not use provider names, runtime names, or team names as memberName'
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches due nudges only after revalidating current status and readiness', async () => {
|
||||
|
|
@ -544,12 +551,41 @@ describe('MemberWorkSync use cases', () => {
|
|||
memberName: 'bob',
|
||||
messageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
|
||||
});
|
||||
expect(outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)).toMatchObject({
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'delivered',
|
||||
deliveredMessageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('recomputes agenda before dispatch and supersedes stale outbox fingerprints', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const { deps, source, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{ teamName: 'team-a', memberName: 'bob' },
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
source.agenda.items = [];
|
||||
|
||||
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 });
|
||||
expect(inbox.inserted).toEqual([]);
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'superseded',
|
||||
lastError: 'status_no_longer_matches_outbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch stale outbox items after the member reports still working', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
|
|
@ -579,7 +615,9 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 });
|
||||
expect(inbox.inserted).toEqual([]);
|
||||
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'superseded',
|
||||
lastError: 'status_no_longer_matches_outbox',
|
||||
});
|
||||
|
|
@ -630,7 +668,9 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 });
|
||||
expect(inbox.inserted).toEqual([]);
|
||||
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'member_nudge_rate_limited',
|
||||
nextAttemptAt: '2026-04-29T01:00:00.000Z',
|
||||
|
|
@ -667,7 +707,9 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 });
|
||||
expect(inbox.inserted).toEqual([]);
|
||||
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'member_busy:active_tool_activity',
|
||||
nextAttemptAt: '2026-04-29T00:02:00.000Z',
|
||||
|
|
@ -717,7 +759,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('rejects invalid report tokens without recording replayable intents', async () => {
|
||||
const { deps, store } = createDeps();
|
||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const reporter = new MemberWorkSyncReporter(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
|
||||
|
|
@ -741,7 +783,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
it('replays pending controller intents through the same app validator', async () => {
|
||||
const { deps, store } = createDeps();
|
||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
store.pendingIntents.set('intent-1', {
|
||||
id: 'intent-1',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
|
|
@ -9,11 +9,13 @@ import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infras
|
|||
|
||||
describe('HmacMemberWorkSyncReportTokenAdapter', () => {
|
||||
let root: string;
|
||||
let paths: MemberWorkSyncStorePaths;
|
||||
let adapter: HmacMemberWorkSyncReportTokenAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'member-work-sync-token-'));
|
||||
adapter = new HmacMemberWorkSyncReportTokenAdapter(new MemberWorkSyncStorePaths(root));
|
||||
paths = new MemberWorkSyncStorePaths(root);
|
||||
adapter = new HmacMemberWorkSyncReportTokenAdapter(paths);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -76,4 +78,55 @@ describe('HmacMemberWorkSyncReportTokenAdapter', () => {
|
|||
})
|
||||
).resolves.toEqual({ ok: false, reason: 'expired' });
|
||||
});
|
||||
|
||||
it('recovers from a corrupt token secret file', async () => {
|
||||
await mkdir(paths.getTeamDir('team-a'), { recursive: true });
|
||||
await writeFile(paths.getReportTokenSecretPath('team-a'), '{broken', 'utf8');
|
||||
|
||||
const issued = await adapter.create({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
issuedAt: '2026-04-29T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const secretFile = JSON.parse(await readFile(paths.getReportTokenSecretPath('team-a'), 'utf8'));
|
||||
expect(secretFile.schemaVersion).toBe(1);
|
||||
expect(typeof secretFile.secret).toBe('string');
|
||||
await expect(
|
||||
adapter.verify({
|
||||
token: issued.token,
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not cache a failed token secret load forever', async () => {
|
||||
const secretPath = paths.getReportTokenSecretPath('team-a');
|
||||
await mkdir(secretPath, { recursive: true });
|
||||
|
||||
await expect(
|
||||
adapter.create({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
issuedAt: '2026-04-29T00:00:00.000Z',
|
||||
})
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
await rm(secretPath, { recursive: true, force: true });
|
||||
await expect(
|
||||
adapter.create({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
issuedAt: '2026-04-29T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
expiresAt: '2026-04-29T00:15:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,6 +45,108 @@ describe('MemberWorkSyncEventQueue', () => {
|
|||
await queue.stop();
|
||||
});
|
||||
|
||||
it('bounds coalescing so noisy event streams cannot starve reconcile forever', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 100,
|
||||
triggerTiming: {
|
||||
task_changed: { runAfterMs: 100, maxCoalesceWaitMs: 250 },
|
||||
},
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(69);
|
||||
|
||||
expect(reconciles).toHaveLength(0);
|
||||
expect(queue.getDiagnostics()).toMatchObject({
|
||||
queued: 1,
|
||||
queuedItems: [
|
||||
{
|
||||
memberName: 'bob',
|
||||
triggerReasonCounts: { task_changed: 3 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(1);
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('lets manual refresh expedite an already queued delayed reconcile', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
triggerTiming: {
|
||||
task_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 },
|
||||
manual_refresh: { runAfterMs: 0, maxCoalesceWaitMs: 0 },
|
||||
},
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(1);
|
||||
expect(reconciles[0]).toMatchObject({
|
||||
context: { triggerReasons: ['manual_refresh', 'task_changed'] },
|
||||
});
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('does not let legacy quiet window override delay manual refresh', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 10_000,
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(1);
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('does not let a later quiet-window event delay a queued manual refresh', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
triggerTiming: {
|
||||
task_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 },
|
||||
},
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(1);
|
||||
expect(reconciles[0]).toMatchObject({
|
||||
context: { triggerReasons: ['manual_refresh', 'task_changed'] },
|
||||
});
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('drops queued work for inactive teams without reconciling', async () => {
|
||||
const reconcile = vi.fn();
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
|
|
@ -93,6 +195,80 @@ describe('MemberWorkSyncEventQueue', () => {
|
|||
await queue.stop();
|
||||
});
|
||||
|
||||
it('lets manual refresh request an immediate follow-up after an active reconcile', async () => {
|
||||
let release: () => void = () => {
|
||||
throw new Error('reconcile did not start');
|
||||
};
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
if (reconciles.length === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
triggerReason: 'config_changed',
|
||||
runAfterMs: 0,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
|
||||
|
||||
release();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(2);
|
||||
expect(reconciles[1]).toMatchObject({
|
||||
context: { triggerReasons: ['config_changed', 'manual_refresh'] },
|
||||
});
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('does not let a later event delay a due item waiting behind concurrency', async () => {
|
||||
let release: () => void = () => {
|
||||
throw new Error('reconcile did not start');
|
||||
};
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
concurrency: 1,
|
||||
triggerTiming: {
|
||||
task_changed: { runAfterMs: 0, maxCoalesceWaitMs: 5_000 },
|
||||
inbox_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 },
|
||||
},
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
if (reconciles.length === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'alice', triggerReason: 'task_changed' });
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'inbox_changed' });
|
||||
release();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(2);
|
||||
expect(reconciles[1]).toMatchObject({
|
||||
request: { memberName: 'bob' },
|
||||
context: { triggerReasons: ['inbox_changed', 'task_changed'] },
|
||||
});
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('does not spin timers while concurrency is saturated', async () => {
|
||||
let release: () => void = () => {
|
||||
throw new Error('reconcile did not start');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver';
|
||||
|
||||
import type { TeamTask } from '@shared/types';
|
||||
|
||||
describe('MemberWorkSyncTaskImpactResolver', () => {
|
||||
it('targets owner, reviewer, dependent owners and lead oversight without team-wide fan-out', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#11111111',
|
||||
subject: 'Changed',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
from: 'none',
|
||||
to: 'review',
|
||||
reviewer: 'bob',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'task-b',
|
||||
subject: 'Unblocked by A',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
blockedBy: ['task-a'],
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'bob', 'team-lead', 'tom']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: '#11111111' })).resolves.toEqual({
|
||||
memberNames: ['alice', 'bob', 'tom'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to team-wide routing when a task was removed before impact can be resolved', async () => {
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => []) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: { loadActiveMemberNames: vi.fn(async () => ['alice']) },
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'deleted-task' })).resolves.toEqual(
|
||||
{
|
||||
memberNames: [],
|
||||
fallbackTeamWide: true,
|
||||
diagnostics: ['task_not_found'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('targets lead when a deleted task breaks active dependent work', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-deleted',
|
||||
subject: 'Deleted dependency',
|
||||
status: 'deleted',
|
||||
owner: 'alice',
|
||||
deletedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-dependent',
|
||||
subject: 'Depends on deleted task',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
blockedBy: ['task-deleted'],
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-deleted' })).resolves.toEqual(
|
||||
{
|
||||
memberNames: ['alice', 'team-lead', 'tom'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: ['dependent_task_has_deleted_dependency'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('targets dependent owners when dependencies reference the changed task by display id', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#11111111',
|
||||
subject: 'Changed dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-b',
|
||||
subject: 'Depends on display id',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
blockedBy: ['11111111'],
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({
|
||||
memberNames: ['alice', 'tom'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,43 @@ describe('MemberWorkSyncTeamChangeRouter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('routes task events to resolver-impacted members when task identity is available', async () => {
|
||||
const queue = {
|
||||
enqueue: vi.fn(),
|
||||
dropTeam: vi.fn(),
|
||||
};
|
||||
const resolver = {
|
||||
resolve: vi.fn(async () => ({
|
||||
memberNames: ['bob'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: [],
|
||||
})),
|
||||
};
|
||||
const router = new MemberWorkSyncTeamChangeRouter(
|
||||
{ loadActiveMemberNames: async () => ['alice', 'bob'] },
|
||||
queue as never,
|
||||
undefined,
|
||||
resolver as never
|
||||
);
|
||||
|
||||
router.noteTeamChange({
|
||||
type: 'task',
|
||||
teamName: 'team-a',
|
||||
detail: 'task-1.json',
|
||||
taskId: 'task-1',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledWith({ teamName: 'team-a', taskId: 'task-1' });
|
||||
expect(queue.enqueue).toHaveBeenCalledTimes(1);
|
||||
expect(queue.enqueue).toHaveBeenCalledWith({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
triggerReason: 'task_changed',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes inbox and tool-finish events to the addressed member only', () => {
|
||||
const { queue, router } = createRouter();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import path from 'path';
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
} from '@features/member-work-sync/main';
|
||||
import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain';
|
||||
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
|
||||
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
|
||||
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
||||
import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment';
|
||||
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
|
|
@ -20,61 +23,208 @@ function makeTempRoot(): string {
|
|||
}
|
||||
|
||||
afterEach(() => {
|
||||
setClaudeBasePathOverride(null);
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function seedShadowReadyMetrics(input: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<void> {
|
||||
const metricsPath = path.join(
|
||||
input.teamsBasePath,
|
||||
input.teamName,
|
||||
'.member-work-sync',
|
||||
'indexes',
|
||||
'metrics.json'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
metricsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
members: {
|
||||
[input.memberName]: {
|
||||
memberName: input.memberName,
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: 'agenda:v1:seed',
|
||||
actionableCount: 0,
|
||||
evaluatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
recentEvents: Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `seed-status-${index}`,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
kind: 'status_evaluated',
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: `agenda:v1:seed-${index}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 0, 1, index)).toISOString(),
|
||||
actionableCount: 0,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
await assertion();
|
||||
}
|
||||
|
||||
describe('createMemberWorkSyncFeature composition', () => {
|
||||
it('keeps nudge side effects opt-in even when shadow readiness becomes green', () => {
|
||||
expect(resolveMemberWorkSyncNudgeSideEffectsEnabled({})).toBe(false);
|
||||
expect(
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled({
|
||||
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: 'maybe',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['1', 'true', 'yes', 'on'])(
|
||||
'enables nudge side effects only for explicit truthy env value %s',
|
||||
(value) => {
|
||||
expect(
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled({
|
||||
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['0', 'false', 'no', 'off', ''])(
|
||||
'keeps nudge side effects disabled for explicit falsy env value %s',
|
||||
(value) => {
|
||||
expect(
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled({
|
||||
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
|
||||
})
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns an empty dispatch summary when nudge side effects are disabled', async () => {
|
||||
it('dispatches a due nudge through the real outbox and inbox by default', async () => {
|
||||
const claudeRoot = makeTempRoot();
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
const teamName = 'team-a';
|
||||
const memberName = 'bob';
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: makeTempRoot(),
|
||||
configReader: {} as never,
|
||||
taskReader: {} as never,
|
||||
kanbanManager: {} as never,
|
||||
membersMetaStore: {} as never,
|
||||
nudgeSideEffectsEnabled: false,
|
||||
teamsBasePath,
|
||||
configReader: {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: teamName,
|
||||
members: [{ name: memberName }],
|
||||
})),
|
||||
} as never,
|
||||
taskReader: {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '11111111',
|
||||
subject: 'Ship sync',
|
||||
status: 'pending',
|
||||
owner: memberName,
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
kanbanManager: {
|
||||
getState: vi.fn(async () => ({
|
||||
teamName,
|
||||
reviewers: [],
|
||||
tasks: {},
|
||||
})),
|
||||
} as never,
|
||||
membersMetaStore: {
|
||||
getMembers: vi.fn(async () => []),
|
||||
} as never,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(feature.dispatchDueNudges(['team-a'])).resolves.toEqual({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
||||
const status = await feature.refreshStatus({ teamName, memberName });
|
||||
expect(status).toMatchObject({
|
||||
state: 'needs_sync',
|
||||
shadow: { wouldNudge: true },
|
||||
});
|
||||
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
||||
phase2Readiness: { state: 'shadow_ready' },
|
||||
});
|
||||
|
||||
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
|
||||
status,
|
||||
hash: new NodeHashAdapter(),
|
||||
nowIso: status.evaluatedAt,
|
||||
});
|
||||
expect(outboxInput).not.toBeNull();
|
||||
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
||||
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
outcome: 'created',
|
||||
});
|
||||
|
||||
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
||||
claimed: 1,
|
||||
delivered: 1,
|
||||
superseded: 0,
|
||||
retryable: 0,
|
||||
terminal: 0,
|
||||
});
|
||||
await expect(
|
||||
fs.promises.readFile(path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
).resolves.toContain(outboxInput!.id);
|
||||
} finally {
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('plans and dispatches due nudges after queued reconcile by default', async () => {
|
||||
const claudeRoot = makeTempRoot();
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
const teamName = 'team-a';
|
||||
const memberName = 'bob';
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath,
|
||||
configReader: {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: teamName,
|
||||
members: [{ name: memberName }],
|
||||
})),
|
||||
} as never,
|
||||
taskReader: {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '11111111',
|
||||
subject: 'Ship sync',
|
||||
status: 'pending',
|
||||
owner: memberName,
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
kanbanManager: {
|
||||
getState: vi.fn(async () => ({
|
||||
teamName,
|
||||
reviewers: [],
|
||||
tasks: {},
|
||||
})),
|
||||
} as never,
|
||||
membersMetaStore: {
|
||||
getMembers: vi.fn(async () => []),
|
||||
} as never,
|
||||
queueQuietWindowMs: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
||||
feature.noteTeamChange({
|
||||
type: 'task',
|
||||
teamName,
|
||||
taskId: 'task-1',
|
||||
} as never);
|
||||
|
||||
await waitForAssertion(async () => {
|
||||
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
||||
const inbox = await fs.promises.readFile(
|
||||
path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`),
|
||||
'utf8'
|
||||
);
|
||||
expect(inbox).toContain('member_work_sync_nudge');
|
||||
expect(inbox).toContain(`member-work-sync:${teamName}:${memberName}:agenda:v1:`);
|
||||
});
|
||||
} finally {
|
||||
await feature.dispose();
|
||||
}
|
||||
|
|
@ -96,7 +246,6 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
membersMetaStore: {
|
||||
getMembers: vi.fn(async () => []),
|
||||
} as never,
|
||||
nudgeSideEffectsEnabled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -108,7 +257,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('builds Claude Stop hook settings without requiring nudge side effects', async () => {
|
||||
it('builds Claude Stop hook settings with nudges active by default', async () => {
|
||||
const root = makeTempRoot();
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: root,
|
||||
|
|
@ -116,7 +265,6 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
taskReader: {} as never,
|
||||
kanbanManager: {} as never,
|
||||
membersMetaStore: {} as never,
|
||||
nudgeSideEffectsEnabled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -145,7 +293,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('builds Codex turn-settled environment without requiring nudge side effects', async () => {
|
||||
it('builds Codex turn-settled environment with nudges active by default', async () => {
|
||||
const root = makeTempRoot();
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: root,
|
||||
|
|
@ -153,7 +301,6 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
taskReader: {} as never,
|
||||
kanbanManager: {} as never,
|
||||
membersMetaStore: {} as never,
|
||||
nudgeSideEffectsEnabled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -172,7 +319,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('builds OpenCode turn-settled environment without requiring nudge side effects', async () => {
|
||||
it('builds OpenCode turn-settled environment with nudges active by default', async () => {
|
||||
const root = makeTempRoot();
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: root,
|
||||
|
|
@ -180,7 +327,6 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
taskReader: {} as never,
|
||||
kanbanManager: {} as never,
|
||||
membersMetaStore: {} as never,
|
||||
nudgeSideEffectsEnabled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
} from '@features/member-work-sync/contracts';
|
||||
import { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc } from '@features/member-work-sync/main';
|
||||
import {
|
||||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
} from '@features/member-work-sync/main';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncMetricsRequest,
|
||||
|
|
@ -57,6 +61,21 @@ function makeFeature(): MemberWorkSyncFeatureFacade {
|
|||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: [],
|
||||
})),
|
||||
refreshStatus: vi.fn(async (request) => ({
|
||||
teamName: request.teamName,
|
||||
memberName: request.memberName,
|
||||
state: 'needs_sync' as const,
|
||||
agenda: {
|
||||
teamName: request.teamName,
|
||||
memberName: request.memberName,
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:test',
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: [],
|
||||
})),
|
||||
getMetrics: vi.fn(async (request) => ({
|
||||
teamName: request.teamName,
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
|
|
@ -135,9 +154,14 @@ describe('registerMemberWorkSyncIpc', () => {
|
|||
|
||||
registerMemberWorkSyncIpc(ipcMain, feature);
|
||||
|
||||
expect(ipcMain.handle).toHaveBeenCalledTimes(3);
|
||||
expect(ipcMain.handle).toHaveBeenCalledTimes(4);
|
||||
expect([...handlers.keys()].sort()).toEqual(
|
||||
[MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT].sort()
|
||||
[
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
].sort()
|
||||
);
|
||||
|
||||
const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
|
||||
|
|
@ -152,6 +176,9 @@ describe('registerMemberWorkSyncIpc', () => {
|
|||
await expect(
|
||||
handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest)
|
||||
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' });
|
||||
await expect(
|
||||
handlers.get(MEMBER_WORK_SYNC_REFRESH_STATUS)?.({}, statusRequest)
|
||||
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob', state: 'needs_sync' });
|
||||
await expect(
|
||||
handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest)
|
||||
).resolves.toMatchObject({ teamName: 'team-a' });
|
||||
|
|
@ -160,6 +187,7 @@ describe('registerMemberWorkSyncIpc', () => {
|
|||
);
|
||||
|
||||
expect(feature.getStatus).toHaveBeenCalledWith(statusRequest);
|
||||
expect(feature.refreshStatus).toHaveBeenCalledWith(statusRequest);
|
||||
expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest);
|
||||
expect(feature.report).toHaveBeenCalledWith(reportRequest);
|
||||
});
|
||||
|
|
@ -209,8 +237,9 @@ describe('registerMemberWorkSyncIpc', () => {
|
|||
|
||||
removeMemberWorkSyncIpc(ipcMain);
|
||||
|
||||
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3);
|
||||
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(4);
|
||||
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS);
|
||||
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS);
|
||||
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS);
|
||||
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT);
|
||||
expect([...handlers.keys()]).toEqual(['unrelated:channel']);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
} from '@features/member-work-sync/contracts';
|
||||
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
||||
|
|
@ -41,6 +42,19 @@ describe('createMemberWorkSyncBridge', () => {
|
|||
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS, request);
|
||||
});
|
||||
|
||||
it('invokes the refresh status channel without changing the request payload', async () => {
|
||||
const request: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
|
||||
const response = { ok: true };
|
||||
const ipcRenderer = {
|
||||
invoke: vi.fn(async () => response),
|
||||
} as unknown as IpcRenderer;
|
||||
const bridge = createMemberWorkSyncBridge(ipcRenderer);
|
||||
|
||||
await expect(bridge.refreshStatus(request)).resolves.toBe(response);
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS, request);
|
||||
});
|
||||
|
||||
it('invokes the report channel without changing the request payload', async () => {
|
||||
const request: MemberWorkSyncReportRequest = {
|
||||
teamName: 'team-a',
|
||||
|
|
@ -71,8 +85,6 @@ describe('createMemberWorkSyncBridge', () => {
|
|||
} as unknown as IpcRenderer;
|
||||
const bridge = createMemberWorkSyncBridge(ipcRenderer);
|
||||
|
||||
await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -453,4 +453,147 @@ describe('HTTP team runtime routes', () => {
|
|||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves member work sync diagnostics and explicit refresh routes', async () => {
|
||||
const app = Fastify();
|
||||
const mocks = createServicesMock();
|
||||
const queueDiagnostics = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
enqueued: 2,
|
||||
coalesced: 1,
|
||||
reconciled: 1,
|
||||
dropped: 0,
|
||||
failed: 0,
|
||||
queuedItems: [],
|
||||
runningItems: [],
|
||||
};
|
||||
const metrics = {
|
||||
teamName: 'demo-team',
|
||||
generatedAt: '2026-05-05T00:00:00.000Z',
|
||||
memberCount: 1,
|
||||
stateCounts: {
|
||||
caught_up: 1,
|
||||
needs_sync: 0,
|
||||
still_working: 0,
|
||||
blocked: 0,
|
||||
inactive: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
actionableItemCount: 0,
|
||||
wouldNudgeCount: 0,
|
||||
fingerprintChangeCount: 0,
|
||||
reportAcceptedCount: 0,
|
||||
reportRejectedCount: 0,
|
||||
recentEvents: [],
|
||||
phase2Readiness: {
|
||||
state: 'collecting_shadow_data',
|
||||
reasons: ['insufficient_members'],
|
||||
thresholds: {
|
||||
minObservedMembers: 2,
|
||||
minStatusEvents: 10,
|
||||
minObservationHours: 1,
|
||||
maxWouldNudgesPerMemberHour: 1,
|
||||
maxFingerprintChangesPerMemberHour: 1,
|
||||
maxReportRejectionRate: 0.1,
|
||||
},
|
||||
rates: {
|
||||
observationHours: 0,
|
||||
statusEventCount: 0,
|
||||
wouldNudgesPerMemberHour: 0,
|
||||
fingerprintChangesPerMemberHour: 0,
|
||||
reportRejectionRate: 0,
|
||||
},
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
const refreshedStatus = {
|
||||
teamName: 'demo-team',
|
||||
memberName: 'bob',
|
||||
state: 'caught_up',
|
||||
agenda: {
|
||||
teamName: 'demo-team',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-05-05T00:00:00.000Z',
|
||||
fingerprint: 'empty',
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
evaluatedAt: '2026-05-05T00:00:00.000Z',
|
||||
diagnostics: [],
|
||||
};
|
||||
const memberWorkSyncFeature = {
|
||||
getStatus: vi.fn(),
|
||||
refreshStatus: vi.fn(async () => refreshedStatus),
|
||||
getMetrics: vi.fn(async () => metrics),
|
||||
report: vi.fn(async () => ({
|
||||
accepted: true,
|
||||
code: 'accepted',
|
||||
message: 'ok',
|
||||
status: refreshedStatus,
|
||||
})),
|
||||
noteTeamChange: vi.fn(),
|
||||
enqueueStartupScan: vi.fn(),
|
||||
replayPendingReports: vi.fn(),
|
||||
dispatchDueNudges: vi.fn(),
|
||||
buildRuntimeTurnSettledHookSettings: vi.fn(),
|
||||
buildRuntimeTurnSettledEnvironment: vi.fn(),
|
||||
drainRuntimeTurnSettledEvents: vi.fn(),
|
||||
getQueueDiagnostics: vi.fn(() => queueDiagnostics),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
registerTeamRoutes(app, {
|
||||
...mocks.services,
|
||||
memberWorkSyncFeature: memberWorkSyncFeature as any,
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
try {
|
||||
const diagnosticsResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/demo-team/member-work-sync/diagnostics',
|
||||
});
|
||||
expect(diagnosticsResponse.statusCode).toBe(200);
|
||||
expect(diagnosticsResponse.json()).toMatchObject({
|
||||
teamName: 'demo-team',
|
||||
queue: queueDiagnostics,
|
||||
metrics,
|
||||
});
|
||||
|
||||
const refreshResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/demo-team/member-work-sync/bob/refresh',
|
||||
});
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
expect(refreshResponse.json()).toMatchObject(refreshedStatus);
|
||||
expect(memberWorkSyncFeature.refreshStatus).toHaveBeenCalledWith({
|
||||
teamName: 'demo-team',
|
||||
memberName: 'bob',
|
||||
});
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/demo-team/member-work-sync/report',
|
||||
payload: {
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: [' task-a ', '', 'task-a'],
|
||||
},
|
||||
});
|
||||
expect(reportResponse.statusCode).toBe(200);
|
||||
expect(memberWorkSyncFeature.report).toHaveBeenCalledWith({
|
||||
teamName: 'demo-team',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-a'],
|
||||
source: 'mcp',
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
let previousCliPath: string | undefined;
|
||||
let previousCliFlavor: string | undefined;
|
||||
let previousControlUrl: string | undefined;
|
||||
let previousNudgeFlag: string | undefined;
|
||||
let previousDisableAppBootstrap: string | undefined;
|
||||
let previousDisableRuntimeBootstrap: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
|
|
@ -112,7 +111,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
|
||||
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousHome = process.env.HOME;
|
||||
|
|
@ -125,7 +123,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
|
||||
delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
|
||||
|
|
@ -148,7 +145,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl);
|
||||
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
|
||||
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
|
||||
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
||||
restoreEnv('HOME', previousHome);
|
||||
|
|
@ -200,7 +196,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
isTeamActive: (name) =>
|
||||
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
|
||||
listLifecycleActiveTeamNames: async () => [teamName!],
|
||||
nudgeSideEffectsEnabled: false,
|
||||
queueQuietWindowMs: 500,
|
||||
// Native Claude teammates are registered by the real lead process, but in this
|
||||
// headless harness their bootstrap turn can finish before there is a durable
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
FatalWaitError,
|
||||
formatMemberWorkSyncDiagnostics,
|
||||
formatProgressDump,
|
||||
readRuntimeTurnSettledProcessedMetas,
|
||||
restoreEnv,
|
||||
startMemberWorkSyncControlServer,
|
||||
type MemberWorkSyncLiveControlServer,
|
||||
|
|
@ -54,7 +55,6 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
let previousCliPath: string | undefined;
|
||||
let previousCliFlavor: string | undefined;
|
||||
let previousControlUrl: string | undefined;
|
||||
let previousNudgeFlag: string | undefined;
|
||||
let previousCodexHome: string | undefined;
|
||||
let codexHomeDir: string;
|
||||
let ownsCodexHomeDir: boolean;
|
||||
|
|
@ -71,6 +71,11 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
hasProvisioningRun(teamName: string): boolean;
|
||||
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void;
|
||||
setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | null): void;
|
||||
setRuntimeTurnSettledEnvironmentProvider(
|
||||
provider:
|
||||
| ((input: { provider: 'claude' | 'codex' | 'opencode' }) => Promise<Record<string, string> | null>)
|
||||
| null
|
||||
): void;
|
||||
relayInboxFileToLiveRecipient(teamName: string, inboxName: string): Promise<{ relayed: number }>;
|
||||
createTeam(
|
||||
request: Parameters<
|
||||
|
|
@ -94,7 +99,6 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
|
||||
previousCodexHome = process.env.CODEX_HOME;
|
||||
|
||||
const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey();
|
||||
|
|
@ -112,7 +116,6 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
|
||||
process.env.CODEX_HOME = codexHomeDir;
|
||||
|
||||
codexAccountFeature = null;
|
||||
|
|
@ -128,6 +131,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
svc?.setControlApiBaseUrlResolver(null);
|
||||
svc?.setRuntimeTurnSettledEnvironmentProvider(null);
|
||||
providerConnectionService?.setCodexAccountFeature(null);
|
||||
await feature?.dispose().catch(() => undefined);
|
||||
await codexAccountFeature?.dispose().catch(() => undefined);
|
||||
|
|
@ -136,7 +140,6 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl);
|
||||
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
|
||||
restoreEnv('CODEX_HOME', previousCodexHome);
|
||||
setClaudeBasePathOverride(null);
|
||||
if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') {
|
||||
|
|
@ -151,7 +154,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
});
|
||||
|
||||
it(
|
||||
'lets a real Codex teammate report still-working for the current actionable agenda without automatic nudges',
|
||||
'lets a real Codex teammate report still-working for the current actionable agenda with active nudge guards',
|
||||
async () => {
|
||||
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
|
||||
expect(orchestratorCli).toBeTruthy();
|
||||
|
|
@ -221,11 +224,13 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
isTeamActive: (name) =>
|
||||
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
|
||||
listLifecycleActiveTeamNames: async () => [teamName!],
|
||||
nudgeSideEffectsEnabled: false,
|
||||
});
|
||||
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
|
||||
feature!.noteTeamChange(event)
|
||||
);
|
||||
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
|
||||
feature!.buildRuntimeTurnSettledEnvironment(input)
|
||||
);
|
||||
controlServer = await startMemberWorkSyncControlServer(feature);
|
||||
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
|
||||
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
|
||||
|
|
@ -280,9 +285,9 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
`This is a live member-work-sync validation task. Marker: ${marker}.`,
|
||||
'Do not edit files and do not complete this task.',
|
||||
'Call task_start for this task.',
|
||||
`Add one task comment containing exactly: ${marker}:still-working.`,
|
||||
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
|
||||
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
|
||||
`Only after member_work_sync_report is accepted, add one task comment containing exactly: ${marker}:still-working.`,
|
||||
'After that stop. Do not send a user-visible message.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
|
@ -335,6 +340,17 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
state: 'still_working',
|
||||
});
|
||||
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
|
||||
await waitUntil(async () => {
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||
return metas.some(
|
||||
({ meta }) =>
|
||||
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
|
||||
'codex' &&
|
||||
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
|
||||
teamName
|
||||
);
|
||||
}, 60_000);
|
||||
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
|
|
@ -342,6 +358,418 @@ liveDescribe('Member work sync Codex live e2e', () => {
|
|||
},
|
||||
360_000
|
||||
);
|
||||
|
||||
it(
|
||||
'delivers a real work-sync nudge to a Codex teammate and accepts the follow-up report',
|
||||
async () => {
|
||||
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
|
||||
expect(orchestratorCli).toBeTruthy();
|
||||
await assertExecutable(orchestratorCli!);
|
||||
|
||||
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
|
||||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
|
||||
const marker = `member-work-sync-codex-nudge-${Date.now()}`;
|
||||
teamName = `member-work-sync-codex-nudge-${Date.now()}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Member work sync Codex nudge live e2e\n\nKeep this project intentionally tiny.\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const [
|
||||
{ TeamProvisioningService },
|
||||
{ TeamDataService },
|
||||
{ TeamConfigReader },
|
||||
{ TeamTaskReader },
|
||||
{ TeamKanbanManager },
|
||||
{ TeamMembersMetaStore },
|
||||
{ createCodexAccountFeature },
|
||||
{ ProviderConnectionService },
|
||||
] = await Promise.all([
|
||||
import('../../../../src/main/services/team/TeamProvisioningService'),
|
||||
import('../../../../src/main/services/team/TeamDataService'),
|
||||
import('../../../../src/main/services/team/TeamConfigReader'),
|
||||
import('../../../../src/main/services/team/TeamTaskReader'),
|
||||
import('../../../../src/main/services/team/TeamKanbanManager'),
|
||||
import('../../../../src/main/services/team/TeamMembersMetaStore'),
|
||||
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
|
||||
import('../../../../src/main/services/runtime/ProviderConnectionService'),
|
||||
]);
|
||||
|
||||
codexAccountFeature = createCodexAccountFeature({
|
||||
logger: {
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
configManager: {
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
codex: {
|
||||
preferredAuthMode: 'chatgpt' as const,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
providerConnectionService = ProviderConnectionService.getInstance();
|
||||
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
|
||||
|
||||
svc = new TeamProvisioningService();
|
||||
const activeService = svc;
|
||||
const teamDataService = new TeamDataService();
|
||||
feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
configReader: new TeamConfigReader(),
|
||||
taskReader: new TeamTaskReader(),
|
||||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: (name) =>
|
||||
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
|
||||
listLifecycleActiveTeamNames: async () => [teamName!],
|
||||
queueQuietWindowMs: 1,
|
||||
});
|
||||
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
|
||||
feature!.noteTeamChange(event)
|
||||
);
|
||||
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
|
||||
feature!.buildRuntimeTurnSettledEnvironment(input)
|
||||
);
|
||||
controlServer = await startMemberWorkSyncControlServer(feature);
|
||||
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
|
||||
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
|
||||
await fs.writeFile(
|
||||
path.join(tempClaudeRoot, 'team-control-api.json'),
|
||||
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
await activeService.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model,
|
||||
effort,
|
||||
fastMode: 'off',
|
||||
skipPermissions: true,
|
||||
prompt: [
|
||||
'Keep launch work minimal.',
|
||||
'If you receive a member_work_sync_nudge, do not complete the task.',
|
||||
'For a member_work_sync_nudge, call member_work_sync_status first, then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds for the current agenda.',
|
||||
'After reporting, stop without a user-visible message.',
|
||||
].join(' '),
|
||||
members: [],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const last = progressEvents.at(-1);
|
||||
if (last?.state === 'failed') {
|
||||
throw new Error(formatProgressDump(progressEvents));
|
||||
}
|
||||
return last?.state === 'ready';
|
||||
}, 240_000);
|
||||
|
||||
const config = await new TeamConfigReader().getConfig(teamName);
|
||||
const memberName =
|
||||
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
|
||||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
|
||||
config?.members?.[0]?.name?.trim() ||
|
||||
'team-lead';
|
||||
await seedShadowReadyMetrics({ teamName, memberName });
|
||||
|
||||
const task = await teamDataService.createTask(teamName, {
|
||||
subject: `Member work sync live nudge ${marker}`,
|
||||
owner: memberName,
|
||||
startImmediately: false,
|
||||
prompt: [
|
||||
`This is a live member-work-sync nudge validation task. Marker: ${marker}.`,
|
||||
'Do not edit files and do not complete this task.',
|
||||
'Only report still_working if member-work-sync asks you to synchronize.',
|
||||
].join('\n'),
|
||||
});
|
||||
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
|
||||
|
||||
await waitUntil(async () => {
|
||||
const status = await feature!.getStatus({ teamName: teamName!, memberName });
|
||||
if (!status.agenda.items.some((item) => item.taskId === task.id)) {
|
||||
return false;
|
||||
}
|
||||
const inbox = await readInboxMessages(teamName!, memberName);
|
||||
return inbox.some(
|
||||
(message) =>
|
||||
message.messageKind === 'member_work_sync_nudge' &&
|
||||
typeof message.messageId === 'string' &&
|
||||
message.text.includes('Work sync check')
|
||||
);
|
||||
}, 60_000, 500, async () =>
|
||||
formatMemberWorkSyncDiagnostics({
|
||||
feature: feature!,
|
||||
teamName: teamName!,
|
||||
memberName,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
|
||||
const inbox = await readInboxMessages(teamName, memberName);
|
||||
const nudge = inbox.find((message) => message.messageKind === 'member_work_sync_nudge');
|
||||
expect(nudge?.messageId).toBeTruthy();
|
||||
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
|
||||
expect(relay.relayed).toBeGreaterThan(0);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
|
||||
if (fatalRuntimeMessage) {
|
||||
throw new FatalWaitError(fatalRuntimeMessage);
|
||||
}
|
||||
await feature!.replayPendingReports([teamName!]);
|
||||
const status = await feature!.getStatus({ teamName: teamName!, memberName });
|
||||
return status.report?.accepted === true && status.report.state === 'still_working';
|
||||
}, 240_000, 2_000, async () =>
|
||||
formatMemberWorkSyncDiagnostics({
|
||||
feature: feature!,
|
||||
teamName: teamName!,
|
||||
memberName,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
|
||||
const finalStatus = await feature.getStatus({ teamName, memberName });
|
||||
expect(finalStatus.state).toBe('still_working');
|
||||
expect(finalStatus.report).toMatchObject({
|
||||
accepted: true,
|
||||
state: 'still_working',
|
||||
});
|
||||
await waitUntil(async () => {
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||
return metas.some(
|
||||
({ meta }) =>
|
||||
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
|
||||
'codex' &&
|
||||
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
|
||||
teamName
|
||||
);
|
||||
}, 60_000);
|
||||
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
});
|
||||
},
|
||||
420_000
|
||||
);
|
||||
|
||||
it(
|
||||
'lets a real Codex teammate complete the task and report caught-up after the board clears',
|
||||
async () => {
|
||||
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
|
||||
expect(orchestratorCli).toBeTruthy();
|
||||
await assertExecutable(orchestratorCli!);
|
||||
|
||||
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
|
||||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
|
||||
const marker = `member-work-sync-codex-complete-${Date.now()}`;
|
||||
teamName = `member-work-sync-codex-complete-${Date.now()}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Member work sync Codex complete live e2e\n\nKeep this project intentionally tiny.\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const [
|
||||
{ TeamProvisioningService },
|
||||
{ TeamDataService },
|
||||
{ TeamConfigReader },
|
||||
{ TeamTaskReader },
|
||||
{ TeamKanbanManager },
|
||||
{ TeamMembersMetaStore },
|
||||
{ createCodexAccountFeature },
|
||||
{ ProviderConnectionService },
|
||||
] = await Promise.all([
|
||||
import('../../../../src/main/services/team/TeamProvisioningService'),
|
||||
import('../../../../src/main/services/team/TeamDataService'),
|
||||
import('../../../../src/main/services/team/TeamConfigReader'),
|
||||
import('../../../../src/main/services/team/TeamTaskReader'),
|
||||
import('../../../../src/main/services/team/TeamKanbanManager'),
|
||||
import('../../../../src/main/services/team/TeamMembersMetaStore'),
|
||||
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
|
||||
import('../../../../src/main/services/runtime/ProviderConnectionService'),
|
||||
]);
|
||||
|
||||
codexAccountFeature = createCodexAccountFeature({
|
||||
logger: {
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
configManager: {
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
codex: {
|
||||
preferredAuthMode: hasLiveCodexApiKey() ? 'auto' : ('chatgpt' as const),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
providerConnectionService = ProviderConnectionService.getInstance();
|
||||
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
|
||||
|
||||
svc = new TeamProvisioningService();
|
||||
const activeService = svc;
|
||||
const teamDataService = new TeamDataService();
|
||||
const taskReader = new TeamTaskReader();
|
||||
feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
configReader: new TeamConfigReader(),
|
||||
taskReader,
|
||||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: (name) =>
|
||||
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
|
||||
listLifecycleActiveTeamNames: async () => [teamName!],
|
||||
queueQuietWindowMs: 1,
|
||||
});
|
||||
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
|
||||
feature!.noteTeamChange(event)
|
||||
);
|
||||
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
|
||||
feature!.buildRuntimeTurnSettledEnvironment(input)
|
||||
);
|
||||
controlServer = await startMemberWorkSyncControlServer(feature);
|
||||
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
|
||||
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
|
||||
await fs.writeFile(
|
||||
path.join(tempClaudeRoot, 'team-control-api.json'),
|
||||
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
await activeService.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model,
|
||||
effort,
|
||||
fastMode: 'off',
|
||||
skipPermissions: true,
|
||||
prompt: [
|
||||
'Keep launch work minimal.',
|
||||
'If you receive a task, follow task instructions exactly.',
|
||||
'Use member_work_sync_status and member_work_sync_report whenever the task asks you to synchronize work state.',
|
||||
].join(' '),
|
||||
members: [],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const last = progressEvents.at(-1);
|
||||
if (last?.state === 'failed') {
|
||||
throw new Error(formatProgressDump(progressEvents));
|
||||
}
|
||||
return last?.state === 'ready';
|
||||
}, 240_000);
|
||||
|
||||
const config = await new TeamConfigReader().getConfig(teamName);
|
||||
const memberName =
|
||||
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
|
||||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
|
||||
config?.members?.[0]?.name?.trim() ||
|
||||
'team-lead';
|
||||
await seedShadowReadyMetrics({ teamName, memberName });
|
||||
|
||||
const task = await teamDataService.createTask(teamName, {
|
||||
subject: `Member work sync live completion ${marker}`,
|
||||
owner: memberName,
|
||||
startImmediately: true,
|
||||
prompt: [
|
||||
`This is a live member-work-sync completion validation task. Marker: ${marker}.`,
|
||||
'Do not edit files.',
|
||||
'Call task_start for this task.',
|
||||
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
|
||||
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
|
||||
'After that, call task_complete for this task.',
|
||||
`Then call member_work_sync_status again with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
|
||||
'If the returned agenda has no items, call member_work_sync_report with state "caught_up", no taskIds, and the exact agendaFingerprint/reportToken returned by that second status call.',
|
||||
`Only after the caught_up report is accepted, add one task comment containing exactly: ${marker}:completed-and-caught-up.`,
|
||||
'After that stop. Do not send a user-visible message.',
|
||||
].join('\n'),
|
||||
});
|
||||
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
|
||||
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
|
||||
expect(relay.relayed).toBeGreaterThan(0);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
|
||||
if (fatalRuntimeMessage) {
|
||||
throw new FatalWaitError(fatalRuntimeMessage);
|
||||
}
|
||||
await feature!.replayPendingReports([teamName!]);
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const [tasks, status] = await Promise.all([
|
||||
taskReader.getTasks(teamName!),
|
||||
feature!.refreshStatus({ teamName: teamName!, memberName }),
|
||||
]);
|
||||
const currentTask = tasks.find((candidate) => candidate.id === task.id);
|
||||
const hasCompletionMarker = currentTask?.comments?.some((comment) =>
|
||||
comment.text.includes(`${marker}:completed-and-caught-up`)
|
||||
);
|
||||
return Boolean(
|
||||
currentTask?.status === 'completed' &&
|
||||
hasCompletionMarker &&
|
||||
status.state === 'caught_up' &&
|
||||
status.agenda.items.length === 0 &&
|
||||
status.report?.accepted === true &&
|
||||
status.report.state === 'caught_up'
|
||||
);
|
||||
}, 300_000, 2_000, async () =>
|
||||
formatMemberWorkSyncDiagnostics({
|
||||
feature: feature!,
|
||||
teamName: teamName!,
|
||||
memberName,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
|
||||
const [tasks, finalStatus, metrics] = await Promise.all([
|
||||
taskReader.getTasks(teamName),
|
||||
feature.getStatus({ teamName, memberName }),
|
||||
feature.getMetrics({ teamName }),
|
||||
]);
|
||||
const completedTask = tasks.find((candidate) => candidate.id === task.id);
|
||||
expect(completedTask?.status).toBe('completed');
|
||||
expect(finalStatus.state).toBe('caught_up');
|
||||
expect(finalStatus.agenda.items).toEqual([]);
|
||||
expect(finalStatus.report).toMatchObject({
|
||||
accepted: true,
|
||||
state: 'caught_up',
|
||||
});
|
||||
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
|
||||
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
});
|
||||
},
|
||||
480_000
|
||||
);
|
||||
});
|
||||
|
||||
async function readFatalRuntimeMessage(teamName: string): Promise<string | null> {
|
||||
|
|
@ -397,3 +825,86 @@ function resolveConnectedCodexHome(previousCodexHome: string | undefined): strin
|
|||
}
|
||||
return path.join(os.userInfo().homedir, '.codex');
|
||||
}
|
||||
|
||||
async function seedShadowReadyMetrics(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<void> {
|
||||
const metricsPath = path.join(
|
||||
getTeamsBasePath(),
|
||||
input.teamName,
|
||||
'.member-work-sync',
|
||||
'indexes',
|
||||
'metrics.json'
|
||||
);
|
||||
const startMs = Date.now() - 2 * 60 * 60_000;
|
||||
await fs.mkdir(path.dirname(metricsPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
metricsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
members: {
|
||||
[input.memberName]: {
|
||||
memberName: input.memberName,
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: 'agenda:v1:seed',
|
||||
actionableCount: 0,
|
||||
evaluatedAt: new Date(startMs).toISOString(),
|
||||
providerId: 'codex',
|
||||
},
|
||||
},
|
||||
recentEvents: Array.from({ length: 24 }, (_, index) => ({
|
||||
id: `seed-status-${index}`,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
kind: 'status_evaluated',
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: `agenda:v1:seed-${index}`,
|
||||
recordedAt: new Date(startMs + index * 6 * 60_000).toISOString(),
|
||||
actionableCount: 0,
|
||||
providerId: 'codex',
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function readInboxMessages(teamName: string, memberName: string): Promise<
|
||||
Array<{
|
||||
messageId?: string;
|
||||
messageKind?: string;
|
||||
text: string;
|
||||
read?: boolean;
|
||||
}>
|
||||
> {
|
||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`);
|
||||
const raw = await fs.readFile(inboxPath, 'utf8').catch(() => '[]');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.filter((message): message is Record<string, unknown> =>
|
||||
Boolean(message) && typeof message === 'object'
|
||||
)
|
||||
.flatMap((message) => {
|
||||
const text = typeof message.text === 'string' ? message.text : '';
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
...(typeof message.messageId === 'string' ? { messageId: message.messageId } : {}),
|
||||
...(typeof message.messageKind === 'string'
|
||||
? { messageKind: message.messageKind }
|
||||
: {}),
|
||||
text,
|
||||
...(typeof message.read === 'boolean' ? { read: message.read } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
|
|||
let projectPath: string;
|
||||
let previousCliPath: string | undefined;
|
||||
let previousCliFlavor: string | undefined;
|
||||
let previousNudgeFlag: string | undefined;
|
||||
let previousCodexHome: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
|
|
@ -78,7 +77,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
|
|||
|
||||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
|
||||
previousCodexHome = process.env.CODEX_HOME;
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
|
|
@ -89,7 +87,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
|
|||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
|
||||
process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome);
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
|
|
@ -118,7 +115,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
|
|||
|
||||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
|
||||
restoreEnv('CODEX_HOME', previousCodexHome);
|
||||
restoreEnv('HOME', previousHome);
|
||||
restoreEnv('USERPROFILE', previousUserProfile);
|
||||
|
|
|
|||
|
|
@ -133,15 +133,63 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
await expect(store.list()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('upgrades legacy pending records with message kind without changing payload identity', async () => {
|
||||
const store = createStore();
|
||||
const payloadHash = hashOpenCodePromptDeliveryPayload({
|
||||
text: 'Work sync check',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
source: 'watcher',
|
||||
});
|
||||
|
||||
const legacy = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-work-sync',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
taskRefs: [],
|
||||
payloadHash,
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
const envelope = JSON.parse(await fs.readFile(ledgerPath(), 'utf8')) as {
|
||||
data: Record<string, unknown>[];
|
||||
};
|
||||
delete envelope.data[0].messageKind;
|
||||
await fs.writeFile(ledgerPath(), `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
|
||||
|
||||
const upgraded = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-work-sync',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
taskRefs: [],
|
||||
payloadHash,
|
||||
now: '2026-04-25T10:00:30.000Z',
|
||||
});
|
||||
|
||||
expect(upgraded.id).toBe(legacy.id);
|
||||
expect(upgraded.messageKind).toBe('member_work_sync_nudge');
|
||||
expect(upgraded.payloadHash).toBe(payloadHash);
|
||||
expect(upgraded.attempts).toBe(0);
|
||||
await expect(store.list()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(corruptionCases)('rejects corrupted persisted records with %s', async (_name, mutate) => {
|
||||
const store = await writeCorruptedLedgerRecord(mutate);
|
||||
|
||||
await expect(store.list()).rejects.toMatchObject({
|
||||
reason: 'invalid_data',
|
||||
});
|
||||
await expect(fs.readdir(tempDir)).resolves.toContain(
|
||||
'opencode-prompt-delivery-ledger.json'
|
||||
);
|
||||
await expect(fs.readdir(tempDir)).resolves.toContain('opencode-prompt-delivery-ledger.json');
|
||||
expect((await fs.readdir(tempDir)).some((name) => name.includes('.invalid_data.'))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -211,12 +259,12 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
reason: 'visible_reply_ack_only_still_requires_answer',
|
||||
scheduledAt: '2026-04-25T10:00:02.000Z',
|
||||
});
|
||||
expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z'))
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('records empty assistant delivery results as unanswered and stores plain text previews', async () => {
|
||||
|
|
@ -353,11 +401,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
});
|
||||
expect(responded.status).toBe('responded');
|
||||
|
||||
await expect(store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
})).resolves.toMatchObject({
|
||||
await expect(
|
||||
store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
id: record.id,
|
||||
responseState: 'responded_plain_text',
|
||||
});
|
||||
|
|
@ -376,11 +426,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
});
|
||||
|
||||
await expect(store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
})).resolves.toBeNull();
|
||||
await expect(
|
||||
store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not keep responded live deliveries active when no inbox commit is needed', async () => {
|
||||
|
|
@ -419,11 +471,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
expect(responded.status).toBe('responded');
|
||||
expect(responded.inboxReadCommittedAt).toBeNull();
|
||||
|
||||
await expect(store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
})).resolves.toBeNull();
|
||||
await expect(
|
||||
store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
|
||||
const peer = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
|
|
@ -439,11 +493,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
now: '2026-04-25T10:01:00.000Z',
|
||||
});
|
||||
|
||||
await expect(store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
})).resolves.toMatchObject({
|
||||
await expect(
|
||||
store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
id: peer.id,
|
||||
inboxMessageId: 'peer-relay',
|
||||
});
|
||||
|
|
@ -585,21 +641,25 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(store.pruneTerminalRecords({
|
||||
now: new Date('2026-04-25T10:00:20.000Z'),
|
||||
respondedRetentionMs: 10_000,
|
||||
failedRetentionMs: 30_000,
|
||||
})).resolves.toEqual({ pruned: 1, remaining: 2 });
|
||||
await expect(
|
||||
store.pruneTerminalRecords({
|
||||
now: new Date('2026-04-25T10:00:20.000Z'),
|
||||
respondedRetentionMs: 10_000,
|
||||
failedRetentionMs: 30_000,
|
||||
})
|
||||
).resolves.toEqual({ pruned: 1, remaining: 2 });
|
||||
expect((await store.list()).map((record) => record.inboxMessageId).sort()).toEqual([
|
||||
active.inboxMessageId,
|
||||
failed.inboxMessageId,
|
||||
]);
|
||||
|
||||
await expect(store.pruneTerminalRecords({
|
||||
now: new Date('2026-04-25T10:00:40.000Z'),
|
||||
respondedRetentionMs: 10_000,
|
||||
failedRetentionMs: 30_000,
|
||||
})).resolves.toEqual({ pruned: 1, remaining: 1 });
|
||||
await expect(
|
||||
store.pruneTerminalRecords({
|
||||
now: new Date('2026-04-25T10:00:40.000Z'),
|
||||
respondedRetentionMs: 10_000,
|
||||
failedRetentionMs: 30_000,
|
||||
})
|
||||
).resolves.toEqual({ pruned: 1, remaining: 1 });
|
||||
expect((await store.list()).map((record) => record.inboxMessageId)).toEqual([
|
||||
active.inboxMessageId,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -541,6 +541,57 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(sentText).toContain('never use #00000000');
|
||||
});
|
||||
|
||||
it('sends member work sync nudges with report-oriented response instructions', async () => {
|
||||
const sendOpenCodeTeamMessage = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
||||
>(async () => ({
|
||||
accepted: true,
|
||||
sessionId: 'oc-session-bob',
|
||||
memberName: 'bob',
|
||||
diagnostics: [],
|
||||
}));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
sendOpenCodeTeamMessage,
|
||||
})
|
||||
);
|
||||
|
||||
await adapter.sendMessageToMember({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
text: 'Work sync check',
|
||||
messageId: 'msg-work-sync',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
||||
});
|
||||
|
||||
expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
actionMode: 'do',
|
||||
})
|
||||
);
|
||||
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
||||
expect(sentText).toContain('"messageKind":"member_work_sync_nudge"');
|
||||
expect(sentText).toContain('This delivered app message is a member-work-sync nudge.');
|
||||
expect(sentText).toContain('agent-teams_member_work_sync_status');
|
||||
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
||||
expect(sentText).toContain('mcp__agent-teams__member_work_sync_report');
|
||||
expect(sentText).toContain('teamName="team-a"');
|
||||
expect(sentText).toContain('memberName="bob"');
|
||||
expect(sentText).toContain('taskIds: "task-1"');
|
||||
expect(sentText).toContain(
|
||||
'Do not use provider names, runtime names, or team names as memberName'
|
||||
);
|
||||
expect(sentText).not.toContain('Include relayOfMessageId="msg-work-sync"');
|
||||
expect(sentText).not.toContain('You must not end this turn empty.');
|
||||
});
|
||||
|
||||
it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => {
|
||||
const sendOpenCodeTeamMessage = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
||||
|
|
@ -568,7 +619,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
|
||||
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
||||
expect(sentText).toContain('Use teamName="team-a", to="user", from="bob", text, and summary.');
|
||||
expect(sentText).not.toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.');
|
||||
expect(sentText).not.toContain(
|
||||
'Use teamName="team-a", to="alice", from="bob", text, and summary.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps missing bridge members pending while reconcile is still launching', async () => {
|
||||
|
|
|
|||
|
|
@ -10135,7 +10135,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(2);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: freshRun.runId,
|
||||
runId: latestOpenCodeLaunchRunId(
|
||||
adapter,
|
||||
cancelledTeamName,
|
||||
'secondary:opencode:bob'
|
||||
),
|
||||
teamName: cancelledTeamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
|
|
@ -10144,7 +10148,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
messageId: 'msg-fresh-mixed-opencode-after-cancelled-handoff',
|
||||
});
|
||||
expect(adapter.messageInputs[1]).toMatchObject({
|
||||
runId: survivingRun.runId,
|
||||
runId: latestOpenCodeLaunchRunId(
|
||||
adapter,
|
||||
survivingTeamName,
|
||||
'secondary:opencode:tom'
|
||||
),
|
||||
teamName: survivingTeamName,
|
||||
laneId: 'secondary:opencode:tom',
|
||||
memberName: 'tom',
|
||||
|
|
@ -10247,7 +10255,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: survivingRun.runId,
|
||||
runId: latestOpenCodeLaunchRunId(
|
||||
adapter,
|
||||
survivingTeamName,
|
||||
'secondary:opencode:bob'
|
||||
),
|
||||
teamName: survivingTeamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
|
|
@ -11818,7 +11830,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:tom'),
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:tom',
|
||||
memberName: 'tom',
|
||||
|
|
@ -11861,7 +11873,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:bob'),
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
|
|
@ -11932,7 +11944,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:tom'),
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:tom',
|
||||
memberName: 'tom',
|
||||
|
|
@ -12009,7 +12021,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:bob'),
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
|
|
@ -12099,7 +12111,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(adapter.messageInputs).toHaveLength(2);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: firstRun.runId,
|
||||
runId: latestOpenCodeLaunchRunId(
|
||||
adapter,
|
||||
firstTeamName,
|
||||
'secondary:opencode:bob'
|
||||
),
|
||||
teamName: firstTeamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
|
|
@ -12108,7 +12124,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
messageId: 'msg-cross-team-detach-first-bob',
|
||||
});
|
||||
expect(adapter.messageInputs[1]).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
runId: latestOpenCodeLaunchRunId(
|
||||
adapter,
|
||||
secondTeamName,
|
||||
'secondary:opencode:tom'
|
||||
),
|
||||
teamName: secondTeamName,
|
||||
laneId: 'secondary:opencode:tom',
|
||||
memberName: 'tom',
|
||||
|
|
@ -17065,6 +17085,18 @@ class BlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
function latestOpenCodeLaunchRunId(
|
||||
adapter: { readonly launchInputs: readonly TeamRuntimeLaunchInput[] },
|
||||
teamName: string,
|
||||
laneId: string
|
||||
): string {
|
||||
const launchInput = [...adapter.launchInputs]
|
||||
.reverse()
|
||||
.find((input) => input.teamName === teamName && input.laneId === laneId);
|
||||
expect(launchInput?.runId).toBeTruthy();
|
||||
return launchInput!.runId;
|
||||
}
|
||||
|
||||
class BlockingStopOpenCodeRuntimeAdapter extends BlockingOpenCodeRuntimeAdapter {
|
||||
private releaseStopGate: (() => void) | null = null;
|
||||
private readonly stopGate = new Promise<void>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -431,6 +431,61 @@ async function writeDefaultBobOpenCodeBootstrapEvidence(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
async function configureOpenCodeBobDeliveryService(input: {
|
||||
svc: TeamProvisioningService;
|
||||
sendMessageToMember: ReturnType<typeof vi.fn>;
|
||||
observeMessageDelivery?: ReturnType<typeof vi.fn>;
|
||||
}): Promise<void> {
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember: input.sendMessageToMember,
|
||||
observeMessageDelivery: input.observeMessageDelivery ?? vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
input.svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
(input.svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||
(input.svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||
(input.svc as any).setSecondaryRuntimeRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'opencode-run-bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(input.svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(input.svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(input.svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function createMemberSpawnStatusEntry(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
|
|
@ -583,7 +638,6 @@ describe('TeamProvisioningService', () => {
|
|||
await expect(svc.warmup()).resolves.not.toThrow();
|
||||
expect(spawnCli).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('team launch notifications', () => {
|
||||
|
|
@ -1643,7 +1697,8 @@ describe('TeamProvisioningService', () => {
|
|||
],
|
||||
})),
|
||||
};
|
||||
const processRows = createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
|
||||
const processRows =
|
||||
createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
|
||||
.mockReturnValueOnce(processRows.promise)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
|
@ -1720,7 +1775,8 @@ describe('TeamProvisioningService', () => {
|
|||
})),
|
||||
};
|
||||
(svc as any).provisioningRunByTeam.set('runtime-team', 'run-1');
|
||||
const processRows = createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
|
||||
const processRows =
|
||||
createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
|
||||
.mockReturnValueOnce(processRows.promise)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
|
@ -4885,10 +4941,7 @@ describe('TeamProvisioningService', () => {
|
|||
};
|
||||
|
||||
await expect(
|
||||
(svc as any).resolveOpenCodeMembersForRuntimeLane(
|
||||
'team-a',
|
||||
'secondary:opencode:bob'
|
||||
)
|
||||
(svc as any).resolveOpenCodeMembersForRuntimeLane('team-a', 'secondary:opencode:bob')
|
||||
).resolves.toEqual(['bob']);
|
||||
|
||||
expect(getConfigSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -5098,13 +5151,16 @@ describe('TeamProvisioningService', () => {
|
|||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: false,
|
||||
responseState: 'responded_plain_text',
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
});
|
||||
responsePending: false,
|
||||
responseState: 'responded_plain_text',
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
});
|
||||
|
||||
const userInbox = JSON.parse(
|
||||
await fsPromises.readFile(path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), 'utf8')
|
||||
await fsPromises.readFile(
|
||||
path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as Array<Record<string, unknown>>;
|
||||
expect(userInbox).toHaveLength(1);
|
||||
expect(userInbox[0]).toMatchObject({
|
||||
|
|
@ -6465,6 +6521,107 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('accepts member work sync report as OpenCode delivery response proof', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
prePromptCursor: 'cursor-before',
|
||||
responseObservation: {
|
||||
state: 'responded_non_visible_tool' as const,
|
||||
deliveredUserMessageId: 'oc-user-work-sync',
|
||||
assistantMessageId: 'oc-assistant-work-sync-report',
|
||||
toolCallNames: ['member_work_sync_status', 'member_work_sync_report'],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: null,
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Work sync check for #task-1.',
|
||||
messageId: 'msg-work-sync-report',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
taskRefs: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
displayId: 'task-1',
|
||||
teamName: 'team-a',
|
||||
},
|
||||
],
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: false,
|
||||
responseState: 'responded_non_visible_tool',
|
||||
ledgerStatus: 'responded',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps member work sync status-only OpenCode deliveries pending', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
prePromptCursor: 'cursor-before',
|
||||
responseObservation: {
|
||||
state: 'responded_non_visible_tool' as const,
|
||||
deliveredUserMessageId: 'oc-user-work-sync-status',
|
||||
assistantMessageId: 'oc-assistant-work-sync-status',
|
||||
toolCallNames: ['member_work_sync_status'],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: null,
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Work sync check for #task-1.',
|
||||
messageId: 'msg-work-sync-status-only',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
taskRefs: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
displayId: 'task-1',
|
||||
teamName: 'team-a',
|
||||
},
|
||||
],
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'responded_non_visible_tool',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
reason: 'non_visible_tool_without_task_progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const emptyResponseObservation = {
|
||||
|
|
@ -6955,123 +7112,123 @@ describe('TeamProvisioningService', () => {
|
|||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-durable',
|
||||
teamName,
|
||||
laneId,
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-durable',
|
||||
teamName,
|
||||
laneId,
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
text: 'hello after restart',
|
||||
messageId: 'msg-after-restart',
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
|
||||
(svc as any).aliveRunByTeam.set(teamName, 'primary-run');
|
||||
(svc as any).runs.set('primary-run', {
|
||||
runId: 'primary-run',
|
||||
teamName,
|
||||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
progress: { state: 'ready' },
|
||||
request: { providerId: 'codex', cwd: '/repo' },
|
||||
mixedSecondaryLanes: [
|
||||
{
|
||||
laneId,
|
||||
providerId: 'opencode',
|
||||
member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
runId: 'opencode-run-live',
|
||||
state: 'finished',
|
||||
result: {
|
||||
members: {
|
||||
bob: {
|
||||
bootstrapConfirmed: true,
|
||||
launchState: 'confirmed_alive',
|
||||
sessionId: 'oc-session-bob',
|
||||
(svc as any).aliveRunByTeam.set(teamName, 'primary-run');
|
||||
(svc as any).runs.set('primary-run', {
|
||||
runId: 'primary-run',
|
||||
teamName,
|
||||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
progress: { state: 'ready' },
|
||||
request: { providerId: 'codex', cwd: '/repo' },
|
||||
mixedSecondaryLanes: [
|
||||
{
|
||||
laneId,
|
||||
providerId: 'opencode',
|
||||
member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
runId: 'opencode-run-live',
|
||||
state: 'finished',
|
||||
result: {
|
||||
members: {
|
||||
bob: {
|
||||
bootstrapConfirmed: true,
|
||||
launchState: 'confirmed_alive',
|
||||
sessionId: 'oc-session-bob',
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
});
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName: 'bob',
|
||||
text: 'hello live lane',
|
||||
messageId: 'msg-live-lane',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
diagnostics: [],
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName: 'bob',
|
||||
text: 'hello live lane',
|
||||
messageId: 'msg-live-lane',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-live',
|
||||
teamName,
|
||||
laneId,
|
||||
memberName: 'bob',
|
||||
})
|
||||
);
|
||||
expect(sendMessageToMember).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: 'primary-run' })
|
||||
);
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-live',
|
||||
teamName,
|
||||
laneId,
|
||||
memberName: 'bob',
|
||||
})
|
||||
);
|
||||
expect(sendMessageToMember).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: 'primary-run' })
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
|
|
@ -7549,7 +7706,9 @@ describe('TeamProvisioningService', () => {
|
|||
activeRunId: launchInput?.runId,
|
||||
highWatermark: 0,
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
await expect(
|
||||
readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)
|
||||
).resolves.toMatchObject({
|
||||
lanes: {
|
||||
'secondary:opencode:bob': {
|
||||
state: 'degraded',
|
||||
|
|
@ -7652,7 +7811,9 @@ describe('TeamProvisioningService', () => {
|
|||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
await expect(
|
||||
readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)
|
||||
).resolves.toMatchObject({
|
||||
lanes: {
|
||||
'secondary:opencode:tom': {
|
||||
state: 'degraded',
|
||||
|
|
@ -7772,7 +7933,9 @@ describe('TeamProvisioningService', () => {
|
|||
).resolves.toMatchObject({
|
||||
activeRunId: launchInput?.runId,
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
await expect(
|
||||
readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)
|
||||
).resolves.toMatchObject({
|
||||
lanes: {
|
||||
'secondary:opencode:tom': {
|
||||
state: 'active',
|
||||
|
|
@ -13502,7 +13665,9 @@ describe('TeamProvisioningService', () => {
|
|||
teamName,
|
||||
laneId,
|
||||
state: 'active',
|
||||
diagnostics: ['OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'],
|
||||
diagnostics: [
|
||||
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
|
||||
],
|
||||
});
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
|
|
@ -15434,11 +15599,11 @@ describe('TeamProvisioningService', () => {
|
|||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
});
|
||||
await expect(fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')).rejects.toMatchObject(
|
||||
{
|
||||
code: 'ENOENT',
|
||||
}
|
||||
);
|
||||
await expect(
|
||||
fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await expect(
|
||||
fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8')
|
||||
).rejects.toMatchObject({
|
||||
|
|
@ -17005,7 +17170,9 @@ describe('TeamProvisioningService', () => {
|
|||
launchState: 'failed_to_start',
|
||||
hardFailureReason: 'Tom provider launch failed.',
|
||||
});
|
||||
const summary = JSON.parse(await fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8'));
|
||||
const summary = JSON.parse(
|
||||
await fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8')
|
||||
);
|
||||
expect(summary).toMatchObject({
|
||||
teamLaunchState: 'partial_failure',
|
||||
confirmedCount: 2,
|
||||
|
|
@ -17097,7 +17264,9 @@ describe('TeamProvisioningService', () => {
|
|||
launchState: 'failed_to_start',
|
||||
hardFailureReason: exactOpenCodeReason,
|
||||
});
|
||||
const persisted = JSON.parse(await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8'));
|
||||
const persisted = JSON.parse(
|
||||
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||
);
|
||||
expect(persisted.members.alice).toMatchObject({
|
||||
laneId: 'secondary:opencode:alice',
|
||||
launchState: 'failed_to_start',
|
||||
|
|
|
|||
|
|
@ -306,6 +306,48 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('treats member work sync nudges as actionable in lead relay prompt', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
service.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'system',
|
||||
text: 'Work sync check: you have current actionable work assigned.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Work sync check',
|
||||
messageId: 'm-work-sync-1',
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
taskRefs: [{ teamName, taskId: 'task-1', displayId: '11111111' }],
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Message kind: member_work_sync_nudge');
|
||||
expect(payload).toContain('it is actionable work-sync control traffic');
|
||||
expect(payload).toContain(
|
||||
'Call member_work_sync_status with teamName=\\"my-team\\", memberName=\\"team-lead\\", controlUrl=\\"http://127.0.0.1:43123\\"'
|
||||
);
|
||||
expect(payload).toContain('call member_work_sync_report');
|
||||
expect(payload).toContain('controlUrl=\\"http://127.0.0.1:43123\\"');
|
||||
expect(payload).toContain('taskIds from the nudge task refs');
|
||||
expect(payload).toContain(
|
||||
'Do not use provider names, runtime names, or team names as memberName'
|
||||
);
|
||||
expect(payload).toContain('Do NOT ignore it as a pure system notification');
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('uses snapshot config reads for lead inbox relay routing', async () => {
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for inbox relay routing');
|
||||
|
|
@ -2269,9 +2311,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
|
||||
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
|
||||
expect(recipientSpy).toHaveBeenCalledTimes(1);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,21 @@ export async function startMemberWorkSyncControlServer(
|
|||
sendJson(response, 200, payload);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
parts.length === 6 &&
|
||||
parts[0] === 'api' &&
|
||||
parts[1] === 'teams' &&
|
||||
parts[3] === 'member-work-sync' &&
|
||||
parts[5] === 'refresh'
|
||||
) {
|
||||
const payload = await feature.refreshStatus({
|
||||
teamName: parts[2],
|
||||
memberName: parts[4],
|
||||
});
|
||||
sendJson(response, 200, payload);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
parts.length === 5 &&
|
||||
|
|
@ -152,9 +167,7 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
|||
input.feature.getMetrics({ teamName: input.teamName }),
|
||||
input.taskId ? new TeamTaskReader().getTasks(input.teamName) : Promise.resolve([]),
|
||||
]);
|
||||
const task = input.taskId
|
||||
? tasks.find((candidate) => candidate.id === input.taskId)
|
||||
: undefined;
|
||||
const task = input.taskId ? tasks.find((candidate) => candidate.id === input.taskId) : undefined;
|
||||
return [
|
||||
'Member work sync live diagnostics:',
|
||||
JSON.stringify(
|
||||
|
|
@ -240,12 +253,7 @@ export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string
|
|||
meta: Record<string, unknown>;
|
||||
}>
|
||||
> {
|
||||
const processedDir = path.join(
|
||||
teamsBasePath,
|
||||
'.member-work-sync',
|
||||
'runtime-hooks',
|
||||
'processed'
|
||||
);
|
||||
const processedDir = path.join(teamsBasePath, '.member-work-sync', 'runtime-hooks', 'processed');
|
||||
const entries = await fs.readdir(processedDir, { withFileTypes: true }).catch(() => []);
|
||||
const metas = await Promise.all(
|
||||
entries
|
||||
|
|
|
|||
|
|
@ -29,12 +29,16 @@ describe('HttpAPIClient memberWorkSync', () => {
|
|||
const client = new HttpAPIClient('http://127.0.0.1:53123');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ state: 'needs_sync' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ state: 'still_working' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ memberCount: 1 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ accepted: true }));
|
||||
|
||||
await expect(
|
||||
client.memberWorkSync.getStatus({ teamName: 'demo team', memberName: 'bob/qa' })
|
||||
).resolves.toEqual({ state: 'needs_sync' });
|
||||
await expect(
|
||||
client.memberWorkSync.refreshStatus({ teamName: 'demo team', memberName: 'bob/qa' })
|
||||
).resolves.toEqual({ state: 'still_working' });
|
||||
await expect(client.memberWorkSync.getMetrics({ teamName: 'demo team' })).resolves.toEqual({
|
||||
memberCount: 1,
|
||||
});
|
||||
|
|
@ -57,11 +61,21 @@ describe('HttpAPIClient memberWorkSync', () => {
|
|||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/bob%2Fqa/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/metrics',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
4,
|
||||
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/report',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -95,11 +95,13 @@ describe('ClaudeLogsPanel', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('2 lines');
|
||||
expect(host.textContent).toContain('2 raw lines');
|
||||
expect(host.textContent).toContain('first line');
|
||||
expect(host.textContent).not.toContain('Team is not running.');
|
||||
expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull();
|
||||
expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe('[stdout]\nfirst line\nsecond line');
|
||||
expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe(
|
||||
'[stdout]\nfirst line\nsecond line'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ describe('parseStreamJsonToGroups', () => {
|
|||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]?.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'output', content: 'Codex native thread started: thread-1.' }),
|
||||
expect.objectContaining({
|
||||
type: 'output',
|
||||
content: 'Codex native thread started: thread-1.',
|
||||
}),
|
||||
expect.objectContaining({ type: 'output', content: 'Codex turn started.' }),
|
||||
expect.objectContaining({ type: 'output', content: 'Lead response ready.' }),
|
||||
expect.objectContaining({
|
||||
|
|
@ -53,6 +56,46 @@ describe('parseStreamJsonToGroups', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders Codex native command execution and file change events from live JSONL logs', () => {
|
||||
const groups = parseStreamJsonToGroups(
|
||||
[
|
||||
'{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pwd","status":"in_progress"}}',
|
||||
'{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pwd","aggregated_output":"/repo\\n","exit_code":0,"status":"completed"}}',
|
||||
'{"type":"item.completed","item":{"id":"item_2","type":"file_change","changes":[{"path":"/repo/src/a.ts","kind":"update"}],"status":"completed"}}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const tools = groups.flatMap((group) => group.items).filter((item) => item.type === 'tool');
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0]).toMatchObject({
|
||||
type: 'tool',
|
||||
tool: {
|
||||
id: 'item_1',
|
||||
name: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
isOrphaned: false,
|
||||
result: {
|
||||
content: '/repo\n',
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools[1]).toMatchObject({
|
||||
type: 'tool',
|
||||
tool: {
|
||||
id: 'item_2',
|
||||
name: 'Edit',
|
||||
input: { file_path: '/repo/src/a.ts' },
|
||||
isOrphaned: false,
|
||||
result: {
|
||||
content: 'File changes:\n- /repo/src/a.ts (update)',
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders projected Codex native system status rows from persisted logs', () => {
|
||||
const groups = parseStreamJsonToGroups(
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in a new issue