tasks: key derived captures by external id and record who resolved

A task extracted from mail deduped on the live-norm index only, so once he
finished it the row left the live set and the next poll of the same immutable
message re-extracted it as a fresh candidate. mavmaild is a read-only reader
and marks nothing read, so that repeats forever. Derived rows now carry an
ext_id built from the message uid and the extracted span, unique across every
status, while voice keeps live-only norm dedupe because saying an errand again
is the recurrence signal. A derived source can no longer capture straight to
open, and saying a task out loud that Maven had only proposed promotes the
candidate instead of answering that it is already in the list.

SetTaskStatus was classified AuthRead. Resolving a task is not additive, it
erases work off his list, so it is a write, and the row now records the caller
that moved it. ListTasks was unbounded. The list-query matcher claimed any
utterance with "что мне делать", including "с чем мне помочь", and the urgency
stripper matched inside words.

Found in review of #60.
This commit is contained in:
kami
2026-08-01 14:16:39 +04:00
parent 7f42cc73be
commit 708a69375f
14 changed files with 571 additions and 132 deletions
+38 -21
View File
@@ -101,15 +101,17 @@ type WriteFactReq struct {
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
// task came from, empty for anything he stated himself.
type Task struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"`
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
}
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
@@ -118,18 +120,26 @@ type Task struct {
//
// An extractor that reads mail sets Source "email:<account>", Status
// "candidate", and Evidence to whatever makes the task reviewable (the subject
// line). It must NOT set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page. Capture
// is idempotent on normalised text among live tasks, so re-reading the same
// mailbox is free.
// line). It cannot set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page, and the
// store refuses an open capture from a derived source rather than trusting the
// caller to have read this paragraph.
//
// ExternalID is what makes re-reading free for such a source, and it is
// REQUIRED of one. Text dedupe only covers live rows, because a voice capture
// of the same errand next week is a new task. A mailbox has no such signal: it
// hands back the same immutable message forever, so a task he already finished
// would come back as a fresh candidate on the next poll. ExternalID is unique
// over every row whatever its status: message id plus the extracted span.
type CaptureTaskReq struct {
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
}
// CaptureTaskResp — Created is false when the same live task already existed,
@@ -138,6 +148,10 @@ type CaptureTaskReq struct {
type CaptureTaskResp struct {
ID int64 `json:"id"`
Created bool `json:"created"`
// Promoted — this capture turned an existing candidate into open work. He
// stated out loud something Maven had only proposed, which is a
// confirmation, and the caller says so rather than "уже в списке".
Promoted bool `json:"promoted,omitempty"`
}
// IngestMailReq — one message a mail reader has fetched, handed to core for
@@ -400,6 +414,9 @@ type setTaskStatusReq struct {
ID int64 `json:"id"`
Status string `json:"status"`
Ts time.Time `json:"ts"`
// By — the caller making the move, in the source vocabulary. Recorded on
// the row so a resolved task says what resolved it.
By string `json:"by,omitempty"`
}
// idReq — methods keyed by a single id.
@@ -626,7 +643,7 @@ type CoreAPI interface {
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
+2 -2
View File
@@ -450,8 +450,8 @@ func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
return r.Tasks, nil
}
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
}
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
+24 -21
View File
@@ -242,19 +242,20 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
}
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
id, created, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
}
return CaptureTaskResp{ID: id, Created: created}, nil
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
}
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
@@ -265,22 +266,24 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
@@ -849,7 +852,7 @@ var methodTable = map[Method]handlerFunc{
return listTasksResp{Tasks: out}, nil
}),
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
}),
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
out, err := api.ListProposedRoutines(ctx)
+1 -1
View File
@@ -95,7 +95,7 @@ func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq)
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {