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:
parent
3a8179d980
commit
d2bb734af6
4 changed files with 88 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 lead→user 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 ?? []);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue