feat: enhance TeamDataService and TeamProvisioningService for improved task management and user DM forwarding

- Updated TeamDataService to ensure consistent kanban task status updates with rollback capabilities on failure.
- Introduced silentUserDmForward functionality in TeamProvisioningService to manage internal DM forwarding to teammates without user echo.
- Enhanced task status update logic in TeamTaskWriter to prevent redundant updates.
- Improved ClaudeLogsSection to support search query overrides for better log filtering in the UI.
This commit is contained in:
iliya 2026-03-05 18:05:30 +02:00
parent 3a8179d980
commit d2bb734af6
4 changed files with 88 additions and 2 deletions

View file

@ -1367,7 +1367,34 @@ export class TeamDataService {
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
if (patch.op !== 'request_changes') {
// Keep kanban + task.status consistent:
// - moving a task into kanban review/approved implies the work is complete
// - request_changes already moves it back to in_progress and clears kanban entry
if (patch.op !== 'set_column') {
await this.kanbanManager.updateTask(teamName, taskId, patch);
return;
}
const previousState = await this.kanbanManager.getState(teamName);
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId];
await this.kanbanManager.updateTask(teamName, taskId, patch);
try {
await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user');
} catch (error) {
// Best-effort rollback of kanban move if task status update failed.
if (previousKanbanEntry) {
await this.kanbanManager
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
.catch(() => undefined);
} else {
await this.kanbanManager
.updateTask(teamName, taskId, { op: 'remove' })
.catch(() => undefined);
}
throw error;
}
return;
}

View file

@ -156,6 +156,11 @@ interface ProvisioningRun {
* Flushed to liveLeadProcessMessages on result.success.
*/
directReplyParts: string[];
/**
* When set, the current stdin-injected turn is an internal "forward user DM to teammate"
* request triggered by the UI. We suppress any leaduser echo for that turn.
*/
silentUserDmForward: { target: string; startedAt: string } | null;
/** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[];
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
@ -1678,6 +1683,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config',
leadRelayCapture: null,
directReplyParts: [],
silentUserDmForward: null,
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
@ -1967,6 +1973,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members',
leadRelayCapture: null,
directReplyParts: [],
silentUserDmForward: null,
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
@ -2208,6 +2215,52 @@ export class TeamProvisioningService {
this.setLeadActivity(run, 'active');
}
/**
* Best-effort: forward a user-written DM to a teammate via the live lead process.
* This covers cases where teammates don't automatically respond to inbox JSON,
* and only react to Claude Code internal SendMessage routing.
*
* Note: We suppress the lead's textual output for this injected turn to avoid
* confusing lead responses like "No action needed."
*/
async forwardUserDmToTeammate(
teamName: string,
teammateName: string,
userText: string,
userSummary?: string
): Promise<void> {
const runId = this.activeByTeam.get(teamName);
if (!runId) {
throw new Error(`No active process for team "${teamName}"`);
}
const run = this.runs.get(runId);
if (!run?.child?.stdin?.writable) {
throw new Error(`Team "${teamName}" process stdin is not writable`);
}
if (!run.provisioningComplete) {
// Don't inject extra turns during provisioning/bootstrap.
return;
}
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
const message = [
`INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`,
`Action: forward it to that teammate using the SendMessage tool.`,
`IMPORTANT: Do NOT reply to the human user for this turn.`,
`In the forwarded message, ask the teammate to reply to recipient "user" with a short answer.`,
``,
`User message:`,
...(summaryLine ? [summaryLine] : []),
userText,
]
.filter(Boolean)
.join('\n');
await this.sendMessageToTeam(teamName, message);
}
/**
* Relay unread inbox messages addressed to the team lead into the live lead process.
*
@ -2731,7 +2784,9 @@ export class TeamProvisioningService {
}
} else if (run.provisioningComplete) {
// Accumulate assistant text for direct user→lead messages (no relay capture).
run.directReplyParts.push(text);
if (!run.silentUserDmForward) {
run.directReplyParts.push(text);
}
}
}
@ -2740,7 +2795,7 @@ export class TeamProvisioningService {
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
if (run.provisioningComplete) {
if (run.provisioningComplete && !run.silentUserDmForward) {
this.captureSendMessageToUser(run, content ?? []);
}

View file

@ -319,6 +319,9 @@ export class TeamTaskWriter {
const task = JSON.parse(raw) as TeamTask;
const prevStatus = task.status;
if (prevStatus === status) {
return;
}
const nowIso = new Date().toISOString();
// Maintain workIntervals as periods of time where status === 'in_progress'.

View file

@ -398,6 +398,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
// Parser expects chronological order; UI shows newest-first.
cliLogsTail={filteredText}
order="newest-first"
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
className="max-h-[320px] p-2"
containerRefCallback={(el) => {
logContainerRef.current = el;