Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions default/skills/self-scheduling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 17 additions & 7 deletions docs/conversation-provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
29 changes: 17 additions & 12 deletions docs/workspace-issues-and-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/core/workspace-tool-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down
7 changes: 5 additions & 2 deletions src/tool/issue-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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') {
Expand Down
29 changes: 29 additions & 0 deletions src/webui/routes/issues.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/webui/routes/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions src/workspaces/conversation-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
5 changes: 3 additions & 2 deletions src/workspaces/conversation-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export function createWorkspaceConversationControl(
resolution,
subject: input.subject,
})
const selection = continuingOrigin ? undefined : input.selection
const dispatched = inquiry
? await svc.dispatchHeadlessTask(
meta,
Expand All @@ -357,7 +358,7 @@ export function createWorkspaceConversationControl(
undefined,
continuingOrigin?.resumeId,
inquiry,
undefined,
selection,
conversation,
createdBy,
)
Expand All @@ -369,7 +370,7 @@ export function createWorkspaceConversationControl(
undefined,
continuingOrigin?.resumeId,
undefined,
undefined,
selection,
conversation,
createdBy,
)
Expand Down
90 changes: 90 additions & 0 deletions src/workspaces/issues/claim-session.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading