diff --git a/default/skills/self-scheduling/SKILL.md b/default/skills/self-scheduling/SKILL.md index 242691b88..44f4457e5 100644 --- a/default/skills/self-scheduling/SKILL.md +++ b/default/skills/self-scheduling/SKILL.md @@ -156,9 +156,10 @@ plain tracked item; add a `when` and it starts firing. - **`assignee`** *(optional)* — the single owner and scheduled-dispatch policy: - `@new-each-run` recruits a new product Session for each scheduled fire; - - `@new-then-resume` asks the Workspace to recruit once; the first successful dispatch - rewrites the Issue to that concrete `@resumeId`, so every later fire returns - to the same accountable coworker; + - `@new-then-resume` asks the Workspace to recruit once; the first successful + scheduled fire or human comment reply rewrites the Issue to that concrete + `@resumeId`, so every later fire and comment returns to the same + accountable coworker; - an exact `@resumeId` continues that accountable Session, even when its signed Workspace differs from the Issue's Workspace; - `@human` and `@unassigned` are valid only for unscheduled work. diff --git a/docs/conversation-provenance.md b/docs/conversation-provenance.md index e44187512..818b57fc4 100644 --- a/docs/conversation-provenance.md +++ b/docs/conversation-provenance.md @@ -394,12 +394,18 @@ live timeline. The Telegram phone desk already ships sealed mid-turn `text` blocks from that progress; tool and error blocks stay local to the workstation surfaces. -For a human comment without a fixed owner, OpenAlice follows the Issue creation -provenance and uses the universal follow-up rule: continue the attributable -creator, or recruit a reconstructed Agent in the Issue Workspace when creation -has no Session origin. This answering Session is a collaborator, not an -execution owner; `assignee` stays unchanged. Agent-authored comments without a -fixed owner remain durable notes so progress logging does not fan out workers. +`@new-then-resume` is not an unowned Issue. A human comment on that pending +owner policy recruits a fresh Session in the Issue Workspace and claims it, +the same way the first scheduled fire does. Creator or prior-reconstruction +provenance must not keep a previous Session after that rebind. + +For a human comment on `@new-each-run`, `@unassigned`, or `@human`, OpenAlice +follows the Issue creation provenance and uses the universal follow-up rule: +continue the attributable creator, or recruit a reconstructed Agent in the +Issue Workspace when creation has no Session origin. This answering Session is +a collaborator, not an execution owner; `assignee` stays unchanged. +Agent-authored comments without a fixed owner remain durable notes so progress +logging does not fan out workers. #### Mode A: one responsible Session @@ -428,9 +434,13 @@ existing resumable Workspace Session. assignee: "@new-then-resume" ``` -- The first scheduled fire creates a new headless product Session. +- The first scheduled fire **or** human comment reply creates a new headless + product Session. - OpenAlice immediately rewrites `@new-then-resume` to that Session's exact `@resumeId`. - Later fires and Issue comments continue the same accountable coworker. +- Rebinding an existing Issue back to `@new-then-resume` is a new hire: the next + fire or comment recruits again. Creator or prior-reconstruction provenance + must not keep the previous Session. - The Issue may specify `agent` before the first claim; after the claim, the concrete Session owns its runtime. diff --git a/docs/workspace-issues-and-scheduling.md b/docs/workspace-issues-and-scheduling.md index a8309f666..82600acf1 100644 --- a/docs/workspace-issues-and-scheduling.md +++ b/docs/workspace-issues-and-scheduling.md @@ -172,13 +172,18 @@ structured run log remain durable. Inbox inquiries expose the same shape on the Issue Activity and Inbox reply threads render that same field as a compact live timeline: semantic text, tool name/status, and errors. They do not fetch `/output` or show tool payloads. The Telegram phone desk -already projects sealed `text` blocks from that same field. A human comment without a fixed owner -uses the same provenance-aware fallback as Inbox: OpenAlice asks the +already projects sealed `text` blocks from that same field. Current `assignee` +is the only comment-dispatch contract. An exact `@resumeId` continues that +Session. `@new-then-resume` is a pending owner claim: the next human comment or +scheduled fire recruits a fresh Session and rewrites `assignee` to that +`@resumeId`. Creator or prior-reconstruction provenance must not win after an +operator rebinds to this policy. A human comment on `@new-each-run`, +`@unassigned`, or `@human` still uses the Inbox fallback: OpenAlice asks the attributable creator, or recruits a reconstruction Agent in the Issue -Workspace when no creator Session exists. The answer is recorded in Activity -without changing `assignee`; a temporary answerer never becomes the scheduling -owner. Agent-authored comments without a fixed owner remain timeline notes, and -an owner commenting on their own Issue is not echoed back to the same Session. +Workspace when no creator Session exists. That answering Session is a +collaborator; `assignee` stays unchanged. Agent-authored comments without a +fixed owner remain timeline notes, and an owner commenting on their own Issue +is not echoed back to the same Session. `done` and `canceled` are terminal and stop scheduled firing. There is no separate `enabled` flag. A successful one-shot `at` issue is automatically @@ -444,12 +449,12 @@ manufacture shell sleep loops. Add `--reconstruct` only when an unattributed artifact explicitly needs reconstruction guidance. The Issue detail UI treats scheduling as an intrinsic Work item capability. -`assignee: "@new-then-resume"` recruits one fresh Session on the first fire and then -rewrites itself to that concrete `@resumeId`; `assignee: "@new-each-run"` -recruits a new Session on every fire; `assignee: "@resumeId"` keeps one already -known responsible Session. The first and third modes produce a stable owner to -ask; `@new-each-run` execution exposes the creator and each concrete run as -separate follow-up targets. +`assignee: "@new-then-resume"` recruits one fresh Session on the first fire or +human comment reply and then rewrites itself to that concrete `@resumeId`; +`assignee: "@new-each-run"` recruits a new Session on every fire; `assignee: +"@resumeId"` keeps one already known responsible Session. The first and third +modes produce a stable owner to ask; `@new-each-run` execution exposes the +creator and each concrete run as separate follow-up targets. Issue mutation has two complementary histories. Activity records attributable field-level changes (and marks the canonical What document as edited without diff --git a/src/core/workspace-tool-center.ts b/src/core/workspace-tool-center.ts index 8e87530e7..8a840591e 100644 --- a/src/core/workspace-tool-center.ts +++ b/src/core/workspace-tool-center.ts @@ -36,6 +36,7 @@ import type { IssuesSnapshot, IssueDetail, WikilinkIssueRef } from '../workspace import type { WorkspaceSessionDirectory } from '../workspaces/session-directory.js' import type { HeadlessStructuredOutput } from '../workspaces/headless-output.js' import type { HeadlessInquirySubject, HeadlessTaskStatus } from '../workspaces/headless-task-registry.js' +import type { SessionRuntimeSelection } from '../workspaces/session-runtime-binding.js' import type { ApplyTemplateUpgradeInput, TemplateUpgradePlan, @@ -148,6 +149,8 @@ export interface WorkspaceConversationControl { * required. Provenance may still resolve as reconstructed when this is * false; prompt semantics and attribution are deliberately independent. */ readonly reconstruct?: boolean + /** Fresh-Session credential/model/effort freeze. Ignored when continuing. */ + readonly selection?: SessionRuntimeSelection /** Authoritative caller identity for the independent conversation log. */ readonly source?: WorkspaceConversationCaller /** Optional business reverse link persisted with the dispatched task. */ diff --git a/src/tool/issue-tools.ts b/src/tool/issue-tools.ts index bfdac2ddb..322801c0b 100644 --- a/src/tool/issue-tools.ts +++ b/src/tool/issue-tools.ts @@ -410,8 +410,9 @@ export const issueCommentFactory: WorkspaceToolFactory = { 'signed by the current product Session when available. It never mutates', 'the canonical What or changes the next scheduled prompt. If the Issue', 'has a different fixed @resumeId owner, OpenAlice asks that Session in', - 'the background and records its final reply in Activity. Human comments', - 'without a fixed owner ask the creator or a reconstructed Workspace Agent.', + 'the background and records its final reply in Activity. `@new-then-resume`', + 'recruits a fresh Session and claims it as owner. Other Issues without a', + 'fixed owner ask the creator or a reconstructed Workspace Agent.', 'Agent-authored comments without a fixed owner remain durable notes.', ].join('\n'), inputSchema: z.object({ @@ -432,6 +433,8 @@ export const issueCommentFactory: WorkspaceToolFactory = { comment: res.comment, ...(origin ? { authorResumeId: origin.resumeId } : {}), source: origin ?? { kind: 'workspace', workspaceId: ctx.workspaceId }, + issueWorkspaceDir: dir.dir, + ...(ctx.provenanceStore ? { provenanceStore: ctx.provenanceStore } : {}), }) await projectDeskComment(res.issue, res.comment).catch(() => undefined) if (dispatched.status !== 'not_requested') { diff --git a/src/webui/routes/issues.spec.ts b/src/webui/routes/issues.spec.ts index 5f5961507..3681334c8 100644 --- a/src/webui/routes/issues.spec.ts +++ b/src/webui/routes/issues.spec.ts @@ -374,6 +374,35 @@ describe('POST /api/issues/:wsId/:id/comments', () => { })) }) + it('recruits and claims a Session when assignee is @new-then-resume', async () => { + await createIssue(wsDir, { + id: 'i1', + title: 'T', + assignee: '@new-then-resume', + when: { kind: 'every', every: '4h' }, + }) + const { app, ask } = build() + const r = await req(app, 'POST', '/ws-1/i1/comments', { text: 'hello from the phone' }) + expect(r.status).toBe(200) + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ + target: { kind: 'workspace', workspaceId: 'ws-1' }, + source: { kind: 'human' }, + subject: expect.objectContaining({ + kind: 'issue', + issueId: 'i1', + relation: 'owner', + commentId: expect.any(String), + }), + })) + expect(ask).toHaveBeenCalledWith(expect.not.objectContaining({ reconstruct: true })) + expect(r.body.issue.assignee).toBe('@resume-kind-owl-abc123') + expect(r.body.comments[0].delivery).toEqual({ + state: 'pending', + targetResumeId: 'resume-kind-owl-abc123', + taskId: 'run-comment-reply', + }) + }) + it('asks the creator or reconstructs for a human comment without a fixed owner', async () => { await createIssue(wsDir, { id: 'i1', title: 'T' }) const { app, ask } = build() diff --git a/src/webui/routes/issues.ts b/src/webui/routes/issues.ts index 8bba01afb..4909f091c 100644 --- a/src/webui/routes/issues.ts +++ b/src/webui/routes/issues.ts @@ -412,6 +412,8 @@ export function createIssuesRoutes(svc: WorkspaceService, deps: IssueRoutesDeps issue: res.issue, comment: res.comment, source: { kind: 'human' }, + issueWorkspaceDir: meta.dir, + provenanceStore: svc.provenanceStore, }) if (dispatched.status !== 'not_requested') { const updated = await updateIssueCommentDelivery(meta.dir, id, res.comment.id, dispatched.delivery) diff --git a/src/workspaces/conversation-control.spec.ts b/src/workspaces/conversation-control.spec.ts index 3a6c89369..dc850e025 100644 --- a/src/workspaces/conversation-control.spec.ts +++ b/src/workspaces/conversation-control.spec.ts @@ -258,6 +258,28 @@ describe('Workspace conversation control', () => { })) }) + it('forwards a fresh-Session runtime selection when recruiting', async () => { + const { svc, dispatchHeadlessTask } = fakeService() + await createWorkspaceConversationControl(svc).ask({ + target: { kind: 'workspace', workspaceId: 'ws-peer' }, + prompt: 'Hello from a new owner.', + timeoutMs: 300_000, + selection: { credentialSlug: 'openai-primary', model: 'gpt-5.6-sol', reasoningEffort: 'high' }, + }) + expect(dispatchHeadlessTask).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'Hello from a new owner.', + 300_000, + undefined, + undefined, + undefined, + { credentialSlug: 'openai-primary', model: 'gpt-5.6-sol', reasoningEffort: 'high' }, + expect.anything(), + expect.anything(), + ) + }) + it('creates a fresh Session only in the initialized default Auto Prediction Workspace', async () => { const { svc, workspace, dispatchHeadlessTask } = fakeService({ workspaceTemplate: 'auto-prediction' }) await expect(createWorkspaceConversationControl(svc, { diff --git a/src/workspaces/conversation-control.ts b/src/workspaces/conversation-control.ts index 9fd615687..d751f9229 100644 --- a/src/workspaces/conversation-control.ts +++ b/src/workspaces/conversation-control.ts @@ -348,6 +348,7 @@ export function createWorkspaceConversationControl( resolution, subject: input.subject, }) + const selection = continuingOrigin ? undefined : input.selection const dispatched = inquiry ? await svc.dispatchHeadlessTask( meta, @@ -357,7 +358,7 @@ export function createWorkspaceConversationControl( undefined, continuingOrigin?.resumeId, inquiry, - undefined, + selection, conversation, createdBy, ) @@ -369,7 +370,7 @@ export function createWorkspaceConversationControl( undefined, continuingOrigin?.resumeId, undefined, - undefined, + selection, conversation, createdBy, ) diff --git a/src/workspaces/issues/claim-session.spec.ts b/src/workspaces/issues/claim-session.spec.ts new file mode 100644 index 000000000..48ad91008 --- /dev/null +++ b/src/workspaces/issues/claim-session.spec.ts @@ -0,0 +1,90 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { claimIssueFirstSession, issueRuntimeSelection } from './claim-session.js' +import { readWorkspaceIssues } from './declaration.js' +import { createIssue, updateIssueFields } from './mutate.js' + +let dir: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'issue-claim-session-')) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +describe('issueRuntimeSelection', () => { + it('omits an empty tuple', () => { + expect(issueRuntimeSelection({})).toBeUndefined() + }) + + it('freezes credential, model, and effort', () => { + expect(issueRuntimeSelection({ + credential: 'openai-primary', + model: 'gpt-5.6-sol', + effort: 'high', + })).toEqual({ + credentialSlug: 'openai-primary', + model: 'gpt-5.6-sol', + reasoningEffort: 'high', + }) + }) +}) + +describe('claimIssueFirstSession', () => { + it('rewrites @new-then-resume to the dispatched Session', async () => { + await createIssue(dir, { + id: 'desk', + title: 'Desk', + assignee: '@new-then-resume', + when: { kind: 'every', every: '4h' }, + }) + const append = vi.fn(async (input) => ({ id: 'p-1', ...input })) + expect(await claimIssueFirstSession({ + issueWorkspace: { id: 'ws-home', dir }, + issueId: 'desk', + taskId: 'run-1', + resumeId: 'resume-new-owner', + agent: 'pi', + provenanceStore: { append, list: vi.fn(), latest: vi.fn() }, + })).toBe('claimed') + const live = await readWorkspaceIssues(dir) + expect(live.ok && live.issues[0]?.assignee).toBe('@resume-new-owner') + expect(append).toHaveBeenCalledWith(expect.objectContaining({ + action: 'updated', + origin: expect.objectContaining({ resumeId: 'resume-new-owner', agent: 'pi' }), + mutation: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'assignee', before: '@new-then-resume', after: '@resume-new-owner' }), + ]), + }), + }), expect.anything()) + }) + + it('skips when the assignee is no longer a pending first-Session claim', async () => { + await createIssue(dir, { + id: 'desk', + title: 'Desk', + assignee: '@new-then-resume', + when: { kind: 'every', every: '4h' }, + }) + await updateIssueFields(dir, 'desk', { assignee: '@resume-already-owned' }) + const append = vi.fn(async (input) => ({ id: 'p-1', ...input })) + expect(await claimIssueFirstSession({ + issueWorkspace: { id: 'ws-home', dir }, + issueId: 'desk', + taskId: 'run-late', + resumeId: 'resume-new-owner', + agent: 'pi', + provenanceStore: { append, list: vi.fn(), latest: vi.fn() }, + })).toBe('skipped') + const live = await readWorkspaceIssues(dir) + expect(live.ok && live.issues[0]?.assignee).toBe('@resume-already-owned') + expect(append).not.toHaveBeenCalled() + }) +}) diff --git a/src/workspaces/issues/claim-session.ts b/src/workspaces/issues/claim-session.ts new file mode 100644 index 000000000..fea48943f --- /dev/null +++ b/src/workspaces/issues/claim-session.ts @@ -0,0 +1,98 @@ +/** + * First-Session claim for `@new-then-resume`. + * + * The first successful dispatch — scheduled fire or human comment reply — + * rewrites the Issue to that Session's exact `@resumeId`. Later fires and + * comments continue the same owner. Creator provenance must not win over this + * pending-owner policy. + */ +import type { ArtifactOrigin, IProvenanceStore } from '../../core/provenance-store.js' +import { ACTIVITY_UPDATE_COALESCE_MS } from '../../core/provenance-store.js' +import { logger as launcherLogger } from '../logger.js' +import type { SessionRuntimeSelection } from '../session-runtime-binding.js' +import { sessionSignature } from '../session-signature.js' +import { issueMutation, issueMutationFingerprint } from './change-tracker.js' +import { + issueAssigneeClaimsFirstSession, + readWorkspaceIssues, + type IssueRecord, +} from './declaration.js' +import { updateIssueFields } from './mutate.js' + +/** Freeze Issue credential/model/effort into a fresh Session birth. */ +export function issueRuntimeSelection( + issue: Pick, +): SessionRuntimeSelection | undefined { + if (!issue.credential && !issue.credentialSource && !issue.model && !issue.effort) return undefined + return { + ...(issue.credentialSource === 'native' ? { credentialSource: 'native' as const } : {}), + ...(issue.credential ? { credentialSlug: issue.credential } : {}), + ...(issue.model ? { model: issue.model } : {}), + ...(issue.effort ? { reasoningEffort: issue.effort } : {}), + } +} + +export async function claimIssueFirstSession(input: { + issueWorkspace: { id: string; dir: string } + issueId: string + taskId: string + resumeId: string + agent: string + provenanceStore: IProvenanceStore + observeIssues?: ( + workspace: { id: string; dir: string }, + issues: readonly IssueRecord[], + origin: ArtifactOrigin, + ) => Promise +}): Promise<'claimed' | 'skipped'> { + const live = await readWorkspaceIssues(input.issueWorkspace.dir) + const candidate = live.ok ? live.issues.find((issue) => issue.id === input.issueId) : undefined + if (!candidate || !issueAssigneeClaimsFirstSession(candidate.assignee)) { + launcherLogger.info('issue.first_session_claim_skipped', { + wsId: input.issueWorkspace.id, + issueId: input.issueId, + taskId: input.taskId, + resumeId: input.resumeId, + reason: candidate ? 'assignee_changed' : 'issue_unavailable', + }) + return 'skipped' + } + const claimed = await updateIssueFields(input.issueWorkspace.dir, input.issueId, { + assignee: sessionSignature(input.resumeId), + }) + if (!claimed.ok) { + throw new Error( + claimed.reason === 'invalid' + ? claimed.error + : `Issue disappeared before its first Session could claim it: ${input.issueId}`, + ) + } + const mutation = issueMutation(claimed.previous, claimed.issue) + const origin: ArtifactOrigin = { + kind: 'session', + workspaceId: input.issueWorkspace.id, + resumeId: input.resumeId, + agent: input.agent, + execution: { kind: 'headless', taskId: input.taskId }, + } + await input.provenanceStore.append({ + artifact: { kind: 'issue', workspaceId: input.issueWorkspace.id, issueId: input.issueId }, + action: 'updated', + origin, + at: Date.now(), + ...(mutation ? { mutation } : {}), + fingerprint: issueMutationFingerprint(input.issueWorkspace.id, input.issueId, claimed.issue), + }, { coalesceWithinMs: ACTIVITY_UPDATE_COALESCE_MS }) + if (input.observeIssues) { + const reread = await readWorkspaceIssues(input.issueWorkspace.dir) + if (reread.ok) await input.observeIssues(input.issueWorkspace, reread.issues, origin) + } + launcherLogger.info('issue.first_session_claimed', { + wsId: input.issueWorkspace.id, + issueId: input.issueId, + taskId: input.taskId, + resumeId: input.resumeId, + agent: input.agent, + }) + return 'claimed' +} diff --git a/src/workspaces/issues/comment-delivery.spec.ts b/src/workspaces/issues/comment-delivery.spec.ts index 0fdd0cba2..42a18c27f 100644 --- a/src/workspaces/issues/comment-delivery.spec.ts +++ b/src/workspaces/issues/comment-delivery.spec.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceConversationControl } from '../../core/workspace-tool-center.js' import { dispatchIssueCommentReply, issueCommentReplyPrompt, recordIssueCommentReply } from './comment-delivery.js' import { appendIssueComment, readIssueComments, type IssueComment } from './comments.js' -import type { IssueRecord } from './declaration.js' +import { readWorkspaceIssues, type IssueRecord } from './declaration.js' import { createIssue } from './mutate.js' let dir: string @@ -78,6 +78,63 @@ describe('dispatchIssueCommentReply', () => { })).toEqual({ status: 'not_requested', reason: 'non_human_note' }) }) + it('recruits and claims a first Session when assignee is @new-then-resume', async () => { + await createIssue(dir, { + id: 'audit', + title: 'Audit', + assignee: '@new-then-resume', + agent: 'pi', + when: { kind: 'every', every: '4h' }, + }) + const live = await readWorkspaceIssues(dir) + const pending = live.ok ? live.issues[0] : issue('@new-then-resume') + const append = vi.fn(async (input) => ({ id: 'p-1', ...input })) + const control = conversation({ + status: 'dispatched', + taskId: 'run-new-owner', + resumeId: 'resume-new-owner', + workspaceId: 'ws-home', + workspace: 'home-desk', + agent: 'pi', + resolution: { + mode: 'reconstructed', + workspaceId: 'ws-home', + reason: 'explicit-workspace', + }, + }) + expect(await dispatchIssueCommentReply({ + conversation: control, + issueWorkspaceId: 'ws-home', + issueWorkspaceDir: dir, + issue: pending ?? issue('@new-then-resume'), + comment, + source: { kind: 'human' }, + provenanceStore: { append, list: vi.fn(), latest: vi.fn() }, + })).toEqual({ + status: 'scheduled', + delivery: { + state: 'pending', + targetResumeId: 'resume-new-owner', + taskId: 'run-new-owner', + }, + }) + expect(control.ask).toHaveBeenCalledWith(expect.objectContaining({ + target: { kind: 'workspace', workspaceId: 'ws-home' }, + agent: 'pi', + source: { kind: 'human' }, + subject: { + kind: 'issue', + workspaceId: 'ws-home', + issueId: 'audit', + relation: 'owner', + commentId: 'comment-1', + }, + })) + expect(control.ask).toHaveBeenCalledWith(expect.not.objectContaining({ reconstruct: true })) + const after = await readWorkspaceIssues(dir) + expect(after.ok && after.issues[0]?.assignee).toBe('@resume-new-owner') + }) + it('asks the creator or reconstructs for a human comment without a fixed owner', async () => { const control = conversation({ status: 'dispatched', diff --git a/src/workspaces/issues/comment-delivery.ts b/src/workspaces/issues/comment-delivery.ts index 55f66b5b6..a428d5ff6 100644 --- a/src/workspaces/issues/comment-delivery.ts +++ b/src/workspaces/issues/comment-delivery.ts @@ -2,9 +2,11 @@ import type { WorkspaceConversationCaller, WorkspaceConversationControl, } from '../../core/workspace-tool-center.js' -import type { IProvenanceStore } from '../../core/provenance-store.js' +import type { ArtifactOrigin, IProvenanceStore } from '../../core/provenance-store.js' import type { HeadlessTaskRecord, HeadlessTaskStatus } from '../headless-task-registry.js' +import { logger as launcherLogger } from '../logger.js' import { sessionSignature } from '../session-signature.js' +import { claimIssueFirstSession, issueRuntimeSelection } from './claim-session.js' import { appendIssueComment, updateIssueCommentDelivery, @@ -12,7 +14,11 @@ import { type IssueCommentDelivery, } from './comments.js' import { renderIssueCommentPrompt } from './comment-prompt.js' -import { issueAssigneeResumeId, type IssueRecord } from './declaration.js' +import { + issueAssigneeClaimsFirstSession, + issueAssigneeResumeId, + type IssueRecord, +} from './declaration.js' import { projectDeskComment, projectWorkspaceDeskFailure, @@ -41,12 +47,15 @@ export function issueCommentReplyPrompt(input: { } /** - * A fixed Issue owner is a real colleague: comments from somebody else are - * delivered to that exact product Session. Human comments on Issues without a - * fixed owner use the same provenance-aware fallback as Inbox: continue the - * creator when attributable, otherwise recruit a reconstruction worker in the - * Issue Workspace. That answering Session is a collaborator, not a new owner; - * the Issue assignee and scheduling contract stay unchanged. + * Current `assignee` is the only comment-dispatch contract. + * + * - Exact `@resumeId`: continue that Session. + * - `@new-then-resume`: recruit a fresh Session in the Issue Workspace and + * claim it as owner. Creator / prior-reconstruction provenance must not win + * after an operator rebinds to this pending-owner policy. + * - `@new-each-run`, `@unassigned`, `@human`: human comments use the Inbox + * fallback (creator, else reconstruct). That answering Session is a + * collaborator; assignee stays unchanged. * * Agent-authored comments on Issues without a fixed owner remain notes. This * avoids turning progress logging into an unsolicited worker fan-out. @@ -58,8 +67,16 @@ export async function dispatchIssueCommentReply(input: { comment: IssueComment authorResumeId?: string source?: WorkspaceConversationCaller + issueWorkspaceDir?: string + provenanceStore?: IProvenanceStore + observeIssues?: ( + workspace: { id: string; dir: string }, + issues: readonly IssueRecord[], + origin: ArtifactOrigin, + ) => Promise }): Promise { const targetResumeId = issueAssigneeResumeId(input.issue.assignee) + const claimsFirstSession = issueAssigneeClaimsFirstSession(input.issue.assignee) if (targetResumeId === input.authorResumeId) { return { status: 'not_requested', reason: 'owner_commented' } } @@ -78,25 +95,30 @@ export async function dispatchIssueCommentReply(input: { } try { + const selection = claimsFirstSession ? issueRuntimeSelection(input.issue) : undefined const target = targetResumeId ? { kind: 'resume' as const, resumeId: targetResumeId } - : { - kind: 'issue' as const, - workspaceId: input.issueWorkspaceId, - issueId: input.issue.id, - action: 'created' as const, - } + : claimsFirstSession + ? { kind: 'workspace' as const, workspaceId: input.issueWorkspaceId } + : { + kind: 'issue' as const, + workspaceId: input.issueWorkspaceId, + issueId: input.issue.id, + action: 'created' as const, + } const result = await input.conversation.ask({ prompt: issueCommentReplyPrompt(input), target, timeoutMs: COMMENT_REPLY_TIMEOUT_MS, - ...(!targetResumeId ? { reconstruct: true } : {}), + ...(!targetResumeId && !claimsFirstSession ? { reconstruct: true } : {}), + ...(claimsFirstSession && input.issue.agent ? { agent: input.issue.agent } : {}), + ...(selection ? { selection } : {}), ...(input.source ? { source: input.source } : {}), subject: { kind: 'issue', workspaceId: input.issueWorkspaceId, issueId: input.issue.id, - relation: targetResumeId ? 'owner' : 'creator', + relation: targetResumeId || claimsFirstSession ? 'owner' : 'creator', commentId: input.comment.id, }, }) @@ -112,6 +134,27 @@ export async function dispatchIssueCommentReply(input: { }, } } + if (claimsFirstSession && input.issueWorkspaceDir && input.provenanceStore) { + try { + await claimIssueFirstSession({ + issueWorkspace: { id: input.issueWorkspaceId, dir: input.issueWorkspaceDir }, + issueId: input.issue.id, + taskId: result.taskId, + resumeId: result.resumeId, + agent: result.agent, + provenanceStore: input.provenanceStore, + ...(input.observeIssues ? { observeIssues: input.observeIssues } : {}), + }) + } catch (err) { + launcherLogger.warn('issue.comment_first_session_claim_failed', { + wsId: input.issueWorkspaceId, + issueId: input.issue.id, + taskId: result.taskId, + resumeId: result.resumeId, + err, + }) + } + } return { status: 'scheduled', delivery: { diff --git a/src/workspaces/issues/declaration.ts b/src/workspaces/issues/declaration.ts index 97224fa22..4429da03e 100644 --- a/src/workspaces/issues/declaration.ts +++ b/src/workspaces/issues/declaration.ts @@ -147,7 +147,7 @@ export function issueAssigneeResumeId(assignee: string): string | null { return resumeIdFromSignature(assignee) } -/** Transitional ownership: the first dispatch claims one durable Session. */ +/** Transitional ownership: the first fire or human comment claims one durable Session. */ export function issueAssigneeClaimsFirstSession(assignee: string): boolean { return assignee === NEW_THEN_RESUME_ASSIGNEE } diff --git a/src/workspaces/issues/telegram-desk-chat.spec.ts b/src/workspaces/issues/telegram-desk-chat.spec.ts index e42c9ce72..8201f497b 100644 --- a/src/workspaces/issues/telegram-desk-chat.spec.ts +++ b/src/workspaces/issues/telegram-desk-chat.spec.ts @@ -2,9 +2,10 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectorClient } from '@traderalice/connector-protocol' +import { readWorkspaceIssues } from './declaration.js' import { createTelegramConnectorDesk } from './telegram-connector.js' import { containsTelegramNoReply, @@ -96,6 +97,47 @@ describe('telegram desk ingest and stamp', () => { expect(result).toEqual({ ok: false, reason: 'desk_disabled' }) }) + it('recruits and claims a first Session for an inbound DM on @new-then-resume', async () => { + const created = await createTelegramConnectorDesk( + { id: 'ws-a', dir: wsDir }, + [{ id: 'ws-a', dir: wsDir }], + ) + expect(created.ok).toBe(true) + const ask = vi.fn(async () => ({ + status: 'dispatched' as const, + taskId: 'run-desk-owner', + resumeId: 'resume-desk-owner', + workspaceId: 'ws-a', + workspace: 'ws-a', + agent: 'pi', + resolution: { + mode: 'reconstructed' as const, + workspaceId: 'ws-a', + reason: 'explicit-workspace' as const, + }, + })) + const append = vi.fn(async (input: unknown) => ({ id: 'p-1', ...(input as object) })) + const result = await ingestTelegramOwnerMessage(host({ + provenanceStore: () => ({ + append, + list: () => [], + latest: () => undefined, + } as unknown as NonNullable>), + conversation: () => ({ ask, read: vi.fn() } as unknown as NonNullable>), + }), { + connectorId: 'telegram', + userId: '42', + text: 'Switch to a new coworker.', + }) + expect(result.ok).toBe(true) + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ + target: { kind: 'workspace', workspaceId: 'ws-a' }, + subject: expect.objectContaining({ relation: 'owner' }), + })) + const live = await readWorkspaceIssues(wsDir) + expect(live.ok && live.issues[0]?.assignee).toBe('@resume-desk-owner') + }) + it('records an inbound owner DM as a human comment that does not echo', async () => { const created = await createTelegramConnectorDesk( { id: 'ws-a', dir: wsDir }, diff --git a/src/workspaces/issues/telegram-desk-chat.ts b/src/workspaces/issues/telegram-desk-chat.ts index 12785a641..72c9a670c 100644 --- a/src/workspaces/issues/telegram-desk-chat.ts +++ b/src/workspaces/issues/telegram-desk-chat.ts @@ -138,12 +138,15 @@ export async function ingestConnectorOwnerMessages( }) const conversation = host.conversation() + const provenanceStore = host.provenanceStore() const dispatched = await dispatchIssueCommentReply({ conversation, issueWorkspaceId: desk.wsId, issue: appended.issue, comment: appended.comment, source: { kind: 'human' }, + issueWorkspaceDir: workspace.dir, + ...(provenanceStore ? { provenanceStore } : {}), }) if (dispatched.status !== 'not_requested') { await updateIssueCommentDelivery(workspace.dir, desk.issue.id, appended.comment.id, dispatched.delivery) diff --git a/src/workspaces/schedule/scanner.ts b/src/workspaces/schedule/scanner.ts index 0064e98d5..58557227a 100644 --- a/src/workspaces/schedule/scanner.ts +++ b/src/workspaces/schedule/scanner.ts @@ -43,6 +43,7 @@ import { readWorkspaceIssues, type IssueRecord, } from '../issues/declaration.js' +import { issueRuntimeSelection } from '../issues/claim-session.js' import { extraConnectorDeskKeys, findConnectorDesks, @@ -205,7 +206,7 @@ export class ScheduleScanner { issue.id, issueFirePrompt(issue), issue.agent, - issueRunOverrides(issue), + issueRuntimeSelection(issue), issueAssigneeResumeId(issue.assignee) ?? undefined, issueAssigneeClaimsFirstSession(issue.assignee), issueTimeoutMs(issue.timeout), @@ -305,7 +306,7 @@ export class ScheduleScanner { when, issueFirePrompt(issue), issue.agent, - issueRunOverrides(issue), + issueRuntimeSelection(issue), issueAssigneeResumeId(issue.assignee) ?? undefined, issueAssigneeClaimsFirstSession(issue.assignee), issueTimeoutMs(issue.timeout), @@ -541,12 +542,3 @@ export class ScheduleScanner { } } -function issueRunOverrides(issue: IssueRecord): SessionRuntimeSelection | undefined { - if (!issue.credential && !issue.credentialSource && !issue.model && !issue.effort) return undefined - return { - ...(issue.credentialSource === 'native' ? { credentialSource: 'native' as const } : {}), - ...(issue.credential ? { credentialSlug: issue.credential } : {}), - ...(issue.model ? { model: issue.model } : {}), - ...(issue.effort ? { reasoningEffort: issue.effort } : {}), - } -} diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 360b18b68..b9eab50c0 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -16,7 +16,6 @@ import { basename, delimiter, join } from 'node:path'; import { cliBinPath } from '@/core/paths.js'; import { readIssueDefaultAgent, readWorkspaceDefaultAgent } from '@/core/config.js'; import { - ACTIVITY_UPDATE_COALESCE_MS, ArtifactProvenanceStore, type ArtifactOrigin, } from '@/core/provenance-store.js'; @@ -104,6 +103,7 @@ import { type WorkspaceSessionDirectory, } from './session-directory.js'; import { completeOneShotIssueAfterRun } from './issues/auto-complete.js'; +import { claimIssueFirstSession } from './issues/claim-session.js'; import { readIssueComments, updateIssueCommentProgress } from './issues/comments.js'; import { recordIssueCommentReply } from './issues/comment-delivery.js'; import { @@ -115,18 +115,12 @@ import { deskProgressScope, projectWorkspaceDeskTurnProgress, } from './issues/telegram-desk-project.js'; -import { - IssueChangeTracker, - issueMutation, - issueMutationFingerprint, -} from './issues/change-tracker.js'; -import { updateIssueFields } from './issues/mutate.js'; +import { IssueChangeTracker } from './issues/change-tracker.js'; import { issueAutomationHealth, type IssueAutomationOwnerState, } from './issues/automation-health.js'; import { - issueAssigneeClaimsFirstSession, issueAssigneeResumeId, isConnectorDeskIssue, type IssueRecord, @@ -2194,52 +2188,15 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions }, dispatch: dispatchHeadlessTaskMethod, claimFreshSession: async ({ issueWorkspace, issueId, taskId, resumeId, agent }) => { - const live = await readWorkspaceIssues(issueWorkspace.dir); - const candidate = live.ok ? live.issues.find((issue) => issue.id === issueId) : undefined; - if (!candidate || !issueAssigneeClaimsFirstSession(candidate.assignee)) { - launcherLogger.info('issue.first_session_claim_skipped', { - wsId: issueWorkspace.id, - issueId, - taskId, - resumeId, - reason: candidate ? 'assignee_changed' : 'issue_unavailable', - }); - return; - } - const claimed = await updateIssueFields(issueWorkspace.dir, issueId, { - assignee: sessionSignature(resumeId), - }); - if (!claimed.ok) { - throw new Error( - claimed.reason === 'invalid' - ? claimed.error - : `Issue disappeared before its first Session could claim it: ${issueId}`, - ); - } - const mutation = issueMutation(claimed.previous, claimed.issue); - const origin: ArtifactOrigin = { - kind: 'session', - workspaceId: issueWorkspace.id, - resumeId, - agent, - execution: { kind: 'headless', taskId }, - }; - await provenanceStore.append({ - artifact: { kind: 'issue', workspaceId: issueWorkspace.id, issueId }, - action: 'updated', - origin, - at: Date.now(), - ...(mutation ? { mutation } : {}), - fingerprint: issueMutationFingerprint(issueWorkspace.id, issueId, claimed.issue), - }, { coalesceWithinMs: ACTIVITY_UPDATE_COALESCE_MS }); - const reread = await readWorkspaceIssues(issueWorkspace.dir); - if (reread.ok) await observeIssueRecords(issueWorkspace, reread.issues, origin); - launcherLogger.info('issue.first_session_claimed', { - wsId: issueWorkspace.id, + await claimIssueFirstSession({ + issueWorkspace, issueId, taskId, resumeId, agent, + provenanceStore, + observeIssues: (workspace, issues, origin) => + observeIssueRecords(workspace as WorkspaceMeta, issues, origin), }); }, observeIssues: (workspace, issues) => observeIssueRecords(workspace, issues), diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 94f5917a7..0b6ad9c77 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -294,7 +294,7 @@ export const en = { askAboutIssue: 'Ask about this Issue…', assignedSessionPrefix: 'The assigned Session', assignedSessionSuffix: 'will reply here.', - replyBeforeFirstRun: 'Until the first run assigns an owner, the creator or a reconstructed Workspace Agent will reply here.', + replyBeforeFirstRun: 'The next comment or scheduled run will create a Session and keep it as owner.', replyWithoutOwner: 'The creator or a reconstructed Workspace Agent will reply here; ownership stays unchanged.', sending: 'Sending…', commentNotify: 'Comment & notify', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 5a63d495c..48a661f13 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -283,7 +283,7 @@ export const ja: Resources = { askAboutIssue: 'この課題について質問…', assignedSessionPrefix: '担当セッション', assignedSessionSuffix: 'がここで返信します。', - replyBeforeFirstRun: '初回実行で担当が決まるまでは、作成者またはコンテキストを再構築したワークスペースエージェントがここで返信します。', + replyBeforeFirstRun: '次のコメントまたは次回の実行で Session を作成し、以後その Session を担当に固定します。', replyWithoutOwner: '作成者またはコンテキストを再構築したワークスペースエージェントがここで返信します。担当者は変わりません。', sending: '送信中…', commentNotify: 'コメントして通知', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index 3e50d2973..f9e5ce3d6 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -290,7 +290,7 @@ export const zhHant: Resources = { askAboutIssue: '詢問這個議題…', assignedSessionPrefix: '負責的工作階段', assignedSessionSuffix: '會在這裡回覆。', - replyBeforeFirstRun: '首次執行確定負責人前,建立者或負責重建脈絡的工作區 Agent 會在這裡回覆。', + replyBeforeFirstRun: '下一則評論或下一次執行會建立一個 Session,並把它固定為負責人。', replyWithoutOwner: '建立者或負責重建脈絡的工作區 Agent 會在這裡回覆;負責人維持不變。', sending: '正在傳送…', commentNotify: '留言並通知', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index 10057c0fc..b4c53541a 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -282,7 +282,7 @@ export const zh: Resources = { askAboutIssue: '询问这个议题…', assignedSessionPrefix: '负责人会话', assignedSessionSuffix: '会在这里回复。', - replyBeforeFirstRun: '首次运行确定负责人前,创建者或负责重建上下文的工作区 Agent 会在这里回复。', + replyBeforeFirstRun: '下一条评论或下一次运行会新建一个 Session,并把它固定为负责人。', replyWithoutOwner: '创建者或负责重建上下文的工作区 Agent 会在这里回复;负责人保持不变。', sending: '正在发送…', commentNotify: '评论并通知',